admin.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. // 管理 API:仅后台登录态可访问
  2. import fs from 'node:fs'
  3. import type { FastifyInstance, FastifyRequest } from 'fastify'
  4. import mime from 'mime-types'
  5. import { z } from 'zod'
  6. import { BUILTIN_GLOBAL_DENY, COMMUNITY_SKILL_PLUGIN, CUSTOM_SKILL_PLUGIN, type AppSettings, type McpServerInput, type DeepPartial, type FileAccessPolicy, type PluginAction } from '@agent-server/shared'
  7. import type { App } from '../../app'
  8. import { AppError, badRequest, notFound } from '../../lib/errors'
  9. import { runClaudeCli } from '../../providers/claude/cli'
  10. import { CodexProvider } from '../../providers/codex/adapter'
  11. import { checkKnowledgePath, checkPathBasic, resolveAccess, toResolvedDto } from '../../services/access-policy'
  12. import { parse, type AuditKind, type KnowledgeInput } from '../../services/catalog'
  13. import { defaultSettings } from '../../services/settings'
  14. import { ADMIN_COOKIE, isAdmin, requireAdmin } from '../auth'
  15. import { providerStatuses } from './v1'
  16. import { API_SPECS } from '../../api-reference/reference'
  17. import { renderApiReferenceHtml } from '../../api-reference/render'
  18. import { APP_VERSION } from '../../config'
  19. /** 文档中示例使用的服务地址:后台页面传入浏览器看到的地址(经反向代理时与服务自身看到的不同),否则用请求的地址 */
  20. function referenceBase(req: FastifyRequest<{ Querystring: { base?: string } }>): string {
  21. const b = req.query.base
  22. if (b && /^https?:\/\/[a-z0-9.-]+(:\d+)?$/i.test(b)) return b
  23. return `${req.protocol}://${req.host}`
  24. }
  25. type IdParams = { Params: { id: string } }
  26. const communityRefSchema = z.object({ source: z.string().min(3).max(200), skillId: z.string().min(1).max(100) })
  27. const pluginActionSchema: z.ZodType<PluginAction> = z.union([
  28. z.object({ action: z.literal('marketplace-add'), source: z.string().trim().min(1).max(300) }),
  29. z.object({ action: z.enum(['marketplace-remove', 'marketplace-update']), name: z.string().min(1).max(120) }),
  30. z.object({ action: z.enum(['install', 'uninstall', 'update', 'enable', 'disable']), plugin: z.string().min(1).max(200) }),
  31. ])
  32. export default async function adminRoutes(f: FastifyInstance, { app }: { app: App }) {
  33. // ---- 登录(无需认证)----
  34. // 简单的失败限流:每个 IP 10 分钟内最多失败 5 次
  35. const failures = new Map<string, number[]>()
  36. f.post('/auth/login', async (req, reply) => {
  37. const { password } = parse(z.object({ password: z.string().min(1) }), req.body)
  38. const recent = (failures.get(req.ip) ?? []).filter(t => t > Date.now() - 600_000)
  39. if (recent.length >= 5) throw new AppError(429, 'too_many_attempts', '登录失败次数过多,请 10 分钟后再试')
  40. let s
  41. try {
  42. s = app.adminAuth.login(password)
  43. failures.delete(req.ip)
  44. } catch (err) {
  45. failures.set(req.ip, [...recent, Date.now()])
  46. throw err
  47. }
  48. reply.setCookie(ADMIN_COOKIE, s.id, { path: '/', httpOnly: true, sameSite: 'strict', secure: req.protocol === 'https', expires: s.expiresAt })
  49. app.audit.log({ actor: 'admin', kind: 'admin', summary: '后台登录', detail: { ip: req.ip } })
  50. return { ok: true }
  51. })
  52. f.post('/auth/logout', async (req, reply) => {
  53. app.adminAuth.logout(req.cookies[ADMIN_COOKIE])
  54. reply.clearCookie(ADMIN_COOKIE, { path: '/' })
  55. return { ok: true }
  56. })
  57. f.get('/auth/me', async req => ({ loggedIn: isAdmin(app, req) }))
  58. // OAuth 授权回调(docs/design/16-mcp-support.md §5):授权服务器跳转回来时不带后台登录(Cookie 为 SameSite=Strict),
  59. // 凭一次性的 state 找到发起授权的服务
  60. f.get<{ Querystring: Record<string, string> }>('/mcp/oauth/callback', async (req, reply) => {
  61. const r = await app.mcpOAuth.callback(req.query)
  62. reply.type('text/html; charset=utf-8').header('cache-control', 'no-store')
  63. return oauthResultPage(r)
  64. })
  65. // ---- 以下需要登录 ----
  66. f.register(async g => {
  67. g.addHook('preHandler', requireAdmin(app))
  68. g.post('/auth/password', async req => {
  69. const body = parse(z.object({ current: z.string(), next: z.string().min(8) }), req.body)
  70. app.adminAuth.changePassword(body.current, body.next)
  71. app.audit.log({ actor: 'admin', kind: 'admin', summary: '修改后台密码' })
  72. return { ok: true }
  73. })
  74. g.get('/overview', async () => ({
  75. providers: await providerStatuses(app),
  76. runs: app.runs.stats(),
  77. version: APP_VERSION,
  78. }))
  79. g.get('/providers', async () => providerStatuses(app))
  80. // 引擎账户的订阅额度(较慢,概览页单独加载)
  81. g.get<{ Params: { id: string } }>('/providers/:id/quota', async req => {
  82. const provider = app.providers[req.params.id as 'claude' | 'codex']
  83. if (!provider?.quota) throw notFound(`引擎 ${req.params.id} 的额度信息`)
  84. return provider.quota(app.settings.get())
  85. })
  86. // 对外 API 协议:HTML(后台页面内嵌显示)与 PDF 下载
  87. type RefQuery = { Querystring: { theme?: string; base?: string } }
  88. const referenceHtml = (req: FastifyRequest<RefQuery>, theme: 'light' | 'dark') => renderApiReferenceHtml({
  89. baseUrl: referenceBase(req), theme, version: `v1 · 服务 ${APP_VERSION}`, generatedAt: new Date(),
  90. })
  91. g.get('/api-reference/meta', async () => ({ pdfAvailable: app.apiPdf.available }))
  92. // 后台页面用同一份协议定义自行渲染(可以在每个接口旁调试);HTML 版本用于 PDF 与新窗口打开
  93. g.get('/api-reference.json', async () => ({ version: `v1 · 服务 ${APP_VERSION}`, specs: API_SPECS }))
  94. g.get<RefQuery>('/api-reference.html', async (req, reply) => {
  95. reply.header('Content-Type', 'text/html; charset=utf-8')
  96. // 文档不需要脚本
  97. reply.header('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'self'")
  98. return referenceHtml(req, req.query.theme === 'dark' ? 'dark' : 'light')
  99. })
  100. g.get<RefQuery>('/api-reference.pdf', async (req, reply) => {
  101. if (!app.apiPdf.available) throw new AppError(503, 'pdf_unavailable', '服务器上没有安装 Chrome / Chromium,请使用浏览器打印保存为 PDF')
  102. const pdf = await app.apiPdf.render(referenceHtml(req, 'light'))
  103. const name = encodeURIComponent(`AgentServer API 协议 ${new Date().toISOString().slice(0, 10)}.pdf`)
  104. reply.header('Content-Type', 'application/pdf')
  105. reply.header('Content-Disposition', `attachment; filename*=UTF-8''${name}`)
  106. return reply.send(pdf)
  107. })
  108. // 用量统计(全部来源)与最近的失败
  109. g.get<{ Querystring: { days?: string } }>('/usage', async req => app.usage.summary({ days: Number(req.query.days ?? 30) || 30 }))
  110. g.get<{ Querystring: { limit?: string } }>('/usage/errors', async req => app.usage.recentErrors(Number(req.query.limit ?? 10) || 10))
  111. // 在后台登录 Claude(以服务账号运行官方 CLI 的登录流程)
  112. g.get('/providers/claude/login', async () => app.claudeLogin.current())
  113. g.post('/providers/claude/login', async req => {
  114. const body = parse(z.object({ sso: z.boolean().optional(), email: z.string().trim().max(200).optional() }), req.body ?? {})
  115. return app.claudeLogin.start({ sso: body.sso, email: body.email || undefined })
  116. })
  117. g.post<IdParams>('/providers/claude/login/:id/code', async req => {
  118. const { code } = parse(z.object({ code: z.string().min(1).max(2000) }), req.body)
  119. return app.claudeLogin.submitCode(req.params.id, code)
  120. })
  121. g.delete<IdParams>('/providers/claude/login/:id', async (req, reply) => {
  122. app.claudeLogin.cancel(req.params.id)
  123. reply.code(204)
  124. })
  125. // Codex 登录:设备码方式,在 OpenAI 官方页面输入短码完成
  126. const codex = () => {
  127. const p = app.providers.codex
  128. if (!(p instanceof CodexProvider)) throw new AppError(400, 'provider_unavailable', 'Codex 未接入')
  129. return p
  130. }
  131. g.get('/providers/codex/login', async () => codex().loginState())
  132. g.post('/providers/codex/login', async () => {
  133. const r = await codex().startLogin(app.settings.get())
  134. app.audit.log({ actor: 'admin', kind: 'admin', summary: '后台发起 Codex 登录(设备码)' })
  135. return r
  136. })
  137. g.delete('/providers/codex/login', async (_req, reply) => {
  138. await codex().cancelLogin(app.settings.get())
  139. reply.code(204)
  140. })
  141. g.post('/providers/codex/logout', async () => {
  142. await codex().logout(app.settings.get())
  143. app.audit.log({ actor: 'admin', kind: 'admin', summary: 'Codex 退出登录' })
  144. return { ok: true }
  145. })
  146. g.post('/providers/claude/logout', async () => {
  147. const r = await runClaudeCli(app.settings.get().providers.claude.cliPath, ['auth', 'logout'], { timeoutMs: 30_000 })
  148. app.audit.log({ actor: 'admin', kind: 'admin', summary: `Claude 退出登录${r.code === 0 ? '' : '失败'}` })
  149. if (r.code !== 0) throw new AppError(502, 'logout_failed', (r.stderr || r.stdout).trim().slice(-300) || '退出登录失败')
  150. return { ok: true }
  151. })
  152. // 设置
  153. g.get('/settings', async () => ({ settings: app.settings.get(), defaults: defaultSettings(), builtinGlobalDeny: BUILTIN_GLOBAL_DENY }))
  154. g.patch('/settings', async req => {
  155. const next = app.settings.update(req.body as DeepPartial<AppSettings>)
  156. app.audit.log({ actor: 'admin', kind: 'admin', summary: '修改系统设置', detail: req.body })
  157. return { settings: next }
  158. })
  159. // 某个引擎可选的技能:Claude = 本地技能 + 已启用插件 + 第三方技能;Codex = Codex 实际可见的技能
  160. g.get<{ Querystring: { engine?: string } }>('/skills', async req => app.skillCatalog(req.query.engine === 'codex' ? 'codex' : 'claude'))
  161. // MCP 服务:内置工具(只读展示)+ 第三方服务(经 MCP Gateway 转接,ADR-0021)
  162. g.get('/mcp', async () => app.mcpServers.state())
  163. g.post('/mcp/servers', async (req, reply) => {
  164. reply.code(201)
  165. return app.mcpServers.create(req.body as McpServerInput)
  166. })
  167. g.put<IdParams>('/mcp/servers/:id', async req => app.mcpServers.update(req.params.id, req.body as McpServerInput))
  168. g.delete<IdParams>('/mcp/servers/:id', async (req, reply) => {
  169. await app.mcpServers.delete(req.params.id)
  170. reply.code(204)
  171. })
  172. g.post<IdParams>('/mcp/servers/:id/refresh', async req => app.mcpServers.refresh(req.params.id))
  173. g.put<{ Params: { id: string; tool: string } }>('/mcp/servers/:id/tools/:tool', async req => {
  174. const policy = parse(z.object({ policy: z.enum(['auto', 'ask', 'confirm', 'off']) }), req.body).policy
  175. return app.mcpServers.setToolPolicy(req.params.id, req.params.tool, policy)
  176. })
  177. g.put<{ Params: { id: string; tool: string } }>('/mcp/servers/:id/tools/:tool/hints', async req => {
  178. const hints = parse(z.object({ readOnly: z.boolean().optional(), destructive: z.boolean().optional(), idempotent: z.boolean().optional() }), req.body)
  179. return app.mcpServers.setToolHints(req.params.id, req.params.tool, hints)
  180. })
  181. g.put<{ Params: { id: string; tool: string } }>('/mcp/servers/:id/tools/:tool/background', async req => {
  182. const { background } = parse(z.object({ background: z.boolean() }), req.body)
  183. return app.mcpServers.setToolBackground(req.params.id, req.params.tool, background)
  184. })
  185. g.post<IdParams>('/mcp/servers/:id/oauth/start', async req => app.mcpOAuth.start(req.params.id, `${req.protocol}://${req.host}`))
  186. g.delete<IdParams>('/mcp/servers/:id/oauth', async (req, reply) => {
  187. await app.mcpOAuth.revoke(req.params.id)
  188. reply.code(204)
  189. })
  190. g.post('/mcp/test', async req => {
  191. const b = req.body as { input: McpServerInput; id?: string | null }
  192. return app.mcpServers.test(b.input, b.id ?? null)
  193. })
  194. // 文件管理(管理员):位置 + 相对路径
  195. type FsQuery = { Querystring: { root?: string; path?: string; inline?: string } }
  196. const fsRoot = (q: { root?: string }) => q.root ?? ''
  197. g.get('/fs/roots', async () => ({ roots: app.fileManager.roots(), shortcuts: app.fileManager.shortcuts() }))
  198. g.get<FsQuery>('/fs/list', async req => app.fileManager.list(fsRoot(req.query), req.query.path ?? ''))
  199. g.get<FsQuery>('/fs/text', async req => app.fileManager.readText(fsRoot(req.query), req.query.path ?? ''))
  200. g.put<FsQuery>('/fs/text', async req => {
  201. const b = parse(z.object({ content: z.string(), baseMtime: z.string().nullable() }), req.body)
  202. return app.fileManager.writeText(fsRoot(req.query), req.query.path ?? '', b.content, b.baseMtime)
  203. })
  204. g.get<FsQuery>('/fs/download', async (req, reply) => {
  205. const f = app.fileManager.openFile(fsRoot(req.query), req.query.path ?? '')
  206. const type = (mime.lookup(f.name) || 'application/octet-stream') as string
  207. reply.header('Content-Type', type)
  208. reply.header('Content-Disposition', `${req.query.inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(f.name)}`)
  209. reply.header('X-Content-Type-Options', 'nosniff')
  210. // 预览 HTML / SVG 时禁止执行脚本
  211. reply.header('Content-Security-Policy', "sandbox; default-src 'none'; img-src data: 'self'; style-src 'unsafe-inline'")
  212. return reply.send(fs.createReadStream(f.abs))
  213. })
  214. g.get<FsQuery>('/fs/zip', async (req, reply) => {
  215. const z_ = app.fileManager.zipDir(fsRoot(req.query), req.query.path ?? '')
  216. reply.header('Content-Type', 'application/zip')
  217. reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(z_.name)}`)
  218. return reply.send(z_.data)
  219. })
  220. g.post<FsQuery>('/fs/mkdir', async req => {
  221. app.fileManager.mkdir(fsRoot(req.query), req.query.path ?? '')
  222. return { ok: true }
  223. })
  224. g.post<FsQuery>('/fs/upload', async (req, reply) => {
  225. const limit = app.settings.get().limits.uploadMaxMB * 1048576
  226. const saved = []
  227. for await (const part of req.files({ limits: { fileSize: limit, files: 50 } })) {
  228. const data = await part.toBuffer()
  229. if (part.file.truncated) throw new AppError(413, 'file_too_large', `文件超过 ${app.settings.get().limits.uploadMaxMB} MB`)
  230. saved.push(app.fileManager.upload(fsRoot(req.query), req.query.path ?? '', part.filename, data))
  231. }
  232. if (!saved.length) throw badRequest('没有收到文件')
  233. reply.code(201)
  234. return saved
  235. })
  236. g.post<FsQuery>('/fs/move', async req => {
  237. const b = parse(z.object({ to: z.string().min(1) }), req.body)
  238. app.fileManager.move(fsRoot(req.query), req.query.path ?? '', b.to)
  239. return { ok: true }
  240. })
  241. g.delete<FsQuery>('/fs/entry', async req => {
  242. app.fileManager.delete(fsRoot(req.query), req.query.path ?? '')
  243. return { ok: true }
  244. })
  245. // 自定义技能(docs/design/12-custom-skills.md)
  246. type SkillParams = { Params: { id: string } }
  247. type FileQuery = { Params: { id: string }; Querystring: { path?: string; version?: string } }
  248. const optVersion = (v: string | undefined) => (v === undefined || v === '' ? undefined : Number(v))
  249. g.get('/custom-skills', async () => app.customSkills.list())
  250. g.post('/custom-skills', async (req, reply) => {
  251. reply.code(201)
  252. return app.customSkills.create(parse(z.object({ name: z.string(), description: z.string() }), req.body))
  253. })
  254. g.get<SkillParams>('/custom-skills/:id', async req => app.customSkills.detail(req.params.id))
  255. g.patch<SkillParams>('/custom-skills/:id', async req =>
  256. app.customSkills.setFlags(req.params.id, parse(z.object({ enabled: z.boolean().optional(), includeInAll: z.boolean().optional() }), req.body)))
  257. g.delete<SkillParams>('/custom-skills/:id', async (req, reply) => {
  258. app.customSkills.delete(req.params.id)
  259. reply.code(204)
  260. })
  261. g.get<FileQuery>('/custom-skills/:id/file', async (req, reply) => {
  262. const f = app.customSkills.readFile(req.params.id, req.query.path ?? '', optVersion(req.query.version))
  263. reply.header('Content-Type', f.text ? 'text/plain; charset=utf-8' : 'application/octet-stream')
  264. reply.header('Content-Disposition', `${f.text ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(f.name)}`)
  265. reply.header('X-Content-Type-Options', 'nosniff')
  266. return reply.send(f.data)
  267. })
  268. // 文本文件:JSON { content };二进制文件:multipart(字段 file)
  269. g.put<FileQuery>('/custom-skills/:id/file', async req => {
  270. if (req.isMultipart()) {
  271. const part = await req.file({ limits: { fileSize: 10 * 1048576 } })
  272. if (!part) throw badRequest('没有收到文件')
  273. const data = await part.toBuffer()
  274. if (part.file.truncated) throw new AppError(413, 'file_too_large', '单个文件不能超过 10 MB')
  275. return app.customSkills.writeFile(req.params.id, req.query.path ?? part.filename, data)
  276. }
  277. const body = parse(z.object({ content: z.string() }), req.body)
  278. return app.customSkills.writeFile(req.params.id, req.query.path ?? '', Buffer.from(body.content, 'utf8'))
  279. })
  280. g.delete<FileQuery>('/custom-skills/:id/file', async req => app.customSkills.deleteFile(req.params.id, req.query.path ?? ''))
  281. g.post<SkillParams>('/custom-skills/:id/move', async req => {
  282. const b = parse(z.object({ from: z.string(), to: z.string() }), req.body)
  283. return app.customSkills.move(req.params.id, b.from, b.to)
  284. })
  285. g.post<SkillParams>('/custom-skills/:id/discard', async req => app.customSkills.discard(req.params.id))
  286. g.post<SkillParams>('/custom-skills/:id/save', async req =>
  287. app.customSkills.save(req.params.id, parse(z.object({ baseVersion: z.number().int(), publish: z.boolean() }), req.body)))
  288. g.post<SkillParams>('/custom-skills/:id/publish', async req =>
  289. app.customSkills.publish(req.params.id, parse(z.object({ version: z.number().int() }), req.body).version))
  290. g.post<{ Params: { id: string; v: string } }>('/custom-skills/:id/versions/:v/restore', async req => app.customSkills.restore(req.params.id, Number(req.params.v)))
  291. g.get<{ Params: { id: string }; Querystring: { from?: string; to?: string } }>('/custom-skills/:id/diff', async req => {
  292. const v = (x: string | undefined) => (x === 'working' ? 'working' as const : Number(x))
  293. return app.customSkills.diff(req.params.id, v(req.query.from), v(req.query.to))
  294. })
  295. g.get<FileQuery>('/custom-skills/:id/export', async (req, reply) => {
  296. const z_ = app.customSkills.exportZip(req.params.id, optVersion(req.query.version))
  297. reply.header('Content-Type', 'application/zip')
  298. reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(z_.name)}`)
  299. return reply.send(z_.data)
  300. })
  301. g.post<SkillParams>('/custom-skills/:id/detach', async req => app.customSkills.detach(req.params.id))
  302. // 技能源:服务器目录;上传的 zip 一次性导入
  303. const sourceSchema = z.object({
  304. name: z.string(), description: z.string().default(''), path: z.string().default(''),
  305. syncMode: z.enum(['manual', 'auto']).default('manual'), intervalMin: z.number().default(10),
  306. })
  307. g.get('/skill-sources', async () => app.skillSources.list())
  308. g.post('/skill-sources', async (req, reply) => {
  309. reply.code(201)
  310. return app.skillSources.create(parse(sourceSchema, req.body))
  311. })
  312. g.put<SkillParams>('/skill-sources/:id', async req => app.skillSources.update(req.params.id, parse(sourceSchema, req.body)))
  313. g.delete<SkillParams>('/skill-sources/:id', async (req, reply) => {
  314. app.skillSources.delete(req.params.id)
  315. reply.code(204)
  316. })
  317. g.post<SkillParams>('/skill-sources/:id/scan', async req => app.skillSources.scan(req.params.id))
  318. g.post<SkillParams>('/skill-sources/:id/import', async req => {
  319. const b = parse(z.object({ skills: z.array(z.string()).min(1), digest: z.string() }), req.body)
  320. return app.skillSources.import(req.params.id, b.skills, b.digest)
  321. })
  322. g.post('/skill-sources/upload', async (req, reply) => {
  323. const part = await req.file({ limits: { fileSize: 50 * 1048576 } })
  324. if (!part) throw badRequest('没有收到文件')
  325. const data = await part.toBuffer()
  326. if (part.file.truncated) throw new AppError(413, 'file_too_large', 'zip 文件不能超过 50 MB')
  327. reply.code(201)
  328. return app.skillSources.upload(part.filename, data)
  329. })
  330. g.post<SkillParams>('/skill-sources/uploads/:id/import', async req => {
  331. const b = parse(z.object({ skills: z.array(z.string()).min(1), digest: z.string() }), req.body)
  332. return app.skillSources.importUpload(req.params.id, b.skills, b.digest)
  333. })
  334. g.delete<SkillParams>('/skill-sources/uploads/:id', async (req, reply) => {
  335. app.skillSources.discardUpload(req.params.id)
  336. reply.code(204)
  337. })
  338. // 选择服务器目录(登记技能源、资料库时):只列子目录名
  339. g.get<{ Querystring: { path?: string } }>('/server-dirs', async req => app.skillSources.listDirs(req.query.path ?? '/'))
  340. // 第三方技能(skills.sh 目录 → GitHub,两个引擎共用)
  341. g.get('/community-skills', async () => app.communitySkills.list())
  342. g.get<{ Querystring: { q?: string } }>('/community-skills/search', async req => app.communitySkills.search(req.query.q ?? ''))
  343. g.post('/community-skills/preview', async req => {
  344. const b = parse(communityRefSchema, req.body)
  345. return app.communitySkills.preview(b.source, b.skillId)
  346. })
  347. g.post('/community-skills/install', async req => {
  348. const b = parse(communityRefSchema.extend({ sha: z.string() }), req.body)
  349. return app.communitySkills.install(b.source, b.skillId, b.sha)
  350. })
  351. g.delete<{ Params: { name: string } }>('/community-skills/:name', async req => app.communitySkills.uninstall(req.params.name))
  352. // Codex 插件(openai-curated 目录,只在 Playground 中使用)
  353. g.get<{ Querystring: { q?: string; category?: string; installed?: string; offset?: string; limit?: string; refresh?: string } }>('/codex-plugins', async req =>
  354. app.codexPlugins.catalog({
  355. query: req.query.q, category: req.query.category || undefined, installed: req.query.installed === '1',
  356. offset: Number(req.query.offset ?? 0) || 0, limit: Number(req.query.limit ?? 50) || 50, refresh: req.query.refresh === '1',
  357. }))
  358. g.get<{ Params: { name: string } }>('/codex-plugins/:name', async req => app.codexPlugins.detail(req.params.name))
  359. g.post<{ Params: { name: string } }>('/codex-plugins/:name/install', async req => app.codexPlugins.install(req.params.name))
  360. g.post<{ Params: { name: string } }>('/codex-plugins/:name/uninstall', async req => app.codexPlugins.uninstall(req.params.name))
  361. // Codex 官方技能(openai/skills)
  362. g.get('/codex-skills', async () => app.codexSkills.catalog())
  363. g.post('/codex-skills/refresh', async () => app.codexSkills.refresh())
  364. g.post<{ Params: { name: string } }>('/codex-skills/:name', async req => app.codexSkills.install(req.params.name))
  365. g.delete<{ Params: { name: string } }>('/codex-skills/:name', async req => app.codexSkills.uninstall(req.params.name))
  366. // 技能与插件(调用 Claude Code 自带的插件命令)
  367. g.get<{ Querystring: { refresh?: string } }>('/plugins', async req => app.plugins.state(req.query.refresh === '1'))
  368. g.post('/plugins/actions', async req => app.plugins.act(parse(pluginActionSchema, req.body)))
  369. g.post('/paths/check', async req => {
  370. const { path, purpose } = parse(z.object({ path: z.string().min(1), purpose: z.enum(['knowledge', 'generic']).default('knowledge') }), req.body)
  371. return purpose === 'generic' ? checkPathBasic(path) : checkKnowledgePath(path, app.settings.get())
  372. })
  373. // Profile
  374. g.get('/profiles', async () => app.profiles.list())
  375. g.get<IdParams>('/profiles/:id', async req => app.profiles.get(req.params.id))
  376. g.post('/profiles', async (req, reply) => {
  377. reply.code(201)
  378. const p = app.profiles.create(req.body as never)
  379. app.audit.log({ actor: 'admin', kind: 'admin', summary: `创建 Profile:${p.name}` })
  380. return p
  381. })
  382. g.put<IdParams>('/profiles/:id', async req => {
  383. const p = app.profiles.update(req.params.id, req.body as never)
  384. app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改 Profile:${p.name}`, detail: { version: p.version } })
  385. return p
  386. })
  387. g.get<IdParams>('/profiles/:id/versions', async req => app.profiles.versions(req.params.id))
  388. g.post<{ Params: { id: string; version: string } }>('/profiles/:id/versions/:version/restore', async req => {
  389. const p = app.profiles.restore(req.params.id, Number(req.params.version))
  390. app.audit.log({ actor: 'admin', kind: 'admin', summary: `恢复 Profile:${p.name} → 版本 ${req.params.version}(保存为版本 ${p.version})` })
  391. return p
  392. })
  393. g.delete<IdParams>('/profiles/:id', async (req, reply) => {
  394. const refs = app.apiKeys.referencing(req.params.id)
  395. if (refs.length) throw new AppError(400, 'profile_in_use', `这个 Profile 正被 Agent Key 使用:${refs.join('、')},请先在 Agent Keys 页面取消勾选`)
  396. const botRefs = app.bots.referencing(req.params.id)
  397. if (botRefs.length) throw new AppError(400, 'profile_in_use', `这个 Profile 正被机器人使用:${botRefs.join('、')},请先在机器人页面修改`)
  398. app.profiles.delete(req.params.id)
  399. app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除 Profile:${req.params.id}` })
  400. reply.code(204)
  401. })
  402. /** 文件访问策略生效预览(06 §4.3) */
  403. g.post('/access/preview', async req => {
  404. const body = req.body as { fileAccess: FileAccessPolicy; knowledgeRootIds: string[] }
  405. const settings = app.settings.get()
  406. return toResolvedDto(resolveAccess({
  407. policy: body.fileAccess,
  408. knowledgeRoots: app.knowledge.list(),
  409. selectedKnowledgeRootIds: body.knowledgeRootIds ?? [],
  410. workspaceDir: `${settings.paths.workspaceRoot}/<会话ID>`,
  411. settings,
  412. }))
  413. })
  414. // 资料库
  415. g.get('/knowledge', async () => app.knowledge.list())
  416. g.post('/knowledge', async (req, reply) => {
  417. reply.code(201)
  418. const k = app.knowledge.create(req.body as KnowledgeInput)
  419. app.audit.log({ actor: 'admin', kind: 'admin', summary: `添加资料库:${k.name}(${k.path})` })
  420. return k
  421. })
  422. g.put<IdParams>('/knowledge/:id', async req => {
  423. const k = app.knowledge.update(req.params.id, req.body as KnowledgeInput)
  424. app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改资料库:${k.name}(${k.path})` })
  425. return k
  426. })
  427. g.delete<IdParams>('/knowledge/:id', async (req, reply) => {
  428. app.knowledge.delete(req.params.id)
  429. app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除资料库:${req.params.id}` })
  430. reply.code(204)
  431. })
  432. // Agent Key
  433. g.get('/api-keys', async () => app.apiKeys.list())
  434. g.post('/api-keys', async (req, reply) => {
  435. const body = parse(z.object({ name: z.string().trim().min(1).max(60), allowedProfileIds: z.array(z.string()), defaultProfileId: z.string() }), req.body)
  436. reply.code(201)
  437. const name = body.name
  438. const r = app.apiKeys.create(name, body)
  439. app.audit.log({ actor: 'admin', kind: 'admin', summary: `签发 Agent Key:${name}` })
  440. return r
  441. })
  442. g.patch<IdParams>('/api-keys/:id', async req => {
  443. const { enabled } = parse(z.object({ enabled: z.boolean() }), req.body)
  444. app.apiKeys.setEnabled(req.params.id, enabled)
  445. app.audit.log({ actor: 'admin', kind: 'admin', summary: `${enabled ? '启用' : '停用'} Agent Key:${req.params.id}` })
  446. return { ok: true }
  447. })
  448. g.put<IdParams>('/api-keys/:id/profiles', async req => {
  449. const body = parse(z.object({ allowedProfileIds: z.array(z.string()), defaultProfileId: z.string() }), req.body)
  450. app.apiKeys.setProfiles(req.params.id, body)
  451. app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改 Agent Key 可用的 Profile:${req.params.id}`, detail: body })
  452. return { ok: true }
  453. })
  454. // API 协议页面调试:以某个平台的身份调用 /v1,签发 10 分钟的浏览器令牌(Agent Key 本身不离开服务端)
  455. g.post<IdParams>('/api-keys/:id/debug-token', async req => {
  456. const key = app.apiKeys.list().find(k => k.id === req.params.id)
  457. if (!key) throw notFound('Agent Key')
  458. if (!key.enabled) throw new AppError(400, 'bad_request', `Agent Key「${key.name}」已停用`)
  459. app.audit.log({ actor: 'admin', kind: 'admin', summary: `API 协议页面以「${key.name}」的身份调试接口` })
  460. return app.browserTokens.issue(key.id, 600)
  461. })
  462. g.delete<IdParams>('/api-keys/:id', async (req, reply) => {
  463. app.apiKeys.delete(req.params.id)
  464. app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除 Agent Key:${req.params.id}` })
  465. reply.code(204)
  466. })
  467. // 机器人与 Bot Key(docs/design/13-bot-teams.md §12.1)
  468. const botSchema = z.object({
  469. name: z.string(), avatar: z.string(), kind: z.enum(['lead', 'worker']), description: z.string(), profileId: z.string(), enabled: z.boolean(),
  470. })
  471. g.get('/bots', async () => app.bots.list())
  472. g.post('/bots', async (req, reply) => {
  473. const b = app.bots.create(parse(botSchema, req.body))
  474. app.audit.log({ actor: 'admin', kind: 'admin', summary: `新建机器人:${b.name}` })
  475. reply.code(201)
  476. return b
  477. })
  478. g.put<IdParams>('/bots/:id', async req => {
  479. const b = app.bots.update(req.params.id, parse(botSchema, req.body))
  480. app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改机器人:${b.name}` })
  481. return b
  482. })
  483. g.delete<IdParams>('/bots/:id', async (req, reply) => {
  484. const b = app.bots.get(req.params.id)
  485. app.bots.delete(b.id)
  486. app.botKeys.forgetBot(b.id)
  487. app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除机器人:${b.name}` })
  488. reply.code(204)
  489. })
  490. g.get('/bot-keys', async () => app.botKeys.list())
  491. g.post('/bot-keys', async (req, reply) => {
  492. const body = parse(z.object({ name: z.string().trim().min(1).max(60), allowedBotIds: z.array(z.string()) }), req.body)
  493. const r = app.botKeys.create(body.name, body.allowedBotIds)
  494. app.audit.log({ actor: 'admin', kind: 'admin', summary: `签发 Bot Key:${body.name}` })
  495. reply.code(201)
  496. return r
  497. })
  498. g.patch<IdParams>('/bot-keys/:id', async req => {
  499. const { enabled } = parse(z.object({ enabled: z.boolean() }), req.body)
  500. app.botKeys.setEnabled(req.params.id, enabled)
  501. app.audit.log({ actor: 'admin', kind: 'admin', summary: `${enabled ? '启用' : '停用'} Bot Key:${req.params.id}` })
  502. return { ok: true }
  503. })
  504. g.put<IdParams>('/bot-keys/:id/bots', async req => {
  505. const { allowedBotIds } = parse(z.object({ allowedBotIds: z.array(z.string()) }), req.body)
  506. app.botKeys.setBots(req.params.id, allowedBotIds)
  507. app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改 Bot Key 可用的机器人:${req.params.id}`, detail: { allowedBotIds } })
  508. return { ok: true }
  509. })
  510. // API 协议页面调试:以某个平台的身份调用 /bot/v1,签发 10 分钟的 Bot 浏览器令牌
  511. g.post<IdParams>('/bot-keys/:id/debug-token', async req => {
  512. const key = app.botKeys.list().find(k => k.id === req.params.id)
  513. if (!key) throw notFound('Bot Key')
  514. if (!key.enabled) throw new AppError(400, 'bad_request', `Bot Key「${key.name}」已停用`)
  515. app.audit.log({ actor: 'admin', kind: 'admin', summary: `API 协议页面以「${key.name}」的身份调试 Bot API` })
  516. return app.browserTokens.issue(key.id, 600, Date.now(), 'bot')
  517. })
  518. g.delete<IdParams>('/bot-keys/:id', async (req, reply) => {
  519. app.botKeys.delete(req.params.id)
  520. app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除 Bot Key:${req.params.id}` })
  521. reply.code(204)
  522. })
  523. // 后台调用总览(docs/design/15-background-calls.md §7)
  524. g.get('/background-calls', async () => app.backgroundCalls.overview())
  525. g.post<IdParams>('/background-calls/:id/cancel', async req => {
  526. const call = app.backgroundCalls.cancel(req.params.id, '管理员取消了这次调用')
  527. app.audit.log({ actor: 'admin', kind: 'admin', summary: `取消后台调用:${call.serverName}__${call.tool}` })
  528. return call
  529. })
  530. // 定时任务总览(docs/design/14-schedules.md §9)
  531. g.get('/schedules', async () => app.schedules.overview())
  532. g.get<IdParams>('/schedules/:id/runs', async req => app.schedules.runs(app.schedules.get(req.params.id).id))
  533. g.patch<IdParams>('/schedules/:id', async req => {
  534. const { status } = parse(z.object({ status: z.enum(['active', 'paused']) }), req.body)
  535. const s = app.schedules.setStatus(req.params.id, status)
  536. app.audit.log({ actor: 'admin', kind: 'admin', summary: `${status === 'active' ? '恢复' : '暂停'}定时任务:${s.title}` })
  537. return s
  538. })
  539. g.delete<IdParams>('/schedules/:id', async (req, reply) => {
  540. const s = app.schedules.get(req.params.id)
  541. app.schedules.delete(s.id)
  542. app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除定时任务:${s.title}` })
  543. reply.code(204)
  544. })
  545. // 工作组:复制到 Playground;回归用例(docs/design/13-bot-teams.md §12.1、§12.2)
  546. g.post<IdParams>('/teams/:id/copy', async (req, reply) => {
  547. const team = await app.teamCases.copy(req.params.id)
  548. reply.code(201)
  549. return app.teams.toDto(team)
  550. })
  551. g.get('/team-cases', async () => app.teamCases.list())
  552. g.post('/team-cases', async (req, reply) => {
  553. const c = await app.teamCases.create(req.body)
  554. app.audit.log({ actor: 'admin', kind: 'admin', summary: `保存回归用例:${c.name}` })
  555. reply.code(201)
  556. return c
  557. })
  558. g.patch<IdParams>('/team-cases/:id', async req => app.teamCases.update(req.params.id, req.body))
  559. g.delete<IdParams>('/team-cases/:id', async (req, reply) => {
  560. await app.teamCases.delete(req.params.id)
  561. app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除回归用例:${req.params.id}` })
  562. reply.code(204)
  563. })
  564. g.get<IdParams>('/team-cases/:id/runs', async req => app.teamCases.runs(req.params.id))
  565. // 重放一个或多个用例:每个用例新建一个后台工作组并发出第一条命令(会真实执行,产生成本)
  566. g.post('/team-cases/replay', async (req, reply) => {
  567. const { ids } = parse(z.object({ ids: z.array(z.string()).min(1).max(50) }), req.body)
  568. const created = []
  569. for (const id of ids) created.push(app.teams.toDto(await app.teamCases.replay(id)))
  570. app.audit.log({ actor: 'admin', kind: 'admin', summary: `重放回归用例 ${ids.length} 个`, detail: { ids } })
  571. reply.code(201)
  572. return created
  573. })
  574. // 审计
  575. g.get<{ Querystring: { kind?: AuditKind; sessionId?: string; limit?: string } }>('/audit', async req =>
  576. app.audit.list({ kind: req.query.kind, sessionId: req.query.sessionId, limit: req.query.limit ? Number(req.query.limit) : undefined }))
  577. })
  578. }
  579. const escapeHtml = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!)
  580. /** 授权结果页:通知打开它的后台页面刷新,然后可以关闭 */
  581. function oauthResultPage(r: { ok: boolean; title: string; message: string }) {
  582. const head = r.ok ? '授权成功' : '授权未完成'
  583. return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${head}</title>
  584. <style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:90vh;margin:0 16px;color:#222;background:#fafafa}
  585. @media (prefers-color-scheme:dark){body{color:#ddd;background:#1e1e1e}}main{max-width:420px;text-align:center}h1{font-size:20px}.ok{color:#2e7d32}.bad{color:#c62828}</style></head>
  586. <body><main><h1 class="${r.ok ? 'ok' : 'bad'}">${head}</h1>${r.title ? `<p>${escapeHtml(r.title)}</p>` : ''}<p>${escapeHtml(r.message)}</p></main>
  587. <script>try{window.opener&&window.opener.postMessage({type:'mcp-oauth',ok:${r.ok}},location.origin)}catch(e){}${r.ok ? 'setTimeout(function(){window.close()},1500)' : ''}</script></body></html>`
  588. }