数据截至 (上游 commit 9965cfc0dafd)
前门与边界: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:114 ChannelAdapter 和 src/channel/adapter.ts:140 的 deliver 钩子。状态会在 step 边界自动 JSON 快照,无需手写 serialize()——见 adapter.ts:134-135 的注释。
一个关键设计:事件处理器抛错被吞掉并记日志,不让一次投递失败污染事件流写路径。见 src/channel/adapter.ts:230 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 是边缘,边缘的故障不该回灌进核心循环。