aps-agent/server/entry/http.ts

261 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* HTTP / SSE 入口(架构设计第 2 节)。
*
* 只做协议:路由、请求解析、身份解析、响应与错误包装、事件流推送。
* 业务编排在 app/,控制面在 core/(A4 起)。路由表来自协议里的 ENDPOINT_SPECS,
* 保证「文档写了什么端点,代码就实现什么端点」。
*/
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import type { Subject } from '../app/subject.js';
import { ENDPOINT_SPECS, type ApprovalDecisionRequest, type CreateSessionRequest, type EndpointSpec, type PlatformEvent, type SendMessageRequest } from '@aps/contracts';
import { AppError } from '../app/errors.js';
import type { ApplicationService } from '../app/service.js';
import { resolveSubject } from './dev-identity.js';
import { clientDistDir, createStaticHandler } from './static.js';
const MAX_BODY_BYTES = 1_000_000;
const HEARTBEAT_MS = 15_000;
interface Route {
spec: EndpointSpec;
regex: RegExp;
names: string[];
}
const serveStatic = createStaticHandler(clientDistDir());
const ROUTES: Route[] = ENDPOINT_SPECS.map((spec) => {
const names: string[] = [];
const pattern = spec.path.replace(/\{(\w+)\}/g, (_match, name: string) => {
names.push(name);
return '([^/]+)';
});
return { spec, regex: new RegExp(`^${pattern}$`), names };
});
export function createRequestHandler(service: ApplicationService, internalToken: string): (req: IncomingMessage, res: ServerResponse) => void {
return (req, res) => {
void handleRequest(req, res, service, internalToken).catch((error: unknown) => respondError(res, error));
};
}
export function createHttpServer(service: ApplicationService, internalToken: string): Server {
return createServer(createRequestHandler(service, internalToken));
}
async function handleRequest(req: IncomingMessage, res: ServerResponse, service: ApplicationService, internalToken: string): Promise<void> {
setCorsHeaders(req, res);
if (req.method === 'OPTIONS') {
res.writeHead(204).end();
return;
}
const url = new URL(req.url ?? '/', 'http://localhost');
// 引擎工具回调(架构设计 5.2 / ADR-0006):只允许本机 loopback + 启动时生成的内部令牌。
// 这是引擎与平台之间唯一的调用入口,平台侧仍收口到 Core.invoke。
if (url.pathname === '/internal/tool-call') {
return handleInternalToolCall(req, res, service, internalToken);
}
// 非 API 路径先尝试托管客户端产物(A1.5;未构建时返回 false,继续走协议错误处理)
if ((req.method === 'GET' || req.method === 'HEAD') && !url.pathname.startsWith('/v1')) {
if (await serveStatic(url.pathname, res)) return;
}
const match = matchRoute(req.method ?? 'GET', url.pathname);
if (!match) {
throw new AppError('not_found', `没有这个端点:${req.method ?? 'GET'} ${url.pathname}`, { status: 404 });
}
const { route, params } = match;
const subject = resolveSubject(req.headers);
const sessionId = params.get('id') ?? '';
const projectId = url.searchParams.get('projectId') ?? undefined;
const sessionFilter = url.searchParams.get('sessionId') ?? undefined;
switch (`${route.spec.method} ${route.spec.path}`) {
case 'GET /v1/health':
return sendJson(res, 200, service.health());
case 'GET /v1/me':
return sendJson(res, 200, service.me(subject));
case 'GET /v1/projects':
return sendJson(res, 200, service.listProjects());
case 'GET /v1/sessions':
return sendJson(res, 200, service.listSessions(projectId));
case 'POST /v1/sessions':
return sendJson(res, 200, service.createSession(subject, (await readJson(req)) as CreateSessionRequest));
case 'GET /v1/sessions/{id}':
return sendJson(res, 200, service.getSession(subject, sessionId));
case 'GET /v1/sessions/{id}/events':
return openEventStream(req, res, service, sessionId, subject);
case 'POST /v1/sessions/{id}/messages':
return sendJson(res, 202, service.sendMessage(subject, sessionId, (await readJson(req)) as SendMessageRequest));
case 'POST /v1/sessions/{id}/cancel':
return sendJson(res, 200, service.cancel(subject, sessionId));
case 'GET /v1/capabilities':
return sendJson(res, 200, service.capabilities(subject, sessionFilter));
case 'GET /v1/executions/{executionId}': {
const executionId = params.get('executionId') ?? '';
const receipt = service.execution(executionId);
if (!receipt) throw new AppError('not_found', `执行记录 不存在`, { status: 404 });
return sendJson(res, 200, receipt);
}
case 'GET /v1/executions/{executionId}/trace': {
// 执行追溯(A4.5):审计链 + 确认记录 + 回执;只有本人能看
const executionId = params.get('executionId') ?? '';
return sendJson(res, 200, service.executionTrace(subject, executionId));
}
case 'POST /v1/approvals/{approvalId}': {
// 确认卡响应(协议第 7 节):只有会话发起人能确认;版本或有效期不符会返回 expired
const approvalId = params.get('approvalId') ?? '';
const body = (await readJson(req)) as ApprovalDecisionRequest;
return sendJson(res, 200, await service.decideApproval(subject, approvalId, body));
}
default:
throw new AppError('not_found', `未实现的端点:${route.spec.method} ${route.spec.path}`, { status: 404 });
}
}
function matchRoute(method: string, pathname: string): { route: Route; params: Map<string, string> } | undefined {
for (const route of ROUTES) {
if (route.spec.method !== method) continue;
const matched = route.regex.exec(pathname);
if (!matched) continue;
const params = new Map<string, string>();
route.names.forEach((name, index) => params.set(name, matched[index + 1] ?? ''));
return { route, params };
}
return undefined;
}
function openEventStream(
req: IncomingMessage,
res: ServerResponse,
service: ApplicationService,
sessionId: string,
subject: Subject,
): void {
const url = new URL(req.url ?? '/', 'http://localhost');
const headerValue = req.headers['last-event-id'];
const raw = url.searchParams.get('since') ?? (Array.isArray(headerValue) ? headerValue[0] : headerValue) ?? '0';
const parsed = Number.parseInt(String(raw), 10);
const since = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
service.assertSessionAccess(subject, sessionId); // 会话不存在或不属于当前身份时先拒
res.writeHead(200, {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-transform',
connection: 'keep-alive',
'x-accel-buffering': 'no',
});
res.write(': connected\n\n'); // 让客户端立刻知道连接已建立
const unsubscribe = service.subscribe(subject, sessionId, since, (event: PlatformEvent) => writeEvent(res, event));
const heartbeat = setInterval(() => service.heartbeat(sessionId), HEARTBEAT_MS);
heartbeat.unref();
req.on('close', () => {
clearInterval(heartbeat);
unsubscribe();
res.end();
});
}
function writeEvent(res: ServerResponse, event: PlatformEvent): void {
res.write(`id: ${event.seq}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
}
async function readJson(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of req) {
const buffer = chunk as Buffer;
size += buffer.length;
if (size > MAX_BODY_BYTES) throw new AppError('payload_too_large', '请求体过大', { status: 413 });
chunks.push(buffer);
}
if (size === 0) return {};
const text = Buffer.concat(chunks).toString('utf8');
try {
return JSON.parse(text);
} catch {
throw new AppError('invalid_json', '请求体不是合法 JSON', { status: 400 });
}
}
function sendJson(res: ServerResponse, status: number, body: unknown): void {
const payload = JSON.stringify(body);
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
res.end(payload);
}
function respondError(res: ServerResponse, error: unknown): void {
if (res.headersSent) {
res.end();
return;
}
const appError =
error instanceof AppError
? error
: new AppError('internal', error instanceof Error ? error.message : '内部错误', { status: 500, retryable: true });
sendJson(res, appError.status, {
error: { code: appError.code, message: appError.message, retryable: appError.retryable },
});
}
function setCorsHeaders(req: IncomingMessage, res: ServerResponse): void {
// 开发期回显来源,便于桌面端与浏览器本地联调;A4.1 身份落地后按白名单收紧。
const origin = req.headers.origin;
if (origin) {
res.setHeader('access-control-allow-origin', origin);
res.setHeader('vary', 'origin');
res.setHeader('access-control-allow-credentials', 'true');
}
res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
res.setHeader('access-control-allow-headers', 'content-type,last-event-id,x-aps-client,authorization');
}
/**
* 引擎工具回调:POST /internal/tool-call
* 入参 { sessionId, capabilityId, params, idempotencyKey? }
* 鉴权:loopback 来源 + 启动时生成的内部令牌(A3.11 会在此基础上加 nonce 轮换与 origin 校验)
*/
async function handleInternalToolCall(
req: IncomingMessage,
res: ServerResponse,
service: ApplicationService,
internalToken: string,
): Promise<void> {
if (req.method !== 'POST') {
throw new AppError('method_not_allowed', '内部回调只接受 POST', { status: 405 });
}
const remote = req.socket.remoteAddress ?? '';
const isLoopback = remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
if (!isLoopback) {
throw new AppError('forbidden', '内部回调只允许本机调用', { status: 403 });
}
const provided = req.headers['x-aps-internal-token'];
if (String(Array.isArray(provided) ? provided[0] : (provided ?? '')) !== internalToken) {
throw new AppError('unauthorized', '内部令牌不正确', { status: 401 });
}
const body = (await readJson(req)) as { sessionId?: unknown; capabilityId?: unknown; params?: unknown; idempotencyKey?: unknown; timeoutMs?: unknown };
const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
const capabilityId = typeof body.capabilityId === 'string' ? body.capabilityId : '';
if (!sessionId || !capabilityId) {
throw new AppError('invalid_request', 'sessionId 与 capabilityId 必填', { status: 400 });
}
const receipt = await service.invokeFromSession(sessionId, {
capabilityId,
params: body.params,
...(typeof body.idempotencyKey === 'string' ? { idempotencyKey: body.idempotencyKey } : {}),
...(typeof body.timeoutMs === 'number' ? { timeoutMs: body.timeoutMs } : {}),
});
sendJson(res, 200, receipt);
}