DSH / Atlas
2026-06-20implementedarchitecturearchived 2026-09-04

Branded IDs everywhere they belong

在所有应有之处使用 branded ID

The harness brands `ToolCallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using `Branded<B> = string & { readonly [BRAND]: B }` and the stateless `brandString<T>()` constructor from `@deepseek-ai/dsh-brand` at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md). `dsh-brand` also states the governing policy: *"Bran

English

Problem

The harness brands ToolCallId (packages/llm/llm/src/brand.ts) and the shared agent/session SessionId (packages/core/session/src/types.ts) using Branded<B> = string & { readonly [BRAND]: B } and the stateless brandString<T>() constructor from @deepseek-ai/dsh-brand at packages/util/brand/ — see its README. dsh-brand also states the governing policy: "Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand." That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker.

Gap 1 — unbranded cross-boundary IDs in the bash seam. The background-job id is a plain string: BashTask.id: string (packages/shell/shell/src/types.ts), carried as string through the whole executor seam (ShellExecutor.get/ownerOf/readOutput/kill(id: string) in packages/shell/shell/src/index.ts) and validated/passed as string by the model-facing tools (validateJobId, assertTaskAccess, the job_id schema arg in packages/shell/tool-bash/src/index.ts). It is generated by a per-executor counter — `bash-${this.nextTaskId++}` in packages/shell/bash-local/src/index.ts — which gives it exactly the same name-N shape as SessionId's default (`session-${++counter}` in packages/core/session/src/index.ts). A bash job id and a session id are trivially swappable at a call site and the compiler says nothing. It is a model-facing id (the model passes job_id back to bash_output/bash_kill), so a confusion here is reachable from untrusted input.

The bash owner token is the related sub-case: ShellExecRequest.owner?: string and ShellExecSpec.owner: string | undefined (packages/shell/shell/src/types.ts) are documented as a deliberately opaque isolation key, but in every live caller the value IS the owning agent's shared Agent.id/SessionId (callerToken = (exec) => exec.agent?.id in packages/shell/tool-bash/src/index.ts) wearing a different seam-local name. It is compared for access control (owner !== callerToken(exec)), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the shared id alias covered by the unified agent/session identity decision.

Gap 2 — brand erosion at the boundaries of the already-branded IDs. Even ToolCallId and SessionId decay back to bare string at exactly the places confusion is most likely: registry/store key types and public method params. Representative sites include the session store, the agent registry (both keyed by the shared SessionId), tool-presentation call-id maps, ACP's session records, and the persistence coordinator. A brand that is dropped at a collection key buys nothing on lookups — the value of the existing brands is partly unrealized.

Decision

Brands remain ordinary strings; brandString<T>() returns its input unchanged, so serialization, comparison, and wire formats do not change. The decision has three parts, all honoring the existing "not every string" policy.

  • Brand the bash job id. Add BashTaskId = Branded<'BashTaskId'> in packages/shell/shell/src/types.ts (the package that owns the id), importing Branded and constructing values with brandString<BashTaskId>() from @deepseek-ai/dsh-brand. The brand utility exists so dsh-shell can brand its ids by depending on it alone — it never pulls in dsh-llm or dsh-session just to reach the primitive. Thread the type through BashTask.id, the ShellExecutor Service Definition methods (get/ownerOf/readOutput/kill), the generation site in dsh-bash-local, and the dsh-tool-bash validation/access surface.

  • Mint a distinct OwnerToken brand. Add OwnerToken = Branded<'OwnerToken'> in packages/shell/shell/src/types.ts; type ShellExecRequest.owner / ShellExecSpec.owner / ShellExecutor.ownerOf as OwnerToken | undefined. The dsh-tool-bash consumer applies brandString<OwnerToken>() to the agent's shared id (SessionId) at the one place the two vocabularies meet. The bash Service Definition never imports dsh-session. (Rationale in the next section.)

  • Stop the brand erosion. Propagate the existing brands to the Map key types and public method params listed under Gap 2 — Map<SessionId, Session>, Map<SessionId, Agent>, get(id: SessionId), Map<ToolCallId, …>, ACP's SessionId surface, and the coordinator's Map<SessionId, …>. This is the larger mechanical share of the change and the part that makes the existing brands actually load-bearing on lookups, not just on struct fields.

Illustrative shape:

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

/** A background bash task handle (generated `bash-N` by the local executor). */
export type BashTaskId = Branded<'BashTaskId'>
const taskId = brandString<BashTaskId>('bash-1')

/** A bash task's opaque isolation key — the consumer's owner identity, NOT the bash seam's. */
export type OwnerToken = Branded<'OwnerToken'>
const owner = brandString<OwnerToken>('session-1')

Alternatives considered

Why not typing owner as SessionId?

The obvious shortcut is to type owner as SessionId directly — it always is one. We reject that. The bash executor seam is a capability seam (Service Definition dsh-shell, Service Provider dsh-bash-local, Consumer dsh-tool-bash) and its owner token is documented as deliberately opaque: the executor "never interprets it (no access policy lives in the seam — that is the consumer's job)" (packages/shell/shell/src/types.ts). Typing the Service Definition's field as SessionId would import dsh-session's vocabulary into a package that must not know what an owner token means — it would couple a generic execution backend to the session model and contradict the opaque-token design. A sandboxed or remote executor that replaces dsh-bash-local should not inherit a session dependency. The distinct OwnerToken brand keeps the seam decoupled: dsh-shell knows only "an owner is some opaque branded token," and the dsh-tool-bash consumer — which already decides the access policy — is the single boundary that applies brandString<OwnerToken>() to its SessionId. The brand still delivers the safety win (you cannot pass a BashTaskId or a raw string where an owner is expected) without the coupling.

Out of scope / possible extensions

Kept deliberately narrow per the "not every string needs a brand" policy. Each of these is a plausible future brand, deferred with a reason, not a commitment:

  • ModelId (GenerateOptions.model, the LlmRuntime adapter-registry key) — a real cross-package lookup key (config → agent → llm → adapter); a reasonable next brand, left out only to keep this decision's blast radius focused.
  • ToolName (the ToolRuntime key) — author-defined, human-readable, and rarely confused with another id; the weakest candidate, likely not worth a brand.
  • ErrorCode (HarnessError.code) — a closed vocabulary (ABORTED, NO_ADAPTER, …), not a per-instance id; better served by a string-literal union than a brand, if anything.
  • Other numeric ordinals — the Session sequence and log-offset decision brands event identities and log gaps because they cross persistence and reference seams. Turn and step numbers remain plain numbers: they are payload-local ordinals and are not interchangeable with Session event positions.
  • Validated construction — brandString<T>() performs no runtime check, and every boundary (ACP sessionId, provider-issued call.id, the empty-string fallback in dsh-llm-deepseek) trusts the raw string. A SessionId.parse() / isValid() companion that throws on malformed input at boundaries is a genuine gap, but it is a runtime-behavior change with its own design (what is "malformed"? what happens on failure?) and belongs in its own decision.

Verification

The landed invariants: BashTaskId and OwnerToken are defined in dsh-shell and threaded end-to-end (Service Definition, the dsh-bash-local generation site, the dsh-tool-bash model-facing tool) with no dsh-shell dependency on dsh-session; no collection keyed by an in-scope branded id (ToolCallId/SessionId/BashTaskId) is keyed by bare string; public method params and exported signatures keep the brand; and boundaries where raw strings enter use brandString<T>() rather than scattered as casts.

Consequences

  • Mechanical churn across two surfaces. Propagating brands touches the bash seam (Service Definition + Service Provider + Consumer) and the ACP session-id surface plus the persistence coordinator. The churn is broad but low-severity: a missed site is a compile error, not a silent bug. Construction returns the same runtime string, so there is no snapshot or e2e behavioral diff. It sits next to the unified agent/session identity decision because both touch the session-id / owner-token boundary; OwnerToken stays distinct from the unified id for the decoupling reason above.
  • Brands do not validate. A brand is a confusability guard, not a correctness proof: a wrong session id that is still a well-formed string passes the type checker exactly as before. This decision does not close that gap (see Out of scope) — it only stops the category error of passing the wrong kind of id.
  • The "where to stop" line stays a judgment call. Branding BashTaskId but not ToolName, OwnerToken but not ModelId, is a taste call about which strings "could plausibly be confused." Reasonable reviewers may want more or fewer; the policy in brand.ts is the tie-breaker, and this decision errs toward the ids that are model-facing or used for access control.

中文

问题

harness 使用 Branded<B> = string & { readonly [BRAND]: B } 以及 @deepseek-ai/dsh-brand 中的无状态 brandString<T>() 构造函数,为 ToolCallId(packages/llm/llm/src/brand.ts)和 agent(智能体)/会话共享的 SessionId(packages/core/session/src/types.ts)做 brand 处理;该包位于 packages/util/brand/,见其 README。dsh-brand 还声明了治理策略:「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 仍能通过类型检查器。

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

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

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

决策

Brand 仍是普通字符串;brandString<T>() 原样返回输入,因此序列化、比较与协议格式(wire format)均不改变。该决策分三部分,全部遵循既有的「不是每个 string 都需要」策略。

  • 为 bash job id 加 brand。 在 packages/shell/shell/src/types.ts(拥有该 id 的包)中添加 BashTaskId = Branded<'BashTaskId'>,从 @deepseek-ai/dsh-brand 导入 Branded 并用 brandString<BashTaskId>() 构造值。brand 工具包让 dsh-shell 只依赖它就能为自己的 id 加 brand,而无需为了原语引入 dsh-llm 或 dsh-session。将该类型贯穿 BashTask.id、ShellExecutor Service Definition 方法(get/ownerOf/readOutput/kill)、dsh-bash-local 中的生成点,以及 dsh-tool-bash 的校验/访问面。

  • 铸造独立的 OwnerToken brand。 在 packages/shell/shell/src/types.ts 中添加 OwnerToken = Branded<'OwnerToken'>;将 ShellExecRequest.owner / ShellExecSpec.owner / ShellExecutor.ownerOf 的类型标注为 OwnerToken | undefined。dsh-tool-bash 消费方在两套词汇唯一交汇的位置,对 agent 共享的 id(SessionId)应用 brandString<OwnerToken>()。bash Service Definition 从不导入 dsh-session。(理由见下一节。)

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

示意形状:

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

/** A background bash task handle (generated `bash-N` by the local executor). */
export type BashTaskId = Branded<'BashTaskId'>
const taskId = brandString<BashTaskId>('bash-1')

/** A bash task's opaque isolation key — the consumer's owner identity, NOT the bash seam's. */
export type OwnerToken = Branded<'OwnerToken'>
const owner = brandString<OwnerToken>('session-1')

曾考虑的替代方案

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

显而易见的捷径是直接把 owner 类型标注为 SessionId——它确实总是一个会话 id。我们否决这个方案。bash 执行器 seam 是能力 seam(Service 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 消费方,是把 brandString<OwnerToken>() 应用于其 SessionId 的唯一边界。该 brand 仍带来安全收益(不能把 BashTaskId 或裸 string 传到 owner 位置),且不引入耦合。

不在范围内 / 可能的扩展

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

  • ModelId(GenerateOptions.model,LlmRuntime 适配器注册表的键):一个真正的跨包查找键(config → agent → llm → 适配器);合理的下一个 brand,仅为控制本决策的影响范围而暂不纳入。
  • ToolName(ToolRuntime 的键):由作者定义、人类可读,且很少与其他 id 混淆;最弱的候选,可能不值得加 brand。
  • ErrorCode(HarnessError.code):一个封闭词汇(ABORTED、NO_ADAPTER……),不是逐实例的 id;如果要做,string 字面量联合类型比 brand 更合适。
  • 其他数值序号:Session 序列号与日志偏移决策会为事件身份与日志间隙加 brand,因为它们跨越 persistence 与引用 seam。turn 与 step number 保持普通 number:它们是 payload-local ordinal,不会与 Session 事件位置互换。
  • 带校验的构造:brandString<T>() 不执行运行时检查,且每个边界(ACP sessionId、提供方签发的 call.id、dsh-llm-deepseek 中的空字符串回退)都信任裸 string。一个在边界处对格式错误的输入抛异常的 SessionId.parse() / isValid() 配套工具确实是缺口,但它属于运行时行为变更,有自己的设计问题(什么算「格式错误」?失败时会怎样?),应在独立决策中处理。

验证

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

后果

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