diff --git a/scripts/internal-tool-call.test.mjs b/scripts/internal-tool-call.test.mjs new file mode 100644 index 0000000..5c09e83 --- /dev/null +++ b/scripts/internal-tool-call.test.mjs @@ -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(); + } +}); \ No newline at end of file diff --git a/server/app/service.ts b/server/app/service.ts index dae3f9b..548a13a 100644 --- a/server/app/service.ts +++ b/server/app/service.ts @@ -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' }; diff --git a/server/app/sessions.ts b/server/app/sessions.ts index 784a8a5..23b8606 100644 --- a/server/app/sessions.ts +++ b/server/app/sessions.ts @@ -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(); - 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', diff --git a/server/entry/http.ts b/server/entry/http.ts index fc61614..ca91f5b 100644 --- a/server/entry/http.ts +++ b/server/entry/http.ts @@ -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 { +async function handleRequest(req: IncomingMessage, res: ServerResponse, service: ApplicationService, internalToken: string): Promise { 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 { + 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); } \ No newline at end of file diff --git a/server/index.ts b/server/index.ts index e5bd72d..6b12dac 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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; } @@ -36,7 +39,8 @@ export async function startServer(options: StartOptions = {}): Promise((resolve, reject) => { server.once('error', reject); @@ -50,6 +54,7 @@ export async function startServer(options: StartOptions = {}): Promise new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve()));