dsh-mesh-node 0.0.2 → 0.0.4

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": "dsh-mesh-node",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Mesh server for dsh-mesh: expose a machine to peer DSH nodes over MCP/HTTPS, with delegation and optional desktop-control tools.",
5
5
  "type": "module",
6
6
  "private": false,
package/src/cli.js CHANGED
@@ -521,23 +521,66 @@ async function main(argv) {
521
521
 
522
522
  if (cmd === 'token') {
523
523
  const sub = argv[1] || 'list'
524
- const { addInboundToken, loadSecrets } = await import('./core/config.js')
524
+ const { addInboundToken, loadSecrets, setTokenRole, migrateTokensToRoles } = await import('./core/config.js')
525
+ const { ROLE_NAMES, isRole, describeRole } = await import('./core/roles.js')
525
526
  if (sub === 'list') {
526
527
  const secrets = loadSecrets(home)
527
- const rows = (secrets.inbound || []).map((entry) => ({ id: entry.id, note: entry.note || '', acl: entry.acl || null }))
528
+ const rows = (secrets.inbound || []).map((entry) => ({
529
+ id: entry.id,
530
+ note: entry.note || '',
531
+ role: entry.role || null,
532
+ roleHint: entry.role ? describeRole(entry.role) : (entry.acl ? '显式 ACL(legacy)' : '继承节点 ACL'),
533
+ acl: entry.acl || null
534
+ }))
528
535
  console.log(JSON.stringify(rows, null, 2))
529
536
  return 0
530
537
  }
538
+ if (sub === 'set-role') {
539
+ const id = argv[2]
540
+ const role = argValue(argv, '--role', '')
541
+ const yes = argv.includes('--yes')
542
+ if (!id || !isRole(role)) {
543
+ console.error(`usage: node src/cli.js token set-role <id> --role <${ROLE_NAMES.join('|')}> [--yes]`)
544
+ return 1
545
+ }
546
+ if (!yes) {
547
+ console.log(JSON.stringify({ dryRun: true, id, role, description: describeRole(role) }, null, 2))
548
+ console.log('加 --yes 才写入 secrets.json(热重载,无需重启)')
549
+ return 0
550
+ }
551
+ const r = setTokenRole({ home, id, role })
552
+ console.log(JSON.stringify({ ...r, description: describeRole(role) }, null, 2))
553
+ return 0
554
+ }
555
+ if (sub === 'migrate-roles') {
556
+ const yes = argv.includes('--yes')
557
+ const r = migrateTokensToRoles({ home, dryRun: !yes })
558
+ console.log(JSON.stringify(r, null, 2))
559
+ if (!yes) console.log('加 --yes 才真正迁移(迁移前自动备份 secrets.json)')
560
+ return 0
561
+ }
531
562
  if (sub === 'add') {
532
563
  const id = argValue(argv, '--id', undefined)
533
564
  const note = argValue(argv, '--note', 'narrow token')
534
565
  const allowRaw = argValue(argv, '--allow', '')
535
566
  const fsRootsRaw = argValue(argv, '--fs-roots', '')
567
+ const roleRaw = argValue(argv, '--role', '')
536
568
  const yes = argv.includes('--yes')
569
+ if (roleRaw && !isRole(roleRaw)) {
570
+ console.error(`unknown role: ${roleRaw}(可选:${ROLE_NAMES.join(', ')})`)
571
+ return 1
572
+ }
537
573
  const acl = {}
538
- if (allowRaw) acl.allow = allowRaw.split(',').map((s) => s.trim()).filter(Boolean)
574
+ if (!roleRaw && allowRaw) acl.allow = allowRaw.split(',').map((s) => s.trim()).filter(Boolean)
575
+ if (roleRaw && allowRaw) console.error('⚠️ 同时给了 --role 和 --allow:以角色能力包为准,--allow 被忽略')
539
576
  if (fsRootsRaw) acl.fsRoots = fsRootsRaw.split(';').map((s) => s.trim()).filter(Boolean)
540
- const plan = { id: id || '(auto: token-<random>)', note, acl: Object.keys(acl).length ? acl : '(inherit node ACL)' }
577
+ const plan = {
578
+ id: id || '(auto: token-<random>)',
579
+ note,
580
+ role: roleRaw || null,
581
+ roleDescription: roleRaw ? describeRole(roleRaw) : undefined,
582
+ acl: Object.keys(acl).length ? acl : '(inherit node ACL)'
583
+ }
541
584
  if (!yes) {
542
585
  console.log('(dry-run)将新增 inbound token:')
543
586
  console.log(JSON.stringify(plan, null, 2))
@@ -545,12 +588,12 @@ async function main(argv) {
545
588
  return 0
546
589
  }
547
590
  const finalId = id || `token-${Math.random().toString(16).slice(2, 10)}`
548
- const created = addInboundToken({ home, id: finalId, note, acl: Object.keys(acl).length ? acl : undefined })
549
- console.log(JSON.stringify({ id: created.id, token: created.token, acl: plan.acl, note }, null, 2))
591
+ const created = addInboundToken({ home, id: finalId, note, role: roleRaw || undefined, acl: Object.keys(acl).length ? acl : undefined })
592
+ console.log(JSON.stringify({ id: created.id, role: created.role, token: created.token, acl: plan.acl, note }, null, 2))
550
593
  console.log('⚠️ token 只显示这一次;只给对端配置用,禁止写进 git / 群聊 / 会分享的文档')
551
594
  return 0
552
595
  }
553
- console.error('usage: node src/cli.js token list | token add [--id ID] [--allow tool1,tool2] [--fs-roots "C:\\a;C:\\b"] [--note TEXT] [--yes]')
596
+ console.error('usage: node src/cli.js token list | token add [--role observer|controller|node|admin] [--allow a,b] [--fs-roots "C:\\a;C:\\b"] [--note TEXT] [--yes] | token set-role <id> --role <role> [--yes] | token migrate-roles [--yes]')
554
597
  return 1
555
598
  }
556
599
 
@@ -627,15 +670,22 @@ async function main(argv) {
627
670
  : `${scheme}://${host}:${port}${config.node.path || '/mcp'}`
628
671
  if (argv.includes('--code')) {
629
672
  const { createPairingCode } = await import('./core/pairing.js')
673
+ const { isRole, ROLE_NAMES } = await import('./core/roles.js')
630
674
  const { describeCert } = await import('./core/tls.js')
631
675
  const rawTtl = Number(argValue(argv, '--ttl', String(config.pairing?.codeTtlSec || 1800)))
632
676
  const ttlSec = Number.isFinite(rawTtl) ? Math.min(86400, Math.max(60, Math.round(rawTtl))) : 1800
633
- const created = createPairingCode({ home, ttlSec })
677
+ const role = argValue(argv, '--role', 'node')
678
+ if (!isRole(role)) {
679
+ console.error(`unknown role: ${role}(可选:${ROLE_NAMES.join(', ')})`)
680
+ return 1
681
+ }
682
+ const created = createPairingCode({ home, ttlSec, role })
634
683
  const cert = scheme === 'https' ? describeCert(home) : null
635
684
  console.log(
636
685
  JSON.stringify(
637
686
  {
638
687
  pairingCode: created.code,
688
+ role: created.role,
639
689
  expiresAt: created.expiresAt,
640
690
  ttlSec,
641
691
  ttlHint: `码 ${Math.round(ttlSec / 60)} 分钟内有效、一次性;想留更久用 --ttl 86400(最长 24 h)`,
@@ -644,6 +694,7 @@ async function main(argv) {
644
694
  certFingerprint: cert?.fingerprint256 ?? null,
645
695
  nextSteps: [
646
696
  `on the peer machine: node src/cli.js peer add --url ${endpoint.replace(/\/mcp$/, '')} --code ${created.code} --name ${config.node.name}`,
697
+ `role: ${created.role}(对端兑换后获得这个角色;不用再逐工具授权)`,
647
698
  scheme === 'https'
648
699
  ? `pass --fingerprint ${describeCert(home).fingerprint256} (or --ca <this node cert.pem>)`
649
700
  : undefined
@@ -1,8 +1,9 @@
1
1
  import { homedir } from 'node:os'
2
2
  import { join, resolve, sep, isAbsolute } from 'node:path'
3
- import { mkdirSync, readFileSync, writeFileSync, existsSync, realpathSync, statSync, renameSync } from 'node:fs'
3
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, realpathSync, statSync, renameSync, copyFileSync } from 'node:fs'
4
4
  import { randomBytes, timingSafeEqual } from 'node:crypto'
5
5
  import { readJsonFile } from './util.js'
6
+ import { roleAllow, isRole, inferRole } from './roles.js'
6
7
 
7
8
  export const MESH_VERSION = '0.17.0'
8
9
  export const CONFIG_VERSION = 7
@@ -514,10 +515,22 @@ export function rotateToken({ home = meshHome(), id = 'default', note } = {}) {
514
515
  return { id, token }
515
516
  }
516
517
 
517
- /** Per-token ACL; `null` means "inherit the node-wide acl". */
518
+ /** Per-token ACL;`null` 表示“继承节点 ACL”。
519
+ * - token 带 role:允许集合由角色能力包决定(node/admin 继承节点 ACL)
520
+ * - token 不带 role:沿用旧的显式 acl.allow(向后兼容)
521
+ */
518
522
  export function peerAclForToken(secrets, tokenId) {
519
523
  const entry = (secrets?.inbound || []).find((item) => item?.id === tokenId)
520
524
  const acl = entry?.acl
525
+ if (entry?.role && isRole(entry.role)) {
526
+ const allowed = roleAllow(entry.role)
527
+ return {
528
+ role: entry.role,
529
+ allow: allowed || undefined,
530
+ deny: Array.isArray(acl?.deny) ? acl.deny : [],
531
+ fsRoots: Array.isArray(acl?.fsRoots) ? acl.fsRoots : undefined
532
+ }
533
+ }
521
534
  if (!acl || typeof acl !== 'object') return null
522
535
  return {
523
536
  allow: Array.isArray(acl.allow) ? acl.allow : undefined,
@@ -528,18 +541,87 @@ export function peerAclForToken(secrets, tokenId) {
528
541
 
529
542
  /**
530
543
  * Add an inbound token (used by pairing and by `peer` bookkeeping).
531
- * `acl` is optional: when given the token may only call that subset of tools.
544
+ * `role` 是推荐方式(observer/controller/node/admin);`acl` 保留给旧的显式写法。
545
+ * 带 role 时 acl.allow 被忽略(角色决定允许集合),只保留 deny / fsRoots。
532
546
  */
533
- export function addInboundToken({ home = meshHome(), id, note, acl, token } = {}) {
547
+ export function addInboundToken({ home = meshHome(), id, note, acl, role, token } = {}) {
534
548
  if (!id) throw new Error('addInboundToken requires an id')
549
+ if (role !== undefined && role !== null && !isRole(role)) throw new Error(`unknown role: ${role}`)
535
550
  const secrets = loadSecrets(home)
536
551
  const existing = (secrets.inbound || []).find((entry) => entry.id === id)
537
552
  if (existing) throw new Error(`token id already exists: ${id}`)
538
553
  const entry = { id, token: token || newToken(), note: note || 'paired' }
539
- if (acl && typeof acl === 'object') entry.acl = acl
554
+ if (role) entry.role = role
555
+ if (acl && typeof acl === 'object') {
556
+ if (role) {
557
+ const narrowed = { ...acl }
558
+ delete narrowed.allow
559
+ if (Object.keys(narrowed).length > 0) entry.acl = narrowed
560
+ } else {
561
+ entry.acl = acl
562
+ }
563
+ }
540
564
  secrets.inbound = [...(secrets.inbound || []), entry]
541
565
  saveSecrets(secrets, home)
542
- return { id: entry.id, token: entry.token }
566
+ return { id: entry.id, token: entry.token, role: entry.role || null }
567
+ }
568
+
569
+ /** 改一个入向 token 的角色(用户/管理员决策;会移除旧的显式 allow,保留 deny/fsRoots)。 */
570
+ export function setTokenRole({ home = meshHome(), id, role } = {}) {
571
+ if (!id) throw new Error('setTokenRole requires an id')
572
+ if (!isRole(role)) throw new Error(`unknown role: ${role}`)
573
+ const secrets = loadSecrets(home)
574
+ const entry = (secrets.inbound || []).find((item) => item?.id === id)
575
+ if (!entry) throw new Error(`token id not found: ${id}`)
576
+ const before = entry.role || null
577
+ entry.role = role
578
+ if (entry.acl && typeof entry.acl === 'object') {
579
+ const narrowed = { ...entry.acl }
580
+ delete narrowed.allow
581
+ if (Object.keys(narrowed).length > 0) entry.acl = narrowed
582
+ else delete entry.acl
583
+ }
584
+ saveSecrets(secrets, home)
585
+ return { id, before, role }
586
+ }
587
+
588
+ /**
589
+ * 旧 token 自动迁移到角色(用户无需逐工具操作)。
590
+ * 规则:observer/controller 子集 → 对应角色;显式 ACL 恰好等于节点 ACL → node;
591
+ * 其余自定义/奇怪组合保留显式 ACL 并报告(不擅自扩权)。
592
+ */
593
+ export function migrateTokensToRoles({ home = meshHome(), dryRun = false } = {}) {
594
+ const secrets = loadSecrets(home)
595
+ const nodeAllow = (loadConfig(home).acl?.allow || [])
596
+ const changes = []
597
+ let mutated = false
598
+ for (const entry of secrets.inbound || []) {
599
+ if (entry.role && isRole(entry.role)) continue
600
+ const allow = Array.isArray(entry.acl?.allow) ? entry.acl.allow : null
601
+ const role = inferRole(allow, nodeAllow)
602
+ if (!role) {
603
+ changes.push({ id: entry.id, role: null, skipped: true, fromTools: allow?.length ?? null })
604
+ continue
605
+ }
606
+ changes.push({ id: entry.id, role, fromTools: allow?.length ?? null })
607
+ if (!dryRun) {
608
+ entry.role = role
609
+ if (entry.acl && typeof entry.acl === 'object') {
610
+ const narrowed = { ...entry.acl }
611
+ delete narrowed.allow
612
+ if (Object.keys(narrowed).length > 0) entry.acl = narrowed
613
+ else delete entry.acl
614
+ }
615
+ mutated = true
616
+ }
617
+ }
618
+ let backup = null
619
+ if (!dryRun && mutated) {
620
+ backup = `${secretsPath(home)}.bak-roles-${new Date().toISOString().replace(/[:.]/g, '-')}`
621
+ copyFileSync(secretsPath(home), backup)
622
+ saveSecrets(secrets, home)
623
+ }
624
+ return { dryRun, backup, changes }
543
625
  }
544
626
 
545
627
  /** Delete an inbound token; the holder's next request fails with 401. */
@@ -0,0 +1,57 @@
1
+ // @generated hardware:本机硬件概况(CPU / 内存 / 磁盘 / OS),供 mesh_self、host_self 与 UI 使用
2
+ // [why] 2026-11-09 用户反馈 AI 网络面板很多设备没有内存/系统版本/磁盘信息:
3
+ // 之前只有 survey 快照采集过本机硬件,远端节点协议里没有这些字段。
4
+ // 现在统一由各节点 mesh server 在 host_self / mesh_self 里上报,UI 即可展示全部设备。
5
+ import { arch, cpus, freemem, hostname, platform, release, totalmem, uptime } from 'node:os'
6
+ import { statfsSync } from 'node:fs'
7
+
8
+ const GiB = 1024 ** 3
9
+ const round1 = (n) => Math.round(Number(n) / GiB * 10) / 10
10
+
11
+ function osLabel() {
12
+ const p = platform()
13
+ const r = String(release() || '')
14
+ if (p === 'win32') {
15
+ const build = Number(String(r).split('.')[2] || 0)
16
+ const name = build >= 22000 ? 'Windows 11' : 'Windows 10'
17
+ return `${name} (${r})`
18
+ }
19
+ if (p === 'darwin') return 'macOS ' + r
20
+ if (p === 'linux') return 'Linux ' + r
21
+ return `${p} ${r}`.trim()
22
+ }
23
+
24
+ /** 系统盘 / 根分区容量(Windows 取 SystemDrive,其他取 /)。失败时 disk=null。 */
25
+ function diskInfo() {
26
+ const path = process.platform === 'win32' ? `${process.env.SystemDrive || 'C:'}\\` : '/'
27
+ try {
28
+ const st = statfsSync(path)
29
+ const blockSize = Number(st.bsize || 4096)
30
+ return {
31
+ path,
32
+ totalGB: round1(Number(st.blocks) * blockSize),
33
+ freeGB: round1(Number(st.bavail) * blockSize)
34
+ }
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /** 稳定、跨平台的硬件概况(纯只读,不含任何个人/凭据数据)。 */
41
+ export function hardwareInfo() {
42
+ const list = cpus()
43
+ const first = list[0] || {}
44
+ return {
45
+ hostname: hostname(),
46
+ platform: platform(),
47
+ release: release(),
48
+ arch: arch(),
49
+ os: osLabel(),
50
+ cpuModel: String(first.model || '').trim(),
51
+ cpuCores: list.length,
52
+ memTotalGB: round1(totalmem()),
53
+ memFreeGB: round1(freemem()),
54
+ disk: diskInfo(),
55
+ uptimeSec: Math.round(uptime())
56
+ }
57
+ }
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomInt, timingSafeEqual } from 'node:crypto'
2
2
  import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
3
3
  import { meshHome, pairingPath, peersPath, ensureHome } from './config.js'
4
+ import { isRole } from './roles.js'
4
5
 
5
6
  /**
6
7
  * One-time pairing codes.
@@ -60,8 +61,9 @@ function readJsonSafe(file) {
60
61
  * Create (and persist) a one-time code. Only one code is valid at a time:
61
62
  * creating a new one invalidates the previous one.
62
63
  */
63
- export function createPairingCode({ home = meshHome(), ttlSec = 300, note } = {}) {
64
+ export function createPairingCode({ home = meshHome(), ttlSec = 300, note, role = 'node' } = {}) {
64
65
  ensureHome(home)
66
+ if (!isRole(role)) throw new Error(`unknown role: ${role}`)
65
67
  const compact = randomCode()
66
68
  const now = Date.now()
67
69
  const record = {
@@ -71,10 +73,11 @@ export function createPairingCode({ home = meshHome(), ttlSec = 300, note } = {}
71
73
  expiresAt: new Date(now + Math.max(30, ttlSec) * 1000).toISOString(),
72
74
  attempts: 0,
73
75
  used: false,
74
- note: note || undefined
76
+ note: note || undefined,
77
+ role
75
78
  }
76
79
  writeJson0600(pairingPath(home), record)
77
- return { code: formatCode(compact), expiresAt: record.expiresAt, ttlSec }
80
+ return { code: formatCode(compact), expiresAt: record.expiresAt, ttlSec, role }
78
81
  }
79
82
 
80
83
  export function readPairingState(home = meshHome()) {
@@ -86,7 +89,8 @@ export function readPairingState(home = meshHome()) {
86
89
  attempts: record.attempts || 0,
87
90
  used: record.used === true,
88
91
  expired: new Date(record.expiresAt).getTime() <= Date.now(),
89
- note: record.note
92
+ note: record.note,
93
+ role: record.role || 'node'
90
94
  }
91
95
  }
92
96
 
@@ -124,7 +128,7 @@ export function claimPairingCode({ home = meshHome(), code, maxAttempts = 5 } =
124
128
  record.used = true
125
129
  record.usedAt = new Date().toISOString()
126
130
  writeJson0600(pairingPath(home), record)
127
- return { ok: true }
131
+ return { ok: true, role: record.role || 'node' }
128
132
  }
129
133
 
130
134
  /** Peer bookkeeping on the *client* side: who we can talk to, and with which token. */
@@ -0,0 +1,107 @@
1
+ // @generated roles:mesh 入向 token 的角色 / 能力包(替代逐工具 ACL)
2
+ // [why] 2026-11-09 事故:手机 controller token 被手工收窄成 5 个工具,导致 9181 次
3
+ // E_DENIED、委托链路瘫痪半天,最后要用户手工改 secrets.json 才恢复。
4
+ // 角色制:入网时选一次设备角色(observer/controller/node/admin),能力包随代码升级;
5
+ // 旧的逐工具 token 可自动推断迁移,用户不再需要逐工具授权。
6
+ //
7
+ // 原则:
8
+ // - 角色只做“收窄”,永远不会超过节点 ACL(applyPeerAcl 做交集)
9
+ // - controller = 协调者:能看状态 + 能委托 + 能操作桌面,但**不能**跨机写/重启/改配置
10
+ // - node = 继承节点 ACL(现有 PC 节点行为不变)
11
+ // - admin 预留:目前与 node 等价,真正提权仍需改节点 ACL(用户决策)
12
+ // 注意:本文件不 import config.js(config.js 会 import 本文件,避免循环依赖)。
13
+
14
+ export const ROLES_VERSION = 1
15
+
16
+ const OBSERVER = [
17
+ 'mesh_self',
18
+ 'mesh_overview',
19
+ 'mesh_alert',
20
+ 'mesh_audit_tail',
21
+ 'cluster_status',
22
+ 'fs_read',
23
+ 'fs_list',
24
+ 'fs_stat',
25
+ 'fs_stats',
26
+ 'fs_grep',
27
+ 'git_status',
28
+ 'git_log',
29
+ 'git_diff',
30
+ 'repo_scan',
31
+ 'task_status',
32
+ 'task_list',
33
+ 'session_history',
34
+ 'host_instances',
35
+ 'host_self',
36
+ 'host_tools',
37
+ 'host_sessions',
38
+ 'host_logs',
39
+ 'host_agents',
40
+ 'channel_targets',
41
+ 'channel_sessions',
42
+ 'channel_page',
43
+ 'delegate_list',
44
+ 'delegate_status',
45
+ 'delegate_candidates',
46
+ 'repair_status'
47
+ ]
48
+
49
+ const CONTROLLER = [
50
+ ...OBSERVER,
51
+ 'delegate',
52
+ 'delegate_reply',
53
+ 'delegate_gc',
54
+ 'pc_action',
55
+ 'pc_app',
56
+ 'pc_window',
57
+ 'pc_browser'
58
+ ]
59
+
60
+ /** @type {Record<string, { allow: string[] | null, description: string }>} */
61
+ export const ROLE_PRESETS = {
62
+ observer: { allow: OBSERVER, description: '只读观察:状态 / 文件 / git / 日志 / 委托列表' },
63
+ controller: { allow: CONTROLLER, description: '协调者:观察 + 委托 + 桌面控制(不能跨机写/重启/改配置)' },
64
+ node: { allow: null, description: '节点:继承本机 ACL(与旧行为一致)' },
65
+ admin: { allow: null, description: '管理员:预留,当前等同 node(真正提权需改节点 ACL)' }
66
+ }
67
+
68
+ export const ROLE_NAMES = Object.keys(ROLE_PRESETS)
69
+
70
+ export function isRole(name) {
71
+ return ROLE_NAMES.includes(String(name || ''))
72
+ }
73
+
74
+ /** 角色允许的工具;null 表示“继承节点 ACL”。 */
75
+ export function roleAllow(role) {
76
+ const preset = ROLE_PRESETS[role]
77
+ return preset ? preset.allow : null
78
+ }
79
+
80
+ /** 最小覆盖给定工具集合的角色(用于旧 token 自动迁移)。
81
+ * - observer / controller:显式 allow 是角色能力包子集 → 升级到该角色
82
+ * - node:显式 allow 恰好等于节点 ACL → node(等价继承)
83
+ * - 其他(自定义、或比角色包更宽/更窄的奇怪组合)→ null,保留显式 ACL,由用户决定
84
+ * @param {string[] | null | undefined} allowList
85
+ * @param {string[]} [nodeAllow]
86
+ */
87
+ export function inferRole(allowList, nodeAllow = null) {
88
+ const allow = Array.isArray(allowList) ? allowList : null
89
+ if (!allow) return 'node'
90
+ const covers = (role) => {
91
+ const preset = ROLE_PRESETS[role]
92
+ if (!preset || !preset.allow) return false
93
+ return allow.every((name) => preset.allow.includes(name))
94
+ }
95
+ if (covers('observer')) return 'observer'
96
+ if (covers('controller')) return 'controller'
97
+ if (Array.isArray(nodeAllow) && nodeAllow.length > 0) {
98
+ const a = new Set(allow)
99
+ const b = new Set(nodeAllow)
100
+ if (a.size === b.size && [...a].every((name) => b.has(name))) return 'node'
101
+ }
102
+ return null // 自定义 / 无法安全推断 → 保留显式 ACL
103
+ }
104
+
105
+ export function describeRole(role) {
106
+ return ROLE_PRESETS[role]?.description || '自定义 ACL'
107
+ }
package/src/selftest.js CHANGED
@@ -4,8 +4,9 @@ import { tmpdir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { Client } from '@modelcontextprotocol/client'
6
6
  import { StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
7
- import { loadSecrets, defaultConfig, configPath, loadConfig, DEFAULT_ALLOW, aclDrift } from './core/config.js'
7
+ import { loadSecrets, defaultConfig, configPath, loadConfig, DEFAULT_ALLOW, aclDrift, peerAclForToken } from './core/config.js'
8
8
  import { describeAcl, resolveAclUpdate } from './core/acl.js'
9
+ import { roleAllow, inferRole } from './core/roles.js'
9
10
  import { applyPatchInsert, assertInside, normalizePluginFiles, patchHasPlugin, pluginRoot } from './core/repair.js'
10
11
  import { hostTokenPath, resolveHostInstance } from './core/host-agent.js'
11
12
  import { buildAll, isAllowed } from './tools/index.js'
@@ -849,6 +850,40 @@ export async function runSelftest({ log = console } = {}) {
849
850
  assert(!isAllowed(config, 'host_session_archive'), 'host_session_archive must be denied by default')
850
851
  })
851
852
 
853
+ await check('roles: controller can delegate but not cross-machine writes/restarts', () => {
854
+ const allow = roleAllow('controller')
855
+ assert(allow.includes('delegate') && allow.includes('delegate_status'), 'controller must be able to delegate')
856
+ assert(allow.includes('pc_action') && allow.includes('fs_read'), 'controller must keep pc_* + read tools')
857
+ for (const dangerous of ['host_call', 'service_restart', 'mesh_config_patch', 'fs_write', 'shell_run']) {
858
+ assert(!allow.includes(dangerous), `controller must not include ${dangerous}`)
859
+ }
860
+ })
861
+
862
+ await check('roles: observer is read-only and cannot delegate or control', () => {
863
+ const allow = roleAllow('observer')
864
+ assert(allow.includes('fs_read') && allow.includes('cluster_status'), 'observer should read')
865
+ for (const notAllowed of ['delegate', 'pc_action', 'host_call', 'service_restart']) {
866
+ assert(!allow.includes(notAllowed), `observer must not include ${notAllowed}`)
867
+ }
868
+ })
869
+
870
+ await check('roles: inferRole maps legacy allow-lists safely', () => {
871
+ assert(inferRole(['mesh_self', 'fs_read']) === 'observer', 'read-only allow → observer')
872
+ assert(inferRole(['mesh_self', 'pc_action', 'delegate']) === 'controller', 'pc_*+delegate allow → controller')
873
+ assert(inferRole(DEFAULT_ALLOW, DEFAULT_ALLOW) === 'node', 'exact node ACL → node')
874
+ assert(inferRole(['mesh_self', 'shell_run'], DEFAULT_ALLOW) === null, 'unmappable custom ACL stays explicit')
875
+ })
876
+
877
+ await check('roles: peerAclForToken narrows by role and inherits for node', () => {
878
+ const controller = peerAclForToken({ inbound: [{ id: 'c', role: 'controller' }] }, 'c')
879
+ assert(controller && controller.role === 'controller', 'role should be reported')
880
+ assert(controller.allow.includes('delegate') && !controller.allow.includes('host_call'), 'controller allow-list applied')
881
+ const node = peerAclForToken({ inbound: [{ id: 'n', role: 'node' }] }, 'n')
882
+ assert(node && node.allow === undefined, 'node role inherits the node ACL')
883
+ const legacy = peerAclForToken({ inbound: [{ id: 'l', acl: { allow: ['mesh_self'] } }] }, 'l')
884
+ assert(legacy.allow.length === 1 && legacy.allow[0] === 'mesh_self', 'legacy explicit ACL untouched')
885
+ })
886
+
852
887
  const failed = results.filter((r) => !r.ok)
853
888
  console.log(`\nselftest: ${results.length - failed.length}/${results.length} passed`)
854
889
  if (failed.length > 0) {
package/src/server.js CHANGED
@@ -224,6 +224,7 @@ export async function startMeshServer({ config, secrets, log = console, port, bi
224
224
  home: meshHomeDir,
225
225
  id: baseId,
226
226
  note: `paired ${new Date().toISOString()}`,
227
+ role: claim.role,
227
228
  acl: body?.acl
228
229
  })
229
230
  } catch {
@@ -231,6 +232,7 @@ export async function startMeshServer({ config, secrets, log = console, port, bi
231
232
  home: meshHomeDir,
232
233
  id: `${baseId}-${Math.random().toString(36).slice(2, 6)}`,
233
234
  note: `paired ${new Date().toISOString()}`,
235
+ role: claim.role,
234
236
  acl: body?.acl
235
237
  })
236
238
  }
@@ -242,6 +244,7 @@ export async function startMeshServer({ config, secrets, log = console, port, bi
242
244
  ok: true,
243
245
  token: created.token,
244
246
  tokenId: created.id,
247
+ role: created.role || null,
245
248
  node: liveConfig.node?.name || 'local',
246
249
  version: MESH_VERSION,
247
250
  url: `${scheme}://${listenBind}:${actualPort}${mcpPath}`,
package/src/tools/core.js CHANGED
@@ -3,6 +3,7 @@ import { basename } from 'node:path'
3
3
  import { z } from 'zod'
4
4
  import { MESH_VERSION, configPath } from '../core/config.js'
5
5
  import { describeCert } from '../core/tls.js'
6
+ import { hardwareInfo } from '../core/hardware.js'
6
7
  import { auditPath, auditSummary } from '../core/audit.js'
7
8
  import { ok } from '../core/result.js'
8
9
 
@@ -30,6 +31,7 @@ export function buildCore(ctx) {
30
31
  configPath: configPath(ctx.home),
31
32
  auditPath: auditPath(ctx.home)
32
33
  },
34
+ hardware: hardwareInfo(),
33
35
  host: {
34
36
  hostname: hostname(),
35
37
  platform: platform(),
package/src/tools/host.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod'
2
+ import { hardwareInfo } from '../core/hardware.js'
2
3
  import { createHostClient, hostInstances, hostTokenPath, HostError } from '../core/host-agent.js'
3
4
  import { buildSessionProfile, classifySession, sessionBlock } from '../core/session-profile.js'
4
5
  import { fail, ok } from '../core/result.js'
@@ -91,7 +92,8 @@ export function buildHost(ctx) {
91
92
  inputSchema: z.object({ instance: instanceSchema }),
92
93
  handler: async ({ instance }) => {
93
94
  try {
94
- return ok(await hostClient(ctx, instance).self())
95
+ const data = await hostClient(ctx, instance).self()
96
+ return ok({ ...data, hardware: hardwareInfo() })
95
97
  } catch (error) {
96
98
  return hostError(error)
97
99
  }