对外接口与自我修改
五条通道四种定位;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:73 的 WebServer 是个 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/prefixHTTP 路由,同表内重复路径直接抛错——路由模式是组装级契约,撞车是配置错误而不是运行时情况;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
}
}
三道栅栏的顺序是设计过的:
- Host 围栏对每个请求生效,不管有没有浏览器标记。这里最关键的是那句"There is no marker shortcut"——很多实现会写"如果没有
Origin头,说明不是浏览器发的,放过",这是错的:明文 HTTP 下浏览器读图片和做导航时既不带Origin也不带 Fetch-Metadata,和 curl 完全没法区分,而它的响应是被那个 rebound 页面读得到的。Host 是唯一 rebinding 伪造不了的头,所以它必须是无条件的那一道。 - cross-site 标记直接拒,不看
Origin。 Origin在的时候必须精确等于 Host authority,两边走同一套 WHATWG 归一化。字面量"null"(沙箱 iframe、file:页面)是不透明来源,拒。
围栏之外还有两条配套决定:
trustedHosts里的条目必须是裸的、规范的host[:port]——WHATWG 解析回来必须和写进去的一模一样,否则插件加载直接大声失败。理由很具体:不然harness.internal/path会悄悄授权里面那个 hostname,一个悬空冒号或零填充端口会把"某个端口"悄悄放宽成"任意端口"。- 一批特权方法被钉死在 loopback:
host.pickDirectory、host.openPath,以及整个配置面(settings.*、credentials.*)和 agent-preset 创作面(agentPreset.read/copy/openDocument/remove)。做法是让它们通过信任围栏时带一个空的信任列表——声明了trustedHosts的授权能到达其他所有方法,但这些方法在有真正的认证层之前只能本机访问。
这个"钉死"的分界线画得很有意思。agentPreset.list 和 agentPreset.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 隧道)提供 createApiClient 和 fetch——外加它自己拥有 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.loader 和 ctx.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:90 的 TypertGatewayService,invoke() 在 :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 |
InitializeParams → InitializeResult |
| client→server | session/prompt |
SessionPromptParams → SessionPromptResult(持久入队回执) |
| 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:带 id 和 method 是请求,只有 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:121 的 apply(ctx, config) 在 stdin/stdout 上开一个 AgentSideConnection,驱动 ctx.agents。stdout 保留给协议帧(和 SDK 同一条规则)。配置只有 provider 和 model 两个可选字段(可选是为了让别的 agent/request 监听器供给目标,但可运行的 ACP 组装要求两者都给)。
协议行为里有几处判断值得单独看:
能力协商不吹牛。 initialize 只在挂了持久附件存储并且配置的精确 provider/model 解析出显式图片输入时才宣告图片 prompt;audio 和 embedded context 恒为 false。不宣告 session、editor、terminal、filesystem、MCP 任何能力。authenticate 是 no-op,因为 server 不宣告任何认证方法——不是假装支持然后失败,而是先不宣告。
session/new 的拒绝是显式的。 创建 agent 要一个绝对的主 cwd;空的 additionalDirectories 和 mcpServers 接受,非空的拒绝。这比"忽略不认识的字段"诚实:客户端会立刻知道这个能力不存在。
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) |
挑自己的 mode(claude = 字面量或正则,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 runHook 传 signal,把 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: undefined 的 HookOutput,也就是一个非阻塞错误。这和第 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) |
deny → PreStepDecision.reject;只有 additionalContext → 先 next() 委托,再把一条独立来源的消息追加到下游的 enter 决定上 |
PreToolUse |
tools/pre-execute(waterfall) |
deny → PreToolDecision.deny;ask → PreToolDecision.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-step、tools/pre-execute、tools/post-execute、agent/turn-stopping、subagent/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/invoked 和 hook/result 声明合并进 SessionEventMap,是 log-only 的(和 compaction/* 一样:不是 SurfaceEventType,没有 surfaceOp),按 handlerId 配对。stderrSummary 截到 stderrSummaryMaxChars(桥的配置,参考默认 DEFAULT_STDERR_SUMMARY_MAX_CHARS = 500;空则省略)。
配对约束和第 9 章审批一样:hook 调用/结果记录必须落在一个打开的 turn 里。UserPromptSubmit、PreToolUse、PostToolUse、Stop 按构造就满足这个所有者定义的关系。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— 在对两个半都做完语法检查之后记录一个包(name、purpose,以及 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_REDIRECTS(packages/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
processstaysundefined, because a throwing accessor would detonate the commontypeof processfeature 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_INSPECTION(packages/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_API 在 packages/extensions/tool-cordis/src/api-catalog.ts:83,EVENT_API 在 :2367,INHERITED_CTX_API 在 :5059。最后这个是手工策展的一层——框架继承来的 ctx 表面(ctx.on、ctx.effect、ctx.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 把目录和活的服务存储做交集:什么在运行来自存储,每个服务能做什么来自目录,而目录没覆盖到的活服务被报成"可达但没有签名"而不是被省略。(describeServices 在 packages/extensions/tool-cordis/src/inspect.ts:131,describePlugins 在 :149,describeTools 在 :167,describeDynamic 在 :180,describeApi 在 :264,describeEvents 在 :314。)
有两个判断刻意住在这个包里而不是生成产物里,理由是"反射数据要忠于代码,而报告必须有用":
只显示可调用的方法。 非方法成员是状态而不是动词,而且它们渲染出来的形式会携带实现体里的初始值;symbol-key 的成员是插件之间的内部接缝,包 façade 刻意到不了,所以点名一个就是在广告一个打不出去的调用。
只把 host 半能到达的 key 报给模型。 反射模型覆盖每个包声明的 ctx.<key>,包括启动器供给的 boot 值(agent、headlessIo…)和浏览器半的服务(connection)。策展把每个 key 的 reach 分成 injectable、not-a-service、other-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)。它注入 tools 和 dynamicCordisRunner。
这里有个组装事实值得记:注册表、vm 沙箱、浏览器广播都属于 cordis-host-runner(ctx.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/connection 和 host/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.event 和 session.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-api、verify-client-catalog、verify-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-start、agent/pre-step、tools/pre-execute、tools/post-execute、agent/turn-stopping、subagent/start、subagent/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.md(ClientTransportHooks) |
| Typert 三包分工 | packages/typert/README.md |
ctx.typert 注册表 API |
packages/typert/registry/src/index.ts:19-25、packages/typert/registry/README.md |
TypertGatewayService |
packages/api/gateway/src/index.ts:90(invoke() :145,TypertGatewayError :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:53(initialize :111,shutdown :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:121(newSession :308,prompt :335,requestPermission :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:62(patchDualRealmInstanceof :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:196(new 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:83(SERVICE_API)、:2367(EVENT_API)、:5059(INHERITED_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:111(gen-cordis-api)、:112(verify-cordis-api)、:115(verify-client-catalog)、:132(doc-sync) |