dsh-mesh-node 0.0.3 → 0.0.5
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 +1 -1
- package/src/cli.js +59 -8
- package/src/core/config.js +88 -6
- package/src/core/pairing.js +9 -5
- package/src/core/roles.js +109 -0
- package/src/selftest.js +37 -1
- package/src/server.js +3 -0
package/package.json
CHANGED
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) => ({
|
|
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 = {
|
|
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 [--
|
|
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
|
|
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
|
package/src/core/config.js
CHANGED
|
@@ -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
|
|
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`
|
|
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 (
|
|
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. */
|
package/src/core/pairing.js
CHANGED
|
@@ -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,109 @@
|
|
|
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
|
+
'host_session_create',
|
|
55
|
+
'host_session_prompt',
|
|
56
|
+
'pc_action',
|
|
57
|
+
'pc_app',
|
|
58
|
+
'pc_window',
|
|
59
|
+
'pc_browser'
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
/** @type {Record<string, { allow: string[] | null, description: string }>} */
|
|
63
|
+
export const ROLE_PRESETS = {
|
|
64
|
+
observer: { allow: OBSERVER, description: '只读观察:状态 / 文件 / git / 日志 / 委托列表' },
|
|
65
|
+
controller: { allow: CONTROLLER, description: '协调者:观察 + 委托 + 桌面控制(不能跨机写/重启/改配置)' },
|
|
66
|
+
node: { allow: null, description: '节点:继承本机 ACL(与旧行为一致)' },
|
|
67
|
+
admin: { allow: null, description: '管理员:预留,当前等同 node(真正提权需改节点 ACL)' }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const ROLE_NAMES = Object.keys(ROLE_PRESETS)
|
|
71
|
+
|
|
72
|
+
export function isRole(name) {
|
|
73
|
+
return ROLE_NAMES.includes(String(name || ''))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 角色允许的工具;null 表示“继承节点 ACL”。 */
|
|
77
|
+
export function roleAllow(role) {
|
|
78
|
+
const preset = ROLE_PRESETS[role]
|
|
79
|
+
return preset ? preset.allow : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 最小覆盖给定工具集合的角色(用于旧 token 自动迁移)。
|
|
83
|
+
* - observer / controller:显式 allow 是角色能力包子集 → 升级到该角色
|
|
84
|
+
* - node:显式 allow 恰好等于节点 ACL → node(等价继承)
|
|
85
|
+
* - 其他(自定义、或比角色包更宽/更窄的奇怪组合)→ null,保留显式 ACL,由用户决定
|
|
86
|
+
* @param {string[] | null | undefined} allowList
|
|
87
|
+
* @param {string[]} [nodeAllow]
|
|
88
|
+
*/
|
|
89
|
+
export function inferRole(allowList, nodeAllow = null) {
|
|
90
|
+
const allow = Array.isArray(allowList) ? allowList : null
|
|
91
|
+
if (!allow) return 'node'
|
|
92
|
+
const covers = (role) => {
|
|
93
|
+
const preset = ROLE_PRESETS[role]
|
|
94
|
+
if (!preset || !preset.allow) return false
|
|
95
|
+
return allow.every((name) => preset.allow.includes(name))
|
|
96
|
+
}
|
|
97
|
+
if (covers('observer')) return 'observer'
|
|
98
|
+
if (covers('controller')) return 'controller'
|
|
99
|
+
if (Array.isArray(nodeAllow) && nodeAllow.length > 0) {
|
|
100
|
+
const a = new Set(allow)
|
|
101
|
+
const b = new Set(nodeAllow)
|
|
102
|
+
if (a.size === b.size && [...a].every((name) => b.has(name))) return 'node'
|
|
103
|
+
}
|
|
104
|
+
return null // 自定义 / 无法安全推断 → 保留显式 ACL
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function describeRole(role) {
|
|
108
|
+
return ROLE_PRESETS[role]?.description || '自定义 ACL'
|
|
109
|
+
}
|
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,41 @@ 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
|
+
assert(allow.includes('host_session_create') && allow.includes('host_session_prompt'), 'controller must allow the CLI delegation path (session create/prompt)')
|
|
858
|
+
for (const dangerous of ['host_call', 'service_restart', 'mesh_config_patch', 'fs_write', 'shell_run']) {
|
|
859
|
+
assert(!allow.includes(dangerous), `controller must not include ${dangerous}`)
|
|
860
|
+
}
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
await check('roles: observer is read-only and cannot delegate or control', () => {
|
|
864
|
+
const allow = roleAllow('observer')
|
|
865
|
+
assert(allow.includes('fs_read') && allow.includes('cluster_status'), 'observer should read')
|
|
866
|
+
for (const notAllowed of ['delegate', 'pc_action', 'host_call', 'service_restart']) {
|
|
867
|
+
assert(!allow.includes(notAllowed), `observer must not include ${notAllowed}`)
|
|
868
|
+
}
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
await check('roles: inferRole maps legacy allow-lists safely', () => {
|
|
872
|
+
assert(inferRole(['mesh_self', 'fs_read']) === 'observer', 'read-only allow → observer')
|
|
873
|
+
assert(inferRole(['mesh_self', 'pc_action', 'delegate']) === 'controller', 'pc_*+delegate allow → controller')
|
|
874
|
+
assert(inferRole(DEFAULT_ALLOW, DEFAULT_ALLOW) === 'node', 'exact node ACL → node')
|
|
875
|
+
assert(inferRole(['mesh_self', 'shell_run'], DEFAULT_ALLOW) === null, 'unmappable custom ACL stays explicit')
|
|
876
|
+
})
|
|
877
|
+
|
|
878
|
+
await check('roles: peerAclForToken narrows by role and inherits for node', () => {
|
|
879
|
+
const controller = peerAclForToken({ inbound: [{ id: 'c', role: 'controller' }] }, 'c')
|
|
880
|
+
assert(controller && controller.role === 'controller', 'role should be reported')
|
|
881
|
+
assert(controller.allow.includes('delegate') && !controller.allow.includes('host_call'), 'controller allow-list applied')
|
|
882
|
+
const node = peerAclForToken({ inbound: [{ id: 'n', role: 'node' }] }, 'n')
|
|
883
|
+
assert(node && node.allow === undefined, 'node role inherits the node ACL')
|
|
884
|
+
const legacy = peerAclForToken({ inbound: [{ id: 'l', acl: { allow: ['mesh_self'] } }] }, 'l')
|
|
885
|
+
assert(legacy.allow.length === 1 && legacy.allow[0] === 'mesh_self', 'legacy explicit ACL untouched')
|
|
886
|
+
})
|
|
887
|
+
|
|
852
888
|
const failed = results.filter((r) => !r.ok)
|
|
853
889
|
console.log(`\nselftest: ${results.length - failed.length}/${results.length} passed`)
|
|
854
890
|
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}`,
|