dsh 源码解析
第四段 · 编排与对外 · 第 12 章

对外接口与自我修改

五条通道四种定位;DNS rebinding 围栏;模型改自己正在跑的 runtime

前十一章讲的都是 dsh 的里面:Cordis 底座怎么装配插件,agent loop 怎么推进 turn,会话日志怎么当唯一真相,工具怎么执行,能力怎么替换,沙箱怎么围栏,压缩怎么省钱,编排怎么扇出。这一章讲边界——别的进程、别的浏览器、别人写的 hook 脚本怎么驱动这个 runtime,以及最后一件事:模型怎么修改自己正在运行的这个 runtime

这两件事看起来无关,其实是同一个问题的两面。对外接口要回答"外面的人能碰到里面的什么",自我修改要回答"里面的模型能碰到自己的什么"。dsh 对这两个问题给的是同一种答案:把可碰到的表面显式列出来,让不可碰的东西在类型上或数据上根本表达不出来

12.1 五条通道,四种截然不同的定位

先把地图摆清楚。dsh 对外一共五条通道,它们的定位差异比技术差异大得多

通道 包组 谁是客户端 定位
Web GUI packages/host/ + packages/client/ + packages/api/ 浏览器(人在看) 完整交互产品:渲染、审批、导航、命令
SDK stdio JSON-RPC packages/sdk/ + python/ 另一个进程里的程序 把整个 harness 当子进程驱动
ACP packages/acp/ 程序化客户端(含 dsh 自己的 subagent-acp 纯 automation 适配器,明确不做 UI
hooks packages/hooks/ 用户已有的 Claude Code / Codex hook 配置 兼容路径,不是推荐扩展方式
tool-cordis packages/extensions/ 模型自己 自我检查 + 自我修改

四种定位的区别值得单独说一句,因为这是本章最重要的判断:

Web 是产品,ACP 是适配器,SDK 是驱动,hooks 是妥协,tool-cordis 是回环。

packages/acp/acp/README.md 把这件事写得毫不含糊:

This package is a transport adapter, not a UI integration or a capability seam. It does not expose editor navigation, transcript replay, commands, modes, configuration pickers, elicitation, reasoning, plans, titles, or tool presentation.

这一整串"不做什么"比"做什么"信息量大。ACP 协议本身能表达 plan、mode、terminal、editor navigation——dsh 的实现主动不实现它们,并把理由写在同一段里:交互渲染和向人提问属于 Web host 和 client 模块。一个通道拒绝长成产品,是为了不和真正的产品抢所有权。

hooks 那一句更狠。packages/hooks/hooks-claude-code/README.md 开头就说:

A native cordis plugin could do everything this bridge does — more powerfully, with typed returns and no serialization boundary. The bridge exists only as a compatibility path for the mapped CC command-hook subset; anything bespoke should be a native plugin on the same extension points.

一个团队愿意在自己的包 README 第二段就写"你其实不应该用这个,你应该写原生插件",说明它清楚这个包的存在理由是迁移成本而不是架构优越性。这是本章第一条可迁移经验:兼容层要在文档里自己承认自己是兼容层,否则它会被当成推荐路径,然后你就永远删不掉它了。

12.2 Web:四层,每层只知道下一层

Web 这条通道是唯一"完整产品",所以它也是唯一有严格分层的。packages/api/README.md 给出了运行时依赖方向:

remotes → gateway → connection → webserver

四层各自的所有权:

ctx key 拥有什么
WebServer host/webserver ctx.webServer HTTP 路由表、upgrade 路由表、index.html 注入
Connection client/connection ctx.connection /api 这一条路由、信任围栏、两条下行 WebSocket
Gateway api/gateway ctx.typertGateway / ctx.remote RPC 分发、参数校验、结果校验、错误分类
Remotes api/remotes 不提供服务 BFF 策略:Agent/Session 查找策略、Client 贡献组装

WebServer 一个 harness 概念都不知道

packages/host/webserver/src/index.ts:73WebServer 是个 node:http 服务器,配置只有 {host, port}。它的 README 里有一句我认为是整个 dsh 分层最干净的自我描述:

The package knows no harness concepts and serves no files.

/api 的 HTTP bridge 和下行 WebSocket 是 connection 插件拥有的路由;插件 bundle 和 HMR 事件流是 modules/hmr 插件拥有的路由;dist 静态文件属于 fallback owner。WebServer 只把裸 socket 和 request 交出去。

它管的是一组很小但很硬的规则:

  • register(route) 加命名的 exact/prefix HTTP 路由,同表内重复路径直接抛错——路由模式是组装级契约,撞车是配置错误而不是运行时情况;
  • registerFallback(handler) 只允许注册一个,第二次抛错,没有注册时返回 404;
  • HTTP 匹配顺序固定:先全表 exact,再最长 prefix,最后 fallback。注册顺序不携带任何请求语义;
  • upgrade 只做精确匹配,没匹配上的连接直接关掉;
  • host 只接受 127.0.0.1(默认姿态)和 0.0.0.0(刻意暴露),没有第三种;
  • listen 失败(EADDRINUSE 之类)从 activation 里抛出去,让 Loader 组装带着 bind 诊断失败,失败的候选 fiber 被 dispose;
  • 单个 HTTP 请求处理抛异常回 400(headers 已经发出去就销毁 socket)并记 warning,永远不退进程
  • dispose 要走完 close() + closeAllConnections() + 销毁所有跟踪的 upgraded socket,等它们真关掉才返回。

最后两条是一对:请求级失败不能杀进程,进程级 dispose 必须真的静默。这两条一起才叫"可靠的服务器插件"。

/api 的信任围栏:Host 头是唯一不能伪造的东西

Connection 层最值得读的是那个信任围栏。它防的是 DNS rebinding:一个恶意页面把自己的域名解析到 127.0.0.1,然后从页面里发请求打你本地的 API。

packages/client/connection/src/api-request-trust.ts:96 的实现只有三十行,但每一段都有理由:

export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
  // Host fence (DNS-rebinding defense), applied to every request: the browser
  // fills Host from the URL it believes it is talking to, so a rebound page
  // carries the attacker's domain here even though the socket lands on this
  // server. There is no marker shortcut — a browser read over plain HTTP
  // (images and navigations) arrives with neither Origin nor
  // Fetch-Metadata, indistinguishable from curl, and its response is readable
  // by the rebound page.
  const host = header(request.headers, 'host')
  if (host === undefined) return false
  const hostUrl = parseAuthority(host)
  if (hostUrl === undefined) return false
  if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
  // Cross-site fence: modern browsers label the initiator relationship on
  // every fetch; an explicit cross-site marker is refused regardless of Origin.
  if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
  // Origin fence: when a browser attaches an Origin it must be exactly this
  // authority (compared through the same normalization as the Host). Absent
  // Origin is fine — the Host fence above already bound the request. The
  // literal "null" (sandboxed iframes, file: pages) is an opaque origin, refused.
  const origin = header(request.headers, 'origin')
  if (origin === undefined) return true
  try {
    return new URL(origin).host === hostUrl.host
  } catch {
    return false
  }
}

三道栅栏的顺序是设计过的:

  1. Host 围栏对每个请求生效,不管有没有浏览器标记。这里最关键的是那句"There is no marker shortcut"——很多实现会写"如果没有 Origin 头,说明不是浏览器发的,放过",这是错的:明文 HTTP 下浏览器读图片和做导航时既不带 Origin 也不带 Fetch-Metadata,和 curl 完全没法区分,而它的响应是被那个 rebound 页面读得到的。Host 是唯一 rebinding 伪造不了的头,所以它必须是无条件的那一道。
  2. cross-site 标记直接拒,不看 Origin
  3. Origin 在的时候必须精确等于 Host authority,两边走同一套 WHATWG 归一化。字面量 "null"(沙箱 iframe、file: 页面)是不透明来源,拒。

围栏之外还有两条配套决定:

  • trustedHosts 里的条目必须是裸的、规范的 host[:port]——WHATWG 解析回来必须和写进去的一模一样,否则插件加载直接大声失败。理由很具体:不然 harness.internal/path 会悄悄授权里面那个 hostname,一个悬空冒号或零填充端口会把"某个端口"悄悄放宽成"任意端口"。
  • 一批特权方法被钉死在 loopbackhost.pickDirectoryhost.openPath,以及整个配置面(settings.*credentials.*)和 agent-preset 创作面(agentPreset.read/copy/openDocument/remove)。做法是让它们通过信任围栏时带一个空的信任列表——声明了 trustedHosts 的授权能到达其他所有方法,但这些方法在有真正的认证层之前只能本机访问。

这个"钉死"的分界线画得很有意思。agentPreset.listagentPreset.select 故意留在外面:roster 只带 id 和 trust,而选一个 preset 并不比 session.create 自己的 agentPreset 参数多授予什么,反正默认那个就已经带 bash 了。也就是说,分界线不是按"读/写"划的,而是按**"这个调用比现有的最宽路径多给了什么"**划的。

最后,README 里有一句必须原样保留:

The fence is a reachability policy, not authentication; the Web carrier provides no authentication layer.

以及 dsh web --host 0.0.0.0 刻意不支持,直到远程访问有了认证层。一个 dev-facing 的 web 服务能忍住不加"就先能远程用起来"的开关,是很少见的自律。

两条只下行的 WebSocket

/api/events.mux/api/events.host 各接一个 WebSocket upgrade,只向浏览器发对应的 ServerRequest 文本消息;客户端在这两条 socket 上不发任何应用数据。unary 调用和 respond 操作走 HTTP POST。

失败语义是整代重建:任一 socket 断掉,当前连接 generation 失败,两条流一起重建;readiness 要求两条 socket 都开着并且 host.describe 这个 HTTP 调用成功。普通 GET 打这两个路径返回 426,没有 SSE 回退——toFetchHandler 的 SSE 编解码只服务同进程内的 isomorphic carrier。

每次握手成功后,会在 onConnected 之前把 host.describe 的确切返回值发布到可观察的 hostDescription;generation 丢失或显式停止会清掉它。这样"原生能力"的消费方永远不会拿着一个已断开连接的答案

还有个逃生口值得记:ClientTransportHooks 命名了一个页面全局 __DSH_TRANSPORT__,可以整体替换浏览器 carrier。上线的 web app 不设它,走 HTTP + WebSocket;而一个拥有不同物理传输的宿主(worker preview 的 postMessage 隧道)提供 createApiClientfetch——外加它自己拥有 bundle 字节时的 loadBundle——而不是去 fork 这个插件。

12.3 Typert:编译期反射 + 运行时注册表

Gateway 之所以能对 RPC 做严格的参数和结果校验,是因为它下面有 Typert:一个把 TypeScript 类型变成运行时可查数据的子系统。packages/typert/README.md 的三包分工:

角色 Cordis key
registry/ 存运行时包反射和 schema ctx.typert
loader/ 发现 Loader entry 并注册生成的 host artifact 消费 ctx.loaderctx.typert
generator/ 从源码类型生成运行时 artifact 构建期库

三件事分开:源码分析(generator,构建期)、运行时存储(registry)、Loader 发现(loader)。

registry 的身份规则很小:包反射按 <package>#<face> 索引,schema 按 <package>#<name> 索引并保留生产者的 Zod 实例,JSON Schema 在消费边界按需计算(toJSONSchema()z.toJSONSchema() 投影,不缓存)。

有两个所有权细节值得抄:

注册与配置的生命周期独立。 ctx.typert.lookups.register() 注册业务包拥有的 wire 声明和默认解析器;configure() 注册 Host 组装拥有的解析器,可以异步跑。两者生命周期互不依赖:配置可以先于 provider 出现,卸载配置会恢复默认策略contexts.registerHost() / configureHost() 对 scoped Context 身份用同一套所有权切分。

注册是原子的,返回精确的 effect disposer。 register(contribution) 在提交任何东西之前先拒绝畸形身份和重复的 package-face / schema key,然后返回那个确切的 Cordis effect disposer。schema key 故意不含 face(host 和 client 跑在不同 context 里),所以把两个 face 里同名的 schema 注册进同一个 context 会被当成重复而拒绝。

Gateway 侧(packages/api/gateway/src/index.ts:90TypertGatewayServiceinvoke():145)有三点:

  • strict 模式ctx.typert.local 读生成的 invocation descriptor;SRC 模式是开发期回退,只给从来没有过 strict 定义的端点用,它解析简单参数名并且非 lookup 参数只接受 JSON-safe 值。关键在最后一句:撤回一个已观察到的 strict 定义会失败,而不是弱化校验。这就堵住了"临时删掉生成产物让校验变松"这条路。
  • 错误分类不是一个笼统的 500:TypertGatewayError 区分 dispatch、binding、provider、lookup、Context、arguments、codec 各自拥有的失败。解析器可以用 TypertLookupFailure 携带一个已有的 RPC error,保留它原本的错误码,让 cold-resume 失败或所有权围栏这类策略拒绝不会被压成通用错误。
  • 取消信号是 descriptor 元数据,不是 wire 参数。一个支持取消的 Remote 方法把 signal: AbortSignal 声明成 Host 侧最后一个参数;Connection 把 signal 给 Gateway,Gateway 在解码完业务参数之后注入它。SRC 认这个保留的末位名字,strict 生成额外要求全局 AbortSignal 类型。

Client 侧 ctx.remote 有两个我认为值得单独学的设计:

$on() 的合法 key 恰好是 Host 组装的转发选择,监听器类型就是拥有那个包自己的 Cordis Events 声明——所以不存在第二份可能漂移的签名。这是"一个事实只有一个归属位置"在 RPC 上的应用。

$dispatch() 是那个表面的另一半,属于承载者。 拥有 Host frame sink 的那个 Client 半把每个解码出来的 frame 交过去;没人订阅的事件名直接丢掉,因为 wire 上跑的是 Host 选择转发的东西。普通消费者只订阅,永远不调 $dispatch()

还有:撤回一个 contribution 会同时移除它的 descriptor 和方法、abort 在飞的调用、并让被持有的方法句柄开始 reject。方法查找和调用用普通对象和函数,不用 Proxy

12.4 双半包与 slot:声明即渲染授权即运行时规格

Web GUI 的扩展性靠 slot。packages/client/ui-slots/README.md 里那句设计口号值得原样引用:

declaration = render authorization = runtime spec, one table

一次 register({ name, children?, store?, inject?, ...kind }, Component) 调用做四件事:把组件贡献进一个已声明的 slot、声明它的子 slot、声明一个 store 座位、声明注册方的业务面。组件在调用点就按 ComposedProps 检查——四份 share 的交集,每份都有单一来源:

share 类型 来源
runtime PropsRuntime<K> SlotMap 条目:owner(父的 renderSlot 调用点)+ session 标准套件 + 全局座位
child render PropsRenderSlots<S> register 调用的 children key 集合(静态收窄的 renderSlot
store PropsStore<H> 声明的 handle:useStore 选择器 hook + 剥掉 draft 的 actions
business I inject 工厂的返回值推断

chain 类型的 slot 把 keyed 路由反过来:不是分发点挑 entryKey,而是每个注册自带一个纯 ChainSelect 选择器(可选升序 priority,同值按注册顺序),第一个返回非 null 的当选并把返回值作为组件的 matched prop,全 null 落到 owner 的 renderSlotChain 回退。

加载期校验在 register 就抛:注册进未声明的 slot、重复声明子 slot、同一个共享 handle 挂在两个 scope 下、chain 注册缺 select。条目的 disposer 递归塌掉它声明的子 slot——ledger 行、贡献、store 挂载死在同一条生命周期轴上。

这个包还有两条自我约束:它不依赖 React、不依赖 cordis(React 类型只在运行时用到);引擎产物和渲染宿主契约携带的是裸快照源(getSnapshot/subscribe),从不是 React hook——hook 绑定属于渲染机器,只有 props 契约用到的 hook 类型(SnapshotSelectorHook)留在这里。

12.5 SDK:把整个 harness 当子进程

SDK 这条通道的定位在 packages/sdk/README.md 第一段就框死了:

Callers supply the runtime executable and its cordis.yml; this group does not create, configure, build, or launch developer projects.

也就是说 SDK 不管脚手架。它只负责"启动你指定的那个 runtime,然后驱动它"。

协议只有三个请求、四个通知

packages/sdk/protocol/README.md 的完整表:

方向 方法 类型
client→server initialize InitializeParamsInitializeResult
client→server session/prompt SessionPromptParamsSessionPromptResult(持久入队回执)
client→server shutdown 无参 → {}
server→client session.event SessionEventNotification(runtime 里每个 session,不过滤)
server→client session.status SessionStatusNotification(整 agent 的 running/idle 跃变)
server→client subagent.started SubagentStartedNotification
server→client subagent.finished SubagentFinishedNotification进程内的 run)

JsonRpcLineTransport 用换行分隔的紧凑 JSON 帧跑 JSON-RPC 2.0:带 idmethod 是请求,只有 id 是响应,只有 method 是通知,畸形 JSON 行直接忽略start() 挂流监听,close() 摘掉监听并 reject 掉挂起的请求,但不销毁流(流是调用方拥有的)。缺处理器答 -32601,处理器 reject 答 -32603 带错误消息。错误响应会把挂起的 request() reject 成 JsonRpcResponseError,它保留 wire 上的 code 和可选 data

最重要的一条:入队回执不是回答的 id

SessionPromptResult.messageId 标识那条排进队的 UserMessage。README 明确写了它标识什么:

it does not identify a later assistant message, turn ending, or prompt result.

这不是接口不完善,这是第 5 章那套 append-only 日志语义在 wire 上的必然结果。session/prompt 排一条带标识的用户消息进去,立刻返回 { messageId };服务端把每条持久事实当 session.event 流出去,把整 agent 的生命周期跃变当 session.status 流出去,它不把某条 assistant 消息或某个 turn/end 归属给那个 prompt。独立的请求可以往同一个 session 上再排活。

所以客户端要把开放式的 session.event 流和 agent 级的 session.status 结合起来,按自己的活动所有权判断什么时候算完。这个决定的代价很实在(客户端要自己写这套判断),收益是服务端不用去猜"哪条回答属于哪个 prompt"——而在一个 followup / steer / inject 可以随时插队、子代理结算通知会挤进 step 边界的系统里,这个猜测本来就没有正确答案。

initialize 是 runtime readiness 边界

packages/sdk/server/README.md:当 server 被 Loader 组装挂载时,initialize等当前插件树 settle 完才回复,这样像初始 MCP 工具发现这种异步的兄弟能力对第一个 prompt 就是可见的。手搓的 context(没有 Loader)立刻可用。

initialize.serverInfo.name 是 wire-stable 的 deepseek-harness-sdk-runtime。可选的正整数 initialize.maxTokens 成为每个 SDK 创建的 agent 及其进程内后代的请求输出上限;非法值 reject 掉 initialization,省略则不下发 SDK 上限,让选中的 adapter 或 provider 路由默认值生效。压缩插件拥有它们自己独立的摘要上限,不受这个影响。

stdout 就是协议

Stdout carries only JSON-RPC frames. The deployment must not compose a stdout logger; diagnostics belong on stderr.

这是一条组装约束,不是代码里能强制的东西——所以它必须写在 README 里,而且写成"must not"。

关闭语义分成三层:插件回答 shutdown、flush 响应、dispose 根 context(让 SDK 拥有的 agent、订阅、持久化到达静默)、然后 exit 0。EOF 和信号退出属于 app bin,它也 dispose 根 context。只卸载这个插件则停止服务而不退进程。三种关闭路径都收敛到"dispose 根 context"这一件事上。

两个 SDK,一个刻意的差别

TypeScript SDK(packages/sdk/client/)和 Python SDK(python/)是设计上的双生子:同一个 runtime peer、同一个协议、同一套分层(DeepSeekHarness 是高层拥有 run 的 API,HarnessClient 是低层协议客户端)。

差别只有一处,而且是刻意的:TS SDK 的启动规格完全显式command/args),因为它服务的是仓库邻近的 TypeScript 消费方——包括 dsh-subagent-dsh-sdk 这个 backend 和自动化脚本——它们知道自己在启哪个 runtime。找打包好的可执行文件这件事留给 Python 发行版

DeepSeekHarness 的生命周期规则值得抄:

await using harness = new DeepSeekHarness({
  launch: { command: 'node', args: ['lib/bin.js', 'cordis.yml'] },
  provider: 'deepseek-official',
  model: 'deepseek-v4-flash',
  maxTokens: 49_152,
})
const result = await harness.run('say hi')

子进程首次使用时惰性启动,跨多次 run() 由实例持有;close()(或 await using)是必须的,这样子进程总会被回收。start() 记忆化 initialize 握手;握手失败会回收 runtime 并换上一个全新的 client,所以后续调用会用一个新子进程重试——直到 close(),那是终态。

这个"失败即换新 client"的处理很像第 2 章 Fiber 的 epoch:一个失败的握手不是一个可以重试的调用,而是一个已经脏掉的身份,正确做法是丢掉它整个换一个。

12.6 ACP:一个刻意长不大的适配器

ACP(Agent Client Protocol)是 automation-only 的 JSON-RPC stdio server。仓库内的主要客户端就是 dsh 自己的 dsh-subagent-acp——也就是第 11 章那个 provider registry 里的一个 provider。

插件本体很薄:packages/acp/acp/src/index.ts:121apply(ctx, config) 在 stdin/stdout 上开一个 AgentSideConnection,驱动 ctx.agentsstdout 保留给协议帧(和 SDK 同一条规则)。配置只有 providermodel 两个可选字段(可选是为了让别的 agent/request 监听器供给目标,但可运行的 ACP 组装要求两者都给)。

协议行为里有几处判断值得单独看:

能力协商不吹牛。 initialize 只在挂了持久附件存储并且配置的精确 provider/model 解析出显式图片输入时才宣告图片 prompt;audio 和 embedded context 恒为 false。不宣告 session、editor、terminal、filesystem、MCP 任何能力。authenticate 是 no-op,因为 server 不宣告任何认证方法——不是假装支持然后失败,而是先不宣告

session/new 的拒绝是显式的。 创建 agent 要一个绝对的主 cwd;空的 additionalDirectoriesmcpServers 接受,非空的拒绝。这比"忽略不认识的字段"诚实:客户端会立刻知道这个能力不存在。

session/prompt 的顺序是设计过的packages/acp/acp/src/index.ts:335):保留有序的文本和支持的内联图片块,把 resource link 渲染成方括号文本引用,拒绝 audio、embedded resource、畸形/空输入、以及未宣告能力时的图片。然后——先校验整批图片、重新检查 session 最新的精确路由,然后才做任何保存在 user 事件之前提交每一张图片;每个 session 只允许一个在飞的请求;等准入,排进队之后再等整 agent idle 加有序输出投递。

"先全批校验再落盘"和"图片先于 user 事件提交"这两条合起来保证了一件事:日志里不会出现一条引用了不存在附件的用户消息。这是第 5 章那条 "model-visible means logged" 在附件上的落地——引用和被引用的东西必须一起变成事实。

cancelled 有三个来源,end_turn 只有一个。 正常静默报 end_turn;显式 ACP 取消、dispose、以及**准入被丢弃的 prompt(一个无 turn 的槽位)**都报 cancelled。第三种最容易漏——一个从来没进到 turn 里的 prompt 不是失败,也不是完成,它是被取消。

session/cancel 分两个阶段,权限不同。 还没进 Agent inbox 时:标记并 abort 掉在飞的准入,不取消也不等待无关的 Agent 工作,不发布迟到的 user 消息,prompt 结算为 cancelled。已经进了 inbox:取消被寻址的那个 Agent 并等它拥有的区间静默。没有在飞 prompt 时取消自主工作;未知 id 是 no-op。

这个分阶段和第 11 章 continuable 子代理的 interrupt 是同一个思路:取消的语义取决于工作走到哪一步了,用一个统一的 "cancel everything" 会误伤。

session/update 只发已提交的东西。 每个已提交的 assistant/message 里每个非空文本或图片块发一条 agent_message_chunk,保持顺序。图片在内联 base64 投递前重新读取并做完整性校验原始 delta 和非消息事件一概不发——注意这和 SDK 的 session.event 恰好相反:SDK 流全量持久事实,ACP 只流已提交的助手消息。定位不同,投影就不同。

session/request_permission 为携带 tool call id 的、桥拥有的审批请求提供一次性 allow/reject 选项(packages/acp/acp/src/index.ts:274)。客户端可以自动应答。这就接回了第 9 章的 approval seam:ACP 不发明审批模型,它只是 answerer 的一种实现。

12.7 hooks:一个诚实的兼容层

hooks 子系统桥接的是用户已有的 Claude Code / Codex hook 配置。它的结构很清楚:一个 dialect-neutral 的核心库加两个方言桥。

共享什么,不共享什么

packages/hooks/hook-protocol 不是 cordis 插件——它不注册、不注入,是个库。README 里那张分工表是这个包存在的全部理由:

关注点 核心库 方言桥
matcher 校验 + 测试 matcherDiagnostic(pattern, mode)matchesMatcher(pattern, query, mode) 挑自己的 modeclaude = 字面量或正则,codex = 永远正则),拒绝带诊断的配置组
跑一个 hook runHook(bash, hook, opts, now) 构造 per-event 的 stdin payload 和方言的 env
解码输出 parseHookOutput(exit, stdout, stderr) → 中立的 HookOutput 把中立 HookOutput 映射到扩展点特定的 typed Decision
合并 N 个 hook mergeHookOutputs(outputs) → 最严格的 MergedHookOutcome
持久记录 appendHookInvoked / appendHookResult 在每次调用前后调它们
detached 静默 createDetachedRuns() 给每次 detached runHooksignal,把 drain 注册成 effect disposer

Codex 是刻意重新实现了 Claude Code hook 协议的一个子集——同样的 hooks.json matcher-group 形状、同样的 exit-code/stdout 输出契约、同样的 command-hook 执行模型。所以"真正共享的部分"放核心库,每个桥只拥有不同的那部分。这个切法比"抽一个抽象基类"要健康得多:分界线是协议事实而不是代码相似度。

runHook 有几条硬规则:要求并转发调用方拥有的 options.signal(所以取消能到达 executor 的进程组 kill 和 join 边界);env 在 executor 的凭据擦洗之后合并;尊重 hook 自己的 timeoutSec,否则用 options.defaultTimeoutMs(桥拥有这个默认值,配置默认是核心库的 DEFAULT_HOOK_TIMEOUT_MS = 10 分钟参考值);now 注入进来是为了 duration 可测。

最关键的一条:runHook 永不抛异常。executor 的 rejection(基础设施故障)变成一个 exitCode: undefinedHookOutput,也就是一个非阻塞错误。这和第 11 章 WorkflowRun.result 永不 reject 是同一个模式:在一个用户配置驱动的边界上,"配置写错了"和"进程炸了"都不该把 agent loop 打断

parseHookOutput 的解码规则:exit 2 用 stderr 阻塞,其他失败非阻塞。匹配上的 hook-specific 权限决定覆盖遗留的顶层决定;不匹配或缺失的 event 判别符只抑制 event-specific 字段,顶层字段保持 event-agnostic。成功的非 JSON 输出留给桥自己处理。

mergeHookOutputs 折叠同一个点上所有匹配的 hook:权限优先级 deny > ask > allow,halt 在第一个 continue:false 上变粘,block 原因用 \n\n 连接,additionalContext/systemMessages 按顺序累积。

映射表:把方言接到已有的扩展点上

packages/hooks/hooks-claude-code/README.md 的映射表就是这个桥的全部:

CC hook harness 点 映射
SessionStart agent/session-start(emit) additionalContext → 往新 session agent.inject()(不能阻塞)
UserPromptSubmit agent/pre-step(waterfall) denyPreStepDecision.reject;只有 additionalContext → 先 next() 委托,再把一条独立来源的消息追加到下游的 enter 决定上
PreToolUse tools/pre-execute(waterfall) denyPreToolDecision.denyaskPreToolDecision.ask
PostToolUse tools/post-execute(waterfall) deny → 带反馈的 block;只有 additionalContext → 先 next() 再把独立来源的 context 前置到下游决定上;Code Mode 把子调用的 context 推迟到外层 run_code 结果
Stop agent/turn-stopping(serial) 一个阻塞的 Stop hook 把理由喂进 steer(),强制再来一个 step
SubagentStart subagent/start(emit) additionalContext → 往活的进程内子代理 agent.inject();远程子代理没有本地注入目标
SubagentStop subagent/end(emit) 只观察

这张表最漂亮的地方是:它一个新扩展点都没加。前面十一章讲过的 agent/pre-steptools/pre-executetools/post-executeagent/turn-stoppingsubagent/start,就是这个桥的全部落点。一个兼容层如果需要为自己开新的扩展点,说明底座的扩展点设计得不够正交。

几处细节:

  • "只有 additionalContext" 的情况都是先 next() 再改下游决定,而不是自己造一个决定。这就是第 4 章那条 waterfall 规矩:加数据而不是抢决定权,所以后面更外层的监听器仍然能 reject 或改写。
  • 三个 emit 点 detached 跑——没有任何扩展点会 await SessionStart/SubagentStart/SubagentStop 的 hook。每条 run chain 被跟踪,dispose 桥时先 abort 还在跑的 hook 进程(用 kill,不是等它超时),再 drain 掉续延,然后 dispose 才 resolve。
  • matcher 主体因点而异PreToolUse/PostToolUse 是工具名,SessionStart 是 session source,SubagentStart/SubagentStop 是常量 agent_type = general-purpose——因为 harness 的 subagent seam 不携带 per-kind 标签,桥报告的是 Claude Code 自己 Task 工具的默认值(默认/*/空 matcher 会触发,指定 kind 的不会)。UserPromptSubmit/Stop 忽略 matcher。
  • 同一个点上多个 hook 串行跑、按配置顺序,然后最严格折叠。串行的理由写得很直白:让每个 hook 的 hook/invoked/hook/result 对在日志里相邻。而折叠对决定本身是顺序无关的——所以串行买的不是正确性,是可读性
  • 注入的 context 带显式来源 { kind: 'plugin', plugin: 'hooks-claude-code' },这样持久消息永远不会被误当成用户 prompt。

hook/* 事件

hook/invokedhook/result 声明合并进 SessionEventMap,是 log-only 的(和 compaction/* 一样:不是 SurfaceEventType,没有 surfaceOp),按 handlerId 配对。stderrSummary 截到 stderrSummaryMaxChars(桥的配置,参考默认 DEFAULT_STDERR_SUMMARY_MAX_CHARS = 500;空则省略)。

配对约束和第 9 章审批一样:hook 调用/结果记录必须落在一个打开的 turn 里UserPromptSubmitPreToolUsePostToolUseStop 按构造就满足这个所有者定义的关系。SessionStart 跑在第 1 个 turn 之前,因此拿不到 hook/* 记录;它允许的 context 留在 inbox 里 pending,直到某次唤醒投递打开一个 turn。

这里有个诚实的处理值得学:每个 agent-scoped 的 stdin payload 都带 session_id 和字符串形状的 transcript_path。后者在可用时通过 ctx.sessionPersistence.locate(session.header) 解析,否则发 ''查找不会创建也不会 flush 那个 artifact,所以第一次 turn-end 检查点之前这个路径可能是不存在的,或者会漏掉当前打开的 turn。写成 '' 而不是编一个路径出来。

配置侧还有一个已知限制被明确标了 TODO:配置只解析一次configPath进程级的(相对路径在加载时对进程启动 cwd 解析),所以还没有 per-session(session/new.cwd)的配置发现。读/解析失败被容纳——包括在消费 matcher 的 event 上出现非法正则,会带着 pattern 和 event 报出来——桥记一条 warning 什么都不注册,而不是让 boot 崩掉。理由一句话:一个打错的路径不该把 agent 弄死。

只有 shell 形式的 type: 'command' hook 会跑;http/mcp_tool/prompt/agent 形式解析并跳过加 warning。而且 hook 本身跑在 agent 的 session workspace 里:agent-scoped 的点,桥把 session 的 cwd(那个 session/new.cwd)作为 hook 进程的工作目录,这样 hook 里的 pwd、相对路径、标记文件操作的是用户的项目树,不是服务器的启动目录。

12.8 tool-cordis:模型修改自己正在跑的 runtime

最后这个子系统是全书最"元"的部分:五个面向模型的工具,操作当前这个 DSH 进程里活着的 Cordis runtime

包组分工(packages/extensions/README.md):

角色 ctx key
tool-cordis/ 面向模型的 runtime 检查和动态包工具 注册在 ctx.tools
cordis-host-runner/ 定义注册表、host 半的 node:vm 沙箱、request-run 往返 提供 ctx.dynamicCordisRunner
cordis-client-runner/ 双半包的浏览器半:把定义求值成活的浏览器插件并应答 run 请求 client 面;提供浏览器侧 ctx.dynamicCordisRunner
ui-cordis/ 浏览器表面:操作每个定义的全局面板,以及只读的 define 卡片 client 面;注册 slot

注意最后两个包住在 packages/extensions/ 而不是 packages/client/——因为它们是这个子系统双半包的另一半,host 聚合把它们排除掉,让每个 face 保有自己的编译器 program。

五个动词:两对配对 + 一个只读报告

  • cordis_inspect — 当前进程的只读报告:服务、所有活着的插件 fiber、注册的工具、本 session 的动态包、反射支撑的 api/events 引用,以及编译期的 client 座位表面(浏览器半能往里贡献 UI 的地方)。精确的 name 配上 what: "api" / "events" / "client" 会收窄报告并加上完整契约。
  • cordis_define — 在对两个半都做完语法检查之后记录一个包(namepurpose,以及 host 半 code 和/或浏览器半 client)。什么都不跑;用户在对话里看到一张带启动控件的卡片。铸出来的 dyn-<n> id 同时骑在结果值上持久的 presentation metadata 上——这就是那张卡片在 replay 之后仍然能寻址 run 动词的原因。
  • cordis_run — 在沙箱里求值 host 半,并把浏览器半投递给每一个打开的 web 页面。重复 run 一个已经在跑的包是重新投递活的版本而不是失败——这就是一个刷新过的页面把它拿回来的方式。
  • cordis_stop — dispose host 半到静默,撤回浏览器半;定义存活,可以再跑。
  • cordis_undefine — 需要的话先停,然后忘掉定义;它的卡片作为一条"未加载"记录留在对话里。

生命周期边界写得极其克制:动态包活在共享的 DSH 进程内存里。它们跨后续 turn 保持活跃,可能影响那个进程里的其他 session,但在 cordis_stop/cordis_undefine、工具集卸载或 DSH 重启之后消失。它们不创建 Plugin 文件、不安装包、不改 cordis.yml 或个人/项目配置、不活过重启、不能被自动提升。想保留一个实验,就让 Agent 走正常开发流程实现一个本地/项目/仓库 Plugin。每个动词都是 session-scoped:一个包只在定义它的那个 session 里可见可控。

"不能被自动提升"这一条是刻意的。给一条从"临时实验"到"正式插件"的自动通道会非常诱人,也会立刻变成一条绕过所有 review 的持久化后门。

沙箱:说清它值多少钱

packages/extensions/cordis-host-runner/src/sandbox.ts:1 的模块 JSDoc 一开头就把姿态说了:

/**
 * The `node:vm` sandbox a dynamic package's HOST half evaluates in: a fresh realm whose globals
 * are a tagged write-through console, the `harness` registration helpers, the encoding primitives
 * a bare vm context lacks, and callable traps over the Node APIs the sandbox deliberately
 * withholds. Traps steer filesystem, network, process, and timer work to `ctx.fs`, `ctx.web`,
 * `ctx.bash`, and Cordis timers. This keeps cooperative packages inspectable and disposable but
 * is not containment: host-realm helper functions remain an escape route.
 */

"keeps cooperative packages inspectable and disposable but is not containment"——这一句话就把这个沙箱的价值和边界都定了:它让诚实的代码可检查、可回收,它不防恶意代码。README 的结论一句话:像对待 bash 权限那样对待这个工具集

具体机制里有几处判断很值得学:

trap 只装在函数值的全局上。 NODE_API_REDIRECTSpackages/extensions/cordis-host-runner/src/sandbox.ts:96)把 require、四个 timer、fetch 映射成抛错的 trap,每条错误信息里带着"你应该用哪个 cordis 服务":

const NODE_API_REDIRECTS: Record<string, string> = {
  require:
    'Node modules are unavailable. Use the cordis services on ctx instead — e.g. inject: [\'fs\'] for files, '
    + '[\'web\'] for HTTP, [\'bash\'] for processes; query Service.listService with cordis_inspect_query first.',
  setTimeout: TIMER_REDIRECT,
  // ...
  fetch:
    'Network access goes through the cordis web service: declare inject: [\'web\'] and call ctx.web '
    + '(query Host Service.listService with cordis_inspect_query for its methods).',
}

但源码注释里那条"只 trap 函数值全局"的理由才是精髓:

Only function-valued globals are trapped; a data-valued global such as process stays undefined, because a throwing accessor would detonate the common typeof process feature probe at resolution time.

process 装一个抛错的 accessor 会让最常见的 typeof process 特性探测在取值时就炸掉。一个特性探测本来的语义是"问一下有没有",把它变成"问一下就死"是纯粹的伤害。所以数据形状的全局留成 undefined,只有函数形状的才 trap——trap 的作用是在你真的去用的时候教你正确路径,不是在你打听的时候惩罚你

跨 realm 的 instanceof 被显式修补。 DUAL_REALM_INSTANCEOF_PRELUDE:62)在新建的沙箱里跑一次,给 VM 侧构造器的 Symbol.hasInstance 装上"VM 值或 host 值都算"的判定,配对的 intrinsics 是 Object, Array, Function, Error, TypeError, RangeError, SyntaxError, Promise, RegExp, Date, Map, Set。注释里限定了范围:只 patch VM 构造器,host intrinsics 不动

这解决的是一个真实的疼点:一个跑在 vm realm 里的插件收到 host 传进来的参数、事件、服务返回值,用 instanceof Error 判断会得到 false——因为那是 host realm 的 Error。不修,每个动态包作者都要踩一次;修在 host 侧,一次修完。

console 是 write-through 的,不是缓冲的。 taggedConsole(id):51)给每行打上 [cordis:<id>] 标签,写到 host 的 stdout/stderr。JSDoc 给了理由:

Write-through (host stdout/stderr), NOT buffered into the tool result: a registered listener fires long after the run call returned, and its output must land somewhere the user can see.

一个注册进去的监听器会在 cordis_run 早就返回之后才触发。想把 console 输出塞进工具结果,物理上就做不到。

编译门在 new Function 上。 :196 开头的注释解释得很清楚:new Function 是那个门——没有真 node:vm 的宿主(浏览器 worker)仍然会拒绝,页面 CSP 没有 'unsafe-eval'new Function 也会拒。也就是说语法检查用的是目标环境真正会用的那个求值原语,而不是一个独立的 parser。这样"能通过检查但跑不起来"的情况被排除掉了。

暴露给包代码的表面被穷举列出。 HOST_BUILTIN_INSPECTIONpackages/extensions/cordis-host-runner/src/sandbox.ts:18)把 host 闭包符号连签名一起写成数据:

export const HOST_BUILTIN_INSPECTION = [
  {
    name: 'ctx',
    description: 'Restricted Cordis Context. Prefer ctx.get(name) with an undefined check; use inject for hard dependencies.',
    signatures: [
      'ctx.get(name: string): unknown | undefined',
      'ctx.on(name: string, listener: Function): () => void',
      'ctx.provide(name: string, value: unknown): () => void',
      'ctx.effect(callback: Function, label?: string): () => void',
    ],
  },
  // harness / console / btoa / atob / TextEncoder / TextDecoder …
] as const

值得注意的是这里的 ctx effect(),而 README 的已知限制里写的是"ctx façade exposes no effect(),包代码不能注册自定义 disposer,on/provide/tools.register 是受支持的清理路径"。这处差异说明这张表描述的是沙箱直接暴露的闭包符号,而模型实际能用的清理路径以 README 的限制为准——读这类"暴露表面"时,穷举表和已知限制要一起读

两个生成目录 + 冻结门

cordis_inspect 能回答"这个 runtime 能做什么",靠的是两份编译期生成的目录,而不是运行时反射猜测。

src/api-catalog.ts 是工作区所有 Cordis 声明的投影:渲染好的方法签名、源码 JSDoc、harness 事件连它们的 dispatch mode、以及那些签名引用到的类型形状。关键在于它由docs/subsystems 同一次 AST 遍历产出——所以模型读到的数据和渲染出来的文档不可能分叉。它是关于仓库的编译期事实,所以 pnpm run gen-cordis-api 重新生成它,pnpm run verify-cordis-api 给它的新鲜度把门(package.json:111-112,都收在 doc-sync 这个门里,package.json:132)。

文件很大:SERVICE_APIpackages/extensions/tool-cordis/src/api-catalog.ts:83EVENT_API:2367INHERITED_CTX_API:5059。最后这个是手工策展的一层——框架继承来的 ctx 表面(ctx.onctx.effectctx.loader、timer 助手)是 Context 本身而不是服务 key,而框架层住在被钉住的 vendor 包里、在所有被分析的 face 之外,所以生成器策展那一层,并把它同时渲染进这份目录和 docs/cordis-api/inherited.md

src/client-catalog.ts 描述浏览器半的座位,由 scripts/gen-client-catalog.ts 从对每一处 SlotMap 声明合并和每一处 slots.register 调用点的词法扫描生成(pnpm run verify-client-catalog 把新鲜度门,package.json:115)。它只携带浏览器半能作用的那一个表面——slot key、每次 register 调用的选项、组件收到的 props、谁已经占了这个座位、哪个 owner 的挂载让这个座位存在——以纯数据形式:这个包留在 host 侧、不 import 任何 client 模块,所以字符串是唯一跨过去的东西。

生成器大声失败而不是发一条模型没法作用的条目:一个没有面向注册方的说明文字的 slot、一个非字面量的 kind/scope、没有导出提供的 owner props、重复 key、往未声明的 slot 注册——每一种都会把门弄坏。owner props 只展开一层(owner 声明加它自己的成员文档,以及它字段引用到的形状的名字),单个 slot 的整份报告有预算上限,因为收窄到一个 slot 的意义是花更少的 context,不是更多

还有一条所有权规则:一个 slot 的教学文字就是它声明处的 JSDoc,所以想改进模型读到的东西,要去声明它的那个包改契约,不是改这份目录。

两条面向模型的策展判断

src/inspect.ts 把目录和活的服务存储做交集:什么在运行来自存储,每个服务能做什么来自目录,而目录没覆盖到的活服务被报成"可达但没有签名"而不是被省略。(describeServicespackages/extensions/tool-cordis/src/inspect.ts:131describePlugins:149describeTools:167describeDynamic:180describeApi:264describeEvents:314。)

有两个判断刻意住在这个包里而不是生成产物里,理由是"反射数据要忠于代码,而报告必须有用":

只显示可调用的方法。 非方法成员是状态而不是动词,而且它们渲染出来的形式会携带实现体里的初始值;symbol-key 的成员是插件之间的内部接缝,包 façade 刻意到不了,所以点名一个就是在广告一个打不出去的调用。

只把 host 半能到达的 key 报给模型。 反射模型覆盖每个包声明的 ctx.<key>,包括启动器供给的 boot 值(agentheadlessIo…)和浏览器半的服务(connection)。策展把每个 key 的 reach 分成 injectablenot-a-serviceother-face只有 injectable 进报告。分类作为数据挂在每条目录条目上而不是在渲染时施加,所以这个排除本身可以被单独测试;verify-cordis-catalog 把被分类的集合钉成恰好等于文档投影不渲染的那些 key——一个新声明的 key 会把门弄停,而不是悄悄邀请模型去 inject 一个永远不会到的东西。而一个被分类了但确实有活 provider 的 key 仍然报成运行中且可注入:服务存储才是"什么存在"的权威

这两条合起来是一句话:报告不能广告一个打不出去的调用。这是本章我最想让人带走的一句。一个自我描述接口最大的失败模式不是漏报,而是报了一个不存在的可能性——模型会去试,会失败,会重试,会烧掉一整个 turn。

渲染与导出形状

每个工具渲染一张 generic 卡片(read/execute/delete);cordis_define 把提交的两个半当 rawInput 携带,并用 label 和 purpose 给卡片起标题。Presenter 是参数的纯函数,结果保持默认文本渲染。Web 客户端注册自己的 keyed cordis_define 行(@deepseek-ai/dsh-client-ui-cordis),从调用参数和结果元数据里读 label、purpose 和铸出来的 id;没有这个注册的表面回退到 generic 卡片。

导出形状是命名空间插件:命名导出 name/inject/apply没有 default export——引用的事故报告是 docs/postmortem/0001-acp-default-export-drops-inject.md(default export 会丢掉 inject)。它注入 toolsdynamicCordisRunner

这里有个组装事实值得记:注册表、vm 沙箱、浏览器广播都属于 cordis-host-runnerctx.dynamic),tool-cordis 注入它——一个装了这些工具但没装 runner 的组装永远不会激活它们。所以"要不要给这个部署自我修改能力"是一个组装决定,而不是一个配置开关。

12.9 设计代价

Web 那四层是真的四层。 加一个 RPC 方法要碰生成的 descriptor、Gateway 的校验、Remotes 的 BFF 策略、可能还有 Client 的 contribution。分层买到的是"WebServer 一个 harness 概念都不知道"和"Gateway 的错误分类有七种归属",代价是纵向改动要穿透。

遗留的 API Proxy 还在。 packages/host/apiproxy 作为还没迁到 Remote 的方法的回退存在。它消费 api-remotes 拥有的 Host resolver,所以迁完的和遗留的方法共享同一套 Agent/Session 身份策略——这是个很好的过渡处理,但它确实意味着现在有两条路径。README 里还标了 Connection 和 WebServer 目前住在 client/connectionhost/webserver,将来可以纯搬包到 api/ 下而不改服务契约。

/api bridge 把每个请求体缓进内存。 maxRequestBodyBytes 默认 300 MiB(按默认 200 MiB 图片聚合上限 base64 膨胀后加信封余量算的),所以它同时也是每请求的常驻内存上界。要在不缩小图片上限的前提下降低它,需要一条流式 body 路径。

history 会恢复一个未附着的 session。 打开历史可能会创建 host 侧的 agent 并给第一次打开加延迟;没有"只读持久化"的路径。

WebServer 没有 TLS、认证、origin 策略。 绑一个非 loopback 地址就是把服务器暴露给那个网络;部署硬化(或者前面挡一个真的反向代理)刻意不在 dev-facing v1 的范围内。socket 选项固定,配置只选 bind host 和 port。

SDK 客户端要自己判断"完成"。 messageId 不是回答的 id,所以每个客户端都要写一套"把 session.eventsession.status 结合起来"的活动所有权判断。这是把复杂度从服务端推给了客户端,换来的是服务端不做没有正确答案的猜测。

session.event 不过滤。 runtime 里每个 session 的每条持久事实都会流给 SDK 客户端。这对调试和录制极好,对只关心一个 session 的客户端是额外流量和额外过滤代码。

stdout 是协议这条约束在代码里强制不了。SDK server 和 ACP 都靠 README 里的 "must not compose a stdout logger"。任何往 stdout 打日志的插件都会静默破坏这条通道。

hooks 只支持 command 形式,配置只解析一次,而且是进程级的。 http/mcp_tool/prompt/agent 形式的 hook 解析并跳过。没有 per-session 配置发现(有 TODO(per-session-hook-config))。transcript_path 在第一次 turn-end 检查点之前可能是空串。

hooks 是序列化边界。 每个 hook 是一次进程启动加一次 stdin/stdout 往返,跑在 waterfall 里。原生插件有 typed 返回值、没有序列化。桥自己在 README 里说了这件事。

tool-cordis 的沙箱不是安全边界。 host realm 的 helper 函数是逃逸路径,包代码能到 Node。加载这个插件要像授予 bash 一样谨慎。ctx façade 没有 effect(),所以包代码注册不了自定义 disposer。异步的 host 半函数体逃得掉 vmTimeoutMs

动态包是进程内共享的。 它们可能影响同一进程里的其他 session,而且不活过重启,也不能自动提升成真插件。这两件事同时是特性和限制。

两份生成目录要靠门维持新鲜。 verify-cordis-apiverify-client-catalogverify-cordis-catalog 都在 doc-sync 里。门是必要的:目录一旦过期,模型读到的就是关于这个仓库的假话。

12.10 可迁移经验

1. 兼容层要在文档里自己承认自己是兼容层。 "A native plugin could do everything this bridge does — more powerfully"这句话写在包 README 第二段,是防止兼容层被当成推荐路径的唯一有效办法。

2. 一个通道要拒绝长成产品。 ACP 明确列出它暴露的十几样东西,并把交互渲染和向人提问的所有权还给 Web。通道之间不抢所有权,边界才稳。

3. 载体层不要知道业务概念。 WebServer 只有 {host, port}、路由表和 socket,"knows no harness concepts and serves no files"。所有业务路由都是别人注册进来的。这样它才可能被替换、被测试、被复用。

4. 请求级失败不能杀进程,进程级 dispose 必须真的静默。 一个请求处理抛异常回 400 记 warning;dispose 要等 HTTP server 和所有 upgraded socket 真的关掉才返回。这两条是一对,缺一条都不叫可靠。

5. 安全围栏里不要给"看起来不是浏览器"开捷径。 明文 HTTP 下浏览器读图片既不带 Origin 也不带 Fetch-Metadata,和 curl 无法区分,而它的响应是被读得到的。所以那道无条件的栅栏必须建在唯一不能被伪造的输入上。

6. 权限分界线按"比现有最宽路径多给了什么"划,不按读/写划。 agentPreset.read 钉在 loopback(读一个 preset 是侦察),agentPreset.select 不钉(它不比 session.create 的参数多给什么)。这个问法比"这是读还是写"精确得多。

7. 配置里的授权条目必须规范到"解析回来一模一样",否则加载期大声失败。 不然 harness.internal/path 会悄悄授权里面那个 hostname,一个悬空冒号会把单端口放宽成任意端口。授权配置的解析宽容度是负资产。

8. 声明、渲染授权、运行时规格应该是同一张表。 slot 那句 "declaration = render authorization = runtime spec, one table" 是这条的极致形式:一次 register 调用同时声明子 slot、store 座位和业务面,组件在调用点就按四份 share 的交集检查。

9. 入队回执不要假装是结果标识。 在一个可以随时插队、可以被 steer、子代理结算会挤进 step 边界的系统里,"哪条回答属于哪个 prompt"没有正确答案。返回一个诚实的 messageId 并说清它不标识什么,比返回一个会骗人的 resultId 好。

10. 握手失败是脏身份,不是可重试的调用。 SDK 客户端在握手失败时回收 runtime 并换一个全新 client。这和 Fiber epoch、和"连接 generation 丢失就重建两条流"是同一个模式:失败污染的是身份的话,就换身份,不要在脏身份上重试

11. 能力协商要先不宣告,而不是假装支持然后失败。 ACP 只在附件存储挂上且精确路由解析出图片输入时才宣告图片能力;authenticate 是 no-op 因为没宣告任何认证方法;非空的 mcpServers 直接拒。客户端立刻知道边界在哪。

12. 引用和被引用的东西必须一起变成事实。 ACP 先全批校验图片、再重查路由、然后才落盘,并且在 user 事件之前提交每一张图片。日志里不会出现一条引用了不存在附件的用户消息。

13. 在用户配置驱动的边界上,"配置写错"和"进程炸了"都不该打断主循环。 runHook 永不抛,executor 的 rejection 变成 exitCode: undefined 的非阻塞错误;配置解析失败记 warning 什么都不注册。一个打错的路径不该把 agent 弄死。

14. 兼容层不应该需要新的扩展点。 CC 的七个 hook 点全部落在已有的 agent/session-startagent/pre-steptools/pre-executetools/post-executeagent/turn-stoppingsubagent/startsubagent/end 上。如果一个外部协议接不上,那是底座扩展点不够正交的信号。

15. 只加数据的监听器要先 next() 再改下游决定。 别自己造一个决定,那会抢掉外层监听器 reject 或改写的机会。

16. detached 的工作要在 dispose 时先 kill 再 drain。 先 fire abort signal 让还在跑的 hook 进程被杀掉(而不是等它跑完 10 分钟超时),再等所有被跟踪的链结算。fiber.dispose() resolve 的意思必须是"没有 detached 工作会再打进一个已 dispose 的 context"。

17. 串行执行有时买的是可读性而不是正确性。 同一个点上多个 hook 串行跑,让每个 hook 的 invoked/result 对在日志里相邻——而折叠对决定本身是顺序无关的。把这个理由写下来,下一个人才不会为了"优化"把它改成并发。

18. 注入的上下文要带来源标记。 { kind: 'plugin', plugin: 'hooks-claude-code' },这样持久消息永远不会被误当成用户 prompt。

19. 特性探测不该被惩罚。 只 trap 函数值的全局,让 process 这种数据形状的全局留成 undefined——一个抛错的 accessor 会让 typeof process 在取值时就炸。trap 的作用是在你真的去用的时候教你正确路径。

20. 语法检查要用目标环境真正会用的求值原语。new Function 当门,而不是另找一个 parser——这样"能过检查但跑不起来"的情况被排除掉了。

21. 自我描述的接口绝不能广告一个打不出去的调用。 只报可调用的方法、只报 host 半能到达的 key,并把这个分类作为数据挂在目录条目上让它可以被单独测试,再用一道门把"被排除的集合"钉死。报一个不存在的可能性比漏报危险得多:模型会去试、会失败、会烧掉一整个 turn。

22. 模型读的数据和渲染的文档要出自同一次 AST 遍历。 这样它们不可能分叉。再配一道新鲜度门,因为一份过期的自我描述目录就是关于这个仓库的假话。

23. 收窄一个报告的意义是花更少的 context,不是更多。 所以单 slot 的详情是 opt-in 的、owner props 只展开一层、整份报告有预算上限。

24. 教学文字要住在声明处。 slot 的说明就是它声明处的 JSDoc,改进模型读到的东西要去改契约本身,而不是改那份生成的目录。

25. 给模型自我修改能力时,把"能不能"做成组装决定而不是配置开关。 装了 tool-cordis 但没装 runner 的组装永远不会激活这些动词。而且不给"临时实验自动提升成正式插件"的通道——那会立刻变成绕过所有 review 的持久化后门。

12.11 源码位置表

主题 位置
Web 四层依赖方向 packages/api/README.md
WebServer 服务 packages/host/webserver/src/index.ts:73(默认导出 :303
WebServer 路由、匹配顺序、失败与 dispose 语义 packages/host/webserver/README.md
/api 信任围栏实现 packages/client/connection/src/api-request-trust.ts:96
Host 围栏 / cross-site / Origin 三道栅栏的理由 packages/client/connection/src/api-request-trust.ts:97-119(内联注释)
trustedHosts 规范性要求、特权方法钉 loopback、两条下行 WebSocket packages/client/connection/README.md
__DSH_TRANSPORT__ 传输替换钩子 packages/client/connection/README.mdClientTransportHooks
Typert 三包分工 packages/typert/README.md
ctx.typert 注册表 API packages/typert/registry/src/index.ts:19-25packages/typert/registry/README.md
TypertGatewayService packages/api/gateway/src/index.ts:90invoke() :145TypertGatewayError :44
strict / SRC 模式、错误分类、取消信号作为 descriptor 元数据 packages/api/gateway/README.md
ctx.remote$mount / $on / $dispatch packages/api/gateway/README.md
slot 注册表与四份 props share packages/client/ui-slots/README.md
SDK 包组定位(不管脚手架) packages/sdk/README.md
SDK wire 协议全表、JsonRpcLineTransport packages/sdk/protocol/README.md
messageId 不标识回答 packages/sdk/protocol/README.md
HarnessSdkJsonRpcServer packages/sdk/server/src/server.ts:53initialize :111shutdown :150,事件订阅 :71-76
initialize 作为 readiness 边界、stdout 是协议、关闭语义 packages/sdk/server/README.md
TS SDK 的 DeepSeekHarness / HarnessClient 与显式 launch spec packages/sdk/client/README.md
Python SDK 两包与 bundled runtime 选择 python/README.md
ACP 插件 packages/acp/acp/src/index.ts:121newSession :308prompt :335requestPermission :274
ACP 定位、能力协商、协议契约表 packages/acp/acp/README.md
cancelled 的三个来源 packages/acp/acp/src/codec.ts:20 附近、packages/acp/acp/README.md
hook 协议共享/方言分工表、runHook 永不抛、mergeHookOutputs packages/hooks/hook-protocol/README.md
CC hook → harness 扩展点映射表 packages/hooks/hooks-claude-code/README.md
hook/* 事件与打开 turn 约束 packages/hooks/hook-protocol/README.md
五个动词与动态包生命周期 packages/extensions/tool-cordis/README.md
extensions 包组分工与双半包归属 packages/extensions/README.md
vm 沙箱模块姿态 packages/extensions/cordis-host-runner/src/sandbox.ts:1
HOST_BUILTIN_INSPECTION packages/extensions/cordis-host-runner/src/sandbox.ts:18
taggedConsole write-through 理由 packages/extensions/cordis-host-runner/src/sandbox.ts:51(JSDoc 在 :45
跨 realm instanceof 修补 packages/extensions/cordis-host-runner/src/sandbox.ts:62patchDualRealmInstanceof :79,配对 intrinsics :81
NODE_API_REDIRECTS 与"只 trap 函数值全局"的理由 packages/extensions/cordis-host-runner/src/sandbox.ts:96(JSDoc 在 :88,trap 构造 :111
new Function 作为编译门 packages/extensions/cordis-host-runner/src/sandbox.ts:196new Function(wrapped) :217
vmTimeoutMs 配置与 runner 服务 packages/extensions/cordis-host-runner/src/index.ts:88:128 默认 5000)
生成的 API 目录 packages/extensions/tool-cordis/src/api-catalog.ts:83SERVICE_API)、:2367EVENT_API)、:5059INHERITED_CTX_API
目录查询函数 packages/extensions/tool-cordis/src/api-catalog.ts:5099:5133
活服务与目录求交、各段报告 packages/extensions/tool-cordis/src/inspect.ts:131:149:167:180:264:314
两条面向模型的策展判断、client 目录生成门 packages/extensions/tool-cordis/README.md
目录新鲜度门 package.json:111gen-cordis-api)、:112verify-cordis-api)、:115verify-client-catalog)、:132doc-sync

对外接口与自我修改