一条投递,一个根会话
外部投递经认证后变成普通根会话:不从对话开始的会话。webhook 规则注册表接收已认证投递,规则把它翻译成 Session 请求;一条事务串起工作区、Agent、权限、标题与首条消息——投递被接纳后,所有权就交还普通会话生命周期。
Written in Chinese
第 0 秒,GitHub 把一个 push 事件 POST 到 webhook 路由。处理器先看方法是不是 POST、Content-Type 是不是 JSON、body 有没有超限,然后验 x-hub-signature-256——签名不对,401;对上了,解析 payload,组成一条带 deliveryId 的投递,交给 webhook 运行时。运行时按 kind 找到匹配的规则,规则把投递翻译成一个 Session 请求。事务开始:一个 webhook- 打头的根会话建起来,Agent 起来,权限预设挂上,标题改好,投递内容作为第一条 user 消息送进去。HTTP 那边 202 早已返回。这个会话没有对话框、没有用户打字——它是从一条外部投递开始的根会话。
第 43 章、第 46 章、第 48 章讲的是同一运行时的不同面孔。这一章讲不从对话开始的会话:入口不是用户消息,是外部投递;认证不是登录,是签名验证;建会话不是一次交互,是一条事务。
认证在 provider 层:签名对了才算投递
webhook 运行时本身不认证——认证是各 provider 适配器的事,运行时只认”已经验证过的投递”(VerifiedWebhookDelivery)。为什么这样切?因为信任的边界要放在认识发送方的那一侧:运行时只懂”kind/source/deliveryId/event”这种中立形状,不懂 GitHub 的签名头、Slack 的 challenge、或者将来的什么 provider;每个 provider 的验签方式不同,但验完之后的产物形状相同。适配器把外部字节变成可信投递,运行时只处理可信投递——认证逻辑换 provider 时只在适配器里改,注册表、事务、回滚一条不用动。
GitHub 适配器(webhook-github)把 HTTP 入口的每道闸都写成了明确的拒绝,全部错误码不泄漏请求细节(WebhookHttpError 的 message 都是模板文字,Octokit 验证错误被吞掉):
| 闸门 | 拒绝 |
|---|---|
| 方法不是 POST | 405,带 allow: POST |
Content-Type 不是 application/json(至多一个 UTF-8 charset 参数) | 415 |
Content-Length 非法或 body 超 maxBodyBytes | 400 / 413(超了先把流排干再回,可读响应而非断 socket) |
| 流在 EOF 前断开 | 400 |
body 不是合法 UTF-8(TextDecoder('utf-8', { fatal: true })) | 400 |
x-hub-signature-256/x-github-delivery/x-github-event 缺失或非单值 | 400 |
| Octokit 验签失败 | 401 “invalid webhook signature” |
| secret 从凭据服务解析不到或为空 | 503(配置缺失,不是签名问题) |
| payload 不是无损 JSON 对象 | 400 |
| dispatch 不可用(运行时在关闭) | 503 |
body 的读取是精确的:readBoundedUtf8Body 校验 Content-Length 是纯十进制、Number.isSafeInteger,流式收字节、超限即拒,最后才解码——非法 UTF-8 也是 400。payload 必须是无损 JSON 对象(snapshotJsonValue 失败 400)。全部通过,才组成 VerifiedWebhookDelivery:kind: 'github'、source(部署配置的源标识)、deliveryId(x-github-delivery 原样)、event: { name, payload }、receivedAt(Unix 毫秒)。
适配器插件本身有两层校验:Schemastery 的 Config(secretEnv 带 credential-ref 角色),加上 assertConfig 处理 Schema 表达不了的——source 必须非空且不带头尾空格,path 必须是绝对非根路径名(以 / 开头、不是 /、不以 / 结尾、无 ? 无 #)。路由是 kind: 'exact' 的精确路由:只认这一个路径,不吞其它路径的请求。
本节源码依据(8 处)
规则注册表:fire-and-forget,快照后分发
WebhookRuntime 是投递的接收方,也是”投递 → 会话请求”的翻译层。规则(WebhookRule)是可信的程序化注册:id 唯一、kind 是 provider 标签、run(delivery, signal) 返回一个 Session 请求或 null(null 就是不建会话)。注册有生命周期:注册在 Cordis effect 里,卸载时先 closing、再 abort 所有在飞调用、最后 drain 干净(disposeRegistration 的 hide → abort → drain)。
dispatch() 是 fire-and-forget 的,两个决定都写在代码里:
- 先快照,再分发。
snapshotDelivery逐字段校验(kind/source/deliveryId 非空字符串、receivedAt非负安全整数),snapshotJsonValue确认无损 JSON,最后deepFreeze——“validate and detach one delivery before sharing it across arbitrary rules”。为什么必须冻结?一条投递要分发给任意规则,而规则是部署侧的可信代码:不冻结,规则 A 改了投递里的一个字段,规则 B 看到的就不是发送方发的那份;冻结之后,所有规则拿到的是同一份不可变事实。 - 不等回调结算。
dispatch遍历注册表、按 kind 匹配、起调用、返回,HTTP 侧在dispatch返回后立刻回 202。为什么不等?因为 202 的语义是”我收到了,正在处理”,不是”处理完了”——webhook 的发送方(GitHub)重试策略基于这个语义;等规则跑完,慢规则会拖住 HTTP 响应,还可能让发送方误以为投递被拒。规则失败只记 warn(带 provider/source/deliveryId/ruleId 的可定位上下文),规则卸载导致的中止记 debug。
每条调用被 track 进规则的 active 集,规则卸载时 abort + drain;运行时自身卸载时,先 closing = true,再对每条注册规则 dispose(“caller-owned registration effects normally dispose first; this covers provider-first unload”——正常情况下规则自己的 effect 先卸,运行时卸载兜底 provider 先退的场景)。
本节源码依据(6 处)
一条事务:投递变成根会话
规则返回的 WebhookSessionRequest 有六个字段,resolveRequest 在同进程内先把它快照并校验——“snapshot and validate a same-process rule result before crossing awaits”,跨 await 之前先定型:
| 字段 | 约束 | 缺省 |
|---|---|---|
workspacePath | 必须绝对路径(isAbsolute) | 无 |
title | 非空字符串 | 无 |
prompt | 非空字符串(首条 user 消息的文本) | 无 |
agentPreset | 非空字符串(Agent 组合的 preset id) | 无 |
permissionPreset | 非空字符串(沙箱与审批 preset id) | 无 |
model | 可选对象:provider/model 非空、maxTokens 正安全整数 | 取 agentDefaultModel.currentSelection()(含 reasoning effort 的完整当前默认) |
createWebhookSession 把建会话写成一条事务,每一步都可在中途失败,失败只回滚已做的:
permissionPresets.resolve(权限预设存在性);agentPresets.resolve+acquireScope(preset 作用域,using语义——事务结束或失败自动释放);workspaceRegistry.create(workspacePath)(工作区注册);agents.create:sessionId 是webhook-${randomUUID()}(命名约定,不是特殊类型),meta 带cwd与agentPreset,setup里 mount preset 并装一个初始模型选择(installInitialModelSelection:在第一个持久 request header 出现之前,把 Agent 的模型选择固定为投递指定的值);workspace.attachSession(sessionId)(会话挂进工作区,attached = true从这里开始);permissionPresets.set+sessionTitle.rename;followup(createUserMessage(...)):首条消息的source记全痕迹——kind: 'webhook'、provider、source、deliveryId、ruleId、form: 'notice'、summary: "${delivery.kind} webhook handled by ${ruleId}"。
第 5 步之前失败,什么都在作用域和 try/catch 里自动释放;第 5 步之后失败,回滚只覆盖已做的:workspace.detachSession + handle.dispose(),回滚本身失败只记 warn——“Log a rollback failure without replacing the operation’s original failure”,原失败永远是主失败。
有两个顺序值得停下来问为什么。为什么 attachSession 之后才 permissionPresets.set 和 rename? 因为权限预设和标题都是”会话已经成立”之后才有的属性:attach 之前失败,会话不存在,谈权限和标题没有意义;attach 之后这些操作失败,会话已经挂在工作区里,所以要回滚 detach——顺序本身定义了”会话成立的时刻”。为什么 form 固定是 notice? MessageSourceMap 把 webhook 声明的 form 钉死在 'notice':投递不是用户提问(那是 user 表单),也不是系统注入,它是”作为通知进会话”的程序化输入——固定表单让消费侧(渲染、审计、重放)不用猜。
关键的所有权转移写在第 7 步的注释里:“Successful prompt admission ends webhook ownership of the operation; the Agent remains lifecycle-owned by ctx and follows normal Session behavior.”——投递被接纳之后,webhook 对这次操作的所有权就结束了。这个 webhook- 会话接下来和任何根会话没有区别:Agent 归 ctx 生命周期管,走普通的会话行为、审批、审计。这也是占位文件那句话的完整含义:“外部投递经认证后变成普通根会话:不从对话开始的会话”。
deliveryId 还有一个边界值得单独记:types.ts 里写明它 “never as built-in deduplication state”——投递标识只进消息 source 作痕迹,运行时不做内置去重。GitHub 的投递可能重试(同一次事件多次 POST),每次都是一条新的投递、可能建一个新会话;要不要挡重复,是规则自己的判断(规则可以在 run 里查 deliveryId 决定返回 null)。为什么运行时不做?因为”重试一次该不该再建会话”是业务语义,只有部署侧知道——运行时做了去重,就等于替部署侧做了这个决定。
本节源码依据(9 处)
谁把一个会话从投递里生出来
把这一章的三个角色钉死:provider 适配器负责认证,把字节变成可信投递(GitHub 适配器的拒绝序列就是认证的证明);规则注册表负责翻译,把投递变成会话请求,fire-and-forget,失败只记日志;会话事务负责落地,把请求变成根会话——校验、作用域、工作区、Agent、挂接、命名、首条消息,一步一闸,失败回滚只回滚已做的。
“不从对话开始的会话”不是一种特殊会话。它和任何根会话用同一个 Agent 生命周期、同一套审批、同一份审计。区别只在开头:不是用户打了一行字,而是一条签名验证过的投递,经过一条事务,变成一张新面孔下的普通会话。第七部的几章到这里收尾——运行时不变,面孔在变。
下一章看会话里哪些名字必须擦掉:投递留下的痕迹(kind、source、deliveryId)要在日志里留得住,密钥和令牌却连日志边都挨不上——第 50 章讲这张擦除的边界。