aps-agent/providers/aps/index.ts

253 lines
12 KiB
TypeScript
Raw Permalink Normal View History

/**
* APS Provider(架构设计第 6 节):把 APS 的能力翻译成平台契约。
*
* 只做适配:声明能力、转发调用、归一化回执。不判断权限(那归 APS)、不碰模型上下文。
*
* **能力的权威来源是 APS 自己的 `/api/manifest`**(A5.1):平台启动注册时拉一次,
* 不在平台侧手抄一份,避免两边漂移。APS 起不来时 `describe()` 直接失败——注册不上,
* 比"注册上了但能力是旧的"安全。
*/
import {
ProviderError,
isErrorKind,
type AuthExchangeRequest,
type AuthExchangeResult,
type Capability,
type CapabilityCall,
type CredentialCheck,
type ExecutionContext,
type HealthStatus,
type Provider,
type ProviderManifest,
} from '@aps/provider-sdk';
import type { ExecutionReceipt } from '@aps/contracts';
export const APS_PROVIDER_ID = 'aps';
export interface ApsProviderOptions {
/** APS 服务地址,如 http://127.0.0.1:8000 */
baseUrl: string;
/** 服务间令牌(B5 的鉴权位;首版 APS 未强制) */
token?: string;
/** 单次调用超时,默认 30s;写能力应短于平台的 execution 超时 */
timeoutMs?: number;
/** 便于测试注入 */
fetchImpl?: typeof fetch;
}
interface ApsManifestBody {
id?: unknown;
displayName?: unknown;
version?: unknown;
platformVersion?: unknown;
requiresIdentity?: unknown;
auth?: unknown;
capabilities?: unknown;
}
const DEFAULT_TIMEOUT_MS = 30_000;
const RECEIPT_STATUSES = new Set(['accepted', 'pending-confirmation', 'succeeded', 'failed']);
function asString(value: unknown, fallback: string): string {
return typeof value === 'string' && value.trim() !== '' ? value : fallback;
}
/** 登录方式声明:只认平台定义的三种,别的当没声明 */
function toAuthSpec(raw: unknown): { kind: 'oauth' | 'password-exchange' | 'apikey'; authorizationUrl?: string } | undefined {
const item = (raw ?? {}) as Record<string, unknown>;
const kind = item.kind;
if (kind !== 'oauth' && kind !== 'password-exchange' && kind !== 'apikey') return undefined;
return {
kind,
...(typeof item.authorizationUrl === 'string' ? { authorizationUrl: item.authorizationUrl } : {}),
};
}
/** APS 的能力声明与平台 provider-sdk 的 Capability 同形,这里只做字段校验与兜底 */
function toCapability(raw: unknown): Capability {
const item = (raw ?? {}) as Record<string, unknown>;
const id = asString(item.id, '');
if (id === '') throw new ProviderError('invalid_argument', 'APS manifest 里有能力缺 id');
const effect = item.effect === 'draft' || item.effect === 'write' ? item.effect : 'read';
const mode = item.mode === 'async' ? 'async' : 'sync';
const idempotency = item.idempotency === 'key' || item.idempotency === 'natural' ? item.idempotency : 'none';
const errors = Array.isArray(item.errors)
? item.errors.filter((kind): kind is Capability['errors'][number] => isErrorKind(kind))
: [];
return {
id,
description: asString(item.description, item.title === undefined ? id : String(item.title)),
effect,
mode,
inputSchema: (item.inputSchema as Record<string, unknown>) ?? { type: 'object' },
outputSchema: (item.outputSchema as Record<string, unknown>) ?? { type: 'object' },
idempotency,
errors: errors.length > 0 ? errors : ['unavailable', 'unknown_result'],
...(typeof item.title === 'string' ? { title: item.title } : {}),
...(typeof item.whenNotToUse === 'string' ? { whenNotToUse: item.whenNotToUse } : {}),
...(Array.isArray(item.synonyms) ? { synonyms: item.synonyms.filter((s): s is string => typeof s === 'string') } : {}),
};
}
export function createApsProvider(options: ApsProviderOptions): Provider {
const baseUrl = options.baseUrl.replace(/\/+$/, '');
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const doFetch = options.fetchImpl ?? fetch;
const headers = (): Record<string, string> => ({
'content-type': 'application/json',
...(options.token === undefined ? {} : { authorization: `Bearer ${options.token}` }),
});
let manifestCache: ProviderManifest | undefined;
async function describe(): Promise<ProviderManifest> {
{
let response: Response;
try {
response = await doFetch(`${baseUrl}/api/manifest`, { headers: headers(), signal: AbortSignal.timeout(timeoutMs) });
} catch (error) {
throw new ProviderError('unavailable', `连不上 APS:${error instanceof Error ? error.message : '未知错误'}`);
}
if (!response.ok) {
throw new ProviderError('unavailable', `APS manifest 返回 HTTP ${response.status}`);
}
const body = (await response.json()) as ApsManifestBody;
const raw = Array.isArray(body.capabilities) ? body.capabilities : [];
const described: ProviderManifest = {
id: asString(body.id, APS_PROVIDER_ID),
displayName: asString(body.displayName, 'APS 排产系统'),
version: asString(body.version, '0.0.0'),
platformVersion: typeof body.platformVersion === 'number' ? body.platformVersion : 1,
requiresIdentity: body.requiresIdentity !== false,
// 登录方式要透传:平台按它决定绑定流程(ADR-0012)
...(toAuthSpec(body.auth) === undefined ? {} : { auth: toAuthSpec(body.auth) as NonNullable<ProviderManifest['auth']> }),
capabilities: raw.map(toCapability),
};
manifestCache = described;
return described;
}
}
async function invoke(context: ExecutionContext, call: CapabilityCall): Promise<ExecutionReceipt> {
{
// 能力白名单:APS 上不存在的能力直接报错,不把一个 404/兜底响应当成回执
const manifest = manifestCache ?? (await describe());
if (!manifest.capabilities.some((capability) => capability.id === call.capabilityId)) {
throw new ProviderError('invalid_argument', `APS 没有能力 ${call.capabilityId}`);
}
const payload = {
params: call.params ?? {},
executionId: context.executionId,
deadlineAt: context.deadlineAt,
...(context.identity === undefined ? {} : { identity: { accountId: context.identity.accountId } }),
...(context.idempotencyKey === undefined ? {} : { idempotencyKey: context.idempotencyKey }),
};
// 登录态只从一次性句柄里取(A4.3),不进参数、不进日志
const secret = context.credentials === undefined ? undefined : await context.credentials.read();
const authHeaders = secret?.token === undefined ? headers() : { ...headers(), authorization: `Bearer ${secret.token}` };
let response: Response;
try {
response = await doFetch(`${baseUrl}/api/capabilities/${encodeURIComponent(call.capabilityId)}`, {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(payload),
signal: AbortSignal.timeout(Math.max(1_000, Math.min(timeoutMs, context.deadlineAt - Date.now()))),
});
} catch (error) {
throw new ProviderError('unavailable', `调用 APS 失败:${error instanceof Error ? error.message : '未知错误'}`);
}
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
// 业务系统说"你没登录/没权限":平台不覆盖这个结论,交给策略层把绑定标成待重绑
throw new ProviderError('forbidden', `APS 拒绝了这次调用(HTTP ${response.status}):登录态已失效或权限不足,请重新绑定`);
}
const kind = response.status >= 500 ? 'unavailable' : 'invalid_argument';
throw new ProviderError(kind, `APS 返回 HTTP ${response.status}`);
}
const body = (await response.json()) as Partial<ExecutionReceipt> & { errorKind?: unknown };
const status = typeof body.status === 'string' && RECEIPT_STATUSES.has(body.status) ? body.status : 'failed';
return {
executionId: asString(body.executionId, context.executionId),
status: status as ExecutionReceipt['status'],
...(body.result === undefined ? {} : { result: body.result }),
// 分类非法时交给 Core 归一,provider 不猜
...(typeof body.errorKind === 'string' ? { errorKind: body.errorKind } : {}),
...(typeof body.approvalId === 'string' ? { approvalId: body.approvalId } : {}),
};
}
}
async function health(): Promise<HealthStatus> {
{
try {
const response = await doFetch(`${baseUrl}/api/health`, { headers: headers(), signal: AbortSignal.timeout(3_000) });
if (!response.ok) return { status: 'down', detail: `HTTP ${response.status}`, checkedAt: Date.now() };
const body = (await response.json()) as { status?: unknown; detail?: unknown };
const status = body.status === 'degraded' || body.status === 'down' || body.status === 'ok' ? body.status : 'ok';
return {
status,
...(typeof body.detail === 'string' ? { detail: body.detail } : {}),
checkedAt: Date.now(),
};
} catch (error) {
return { status: 'down', detail: error instanceof Error ? error.message : '健康探测失败', checkedAt: Date.now() };
}
}
}
async function exchange(request: AuthExchangeRequest): Promise<AuthExchangeResult> {
if (request.password === undefined) {
throw new ProviderError('invalid_argument', 'APS 走账号密码换取 token,绑定时要给一次密码');
}
let response: Response;
try {
response = await doFetch(`${baseUrl}/api/auth/login`, {
method: 'POST',
headers: headers(),
body: JSON.stringify({ accountId: request.accountId, password: request.password }),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (error) {
throw new ProviderError('unavailable', `连不上 APS:${error instanceof Error ? error.message : '未知错误'}`);
}
if (response.status === 401) {
throw new ProviderError('forbidden', 'APS 拒绝了这组账号密码');
}
if (!response.ok) {
throw new ProviderError(response.status >= 500 ? 'unavailable' : 'invalid_argument', `APS 登录返回 HTTP ${response.status}`);
}
const body = (await response.json()) as { token?: unknown; accountId?: unknown; displayName?: unknown; expiresAt?: unknown };
if (typeof body.token !== 'string' || body.token === '') {
throw new ProviderError('unknown_result', 'APS 登录成功但没返回 token');
}
return {
accountId: asString(body.accountId, request.accountId),
...(typeof body.displayName === 'string' ? { displayName: body.displayName } : {}),
secret: { token: body.token },
...(typeof body.expiresAt === 'number' ? { expiresAt: body.expiresAt } : {}),
};
}
async function checkCredential(secret: Record<string, string>): Promise<CredentialCheck> {
if (secret.token === undefined) return { ok: false, reason: '凭据里没有 token' };
try {
const response = await doFetch(`${baseUrl}/api/auth/whoami`, {
headers: { ...headers(), authorization: `Bearer ${secret.token}` },
signal: AbortSignal.timeout(3_000),
});
if (response.status === 401 || response.status === 403) return { ok: false, reason: '登录态已失效,请重新绑定' };
if (!response.ok) return { ok: false, reason: `APS 返回 HTTP ${response.status}` };
return { ok: true };
} catch (error) {
// 连不上不等于登录态失效:报 ok=false 会误伤,这里给出原因让上层决定
return { ok: false, reason: error instanceof Error ? error.message : '凭据体检失败' };
}
}
return { describe, invoke, health, exchange, checkCredential };
}
/** 供平台按配置加载:模块导出统一的 createProvider,参数来自配置 */
export function createProvider(options: { baseUrl: string; token?: string; timeoutMs?: number }): Provider {
return createApsProvider(options);
}