skills-cli.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  1. // 技能推送工具 agent-skills(docs/design/19-skill-cli.md):对测试服务端到端运行命令;浏览器登录(设备码授权)的服务端规则
  2. import fs from 'node:fs'
  3. import os from 'node:os'
  4. import path from 'node:path'
  5. import pino from 'pino'
  6. import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
  7. import { main, type Io } from '../../../packages/skills-cli/src/main'
  8. import { isNewer, npxArgs } from '../../../packages/skills-cli/src/update'
  9. import { createApp, type App } from '../src/app'
  10. import { DEVICE_LOGIN } from '../src/custom-skills/device-login'
  11. import { buildServer } from '../src/http/server'
  12. let app: App
  13. let server: Awaited<ReturnType<typeof buildServer>>
  14. let base: string
  15. let cfgDir: string
  16. let project: string
  17. const md = (name: string, extra = '') => `---\nname: ${name}\ndescription: ${name} 的说明:处理某类业务数据的流程与规范\n${extra}---\n# ${name}\n`
  18. /** 运行一条命令;onSleep 在每次等待时调用(模拟管理员在浏览器中操作) */
  19. async function run(args: string[], opts: { env?: Record<string, string>; cwd?: string; onSleep?: () => void; stdin?: string; version?: string; relaunch?: Io['relaunch'] } = {}) {
  20. const out: string[] = []
  21. const err: string[] = []
  22. const io: Io = {
  23. out: s => void out.push(s), err: s => void err.push(s),
  24. env: { AGENT_SKILLS_CONFIG_DIR: cfgDir, ...opts.env }, cwd: opts.cwd ?? project, tty: false,
  25. openUrl: async () => false,
  26. sleep: async () => {
  27. opts.onSleep?.()
  28. await new Promise(r => setTimeout(r, 20))
  29. },
  30. readStdin: async () => opts.stdin ?? '',
  31. prompt: async () => '',
  32. relaunch: opts.relaunch ?? (async () => null),
  33. version: opts.version,
  34. }
  35. const code = await main(args, io)
  36. return { code, out: out.join('\n'), err: err.join('\n') }
  37. }
  38. const pending = (name: string) => app.skillRegistry.listItems('pending').find(i => i.name === name)
  39. beforeAll(async () => {
  40. const log = pino({ level: 'silent' })
  41. app = createApp(log, { providers: {} })
  42. server = await buildServer(app, { logger: log })
  43. base = await server.listen({ port: 0, host: '127.0.0.1' })
  44. cfgDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-cli-cfg-'))
  45. project = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-cli-proj-'))
  46. for (const n of ['alpha', 'beta']) {
  47. fs.mkdirSync(path.join(project, 'skills', n, 'references'), { recursive: true })
  48. fs.writeFileSync(path.join(project, 'skills', n, 'SKILL.md'), md(n))
  49. fs.writeFileSync(path.join(project, 'skills', n, 'references', 'guide.md'), `${n} v1\n`)
  50. }
  51. fs.writeFileSync(path.join(project, 'skills', 'alpha', '.env'), 'TOKEN=abc')
  52. DEVICE_LOGIN.intervalSec = 1
  53. })
  54. afterAll(async () => {
  55. await server.close()
  56. })
  57. describe('推送工具:浏览器登录', () => {
  58. it('login:显示验证码,管理员批准后保存密钥(权限 600);whoami', async () => {
  59. let approved = false
  60. const r = await run(['login', '--server', base], {
  61. onSleep: () => {
  62. const l = app.skillDeviceLogins.pending()[0]
  63. if (l && !approved) {
  64. expect(l.client.user).toBe(os.userInfo().username)
  65. app.skillDeviceLogins.approve(l.userCode, { name: '张三 · 本机', expiresInDays: 30 })
  66. approved = true
  67. }
  68. },
  69. })
  70. expect(r.code).toBe(0)
  71. expect(r.err).toMatch(/验证码:[A-Z]{4}-[A-Z]{4}/)
  72. expect(r.err).toContain(`${base}/admin/skill-login?code=`)
  73. expect(r.out).toContain('已登录')
  74. const file = path.join(cfgDir, 'credentials.json')
  75. expect(fs.statSync(file).mode & 0o777).toBe(0o600)
  76. const creds = JSON.parse(fs.readFileSync(file, 'utf8'))
  77. expect(creds.default).toBe(base)
  78. expect(creds.servers[base].token).toMatch(/^sk_pub_/)
  79. expect(app.skillRegistry.listKeys().find(k => k.name === '张三 · 本机')).toMatchObject({ origin: 'device' })
  80. const who = await run(['whoami'])
  81. expect(who.code).toBe(0)
  82. expect(who.out).toContain('张三 · 本机')
  83. expect(who.out).toContain('浏览器登录')
  84. })
  85. it('服务端规则:拒绝、询问过快、设备码只能换取一次、每个 IP 的申请次数', () => {
  86. const d = app.skillDeviceLogins.start({ host: 'h', user: 'u' }, '10.0.0.1', 'https://x')
  87. expect(d.verificationUriComplete).toBe(`https://x/admin/skill-login?code=${d.userCode}`)
  88. expect(() => app.skillDeviceLogins.poll(d.deviceCode)).toThrow(/等待/)
  89. app.skillDeviceLogins.deny(d.userCode.toLowerCase().replace('-', ' '))
  90. expect(() => app.skillDeviceLogins.poll(d.deviceCode)).toThrow(/拒绝/)
  91. expect(() => app.skillDeviceLogins.approve(d.userCode, { name: 'x', expiresInDays: 1 })).toThrow(/处理过/)
  92. const saved = DEVICE_LOGIN.intervalSec
  93. DEVICE_LOGIN.intervalSec = 5
  94. try {
  95. const e = app.skillDeviceLogins.start({}, '10.0.0.2', 'https://x')
  96. expect(() => app.skillDeviceLogins.poll(e.deviceCode)).toThrow(/等待/)
  97. expect(() => app.skillDeviceLogins.poll(e.deviceCode)).toThrow(/过于频繁/)
  98. } finally {
  99. DEVICE_LOGIN.intervalSec = saved
  100. }
  101. const f = app.skillDeviceLogins.start({}, '10.0.0.3', 'https://x')
  102. app.skillDeviceLogins.approve(f.userCode, { name: '一次性', expiresInDays: null })
  103. expect(app.skillDeviceLogins.poll(f.deviceCode)).toMatchObject({ name: '一次性', expiresAt: null })
  104. expect(() => app.skillDeviceLogins.poll(f.deviceCode)).toThrow(/过期/)
  105. expect(app.skillDeviceLogins.get(f.userCode).status).toBe('consumed')
  106. for (let i = 0; i < DEVICE_LOGIN.perIp; i++) app.skillDeviceLogins.start({}, '10.0.0.9', 'https://x')
  107. expect(() => app.skillDeviceLogins.start({}, '10.0.0.9', 'https://x')).toThrow(/频繁/)
  108. })
  109. })
  110. describe('推送工具:推送与查询', () => {
  111. it('init 生成不含密钥的项目配置;push --dry-run 只校验;本地预检不通过时不推送', async () => {
  112. expect((await run(['init'])).code).toBe(0)
  113. const cfg = JSON.parse(fs.readFileSync(path.join(project, '.agent-skills.json'), 'utf8'))
  114. expect(cfg).toEqual({ server: base, paths: ['skills'], exclude: [] })
  115. expect(JSON.stringify(cfg)).not.toContain('sk_pub_')
  116. const dry = await run(['push', '--dry-run'])
  117. expect(dry.code).toBe(0)
  118. expect(dry.out).toContain('试运行')
  119. expect(dry.out).toContain('.env(已排除)')
  120. expect(app.skillRegistry.pendingCount()).toBe(0)
  121. fs.writeFileSync(path.join(project, 'skills', 'beta', 'SKILL.md'), '---\nname: beta\n---\n')
  122. const bad = await run(['push'])
  123. expect(bad.code).toBe(1)
  124. expect(bad.out).toContain('缺少 description')
  125. expect(app.skillRegistry.pendingCount()).toBe(0)
  126. fs.writeFileSync(path.join(project, 'skills', 'beta', 'SKILL.md'), md('beta'))
  127. })
  128. it('push:在子目录中执行也按项目配置找技能;输出审核链接;status 查看结果', async () => {
  129. const r = await run(['push', '-m', '首次推送'], { cwd: path.join(project, 'skills', 'alpha') })
  130. expect(r.code).toBe(0)
  131. expect(r.out).toContain('已推送 sub_')
  132. expect(r.out).toMatch(/alpha {2}待审核(新技能)/)
  133. const item = pending('alpha')!
  134. expect(r.out).toContain(`${base}/admin/plugins?tab=custom&review=${item.id}`)
  135. expect(item).toMatchObject({ message: '首次推送', keyName: '张三 · 本机' })
  136. expect(item.meta.cli).toBe('dev')
  137. expect(app.skillRegistry.item(item.id).files.map(f => f.path)).toEqual(['SKILL.md', 'references/guide.md'])
  138. const st = await run(['status'])
  139. expect(st.out).toMatch(/alpha(待审核)、beta(待审核)/)
  140. })
  141. it('push --wait:批准后退出码 0;被拒绝为 4;超时仍在待审核为 5', async () => {
  142. fs.writeFileSync(path.join(project, 'skills', 'alpha', 'references', 'guide.md'), 'alpha v2\n')
  143. const ok = await run(['push', '--only', 'alpha', '--wait'], {
  144. onSleep: () => {
  145. const i = pending('alpha')
  146. if (i) app.skillRegistry.approve(i.id, { action: 'create', name: 'alpha', publish: true })
  147. },
  148. })
  149. expect(ok.code).toBe(0)
  150. expect(ok.out).toContain('审核结果')
  151. expect(ok.out).toContain('alpha 已批准:新建 alpha v1,已发布')
  152. const rejected = await run(['push', '--only', 'beta', '--wait'], {
  153. onSleep: () => {
  154. const i = pending('beta')
  155. if (i) app.skillRegistry.reject(i.id, '描述不清楚')
  156. },
  157. })
  158. expect(rejected.code).toBe(4)
  159. expect(rejected.out).toContain('拒绝原因:描述不清楚')
  160. const timeout = await run(['push', '--only', 'beta', '--wait', '--timeout', '0.2'])
  161. expect(timeout.code).toBe(5)
  162. expect(timeout.out).toContain('仍在待审核')
  163. // 撤回
  164. const id = /已推送 (sub_\w+)/.exec(timeout.out)![1]!
  165. const w = await run(['withdraw', id])
  166. expect(w.code).toBe(0)
  167. expect(w.out).toContain('beta 已撤回')
  168. })
  169. it('CI:用环境变量中的密钥推送;--strict 时疑似密钥为错误;无效密钥退出码 2', async () => {
  170. const { key } = app.skillRegistry.createKey({ name: 'fwh-app · CI', description: '', expiresInDays: 365 })
  171. const ci = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-cli-ci-'))
  172. fs.mkdirSync(path.join(ci, 'gamma'))
  173. fs.writeFileSync(path.join(ci, 'gamma', 'SKILL.md'), md('gamma'))
  174. fs.writeFileSync(path.join(ci, 'gamma', 'run.sh'), 'curl -u admin:password123@ https://x\npassword = abcdefghijklmnop1234\n')
  175. const env = { AGENT_SKILLS_TOKEN: key, AGENT_SKILLS_SERVER: base, AGENT_SKILLS_CONFIG_DIR: fs.mkdtempSync(path.join(os.tmpdir(), 'skills-cli-empty-')) }
  176. const strict = await run(['push', '--strict'], { cwd: ci, env })
  177. expect(strict.code).toBe(1)
  178. expect(strict.out).toContain('run.sh 可能包含密码或密钥赋值')
  179. const r = await run(['push', '--json'], { cwd: ci, env })
  180. expect(r.code).toBe(0)
  181. const sub = JSON.parse(r.out)
  182. expect(sub.items[0]).toMatchObject({ name: 'gamma', status: 'pending' })
  183. expect(pending('gamma')!.keyName).toBe('fwh-app · CI')
  184. const bad = await run(['push'], { cwd: ci, env: { ...env, AGENT_SKILLS_TOKEN: 'sk_pub_wrong' } })
  185. expect(bad.code).toBe(2)
  186. expect(bad.err).toContain('无效')
  187. })
  188. it('logout:在服务器上停用密钥并删除本机保存的密钥', async () => {
  189. const r = await run(['logout'])
  190. expect(r.code).toBe(0)
  191. expect(r.out).toContain('已在服务器上停用密钥:张三 · 本机')
  192. expect(app.skillRegistry.listKeys().find(k => k.name === '张三 · 本机')!.enabled).toBe(false)
  193. expect(JSON.parse(fs.readFileSync(path.join(cfgDir, 'credentials.json'), 'utf8')).servers).toEqual({})
  194. expect((await run(['whoami'])).code).toBe(2)
  195. expect((await run(['bogus'])).code).toBe(1)
  196. expect((await run(['--help'])).out).toContain('退出码')
  197. })
  198. })
  199. describe('推送工具:自动更新', () => {
  200. it('服务器上有新版本时用带版本号的包重新执行同一条命令;版本相同、关闭检查或查询失败时继续用当前版本', async () => {
  201. expect(isNewer('0.10.0', '0.9.9')).toBe(true)
  202. expect(isNewer('0.2.0', '0.2.0')).toBe(false)
  203. expect(isNewer('1.0.0', 'dev')).toBe(false)
  204. const real = globalThis.fetch
  205. let latest: string | null = '0.3.0'
  206. const spy = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
  207. if (!String(input).endsWith('/npm/@agent-server%2fskills-cli')) return real(input, init)
  208. return Promise.resolve(latest ? Response.json({ name: '@agent-server/skills-cli', 'dist-tags': { latest } }) : new Response('', { status: 404 }))
  209. })
  210. try {
  211. const calls: string[][] = []
  212. const relaunch: Io['relaunch'] = async (release, argv) => {
  213. calls.push(npxArgs(release, argv))
  214. return 7
  215. }
  216. const up = await run(['whoami', '--server', base], { version: '0.2.0', relaunch })
  217. expect(up.code).toBe(7)
  218. expect(up.err).toContain('新版本 0.3.0(当前 0.2.0)')
  219. expect(calls).toEqual([['-y', '--registry', `${base}/npm/`, '@agent-server/skills-cli@0.3.0', 'whoami', '--server', base]])
  220. // 无法重新执行时继续用当前版本(此时没有登录,whoami 退出码 2)
  221. const failed = await run(['whoami', '--server', base], { version: '0.2.0', relaunch: async () => null })
  222. expect(failed.code).toBe(2)
  223. expect(failed.err).toContain('无法自动更新')
  224. calls.length = 0
  225. expect((await run(['whoami', '--server', base], { version: '0.3.0', relaunch })).code).toBe(2)
  226. expect((await run(['whoami', '--server', base], { version: '0.2.0', relaunch, env: { AGENT_SKILLS_NO_UPDATE_CHECK: '1' } })).code).toBe(2)
  227. expect((await run(['whoami', '--server', base], { relaunch })).code).toBe(2) // 开发版本
  228. latest = null
  229. expect((await run(['whoami', '--server', base], { version: '0.2.0', relaunch })).code).toBe(2)
  230. expect(calls).toEqual([])
  231. } finally {
  232. spy.mockRestore()
  233. }
  234. })
  235. })