2026-09-13 16:18:10 +08:00
|
|
|
|
#!/usr/bin/env node
|
2026-09-19 15:12:19 +08:00
|
|
|
|
const cfg = require('../config/index.cjs')
|
2026-09-13 16:18:10 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* gen-capabilities.cjs —— 生成「实例能力清单」(档案 56)
|
|
|
|
|
|
*
|
|
|
|
|
|
* 解决什么:agent 每开新会话都要**现场试一遍**才能知道能做什么(实测同一批探测重复 3 轮,
|
|
|
|
|
|
* 撞同样 4 类墙:/etc 白名单、127.0.0.1 被 SSRF 拒、技能不存在、写边界),用户也跟着反复问
|
|
|
|
|
|
* "能力有变化吗"。本脚本把**平台事实**固化成两份同源产物:
|
2026-09-19 15:12:19 +08:00
|
|
|
|
* ① <platform-dir>/state/capabilities.json —— 给平台 API / 门户(机读)
|
2026-09-13 16:18:10 +08:00
|
|
|
|
* ② <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')
|
|
|
|
|
|
|
2026-09-19 15:12:19 +08:00
|
|
|
|
const STATE = cfg.stateDir()
|
2026-09-13 16:18:10 +08:00
|
|
|
|
const OUT_JSON = join(STATE, 'capabilities.json')
|
2026-09-19 15:12:19 +08:00
|
|
|
|
const BUNDLED = cfg.bundledSkillsDir()
|
|
|
|
|
|
const USERS = cfg.usersDir()
|
2026-09-13 16:18:10 +08:00
|
|
|
|
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`),并提示「点会话页右下角『我的文件』可查看/下载」。',
|
2026-09-19 15:12:19 +08:00
|
|
|
|
avoid: ['不要给服务器绝对路径', '不要给 127.0.0.1:<port> 链接(用户浏览器打不开)'],
|
2026-09-13 16:18:10 +08:00
|
|
|
|
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 + ' 个')
|
|
|
|
|
|
}
|