Files
dsh_shenxian/scripts/gen-capabilities.cjs
T

178 lines
8.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* gen-capabilities.cjs —— 生成「实例能力清单」(档案 56)
*
* 解决什么:agent 每开新会话都要**现场试一遍**才能知道能做什么(实测同一批探测重复 3 轮,
* 撞同样 4 类墙:/etc 白名单、127.0.0.1 被 SSRF 拒、技能不存在、写边界),用户也跟着反复问
* "能力有变化吗"。本脚本把**平台事实**固化成两份同源产物:
* ① /opt/dsh/state/capabilities.json —— 给平台 API / 门户(机读)
* ② <bundled-skill-dir>/platform-capabilities/SKILL.md —— 给实例内 agent(它可被 skill 机制加载)
*
* 用法:node scripts/gen-capabilities.cjs # 生成
* node scripts/gen-capabilities.cjs --print # 只打印 JSON
* 幂等、只写上述两个路径;任何探测失败只降级为 "unknown",不报错退出。
*/
'use strict'
const { execFileSync } = require('node:child_process')
const { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } = require('node:fs')
const { join } = require('node:path')
const STATE = process.env.DSH_STATE_DIR ?? '/opt/dsh/state'
const OUT_JSON = join(STATE, 'capabilities.json')
const BUNDLED = process.env.DSH_BUNDLED_SKILL_DIR ?? '/var/lib/dshs/bundled-skills'
const USERS = process.env.DSH_USERS_DIR ?? '/var/lib/dshs/users'
const PERMISSION_MODE = process.env.DSH_PERMISSION_MODE ?? 'danger-full-access'
const run = (cmd, args) => {
try {
return execFileSync(cmd, args, { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] })
.trim().split('\n')[0].slice(0, 80)
} catch {
return 'MISSING'
}
}
// ── 1) 工具链(宿主层安装 → 实例经 /usr 只读可见)──────────────
const tools = {
node: run('node', ['-v']),
npm: run('npm', ['-v']),
pnpm: run('pnpm', ['-v']),
python3: run('python3', ['--version']),
git: run('git', ['--version']),
curl: run('curl', ['--version']),
ripgrep: run('rg', ['--version']),
jq: run('jq', ['--version']),
ffmpeg: run('ffmpeg', ['-version']),
bubblewrap: run('bwrap', ['--version']),
}
// ── 2) 技能清单(共享层 + 各用户个人层)────────────────────────
const listSkills = (dir) => {
if (!existsSync(dir)) return []
try {
return readdirSync(dir).filter((n) => {
try { return statSync(join(dir, n)).isDirectory() } catch { return false }
})
} catch {
return []
}
}
const sharedSkills = listSkills(BUNDLED)
const perUserSkills = {}
if (existsSync(USERS)) {
for (const uid of readdirSync(USERS)) {
const s = listSkills(join(USERS, uid, 'home', 'skills'))
if (s.length) perUserSkills[uid.slice(0, 8)] = s
}
}
// ── 3) 能力清单(结论层;每项都可在平台上核对)──────────────────
const capabilities = {
generatedAt: new Date().toISOString(),
permissionMode: PERMISSION_MODE,
note: PERMISSION_MODE === 'danger-full-access'
? '当前平台默认档位 = 完全权限(不在实例内叠加文件沙箱、不弹审批)。dsh 界面里可切换。'
: '当前平台默认档位 = workspace-write(需要可用的沙箱后端;本机不可用时会拒绝任何 shell)。',
read: {
allowed: ['/usr(工具链与运行时,只读)', '/etc 白名单(见平台补丁)', '/proc /dev(沙箱内)', '自己的工作区 ws/ 与实例 home/'],
denied: ['其他用户目录(users/<其他 uuid>/)', '宿主内部路径与凭据', '/etc 白名单以外的配置'],
},
write: {
allowed: ['工作区 ws/**(含子目录)', '实例 home/**(如 home/skills、home/profiles)', '私有 /tmp'],
denied: ['/usr /etc /var 等系统路径(只读挂载)', '平台策略文件(profile 的 cordis.patch.yml / package.json / pnpm-lock.yaml 为只读)'],
},
network: {
egress: '可出公网(仅封云元数据端点 100.100.100.200)',
denied: ['宿主 loopback 127.0.0.0/8(含 127.0.0.1:3080 门户、:22 等)', '内网卡与 docker0 网关', '平台公网 EIP'],
hint: '**不要把 127.0.0.1 当作可用入口**:web_fetch 会以「resolves to a non-public IP」被拒;实例内也不可访问门户。',
},
tools,
skills: {
shared: sharedSkills,
perUser: perUserSkills,
hint: sharedSkills.length === 0 && Object.keys(perUserSkills).length === 0
? '当前平台**没有任何已注册技能**(共享层与个人层均为空)→ 调用 skill 工具必然报 unknown,请改用 bash/文件工具完成。'
: '技能来自共享只读层与用户个人层;调用前先按名字确认存在于上表。',
},
fileDelivery: {
hint: '把产出交给用户时:**用工作区相对路径**(如 `报告.md`),并提示「点会话页右下角『我的文件』可查看/下载」。',
avoid: ['不要给 `/var/lib/dshs/users/...` 这类服务器绝对路径', '不要给 127.0.0.1:<port> 链接(用户浏览器打不开)'],
howToShare: '用户在会话页右下角「我的文件」面板里可浏览工作区并下载任意文件(平台代理,不暴露宿主路径)。',
},
}
// ── 4) 写 JSON ────────────────────────────────────────────────
try {
mkdirSync(STATE, { recursive: true })
} catch { /* 已存在 */ }
if (!process.argv.includes('--print')) {
writeFileSync(OUT_JSON, JSON.stringify(capabilities, null, 2) + '\n')
}
// ── 5) 写 agent 可读的 SKILL.md(共享只读层)──────────────────
const fmtKV = (o) => Object.entries(o).map(([k, v]) => `| ${k} | ${v} |`).join('\n')
const skillMd = `---
name: platform-capabilities
description: 本 dsh 实例(托管在 dshs 平台上)的**能力边界与交付约定**。开工前读它可省去自行探测;包含可读/可写路径、网络边界、可用工具版本、已注册技能清单、以及把文件交给用户的正确方式。
---
# 平台能力清单(由 \`scripts/gen-capabilities.cjs\` 生成 · ${capabilities.generatedAt})
> **本文件是权威结论,不要靠现场试探推断能力**。若与实测冲突,先按本文件执行,并把差异报告给用户。
## 1. 权限档位
${capabilities.note}
## 2. 可读
| 允许 | 说明 |
|---|---|
${capabilities.read.allowed.map((x) => `| ${x} | — |`).join('\n')}
**不可读**:${capabilities.read.denied.join(';')}
## 3. 可写
**可写**:${capabilities.write.allowed.join(';')}
**不可写**:${capabilities.write.denied.join(';')}
## 4. 网络
- 出网:${capabilities.network.egress}
- **不可达**:${capabilities.network.denied.join(';')}
- ⚠️ ${capabilities.network.hint}
## 5. 可用工具(宿主层安装,全部实例共享)
| 工具 | 版本 |
|---|---|
${fmtKV(tools)}
## 6. 已注册技能
- 共享层(所有人可用):${sharedSkills.length ? sharedSkills.join(', ') : '**(空)**'}
- 个人层:${Object.keys(perUserSkills).length ? JSON.stringify(perUserSkills) : '**(空)**'}
- ⚠️ ${capabilities.skills.hint}
## 7. 把文件交给用户(重要)
${capabilities.fileDelivery.hint}
- ❌ 不要做:${capabilities.fileDelivery.avoid.join(';')}
- ✅ 怎么做:${capabilities.fileDelivery.howToShare}
`
if (!process.argv.includes('--print')) {
const dir = join(BUNDLED, 'platform-capabilities')
try {
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'SKILL.md'), skillMd)
} catch (e) {
console.error('写 SKILL.md 失败:', e.message)
}
}
if (process.argv.includes('--print')) {
console.log(JSON.stringify(capabilities, null, 2))
} else {
console.log('已生成:')
console.log(' ' + OUT_JSON)
console.log(' ' + join(BUNDLED, 'platform-capabilities', 'SKILL.md'))
console.log(' 工具: ' + Object.entries(tools).map(([k, v]) => `${k}=${v}`).join(' '))
console.log(' 技能: 共享 ' + sharedSkills.length + ' 个,个人层用户 ' + Object.keys(perUserSkills).length + ' 个')
}