@hyzyn/dsh-docker 0.9.6 → 0.11.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.
@@ -1451,43 +1451,51 @@ await test('聚合选择:入口 / 勾选框 / 操作条装配进 bundle', () =
1451
1451
  assert.ok(decoded.includes('已选 '), '缺少「已选 N 个容器」计数')
1452
1452
  assert.ok(decoded.includes('聚合日志 · '), '缺少聚合视图标题')
1453
1453
  assert.ok(decoded.includes('至少选择 2 个容器'), '缺少 N<2 的提示')
1454
- assert.ok(decoded.includes('连接数较多,浏览器并发长连接有限制'), '缺少软上限提示')
1455
- // 硬上限文案由「数字 + 后缀」拼出来(数字不写死),且 SSH 与本地后缀不同,
1456
- // 所以这里分开锁静态部分;具体数字由下面 pickDecide 的用例断言。
1454
+ assert.ok(decoded.includes('已接近上限,再选下去整个页面会变卡'), '缺少软上限提示')
1455
+ // 硬上限文案由「数字 + 后缀」拼出来(数字不写死),所以这里只锁静态部分;
1456
+ // 具体数字由下面 pickDecide 的用例断言(它同时钉住「数字从哪来」)。
1457
1457
  assert.ok(decoded.includes('最多 '), '缺少硬上限的「最多」前缀')
1458
1458
  assert.ok(decoded.includes('个容器'), '缺少硬上限的「个容器」文案')
1459
- assert.ok(decoded.includes(',浏览器并发长连接有限制'), '缺少本地目标的硬上限后缀')
1460
- assert.ok(decoded.includes('(SSH 目标上一条连接要同时装实时流与刷新等短命令)'), '缺少 SSH 目标的硬上限后缀')
1459
+ assert.ok(decoded.includes('会占满浏览器的同源连接,整个页面都会卡'), '缺少硬上限的成因后缀')
1460
+ // 上限的来由要写在选择条上(D165):不写清楚,3 看起来就只是个随手定的数字
1461
+ assert.ok(decoded.includes('浏览器每个站点最多同时保持 4 条实时流'), '缺少「预算从哪来」的说明')
1462
+ assert.ok(code.includes('dk_pickBudget'), '缺少预算说明的样式钩子')
1463
+ // 接线判据走源码级:局部标识符会被压缩改名,产物里 `budget: t(` 认不出来
1464
+ const pickSource = readFileSync(new URL('../client-src/index.js', import.meta.url), 'utf8')
1465
+ assert.ok(/budget: t\('hint\.pickBudget'\)/.test(pickSource), '预算说明必须真的接到操作条上')
1466
+ assert.ok(/props\.budget === undefined \? null/.test(pickSource), '操作条要渲染 budget(拿到不渲染等于没接)')
1461
1467
  assert.ok(decoded.includes('Escape'), '缺少 Esc 退出选择态')
1462
1468
  assert.ok(code.includes('dk_pick'), '缺少勾选框样式钩子')
1463
1469
  assert.ok(code.includes('dk_pickBar'), '缺少聚合操作条样式钩子')
1464
1470
  assert.ok(code.includes('dk_pillPick'), '缺少「聚合选择」激活态样式钩子')
1465
1471
  })
1466
1472
 
1467
- await test('聚合选择:N<2 置灰、2~6 可聚合、7~8 软提示、>8 置灰(SSH 目标上限收到 6)', () => {
1473
+ await test('聚合选择:上限绑在浏览器同源连接预算上(4 条流 − 事件流 = 3)', () => {
1468
1474
  const pick = pickApi()
1469
- assert.equal(pick.MAX, 8)
1470
- assert.equal(pick.SOFT_MAX, 6)
1475
+ /*
1476
+ * D165(2026-10-08 真机实测):宿主是 HTTP/1.1,每条 EventSource 独占一条同源
1477
+ * 连接且不释放。实测常驻流 4 条 → 页内请求 6/6 成功(中位 29ms);5 条 → 0/6 全部超时
1478
+ * (同时刻进程外请求 41ms 正常,堵在浏览器不在宿主)。容器页常开着事件流,所以聚合
1479
+ * 只能用掉剩下的 3 条。这条用例把「数字从哪来」钉住,免得后人凭手感调大。
1480
+ */
1481
+ assert.equal(pick.MAX, 3)
1482
+ assert.equal(pick.SSH_MAX, 3, 'SSH 与本地共用同一个浏览器预算(卡页面的是浏览器,不是 MaxSessions)')
1483
+ assert.equal(pick.SOFT_MAX, 2)
1484
+ assert.equal(pick.BUDGET, 4, '同源预算:4 条常驻流')
1485
+ assert.equal(pick.RESERVED, 1, '其中 1 条留给活动条的事件流')
1471
1486
  assert.deepEqual(pick.decide(0), { canRun: false, hint: '' })
1472
1487
  assert.deepEqual(pick.decide(1), { canRun: false, hint: '至少选择 2 个容器' })
1473
1488
  assert.deepEqual(pick.decide(2), { canRun: true, hint: '' })
1474
- assert.deepEqual(pick.decide(6), { canRun: true, hint: '' })
1475
- assert.deepEqual(pick.decide(7), { canRun: true, hint: '连接数较多,浏览器并发长连接有限制' })
1476
- assert.deepEqual(pick.decide(8), { canRun: true, hint: '连接数较多,浏览器并发长连接有限制' })
1477
- const over = pick.decide(9)
1489
+ // 第 3 个已经在吃满预算前的最后一格:可点,但给一句软提示
1490
+ assert.equal(pick.decide(3).canRun, true)
1491
+ assert.match(pick.decide(3).hint, /整个页面会变卡/)
1492
+ const over = pick.decide(4)
1478
1493
  assert.equal(over.canRun, false, '超过硬上限必须置灰')
1479
- assert.match(over.hint, /最多 8 个容器/)
1494
+ assert.match(over.hint, /最多 3 个容器/)
1480
1495
 
1481
- // SSH 目标更紧:一条 TCP 连接的通道额度(MaxSessions 默认 10)要同时装下聚合流、
1482
- // 统计流、事件流与「刷新列表」这类短命令,所以聚合上限收到 6(= 软提示线,于是 SSH
1483
- // 上不再有「可点但已偏多」的区间)。本地目标走子进程,没有这个约束。
1484
- assert.equal(pick.SSH_MAX, 6)
1485
- assert.deepEqual(pick.decide(6, true), { canRun: true, hint: '' })
1486
- const sshOver = pick.decide(7, true)
1487
- assert.equal(sshOver.canRun, false, 'SSH 目标上 7 个容器必须置灰')
1488
- assert.match(sshOver.hint, /最多 6 个容器/)
1489
- assert.match(sshOver.hint, /SSH 目标/, '提示必须说明 SSH 上为什么更紧')
1490
- assert.equal(pick.decide(7, false).canRun, true, '本地目标不受 SSH 上限影响')
1496
+ // 两个目标类型走同一套上限:真正卡住页面的是浏览器同源预算,与目标是 SSH 还是本机无关
1497
+ assert.deepEqual(pick.decide(4, true).canRun, false, 'SSH 目标同样在 4 个时置灰')
1498
+ assert.match(pick.decide(4, true).hint, /最多 3 个容器/)
1491
1499
  })
1492
1500
 
1493
1501
  await test('聚合选择:勾选增删保序 + 列表刷新按 id 对账', () => {
@@ -1606,7 +1614,7 @@ await test('事件流断开:状态判定只认 closed,unsupported 不给重
1606
1614
  await test('事件流断开:活动条接线(兄弟按钮、不是嵌套按钮)与 ⟳ 接手动刷新', () => {
1607
1615
  const decoded = decodeBundle(code)
1608
1616
  assert.ok(decoded.includes('重连'), '缺少重连按钮文案')
1609
- assert.ok(decoded.includes('不会自己回来'), '缺少「不会自愈」的说明')
1617
+ assert.ok(decoded.includes('不会自动恢复'), '缺少「不会自愈」的说明')
1610
1618
  assert.ok(code.includes('dk_activityHeadRow'), '缺少头行容器(兄弟布局的前提)')
1611
1619
  assert.ok(code.includes('dk_activityRetry'), '缺少重连按钮样式钩子')
1612
1620
 
@@ -1653,6 +1661,45 @@ await test('事件流断开:活动条接线(兄弟按钮、不是嵌套按
1653
1661
  '重连补偿的判据必须含 manualReconnect(否则从未 open 过的流重连后列表不刷新)')
1654
1662
  })
1655
1663
 
1664
+ /*
1665
+ * D164:服务端**拒绝了**这条流时,「重连」看起来毫无反应——真机现场(2026-10-08):
1666
+ * 248 上长流配额恒满 8/8,每次点重连都被拒;而客户端把 `event: error` 的 data 整个丢掉,
1667
+ * 只置 `closed`,于是界面与点击前逐像素相同。统计流一直是读出 message 的,这里补齐。
1668
+ */
1669
+ await test('D164:事件流的 error 帧要解出服务端原因(而不是丢掉)', () => {
1670
+ const api = eventsApi()
1671
+ assert.ok(api.errorMessage !== undefined, '缺少 __events.errorMessage 测试缝')
1672
+ // 配额拒绝:必须原样带出那句可执行的文案
1673
+ const refused = api.errorMessage({ data: JSON.stringify({ message: '目标 248 上已有 8 条实时流(上限 8)' }) })
1674
+ assert.equal(refused, '目标 248 上已有 8 条实时流(上限 8)')
1675
+ // 非 JSON 的 data:整段当原因(宁可显示原文,也别显示「不知道」)
1676
+ assert.equal(api.errorMessage({ data: 'boom' }), 'boom')
1677
+ // 连接层错误(无 data):不是「服务端说了什么」,必须返回空串——
1678
+ // 那种情况 EventSource 会自己重连,不能冒充成一次被拒绝的重连
1679
+ assert.equal(api.errorMessage({}), '')
1680
+ assert.equal(api.errorMessage({ data: '' }), '')
1681
+ assert.equal(api.errorMessage(new Event('error')), '')
1682
+ assert.equal(api.errorMessage(null), '')
1683
+ assert.equal(api.errorMessage(undefined), '')
1684
+ // 畸形 JSON 不许把半截载荷当文案漏出去
1685
+ assert.equal(api.errorMessage({ data: '{"message":' }), '{"message":')
1686
+ // message 不是字符串时退回原文,而不是渲染出 [object Object]
1687
+ assert.equal(api.errorMessage({ data: JSON.stringify({ message: 42 }) }), '{"message":42}')
1688
+ })
1689
+
1690
+ await test('D164:被拒原因要接到界面上(接线判据)', () => {
1691
+ const decoded = decodeBundle(code)
1692
+ assert.ok(decoded.includes('重连被拒'), '缺少「被拒绝」的提示文案')
1693
+ assert.ok(decoded.includes('不会自动恢复'), '原有的「不会自愈」说明必须保留')
1694
+ const source = readFileSync(new URL('../client-src/index.js', import.meta.url), 'utf8')
1695
+ // 拒绝原因必须进 state,并作为 props 传给活动条(只解出来不传等于没修)
1696
+ assert.ok(/setEventsRefused\(message\)/.test(source), 'error 帧的原因必须写进 state')
1697
+ assert.ok(/refused: eventsRefused/.test(source), '活动条必须拿到被拒原因')
1698
+ assert.ok(/hint\.eventsReconnectRefused/.test(source), '活动条必须渲染被拒文案')
1699
+ // 重连成功要清掉(否则「上次被拒」会一直挂着,看着像现在还坏着)
1700
+ assert.ok(/setEventsRefused\(''\)/.test(source), '重连成功 / 重新建流时必须清掉上一条原因')
1701
+ })
1702
+
1656
1703
  await test('变更操作执行中态:按钮转圈 + 卡片锁组 + 确认框不关', () => {
1657
1704
  // 三处过渡态钩子:按钮 data-busy(图标换转圈)、卡片 data-pending(整组变更按钮压暗锁住)、
1658
1705
  // 确认框 k_confirmBusy(保持打开 + 「执行中…」)。真实点击路径需要浏览器,这里锁契约。
@@ -2419,6 +2466,195 @@ await test('连接本机:下拉项接的是 connectLocal(哨兵被拦在换
2419
2466
  assert.ok(/return/.test(branch), '哨兵分支必须 return,不能继续走换目标')
2420
2467
  })
2421
2468
 
2469
+ /* ------------------------------------------------------------------ *
2470
+ * 就地提权:多能力 pending → 一条命令解锁全部(一次粘贴)
2471
+ * ------------------------------------------------------------------ */
2472
+
2473
+ /**
2474
+ * 为什么这一组用例必须存在:改前 `elevation` 是**单槽** state(`setElevation({capability})`),
2475
+ * 面板开着时再点另一个开关会把前一个能力的面板**替换**掉、它的命令从界面消失(宿主侧 challenge
2476
+ * 仍在 pending)。2026-09-26 的设置卡片截图实测:两次授权时刻差 14 秒——用户必须在宿主终端
2477
+ * **粘贴两次**。逐能力记录 + 合并复制修掉的就是这件事:两个开关各点一次,界面上同时有两块面板
2478
+ * 与一个「复制全部」,终端**粘贴一次**两行。
2479
+ *
2480
+ * 断言打在**渲染树**与**按钮交下去的字节**上(不只是纯函数):纯函数对而接线错(按钮接错回调、
2481
+ * 只复制了第一条、顺序反了)在真机上才暴露,正是离线冒烟要拦的那一类。
2482
+ */
2483
+ function elevationApi() {
2484
+ const exports_ = registration.factory((spec) => SEED[spec])
2485
+ assert.ok(exports_.__elevation !== undefined, '缺少 __elevation 测试缝')
2486
+ return exports_.__elevation
2487
+ }
2488
+
2489
+ /** 面板组的渲染:回调用桩记录,配置值默认都关着(与「开关还没打开」的真实初态一致)。 */
2490
+ function renderElevationPanels(records, overrides = {}) {
2491
+ const api = elevationApi()
2492
+ const copied = []
2493
+ const closed = []
2494
+ const regenerated = []
2495
+ const tree = api.view(records, {
2496
+ now: overrides.now ?? 1_700_000_000_000,
2497
+ copiedKey: overrides.copiedKey ?? '',
2498
+ configured: overrides.configured ?? (() => false),
2499
+ onCopy: (capability, command) => copied.push({ capability, command }),
2500
+ onCopyAll: (text) => copied.push({ capability: api.COPY_ALL_KEY, command: text }),
2501
+ onRegenerate: (capability) => regenerated.push(capability),
2502
+ onClose: (capability) => closed.push(capability),
2503
+ onDisableFirst: () => {},
2504
+ })
2505
+ return { api, tree, copied, closed, regenerated }
2506
+ }
2507
+
2508
+ /** 合并按钮:从渲染树里找(而不是读入参),保证「界面上真的有」这件事被测到。 */
2509
+ function mergeButtonOf(tree) {
2510
+ return treeFind(tree, (node) => node.type === 'button' && node.props?.key === 'copyAll')[0]
2511
+ }
2512
+
2513
+ await test('就地提权:两个 pending → 合并按钮复制的内容 = 两条命令按发起顺序的换行连接(逐字节)', () => {
2514
+ const api = elevationApi()
2515
+ const expiresAt = 1_700_000_000_000 + 300_000
2516
+ // 发起顺序:先点 allowMutations,再点 allowExec(数组顺序就是点击顺序)
2517
+ const records = [
2518
+ { capability: 'allowMutations', command: "touch '/home/u/.dsh/dsh-kit/grant-confirm/aaa'", expiresAt },
2519
+ { capability: 'allowExec', command: "touch '/home/u/.dsh/dsh-kit/grant-confirm/bbb'", expiresAt },
2520
+ ]
2521
+ const { tree, copied } = renderElevationPanels(records)
2522
+ const button = mergeButtonOf(tree)
2523
+ assert.ok(button !== undefined, '两个 pending 时必须出「复制全部」按钮')
2524
+
2525
+ /*
2526
+ * 驱动按钮的 onClick(真跑一次「点了会怎样」):断言的是**交给复制动作的那个字符串**
2527
+ * (桩里没有 navigator,剪贴板本身是浏览器的事)——内容错(少一条 / 顺序反 / 用 && 连接)
2528
+ * 才是本用例要拦的。
2529
+ */
2530
+ button.props.onClick()
2531
+ assert.equal(copied.length, 1, '点一次合并按钮应恰好触发一次复制')
2532
+ const text = copied[0].command
2533
+ const expected = records[0].command + '\n' + records[1].command
2534
+ assert.equal(text, expected, '合并内容必须逐字节等于两条命令的换行连接')
2535
+ assert.equal(
2536
+ Buffer.from(text, 'utf8').toString('base64'),
2537
+ Buffer.from(expected, 'utf8').toString('base64'),
2538
+ '按字节比对(换行符形态也是契约)',
2539
+ )
2540
+ assert.equal(text.split('\n').length, 2, '两条命令 = 两行')
2541
+ assert.ok(!text.includes('&&'), '不得用 && 连接(PS 5.1 不认)')
2542
+ assert.ok(!text.includes('; '), '不得用 ; 连接(cmd 不认)')
2543
+ assert.ok(text.indexOf(records[0].command) < text.indexOf(records[1].command), '顺序必须是发起顺序(先点的在前)')
2544
+ assert.equal(copied[0].capability, api.COPY_ALL_KEY, '合并复制的反馈键必须与单条按钮隔离')
2545
+
2546
+ // 每块面板自己的单条复制按钮仍在(用户仍可只复制一条),且只复制自己那条
2547
+ const singleButtons = treeFind(tree, (node) => node.type === 'button' && node.props?.key === 'copy')
2548
+ assert.equal(singleButtons.length, 2, '两块面板各自保留单条复制按钮')
2549
+ singleButtons[0].props.onClick()
2550
+ assert.equal(copied[1].command, records[0].command, '单条复制只复制自己那条命令')
2551
+ assert.equal(copied[1].capability, 'allowMutations', '单条复制的反馈键是能力名(与合并按钮互不串)')
2552
+ })
2553
+
2554
+ await test('就地提权:单个 pending → 无合并按钮(那条命令就在自己的面板里)', () => {
2555
+ const expiresAt = 1_700_000_000_000 + 300_000
2556
+ const single = [{ capability: 'allowMutations', command: "touch '/tmp/a'", expiresAt }]
2557
+ const { tree, api } = renderElevationPanels(single)
2558
+ assert.equal(mergeButtonOf(tree), undefined, '只点过一个开关时不该出现「复制全部(1 条)」')
2559
+ // 反面判据:纯判定同样为假(防止某天有人把「≥2」放宽成「≥1」而用例只看渲染树)
2560
+ assert.equal(api.shouldOfferMerge(single, 1_700_000_000_000), false)
2561
+ // 界面结构不变形:一块面板 + 一个单条复制按钮
2562
+ assert.equal(treeFind(tree, (node) => node.props?.className === 'dk_elevPanel').length, 1)
2563
+ assert.equal(treeFind(tree, (node) => node.type === 'button' && node.props?.key === 'copy').length, 1)
2564
+ })
2565
+
2566
+ await test('就地提权:一个 granted 后 → 合并按钮只剩剩下那一条(组里还有别的能力没解锁)', () => {
2567
+ const api = elevationApi()
2568
+ const expiresAt = 1_700_000_000_000 + 300_000
2569
+ const two = [
2570
+ { capability: 'allowMutations', command: "touch '/tmp/a'", expiresAt },
2571
+ { capability: 'allowExec', command: "touch '/tmp/b'", expiresAt },
2572
+ ]
2573
+ // allowMutations 先解锁:记录留痕(面板消失),命令清掉
2574
+ const afterGrant = api.markGranted(two, 'allowMutations')
2575
+ assert.equal(api.shouldOfferMerge(afterGrant, 1_700_000_000_000), true, '还有一条没解锁时按钮要留着')
2576
+ assert.deepEqual(api.merge(afterGrant, 1_700_000_000_000), ["touch '/tmp/b'"], '只剩仍 pending 的那一条')
2577
+ assert.equal(api.text(afterGrant, 1_700_000_000_000), "touch '/tmp/b'", '合并内容退化成那一行(没有多余换行)')
2578
+
2579
+ const { tree, copied } = renderElevationPanels(afterGrant)
2580
+ const button = mergeButtonOf(tree)
2581
+ assert.ok(button !== undefined, '一个 granted 后合并按钮仍在')
2582
+ button.props.onClick()
2583
+ assert.equal(copied[0].command, "touch '/tmp/b'", '按钮此刻只复制仍 pending 的那条')
2584
+ // 已授权的那块面板消失,仍 pending 的那块还在
2585
+ assert.equal(treeFind(tree, (node) => node.props?.className === 'dk_elevPanel').length, 1, '已授权的面板必须消失')
2586
+
2587
+ // 两条都解锁 → 组里没有可复制命令:留痕被收掉,按钮自然消失
2588
+ const allGranted = api.markGranted(afterGrant, 'allowExec')
2589
+ assert.deepEqual(allGranted, [], '全部解锁后整组清空(含留痕)')
2590
+ assert.equal(
2591
+ api.view(allGranted, { copiedKey: '', configured: () => false, onCopy() {}, onCopyAll() {}, onRegenerate() {}, onClose() {}, onDisableFirst() {} }),
2592
+ null,
2593
+ '全部结束后面板组不渲染',
2594
+ )
2595
+ })
2596
+
2597
+ await test('就地提权:过期的那条不进合并(复制过去只会让宿主落一个过期后才出现的文件)', () => {
2598
+ const api = elevationApi()
2599
+ const now = 1_700_000_000_000
2600
+ const records = [
2601
+ { capability: 'allowMutations', command: "touch '/tmp/a'", expiresAt: now - 1 },
2602
+ { capability: 'allowExec', command: "touch '/tmp/b'", expiresAt: now + 300_000 },
2603
+ ]
2604
+ assert.deepEqual(api.merge(records, now), ["touch '/tmp/b'"], '过期的从合并里剔掉')
2605
+ const { tree, copied } = renderElevationPanels(records, { now })
2606
+ const button = mergeButtonOf(tree)
2607
+ assert.ok(button !== undefined, '组里仍有两条记录时按钮在(只是内容少了一条)')
2608
+ button.props.onClick()
2609
+ assert.equal(copied[0].command, "touch '/tmp/b'", '合并按钮只复制仍 pending 的那些')
2610
+ // 两条都过期 → 没有可复制的:按钮收起(不再承诺一个不可能发生的解锁)
2611
+ const bothExpired = records.map((record) => ({ ...record, expiresAt: now - 1 }))
2612
+ assert.equal(api.shouldOfferMerge(bothExpired, now), false)
2613
+ })
2614
+
2615
+ await test('就地提权:✕ 只关一个能力的面板,另一个照常解锁(不撤销宿主 challenge)', () => {
2616
+ const api = elevationApi()
2617
+ const expiresAt = 1_700_000_000_000 + 300_000
2618
+ const records = [
2619
+ { capability: 'allowMutations', command: "touch '/tmp/a'", expiresAt },
2620
+ { capability: 'allowExec', command: "touch '/tmp/b'", expiresAt },
2621
+ ]
2622
+ const { tree, closed } = renderElevationPanels(records)
2623
+ const closeButtons = treeFind(tree, (node) => node.type === 'button' && node.props?.key === 'close')
2624
+ assert.equal(closeButtons.length, 2, '每块面板各有自己的收起按钮')
2625
+ closeButtons[0].props.onClick()
2626
+ assert.deepEqual(closed, ['allowMutations'], '✕ 只收起被点的那一个能力')
2627
+ // 关掉 A 之后 B 照常解锁:记录集里仍留着 B,合并内容只剩 B 那条
2628
+ const afterClose = api.remove(records, 'allowMutations')
2629
+ assert.deepEqual(api.merge(afterClose, 1_700_000_000_000), ["touch '/tmp/b'"])
2630
+ assert.equal(api.shouldOfferMerge(afterClose, 1_700_000_000_000), false, '只剩一个 pending 时合并按钮收起')
2631
+ })
2632
+
2633
+ await test('就地提权:两条命令各自独立——一条出错 / 在途不影响另一条的合并', () => {
2634
+ const api = elevationApi()
2635
+ const expiresAt = 1_700_000_000_000 + 300_000
2636
+ const records = [
2637
+ { capability: 'allowMutations', error: 'HTTP 429' },
2638
+ { capability: 'allowExec', command: "touch '/tmp/b'", expiresAt },
2639
+ ]
2640
+ assert.deepEqual(api.merge(records, 1_700_000_000_000), ["touch '/tmp/b'"], '出错那条没有命令,不进合并')
2641
+ assert.equal(api.shouldOfferMerge(records, 1_700_000_000_000), false, '只有一条可复制时不出按钮')
2642
+ // 在途(begin 还没回)同样不参与合并
2643
+ assert.deepEqual(api.merge([{ capability: 'allowExec' }], 1_700_000_000_000), [])
2644
+ })
2645
+
2646
+ await test('就地提权:i18n 双份齐(新增键 zh/en 都在,且都进了产物)', () => {
2647
+ const source = readFileSync(new URL('../client-src/index.js', import.meta.url), 'utf8')
2648
+ for (const key of ['elev.copyAll', 'elev.copyAllHint', 'elev.copyAllCopied']) {
2649
+ const hits = source.split(`'${key}':`).length - 1
2650
+ assert.equal(hits, 2, `${key} 必须在 zh / en 两份目录里各出现一次(现在是 ${String(hits)} 次)`)
2651
+ }
2652
+ const decoded = decodeBundle(code)
2653
+ assert.ok(decoded.includes('复制全部'), 'bundle 里应有合并按钮的中文兜底文案')
2654
+ assert.ok(code.includes('dk_elevPanels'), 'bundle 里应有面板组的样式钩子')
2655
+ assert.ok(code.includes('dk_elevMerge'), 'bundle 里应有合并复制行的样式钩子')
2656
+ })
2657
+
2422
2658
  /* ------------------------------------------------------------------ *
2423
2659
  * 结果
2424
2660
  * ------------------------------------------------------------------ */
@@ -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
  * ------------------------------------------------------------------ */