// 管理 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 = 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() 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 }>('/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, 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('/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('/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('/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('/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) 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('/mcp/servers/:id', async req => app.mcpServers.update(req.params.id, req.body as McpServerInput)) g.delete('/mcp/servers/:id', async (req, reply) => { await app.mcpServers.delete(req.params.id) reply.code(204) }) g.post('/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('/mcp/servers/:id/oauth/start', async req => app.mcpOAuth.start(req.params.id, `${req.protocol}://${req.host}`)) g.delete('/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('/fs/list', async req => app.fileManager.list(fsRoot(req.query), req.query.path ?? '')) g.get('/fs/text', async req => app.fileManager.readText(fsRoot(req.query), req.query.path ?? '')) g.put('/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('/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('/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('/fs/mkdir', async req => { app.fileManager.mkdir(fsRoot(req.query), req.query.path ?? '') return { ok: true } }) g.post('/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('/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('/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('/custom-skills/:id', async req => app.customSkills.detail(req.params.id)) g.patch('/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('/custom-skills/:id', async (req, reply) => { app.customSkills.delete(req.params.id) reply.code(204) }) g.get('/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('/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('/custom-skills/:id/file', async req => app.customSkills.deleteFile(req.params.id, req.query.path ?? '')) g.post('/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('/custom-skills/:id/discard', async req => app.customSkills.discard(req.params.id)) g.post('/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('/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('/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('/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('/skill-sources/:id', async req => app.skillSources.update(req.params.id, parse(sourceSchema, req.body))) g.delete('/skill-sources/:id', async (req, reply) => { app.skillSources.delete(req.params.id) reply.code(204) }) g.post('/skill-sources/:id/scan', async req => app.skillSources.scan(req.params.id)) g.post('/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('/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('/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('/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('/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('/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('/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('/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('/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('/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('/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('/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('/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('/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('/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('/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('/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('/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('/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('/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('/schedules/:id/runs', async req => app.schedules.runs(app.schedules.get(req.params.id).id)) g.patch('/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('/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('/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('/team-cases/:id', async req => app.teamCases.update(req.params.id, req.body)) g.delete('/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('/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 `${head}

${head}

${r.title ? `

${escapeHtml(r.title)}

` : ''}

${escapeHtml(r.message)}

` }