深入浅出 DeepSeek Harness 第七部:产品面——同一运行时的不同面孔 第 49 章

一条投递,一个根会话

外部投递经认证后变成普通根会话:不从对话开始的会话。webhook 规则注册表接收已认证投递,规则把它翻译成 Session 请求;一条事务串起工作区、Agent、权限、标题与首条消息——投递被接纳后,所有权就交还普通会话生命周期。

本章以中文撰写

源码版本
dsh-v0.1.7-rc.2
验证日期
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 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 章讲的是同一运行时的不同面孔。这一章讲不从对话开始的会话:入口不是用户消息,是外部投递;认证不是登录,是签名验证;建会话不是一次交互,是一条事务。

一条投递变成根会话:GitHub POST 到精确路由,provider 认证(405/415/413/400/401/503 的拒绝序列),验签后组 VerifiedWebhookDelivery;WebhookRuntime 快照 + deepFreeze 后按 kind 分发,fire-and-forget、202 先回;规则翻译成 WebhookSessionRequest;createWebhookSession 一条事务建会话(webhook-uuid → attach → 权限/标题 → followup),接纳后所有权交还普通根会话。

认证在 provider 层:签名对了才算投递

webhook 运行时本身不认证——认证是各 provider 适配器的事,运行时只认”已经验证过的投递”(VerifiedWebhookDelivery)。为什么这样切?因为信任的边界要放在认识发送方的那一侧:运行时只懂”kind/source/deliveryId/event”这种中立形状,不懂 GitHub 的签名头、Slack 的 challenge、或者将来的什么 provider;每个 provider 的验签方式不同,但验完之后的产物形状相同。适配器把外部字节变成可信投递,运行时只处理可信投递——认证逻辑换 provider 时只在适配器里改,注册表、事务、回滚一条不用动。

GitHub 适配器(webhook-github)把 HTTP 入口的每道闸都写成了明确的拒绝,全部错误码不泄漏请求细节(WebhookHttpError 的 message 都是模板文字,Octokit 验证错误被吞掉):

闸门拒绝
方法不是 POST405,带 allow: POST
Content-Type 不是 application/json(至多一个 UTF-8 charset 参数)415
Content-Length 非法或 body 超 maxBodyBytes400 / 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 把建会话写成一条事务,每一步都可在中途失败,失败只回滚已做的:

  1. permissionPresets.resolve(权限预设存在性);
  2. agentPresets.resolve + acquireScope(preset 作用域,using 语义——事务结束或失败自动释放);
  3. workspaceRegistry.create(workspacePath)(工作区注册);
  4. agents.create:sessionId 是 webhook-${randomUUID()}(命名约定,不是特殊类型),meta 带 cwd 与 agentPreset,setup 里 mount preset 并装一个初始模型选择(installInitialModelSelection:在第一个持久 request header 出现之前,把 Agent 的模型选择固定为投递指定的值);
  5. workspace.attachSession(sessionId)(会话挂进工作区,attached = true 从这里开始);
  6. permissionPresets.set + sessionTitle.rename;
  7. 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 章讲这张擦除的边界。

本节源码依据(2 处)