数据截至 (上游 commit e55b2a12c9a5)
连接器网关(原 Executor):agent 的手脚与那道凭据闸门
30 秒导读: 模型只会说话,连接器网关是让它「动手」的那一层。agent 在沙箱里发出的每一次外部调用都是一句
{connector, action, args},通过 HTTP 打到服务端网关;网关查授权、过策略、在服务端把凭据贴到出站请求上、执行、记审计。沙箱从头到尾没见过任 何应用密钥。改名说明: 上游重构把
apps/api/src/executor/整体改名为apps/api/src/connectors/,HTTP 前缀从/v1/executor改成/v1/connectors,CLI 从kortix executor改成kortix connectors,沙箱会话令牌从KORTIX_EXECUTOR_TOKEN改成KORTIX_CLI_TOKEN。本章行文一律用新名字。
本章是 Kortix (Suna) — 架构与原理 的第 5 章,讲工具面。连接器在 manifest(kortix.yaml)里怎么声明、字段怎么校验,见 01 章;模型走的是另一条路,见 06 章。
一条边界要先划清: 「连接的机器」(Agent Computer Tunnel)在本章只以连接器的一面出现——它是一种 binding、一种没有凭据的动作来源。反向隧道本身的形状(/v1/tunnel 的 relay、跨实例转发表、权限审批信封、packages/agent-tunnel)写在 03 章 §9.3,本章不重复。
1. 这是什么(零基础也能懂)
一句话定义: 连接器网关是一个服务端工具网关——把 Stripe / GitHub / Slack / 任意 MCP 服务器 / 任意 OpenAPI 接口,统一成一张「动作目录」,让沙箱里的 agent 用同一种调用方式使用,同时把凭据扣在服务端。
它解决什么问题。 假设你让 agent 去「给这个 GitHub issue 回一条评论 」。最朴素的做法是把 GITHUB_TOKEN 塞进沙箱环境变量,让 agent 自己 curl。这条路有三个致命问题:
- 沙箱里跑的是模型生成的代码,一个 prompt injection 就能把 token 打印出来、发到外网;
- token 一进沙箱,你就再也管不住它调什么——没有「这个 agent 只能读、不能删」这回事;
- 出了事没有账:谁、什么时候、用谁的身份、调了哪个接口,查不到。
连接器网关的答案是把这三件事全挪到服务端:沙箱只知道动作的名字和参数,凭据、策略、审计都在墙外。
用起来什么样。 沙箱里的 agent 主要通过 kortix CLI 使用它(这是默认路径):
# 我这个会话能用哪些连接器?
$ kortix connectors ls
# 找一个能干这事的工具
$ kortix connectors discover "create a stripe charge"
# 调用它 —— 注意:没有任何 token 出现在这条命令里
$ kortix connectors call stripe.charges.create '{"amount":2000,"currency":"usd"}'
一句话直觉。 把它想成代客泊车钥匙(valet key):你把车交给代客,给的是一把只能开门打火、打不开后备箱、跑不了高速的钥匙。agent 拿到的不是钥匙,连钥匙孔都摸不到——它只能对着门童喊「把车开到门口」,由门童拿真钥匙去办,并且门童有权说「这个动作要等车主点头」。
2. 顶层全景(它大概怎么转)
怎么读这张图: 从左到右是一次工具调用的生命周期;竖直的那条双线是信任边界——密钥只存在于线的右侧。
沙箱内(不可信) ‖ 服务端(可信)
‖
┌──────────────┐ ‖ ┌───────────────┐ ┌──────────────┐
│ agent / │ HTTP ‖ │ ① 认门 │ │ ② 查目录 │
│ OpenCode │────────>‖──>│ 会话令牌→身份 │──>│ 连接器+动作 │
│ │ {连接器, ‖ │ + agent 授权 │ │ 是否存在/启用 │
└──────────────┘ 动作, ‖ └───────────────┘ └──────┬───────┘
▲ 参数} ‖ │
│ ‖ v
│ ‖ ┌───────────────┐ ┌──────────────┐
│ ‖ │ ④ 贴凭据 │<──│ ③ 过策略 │
│ ‖ │ applyAuth │ │ 项目→连接器 │
│ 结果 / 拒绝 ‖ │ (密钥在这出现) │ │ →risk 默认 │
└─────────────────‖───┴───────┬───────┘ └──────────────┘
‖ │ 出站
‖ v
‖ ┌───────────────┐ ┌──────────────┐
‖ │ ⑤ 打第三方 │──>│ ⑥ 记审计 │
‖ └───────────────┘ └──────────────┘
部件一句话职责:
| 部件 | 干什么 | 文件 |
|---|---|---|
router.ts | 两面 HTTP:沙箱面(/connectors、/call)与仪表盘面(连接器 CRUD、sync、credential、policies) | apps/api/src/connectors/router.ts |
gateway.ts | 唯一咽喉:解析连接器/动作 → 解凭据 → 过策略 → 执行 → 审计 | apps/api/src/connectors/gateway.ts |
policy.ts | 纯函数策略引擎:分层叠加、首 次命中即停、risk 派生默认 | apps/api/src/connectors/policy.ts |
share.ts | 通用的成员/组可见性纯函数(今天只服务 session 可见性;连接器已不再用它) | apps/api/src/connectors/share.ts |
call.ts | 真正构造并发出出站请求;applyAuth 是密钥贴上去的地方 | apps/api/src/connectors/call.ts |
normalize.ts | 多种源(OpenAPI/Postman/GraphQL/MCP/HTTP/Pipedream)→ 统一 NormalizedAction[] | apps/api/src/connectors/normalize.ts |
sync.ts / materialize.ts | 把 manifest 的声明落成 DB 里的运行时视图 | apps/api/src/connectors/sync.ts |
db-deps.ts | 生产环境把上面这些接口接到真 DB / 真 fetch 上 | apps/api/src/connectors/db-deps.ts |
主线走一遍(高层): agent 发 POST /v1/connectors/call {connector:"stripe", action:"charges.create", args:{…}} → 网关认出「这是 proj-1 的 alice 启动的 release-bot」→ 确认 release-bot 被授权用 stripe 这个连接器 → 解出项目共享的那份 Stripe key → 查策略发现 charges.create 是 write、项目 default_mode = risk → 返回 pending_approval 加一条审批链接,等人点头。整条路上,沙箱收到的只有一个 202 和一句原因。
3. 核心原理
3.1 归一化:五种源,一张动作目录
要解决的小问题: Stripe 是 OpenAPI,Linear 是 GraphQL,某个内部服务是 MCP,还有 Pipedream 上 3,000+ 个已接好的 SaaS(README.md:113)。如果 agent 要为每一种学一套调用方式,这层就白做了。
思路: 定义一个中间表示 NormalizedAction(types.ts:18),所有源先翻译成它。关键字段只有四个:
| 字段 | 是什么 | 谁用它 |
|---|---|---|
path | 连接器内相对点分路径,如 charges.create | 策略匹配、agent 调用 |
inputSchema | JSON Schema,参数长什么样 | agent 组参数;网关推参数位置 |
risk | read / write / destructive | 策略的兜底默认 |
binding | 真正怎么发这个请求 | executeCall 分派 |
binding 是个判别联合(types.ts:31-59),九种形态:openapi、postman、graphql、mcp、http、tunnel(连接的机器)、voice(实时通话)、pipedream、pipedream_proxy。
精髓在 risk 怎么来的:不猜,从源自己的语义里读。
| 源 | risk 派生规则 | 位置 |
|---|---|---|
| OpenAPI / HTTP | GET/HEAD/OPTIONS→read,DELETE→destructive,其余→write | normalize.ts:22 riskForMethod |
| MCP | destructiveHint→destructive,readOnlyHint→read,否则 write | normalize.ts:310 riskForMcp |
| GraphQL | query→read,mutation→write | normalize.ts:239 normalizeGraphql |
| 渠道(Slack/邮件) | 手工标注在固定目录里 | channels.ts:807 channelCatalog |
这一步是后面整个策略层的地基:没有人手写规则的项目也自动获得「读随便跑、写要人审」的保护,因为 risk 是白来的。
一个值得学的小设计:Pipedream 连接器额外合成一个 request 动作(normalize.ts:376 pipedreamProxyAction)。Pipedream 的 curated actions 覆盖不全,于是每个 pipedream 连接器都多一个万能动作:agent 给 method + 完整 URL + body,凭据仍由 Pipedream 在服务端注入。这让一个「1-click 接好的 SaaS」立刻具备了完整 API 面。而且当某个 curated action 在 Pipedream 运行时炸了,网关会在错误信息里主动把 agent 指向这个后备(gateway.ts:881 fallbackHint)。
3.2 那道凭据闸门:密钥只在 applyAuth 那一瞬间出现
要解决的小问题: 怎么让沙箱「能用」一个密钥,却「拿不到」它。
思路: 把「调用意图」和「凭据」在时间上分开。沙箱负责前者,服务端在构造出站请求的最后一步补上后者。这 个最后一步就是 applyAuth(call.ts:55):
// call.ts:70-74 applyAuth —— 真实源码节选
if (auth.type === 'bearer') {
const prefix = auth.prefix ?? 'Bearer';
headers['Authorization'] = `${prefix} ${secret}`.trim();
return;
}
鉴权描述是个九型联合(ConnectorAuth,call.ts:14-28):bearer / basic / custom / api_key / oauth1 / hmac / aws_sigv4 / mtls / none,落点可以是 header、query 或 cookie。简单形态(bearer/basic/custom/api_key)由 applyAuth 直接贴;需要签名或证书的四种(oauth1/hmac/aws_sigv4/mtls)必须用 method + 最终 URL + query 来算,所以 applyAuth 对它们直接跳过、由 buildHttpRequest 在完成组包后签名(call.ts:46-49 的注释点明了这层分工)。明文密钥的生命周期仍然只有一次出站请求的构造过程那么长。
密钥从哪来?两个存储位置,都加密:
- 连接器凭据
connection_credentials表,一行一个(连接器, 用户)——userId = NULL是项目共享那份,也是今天唯一会写的形态(per_user逐成员凭据已于 2026-07-05 移除,见credentials.ts:4-11头注释;credentials.ts:96resolveCredentialValue)。 - 项目密钥
project_secrets,用 HKDF 从API_KEY_SECRET按projectId派生密钥、AES-256-GCM 信封加密(projects/secrets.ts:59projectSecretKey、:74encryptProjectSecret)。
闸门的另一半在这里:项目密钥里 scope='connector' 的行,永远不会被注入沙箱环境变量。 注入路径上有一句硬拦截:
// projects/secrets.ts:176-178(listProjectSecrets)
// Connector credentials / Pipedream bindings are resolved server-side by the
// Connector gateway — never injected into the sandbox env.
if (row.scope === 'connector') continue;
所以一个 Stripe key 在系统里只有一条使用路径:经过网关。projects/lib/sandbox-env-sync.ts:445 的 resolveSandboxEnvSnapshot 走的正是这个过滤后的视图,再叠一层名字消毒。
Pipedream 的做法更彻底:连密钥都不存。 存的是 Pipedream 那边的已连接账号 id,以 scope='connector' 落库(pipedream.ts:1-11 头注释),执行时把它当 binding 传给 Connect API,真正的 OAuth token 在 Pipedream 侧注入(pipedream.ts:580 runPipedreamAction)。网关代码里那句注释点破了这一点:
// gateway.ts:733
accountId: usable.secret, // the resolved binding = Pipedream account id
3.3 三层策略叠加:谁说了算
要解决的小问题: 管理员想立死规矩(「谁都不许调 *.delete*」),连接器作者想立自己的规矩(「这个连接器的写操作要人审」),而绝大多数项目一条规矩都不想写。这三种诉求要能共存,且优先级不能含糊。
思路: 三层,自 上而下,命中即停。
一次调用的路径 = "stripe.charges.create"
│
v
┌─────────────────────────┐ 匹配全限定路径
│ ① 项目 policies: │ 管理员护栏 —— 命中即定,下面两层无权翻案
└───────────┬─────────────┘
未命中 │
v
┌─────────────────────────┐ 匹配连接器内相对路径
│ ② connectors[].policies │ 连接器作者的规矩
└───────────┬─────────────┘
未命中 │
v
┌─────────────────────────┐ 连接器标了 sensitive?→ 一律人审(连 read 也是)
│ ③ risk 派生默认 │ default_mode = risk:read→放行 / write|destructive→人审
└─────────────────────────┘ default_mode = allow_all → 一律放行
①②两层的规则还可以按参数值设条件(「只能发给这些地址」之类,PolicyArgCondition),所以引擎拿到的是注入上下文之后的真实 args——网关后来才补的字段绕不过规则(gateway.ts:517-530 的注释)。
判决只有三种:always_run(直接跑)、require_approval(挂起等人)、block(拒)。实现是一个纯函数:
// policy.ts:356-378 resolveEffectiveAction —— 真实源码节选
const projectHit = firstMatchOrNull(input.fullPath, input.projectPolicies, args, argsAvailable);
if (projectHit) return { action: projectHit, source: 'project' };
const connectorHit = firstMatchOrNull(input.relPath, input.connectorPolicies, args, argsAvailable);
if (connectorHit) return { action: connectorHit, source: 'connector' };
if (input.sensitive) return { action: 'require_approval', source: 'risk_default' };
if (input.defaultMode === 'allow_all') return { action: 'always_run', source: 'allow_all' };
return { action: riskDefaultAction(input.risk), source: 'risk_default' };
注意它返回 source——「为什么是这个判决」跟判决一起返回,直接写进审计记录(gateway.ts:541、:609 的 policy_source)。这是可解释性,不是装饰。
两个细节值得抄:
- 匹配器可以是 glob,也可以是正则。 用斜杠包起来就是正则:
/^charges\.(create|update)$/i(policy.ts:93isRegexMatcher)。glob 会被锚定成^…$、大小写不敏感(policy.ts:81globToRegex)。 - 写错的正则退化成「永不匹配」,而不是「匹配一切」。
// policy.ts:105 —— 编译失败时的兜底
} catch {
return /(?!)/; // invalid regex → never matches (fail safe, never allow-all)
}
一个手滑的正则,最坏结果是「这条规则没生效」,而不是「这条规则把所有东西都放行了」。安全默认值该往哪边倒,这就是标准答案。
risk 默认的映射极简(policy.ts:313 riskDefaultAction):read → always_run,其余 → require_approval。而项目层的默认模式,在没配置时是 allow_all(gateway.ts:481-488、db-deps.ts:812)——即向后兼容优先,风险模式是主动开启的。
3.4 谁能用:两道正交的门 + 一次凭据解析
「能不能调这个动作」在今天由两套机制回答,任何一个说不就停:
| 问题 | 机制 | 判定函数 |
|---|---|---|
| 这个agent被授权用这个连接器吗? | manifest 的 agents[].connectors | iam/agent-scope.ts:101 agentMayUseConnector |
| 这个动作本身被允许吗? | 分层策略(3.3) | policy.ts:356 resolveEffectiveAction |
两道老门已退役,值得点名。 ① 连接器曾经走
share_scope+ 成员/组白名单那套共享机制——已移除:连接器现在永远项目内可见,唯一的访问门就是 agent 侧 grant(share.ts:14-18的头注释直说「CONNECTORS no longer use this either」;那套纯函数今天只服务 session 可见性)。② 凭据模式的per_user(每个成员连自己的账号)——2026-07-05 移除,所有连接器都解析userId = NULL的项目共享凭据(credentials.ts:4-11)。
凭据解析因此只剩一种形态。 connectorUsable(gateway.ts:341-356)就三行逻辑:
// gateway.ts:350-355 connectorUsable —— 真实源码节选
if (!connector.hasAuth) return { ok: true, secret: null };
if (credentialOverride != null) return { ok: true, secret: credentialOverride };
const secret = await deps.resolveCredential(connector, null);
if (secret == null) return { ok: false, reason: 'needs_auth' };
return { ok: true, secret };
hasAuth = false 的连接器(如 tunnel)直接放行;其余一律取项目共享那把钥匙。Pipedream 的外部身份也随之固定成连接器级的 projectId:slug(pipedream.ts:42-52 注释);唯一的分岔在会话选了非默认 connection 时,按 connectionId 另立一个稳定身份(gateway.ts:722-724)。
agent 授权这道门: agents[] 里声明 connectors: ["github"],会话令牌带着这份 grant。网关面在进入 handleCall 之前就先拦一道:
// router.ts:639-640 —— 默认拒绝
if (!agentMayUseConnector(p.agentGrant ?? null, canonicalConnectorAlias(connectorSlug))) {
return c.json({ ok: false, status: 'denied', reason: 'connector_not_assigned' }, 403);
}
关键是它和角色检查相乘而非相加:声明的 grant 不做「∩ 启动人角色」的计算,因为路由层本来就在校验那个人的角色,净效果天然是 userRole ∩ agentGrant——agent 永远不可能超过启动它的人(projects/agents.ts:321-324 注释;GRANTABLE_KORTIX_CLI 在 agents.ts:80 明确把可授予范围圈在项目级动作内)。
同样的判定也用在列目录上,不只用在调用上:listCatalog 会跳过 agent 无权使用的连接器(db-deps.ts:1039),并把 block 的动作从目录里过滤掉(db-deps.ts:1059-1068)。看不见的东西不会被 agent 反复尝试——这既是安全,也是省 token。
4. 深入实现
4.1 handleCall 全路径
一次调用在网关里的顺序是固定的(gateway.ts:426 handleCall),每一步失败都走同一个审计出口:
| 步 | 做什么 | 失败时 | 行 |
|---|---|---|---|
| 1 | 解析连接器(含 Slack/邮件的保留 slug 兜底) | denied: connector_not_found | :427、:293 |
| 2 | 查动作 | denied: action_not_found | :438 |
| 3 | 邮件会话上下文注入(把 inbox/thread/message 钉死) | 无 | :446、:357 |
| 4 | 解凭据(hasAuth=false 直通) | denied: needs_auth | :450、:341 |
| 5 | 算请求指纹 + 参数预览(审批与审计用) | 无 | :467、:489 |
| 6 | 分层策略(含 sensitive 档、审批结转) | denied: policy_block / pending_approval | :479-530 |
| 7 | 执行(四条分支:computer / voice / pipedream / 其余) | error + 上游原因 | :633、:686、:715、:770 |
| 8 | 审计 | 静默吞掉 | :886 |
第 3 步是一类值得单独说的设计:有些参数不能让 agent 自己填。 邮件会话里,inbox_id、thread_id、message_id 由服务端从连接元数据/会话上下文取出后覆盖进 args(gateway.ts:357-424),agent 无法回复到别的线程去。同类设计还有 voice 频道:spawn_room 这类动作不在沙箱里拼任何东西,由服务端的 executeVoiceCall 分支直接建房间(gateway.ts:686-711)。(旧版这里的「Meet 直播回调注入」——服务端拼 webhook + HMAC——已随 Recall.ai 那套移除,实时通话改走 voice 频道。)
审计这一步被整个包在 try/catch 里,注释只有一句:auditing must never break the call path(gateway.ts:910)。取舍很清楚:宁可丢一条日志,也不能因为日志写不进去而让一次已经成功的业务调用失败。
4.2 executeCall:五种执行,三种例外
executeCall(call.ts:962)按 binding.kind 分派:
| kind | 怎么执行 | 构造函数 |
|---|---|---|
openapi | 路径模板替换 + 参数按 x-in 提示分流 + applyAuth | call.ts:387 buildHttpRequest |
http | 同上,base_url 来自连接器配置 | 同上 |
postman | Postman 收藏里抽出 来的请求模板 | call.ts:493 buildPostmanRequest |
mcp | 包成 JSON-RPC tools/call POST | call.ts:846 performMcpExchange(分派点 :1021-1033) |
graphql | 内联参数拼成查询串,__select 提供选择集 | call.ts:558 buildGraphqlRequest |
参数往哪放,靠归一化时埋下的提示。 OpenAPI 归一化会给每个属性打上 x-in: path|query|header,执行时读出来(call.ts:358 paramHintsFromSchema);没有提示的参数,按方法能不能带 body 决定进 body 还是进 query(call.ts:372 methodAllowsBody,用法在 :427)。
响应解析容忍 SSE。 MCP 的 streamable-HTTP 会把 JSON 包在 data: 行里,所以解析器先试 JSON,失败了就从后往前扫 data: 行(call.ts:718 parseResponseBody)。
三种例外不走这里:
tunnel(连接的机器)由网关转交给隧道 RPC 核心(gateway.ts:633-682;核心在apps/api/src/tunnel/core/rpc-core.ts,隧道自身见 03 章 §9.3)。它有个独特的返回态permission_required,被映射成pending_approval,原因串里带上请求 id 让人去 Computers 页面批准。这类连接器没有凭据——活着的 WebSocket 本身就是凭据(computers.ts:1-12头注释;materialize.ts:62-70显式写入auth: none,于是hasAuth=false)。voice(实时通话)由服务端的executeVoiceCall建房间,不是出站 HTTP(gateway.ts:686-711)。pipedream/pipedream_proxy交给 Connect API(gateway.ts:715-745)。- 其余任何 kind,
executeCall直接抛「尚未实现」(call.ts:1048)。
一处代码与注释的出入(核对于本 commit):
call.ts:1-9的文件头注释说 GraphQL「只完成了归一化,执行是后续工作」,但executeCall在:850-862确实分派到了buildGraphqlRequest。以代码为准:GraphQL 可执行,注释是陈旧的。
4.3 双面 HTTP:同一套逻辑,两种身份
router.ts 一个文件挑两副担子(createConnectorRouter,router.ts:584;挂载在 /v1/connectors,apps/api/src/index.ts:914):
| 面 | 路由 | 认谁 | 用途 |
|---|---|---|---|
| 沙箱面 | GET /v1/connectors/connectorsPOST /v1/connectors/call | KORTIX_CLI_TOKEN(会话令牌,db-deps.ts:915 resolvePrincipal) | agent 干活 |
| 沙箱面(带项目) | /projects/{id}/catalog、/projects/{id}/call | 任意有效身份(会话令牌或登录用户令牌,db-deps.ts:950 resolveProjectPrincipal) | 让 kortix connectors 在笔记本上也能用 |
| 仪表盘面 | /projects/{id}/connectors 及其 sync、credential、secret-binding、policies、sensitive… | 用户身份 + 项目访问权(router.ts:240 resolveAdmin) | 人来管连接器 |
两个沙箱面共用同一份实现——catalogResponse 和 callResponse 两个闭包(router.ts:590、:594),路由只负责解析身份。「本地和云上是同一个网关、同一套授权」这句话,靠的就是这个共用。
仪表盘面的写操作要的是 project.connector.write,而不是粗粒度的 project.write(iam/actions.ts:121;db-deps.ts:1112-1118 注释)。这样一个自定义角色可以只给人「用连接器」不给「管连接器」,而 agent 会话令牌想管连接器也必须真的持有这个动作。
一个被注释解释得很好的怪味道:执行失败返回 500 而不是 502。
// router.ts:937-941 —— 路由 schema 上的注释
// 500, NOT 502: Cloudflare replaces origin 502/504 bodies with its own
// branded error page, which destroys the JSON `reason` before the
// sandbox SDK can read it — the agent then sees a bare "HTTP 502" and
// can't self-correct.
这是「让 agent 能自我修正」这条目标反向约束了 HTTP 状态码的选择。同一条思路也体现在 upstreamReason(gateway.ts:859)上:绝不给 agent 一个光秃秃的状态码,上游的字符串错误原样透传,结构化 body 截取成 JSON 摘要。
状态到 HTTP 的映射(router.ts:658-686):ok→200、pending_approval→202(带 approval_url / approval_summary)、connector_not_found/action_not_found→404、其余 denied→403、error→500。
4.4 沙箱那头怎么拿到这些工具
有两条路,默认走的是第一条:
kortix connectorsCLI(默认) —— 沙箱镜像里就有,读KORTIX_CLI_TOKEN+KORTIX_API_URL,内部用@kortix/sdk打网关(薄客户端内核在apps/cli/src/connector-gateway/gateway.ts:39connectorClient)。- MCP 服务器(可选,默认关闭) ——
kortix connectors mcp把自己暴露成 OpenCode 的一个本地 MCP 服务器。
第二条要显式开:KORTIX_CONNECTORS_MCP_ENABLED 为真时,daemon 才把它写进 OpenCode 的内联配置(apps/kortix-sandbox-agent-server/src/opencode.ts:211-213、:291-310)。测试把这个默认锁死了:
// apps/kortix-sandbox-agent-server/src/__tests__/connector-mcp-config.test.ts:74
test('does not register connector MCP by default; CLI is the primary Connector path', async () => {
expect(await buildOpencodeConfigContent(ENV)).toBeUndefined()
})
MCP 那条路的设计精髓是「元工具」而不是「摊平目录」。 常见做法是把每个连接器动作都注册成一个 MCP tool——目录一上百个动作,tools/list 就把上下文淹了。Suna 只暴露一小撮固定的元工具(apps/cli/src/connector-gateway/mcp.ts:225 META_TOOLS):
| 元工具 | 干什么 |
|---|---|
connectors | 列出本会话能用的连接器 |
discover | 按自然语言意图搜工具 |
describe | 看某个工具的完整输入 schema |
call | 调用它 |
connect / request_secret | 生成一个人去点的授权/填密钥链接 |
工具面不随连接器数量增长,agent 靠渐进式发现按需下钻。改 manifest 的能力(add_connector / remove_connector)没进 MCP 面,只留在 CLI(kortix connectors add/rm)。顺带一提,connect / request_secret 这两个元工具体现了另一个原则:agent 发现自己缺凭据时,能做的不是索要密钥,而是生成一条链接让人去填。
4.5 可注入依赖:整条决策路径都能被单测
这是本章工程上最值得抄的一点。网关不 import 数据库,它 import 的是一个接口 GatewayDeps(gateway.ts:110);路由不 import 网关的实现,它 import 的是 ConnectorRouterDeps(router.ts:217)。生产环境在 db-deps.ts 里把它们接到真 DB 和真 fetch 上(db-deps.ts:502 makeDbGatewayDeps、:1686 dbConnectorRouterDeps),测试里换成内存假件。
于是测试可以这么写——注意 fetchImpl 也是依赖,连「第三方」都是假的:
// __tests__/unit-connector-gateway.test.ts:57 makeDeps —— 真实测试代码节选
const deps: GatewayDeps = {
loadConnectorBySlug: async () => STRIPE,
loadAction: async () => CREATE_CHARGE,
resolveCredential: async (connector, userId) => { credentialCalls.push({ connectorId: connector.connectorId, userId }); … },
loadPolicies: async () => o.policies ?? [],
recordExecution: async (r) => { records.push(r); return null; },
fetchImpl: async (url, init) => { fetchCalls.push({ url, ...init }); … },
};
这带来两个直接的能力:
- 能断言「密钥去哪了」。
fetchCalls里存着完整的出站 header,测试可以直接检查Authorization是不是那个 secret——安全属性变成了一条可执行断言,而不是一句口头承诺。 - 能断言「凭据从哪来」。
credentialCalls记录了每次resolveCredential的(connectorId, userId),于是「总是取项目共享那份(userId: null)、邮件类的 secretOverride 直通」这些行为都是被测出来的。
再上一层,e2e-connector-faces.test.ts 直接起一个真 Hono server,把 SDK、CLI、MCP 三副面孔全跑一遍,底下仍是内存假件(__tests__/e2e-connector-faces.test.ts:1-5)。
5. 巧妙之处(可以直接借鉴的)
- 凭据的「最后一米」原则。 密钥不是「传给执行层」,而是在构造请求的最后一步才被贴上(
call.ts:55applyAuth;签名类鉴权在buildHttpRequest里用同一份 secret 算签名)。整个系统里明文密钥的生命周期短到只有一次出站请求的构造过程。 - 安全默认值往「不生效」倒,不往「全放行」倒。 正则编译失败 → 永不匹配(
policy.ts:105);manifest 读不出来 → 不删任何连接器,因为「读不出来」可能只是 git 抖了一下(sync.ts:438-442、:472-478)。 - 判决连同理由一起返回。
resolveEffectiveAction返回source,直接进审计(policy.ts:356、gateway.ts:541)。事后能回答「为什么当时被拦了」。 - 空白名单折叠成全项目。 一条规则消灭了一整类哑火状态(
share.ts:76-77;连接器已不再用这套共享——它今天服务 session 可见性,见 3.4)。 - 目录即权限视图。 agent 看不到没被授权的连接器、看不到被 block 的动作(
db-deps.ts:1039、:1055-1064),不给它「反复试错」的机会。 - 错误信息是写给 agent 看的。 上游原因原样透传(
gateway.ts:859)、pipedream 组件炸了就指向request后备(gateway.ts:881)、状态码避开 CDN 会吞 body 的 502(router.ts:683-685)。这是把「agent 是一等读者」当真了。 - 保留 slug 防影子。 平台自带的 Slack 连接器叫
kortix_slack而非slack,并在网关入口对固定动作名做定向解析(channels.ts:28-37、gateway.ts:297-330),防止用户自建的slack连接器把内建读操作顶掉。 - 依赖注入让「安全属性」变成「可执行断言」(见 4.5)。
6. 边界与局限
require_approval不再只是「返回 202 + 记一条审计」。 现在它会铸一条审批链接、附脱敏后的参数摘要(approval_url/approval_summary),人点完之后经会话回调恢复这次调用(gateway.ts:494-515、:620-625)。反方向的一项收紧也值得知道:session 级的「本次会话都放行」授权被明确废弃——审批必须看到每一次调用的具体参数,历史 grant 行只留在账本上供审计,任何运行时代码都不许拿它当授权依据(gateway.ts:552-568的注释)。- 策略检查排在凭据解析之后。
connectorUsable(含解密)在gateway.ts:450执行,策略在:480起。一个注定被block的调用,仍会触发一次凭据解密。功能无碍,但不是最省的顺序。 - 审计里的连接器名用的是调用方给的原始 slug,不是解析后的。
audit拼的是input.connectorSlug(gateway.ts:901),而策略匹配用的是解析后的resolved.slug(gateway.ts:428)。对被兜底重定向的 Slack/邮件调用,这两处会不一致。 - GraphQL 执行把参数内联进查询串(
call.ts:558-659),没走 variables。字符串走JSON.stringify转义,但这条路比参数化查询脆。 enforcePolicies可以整体关掉(gateway.ts:480)。生产环境硬编码为true(db-deps.ts:775),这是留给旧行为的兼容开关,不是配置项。- 目录同步是尽力而为。 拉不到 catalog 的连接器以
status='error'+ 0 个动作入库,不会让整轮 sync 失败(sync.ts:123-130,头注释:17)。好处是一个坏连接器不拖垮全项目,代价是「工具静默消失」需要看状态才发现。 - 凭据模式切换的接口成了「只剥遗留键」的 no-op。
per_user移除之后,setConnectorCredentialModeInManifest的全部职责是把老 manifest 里残留的credential: per_user键剥掉(manifest-crud.ts:375-386);写shared以外的值在路由层就会被拒。
7. 代码地图(导航索引)
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 统一动作表示 / binding 判别联合 | apps/api/src/connectors/types.ts | NormalizedAction、ActionBinding、Risk |
| 凭据贴到请求上(闸门本体) | apps/api/src/connectors/call.ts | applyAuth、ConnectorAuth |
| 出站请求构造与分派 | apps/api/src/connectors/call.ts | executeCall、buildHttpRequest、buildPostmanRequest、buildMcpRequest、buildGraphqlRequest、paramHintsFromSchema、parseResponseBody |
| 唯一咽喉:全路径编排 | apps/api/src/connectors/gateway.ts | handleCall、connectorUsable、resolveConnectorForCall、audit |
| 可注入依赖契约 | apps/api/src/connectors/gateway.ts | GatewayDeps、GatewayConnector、GatewayAction、CallResult |
| 给 agent 的错误信息 | apps/api/src/connectors/gateway.ts | upstreamReason、fallbackHint、mapChannelEnvelope |
| 分层策略引擎 | apps/api/src/connectors/policy.ts | resolveEffectiveAction、firstMatchOrNull、riskDefaultAction、compileMatcher、isValidMatcher |
| 成员/组可见性纯函数(session 用) | apps/api/src/connectors/share.ts | isSecretUsableBy、intentToScope、parseSharingIntent、resolveShareSubject |
| 归一化多种源 | apps/api/src/connectors/normalize.ts | normalize、normalizeOpenApi、normalizeMcp、normalizePipedream、riskForMethod、riskForMcp、pipedreamProxyAction |
| manifest → DB 运行时视图 | apps/api/src/connectors/sync.ts、materialize.ts | syncProjectConnectors、resolveCatalog、connectorConfig、toPolicyRows |
| 连接器凭据存取(加密) | apps/api/src/connectors/credentials.ts | resolveCredentialValue、upsertCredential、deleteCredential |
| 项目密钥加密 + 不注入沙箱 | apps/api/src/projects/secrets.ts | encryptProjectSecret、listProjectSecrets(scope === 'connector' 跳过) |
| 沙箱环境快照 | apps/api/src/projects/lib/sandbox-env-sync.ts | resolveSandboxEnvSnapshot |
| 双面 HTTP | apps/api/src/connectors/router.ts | createConnectorRouter、ConnectorRouterDeps、ConnectorPrincipal |
| 生产接线(真 DB / 真 fetch) | apps/api/src/connectors/db-deps.ts | makeDbGatewayDeps、listCatalog、resolvePrincipal、resolveProjectPrincipal、dbConnectorRouterDeps |
| agent 授权 | apps/api/src/iam/agent-scope.ts、projects/agents.ts | agentMayUseConnector、agentMayPerform、resolveAgentGrant、GRANTABLE_KORTIX_CLI |
| 连接器管理动作 | apps/api/src/iam/actions.ts | PROJECT_ACTIONS.PROJECT_CONNECTOR_WRITE、VALID_ACTIONS |
| Slack / 邮件 / 语音作为连接器 | apps/api/src/connectors/channels.ts | channelCatalog、channelAuth、SLACK_CHANNEL_CONNECTOR_SLUG、EMAIL_CHANNEL_CONNECTOR_SLUG |
| 连接的机器作为连接器 | apps/api/src/connectors/computers.ts | computerCatalog、COMPUTER_SLUG |
tunnel binding 的执行地(隧道本体见 03 章) | apps/api/src/tunnel/core/rpc-core.ts | executeTunnelRpc、resolveCapability |
| Pipedream 1-click | apps/api/src/connectors/pipedream.ts | runPipedreamAction、runPipedreamProxy、externalUserId、pipedreamConnectUrl |
| manifest 往返 CRUD | apps/api/src/connectors/manifest-crud.ts | setConnectorCredentialShared、setConnectorCredentialModeInManifest |
| 沙箱侧:MCP 注册(默认关) | apps/kortix-sandbox-agent-server/src/opencode.ts | buildOpencodeConfigContent |
| 沙箱侧:元工具面 | apps/cli/src/connector-gateway/mcp.ts、connector-gateway/gateway.ts | META_TOOLS、connectorClient |
| 测试:决策+执行全路径 | apps/api/src/__tests__/unit-connector-gateway.test.ts | makeDeps |
| 测试:三副面孔端到端 | apps/api/src/__tests__/e2e-connector-faces.test.ts | — |
继续读: 连接器怎么在 kortix.yaml 里声明与校验 → 01 章;会话令牌怎么来的 → 02 章;沙箱里 OpenCode 怎么起来 → 03 章;改 manifest 为什么要过 change request → 04 章;模型这条路 → 06 章。