aps-agent/server/core/execution/invoke.ts

413 lines
18 KiB
TypeScript
Raw Normal View History

/**
* Core.invoke:工具调用的唯一出口(架构设计 4 / 术语表规则 3)。
*
* 一次调用经过五道关(A4 控制面):身份 → 策略 → (需要确认就先开确认卡)→ 凭据 → 执行 → 审计。
* 三条不变量:
* 1. 只有一个出口:引擎不论怎么调工具,都落到这里
* 2. 平台的允许不能覆盖业务系统的拒绝:Provider 回 forbidden 就是 forbidden,不改写成成功、不重试写操作
* 3. 库里与日志里没有参数与凭据明文:参数只留摘要(确认卡用的参数密文单独存),凭据只在执行期经句柄取用
*/
import { randomUUID } from 'node:crypto';
import type { ExecutionReceipt } from '@aps/contracts';
import { isErrorKind, ProviderError, type Capability, type ErrorKind, type ExecutionContext, type ExecutionIdentity, type ImpactPreview, type Provider } from '@aps/provider-sdk';
import type { ControlPlane } from '../control-plane/control-plane.js';
import type { AccountBinding } from '../identity/context.js';
import type { CapabilityRegistry } from '../registry/registry.js';
import { digestParams, ExecutionStore, type ExecutionRecord } from './store.js';
export interface InvokeRequest {
/** 调用者身份(A4.1 起由身份上下文补齐,现在只需要用户与租户) */
subject: { userId: string; tenantId: string };
sessionId: string;
projectId?: string | null;
capabilityId: string;
params: unknown;
idempotencyKey?: string;
/** 覆盖默认超时(毫秒) */
timeoutMs?: number;
}
export interface ApprovalInput {
decision: 'approve' | 'reject';
revision: number;
idempotencyKey: string;
note?: string;
}
export type ApprovalResult =
| { kind: 'ok'; approvalId: string; decision: 'approved' | 'rejected' | 'expired'; receipt?: ExecutionReceipt }
| { kind: 'not-found' }
| { kind: 'forbidden' };
export interface CoreOptions {
registry: CapabilityRegistry;
executions?: ExecutionStore;
defaultTimeoutMs?: number;
/** A4 控制面:不传则退化为「无策略的直通执行」(只用于老用例与本地试验) */
controlPlane?: ControlPlane;
}
export interface Core {
invoke(request: InvokeRequest): Promise<ExecutionReceipt>;
/** 确认卡响应:校验归属与版本 → 签发一次性授权 → 执行 → 落回执 */
approve(subject: { userId: string; tenantId: string }, approvalId: string, input: ApprovalInput): Promise<ApprovalResult>;
executions: ExecutionStore;
registry: CapabilityRegistry;
approvals?: ControlPlane['approvals'];
}
const DEFAULT_TIMEOUT_MS = 30_000;
function failed(executionId: string, errorKind: ErrorKind, message: string): ExecutionReceipt {
return { executionId, status: 'failed', errorKind, result: { message } };
}
/** 把任意异常归一化成六分类之一(实施设计 5) */
function normalizeError(error: unknown): { kind: ErrorKind; message: string } {
if (error instanceof ProviderError) return { kind: error.kind, message: error.message };
if (error instanceof Error) return { kind: 'unknown_result', message: error.message };
return { kind: 'unknown_result', message: 'Provider 抛出未知错误' };
}
interface RunInput {
executionId: string;
capability: Capability;
provider: Provider;
providerId: string;
subject: { userId: string; tenantId: string };
sessionId: string;
projectId: string | null;
params: unknown;
idempotencyKey?: string;
timeoutMs: number;
binding?: AccountBinding;
/** Provider 声明的登录方式;有值才谈得上"登录态失效" */
authKind?: string;
}
export function createCore(options: CoreOptions): Core {
const executions = options.executions ?? new ExecutionStore();
const defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
const control = options.controlPlane;
/** 真正落到 Provider 的那一步:签发一次性授权 → 调用 → 归一化 → 审计 */
async function run(input: RunInput): Promise<ExecutionReceipt> {
const paramsDigest = digestParams(input.params);
const timeoutMs = input.timeoutMs;
const context: ExecutionContext = {
executionId: input.executionId,
sessionId: input.sessionId,
projectId: input.projectId,
subject: { userId: input.subject.userId, tenantId: input.subject.tenantId },
...(input.binding === undefined
? {}
: { identity: { accountId: input.binding.accountId, displayName: input.binding.displayName } satisfies ExecutionIdentity }),
...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }),
deadlineAt: control ? control.now() + timeoutMs : Date.now() + timeoutMs,
};
// 凭据:只在这一层解开,句柄随上下文传下去;参数里永远没有它
const grant =
control && input.binding && control.credentials.has(input.subject, input.providerId)
? control.credentials.issue({
subject: input.subject,
providerId: input.providerId,
accountId: input.binding.accountId,
executionId: input.executionId,
ttlMs: timeoutMs,
})
: undefined;
if (grant) {
context.credentials = { grantId: grant.grantId, read: async () => grant.read() };
}
executions.create({
executionId: input.executionId,
capabilityId: input.capability.id,
providerId: input.providerId,
sessionId: input.sessionId,
projectId: input.projectId,
subjectUserId: input.subject.userId,
paramsDigest,
...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }),
receipt: { executionId: input.executionId, status: 'accepted' },
});
control?.audit.append({
actorUserId: input.subject.userId,
tenantId: input.subject.tenantId,
event: 'execution.started',
executionId: input.executionId,
sessionId: input.sessionId,
capabilityId: input.capability.id,
providerId: input.providerId,
paramsDigest,
...(input.binding === undefined ? {} : { decision: input.binding.accountId }),
});
// 超时判定:超时不等于成功也不等于失败——结果未知(实施设计 5)
const timeout = new Promise<ExecutionReceipt>((resolve) => {
setTimeout(() => resolve(failed(input.executionId, 'unknown_result', `调用超过 ${timeoutMs}ms,结果未知`)), timeoutMs).unref();
});
let receipt: ExecutionReceipt;
try {
receipt = await Promise.race([input.provider.invoke(context, { capabilityId: input.capability.id, params: input.params }), timeout]);
} catch (error) {
const normalized = normalizeError(error);
receipt = failed(input.executionId, normalized.kind, normalized.message);
} finally {
// 一次性授权用完即废:结果未知也不延长
if (grant) control?.credentials.revoke(grant.grantId);
}
// Provider 返回的分类非法时归一为 unknown_result,避免脏值进记录
if (receipt.status === 'failed' && receipt.errorKind !== undefined && !isErrorKind(receipt.errorKind)) {
receipt = failed(input.executionId, 'unknown_result', `Provider 返回了未知错误分类 ${String(receipt.errorKind)}`);
}
executions.complete(input.executionId, receipt);
// 业务系统说"没登录/没权限":把绑定标成过期,后续在策略层直接拒(ADR-0012)
if (receipt.status === 'failed' && receipt.errorKind === 'forbidden' && control && input.authKind !== undefined && input.binding !== undefined) {
const expired = control.identity.expire(input.subject, input.providerId);
if (expired !== undefined) {
control.audit.append({
actorUserId: input.subject.userId,
tenantId: input.subject.tenantId,
event: 'identity.expired',
executionId: input.executionId,
sessionId: input.sessionId,
capabilityId: input.capability.id,
providerId: input.providerId,
decision: 'expired',
detail: { authKind: input.authKind, reason: '业务系统拒绝,需重新绑定' },
});
}
}
control?.audit.append({
actorUserId: input.subject.userId,
tenantId: input.subject.tenantId,
event: receipt.status === 'failed' && receipt.errorKind === 'forbidden' ? 'provider.denied' : 'execution.finished',
executionId: input.executionId,
sessionId: input.sessionId,
capabilityId: input.capability.id,
providerId: input.providerId,
paramsDigest,
decision: receipt.status === 'failed' ? `failed:${receipt.errorKind ?? 'unknown_result'}` : receipt.status,
detail:
receipt.status === 'failed' && receipt.errorKind === 'forbidden'
? { by: 'provider', message: '业务系统拒绝了这次调用,平台不覆盖该结果' }
: {},
});
return receipt;
}
async function invoke(request: InvokeRequest): Promise<ExecutionReceipt> {
const capabilityId = request.capabilityId;
if (typeof capabilityId !== 'string' || capabilityId.trim() === '') {
return failed(`exec-${randomUUID()}`, 'invalid_argument', 'capabilityId 不能为空');
}
// 幂等:同一键的重试返回原回执,不重复执行
if (request.idempotencyKey) {
const existing = executions.findByIdempotencyKey(request.idempotencyKey);
if (existing) return existing.receipt;
}
const resolved = options.registry.resolve(capabilityId);
const executionId = `exec-${randomUUID()}`;
if (!resolved) {
return failed(executionId, 'invalid_argument', `能力 ${capabilityId} 未注册`);
}
const projectId = request.projectId ?? null;
const base: RunInput = {
executionId,
capability: resolved.capability,
provider: resolved.provider,
providerId: resolved.providerId,
subject: request.subject,
sessionId: request.sessionId,
projectId,
params: request.params,
...(request.idempotencyKey === undefined ? {} : { idempotencyKey: request.idempotencyKey }),
timeoutMs: request.timeoutMs ?? defaultTimeoutMs,
...(resolved.authKind === undefined ? {} : { authKind: resolved.authKind }),
};
if (!control) return run(base);
const binding = control.identity.resolve(request.subject, resolved.providerId);
const decision = control.policy.decide({
subject: request.subject,
capability: resolved.capability,
providerId: resolved.providerId,
binding,
visible: control.isVisible(resolved.capability, request.subject),
requiresIdentity: resolved.requiresIdentity,
projectId,
});
if (decision.decision === 'deny') {
control.audit.append({
actorUserId: request.subject.userId,
tenantId: request.subject.tenantId,
event: 'policy.denied',
executionId,
sessionId: request.sessionId,
capabilityId,
providerId: resolved.providerId,
paramsDigest: digestParams(request.params),
decision: decision.code,
detail: { reason: decision.reason },
});
return failed(executionId, 'forbidden', decision.reason);
}
if (decision.decision === 'require-approval') {
return openApproval(base, binding, decision.code, decision.reason);
}
return run({ ...base, ...(binding === undefined ? {} : { binding }) });
}
/** 写操作:先开确认卡,落一条待确认动作,等用户点头再执行 */
async function openApproval(input: RunInput, binding: AccountBinding | undefined, code: string, reason: string): Promise<ExecutionReceipt> {
if (!control) return failed(input.executionId, 'unavailable', '平台未启用控制面,写操作不可用');
// 同一写请求重试:返回原来那张确认卡,不再开第二张
if (input.idempotencyKey !== undefined) {
const existing = control.approvals.findByIdempotencyKey(input.sessionId, input.idempotencyKey);
if (existing) return existing.receipt;
}
const paramsDigest = digestParams(input.params);
const preview = await previewFor(input);
const action = control.approvals.create({
sessionId: input.sessionId,
projectId: input.projectId,
ownerUserId: input.subject.userId,
tenantId: input.subject.tenantId,
capabilityId: input.capability.id,
providerId: input.providerId,
paramsDigest,
paramsPreview: preview.paramsPreview ?? {},
paramsSealed: control.credentials.sealText(JSON.stringify(input.params ?? null)),
impactSummary: preview.impactSummary,
revision: preview.revision,
expiresAt: control.now() + control.approvalTtlMs,
...(input.idempotencyKey === undefined ? {} : { idempotencyKey: input.idempotencyKey }),
});
const common = {
actorUserId: input.subject.userId,
tenantId: input.subject.tenantId,
executionId: action.executionId,
approvalId: action.approvalId,
sessionId: input.sessionId,
capabilityId: input.capability.id,
providerId: input.providerId,
paramsDigest,
} as const;
control.audit.append({ ...common, event: 'policy.requires_confirmation', decision: code, detail: { reason } });
control.audit.append({ ...common, event: 'approval.requested', decision: 'pending-confirmation', detail: { accountId: binding?.accountId ?? null, revision: action.revision, expiresAt: action.expiresAt } });
return { executionId: action.executionId, status: 'pending-confirmation', approvalId: action.approvalId };
}
/** 影响预览:Provider 说了算;它没说就退回能力描述(只影响确认卡信息量) */
async function previewFor(input: RunInput): Promise<ImpactPreview> {
if (!control) return { impactSummary: input.capability.description, revision: 0 };
if (input.provider.preview) {
try {
const context: ExecutionContext = {
executionId: input.executionId,
sessionId: input.sessionId,
projectId: input.projectId,
subject: { userId: input.subject.userId, tenantId: input.subject.tenantId },
deadlineAt: control.now() + input.timeoutMs,
};
return await input.provider.preview(context, { capabilityId: input.capability.id, params: input.params });
} catch {
/* 预览失败不阻塞确认流程:退回能力描述 */
}
}
return { impactSummary: input.capability.description, revision: 0 };
}
async function approve(subject: { userId: string; tenantId: string }, approvalId: string, input: ApprovalInput): Promise<ApprovalResult> {
if (!control) return { kind: 'not-found' };
const action = control.approvals.get(approvalId);
if (!action) return { kind: 'not-found' };
// 确认人是会话发起人本人:单用户场景不做委托与转派
if (action.ownerUserId !== subject.userId || action.tenantId !== subject.tenantId) return { kind: 'forbidden' };
const decided = control.approvals.decide(approvalId, {
decision: input.decision,
revision: input.revision,
decidedBy: subject.userId,
idempotencyKey: input.idempotencyKey,
});
if (!decided) return { kind: 'not-found' };
const { kind, action: current } = decided;
const common = {
actorUserId: subject.userId,
tenantId: subject.tenantId,
executionId: current.executionId,
approvalId,
sessionId: current.sessionId,
capabilityId: current.capabilityId,
providerId: current.providerId,
paramsDigest: current.paramsDigest,
} as const;
// 同一请求重放:返回同一结果,不重复执行
if (kind === 'replayed') {
return {
kind: 'ok',
approvalId,
decision: current.decision ?? 'expired',
...(current.decision === 'approved' ? { receipt: current.receipt } : {}),
};
}
if (kind === 'expired' || kind === 'revision-mismatch') {
control.audit.append({ ...common, event: 'approval.expired', decision: kind, detail: { requestedRevision: input.revision, currentRevision: action.revision } });
return { kind: 'ok', approvalId, decision: 'expired', ...(current.receipt.status === 'pending-confirmation' ? {} : { receipt: current.receipt }) };
}
if (kind === 'rejected') {
control.audit.append({ ...common, event: 'approval.decided', decision: 'rejected', ...(input.note === undefined ? {} : { detail: { note: input.note } }) });
return { kind: 'ok', approvalId, decision: 'rejected' };
}
control.audit.append({ ...common, event: 'approval.decided', decision: 'approved', ...(input.note === undefined ? {} : { detail: { note: input.note } }) });
const resolved = options.registry.resolve(current.capabilityId);
if (!resolved) {
const receipt = failed(current.executionId, 'invalid_argument', `能力 ${current.capabilityId} 已不在能力目录里`);
control.approvals.complete(current.executionId, receipt);
return { kind: 'ok', approvalId, decision: 'approved', receipt };
}
const plaintext = control.credentials.openText(current.paramsSealed);
const params: unknown = plaintext === undefined ? null : (JSON.parse(plaintext) as unknown);
const binding = control.identity.resolve(subject, current.providerId);
const receipt = await run({
executionId: current.executionId,
capability: resolved.capability,
provider: resolved.provider,
providerId: current.providerId,
subject,
sessionId: current.sessionId,
projectId: current.projectId,
params,
...(current.idempotencyKey === undefined ? {} : { idempotencyKey: current.idempotencyKey }),
timeoutMs: defaultTimeoutMs,
...(binding === undefined ? {} : { binding }),
...(resolved.authKind === undefined ? {} : { authKind: resolved.authKind }),
});
control.approvals.complete(current.executionId, receipt);
return { kind: 'ok', approvalId, decision: 'approved', receipt };
}
return { invoke, approve, executions, registry: options.registry, ...(control === undefined ? {} : { approvals: control.approvals }) };
}
export type { ExecutionRecord };