| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623 |
- // 管理 API:仅后台登录态可访问
- import fs from 'node:fs'
- import type { FastifyInstance, FastifyRequest } from 'fastify'
- import mime from 'mime-types'
- import { z } from 'zod'
- import { BUILTIN_GLOBAL_DENY, COMMUNITY_SKILL_PLUGIN, CUSTOM_SKILL_PLUGIN, type AppSettings, type McpServerInput, type DeepPartial, type FileAccessPolicy, type PluginAction } from '@agent-server/shared'
- import type { App } from '../../app'
- import { AppError, badRequest, notFound } from '../../lib/errors'
- import { runClaudeCli } from '../../providers/claude/cli'
- import { CodexProvider } from '../../providers/codex/adapter'
- import { checkKnowledgePath, checkPathBasic, resolveAccess, toResolvedDto } from '../../services/access-policy'
- import { parse, type AuditKind, type KnowledgeInput } from '../../services/catalog'
- import { defaultSettings } from '../../services/settings'
- import { ADMIN_COOKIE, isAdmin, requireAdmin } from '../auth'
- import { providerStatuses } from './v1'
- import { API_SPECS } from '../../api-reference/reference'
- import { renderApiReferenceHtml } from '../../api-reference/render'
- import { APP_VERSION } from '../../config'
- /** 文档中示例使用的服务地址:后台页面传入浏览器看到的地址(经反向代理时与服务自身看到的不同),否则用请求的地址 */
- function referenceBase(req: FastifyRequest<{ Querystring: { base?: string } }>): string {
- const b = req.query.base
- if (b && /^https?:\/\/[a-z0-9.-]+(:\d+)?$/i.test(b)) return b
- return `${req.protocol}://${req.host}`
- }
- type IdParams = { Params: { id: string } }
- const communityRefSchema = z.object({ source: z.string().min(3).max(200), skillId: z.string().min(1).max(100) })
- const pluginActionSchema: z.ZodType<PluginAction> = z.union([
- z.object({ action: z.literal('marketplace-add'), source: z.string().trim().min(1).max(300) }),
- z.object({ action: z.enum(['marketplace-remove', 'marketplace-update']), name: z.string().min(1).max(120) }),
- z.object({ action: z.enum(['install', 'uninstall', 'update', 'enable', 'disable']), plugin: z.string().min(1).max(200) }),
- ])
- export default async function adminRoutes(f: FastifyInstance, { app }: { app: App }) {
- // ---- 登录(无需认证)----
- // 简单的失败限流:每个 IP 10 分钟内最多失败 5 次
- const failures = new Map<string, number[]>()
- f.post('/auth/login', async (req, reply) => {
- const { password } = parse(z.object({ password: z.string().min(1) }), req.body)
- const recent = (failures.get(req.ip) ?? []).filter(t => t > Date.now() - 600_000)
- if (recent.length >= 5) throw new AppError(429, 'too_many_attempts', '登录失败次数过多,请 10 分钟后再试')
- let s
- try {
- s = app.adminAuth.login(password)
- failures.delete(req.ip)
- } catch (err) {
- failures.set(req.ip, [...recent, Date.now()])
- throw err
- }
- reply.setCookie(ADMIN_COOKIE, s.id, { path: '/', httpOnly: true, sameSite: 'strict', secure: req.protocol === 'https', expires: s.expiresAt })
- app.audit.log({ actor: 'admin', kind: 'admin', summary: '后台登录', detail: { ip: req.ip } })
- return { ok: true }
- })
- f.post('/auth/logout', async (req, reply) => {
- app.adminAuth.logout(req.cookies[ADMIN_COOKIE])
- reply.clearCookie(ADMIN_COOKIE, { path: '/' })
- return { ok: true }
- })
- f.get('/auth/me', async req => ({ loggedIn: isAdmin(app, req) }))
- // OAuth 授权回调(docs/design/16-mcp-support.md §5):授权服务器跳转回来时不带后台登录(Cookie 为 SameSite=Strict),
- // 凭一次性的 state 找到发起授权的服务
- f.get<{ Querystring: Record<string, string> }>('/mcp/oauth/callback', async (req, reply) => {
- const r = await app.mcpOAuth.callback(req.query)
- reply.type('text/html; charset=utf-8').header('cache-control', 'no-store')
- return oauthResultPage(r)
- })
- // ---- 以下需要登录 ----
- f.register(async g => {
- g.addHook('preHandler', requireAdmin(app))
- g.post('/auth/password', async req => {
- const body = parse(z.object({ current: z.string(), next: z.string().min(8) }), req.body)
- app.adminAuth.changePassword(body.current, body.next)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: '修改后台密码' })
- return { ok: true }
- })
- g.get('/overview', async () => ({
- providers: await providerStatuses(app),
- runs: app.runs.stats(),
- version: APP_VERSION,
- }))
- g.get('/providers', async () => providerStatuses(app))
- // 引擎账户的订阅额度(较慢,概览页单独加载)
- g.get<{ Params: { id: string } }>('/providers/:id/quota', async req => {
- const provider = app.providers[req.params.id as 'claude' | 'codex']
- if (!provider?.quota) throw notFound(`引擎 ${req.params.id} 的额度信息`)
- return provider.quota(app.settings.get())
- })
- // 对外 API 协议:HTML(后台页面内嵌显示)与 PDF 下载
- type RefQuery = { Querystring: { theme?: string; base?: string } }
- const referenceHtml = (req: FastifyRequest<RefQuery>, theme: 'light' | 'dark') => renderApiReferenceHtml({
- baseUrl: referenceBase(req), theme, version: `v1 · 服务 ${APP_VERSION}`, generatedAt: new Date(),
- })
- g.get('/api-reference/meta', async () => ({ pdfAvailable: app.apiPdf.available }))
- // 后台页面用同一份协议定义自行渲染(可以在每个接口旁调试);HTML 版本用于 PDF 与新窗口打开
- g.get('/api-reference.json', async () => ({ version: `v1 · 服务 ${APP_VERSION}`, specs: API_SPECS }))
- g.get<RefQuery>('/api-reference.html', async (req, reply) => {
- reply.header('Content-Type', 'text/html; charset=utf-8')
- // 文档不需要脚本
- reply.header('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'self'")
- return referenceHtml(req, req.query.theme === 'dark' ? 'dark' : 'light')
- })
- g.get<RefQuery>('/api-reference.pdf', async (req, reply) => {
- if (!app.apiPdf.available) throw new AppError(503, 'pdf_unavailable', '服务器上没有安装 Chrome / Chromium,请使用浏览器打印保存为 PDF')
- const pdf = await app.apiPdf.render(referenceHtml(req, 'light'))
- const name = encodeURIComponent(`AgentServer API 协议 ${new Date().toISOString().slice(0, 10)}.pdf`)
- reply.header('Content-Type', 'application/pdf')
- reply.header('Content-Disposition', `attachment; filename*=UTF-8''${name}`)
- return reply.send(pdf)
- })
- // 用量统计(全部来源)与最近的失败
- g.get<{ Querystring: { days?: string } }>('/usage', async req => app.usage.summary({ days: Number(req.query.days ?? 30) || 30 }))
- g.get<{ Querystring: { limit?: string } }>('/usage/errors', async req => app.usage.recentErrors(Number(req.query.limit ?? 10) || 10))
- // 在后台登录 Claude(以服务账号运行官方 CLI 的登录流程)
- g.get('/providers/claude/login', async () => app.claudeLogin.current())
- g.post('/providers/claude/login', async req => {
- const body = parse(z.object({ sso: z.boolean().optional(), email: z.string().trim().max(200).optional() }), req.body ?? {})
- return app.claudeLogin.start({ sso: body.sso, email: body.email || undefined })
- })
- g.post<IdParams>('/providers/claude/login/:id/code', async req => {
- const { code } = parse(z.object({ code: z.string().min(1).max(2000) }), req.body)
- return app.claudeLogin.submitCode(req.params.id, code)
- })
- g.delete<IdParams>('/providers/claude/login/:id', async (req, reply) => {
- app.claudeLogin.cancel(req.params.id)
- reply.code(204)
- })
- // Codex 登录:设备码方式,在 OpenAI 官方页面输入短码完成
- const codex = () => {
- const p = app.providers.codex
- if (!(p instanceof CodexProvider)) throw new AppError(400, 'provider_unavailable', 'Codex 未接入')
- return p
- }
- g.get('/providers/codex/login', async () => codex().loginState())
- g.post('/providers/codex/login', async () => {
- const r = await codex().startLogin(app.settings.get())
- app.audit.log({ actor: 'admin', kind: 'admin', summary: '后台发起 Codex 登录(设备码)' })
- return r
- })
- g.delete('/providers/codex/login', async (_req, reply) => {
- await codex().cancelLogin(app.settings.get())
- reply.code(204)
- })
- g.post('/providers/codex/logout', async () => {
- await codex().logout(app.settings.get())
- app.audit.log({ actor: 'admin', kind: 'admin', summary: 'Codex 退出登录' })
- return { ok: true }
- })
- g.post('/providers/claude/logout', async () => {
- const r = await runClaudeCli(app.settings.get().providers.claude.cliPath, ['auth', 'logout'], { timeoutMs: 30_000 })
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `Claude 退出登录${r.code === 0 ? '' : '失败'}` })
- if (r.code !== 0) throw new AppError(502, 'logout_failed', (r.stderr || r.stdout).trim().slice(-300) || '退出登录失败')
- return { ok: true }
- })
- // 设置
- g.get('/settings', async () => ({ settings: app.settings.get(), defaults: defaultSettings(), builtinGlobalDeny: BUILTIN_GLOBAL_DENY }))
- g.patch('/settings', async req => {
- const next = app.settings.update(req.body as DeepPartial<AppSettings>)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: '修改系统设置', detail: req.body })
- return { settings: next }
- })
- // 某个引擎可选的技能:Claude = 本地技能 + 已启用插件 + 第三方技能;Codex = Codex 实际可见的技能
- g.get<{ Querystring: { engine?: string } }>('/skills', async req => app.skillCatalog(req.query.engine === 'codex' ? 'codex' : 'claude'))
- // MCP 服务:内置工具(只读展示)+ 第三方服务(经 MCP Gateway 转接,ADR-0021)
- g.get('/mcp', async () => app.mcpServers.state())
- g.post('/mcp/servers', async (req, reply) => {
- reply.code(201)
- return app.mcpServers.create(req.body as McpServerInput)
- })
- g.put<IdParams>('/mcp/servers/:id', async req => app.mcpServers.update(req.params.id, req.body as McpServerInput))
- g.delete<IdParams>('/mcp/servers/:id', async (req, reply) => {
- await app.mcpServers.delete(req.params.id)
- reply.code(204)
- })
- g.post<IdParams>('/mcp/servers/:id/refresh', async req => app.mcpServers.refresh(req.params.id))
- g.put<{ Params: { id: string; tool: string } }>('/mcp/servers/:id/tools/:tool', async req => {
- const policy = parse(z.object({ policy: z.enum(['auto', 'ask', 'confirm', 'off']) }), req.body).policy
- return app.mcpServers.setToolPolicy(req.params.id, req.params.tool, policy)
- })
- g.put<{ Params: { id: string; tool: string } }>('/mcp/servers/:id/tools/:tool/hints', async req => {
- const hints = parse(z.object({ readOnly: z.boolean().optional(), destructive: z.boolean().optional(), idempotent: z.boolean().optional() }), req.body)
- return app.mcpServers.setToolHints(req.params.id, req.params.tool, hints)
- })
- g.put<{ Params: { id: string; tool: string } }>('/mcp/servers/:id/tools/:tool/background', async req => {
- const { background } = parse(z.object({ background: z.boolean() }), req.body)
- return app.mcpServers.setToolBackground(req.params.id, req.params.tool, background)
- })
- g.post<IdParams>('/mcp/servers/:id/oauth/start', async req => app.mcpOAuth.start(req.params.id, `${req.protocol}://${req.host}`))
- g.delete<IdParams>('/mcp/servers/:id/oauth', async (req, reply) => {
- await app.mcpOAuth.revoke(req.params.id)
- reply.code(204)
- })
- g.post('/mcp/test', async req => {
- const b = req.body as { input: McpServerInput; id?: string | null }
- return app.mcpServers.test(b.input, b.id ?? null)
- })
- // 文件管理(管理员):位置 + 相对路径
- type FsQuery = { Querystring: { root?: string; path?: string; inline?: string } }
- const fsRoot = (q: { root?: string }) => q.root ?? ''
- g.get('/fs/roots', async () => ({ roots: app.fileManager.roots(), shortcuts: app.fileManager.shortcuts() }))
- g.get<FsQuery>('/fs/list', async req => app.fileManager.list(fsRoot(req.query), req.query.path ?? ''))
- g.get<FsQuery>('/fs/text', async req => app.fileManager.readText(fsRoot(req.query), req.query.path ?? ''))
- g.put<FsQuery>('/fs/text', async req => {
- const b = parse(z.object({ content: z.string(), baseMtime: z.string().nullable() }), req.body)
- return app.fileManager.writeText(fsRoot(req.query), req.query.path ?? '', b.content, b.baseMtime)
- })
- g.get<FsQuery>('/fs/download', async (req, reply) => {
- const f = app.fileManager.openFile(fsRoot(req.query), req.query.path ?? '')
- const type = (mime.lookup(f.name) || 'application/octet-stream') as string
- reply.header('Content-Type', type)
- reply.header('Content-Disposition', `${req.query.inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(f.name)}`)
- reply.header('X-Content-Type-Options', 'nosniff')
- // 预览 HTML / SVG 时禁止执行脚本
- reply.header('Content-Security-Policy', "sandbox; default-src 'none'; img-src data: 'self'; style-src 'unsafe-inline'")
- return reply.send(fs.createReadStream(f.abs))
- })
- g.get<FsQuery>('/fs/zip', async (req, reply) => {
- const z_ = app.fileManager.zipDir(fsRoot(req.query), req.query.path ?? '')
- reply.header('Content-Type', 'application/zip')
- reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(z_.name)}`)
- return reply.send(z_.data)
- })
- g.post<FsQuery>('/fs/mkdir', async req => {
- app.fileManager.mkdir(fsRoot(req.query), req.query.path ?? '')
- return { ok: true }
- })
- g.post<FsQuery>('/fs/upload', async (req, reply) => {
- const limit = app.settings.get().limits.uploadMaxMB * 1048576
- const saved = []
- for await (const part of req.files({ limits: { fileSize: limit, files: 50 } })) {
- const data = await part.toBuffer()
- if (part.file.truncated) throw new AppError(413, 'file_too_large', `文件超过 ${app.settings.get().limits.uploadMaxMB} MB`)
- saved.push(app.fileManager.upload(fsRoot(req.query), req.query.path ?? '', part.filename, data))
- }
- if (!saved.length) throw badRequest('没有收到文件')
- reply.code(201)
- return saved
- })
- g.post<FsQuery>('/fs/move', async req => {
- const b = parse(z.object({ to: z.string().min(1) }), req.body)
- app.fileManager.move(fsRoot(req.query), req.query.path ?? '', b.to)
- return { ok: true }
- })
- g.delete<FsQuery>('/fs/entry', async req => {
- app.fileManager.delete(fsRoot(req.query), req.query.path ?? '')
- return { ok: true }
- })
- // 自定义技能(docs/design/12-custom-skills.md)
- type SkillParams = { Params: { id: string } }
- type FileQuery = { Params: { id: string }; Querystring: { path?: string; version?: string } }
- const optVersion = (v: string | undefined) => (v === undefined || v === '' ? undefined : Number(v))
- g.get('/custom-skills', async () => app.customSkills.list())
- g.post('/custom-skills', async (req, reply) => {
- reply.code(201)
- return app.customSkills.create(parse(z.object({ name: z.string(), description: z.string() }), req.body))
- })
- g.get<SkillParams>('/custom-skills/:id', async req => app.customSkills.detail(req.params.id))
- g.patch<SkillParams>('/custom-skills/:id', async req =>
- app.customSkills.setFlags(req.params.id, parse(z.object({ enabled: z.boolean().optional(), includeInAll: z.boolean().optional() }), req.body)))
- g.delete<SkillParams>('/custom-skills/:id', async (req, reply) => {
- app.customSkills.delete(req.params.id)
- reply.code(204)
- })
- g.get<FileQuery>('/custom-skills/:id/file', async (req, reply) => {
- const f = app.customSkills.readFile(req.params.id, req.query.path ?? '', optVersion(req.query.version))
- reply.header('Content-Type', f.text ? 'text/plain; charset=utf-8' : 'application/octet-stream')
- reply.header('Content-Disposition', `${f.text ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(f.name)}`)
- reply.header('X-Content-Type-Options', 'nosniff')
- return reply.send(f.data)
- })
- // 文本文件:JSON { content };二进制文件:multipart(字段 file)
- g.put<FileQuery>('/custom-skills/:id/file', async req => {
- if (req.isMultipart()) {
- const part = await req.file({ limits: { fileSize: 10 * 1048576 } })
- if (!part) throw badRequest('没有收到文件')
- const data = await part.toBuffer()
- if (part.file.truncated) throw new AppError(413, 'file_too_large', '单个文件不能超过 10 MB')
- return app.customSkills.writeFile(req.params.id, req.query.path ?? part.filename, data)
- }
- const body = parse(z.object({ content: z.string() }), req.body)
- return app.customSkills.writeFile(req.params.id, req.query.path ?? '', Buffer.from(body.content, 'utf8'))
- })
- g.delete<FileQuery>('/custom-skills/:id/file', async req => app.customSkills.deleteFile(req.params.id, req.query.path ?? ''))
- g.post<SkillParams>('/custom-skills/:id/move', async req => {
- const b = parse(z.object({ from: z.string(), to: z.string() }), req.body)
- return app.customSkills.move(req.params.id, b.from, b.to)
- })
- g.post<SkillParams>('/custom-skills/:id/discard', async req => app.customSkills.discard(req.params.id))
- g.post<SkillParams>('/custom-skills/:id/save', async req =>
- app.customSkills.save(req.params.id, parse(z.object({ baseVersion: z.number().int(), publish: z.boolean() }), req.body)))
- g.post<SkillParams>('/custom-skills/:id/publish', async req =>
- app.customSkills.publish(req.params.id, parse(z.object({ version: z.number().int() }), req.body).version))
- 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)))
- g.get<{ Params: { id: string }; Querystring: { from?: string; to?: string } }>('/custom-skills/:id/diff', async req => {
- const v = (x: string | undefined) => (x === 'working' ? 'working' as const : Number(x))
- return app.customSkills.diff(req.params.id, v(req.query.from), v(req.query.to))
- })
- g.get<FileQuery>('/custom-skills/:id/export', async (req, reply) => {
- const z_ = app.customSkills.exportZip(req.params.id, optVersion(req.query.version))
- reply.header('Content-Type', 'application/zip')
- reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(z_.name)}`)
- return reply.send(z_.data)
- })
- g.post<SkillParams>('/custom-skills/:id/detach', async req => app.customSkills.detach(req.params.id))
- // 技能源:服务器目录;上传的 zip 一次性导入
- const sourceSchema = z.object({
- name: z.string(), description: z.string().default(''), path: z.string().default(''),
- syncMode: z.enum(['manual', 'auto']).default('manual'), intervalMin: z.number().default(10),
- })
- g.get('/skill-sources', async () => app.skillSources.list())
- g.post('/skill-sources', async (req, reply) => {
- reply.code(201)
- return app.skillSources.create(parse(sourceSchema, req.body))
- })
- g.put<SkillParams>('/skill-sources/:id', async req => app.skillSources.update(req.params.id, parse(sourceSchema, req.body)))
- g.delete<SkillParams>('/skill-sources/:id', async (req, reply) => {
- app.skillSources.delete(req.params.id)
- reply.code(204)
- })
- g.post<SkillParams>('/skill-sources/:id/scan', async req => app.skillSources.scan(req.params.id))
- g.post<SkillParams>('/skill-sources/:id/import', async req => {
- const b = parse(z.object({ skills: z.array(z.string()).min(1), digest: z.string() }), req.body)
- return app.skillSources.import(req.params.id, b.skills, b.digest)
- })
- g.post('/skill-sources/upload', async (req, reply) => {
- const part = await req.file({ limits: { fileSize: 50 * 1048576 } })
- if (!part) throw badRequest('没有收到文件')
- const data = await part.toBuffer()
- if (part.file.truncated) throw new AppError(413, 'file_too_large', 'zip 文件不能超过 50 MB')
- reply.code(201)
- return app.skillSources.upload(part.filename, data)
- })
- g.post<SkillParams>('/skill-sources/uploads/:id/import', async req => {
- const b = parse(z.object({ skills: z.array(z.string()).min(1), digest: z.string() }), req.body)
- return app.skillSources.importUpload(req.params.id, b.skills, b.digest)
- })
- g.delete<SkillParams>('/skill-sources/uploads/:id', async (req, reply) => {
- app.skillSources.discardUpload(req.params.id)
- reply.code(204)
- })
- // 选择服务器目录(登记技能源、资料库时):只列子目录名
- g.get<{ Querystring: { path?: string } }>('/server-dirs', async req => app.skillSources.listDirs(req.query.path ?? '/'))
- // 第三方技能(skills.sh 目录 → GitHub,两个引擎共用)
- g.get('/community-skills', async () => app.communitySkills.list())
- g.get<{ Querystring: { q?: string } }>('/community-skills/search', async req => app.communitySkills.search(req.query.q ?? ''))
- g.post('/community-skills/preview', async req => {
- const b = parse(communityRefSchema, req.body)
- return app.communitySkills.preview(b.source, b.skillId)
- })
- g.post('/community-skills/install', async req => {
- const b = parse(communityRefSchema.extend({ sha: z.string() }), req.body)
- return app.communitySkills.install(b.source, b.skillId, b.sha)
- })
- g.delete<{ Params: { name: string } }>('/community-skills/:name', async req => app.communitySkills.uninstall(req.params.name))
- // Codex 插件(openai-curated 目录,只在 Playground 中使用)
- g.get<{ Querystring: { q?: string; category?: string; installed?: string; offset?: string; limit?: string; refresh?: string } }>('/codex-plugins', async req =>
- app.codexPlugins.catalog({
- query: req.query.q, category: req.query.category || undefined, installed: req.query.installed === '1',
- offset: Number(req.query.offset ?? 0) || 0, limit: Number(req.query.limit ?? 50) || 50, refresh: req.query.refresh === '1',
- }))
- g.get<{ Params: { name: string } }>('/codex-plugins/:name', async req => app.codexPlugins.detail(req.params.name))
- g.post<{ Params: { name: string } }>('/codex-plugins/:name/install', async req => app.codexPlugins.install(req.params.name))
- g.post<{ Params: { name: string } }>('/codex-plugins/:name/uninstall', async req => app.codexPlugins.uninstall(req.params.name))
- // Codex 官方技能(openai/skills)
- g.get('/codex-skills', async () => app.codexSkills.catalog())
- g.post('/codex-skills/refresh', async () => app.codexSkills.refresh())
- g.post<{ Params: { name: string } }>('/codex-skills/:name', async req => app.codexSkills.install(req.params.name))
- g.delete<{ Params: { name: string } }>('/codex-skills/:name', async req => app.codexSkills.uninstall(req.params.name))
- // 技能与插件(调用 Claude Code 自带的插件命令)
- g.get<{ Querystring: { refresh?: string } }>('/plugins', async req => app.plugins.state(req.query.refresh === '1'))
- g.post('/plugins/actions', async req => app.plugins.act(parse(pluginActionSchema, req.body)))
- g.post('/paths/check', async req => {
- const { path, purpose } = parse(z.object({ path: z.string().min(1), purpose: z.enum(['knowledge', 'generic']).default('knowledge') }), req.body)
- return purpose === 'generic' ? checkPathBasic(path) : checkKnowledgePath(path, app.settings.get())
- })
- // Profile
- g.get('/profiles', async () => app.profiles.list())
- g.get<IdParams>('/profiles/:id', async req => app.profiles.get(req.params.id))
- g.post('/profiles', async (req, reply) => {
- reply.code(201)
- const p = app.profiles.create(req.body as never)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `创建 Profile:${p.name}` })
- return p
- })
- g.put<IdParams>('/profiles/:id', async req => {
- const p = app.profiles.update(req.params.id, req.body as never)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改 Profile:${p.name}`, detail: { version: p.version } })
- return p
- })
- g.get<IdParams>('/profiles/:id/versions', async req => app.profiles.versions(req.params.id))
- g.post<{ Params: { id: string; version: string } }>('/profiles/:id/versions/:version/restore', async req => {
- const p = app.profiles.restore(req.params.id, Number(req.params.version))
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `恢复 Profile:${p.name} → 版本 ${req.params.version}(保存为版本 ${p.version})` })
- return p
- })
- g.delete<IdParams>('/profiles/:id', async (req, reply) => {
- const refs = app.apiKeys.referencing(req.params.id)
- if (refs.length) throw new AppError(400, 'profile_in_use', `这个 Profile 正被 Agent Key 使用:${refs.join('、')},请先在 Agent Keys 页面取消勾选`)
- const botRefs = app.bots.referencing(req.params.id)
- if (botRefs.length) throw new AppError(400, 'profile_in_use', `这个 Profile 正被机器人使用:${botRefs.join('、')},请先在机器人页面修改`)
- app.profiles.delete(req.params.id)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除 Profile:${req.params.id}` })
- reply.code(204)
- })
- /** 文件访问策略生效预览(06 §4.3) */
- g.post('/access/preview', async req => {
- const body = req.body as { fileAccess: FileAccessPolicy; knowledgeRootIds: string[] }
- const settings = app.settings.get()
- return toResolvedDto(resolveAccess({
- policy: body.fileAccess,
- knowledgeRoots: app.knowledge.list(),
- selectedKnowledgeRootIds: body.knowledgeRootIds ?? [],
- workspaceDir: `${settings.paths.workspaceRoot}/<会话ID>`,
- settings,
- }))
- })
- // 资料库
- g.get('/knowledge', async () => app.knowledge.list())
- g.post('/knowledge', async (req, reply) => {
- reply.code(201)
- const k = app.knowledge.create(req.body as KnowledgeInput)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `添加资料库:${k.name}(${k.path})` })
- return k
- })
- g.put<IdParams>('/knowledge/:id', async req => {
- const k = app.knowledge.update(req.params.id, req.body as KnowledgeInput)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改资料库:${k.name}(${k.path})` })
- return k
- })
- g.delete<IdParams>('/knowledge/:id', async (req, reply) => {
- app.knowledge.delete(req.params.id)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除资料库:${req.params.id}` })
- reply.code(204)
- })
- // Agent Key
- g.get('/api-keys', async () => app.apiKeys.list())
- g.post('/api-keys', async (req, reply) => {
- const body = parse(z.object({ name: z.string().trim().min(1).max(60), allowedProfileIds: z.array(z.string()), defaultProfileId: z.string() }), req.body)
- reply.code(201)
- const name = body.name
- const r = app.apiKeys.create(name, body)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `签发 Agent Key:${name}` })
- return r
- })
- g.patch<IdParams>('/api-keys/:id', async req => {
- const { enabled } = parse(z.object({ enabled: z.boolean() }), req.body)
- app.apiKeys.setEnabled(req.params.id, enabled)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `${enabled ? '启用' : '停用'} Agent Key:${req.params.id}` })
- return { ok: true }
- })
- g.put<IdParams>('/api-keys/:id/profiles', async req => {
- const body = parse(z.object({ allowedProfileIds: z.array(z.string()), defaultProfileId: z.string() }), req.body)
- app.apiKeys.setProfiles(req.params.id, body)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改 Agent Key 可用的 Profile:${req.params.id}`, detail: body })
- return { ok: true }
- })
- // API 协议页面调试:以某个平台的身份调用 /v1,签发 10 分钟的浏览器令牌(Agent Key 本身不离开服务端)
- g.post<IdParams>('/api-keys/:id/debug-token', async req => {
- const key = app.apiKeys.list().find(k => k.id === req.params.id)
- if (!key) throw notFound('Agent Key')
- if (!key.enabled) throw new AppError(400, 'bad_request', `Agent Key「${key.name}」已停用`)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `API 协议页面以「${key.name}」的身份调试接口` })
- return app.browserTokens.issue(key.id, 600)
- })
- g.delete<IdParams>('/api-keys/:id', async (req, reply) => {
- app.apiKeys.delete(req.params.id)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除 Agent Key:${req.params.id}` })
- reply.code(204)
- })
- // 机器人与 Bot Key(docs/design/13-bot-teams.md §12.1)
- const botSchema = z.object({
- name: z.string(), avatar: z.string(), kind: z.enum(['lead', 'worker']), description: z.string(), profileId: z.string(), enabled: z.boolean(),
- })
- g.get('/bots', async () => app.bots.list())
- g.post('/bots', async (req, reply) => {
- const b = app.bots.create(parse(botSchema, req.body))
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `新建机器人:${b.name}` })
- reply.code(201)
- return b
- })
- g.put<IdParams>('/bots/:id', async req => {
- const b = app.bots.update(req.params.id, parse(botSchema, req.body))
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改机器人:${b.name}` })
- return b
- })
- g.delete<IdParams>('/bots/:id', async (req, reply) => {
- const b = app.bots.get(req.params.id)
- app.bots.delete(b.id)
- app.botKeys.forgetBot(b.id)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除机器人:${b.name}` })
- reply.code(204)
- })
- g.get('/bot-keys', async () => app.botKeys.list())
- g.post('/bot-keys', async (req, reply) => {
- const body = parse(z.object({ name: z.string().trim().min(1).max(60), allowedBotIds: z.array(z.string()) }), req.body)
- const r = app.botKeys.create(body.name, body.allowedBotIds)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `签发 Bot Key:${body.name}` })
- reply.code(201)
- return r
- })
- g.patch<IdParams>('/bot-keys/:id', async req => {
- const { enabled } = parse(z.object({ enabled: z.boolean() }), req.body)
- app.botKeys.setEnabled(req.params.id, enabled)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `${enabled ? '启用' : '停用'} Bot Key:${req.params.id}` })
- return { ok: true }
- })
- g.put<IdParams>('/bot-keys/:id/bots', async req => {
- const { allowedBotIds } = parse(z.object({ allowedBotIds: z.array(z.string()) }), req.body)
- app.botKeys.setBots(req.params.id, allowedBotIds)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `修改 Bot Key 可用的机器人:${req.params.id}`, detail: { allowedBotIds } })
- return { ok: true }
- })
- // API 协议页面调试:以某个平台的身份调用 /bot/v1,签发 10 分钟的 Bot 浏览器令牌
- g.post<IdParams>('/bot-keys/:id/debug-token', async req => {
- const key = app.botKeys.list().find(k => k.id === req.params.id)
- if (!key) throw notFound('Bot Key')
- if (!key.enabled) throw new AppError(400, 'bad_request', `Bot Key「${key.name}」已停用`)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `API 协议页面以「${key.name}」的身份调试 Bot API` })
- return app.browserTokens.issue(key.id, 600, Date.now(), 'bot')
- })
- g.delete<IdParams>('/bot-keys/:id', async (req, reply) => {
- app.botKeys.delete(req.params.id)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除 Bot Key:${req.params.id}` })
- reply.code(204)
- })
- // 后台调用总览(docs/design/15-background-calls.md §7)
- g.get('/background-calls', async () => app.backgroundCalls.overview())
- g.post<IdParams>('/background-calls/:id/cancel', async req => {
- const call = app.backgroundCalls.cancel(req.params.id, '管理员取消了这次调用')
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `取消后台调用:${call.serverName}__${call.tool}` })
- return call
- })
- // 定时任务总览(docs/design/14-schedules.md §9)
- g.get('/schedules', async () => app.schedules.overview())
- g.get<IdParams>('/schedules/:id/runs', async req => app.schedules.runs(app.schedules.get(req.params.id).id))
- g.patch<IdParams>('/schedules/:id', async req => {
- const { status } = parse(z.object({ status: z.enum(['active', 'paused']) }), req.body)
- const s = app.schedules.setStatus(req.params.id, status)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `${status === 'active' ? '恢复' : '暂停'}定时任务:${s.title}` })
- return s
- })
- g.delete<IdParams>('/schedules/:id', async (req, reply) => {
- const s = app.schedules.get(req.params.id)
- app.schedules.delete(s.id)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除定时任务:${s.title}` })
- reply.code(204)
- })
- // 工作组:复制到 Playground;回归用例(docs/design/13-bot-teams.md §12.1、§12.2)
- g.post<IdParams>('/teams/:id/copy', async (req, reply) => {
- const team = await app.teamCases.copy(req.params.id)
- reply.code(201)
- return app.teams.toDto(team)
- })
- g.get('/team-cases', async () => app.teamCases.list())
- g.post('/team-cases', async (req, reply) => {
- const c = await app.teamCases.create(req.body)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `保存回归用例:${c.name}` })
- reply.code(201)
- return c
- })
- g.patch<IdParams>('/team-cases/:id', async req => app.teamCases.update(req.params.id, req.body))
- g.delete<IdParams>('/team-cases/:id', async (req, reply) => {
- await app.teamCases.delete(req.params.id)
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `删除回归用例:${req.params.id}` })
- reply.code(204)
- })
- g.get<IdParams>('/team-cases/:id/runs', async req => app.teamCases.runs(req.params.id))
- // 重放一个或多个用例:每个用例新建一个后台工作组并发出第一条命令(会真实执行,产生成本)
- g.post('/team-cases/replay', async (req, reply) => {
- const { ids } = parse(z.object({ ids: z.array(z.string()).min(1).max(50) }), req.body)
- const created = []
- for (const id of ids) created.push(app.teams.toDto(await app.teamCases.replay(id)))
- app.audit.log({ actor: 'admin', kind: 'admin', summary: `重放回归用例 ${ids.length} 个`, detail: { ids } })
- reply.code(201)
- return created
- })
- // 审计
- g.get<{ Querystring: { kind?: AuditKind; sessionId?: string; limit?: string } }>('/audit', async req =>
- app.audit.list({ kind: req.query.kind, sessionId: req.query.sessionId, limit: req.query.limit ? Number(req.query.limit) : undefined }))
- })
- }
- const escapeHtml = (s: string) => s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)
- /** 授权结果页:通知打开它的后台页面刷新,然后可以关闭 */
- function oauthResultPage(r: { ok: boolean; title: string; message: string }) {
- const head = r.ok ? '授权成功' : '授权未完成'
- 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>
- <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}
- @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>
- <body><main><h1 class="${r.ok ? 'ok' : 'bad'}">${head}</h1>${r.title ? `<p>${escapeHtml(r.title)}</p>` : ''}<p>${escapeHtml(r.message)}</p></main>
- <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>`
- }
|