feat(core): 引擎工具回调端点与会话归属者(A2.5)

会话记录带 owner,创建会话时记录发起人;新增 POST /internal/tool-call(loopback + 启动时生成的内部令牌鉴权),身份取自会话归属者而非调用方自带,平台侧仍收口到 Core.invoke。三条端到端用例:HTTP→应用服务→Core→假 Provider 成功链路、令牌缺失或错误被拒且不留执行记录、未知能力返回失败回执而非 HTTP 错误。测试 41 → 44 全绿。
This commit is contained in:
zh 2026-09-24 15:50:07 +08:00
parent e15e7e81e9
commit f2359cc3ad
5 changed files with 178 additions and 10 deletions

View File

@ -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();
}
});

View File

@ -68,7 +68,7 @@ export class ApplicationService {
return { sessions: this.sessions.list(projectId) }; return { sessions: this.sessions.list(projectId) };
} }
createSession(request: CreateSessionRequest): CreateSessionResponse { createSession(subject: Subject, request: CreateSessionRequest): CreateSessionResponse {
const scope = request.scope; const scope = request.scope;
if (scope !== 'personal' && scope !== 'project') throw badRequest('scope 只能是 personal 或 project'); if (scope !== 'personal' && scope !== 'project') throw badRequest('scope 只能是 personal 或 project');
if (scope === 'project') { if (scope === 'project') {
@ -76,7 +76,7 @@ export class ApplicationService {
if (!projectId) throw badRequest('项目会话必须带 projectId(ADR-0003)'); if (!projectId) throw badRequest('项目会话必须带 projectId(ADR-0003)');
if (!this.projects.get(projectId)) throw badRequest(`项目 ${projectId} 不存在`); 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 }; return { session: record.summary };
} }
@ -133,6 +133,22 @@ export class ApplicationService {
return { accepted: true, messageId, runId }; 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 { cancel(sessionId: string): CancelResponse {
const record = this.sessions.require(sessionId); const record = this.sessions.require(sessionId);
if (record.state !== 'running') return { state: 'idle' }; if (record.state !== 'running') return { state: 'idle' };

View File

@ -10,6 +10,7 @@ import { randomUUID } from 'node:crypto';
import type { ChatMessage, SessionSummary } from '@aps/contracts'; import type { ChatMessage, SessionSummary } from '@aps/contracts';
import { badRequest, notFound } from './errors.js'; import { badRequest, notFound } from './errors.js';
import type { Subject } from './subject.js';
import { EventBus } from './events.js'; import { EventBus } from './events.js';
export type RunState = 'idle' | 'running' | 'cancelling' | 'cancelled'; export type RunState = 'idle' | 'running' | 'cancelling' | 'cancelled';
@ -21,6 +22,8 @@ export interface RunHandle {
export interface SessionRecord { export interface SessionRecord {
summary: SessionSummary; summary: SessionSummary;
/** 会话归属者:引擎回调时的身份来源(ADR-0003:写操作必须在项目会话内) */
owner: Subject;
messages: ChatMessage[]; messages: ChatMessage[];
bus: EventBus; bus: EventBus;
state: RunState; state: RunState;
@ -42,7 +45,7 @@ export function newId(prefix: string): string {
export class SessionStore { export class SessionStore {
private readonly sessions = new Map<string, SessionRecord>(); 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 === 'project' && !input.projectId) throw badRequest('项目会话必须带 projectId(ADR-0003)');
if (input.scope === 'personal' && input.projectId) throw badRequest('个人会话不能带 projectId'); if (input.scope === 'personal' && input.projectId) throw badRequest('个人会话不能带 projectId');
@ -56,6 +59,7 @@ export class SessionStore {
}; };
const record: SessionRecord = { const record: SessionRecord = {
summary, summary,
owner,
messages: [], messages: [],
bus: new EventBus(), bus: new EventBus(),
state: 'idle', state: 'idle',

View File

@ -34,17 +34,17 @@ const ROUTES: Route[] = ENDPOINT_SPECS.map((spec) => {
return { spec, regex: new RegExp(`^${pattern}$`), names }; 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) => { 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 { export function createHttpServer(service: ApplicationService, internalToken: string): Server {
return createServer(createRequestHandler(service)); 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); setCorsHeaders(req, res);
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
res.writeHead(204).end(); res.writeHead(204).end();
@ -53,6 +53,12 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, service:
const url = new URL(req.url ?? '/', 'http://localhost'); 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,继续走协议错误处理) // 非 API 路径先尝试托管客户端产物(A1.5;未构建时返回 false,继续走协议错误处理)
if ((req.method === 'GET' || req.method === 'HEAD') && !url.pathname.startsWith('/v1')) { if ((req.method === 'GET' || req.method === 'HEAD') && !url.pathname.startsWith('/v1')) {
if (await serveStatic(url.pathname, res)) return; if (await serveStatic(url.pathname, res)) return;
@ -79,7 +85,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, service:
case 'GET /v1/sessions': case 'GET /v1/sessions':
return sendJson(res, 200, service.listSessions(projectId)); return sendJson(res, 200, service.listSessions(projectId));
case 'POST /v1/sessions': 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}': case 'GET /v1/sessions/{id}':
return sendJson(res, 200, service.getSession(sessionId)); return sendJson(res, 200, service.getSession(sessionId));
case 'GET /v1/sessions/{id}/events': case 'GET /v1/sessions/{id}/events':
@ -197,3 +203,43 @@ function setCorsHeaders(req: IncomingMessage, res: ServerResponse): void {
res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS'); res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
res.setHeader('access-control-allow-headers', 'content-type,last-event-id,x-aps-client,authorization'); 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);
}

View File

@ -3,6 +3,7 @@
* *
* 组装:应用服务 + HTTP / SSE 入口。控制面(core)与引擎适配(agent)在后续阶段接入。 * 组装:应用服务 + HTTP / SSE 入口。控制面(core)与引擎适配(agent)在后续阶段接入。
*/ */
import { randomUUID } from 'node:crypto';
import type { Server } from 'node:http'; import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net'; import type { AddressInfo } from 'node:net';
@ -23,6 +24,8 @@ export interface RunningServer {
url: string; url: string;
port: number; port: number;
service: ApplicationService; service: ApplicationService;
/** 引擎工具回调的内部令牌:由平台生成,启动引擎子进程时注入(A3.11) */
internalToken: string;
close(): Promise<void>; close(): Promise<void>;
} }
@ -36,7 +39,8 @@ export async function startServer(options: StartOptions = {}): Promise<RunningSe
await service.registerProvider(createFakeProvider()); await service.registerProvider(createFakeProvider());
} }
const server: Server = createHttpServer(service); const internalToken = randomUUID();
const server: Server = createHttpServer(service, internalToken);
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
server.once('error', reject); server.once('error', reject);
@ -50,6 +54,7 @@ export async function startServer(options: StartOptions = {}): Promise<RunningSe
url: `http://${host}:${actualPort}`, url: `http://${host}:${actualPort}`,
port: actualPort, port: actualPort,
service, service,
internalToken,
close: () => close: () =>
new Promise<void>((resolve, reject) => { new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve())); server.close((error) => (error ? reject(error) : resolve()));