智能体把活交给下一个智能体、再交给第三方工具之后,链路中间没有人能回答四个问题:现在到底是谁在动、他被允许做什么、能不能随时叫停、以及事后拿什么证明发生过什么。API 密钥每跳各管一段,人的额度、用途与有效期在传递中就丢了;框架内的护栏出不了自己那份代码。Aroha Protocol 的做法是给每一次委托签一张授权状(mandate):人签一张带额度与有效期的意图授权状给编排者,编排者往下派活时只能收窄不能放宽,任何一环都能离线验链回到人这个根,撤销会被推送给验证方,每一次放行或拒绝都留一张签名回执。本教程六步把它接进你现有的栈:起环境、认识四个包、签发、衰减、验链、再挂到 MCP 上。
Step 1:起环境,并先搞清楚它不是什么
脚手架创建项目、装依赖、写 .env,然后起一个本地 Agent。零配置模式会自己挑完所有默认值,适合先看形态;需要指定能力时用交互式初始化。起完之后有两件事值得马上做:跑一次环境自检,以及起一个本地开发注册表与看板,后面的验链与撤销都能在上面看到。
npx create-aroha-agent my-agent
cd my-agent && npm install && npm run dev
# 输出形如:did:aroha:my-agent http://localhost:3001/aroha/v1 Ready
# 或用 CLI:
aroha init my-agent # 交互式
aroha init my-agent --yes # 零配置,自动写 .env
aroha convert --from mcp --dir . # 把已有 MCP 服务包一层
aroha doctor [endpoint] # 环境连通性自检
aroha dev # 本地注册表 + 看板
在动手改代码之前,先把边界说清楚,否则很容易把它用错地方:它不是 Agent 框架或编排器,不替你计划和调度(那是 LangGraph、CrewAI、各家 Agent SDK 的事);它不是传输协议,而是搭在 MCP 与 A2A 之上;它不是身份提供方,但可以把 IdP 发出来的身份当作授权链的根;它也不是支付网络,消费授权状只是映射到那些协议已有的信封上。它只回答前面那四个问题。
别指望它替你管流程:Aroha 限定 Agent 可以做什么,不负责决定它做什么。把它当成套在现有框架外面的一层权限与凭据,而不是又一个编排器;计划、重试与状态机仍然是你原来那套。
Step 2:四个包,对应四个问题
核心层由四个包组成,各自的职责对应一个要回答的问题:core 负责身份与签名信封、回执与会话档案;credentials 负责授权状的签发、衰减、验链与撤销,是真正要开始用的那个;delegation 负责多跳委托的深度限制与回执拼接;policy 则在模型看到请求之前做确定性限额检查。适配器那一层是给已有栈用的,包括把任意 MCP 服务包成 Aroha Agent 的 mcp-bridge、挡在 MCP 服务前面的 mcp-guard、以及 A2A 与 LangChain 的桥。
核心 适配器(按你已有的栈选)
@aroha-sdk/core 身份/信封/回执 @aroha-sdk/mcp-server
@aroha-sdk/credentials 签发/衰减/验链 ★ @aroha-sdk/mcp-guard
@aroha-sdk/delegation 多跳委托 @aroha-sdk/mcp-bridge
@aroha-sdk/policy 确定性限额 @aroha-sdk/a2a-bridge
@aroha-sdk/langchain-bridge
这里的身份是内容寻址的:did:aroha 后面的标识直接由公钥本身推导出来,因此验证方不需要去查任何注册表就能核对签名。这一点决定了后面整条链可以离线验证——在跨厂商、跨公司的链路上,能不依赖中心服务就完成校验,是它和「每跳各发一把钥匙」最本质的区别。
Step 3:签一张意图授权状
人先给编排者签一张意图授权状,内容是:允许的动作、消费上限、有效期,并且用密码学绑定到接收方的身份标识上。示例里给助手的是 500 美元、商户类别码 4511(航空)、有效期一个小时。密钥对与标识都由 SDK 生成,私钥留在签发方自己手里。
import { generateKeyPair, generateDid } from "@aroha-sdk/core";
import { issueIntentMandate, attenuateToPayment, verifyMandateChain }
from "@aroha-sdk/credentials";
const human = await generateKeyPair();
const assistant = await generateKeyPair();
const flightAgent = await generateKeyPair();
const humanDID = generateDid(human.publicKey);
const assistantDID = generateDid(assistant.publicKey);
const flightAgentDID = generateDid(flightAgent.publicKey);
const intent = await issueIntentMandate(
humanDID, assistantDID,
{ spendLimitUsd: 500, merchantCategory: "4511" }, // 4511 = 航空
human.privateKey,
3_600_000, // TTL:一小时
);
有效期这一项最容易被写成一个巨大的数字,然后被忘掉。它应当贴着真实任务的长度来定:一个小时内要订完机票,就给一小时;给一周意味着这张授权状在整个星期里都是有效的攻击面。额度同理,按这次任务的真实上限给,而不是给一个「反正不会用完」的整数。
私钥就是授权本身:谁能拿到人的私钥,谁就能以人的名义签发授权状。请把人的密钥放在受保护的存储或硬件里,不要和 Agent 的运行时密钥放在同一台机器上——那样做等于把「越权」和「被发现越权」的能力交给了同一个进程。
Step 4:往下派活时只能收窄
编排者把活派给下一个 Agent 时,签的是一张更窄的子授权状,并且把回到人那一层的完整签名链一起带过去。示例里助手把 500 美元收窄成 300 美元交给订票 Agent;子令牌内嵌了这条链,接收方不需要再回头去问任何人。
// 助手把 500 收窄成 300 交给订票 Agent,用途类别不变
const payment = await attenuateToPayment(
intent, flightAgentDID,
{ spendLimitUsd: 300, merchantCategory: "4511" },
assistant.privateKey,
);
「只能收窄」这四个字是整套设计里最硬的那条规则,它靠的是验链而不是约定:任何一跳如果放宽了额度、改了用途、换了签发人、或者过期,链条验证就会失败。也就是说,中间某个 Agent 想给自己多要点权限,它做得到签名,但下游不会接受——它只能回头向上游要一张新的、由人签发的授权状。
Step 5:验链,然后拒答
接收方在动手之前验链,只需要两行:给出期望的根(你承认其权威的那个人)与期望的被授权人(你自己)。返回里 valid 表示签名与结构没问题,chainVerified 表示整条链一路验回到了人的根;两者都为真才继续,否则直接拒绝并给出原因。
const result = await verifyMandateChain(payment.token, undefined, {
expectedRoot: humanDID, // 权威必须来自这个人
expectedGrantee: flightAgentDID, // 被授权人必须是我自己
});
// result.valid === true, result.chainVerified === true
// result.mandate?.constraints.spendLimitUsd === 300
const { valid, chainVerified, mandate, reason } = await verifyMandateChain(
envelope.body.credentialToken, undefined,
{ expectedRoot: process.env.HUMAN_ROOT_DID, expectedGrantee: MY_DID },
);
if (!valid || !chainVerified) {
res.status(403).json({ error: reason });
}
会失败的四种情况值得记住,因为它们正是你想挡住的事:伪造的授权状、被改过内容的、放宽了范围的、过期的,以及由错误一方签发的。验证完之后,额度与用途类别都能从 mandate.constraints 里读出来,用来做真正的业务判断——例如确认这一笔的商户类别确实在允许范围内,而不只是「有授权状」。
// 在业务侧再落一道:授权状给了多少,实际就只能用多少
const limit = mandate?.constraints?.spendLimitUsd ?? 0;
if (amount > limit) return refuse('超出授权额度');
if (mandate?.constraints?.merchantCategory !== mccOf(merchant)) {
return refuse('商户类别不在授权范围内');
}
验链不是授权:它只证明「这一跳被允许到什么程度」,不证明这件事本身该做。金额、商户、收件人这些业务判断仍然要在你自己的代码里做;把验链当成全部检查,是这类系统最常见的误用。
Step 6:挂到 MCP 上,以及上线红线
如果你已经有一个 MCP 服务,mcp-bridge 的 wrapMcp 能把它包成一个 Aroha Agent:传入工具清单与每个工具的真实实现,调用方的公钥解析器用来核对每一次请求的签名、有效期与 nonce 重放。工具清单里每一个名字都必须有对应实现,否则启动就会抛错。包好之后它会带自己的标识与本地端点。
import { wrapMcp, registryPublicKeyResolver } from "@aroha-sdk/mcp-bridge";
const agent = await wrapMcp(server.listTools(), {
name: "my-tools",
price: 0.01,
resolvePublicKey: registryPublicKeyResolver(), // 核对调用方是谁
handlers: {
search: async (args) => ({ content: [{ type: "text", text: await search(args) }] }),
},
});
await agent.start();
// did:aroha:my-tools -> http://localhost:3001
调用方这一侧的约定要记牢:授权状放在请求参数的 _meta 里,键名是 aroha/mandate,既不是 HTTP 头,也不是工具参数;它由 presentMandate 生成,里面带一个持有者证明,证明里签的是规范化的 JSON(RFC 8785)形式,包含用途、受众、工具名、参数哈希、授权状 id、持有者、随机数与有效期。桥会解析持有者、校验证明、拒绝重复使用的随机数,再以持有者为期望被授权人验链。没有有效证明的令牌会被直接拒绝——否则它就是一张谁看到都能重放的 bearer 凭据。
// 调用侧:生成 _meta,随调用一起发出
const _meta = await presentMandate({
token, holderDid: MY_DID, holderPrivateKey: MY_KEY,
audience: AGENT_DID, tool: "search", args,
});
await client.callTool({ name: "search", arguments: args, _meta });
最后几条上线红线:不加 requireMandate 时,MCP 端点不认证任何人,此时启动会拒绝在非回环地址上提供服务,除非显式打开允许未认证的开关(那就得自己在前面加认证);Host 与 Origin 头会按回环名称与允许名单核对。想全开严格模式时用 governed,它会要求每一次调用都带授权状、按密钥事件日志验证人的根、对每一跳查撤销、并钉住根,缺少撤销检查、密钥事件源、根钉或持久化兑付存储时启动会被拒绝,内存版兑付存储明确不被接受。人可以随时撤销,撤销状态由注册表见证者推送给验证方;放行与拒绝都会留签名回执。
桌面运行时尚未发布:仓库说明里提到 Aroha 运行时(桌面应用)在另一个私有仓库开发、目前没有发布;saga、结算、商务与声誉这几个扩展也在仓库外维护,只以 npm 包形式发布。做方案时请按「当前能用的是 SDK 与适配器」来规划,不要把未发布的组件排进工期。
常见问题速查
| 现象 | 原因与处理 |
|---|---|
| 脚手架起不来 | 要求 Node.js 22 及以上、npm 10 及以上 |
| 想知道环境通不通 | aroha doctor,可带 endpoint 检查连通性 |
| 已有 MCP 服务想加验链 | aroha convert --from mcp --dir . 或直接用 wrapMcp |
| chainVerified 为 false | 链上某一跳放宽了范围、被改过、过期或签发方不对 |
| 想只允许来自某个人的权威 | verifyMandateChain 传 expectedRoot,并确认是同一个人 |
| 担心授权状被别人拿去重放 | 走 _meta 的持有者证明,随机数重复使用会被拒绝 |
| 把令牌放进 HTTP 头不管用 | 约定是 _meta 里的 aroha/mandate,没有 HTTP 头约定 |
| 启动报缺少 handler | 工具清单里每个名字都要有实现,否则 start 抛错 |
| 想在公网提供服务被拒 | 未开 requireMandate 时不认证任何人,仅允许回环地址 |
| governed 模式启动失败 | 缺撤销检查、密钥事件源、根钉或持久化兑付存储会被拒;内存存储不接受 |
| 业务上被超额调用 | 验链之外还要自己比对 mandate.constraints 里的额度与类别 |
| 想看撤销与回执 | 人可随时撤销,状态由注册表见证者推送;放行与拒绝都有签名回执 |
| 授权状有效期该给多久 | 贴着真实任务长度给,别给一个「反正用不完」的大数字 |