前门与边界:channels、connections 与安全模型
30 秒导读: 一个 eve agent 要跑起来,得先有人能"跟它说 话",再让它能"对外干活"。channels 是前者——把 Slack / GitHub / 浏览器等外部平台的入站请求,验签后变成一条用户消息;connections 是后者——让模型按需发现并调用 MCP / OpenAPI 工具,框架在出站时悄悄注入 token。这两件事中间横着一条信任边界:secrets 和你的 Node 代码只在 app 侧,模型和它的 shell 命令被关在隔离的 sandbox 里。本章讲清这条边界画在哪、谁能跨过去。
本章假设你已经读过 架构与原理(总览) 和 持久化执行模型。这里只聚焦对外接入面:进来怎么验、出去怎么连、信任怎么分。
1. 这是什么(零基础也能懂)
把 eve agent 想成一栋有人值守的楼。它有两类对外的口子:
- 前门(channels): 外面的人/平台从这里进来跟 agent 说话。门口有保安验身份证(验签 / 验 token),不验过不放进来。
- 后门工具间(connections): agent 干活时要去外部服务取数据(查 Linear issue、读 Notion 页面),从这里出去。出门时门卫帮它别上一张"工牌"(认证 token),但工牌本身 agent 自己摸不到。
楼里还有一道内墙(安全模型):值钱的东西(secrets、你的业务代码)在内侧的 app runtime;模型自己跑 shell 命令的地方是外侧的 sandbox,墙上没有门通回内侧。
这三件事各解决什么问题
| 概念 | 一句话 | 解决的问题 |
|---|---|---|
| channel | agent 的前门适配器 | 把 N 种平台的入站格式,统一成"一条用户消息 + 一个续聊句柄",并在门口验签 |
| connection | agent 的工具出口 | 让模型按需发现并调用外部 MCP/OpenAPI 工具,框架负责注入认证、不让 token 进模型 |
| 安全模型 | app 侧可信 / sandbox 侧隔离的二分 | 让"模型能跑任意命令"这件事在出事时不会泄露你的 secrets |
用起来什么样
一个最小的前门(默认 HTTP channel),只需要声明一条 auth 策略:
// agent/channels/eve.ts —— 示意,真实 API 见 eveChannel
import { eveChannel, vercelOidc, placeholderAuth } from "eve/channels";
// 没认证过的请求一律 401;生产上把 placeholderAuth() 换成真的
export default eveChannel({ auth: [vercelOidc(), placeholderAuth()] });
一个最小的后门(连 Linear 的 MCP server),声明一个 connection 文件即可,模型自己会通过 connection_search 发现里面的工具:
// agent/connections/linear.ts —— 示意
import { defineMcpClientConnection } from "eve/connections";
export default defineMcpClientConnection({
url: "https://mcp.linear.app/mcp",
description: "Linear workspace: issues, projects, cycles, and comments.",
authorization: /* getToken() 或交互式 OAuth */ undefined,
});
一句话直觉: channel 是"验过身份才放进来的对讲机",connection 是"门卫帮你别工牌的旋转门",而安全模型保证"模型再怎么折腾,也够不到保险柜"。
2. 顶层全景(它大概怎么转)
下面这张图把"一次外部交互"从进门到干活到出门串起来。怎么读: 从左到右是数据流,中间那条竖虚线是信任边界——左边 app 侧握着 secrets,右边 sandbox 侧什么密钥都没有。
外部平台 app runtime(可信侧) ┊ sandbox(隔离侧)
┌──────────┐ 入站 ┌───────────────────────────────┐ ┊
│ Slack / │ ────────► │ ① channel route │ ┊
│ GitHub / │ webhook │ · route auth(验 token) │ ┊
│ 浏览器 │ │ · webhook 验签(constant- │ ┊
└──────────┘ │ time HMAC,不信 body 身份) │ ┊
▲ └───────────────┬───────────────┘ ┊
│ 出站回信 │ 归一化成一条用户消息 ┊
│(channel send) ▼ ┊
┌──────────┐ ┌───────────────────────────────┐ proxy ┊ ┌─────────────┐
│ 外部服务 │ ◄──────── │ ② harness / agent 循环 │ ──────► ┊ │ /workspace │
│(Linear │ 注入 auth │ · 内置工具在 app 侧执行 │ shell ┊ │ bash/读写 │
│ /Notion) │ header │ · connection 工具调用 │ ┊ │ 无 secrets │
└──────────┘ │ ③ connection_search → getToken│ ┊ └─────── ──────┘
│ token 每 step 缓存,不入持久│ ┊
└───────────────────────────────┘ ┊
部件一句话职责
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
| route + route auth | 接 HTTP/WS 请求,先跑 auth 策略,不过则 401 | src/public/channels/auth.ts routeAuth |
| webhook 验签 | 用平台密钥对原始 body 做 HMAC,常数时间比对 | src/public/channels/github/verify.ts verifyGitHubRequest |
| channel adapter | 平台事件 → 一条用户消息;持有续聊状态、决定怎么回信 | src/channel/adapter.ts ChannelAdapter |
| cross-channel receive | 一个 channel 把会话甩给另一个 channel(HTTP 转 Slack) | src/channel/cross-channel-receive.ts invokeChannelReceive |
| connection registry | 按名字懒加载 MCP/OpenAPI 客户端 | src/runtime/connections/registry.ts ConnectionRegistryImpl |
| connection_search | 模型用关键词搜遍所有 connection 的工具 | src/runtime/framework-tools/connection-search-dynamic.ts |
| token 解析与缓存 | getToken()/OAuth 出 bearer,注 header,每 step 缓存 | src/runtime/connections/mcp-client.ts resolveHeaders |
| eve-catalog | 所有集成身份的单一来源(channel/connection 清单) | packages/eve-catalog/src/index.ts INTEGRATIONS |
主线走一遍(高层)
一次 Slack @提及,大致这样流过:
- Slack POST 到 channel route →
routeAuth先过一遍路由 auth。 - channel 用
SLACK_SIGNING_SECRET对原始请求 body 验 HMAC(常数时间),验过才信。 - adapter 的
deliver把 Slack 事件归一化成一条带"谁说的"的用户消息,交给 harness。 - 模型若想查 Linear,先调
connection_search发现工具,再按"合格名"(linear__list_issues)直接调用。 - 调用前
resolveHeaders解析 token、注入Authorizationheader;token 不进模型、不入持久状态。 - 结果回到模型,最终响应经 channel 的
send回到 Slack 线程。
3. 核心原理:channels(前门)
本节讲 agent 怎么对外接入:路由 auth、webhook 验签、adapter 归一化、跨 channel 转交。
3.1 channel adapter:把"N 种平台格式"压成"一条消息"
它要解决的小问题: Slack 的事件、GitHub 的 webhook、浏览器的 POST,长得完全不一样;但 harness 只想吃"一条用户消息"。adapter 就是这层翻译。
eve 的 adapter 是个普通对象,不是类。它声明一个稳定 kind(跨 step 序列化用)、一份可自动快照的 state、一个入站钩子 deliver,以及若干出站事件处理器。deliver 在每次投递时跑一次,可以返回一个覆盖 harness 输入的 StepInput,或返回 void 用默认投影:
真实定义见 src/channel/adapter.ts:117 ChannelAdapter 和 src/channel/adapter.ts:143 的 deliver 钩子。状态会在 step 边界自动 JSON 快照,无需手写 serialize()——见 adapter.ts:134-135 的注释。
一个关键设计:事件处理器抛错被吞掉并记日志,不让一次投递失败污染事件流写路径。见 src/channel/adapter.ts:233 callAdapterEventHandler:
// src/channel/adapter.ts:246 —— 真实源码片段
try {
await handler("data" in event ? event.data : undefined, ctx);
} catch (error) {
log.error("adapter event handler threw — event swallowed", { ... });
}
这句"抛错即吞"是有意为之:channel 是边缘,边缘的故障不该回灌进核心循环。
3.2 route auth:默认拒绝,失败关闭(fail closed)
它要解决的小问题: 怎么保证"没认证的请求进不来",同时又能灵活叠加多种认证方式?
eve 用一个 AuthFn 的有序数组,逐个走(walk):第一个返回 SessionAuthContext 的胜出;返回 null/undefined 的跳过下一个;走到底没人接(包括空数组),就 401。这套 walk 的语义见 src/public/channels/auth.ts:508 routeAuth:
// src/public/channels/auth.ts:516 —— 真实源码片段
for (const fn of list) {
const result = await fn(request);
if (result) return result; // 第一个认证成功者胜出
}
// ...走完没人接:
return createUnauthorizedResponse({ challenges: [{ scheme: "Bearer" }] });
这就是"fail closed":默认拒绝,放行匿名要显式加一个 none()(auth.ts:577)作为数组最后一项。框架提供的策略 helper:
| 策略 | helper | 行号 |
|---|---|---|
| Vercel OIDC(当前项目自动放行) | vercelOidc() | auth.ts:917 |
| HTTP Basic(常数时间比对密码) | httpBasic() / verifyHttpBasic() | auth.ts:64, :942 |
| HMAC / ECDSA 签名的 JWT | jwtHmac() / jwtEcdsa() | auth.ts:950, :959 |
| 通用 OIDC | oidc() | auth.ts:973 |
| 本地开发(只认 loopback host) | localDev() | auth.ts:607 |
| 匿名放行(显式) | none() | auth.ts:577 |
还有一个很贴心的"半成品防呆":placeholderAuth()(auth.ts:548)在生产环境抛 401、在非生产返回 null(等价跳过)。这样一个还没配好真 auth 的脚手架 app,部署到生产会整个关门,而不是默默放行:
// src/public/channels/auth.ts:549 —— 真实源码片段
if (process.env.VERCEL_ENV !== "production") {
return null; // 本地:跳过,像普通未匹配项
}
throw new UnauthenticatedError({ // 生产:整条路由 401
code: "eve_production_auth_not_configured", ...
});
失败不泄露:
VerifyResult失败分支不返回任何细节(auth.ts:41-43),路由不会泄露"是哪一步验证挂了",避免给攻击者枚举线索。
3.3 webhook 验签:常数时间 HMAC + 不信 body 身份
这是整章安全含量最高的一支。平台 webhook 没有 bearer token,身份靠的是平台用共享密钥对请求 body 算的签名。验签有两条铁律,内置 channel 都遵守,你自己写 channel 也必须遵守:
铁律一:对原始 body 验签,且用常数时间比对。 看 GitHub channel 的实现 src/public/channels/github/verify.ts:44 verifyGitHubRequest——它先 await request.text() 拿到原始字节(因为 GitHub 签的就是它发的那串字节),算出期望签名,再用 constantTimeCompare 比:
// src/public/channels/github/verify.ts:63 —— 真实源码片段
const expected = signGitHubWebhookBody(body, secret);
if (!constantTimeCompare(expected, signatureHeader)) {
throw new Error("githubChannel: inbound request signature mismatch.");
}
而 constantTimeCompare(verify.ts:75)底层是 Node 的 timingSafeEqual,不是 ===:
// src/public/channels/github/verify.ts:75 —— 真实源码片段
function constantTimeCompare(a: string, b: string): boolean {
if (a.length !== b.length) return false;
try {
return timingSafeEqual(Buffer.from(a), Buffer.from(b)); // 常数时间
} catch (error) { ... return false; }
}
为什么不能用 ===?因为 === 比字符串是逐字符短路的——前几个字符对不上就立刻返回。攻击者测响应时间,就能一个字节一个字节地"猜"出正确签名。常数时间比对消除这个时序侧信道。Slack 走的是同一套(src/public/channels/slack/verify.ts:16 verifySlackRequest,委托给 Chat SDK 的 webhook 原语,并校验时间戳防重放)。
铁律二:绝不信 body 里自称的身份。 caller 是谁,只能从已验证的签名或 token 推导,绝不能信请求 body 里写的 principalId(或类似字段)。body 是攻击者可控的,把它当身份就是跨用户冒充。这条在 docs/concepts/security-model.md 的 "Channel verification" 一节被明文列为规则。Slack inbound 的处理也体现这点:它把 actor、消息、频道、线程渲染成一条带归属的模型消息,"让说话者身份不会从它描述的内容上漂走"(src/public/channels/slack/inbound.ts:11-13 注释)。
可插拔的验证器。 每个平台 channel 还支持传一个 webhookVerifier 覆盖内置 HMAC(例如 Connect 用 Vercel OIDC 验转发来的 webhook)。它的契约很干净——抛错或返 falsy → 401,返字符串 → 用它替换 body 供下游解析。见 src/public/channels/github/verify.ts:28 GitHubWebhookVerifier 的契约文档。
3.4 跨 channel 转交:HTTP 进、Slack 出
它要解决的小问题: 有时入口和出口不是同一个 surface——一个 HTTP webhook 想把对话落到 Slack 线程上。
每个 route handler 都拿到一个 receive 闭包(src/channel/routes.ts:29 CrossChannelReceiveFn)。它按引用身份(reference identity)解析目标 channel——你必须 import 那个 channel 模块的默认导出再传进来,框架不接受结构相似的冒名顶替。解析 + 调用见 src/channel/cross-channel-receive.ts:117 invokeChannelReceive:目标 channel 的 receive 钩子拥有续聊 token 格式和初始 state 的所有权,auth 被原样转发成 session.initiatorAuth。
4. 核心原理:connections(工具出口)
本节讲 agent 怎么连外部服务:工具发现、token 解析、认证注入。
4.1 connection_search:模型按需发现工具
它要解决的小问题: 一个 connection(比如 Linear MCP)可能暴露几十个工具。如果一上来把它们全塞进 system prompt,上下文会爆,而且大多用不上。
eve 的解法是按需发现:框架注册一个动态工具 connection_search,模型用关键词去搜遍所有 connection,命中的工具才"变成可直接调用"。注册逻辑见 src/runtime/framework-tools/connection-search-dynamic.ts:370 createConnectionSearchEvents。它的工具描述本身就告诉模型怎么用:
// connection-search-dynamic.ts:393 —— 真实源码片段(描述串)
"Search for tools across your connections. " +
"Discovered tools become directly callable by their qualified name " +
"(e.g. `linear__list_issues`) in your next response. "
打分是朴素的 token 匹配:工具名命中 +3、描述命中 +1(connection-search-dynamic.ts:100 scoreMatch)。发现的工具被记进 durable context,并在每个 step 重新从对话历史推导工具集(订阅 step.started,connection-search-dynamic.ts:371)——所以compaction 之后旧的搜索结果从消息里消失,发现的工具会自然掉出工具集。这和 harness 的 compaction 是咬合的设计。
registry 本身是协议无关的:按 connection 的 protocol 字段 dispatch 到 OpenApiConnectionClient 或 McpConnectionClient,并懒加载缓存(src/runtime/connections/registry.ts:26 getClient)。
4.2 getToken / OAuth:bearer 怎么来
它要解决的小问题: connection 调外部服务要带认证。这个 bearer 从哪来?
两条路:静态的 getToken(),或交互式 OAuth 流程。出站请求的 header 由 resolveHeaders 组装(src/runtime/connections/mcp-client.ts:356):解析 authorization 定义 → 解析 principal → 调 getToken({ principal }) 拿 bearer → 注成 Authorization: Bearer:
// src/runtime/connections/mcp-client.ts:368 —— 真实源码片段
if (authorization !== undefined) {
const result = await resolveToken(connection, authorization);
merged.Authorization = `Bearer ${result.token}`;
}
principal 是 app 级还是 user 级? 这决定 token 是全员共享还是按人隔离。判定集中在 src/runtime/connections/principal.ts:81 resolveConnectionPrincipal:principalType: "app" → 共享一份 { type: "app" };"user" → 把当前 caller 的 SessionAuthContext 投影成 user principal,没有认证用户就 fail-fast(reason: "principal_required",不可重试)。user-scoped connection 因此强制要求"能解析出认证用户"的 route auth。
交互式 OAuth 的循环防护。 当工具调用返回 401、且这一轮刚好完成过授权,框架不会无限重新弹登录,而是终态失败——见 connection-search-dynamic.ts:472 的 loop guard:
// connection-search-dynamic.ts:472 —— 真实源码片段
if (justCompletedAuth) {
throw new ConnectionAuthorizationFailedError(connectionName, {
retryable: false,
reason: "token_rejected_after_authorization", ...
});
}
MCP 客户端这边也对 401 做了精确分类(src/runtime/connections/mcp-client.ts:252 isMcpAuthRequiredError):只有 401(token 缺失/过期/吊销,RFC 6750 可恢复)才驱逐缓存 token、重连、转成"需要授权";而 403(权限不足)故意放行不重试,因为重跑同一个授权也没用。
4.3 OpenAPI 安全:token 放对位置
它要解决的小问题: 不同 OpenAPI 服务把凭证放在不同地方——有的要 Authorization: Bearer,有的要自定义 header,有的要 query 参数或 cookie。
eve 先统一把 token 解析成 Authorization: Bearer,再按操作的 security 要求搬到对的位置。映射逻辑见 src/runtime/connections/openapi-security.ts:32 resolveSecurity 和 :113 applySecurity:
| OpenAPI scheme | eve 放在哪 |
|---|---|
http(bearer)/ oauth2 / openIdConnect | Authorization: Bearer <token>(默认) |
http(basic)/ basic | Authorization: Basic <token> |
apiKey in header | 指定的 header 名 |
apiKey in query | 指定的 query 参数 |
apiKey in cookie | 指定的 cookie |
// src/runtime/connections/openapi-security.ts:134 —— 真实源码片段
delete headers.Authorization; // apiKey:不放 Authorization
if (placement.in === "header") {
headers[placement.name] = token;
} else if (placement.in === "query") {
query.set(placement.name, token);
} else {
cookies.push(`${placement.name}=${token}`);
}
4.4 token 每 step 缓存,绝不入持久状态(精华)
这是整个 connection 安全模型最关键的一处。 bearer token 解析出来后,缓存在一个虚拟 context 槽里,key 是 (connectionName, principalKey)——两个用户在同一 session 上永不共享 user-scoped token。但这个槽在 step 之间被擦掉,所以 bearer永远不会被序列化进 durable step payload。
看 src/runtime/connections/authorization-tokens.ts:65 writeCachedToken,它写的是 setVirtualContext(运行时专用、不入持久层)而非普通 context:
// src/runtime/connections/authorization-tokens.ts:73 —— 真实源码片段
asContainer(ctx).setVirtualContext(ConnectionAuthorizationTokensKey, {
...existing,
[connectionName]: { ...perConnection, [principalKey]: token },
});
读缓存时还会检查过期:expiresAt 到点就当 cache miss,逼调用方重跑授权(authorization-tokens.ts:51)。跨 step 的复用委托给上游授权提供方(如 Connect 的服务端缓存,它持有刷新授权),eve 自己只缓存一个 step 的时长——这样 WDK payload 里永远没有明文 bearer。模块顶部的 docblock(authorization-tokens.ts:1-14)把这个取舍写得很清楚。
principal cache key 的拼法见 src/runtime/connections/principal.ts:30 principalKey:app → "app";user 带 issuer → "user:${issuer}:${id}",issuer 前缀防止不同身份提供方下相同 id(Slack U123 vs Google U123)互相串号。
5. 安全模型:那条信任边界
本节讲信任边界画在哪、secrets 在哪、凭证怎么到达 sandbox。
5.1 二分:app runtime(可信) vs sandbox(隔离)
整个 eve 的安全心智模型就一张表(源自 docs/concepts/security-model.md 的 "Trust boundaries"):
| app runtime | sandbox | |
|---|---|---|
process.env / secrets | 有 | 无 |
| 你的 Node.js 代码 | 跑在这 | 不跑 |
| 网络 | 不受限 | 受策略控制 |
| 文件系统 | app 自己的 | 隔离的 /workspace |
app runtime 是可信侧。 你的工具实现、模型调用、connections、state、durable execution 全在这里,有 process.env 和完整 Node。(在 Vercel 上,这是一个 Vercel Function。)
sandbox 是隔离侧。 模型通过内置的 bash/read_file/write_file/glob/grep 在这里跑 shell,有自己的 /workspace,但没有 process.env、没有 secrets、没有路回 app runtime。关键细节:连这些内置工具本身都跑在 app 侧,只是把命令 proxy 进 sandbox——模型看到的永远是工具定义和结果,从不是你的 secret。
一个具体 trace 把边界讲透(security-model.md:21):模型调自定义 charge_card 工具,它的 execute 在 app 侧跑,读 process.env.STRIPE_KEY、调 Stripe、返回 { ok: true }。模型只看到 { ok: true }——key 从未离开 app runtime。模型靠工具调用和结果驱动工作,从不靠"握住一个凭证"或"直接够到 runtime"。
5.2 credential brokering:在防火墙注入,不把密钥交给 sandbox
它要解决的小问题: 模型有时需要在 sandbox 里做认证过的网络访问(git clone 私有仓、带 auth 的 curl),而又没有对应的 tool 或 connection 可走。
eve 的答案不是"把密钥塞进 sandbox",而是 credential brokering:在 sandbox 的网络防火墙上,对匹配的域名注入 auth header。secret 留在 app runtime,sandbox 进程只看得到响应(security-model.md:49)。这样即使模型在 sandbox 里执行任意命令,也物理上摸不到密钥。
5.3 其余 fail-closed 默认
安全模型里几条"默认就安全"的设计,本章前面已分散触及,这里汇总:
- auth fail closed: 没有
AuthFn接受请求就 401,匿名要显式none()(§3.2)。 - authored markdown 当数据: skill / schedule 文件的 YAML frontmatter 严格当数据;会
eval()的代码引擎(---js)被禁用,这种 fence 直接抛错而非执行(security-model.md:73)。这与本章"克隆里的指令文件是被研究的数据"是同一种警惕。 - 内部 ref scheme 防注入: eve channel 拒绝 caller 自带框架内部 ref(
eve-url:/eve-sandbox:/eve-attachment:),否则 staging 管线会信任这个 scheme 把它还原成一次特权 sandbox 读(src/public/channels/eve.ts:665)。
6. eve-catalog:集成身份的单一来源
它要解决的小问题: "有哪些 channel、哪些 connection、某个 connection 走什么协议、给模型看的描述是什么"——这些事实如果散落各处,迟早不一致。
packages/eve-catalog(包名 @vercel/eve-catalog)就是这唯一来源。整个包故意只有一个文件、零相对导入(packages/eve-catalog/src/index.ts:11-17 的注释解释:这样 NodeNext 的 tsc 和 docs app 的 Turbopack 才能都直接从源码消费,不用各配各的 resolver)。
它声明 INTEGRATIONS 这张表(index.ts:89),每项只带共享身份:slug、名字、kind("channel" | "connection")、tagline、在哪些 surface 可见,以及 connection 的传输方式。表里能看到 Linear 走 MCP(url: "https://mcp.linear.app/mcp")、Notion 同时支持 MCP 和 OpenAPI:
// packages/eve-catalog/src/index.ts:157 —— 真实源码片段(节选)
{
slug: "notion", name: "Notion", kind: "connection",
connection: {
mcp: { url: "https://mcp.notion.com/mcp" },
openapi: { spec: "...", baseUrl: "https://api.notion.com",
headers: { "Notion-Version": "2022-06-28" } },
},
}
身份与表现分离。 catalog 只管"是什么";表现层各自按 slug 叠加自己的东西——脚手架(eve CLI)叠加它要发的 Connect auth spec,docs gallery 叠加 logo / 关键词 / auth 模式 / 手写 markdown,谁都不重新声明身份(index.ts:6-12)。McpTransport / OpenApiTransport 上的 headers 字段注释也强调:这里只放静态、非密的字面 header(index.ts:29、:37),secrets 永远不进 catalog。
7. 巧妙之处(可借鉴的技术)
| 妙在哪 | 一句话 | file:line + 符号 |
|---|---|---|
| 半成品防呆 | 生产没配 auth 就整条路由 401,而非默默放行 | auth.ts:548 placeholderAuth |
| 失败不泄露 | 验证失败不返回任何"哪一步挂了"的细节 | auth.ts:41 VerifyResult |
| 常数时间比对 | 签名比对用 timingSafeEqual 而非 ===,杀时序侧信道 | github/verify.ts:75 constantTimeCompare |
| 边缘故障隔离 | adapter 事件处理器抛错被吞+记日志,不污染事件流 | adapter.ts:233 callAdapterEventHandler |
| token 不落盘 | bearer 写虚拟 context、每 step 擦除,绝不入 durable payload | authorization-tokens.ts:65 writeCachedToken |
| 401 ≠ 403 | 只 401 触发重新授权,403 故意放行不重试 | mcp-client.ts:252 isMcpAuthRequiredError |
| OAuth 循环防护 | 刚授权完仍 401 则终态失败,不无限弹登录 | connection-search-dynamic.ts:472 |
| 按引用解析转交 | 跨 channel 目标按引用身份匹配,拒结构冒名 | cross-channel-receive.ts:128 resolveTargetByReference |
| 防 ref 注入 | 拒绝 caller 自带的框架内部 ref scheme | eve.ts:665 parseMessagePart |
8. 边界与局限(诚实)
localDev()信任前置边缘。 它按请求 URL 的 hostname 判 loopback;若一个公网 origin 信任攻击者可控的Host头(没 CDN、没归一化反代),攻击者能伪造Host: localhost命中localDev()。代码注释自己点明了这个 caveat(auth.ts:599-606),这种部署要叠真 authenticator。- 跨 step token 复用靠上游。 eve 自己只缓存一个 step;真正的刷新/复用委托给上游授权提供方(如 Connect)。
authorization-tokens.ts:9-14有个 TODO:等有了服务端可派生的 session key,才能用 AEAD codec 把缓存升回 durable。 connection_search打分是朴素 token 匹配。scoreMatch(connection-search-dynamic.ts:100)只做子串/包含匹配,没有语义检索;工具名/描述写得差,可能搜不到。- app-scoped connection 全员共享一份凭证。 principal 为
app时所有 caller 共用一个 token(principal.ts:86);需要按人隔离的场景必须显式声明 user-scoped,并配能解析认证用户的 route auth。 - secrets 的边界靠平台兑现。 "secret 不进 sandbox""brokering 在防火墙注入"这些保证,依赖底层 sandbox 后端(Vercel Sandbox microVM)的硬件级隔离;换一个不提供同等隔离的后端,这些边界要重新评估(security-model.md:19、:49)。
9. 横向对比
同 shelf 的其它 agent 框架在"前门与边界"上的取舍各不相同:多数把"工具/MCP 连接"做成静态注册、一次性塞进上下文,而 eve 选了 connection_search 的按需发现 + compaction 自动回收;在凭证上,eve 把"token 每 step 缓存、绝不落 durable payload、brokering 在防火墙注入"做成默认,而不少框架默认让工具实现直接从环境里读密钥、对模型暴露面更大。channel 验签这一层,eve 把"常数时间 HMAC + 不信 body 身份"写进文档当成强制契约并在内置 channel 里逐一落地,这点比"留给用户自己验"的框架更严。详见总库 doc 对"工具接入面 / 凭证边界"的横向原理整理,以及兄弟 子库 doc 对各自 MCP/连接模型的拆解。
10. 代码地图(导航索引)
| 主题 | 文件路径(相对克隆根) | 符号名 |
|---|---|---|
| channel adapter 接口与事件分发 | packages/eve/src/channel/adapter.ts | ChannelAdapter / callAdapterEventHandler |
| route / send / WS 路由描述符 | packages/eve/src/channel/routes.ts | RouteHandlerArgs / GET / WS |
| route auth walk 与策略 helper | packages/eve/src/public/channels/auth.ts | routeAuth / placeholderAuth / none / vercelOidc |
| GitHub webhook 验签(HMAC) | packages/eve/src/public/channels/github/verify.ts | verifyGitHubRequest / constantTimeCompare |
| Slack webhook 验签 | packages/eve/src/public/channels/slack/verify.ts | verifySlackRequest |
| Slack 入站归一化(带归属) | packages/eve/src/public/channels/slack/inbound.ts | SlackMessage |
| 默认 HTTP 前门 channel | packages/eve/src/public/channels/eve.ts | eveChannel / parseMessagePart |
| 跨 channel 转交 | packages/eve/src/channel/cross-channel-receive.ts | invokeChannelReceive / resolveTargetByReference |
| connection registry(协议无关) | packages/eve/src/runtime/connections/registry.ts | ConnectionRegistryImpl |
| connection_search 动态工具 | packages/eve/src/runtime/framework-tools/connection-search-dynamic.ts | createConnectionSearchEvents / executeConnectionSearch |
| MCP 客户端 / header 解析 / 401 分类 | packages/eve/src/runtime/connections/mcp-client.ts | resolveHeaders / isMcpAuthRequiredError |
| OpenAPI 安全位放置 | packages/eve/src/runtime/connections/openapi-security.ts | resolveSecurity / applySecurity |
| per-step token 缓存(不落盘) | packages/eve/src/runtime/connections/authorization-tokens.ts | writeCachedToken / readCachedToken |
| connection principal 解析 | packages/eve/src/runtime/connections/principal.ts | resolveConnectionPrincipal / principalKey |
| 集成身份单一来源 | packages/eve-catalog/src/index.ts | INTEGRATIONS / connectionProtocols |
| 安全模型权威文档 | docs/concepts/security-model.md | (trust boundaries / credential brokering) |
相邻章节: 前门把消息交给的那个 默认 harness 循环;连接发现与 compaction 的咬合见 上下文控制;session/step 的持久化语义见 持久化执行模型。