feat(core): 引擎工具回调端点与会话归属者(A2.5)
会话记录带 owner,创建会话时记录发起人;新增 POST /internal/tool-call(loopback + 启动时生成的内部令牌鉴权),身份取自会话归属者而非调用方自带,平台侧仍收口到 Core.invoke。三条端到端用例:HTTP→应用服务→Core→假 Provider 成功链路、令牌缺失或错误被拒且不留执行记录、未知能力返回失败回执而非 HTTP 错误。测试 41 → 44 全绿。
This commit is contained in:
parent
e15e7e81e9
commit
f2359cc3ad
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* 引擎工具回调的端到端用例(任务 A2.5)。
|
||||
*
|
||||
* 路径:HTTP 入口(/internal/tool-call)→ 应用服务 → Core.invoke → 假 Provider → 回执与执行记录。
|
||||
* 同时验证鉴权:只允许 loopback + 内部令牌。
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
|
||||
async function startWithProvider() {
|
||||
process.env.APS_DEV_PROVIDER = '1';
|
||||
const { startServer } = await import('../server/dist/index.js');
|
||||
const server = await startServer({ host: '127.0.0.1', port: 0, version: 'test' });
|
||||
const created = await fetch(`${server.url}/v1/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ scope: 'project', projectId: 'p-default', title: '工具回调会话' }),
|
||||
});
|
||||
const session = (await created.json()).session;
|
||||
return { server, session };
|
||||
}
|
||||
|
||||
const callTool = (server, body, token) =>
|
||||
fetch(`${server.url}/internal/tool-call`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', ...(token === undefined ? {} : { 'x-aps-internal-token': token }) },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
test('HTTP → 应用服务 → Core → 假 Provider 的完整链路', async () => {
|
||||
const { server, session } = await startWithProvider();
|
||||
try {
|
||||
const response = await callTool(
|
||||
server,
|
||||
{ sessionId: session.sessionId, capabilityId: 'fake.echo', params: { text: '端到端' } },
|
||||
server.internalToken,
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const receipt = await response.json();
|
||||
assert.equal(receipt.status, 'succeeded');
|
||||
assert.deepEqual(receipt.result, { echo: '端到端' });
|
||||
|
||||
// 执行记录:身份取自会话归属者,参数只留摘要
|
||||
const record = server.service.core.executions.get(receipt.executionId);
|
||||
assert.ok(record);
|
||||
assert.equal(record.sessionId, session.sessionId);
|
||||
assert.equal(record.projectId, 'p-default');
|
||||
assert.equal(record.subjectUserId, 'dev-user', '身份应取自会话归属者,而不是调用方自带');
|
||||
assert.match(record.paramsDigest, /^sha256:/);
|
||||
|
||||
const lookup = await fetch(`${server.url}/v1/executions/${receipt.executionId}`);
|
||||
assert.equal(lookup.status, 200);
|
||||
assert.equal((await lookup.json()).status, 'succeeded');
|
||||
} finally {
|
||||
delete process.env.APS_DEV_PROVIDER;
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('内部令牌缺失或错误时拒绝,且不产生执行记录', async () => {
|
||||
const { server, session } = await startWithProvider();
|
||||
try {
|
||||
const body = { sessionId: session.sessionId, capabilityId: 'fake.echo', params: { text: 'x' } };
|
||||
const missing = await callTool(server, body);
|
||||
assert.equal(missing.status, 401);
|
||||
assert.equal((await missing.json()).error.code, 'unauthorized');
|
||||
|
||||
const wrong = await callTool(server, body, 'not-the-token');
|
||||
assert.equal(wrong.status, 401);
|
||||
|
||||
const before = server.service.core.executions.list().length;
|
||||
const bad = await callTool(server, { sessionId: session.sessionId }, server.internalToken);
|
||||
assert.equal(bad.status, 400, '缺 capabilityId 应 400');
|
||||
assert.equal(server.service.core.executions.list().length, before, '被拒的调用不应产生执行记录');
|
||||
} finally {
|
||||
delete process.env.APS_DEV_PROVIDER;
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('未知能力经回调返回失败回执(不是 HTTP 错误)', async () => {
|
||||
const { server, session } = await startWithProvider();
|
||||
try {
|
||||
const response = await callTool(
|
||||
server,
|
||||
{ sessionId: session.sessionId, capabilityId: 'fake.__nope__', params: {} },
|
||||
server.internalToken,
|
||||
);
|
||||
assert.equal(response.status, 200, '能力不存在属于业务失败,不是协议错误');
|
||||
const receipt = await response.json();
|
||||
assert.equal(receipt.status, 'failed');
|
||||
assert.equal(receipt.errorKind, 'invalid_argument');
|
||||
} finally {
|
||||
delete process.env.APS_DEV_PROVIDER;
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
|
@ -68,7 +68,7 @@ export class ApplicationService {
|
|||
return { sessions: this.sessions.list(projectId) };
|
||||
}
|
||||
|
||||
createSession(request: CreateSessionRequest): CreateSessionResponse {
|
||||
createSession(subject: Subject, request: CreateSessionRequest): CreateSessionResponse {
|
||||
const scope = request.scope;
|
||||
if (scope !== 'personal' && scope !== 'project') throw badRequest('scope 只能是 personal 或 project');
|
||||
if (scope === 'project') {
|
||||
|
|
@ -76,7 +76,7 @@ export class ApplicationService {
|
|||
if (!projectId) throw badRequest('项目会话必须带 projectId(ADR-0003)');
|
||||
if (!this.projects.get(projectId)) throw badRequest(`项目 ${projectId} 不存在`);
|
||||
}
|
||||
const record = this.sessions.create({ scope, projectId: request.projectId, title: request.title });
|
||||
const record = this.sessions.create({ scope, projectId: request.projectId, title: request.title }, subject);
|
||||
return { session: record.summary };
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +133,22 @@ export class ApplicationService {
|
|||
return { accepted: true, messageId, runId };
|
||||
}
|
||||
|
||||
/** 引擎回调入口(A3.11 工具桥的平台侧):按会话归属者发起调用,身份不由调用方自带 */
|
||||
async invokeFromSession(
|
||||
sessionId: string,
|
||||
input: { capabilityId: string; params: unknown; idempotencyKey?: string; timeoutMs?: number },
|
||||
) {
|
||||
const record = this.sessions.require(sessionId);
|
||||
return this.core.invoke({
|
||||
subject: { userId: record.owner.userId, tenantId: record.owner.tenantId },
|
||||
sessionId,
|
||||
projectId: record.summary.projectId,
|
||||
capabilityId: input.capabilityId,
|
||||
params: input.params,
|
||||
...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }),
|
||||
...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
|
||||
});
|
||||
}
|
||||
cancel(sessionId: string): CancelResponse {
|
||||
const record = this.sessions.require(sessionId);
|
||||
if (record.state !== 'running') return { state: 'idle' };
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { randomUUID } from 'node:crypto';
|
|||
import type { ChatMessage, SessionSummary } from '@aps/contracts';
|
||||
|
||||
import { badRequest, notFound } from './errors.js';
|
||||
import type { Subject } from './subject.js';
|
||||
import { EventBus } from './events.js';
|
||||
|
||||
export type RunState = 'idle' | 'running' | 'cancelling' | 'cancelled';
|
||||
|
|
@ -21,6 +22,8 @@ export interface RunHandle {
|
|||
|
||||
export interface SessionRecord {
|
||||
summary: SessionSummary;
|
||||
/** 会话归属者:引擎回调时的身份来源(ADR-0003:写操作必须在项目会话内) */
|
||||
owner: Subject;
|
||||
messages: ChatMessage[];
|
||||
bus: EventBus;
|
||||
state: RunState;
|
||||
|
|
@ -42,7 +45,7 @@ export function newId(prefix: string): string {
|
|||
export class SessionStore {
|
||||
private readonly sessions = new Map<string, SessionRecord>();
|
||||
|
||||
create(input: CreateSessionInput): SessionRecord {
|
||||
create(input: CreateSessionInput, owner: Subject): SessionRecord {
|
||||
if (input.scope === 'project' && !input.projectId) throw badRequest('项目会话必须带 projectId(ADR-0003)');
|
||||
if (input.scope === 'personal' && input.projectId) throw badRequest('个人会话不能带 projectId');
|
||||
|
||||
|
|
@ -56,6 +59,7 @@ export class SessionStore {
|
|||
};
|
||||
const record: SessionRecord = {
|
||||
summary,
|
||||
owner,
|
||||
messages: [],
|
||||
bus: new EventBus(),
|
||||
state: 'idle',
|
||||
|
|
|
|||
|
|
@ -34,17 +34,17 @@ const ROUTES: Route[] = ENDPOINT_SPECS.map((spec) => {
|
|||
return { spec, regex: new RegExp(`^${pattern}$`), names };
|
||||
});
|
||||
|
||||
export function createRequestHandler(service: ApplicationService): (req: IncomingMessage, res: ServerResponse) => void {
|
||||
export function createRequestHandler(service: ApplicationService, internalToken: string): (req: IncomingMessage, res: ServerResponse) => void {
|
||||
return (req, res) => {
|
||||
void handleRequest(req, res, service).catch((error: unknown) => respondError(res, error));
|
||||
void handleRequest(req, res, service, internalToken).catch((error: unknown) => respondError(res, error));
|
||||
};
|
||||
}
|
||||
|
||||
export function createHttpServer(service: ApplicationService): Server {
|
||||
return createServer(createRequestHandler(service));
|
||||
export function createHttpServer(service: ApplicationService, internalToken: string): Server {
|
||||
return createServer(createRequestHandler(service, internalToken));
|
||||
}
|
||||
|
||||
async function handleRequest(req: IncomingMessage, res: ServerResponse, service: ApplicationService): Promise<void> {
|
||||
async function handleRequest(req: IncomingMessage, res: ServerResponse, service: ApplicationService, internalToken: string): Promise<void> {
|
||||
setCorsHeaders(req, res);
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204).end();
|
||||
|
|
@ -53,6 +53,12 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, service:
|
|||
|
||||
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;
|
||||
|
|
@ -79,7 +85,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, service:
|
|||
case 'GET /v1/sessions':
|
||||
return sendJson(res, 200, service.listSessions(projectId));
|
||||
case 'POST /v1/sessions':
|
||||
return sendJson(res, 200, service.createSession((await readJson(req)) as CreateSessionRequest));
|
||||
return sendJson(res, 200, service.createSession(subject, (await readJson(req)) as CreateSessionRequest));
|
||||
case 'GET /v1/sessions/{id}':
|
||||
return sendJson(res, 200, service.getSession(sessionId));
|
||||
case 'GET /v1/sessions/{id}/events':
|
||||
|
|
@ -196,4 +202,44 @@ function setCorsHeaders(req: IncomingMessage, res: ServerResponse): void {
|
|||
}
|
||||
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);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
*
|
||||
* 组装:应用服务 + HTTP / SSE 入口。控制面(core)与引擎适配(agent)在后续阶段接入。
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
|
||||
|
|
@ -23,6 +24,8 @@ export interface RunningServer {
|
|||
url: string;
|
||||
port: number;
|
||||
service: ApplicationService;
|
||||
/** 引擎工具回调的内部令牌:由平台生成,启动引擎子进程时注入(A3.11) */
|
||||
internalToken: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
|
|
@ -36,7 +39,8 @@ export async function startServer(options: StartOptions = {}): Promise<RunningSe
|
|||
await service.registerProvider(createFakeProvider());
|
||||
}
|
||||
|
||||
const server: Server = createHttpServer(service);
|
||||
const internalToken = randomUUID();
|
||||
const server: Server = createHttpServer(service, internalToken);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
|
|
@ -50,6 +54,7 @@ export async function startServer(options: StartOptions = {}): Promise<RunningSe
|
|||
url: `http://${host}:${actualPort}`,
|
||||
port: actualPort,
|
||||
service,
|
||||
internalToken,
|
||||
close: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
|
|
|
|||
Loading…
Reference in New Issue