dsh-remote-agent 0.9.7 → 0.9.8
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/CHANGELOG.md +113 -0
- package/README.md +6 -4
- package/lib/auto-fix.js +304 -0
- package/lib/client.js +279 -9
- package/lib/external-tools.js +238 -0
- package/lib/hub/tools.js +289 -0
- package/lib/hub/ui-routes.js +182 -0
- package/lib/node/fs-service.js +276 -1
- package/lib/node/index.js +29 -2
- package/lib/node/process-service.js +406 -0
- package/package.json +1 -1
- package/scripts/check-doc-drift.mjs +97 -3
- package/scripts/diag-inject.mjs +2 -2
package/lib/hub/tools.js
CHANGED
|
@@ -1363,6 +1363,295 @@ export function registerRemoteTools({ ctx, registry, pulls, log: _log, defaults
|
|
|
1363
1363
|
'tree',
|
|
1364
1364
|
)
|
|
1365
1365
|
|
|
1366
|
+
/* ═══════════════════════════════════════════════════════════════════════
|
|
1367
|
+
* 写操作工具(2026-10-20,用户要求"更多工具 = 文件写操作")
|
|
1368
|
+
* ═══════════════════════════════════════════════════════════════════════
|
|
1369
|
+
*
|
|
1370
|
+
* ⚠⚠ **这四个工具会改副机的文件** —— 前四个 fs 工具全是只读。
|
|
1371
|
+
*
|
|
1372
|
+
* 与只读工具的关键区别:**必须带 `confirm: 'yes'`**。
|
|
1373
|
+
*
|
|
1374
|
+
* 为什么强制确认而不是靠参数默认值:
|
|
1375
|
+
* · 这些操作**不可逆**(删了就是删了)
|
|
1376
|
+
* · 调用方通常是 AI,而 AI 会"顺手"多调一次工具
|
|
1377
|
+
* · 用户对"AI 改了我的文件"的容忍度远低于"AI 读了我的文件"
|
|
1378
|
+
*
|
|
1379
|
+
* 确认不是形式主义:`confirm` 必须由**调用方显式表达**,缺了直接拒绝。
|
|
1380
|
+
* 副机侧还有一层护栏(roots / 不覆盖 / 不递归删),两层独立。
|
|
1381
|
+
*/
|
|
1382
|
+
const confirmParam = {
|
|
1383
|
+
type: 'string',
|
|
1384
|
+
description: '必须显式传 "yes" 才能执行。这是不可逆的文件操作,插件要求调用方明确表示知情。',
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* 写操作工具的通用包装。
|
|
1389
|
+
*
|
|
1390
|
+
* @param {string} name 工具名
|
|
1391
|
+
* @param {string} description 说明(要讲清危险)
|
|
1392
|
+
* @param {object} parameters 参数 schema
|
|
1393
|
+
* @param {object} output 输出 schema
|
|
1394
|
+
* @param {string} op 副机侧的 op 名
|
|
1395
|
+
*/
|
|
1396
|
+
const writeOp = (name, description, parameters, output, op) => {
|
|
1397
|
+
reg({
|
|
1398
|
+
name,
|
|
1399
|
+
description,
|
|
1400
|
+
parameters,
|
|
1401
|
+
output,
|
|
1402
|
+
async execute(args) {
|
|
1403
|
+
// ⚠ 二次确认是**硬要求**:缺了直接拒,不给默认通过
|
|
1404
|
+
if (args?.confirm !== 'yes') {
|
|
1405
|
+
throw new RemoteAgentError(
|
|
1406
|
+
`${name} 会修改副机上的文件(不可逆)。`
|
|
1407
|
+
+ '请在参数里显式传 confirm: "yes" 表示你已确认目标路径正确。',
|
|
1408
|
+
ERROR_CODE.WORKSPACE_DENIED,
|
|
1409
|
+
)
|
|
1410
|
+
}
|
|
1411
|
+
const nodeId = resolveNodeId(registry, args?.node)
|
|
1412
|
+
const params = { ...(args ?? {}) }
|
|
1413
|
+
// confirm / node 都是 hub 侧概念,不透传给副机
|
|
1414
|
+
delete params.node
|
|
1415
|
+
delete params.confirm
|
|
1416
|
+
try {
|
|
1417
|
+
const result = await registry.fsRequest(nodeId, op, params)
|
|
1418
|
+
return { node: nodeId, ...(result && typeof result === 'object' ? result : {}) }
|
|
1419
|
+
} catch (err) {
|
|
1420
|
+
const code = typeof err?.code === 'string' ? err.code : ERROR_CODE.INTERNAL
|
|
1421
|
+
throw new RemoteAgentError(
|
|
1422
|
+
`remote ${op} on ${nodeId} failed: ${String(err?.message ?? err)}`,
|
|
1423
|
+
code,
|
|
1424
|
+
)
|
|
1425
|
+
}
|
|
1426
|
+
},
|
|
1427
|
+
})
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const writeReq = (props, required) => ({
|
|
1431
|
+
type: 'object',
|
|
1432
|
+
properties: { node: nodeParam, confirm: confirmParam, ...props },
|
|
1433
|
+
required: ['path', 'confirm', ...required],
|
|
1434
|
+
additionalProperties: false,
|
|
1435
|
+
})
|
|
1436
|
+
|
|
1437
|
+
writeOp(
|
|
1438
|
+
'remote_write',
|
|
1439
|
+
'在副机上写一个文件(**会修改文件,不可逆**)。默认**不覆盖**已有文件 —— 要覆盖必须显式传 overwrite: true。'
|
|
1440
|
+
+ '路径必须在副机允许的 roots 内。写盘是原子的(临时文件 + rename),不会留半截文件。'
|
|
1441
|
+
+ '单次内容上限 8MB。**必须传 confirm: "yes"**。',
|
|
1442
|
+
writeReq({
|
|
1443
|
+
path: pathParam,
|
|
1444
|
+
content: { type: 'string', description: '文件内容(UTF-8 文本)。上限 8MB。' },
|
|
1445
|
+
overwrite: { type: 'boolean', description: '目标已存在时是否覆盖。默认 false(拒绝)。' },
|
|
1446
|
+
}, ['content']),
|
|
1447
|
+
{
|
|
1448
|
+
type: 'object',
|
|
1449
|
+
properties: {
|
|
1450
|
+
node: { type: 'string' },
|
|
1451
|
+
path: { type: 'string' },
|
|
1452
|
+
bytes: { type: 'number' },
|
|
1453
|
+
overwritten: { type: 'boolean' },
|
|
1454
|
+
},
|
|
1455
|
+
required: ['node', 'path'],
|
|
1456
|
+
},
|
|
1457
|
+
'write',
|
|
1458
|
+
)
|
|
1459
|
+
|
|
1460
|
+
writeOp(
|
|
1461
|
+
'remote_mkdir',
|
|
1462
|
+
'在副机上建目录(递归,已存在视为成功)。路径必须在 roots 内。**必须传 confirm: "yes"**。',
|
|
1463
|
+
writeReq({ path: pathParam }, []),
|
|
1464
|
+
{
|
|
1465
|
+
type: 'object',
|
|
1466
|
+
properties: {
|
|
1467
|
+
node: { type: 'string' },
|
|
1468
|
+
path: { type: 'string' },
|
|
1469
|
+
created: { type: 'boolean' },
|
|
1470
|
+
},
|
|
1471
|
+
required: ['node', 'path'],
|
|
1472
|
+
},
|
|
1473
|
+
'mkdir',
|
|
1474
|
+
)
|
|
1475
|
+
|
|
1476
|
+
writeOp(
|
|
1477
|
+
'remote_move',
|
|
1478
|
+
'在副机上移动/重命名文件(同盘 rename)。两端都必须在 roots 内。'
|
|
1479
|
+
+ '默认不覆盖目标;跨盘移动会明确报错(不做静默复制)。**必须传 confirm: "yes"**。',
|
|
1480
|
+
{
|
|
1481
|
+
type: 'object',
|
|
1482
|
+
properties: {
|
|
1483
|
+
node: nodeParam,
|
|
1484
|
+
confirm: confirmParam,
|
|
1485
|
+
from: { type: 'string', description: '源路径(副机绝对路径,须在 roots 内)。' },
|
|
1486
|
+
to: { type: 'string', description: '目标路径(副机绝对路径,须在 roots 内)。' },
|
|
1487
|
+
overwrite: { type: 'boolean', description: '目标已存在时是否覆盖。默认 false。' },
|
|
1488
|
+
},
|
|
1489
|
+
required: ['from', 'to', 'confirm'],
|
|
1490
|
+
additionalProperties: false,
|
|
1491
|
+
},
|
|
1492
|
+
{
|
|
1493
|
+
type: 'object',
|
|
1494
|
+
properties: {
|
|
1495
|
+
node: { type: 'string' },
|
|
1496
|
+
from: { type: 'string' },
|
|
1497
|
+
to: { type: 'string' },
|
|
1498
|
+
},
|
|
1499
|
+
required: ['node', 'from', 'to'],
|
|
1500
|
+
},
|
|
1501
|
+
'move',
|
|
1502
|
+
)
|
|
1503
|
+
|
|
1504
|
+
writeOp(
|
|
1505
|
+
'remote_delete',
|
|
1506
|
+
'删除副机上的**单个文件或空目录**(**不可逆**)。'
|
|
1507
|
+
+ '刻意**不支持递归删除** —— 非空目录会被拒绝,真有需要请用 exec 显式执行删除命令。'
|
|
1508
|
+
+ '路径必须在 roots 内。**必须传 confirm: "yes"**。',
|
|
1509
|
+
writeReq({ path: pathParam }, []),
|
|
1510
|
+
{
|
|
1511
|
+
type: 'object',
|
|
1512
|
+
properties: {
|
|
1513
|
+
node: { type: 'string' },
|
|
1514
|
+
path: { type: 'string' },
|
|
1515
|
+
kind: { type: 'string' },
|
|
1516
|
+
},
|
|
1517
|
+
required: ['node', 'path'],
|
|
1518
|
+
},
|
|
1519
|
+
'delete',
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
/* ═══════════════════════════════════════════════════════════════════════
|
|
1523
|
+
* 进程与端口诊断(2026-10-20,用户要求"更多工具 = 进程与端口诊断")
|
|
1524
|
+
* ═══════════════════════════════════════════════════════════════════════
|
|
1525
|
+
*
|
|
1526
|
+
* 为什么需要:远程排查时用户不在副机前面,看不见任务管理器。
|
|
1527
|
+
* 这两天排查"副机连不上",最耗时的几步全靠手工敲 netstat / Get-Process。
|
|
1528
|
+
*
|
|
1529
|
+
* · `remote_ps` —— 列进程(**只读**)
|
|
1530
|
+
* · `remote_port` —— 查端口占用(**只读**)
|
|
1531
|
+
* · `remote_kill` —— 杀进程(**不可逆** → 必须 confirm: 'yes')
|
|
1532
|
+
*
|
|
1533
|
+
* ⚠ 这三个工具**不受 roots 约束**(进程没有路径概念)。kill 的护栏在
|
|
1534
|
+
* 副机侧 `lib/node/process-service.js`:confirm + 拒杀自身/父进程/系统进程。
|
|
1535
|
+
*/
|
|
1536
|
+
reg({
|
|
1537
|
+
name: 'remote_ps',
|
|
1538
|
+
description: '列出副机上的进程(PID / 名称 / 内存 / CPU 时间)。只读。'
|
|
1539
|
+
+ '用于排查"副机上到底跑着什么"、"那个卡住的进程还在不在"。'
|
|
1540
|
+
+ '可按进程名子串过滤。',
|
|
1541
|
+
parameters: {
|
|
1542
|
+
type: 'object',
|
|
1543
|
+
properties: {
|
|
1544
|
+
node: nodeParam,
|
|
1545
|
+
limit: { type: 'number', description: '最多返回多少个(1-1000,默认 200)。' },
|
|
1546
|
+
filter: { type: 'string', description: '按进程名子串过滤(不区分大小写)。留空返回全部。' },
|
|
1547
|
+
},
|
|
1548
|
+
additionalProperties: false,
|
|
1549
|
+
},
|
|
1550
|
+
output: {
|
|
1551
|
+
type: 'object',
|
|
1552
|
+
properties: {
|
|
1553
|
+
node: { type: 'string' },
|
|
1554
|
+
platform: { type: 'string' },
|
|
1555
|
+
processes: { type: 'array', items: { type: 'object' } },
|
|
1556
|
+
truncated: { type: 'boolean' },
|
|
1557
|
+
},
|
|
1558
|
+
required: ['node'],
|
|
1559
|
+
},
|
|
1560
|
+
async execute(args) {
|
|
1561
|
+
const nodeId = resolveNodeId(registry, args?.node)
|
|
1562
|
+
const params = { ...(args ?? {}) }
|
|
1563
|
+
delete params.node
|
|
1564
|
+
try {
|
|
1565
|
+
const result = await registry.fsRequest(nodeId, 'ps', params)
|
|
1566
|
+
return { node: nodeId, ...(result && typeof result === 'object' ? result : {}) }
|
|
1567
|
+
} catch (err) {
|
|
1568
|
+
const code = typeof err?.code === 'string' ? err.code : ERROR_CODE.INTERNAL
|
|
1569
|
+
throw new RemoteAgentError(`remote ps on ${nodeId} failed: ${String(err?.message ?? err)}`, code)
|
|
1570
|
+
}
|
|
1571
|
+
},
|
|
1572
|
+
})
|
|
1573
|
+
|
|
1574
|
+
reg({
|
|
1575
|
+
name: 'remote_port',
|
|
1576
|
+
description: '查副机上某个端口的占用情况(谁在监听、谁在连接、对应 PID)。只读。'
|
|
1577
|
+
+ '用于排查"端口被占用"、"副机到底有没有在监听 19387"。',
|
|
1578
|
+
parameters: {
|
|
1579
|
+
type: 'object',
|
|
1580
|
+
properties: {
|
|
1581
|
+
node: nodeParam,
|
|
1582
|
+
port: { type: 'number', description: '要查的端口(1-65535)。' },
|
|
1583
|
+
},
|
|
1584
|
+
required: ['port'],
|
|
1585
|
+
additionalProperties: false,
|
|
1586
|
+
},
|
|
1587
|
+
output: {
|
|
1588
|
+
type: 'object',
|
|
1589
|
+
properties: {
|
|
1590
|
+
node: { type: 'string' },
|
|
1591
|
+
port: { type: 'number' },
|
|
1592
|
+
listeners: { type: 'array', items: { type: 'object' } },
|
|
1593
|
+
},
|
|
1594
|
+
required: ['node', 'port'],
|
|
1595
|
+
},
|
|
1596
|
+
async execute(args) {
|
|
1597
|
+
const nodeId = resolveNodeId(registry, args?.node)
|
|
1598
|
+
try {
|
|
1599
|
+
const result = await registry.fsRequest(nodeId, 'port', { port: args?.port })
|
|
1600
|
+
return { node: nodeId, ...(result && typeof result === 'object' ? result : {}) }
|
|
1601
|
+
} catch (err) {
|
|
1602
|
+
const code = typeof err?.code === 'string' ? err.code : ERROR_CODE.INTERNAL
|
|
1603
|
+
throw new RemoteAgentError(`remote port on ${nodeId} failed: ${String(err?.message ?? err)}`, code)
|
|
1604
|
+
}
|
|
1605
|
+
},
|
|
1606
|
+
})
|
|
1607
|
+
|
|
1608
|
+
reg({
|
|
1609
|
+
name: 'remote_kill',
|
|
1610
|
+
description: '杀掉副机上的一个进程(**不可逆**)。**必须传 confirm: "yes"**。'
|
|
1611
|
+
+ '副机侧有三道保护:拒绝杀本插件进程、拒绝杀其父进程(通常是 DSH 本体)、'
|
|
1612
|
+
+ '拒绝杀系统关键进程(System/csrss/wininit/services/lsass 等)。'
|
|
1613
|
+
+ '杀掉副机的 DSH 会让它彻底失联 —— 除非你确实无法通过其它方式恢复,否则不要这样做。',
|
|
1614
|
+
parameters: {
|
|
1615
|
+
type: 'object',
|
|
1616
|
+
properties: {
|
|
1617
|
+
node: nodeParam,
|
|
1618
|
+
pid: { type: 'number', description: '要杀的进程 PID(正整数)。先用 remote_ps 确认它是什么。' },
|
|
1619
|
+
confirm: confirmParam,
|
|
1620
|
+
},
|
|
1621
|
+
required: ['pid', 'confirm'],
|
|
1622
|
+
additionalProperties: false,
|
|
1623
|
+
},
|
|
1624
|
+
output: {
|
|
1625
|
+
type: 'object',
|
|
1626
|
+
properties: {
|
|
1627
|
+
node: { type: 'string' },
|
|
1628
|
+
ok: { type: 'boolean' },
|
|
1629
|
+
pid: { type: 'number' },
|
|
1630
|
+
name: { type: 'string' },
|
|
1631
|
+
},
|
|
1632
|
+
required: ['node', 'ok', 'pid'],
|
|
1633
|
+
},
|
|
1634
|
+
async execute(args) {
|
|
1635
|
+
if (args?.confirm !== 'yes') {
|
|
1636
|
+
throw new RemoteAgentError(
|
|
1637
|
+
'remote_kill 会终止副机上的进程(不可逆)。请在参数里显式传 confirm: "yes"。',
|
|
1638
|
+
ERROR_CODE.WORKSPACE_DENIED,
|
|
1639
|
+
)
|
|
1640
|
+
}
|
|
1641
|
+
const nodeId = resolveNodeId(registry, args?.node)
|
|
1642
|
+
try {
|
|
1643
|
+
const result = await registry.fsRequest(nodeId, 'kill', {
|
|
1644
|
+
pid: args?.pid,
|
|
1645
|
+
confirm: 'yes',
|
|
1646
|
+
})
|
|
1647
|
+
return { node: nodeId, ...(result && typeof result === 'object' ? result : {}) }
|
|
1648
|
+
} catch (err) {
|
|
1649
|
+
const code = typeof err?.code === 'string' ? err.code : ERROR_CODE.INTERNAL
|
|
1650
|
+
throw new RemoteAgentError(`remote kill on ${nodeId} failed: ${String(err?.message ?? err)}`, code)
|
|
1651
|
+
}
|
|
1652
|
+
},
|
|
1653
|
+
})
|
|
1654
|
+
|
|
1366
1655
|
return disposers
|
|
1367
1656
|
}
|
|
1368
1657
|
|
package/lib/hub/ui-routes.js
CHANGED
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
* 因为创建空目录不是数据泄漏,而写入越界文件才是。见 `resolvePullDest()`。
|
|
81
81
|
*/
|
|
82
82
|
import fs from 'node:fs'
|
|
83
|
+
import os from 'node:os'
|
|
83
84
|
import path from 'node:path'
|
|
84
85
|
import { ERROR_CODE, RemoteAgentError, TASK_MODE } from '../protocol.js'
|
|
85
86
|
// 分模式默认超时(提案 #26-1):与 remote_task 工具同源常量。
|
|
@@ -107,6 +108,14 @@ import { envChecks } from '../env-check.js'
|
|
|
107
108
|
// 角色解析**只有一份实现**:v0.9.0 起默认 role 是 'auto',凡是判定
|
|
108
109
|
// "是不是 hub" 的地方都必须过这一层(v0.9.0 审查 H1 的根因就是这里漏了)。
|
|
109
110
|
import { resolveRole } from '../index.js'
|
|
111
|
+
// 一键修复(2026-10-20):只改托管块 + 备份 + 写后校验 + 可撤销。
|
|
112
|
+
// 用户此前说过"不要改 DSH",所以这个模块的每道防线都对应一次真实事故。
|
|
113
|
+
import { applyFixPlan, isManagedBackup, listBackups, planFixes, undoFix } from '../auto-fix.js'
|
|
114
|
+
// 外部工具检测(2026-10-20):探测 Tailscale/PowerShell/git/node 在不在,
|
|
115
|
+
// 缺了给"为什么需要 + 官方链接 + 安装命令",**不代下载**。
|
|
116
|
+
import { probeExternalTools } from '../external-tools.js'
|
|
117
|
+
// 写盘器:自带备份 + 冲突检测 + 写后校验 + 失败回滚(与 setup 向导共用同一份)。
|
|
118
|
+
import { writePatchBlock } from '../setup.js'
|
|
110
119
|
|
|
111
120
|
export const UI_API_PREFIX = '/api/remote-agent/ui'
|
|
112
121
|
|
|
@@ -324,6 +333,21 @@ export function registerUiRoutes({
|
|
|
324
333
|
const disposers = []
|
|
325
334
|
const guard = makeGuard(ctx, log)
|
|
326
335
|
|
|
336
|
+
/**
|
|
337
|
+
* 当前 profile 的 cordis.patch.yml 绝对路径(与 setup-routes 同口径)。
|
|
338
|
+
*
|
|
339
|
+
* 一键修复要改的就是这个文件。判定方式与 `lib/setup-routes.js` 的
|
|
340
|
+
* `currentPatchFile()` **完全一致** —— 两处不一致会让"面板显示改的是 A、
|
|
341
|
+
* 实际写的是 B",是最难排查的一类错。
|
|
342
|
+
*/
|
|
343
|
+
const currentPatchFile = () => {
|
|
344
|
+
const raw = typeof process.env.DSH_PROFILE === 'string' && process.env.DSH_PROFILE.trim() !== ''
|
|
345
|
+
? process.env.DSH_PROFILE.trim()
|
|
346
|
+
: 'desktop'
|
|
347
|
+
const safe = raw.replace(/[^A-Za-z0-9._-]/g, '')
|
|
348
|
+
return path.join(os.homedir(), '.dsh', 'profiles', safe === '' ? 'desktop' : safe, 'cordis.patch.yml')
|
|
349
|
+
}
|
|
350
|
+
|
|
327
351
|
/** 取当前可达性结论:getter 优先,静态快照兜底(兼容老调用方/测试)。 */
|
|
328
352
|
const reachOf = () => {
|
|
329
353
|
if (typeof getReachability === 'function') {
|
|
@@ -788,6 +812,164 @@ export function registerUiRoutes({
|
|
|
788
812
|
writeJson(res, 200, result)
|
|
789
813
|
})
|
|
790
814
|
|
|
815
|
+
// ── 一键修复(2026-10-20,用户要求)──────────────────────────────
|
|
816
|
+
//
|
|
817
|
+
// ⚠⚠ **这会改 `<profile>/cordis.patch.yml`** —— DSH 自己的配置文件。
|
|
818
|
+
// 用户此前明确说过「不要改 DSH」,所以这里的每一道防线都是**踩过的坑**:
|
|
819
|
+
//
|
|
820
|
+
// · 我们手写过 `host: '0.0.0.0'` → DSH 启动崩溃(10 份 crash 日志)
|
|
821
|
+
// · 我们追加过 remote-agent 段 → 被 DSH 下次启动重写冲掉
|
|
822
|
+
//
|
|
823
|
+
// 防线(缺一不可):
|
|
824
|
+
// 1. `POST` 必须带 `confirm: 'yes'` —— 缺了直接 400,没有默认通过
|
|
825
|
+
// 2. `action: 'plan'` 是**只读**的,先让用户看清"将要改什么"
|
|
826
|
+
// 3. 写盘走 `writePatchBlock` —— 它自带备份 + 冲突检测 + 写后校验 + 回滚
|
|
827
|
+
// 4. 只改**托管块**,用户手写的其它条目一个字节不碰
|
|
828
|
+
// 5. 写后本模块再校验一遍(重复 id / 条目缺失 → 提示用「撤销」)
|
|
829
|
+
// 6. `action: 'undo'` 用备份覆盖回去 —— 一键回滚
|
|
830
|
+
//
|
|
831
|
+
// **只修机械可判定的项**(webserver host / role)。端口占用、防火墙、
|
|
832
|
+
// 装软件、模型凭证这些需要人判断的,只给建议不动手。
|
|
833
|
+
route(`${UI_API_PREFIX}/fix`, async (req, res) => {
|
|
834
|
+
if (req.method !== 'POST') return writeJson(res, 405, { error: 'method' })
|
|
835
|
+
const body = await readJson(req)
|
|
836
|
+
const action = typeof body?.action === 'string' ? body.action : ''
|
|
837
|
+
|
|
838
|
+
// ── 只读:生成修复计划 ─────────────────────────────────────────
|
|
839
|
+
if (action === 'plan') {
|
|
840
|
+
try {
|
|
841
|
+
const nodes = registry.list()
|
|
842
|
+
const env = envChecks({
|
|
843
|
+
role: resolveRole(config) || 'hub',
|
|
844
|
+
config,
|
|
845
|
+
reachability: reachOf(),
|
|
846
|
+
nodesOnline: nodes.filter((n) => n.online).length,
|
|
847
|
+
})
|
|
848
|
+
const plan = planFixes({
|
|
849
|
+
envCheck: env,
|
|
850
|
+
patchFile: currentPatchFile(),
|
|
851
|
+
listenHost: typeof config.listenHost === 'string' ? config.listenHost : '',
|
|
852
|
+
})
|
|
853
|
+
// 附上可撤销的备份列表(用户改坏了能回退)
|
|
854
|
+
plan.backups = listBackups(path.dirname(plan.file), 5)
|
|
855
|
+
return writeJson(res, 200, { ok: true, plan })
|
|
856
|
+
} catch (err) {
|
|
857
|
+
return writeJson(res, 200, { ok: false, error: shortMessage(err) })
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// ── 写盘:应用修复 ─────────────────────────────────────────────
|
|
862
|
+
if (action === 'apply') {
|
|
863
|
+
// ⚠ 二次确认是**硬要求**:这是唯一会改用户 DSH 配置的动作
|
|
864
|
+
if (body?.confirm !== 'yes') {
|
|
865
|
+
return writeJson(res, 400, {
|
|
866
|
+
error: 'confirm-required',
|
|
867
|
+
message: '一键修复会改 profile 的 cordis.patch.yml(DSH 自己的配置文件)。'
|
|
868
|
+
+ '请先在面板上确认"将要改什么",然后带 confirm: "yes" 重试。',
|
|
869
|
+
})
|
|
870
|
+
}
|
|
871
|
+
try {
|
|
872
|
+
const nodes = registry.list()
|
|
873
|
+
const env = envChecks({
|
|
874
|
+
role: resolveRole(config) || 'hub',
|
|
875
|
+
config,
|
|
876
|
+
reachability: reachOf(),
|
|
877
|
+
nodesOnline: nodes.filter((n) => n.online).length,
|
|
878
|
+
})
|
|
879
|
+
const plan = planFixes({
|
|
880
|
+
envCheck: env,
|
|
881
|
+
patchFile: currentPatchFile(),
|
|
882
|
+
listenHost: typeof config.listenHost === 'string' ? config.listenHost : '',
|
|
883
|
+
})
|
|
884
|
+
const role = resolveRole(config) || 'hub'
|
|
885
|
+
const result = applyFixPlan({
|
|
886
|
+
plan,
|
|
887
|
+
writePatchBlock,
|
|
888
|
+
readFile: (f) => fs.readFileSync(f, 'utf8'),
|
|
889
|
+
role,
|
|
890
|
+
})
|
|
891
|
+
if (result.ok === true) {
|
|
892
|
+
log('info', 'auto-fix applied', {
|
|
893
|
+
file: result.file,
|
|
894
|
+
backup: result.backup,
|
|
895
|
+
applied: result.applied,
|
|
896
|
+
})
|
|
897
|
+
} else {
|
|
898
|
+
log('warn', 'auto-fix failed', { file: result.file, error: result.error })
|
|
899
|
+
}
|
|
900
|
+
return writeJson(res, 200, {
|
|
901
|
+
ok: result.ok === true,
|
|
902
|
+
file: result.file,
|
|
903
|
+
backup: result.backup,
|
|
904
|
+
applied: result.applied,
|
|
905
|
+
error: result.error || null,
|
|
906
|
+
// 改完必须重启才生效(配置在启动时读取)
|
|
907
|
+
note: result.ok === true ? '已写入。**重启 DSH 后生效**。' : null,
|
|
908
|
+
})
|
|
909
|
+
} catch (err) {
|
|
910
|
+
return writeJson(res, 200, { ok: false, error: shortMessage(err) })
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// ── 撤销:用备份覆盖回去 ───────────────────────────────────────
|
|
915
|
+
if (action === 'undo') {
|
|
916
|
+
if (body?.confirm !== 'yes') {
|
|
917
|
+
return writeJson(res, 400, { error: 'confirm-required', message: '撤销会覆盖当前配置文件,请带 confirm: "yes" 重试。' })
|
|
918
|
+
}
|
|
919
|
+
const backup = typeof body?.backup === 'string' ? body.backup : ''
|
|
920
|
+
const file = currentPatchFile()
|
|
921
|
+
// 安全:备份必须在我们管理的命名空间里(防任意文件覆盖)
|
|
922
|
+
if (!isManagedBackup(path.basename(backup))) {
|
|
923
|
+
return writeJson(res, 400, { error: 'bad-backup', message: '备份文件名不符合预期(应为 cordis.patch.yml.bak-*)' })
|
|
924
|
+
}
|
|
925
|
+
// 且必须与目标文件同目录(防路径穿越到别处)
|
|
926
|
+
if (path.dirname(path.resolve(backup)) !== path.dirname(path.resolve(file))) {
|
|
927
|
+
return writeJson(res, 400, { error: 'bad-backup', message: '备份必须与配置文件在同一目录' })
|
|
928
|
+
}
|
|
929
|
+
const result = undoFix({ file, backup })
|
|
930
|
+
if (result.ok === true) {
|
|
931
|
+
log('info', 'auto-fix undone', { file, backup })
|
|
932
|
+
}
|
|
933
|
+
return writeJson(res, 200, { ok: result.ok === true, error: result.error || null })
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
return writeJson(res, 400, { error: 'bad-action', message: 'action 必须是 plan / apply / undo' })
|
|
937
|
+
})
|
|
938
|
+
|
|
939
|
+
// ── 外部工具检测(2026-10-20)──────────────────────────────────
|
|
940
|
+
//
|
|
941
|
+
// 用户要求"自动装依赖",我们把范围收窄到**外部工具**,且**不代下载**:
|
|
942
|
+
// · 零 npm 依赖是核心卖点 —— 代装 npm 包会自毁卖点
|
|
943
|
+
// · 代下载第三方二进制会让我们变成软件分发方(版本/来源/校验/责任/
|
|
944
|
+
// 供应链攻击面全落在我们头上)
|
|
945
|
+
//
|
|
946
|
+
// 所以这里只做三件事:探测在不在、说清缺了会怎样、给官方链接与安装命令。
|
|
947
|
+
route(`${UI_API_PREFIX}/tools`, (req, res) => {
|
|
948
|
+
if (req.method !== 'GET') return writeJson(res, 405, { error: 'method' })
|
|
949
|
+
try {
|
|
950
|
+
const r = probeExternalTools()
|
|
951
|
+
writeJson(res, 200, {
|
|
952
|
+
ok: true,
|
|
953
|
+
platform: r.platform,
|
|
954
|
+
tools: r.tools.map((t) => ({
|
|
955
|
+
id: typeof t.id === 'string' ? t.id : '',
|
|
956
|
+
name: typeof t.name === 'string' ? t.name : '',
|
|
957
|
+
installed: t.installed === true,
|
|
958
|
+
version: typeof t.version === 'string' ? t.version : '',
|
|
959
|
+
required: t.required === true,
|
|
960
|
+
why: typeof t.why === 'string' ? t.why : '',
|
|
961
|
+
install: typeof t.install === 'string' ? t.install : '',
|
|
962
|
+
url: typeof t.url === 'string' ? t.url : '',
|
|
963
|
+
})),
|
|
964
|
+
missing: r.missing.map((t) => t.id),
|
|
965
|
+
requiredMissing: r.requiredMissing.map((t) => t.id),
|
|
966
|
+
})
|
|
967
|
+
} catch (err) {
|
|
968
|
+
log('warn', 'tool probe failed', { error: shortMessage(err) })
|
|
969
|
+
writeJson(res, 200, { ok: false, error: shortMessage(err), tools: [], missing: [], requiredMissing: [] })
|
|
970
|
+
}
|
|
971
|
+
})
|
|
972
|
+
|
|
791
973
|
// ── 工作区 SMB 共享(2026-10-17)──────────────────────────────
|
|
792
974
|
//
|
|
793
975
|
// 用户需求:想用本地 IDE 直接打开副机的项目目录 —— 唯一现实路径是把工作区
|