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

413 lines
18 KiB
TypeScript
Raw Permalink 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.

/**
* 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 };