// 业务 API(docs/design/04-api.md) import fs from 'node:fs' import path from 'node:path' import type { FastifyInstance, FastifyRequest } from 'fastify' import type { CreateBrowserTokenBody, InteractionResponse, ProviderStatusDto, SessionDetailDto } from '@agent-server/shared' import { z } from 'zod' import type { App } from '../../app' import { AppError, badRequest, notFound } from '../../lib/errors' import { BROWSER_TOKEN_MAX_TTL } from '../../services/browser-tokens' import { parse } from '../../services/catalog' import { principal, requireClient } from '../auth' import { streamSessionEvents } from '../sse' import { forgetGatewaySession } from '../../mcp-gateway/server' type IdParams = { Params: { id: string } } const interactionResponseSchema: z.ZodType = z.union([ z.object({ decision: z.enum(['allow_once', 'allow_session']) }), z.object({ decision: z.literal('deny'), reason: z.string().max(2000).optional() }), z.object({ answers: z.record(z.string(), z.union([z.string(), z.array(z.string())])) }), ]) export async function providerStatuses(app: App): Promise { const settings = app.settings.get() const result: ProviderStatusDto[] = [] for (const id of ['claude', 'codex'] as const) { const p = app.providers[id] if (p) { result.push(await p.status(settings)) } else { const s = settings.providers[id] result.push({ id, enabled: s.enabled, available: false, loggedIn: null, account: null, cliPath: s.cliPath, version: null, models: s.models, defaultModel: s.defaultModel, message: '尚未接入(M2)', }) } } return result } /** 已注册的 /v1 路由(方法 + 路径),用于核对 API 协议文档是否完整(test/api-reference.test.ts) */ export const registeredV1Routes: { method: string; path: string }[] = [] export default async function v1Routes(f: FastifyInstance, { app }: { app: App }) { f.addHook('onRoute', r => { for (const m of [r.method].flat()) if (m !== 'HEAD' && m !== 'OPTIONS') registeredV1Routes.push({ method: m, path: r.url }) }) f.addHook('preHandler', requireClient(app)) const sessionFor = (req: FastifyRequest) => app.sessions.getRow(req.params.id, principal(req).source) // 引擎状态缓存 30 秒,避免频繁调用 CLI let statusCache: { at: number; data: ProviderStatusDto[] } | null = null // 业务平台的后端用 Agent Key 换取浏览器令牌(ADR-0019) f.post('/browser-tokens', async (req, reply) => { const p = principal(req) if (p.kind !== 'apikey' || p.browser) throw new AppError(403, 'forbidden', '只能用 Agent Key 签发浏览器令牌') const body = (req.body ?? {}) as CreateBrowserTokenBody const ttl = body.ttlSeconds === undefined ? undefined : Number(body.ttlSeconds) if (ttl !== undefined && !(ttl >= 60 && ttl <= BROWSER_TOKEN_MAX_TTL)) throw badRequest(`ttlSeconds 应在 60 ~ ${BROWSER_TOKEN_MAX_TTL} 之间`) reply.code(201) return app.browserTokens.issue(p.source, ttl) }) // 本平台(调用者自己的来源)的用量 f.get<{ Querystring: { days?: string } }>('/usage', async req => app.usage.summary({ days: Number(req.query.days ?? 30) || 30, source: principal(req).source })) f.get('/providers', async () => { if (!statusCache || Date.now() - statusCache.at > 30_000) statusCache = { at: Date.now(), data: await providerStatuses(app) } return statusCache.data }) // 平台只能看到 Agent Key 上配置的 Profile;isDefault 表示该平台的默认 Profile f.get('/profiles', async req => { const p0 = principal(req) const acc = p0.kind === 'admin' ? null : app.apiKeys.access(p0.source) return app.profiles.list() .filter(p => !acc || acc.allowedProfileIds.includes(p.id)) .map(p => ({ id: p.id, name: p.name, description: p.description, provider: p.provider, model: p.model, permissionMode: p.permissionMode, isDefault: acc ? p.id === acc.defaultProfileId : p.isDefault, })) }) // ---- 会话 ---- f.post('/sessions', async (req, reply) => { const row = await app.sessions.create(principal(req).source, (req.body ?? {}) as object) reply.code(201) return app.sessions.toDto(row) }) f.get<{ Querystring: { cursor?: string; limit?: string; scope?: string; archived?: string } }>('/sessions', async req => { const q = req.query return app.sessions.list(principal(req).source, { cursor: q.cursor, limit: q.limit ? Number(q.limit) : undefined, all: q.scope === 'all', archived: q.archived === undefined ? undefined : q.archived === 'true', }) }) f.get('/sessions/:id', async (req): Promise => { const row = sessionFor(req) const eff = app.sessions.effectiveProfile(row) return { ...app.sessions.toDto(row), activeRunId: app.runs.activeForSession(row.id)?.runId ?? null, pendingInteractions: app.interactions.pendingFor(row.id), permissionMode: eff.permissionMode, permissionModeOverridden: eff.overridden, systemPromptAppend: row.systemPromptAppend, } }) f.patch('/sessions/:id', async req => app.sessions.toDto(app.sessions.patch(req.params.id, principal(req).source, req.body))) f.delete('/sessions/:id', async (req, reply) => { await app.sessions.delete(req.params.id, principal(req).source) app.schedules.deleteFor({ kind: 'session', sessionId: req.params.id }) app.backgroundCalls.cancelFor({ sessionId: req.params.id }) app.gatewayTokens.forgetSession(req.params.id) forgetGatewaySession(req.params.id) reply.code(204) }) // ---- 定时任务(docs/design/14-schedules.md):由 AI 在对话中创建,平台可以查看、暂停、恢复、删除 ---- f.get('/sessions/:id/schedules', async req => app.schedules.list({ kind: 'session', sessionId: sessionFor(req).id })) const ownSchedule = (req: FastifyRequest) => app.schedules.getFor(req.params.id, t => { if (t.kind !== 'session') throw notFound('定时任务') app.sessions.getRow(t.sessionId, principal(req).source) }) f.patch('/schedules/:id', async req => { const { status } = parse(z.object({ status: z.enum(['active', 'paused']) }), req.body) return app.schedules.setStatus(ownSchedule(req).id, status) }) f.delete('/schedules/:id', async (req, reply) => { app.schedules.delete(ownSchedule(req).id) reply.code(204) }) f.get('/schedules/:id/runs', async req => app.schedules.runs(ownSchedule(req).id)) // ---- 后台调用(docs/design/15-background-calls.md):外部工具的长调用,平台可以查看和取消 ---- f.get('/sessions/:id/background-calls', async req => app.backgroundCalls.list({ sessionId: sessionFor(req).id })) f.post('/background-calls/:id/cancel', async req => { const call = app.backgroundCalls.get(req.params.id) if (call.teamId) throw notFound('后台调用') app.sessions.getRow(call.sessionId, principal(req).source) return app.backgroundCalls.cancel(call.id) }) f.get('/sessions/:id/events', async req => { const row = sessionFor(req) return { items: app.events.list(row.id, Number(req.query.after ?? 0), Math.min(Number(req.query.limit ?? 5000), 5000)), lastSeq: row.lastSeq } }) f.get('/sessions/:id/stream', async (req, reply) => { const row = sessionFor(req) const lastId = Number(req.headers['last-event-id'] ?? req.query.after ?? 0) || 0 streamSessionEvents(app, req, reply, row.id, lastId) }) // ---- 消息与 Run ---- f.post('/sessions/:id/messages', async (req, reply) => { const row = sessionFor(req) reply.code(202) return app.runs.send(row, (req.body ?? {}) as { text: string }) }) f.get('/runs/:id', async req => { const run = app.runs.get(req.params.id) app.sessions.getRow(run.sessionId, principal(req).source) return run }) f.post('/runs/:id/interrupt', async (req, reply) => { const run = app.runs.get(req.params.id) app.sessions.getRow(run.sessionId, principal(req).source) app.runs.interrupt(run.id, run.sessionId) reply.code(202) return { ok: true } }) // ---- 交互 ---- f.get<{ Querystring: { sessionId?: string } }>('/interactions', async req => { const sid = req.query.sessionId if (!sid) throw badRequest('需要 sessionId 参数') app.sessions.getRow(sid, principal(req).source) return app.interactions.pendingFor(sid) }) f.post('/interactions/:id', async req => { const body = parse(interactionResponseSchema, req.body) const source = principal(req).source app.interactions.respond(req.params.id, body, sid => { try { app.sessions.getRow(sid, source) return true } catch { return false } }) return { ok: true } }) // ---- 文件 ---- f.post('/sessions/:id/files', async (req, reply) => { const row = sessionFor(req) const limitBytes = app.settings.get().limits.uploadMaxMB * 1048576 const saved = [] for await (const part of req.files({ limits: { fileSize: limitBytes, files: 20 } })) { saved.push(await app.files.saveUpload({ sessionId: row.id, workspaceDir: row.workspaceDir, filename: part.filename, stream: part.file, limitBytes, truncated: () => part.file.truncated, })) } if (!saved.length) throw badRequest('没有收到文件') reply.code(201) return saved }) f.get('/sessions/:id/files', async req => { const row = sessionFor(req) await app.files.scan(row.id, row.workspaceDir) return app.files.list(row.id) }) f.get('/files/:id', async (req, reply) => { const file = app.files.get(req.params.id) const row = app.sessions.getRow(file.sessionId, principal(req).source) const abs = path.join(row.workspaceDir, file.relPath) if (!fs.existsSync(abs)) throw notFound('文件') const name = encodeURIComponent(path.basename(file.relPath)) reply.header('Content-Type', file.mime) reply.header('Content-Disposition', `${req.query.inline ? 'inline' : 'attachment'}; filename*=UTF-8''${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(abs)) }) }