跳到主要内容

数据截至 (上游 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 调用
inputSchemaJSON Schema,参数长什么样agent 组参数;网关推参数位置
riskread / write / destructive策略的兜底默认
binding真正怎么发这个请求executeCall 分派

binding 是个判别联合(types.ts:31-59),九种形态:openapipostmangraphqlmcphttptunnel(连接的机器)、voice(实时通话)、pipedreampipedream_proxy

精髓在 risk 怎么来的:不猜,从源自己的语义里读。

risk 派生规则位置
OpenAPI / HTTPGET/HEAD/OPTIONS→read,DELETE→destructive,其余→writenormalize.ts:22 riskForMethod
MCPdestructiveHint→destructive,readOnlyHint→read,否则 writenormalize.ts:310 riskForMcp
GraphQLquery→read,mutation→writenormalize.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:96 resolveCredentialValue)。
  • 项目密钥 project_secrets,用 HKDF 从 API_KEY_SECRETprojectId 派生密钥、AES-256-GCM 信封加密(projects/secrets.ts:59 projectSecretKey:74 encryptProjectSecret)。

闸门的另一半在这里:项目密钥里 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:445resolveSandboxEnvSnapshot 走的正是这个过滤后的视图,再叠一层名字消毒。

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:609policy_source)。这是可解释性,不是装饰。

两个细节值得抄:

  1. 匹配器可以是 glob,也可以是正则。 用斜杠包起来就是正则:/^charges\.(create|update)$/i(policy.ts:93 isRegexMatcher)。glob 会被锚定成 ^…$、大小写不敏感(policy.ts:81 globToRegex)。
  2. 写错的正则退化成「永不匹配」,而不是「匹配一切」。
// 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-488db-deps.ts:812)——即向后兼容优先,风险模式是主动开启的。

3.4 谁能用:两道正交的门 + 一次凭据解析

「能不能调这个动作」在今天由两套机制回答,任何一个说不就停:

问题机制判定函数
这个agent被授权用这个连接器吗?manifest 的 agents[].connectorsiam/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_CLIagents.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_idthread_idmessage_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 提示分流 + applyAuthcall.ts:387 buildHttpRequest
http同上,base_url 来自连接器配置同上
postmanPostman 收藏里抽出来的请求模板call.ts:493 buildPostmanRequest
mcp包成 JSON-RPC tools/call POSTcall.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/connectors
POST /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 及其 synccredentialsecret-bindingpoliciessensitive用户身份 + 项目访问权(router.ts:240 resolveAdmin)人来管连接器

两个沙箱面共用同一份实现——catalogResponsecallResponse 两个闭包(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_approval202(带 approval_url / approval_summary)、connector_not_found/action_not_found→404、其余 denied→403、error→500。

4.4 沙箱那头怎么拿到这些工具

有两条路,默认走的是第一条:

  1. kortix connectors CLI(默认) —— 沙箱镜像里就有,读 KORTIX_CLI_TOKEN + KORTIX_API_URL,内部用 @kortix/sdk 打网关(薄客户端内核在 apps/cli/src/connector-gateway/gateway.ts:39 connectorClient)。
  2. 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:55 applyAuth;签名类鉴权在 buildHttpRequest 里用同一份 secret 算签名)。整个系统里明文密钥的生命周期短到只有一次出站请求的构造过程。
  • 安全默认值往「不生效」倒,不往「全放行」倒。 正则编译失败 → 永不匹配(policy.ts:105);manifest 读不出来 → 不删任何连接器,因为「读不出来」可能只是 git 抖了一下(sync.ts:438-442:472-478)。
  • 判决连同理由一起返回。 resolveEffectiveAction 返回 source,直接进审计(policy.ts:356gateway.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-37gateway.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.tsNormalizedActionActionBindingRisk
凭据贴到请求上(闸门本体)apps/api/src/connectors/call.tsapplyAuthConnectorAuth
出站请求构造与分派apps/api/src/connectors/call.tsexecuteCallbuildHttpRequestbuildPostmanRequestbuildMcpRequestbuildGraphqlRequestparamHintsFromSchemaparseResponseBody
唯一咽喉:全路径编排apps/api/src/connectors/gateway.tshandleCallconnectorUsableresolveConnectorForCallaudit
可注入依赖契约apps/api/src/connectors/gateway.tsGatewayDepsGatewayConnectorGatewayActionCallResult
给 agent 的错误信息apps/api/src/connectors/gateway.tsupstreamReasonfallbackHintmapChannelEnvelope
分层策略引擎apps/api/src/connectors/policy.tsresolveEffectiveActionfirstMatchOrNullriskDefaultActioncompileMatcherisValidMatcher
成员/组可见性纯函数(session 用)apps/api/src/connectors/share.tsisSecretUsableByintentToScopeparseSharingIntentresolveShareSubject
归一化多种源apps/api/src/connectors/normalize.tsnormalizenormalizeOpenApinormalizeMcpnormalizePipedreamriskForMethodriskForMcppipedreamProxyAction
manifest → DB 运行时视图apps/api/src/connectors/sync.tsmaterialize.tssyncProjectConnectorsresolveCatalogconnectorConfigtoPolicyRows
连接器凭据存取(加密)apps/api/src/connectors/credentials.tsresolveCredentialValueupsertCredentialdeleteCredential
项目密钥加密 + 不注入沙箱apps/api/src/projects/secrets.tsencryptProjectSecretlistProjectSecrets(scope === 'connector' 跳过)
沙箱环境快照apps/api/src/projects/lib/sandbox-env-sync.tsresolveSandboxEnvSnapshot
双面 HTTPapps/api/src/connectors/router.tscreateConnectorRouterConnectorRouterDepsConnectorPrincipal
生产接线(真 DB / 真 fetch)apps/api/src/connectors/db-deps.tsmakeDbGatewayDepslistCatalogresolvePrincipalresolveProjectPrincipaldbConnectorRouterDeps
agent 授权apps/api/src/iam/agent-scope.tsprojects/agents.tsagentMayUseConnectoragentMayPerformresolveAgentGrantGRANTABLE_KORTIX_CLI
连接器管理动作apps/api/src/iam/actions.tsPROJECT_ACTIONS.PROJECT_CONNECTOR_WRITEVALID_ACTIONS
Slack / 邮件 / 语音作为连接器apps/api/src/connectors/channels.tschannelCatalogchannelAuthSLACK_CHANNEL_CONNECTOR_SLUGEMAIL_CHANNEL_CONNECTOR_SLUG
连接的机器作为连接器apps/api/src/connectors/computers.tscomputerCatalogCOMPUTER_SLUG
tunnel binding 的执行地(隧道本体见 03 章)apps/api/src/tunnel/core/rpc-core.tsexecuteTunnelRpcresolveCapability
Pipedream 1-clickapps/api/src/connectors/pipedream.tsrunPipedreamActionrunPipedreamProxyexternalUserIdpipedreamConnectUrl
manifest 往返 CRUDapps/api/src/connectors/manifest-crud.tssetConnectorCredentialSharedsetConnectorCredentialModeInManifest
沙箱侧:MCP 注册(默认关)apps/kortix-sandbox-agent-server/src/opencode.tsbuildOpencodeConfigContent
沙箱侧:元工具面apps/cli/src/connector-gateway/mcp.tsconnector-gateway/gateway.tsMETA_TOOLSconnectorClient
测试:决策+执行全路径apps/api/src/__tests__/unit-connector-gateway.test.tsmakeDeps
测试:三副面孔端到端apps/api/src/__tests__/e2e-connector-faces.test.ts

继续读: 连接器怎么在 kortix.yaml 里声明与校验 → 01 章;会话令牌怎么来的 → 02 章;沙箱里 OpenCode 怎么起来 → 03 章;改 manifest 为什么要过 change request → 04 章;模型这条路 → 06 章