Files
deepseek-harness/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md

10 KiB
Raw Blame History

Agent Note: 在所有应有之处使用 branded ID

Status: implemented

English | 中文

问题

harness 使用 Branded<B> = string & { readonly [BRAND]: B } 机制,为 CallIdpackages/llm/llm/src/brand.ts)和 agent智能体/会话共享的 SessionIdpackages/core/session/src/types.ts)做 brand 处理;该机制由纯类型包 @deepseek-ai/dsh-brand 拥有,位于 packages/util/brand/,见其 README,并为每个类型提供零开销的 cast 工厂。dsh-brand 还声明了治理策略:「Branding 用于跨包边界且可能被混淆的 id不是每个 string 都需要 brand。」 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。

缺口 1bash seam 中未 brand 的跨边界 ID。 后台 job id 是普通 stringBashTask.id: stringpackages/shell/shell/src/types.ts),作为 string 贯穿整个执行器 seampackages/shell/shell/src/index.ts 中的 ShellExecutor.get/ownerOf/readOutput/kill(id: string)),再由面向模型的工具以 string 校验并传递(validateJobIdassertTaskAccesspackages/shell/tool-bash/src/index.tsjob_id 的 schema 参数)。它由每执行器计数器生成——packages/shell/bash-local/src/index.ts 中的 `bash-${this.nextTaskId++}`——其形状与 SessionId 的默认值完全相同,都是 name-Npackages/core/session/src/index.ts 中的 `session-${++counter}`。bash job id 和会话 id 在调用点轻易就能互换,而编译器毫无反应。它是面向模型的 id模型会把 job_id 传回 bash_output/bash_kill),所以该混淆可由不受信任的输入触达。

bash owner token 是相关的子情形:ShellExecRequest.owner?: stringShellExecSpec.owner: string | undefinedpackages/shell/shell/src/types.ts)被文档描述为刻意不透明的隔离键,但在所有实际调用方中,该值就是所属 agent 共享的 Agent.id/SessionIdcallerToken = (exec) => exec.agent?.id,位于 packages/shell/tool-bash/src/index.ts),只是披着另一个 seam 本地名称。它被用于访问控制比较(owner !== callerToken(exec)),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug而当前类型系统无法捕获。这正是统一 agent/session 标识决策覆盖的共享 id 别名。

缺口 2已经 brand 的 ID 在边界处被侵蚀。 就连 CallIdSessionId 也恰好在最容易混淆的地方退化为裸 string:注册表/store 键类型和公开方法参数。代表性位置包括会话存储、agent 注册表(二者都以共享的 SessionId 为键)、工具展示层的 call-id map、ACPAgent Client Protocol的会话记录以及持久化协调器。在集合键处丢弃 brand会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。

决策

纯类型变更。Brand 是零开销 cast运行时行为、序列化、比较和协议格式wire format均不变。该决策分三部分全部遵循既有的「不是每个 string 都需要」策略。

  • 为 bash job id 加 brand。packages/shell/shell/src/types.ts拥有该 id 的包)中添加 BashTaskId = Branded<'BashTaskId'> 及其同名工厂,从 @deepseek-ai/dsh-brand 导入 Branded,方式与 SessionId 完全一致。brand 原语位于无依赖的 dsh-brand 工具包中,正是为了让 dsh-shell 仅依赖它就能为自己的 id 加 brand而无需引入 dsh-llm(或 dsh-session)来获取 Branded。将其贯穿 BashTask.idShellExecutor Service Definition 方法(get/ownerOf/readOutput/kill)、dsh-bash-local 中的生成点(在创建时对计数器输出做一次 brand以及 dsh-tool-bash 的校验/访问面(validateJobId 返回 BashTaskIdjob_id 在模型 string 到达的工具边界处被 brand

  • 铸造独立的 OwnerToken brand。packages/shell/shell/src/types.ts 中添加 OwnerToken = Branded<'OwnerToken'>;将 ShellExecRequest.owner / ShellExecSpec.owner / ShellExecutor.ownerOf 的类型标注为 OwnerToken | undefineddsh-tool-bash 消费方在边界处将 agent 共享的 idSessionIdcast 为 OwnerToken——这是两套词汇唯一交汇的地方。bash Service Definition 从不导入 dsh-session。(理由见下一节。)

  • 阻止 brand 侵蚀。 将既有 brand 传播到缺口 2 列出的 Map 键类型和公开方法参数中:Map<SessionId, Session>Map<SessionId, Agent>get(id: SessionId)Map<CallId, …>、ACP 的 SessionId surface、协调器的 Map<SessionId, …>。这是变更中机械量最大的部分,也是让既有 brand 在查找处真正发挥作用(而不仅仅标注在结构体字段上)的关键。

示意形状(工厂模式与已有的三个 brand 完全一致):

import type { Branded } from '@deepseek-ai/dsh-brand'

/** A background bash task handle (generated `bash-N` by the local executor). */
export type BashTaskId = Branded<'BashTaskId'>
export function BashTaskId(id: string): BashTaskId {
  return id as BashTaskId
}

/** A bash task's opaque isolation key — the consumer's owner identity, NOT the bash seam's. */
export type OwnerToken = Branded<'OwnerToken'>
export function OwnerToken(id: string): OwnerToken {
  return id as OwnerToken
}

曾考虑的替代方案

为什么不把 owner 类型标注为 SessionId

显而易见的捷径是直接把 owner 类型标注为 SessionId——它确实总是一个会话 id。我们否决这个方案。bash 执行器 seam 是能力 seamService Definition dsh-shell、Service Provider dsh-bash-local、Consumer dsh-tool-bash),其 owner token 被明确记录为刻意不透明执行器「从不解释它seam 中没有访问策略——那是消费方的职责)」(packages/shell/shell/src/types.ts)。把 Service Definition 的字段类型标注为 SessionId,会把 dsh-session 的词汇引入一个不应知道 owner token 含义的包——这会让通用执行后端耦合会话模型,并违背不透明 token 的设计。取代 dsh-bash-local 的沙箱化执行器或远程执行器不应继承会话依赖。独立的 OwnerToken brand 使 seam 保持解耦:dsh-shell 只知道「owner 是某种带 brand 的不透明 token」而已经决定访问策略的 dsh-tool-bash 消费方,是把其 SessionId cast 为 OwnerToken 的唯一边界。该 brand 仍带来安全收益(不能把 BashTaskId 或裸 string 传到 owner 位置),且不引入耦合。

不在范围内 / 可能的扩展

遵循「不是每个 string 都需要 brand」的策略刻意保持窄范围。以下每项都是合理的未来 brand 候选,附带推迟理由而非承诺:

  • ModelIdGenerateOptions.modelLlmRuntime 适配器注册表的键一个真正的跨包查找键config → agent → llm → 适配器);合理的下一个 brand仅为控制本决策的影响范围而暂不纳入。
  • ToolNameToolRuntime 的键):由作者定义、人类可读,且很少与其他 id 混淆;最弱的候选,可能不值得加 brand。
  • ErrorCodeHarnessError.code):一个封闭词汇(ABORTEDNO_ADAPTER……),不是逐实例的 id如果要做string 字面量联合类型比 brand 更合适。
  • 数值序号:轮次号、步骤号和事件 seqnumber 而非 stringBranded<string> 不适用;可以用并行的 number & { readonly [BRAND]: B } 变体来 brand 它们,但它们是位置序号、很少跨边界传递,收益较低。
  • 带校验的构造brand 工厂是纯 cast无运行时检查且每个边界ACP sessionId、提供方签发的 call.iddsh-llm-deepseek 中的空字符串回退)今天都信任裸 string。一个在边界处对格式错误的输入抛异常的 SessionId.parse() / isValid() 配套工具确实是缺口,但它是运行时行为变更,有自己的设计问题(什么算「格式错误」?失败时会怎样?),应在独立决策中处理,不应捆绑进这次纯类型变更。

验证

已落地的不变式如下:BashTaskIdOwnerToken 定义在 dsh-shell 中,并端到端贯穿 Service Definition、dsh-bash-local 生成点与 dsh-tool-bash 面向模型的工具,且 dsh-shell 未添加对 dsh-session 的依赖;没有任何以范围内 brand idCallId/SessionId/BashTaskId)为键的集合使用裸 string;公开方法参数和导出签名保留 brand每个原始 string 进入的边界(提供方 call id、ACP 会话 id、模型提供的 job_id)都通过 cast 工厂构造 brand而不是散落的 as cast。

后果

  • 两个接口面的机械性改动。 传播 brand 涉及 bash seamService Definition + Service Provider + Consumer以及 ACP 会话 id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。从可观察行为看这是一项纯类型变更——无快照或 e2e 行为差异。它与统一 agent/会话标识决策相邻,因为二者都触及会话 id / owner-token 边界;OwnerToken 出于上述解耦理由仍与统一后的 id 保持独立。
  • Brand 不做校验。 Brand 是混淆防护,不是正确性证明:一个错误的会话 id 只要仍是格式正确的 string就和以前一样能通过类型检查器。本决策不关闭这个缺口见「不在范围内」——它只阻止这类类别错误:传入错误种类的 id。
  • 「在哪里停下」仍是判断题。BashTaskId 加 brand 但不为 ToolName 加,为 OwnerToken 加但不为 ModelId 加,是对哪些 string「可能被混淆」的品味判断。合理的评审者可能想要更多或更少brand.ts 中的策略是裁决依据,本决策倾向于面向模型或用于访问控制的 id。