Merge remote-tracking branch 'origin/master' into worktree/command-delete-as-whole-21017f
# Conflicts: # packages/client/ui-conversation/README.i18n.yaml
This commit is contained in:
commit
2182e3ad09
1913 files changed
+71923
-12722
No files matched your search
+2
-2
@@ -18,5 +18,5 @@
|
||||
en: fa503dcfaa6f438f
|
||||
zh: 26052212a124e133
|
||||
/agent-note-compiler-independent-typert-type-model/consequences:
|
||||
en: 4a472acf377f8361
|
||||
zh: cb83602b123a4aae
|
||||
en: e1022cf9523fb28e
|
||||
zh: 01dfb764fb98de0d
|
||||
@@ -51,3 +51,5 @@ For each supported node kind and literal category, Zod emitter tests run both su
|
||||
New generation targets and static checks can reuse the same TypeGraph, and business categories can extend PackageModel without parsing the AST again. Preserving pre-evaluation types and independent faces makes the model more complex than a flattened schema; emitters must explicitly declare their supported scope and fail on missing capabilities.
|
||||
|
||||
Explicit package opt-in keeps artifact publication and exports under package ownership. Repository orchestration may still run the whole-workspace Host contract pass for every opted-in package; that pass remains owned by the later Remote Gateway Agent Note. The static Cordis catalogs remain reproducible from the canonical model without coupling `tool-cordis` to runtime registry state. `ctx.typert` reflects only artifacts mounted in the current runtime, and unloading does not control Zod instances that consumers retain after materializing them.
|
||||
|
||||
Ordinary Host and Remote artifacts reuse this model to emit one static method spec per endpoint. Each parameter has normal `codec` and `strictCodec` variants derived from the same checker-resolved type model; neither analysis nor the model carries a per-method input policy. A pure factory returns a lazy info instance whose direct method index and descriptor list share objects; the existing Registry retains those descriptors, while `TYPERT_REMOTE.methods` supplies exact static types. The [unified Typert/Remote metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.md) records this delivery mechanism and the incomplete downstream adaptation. Declaration and face-isolation rules remain unchanged; separate Programs and processes do not share object identity.
|
||||
+2
@@ -51,3 +51,5 @@ Zod emitter 对支持的节点和各类 literal 逐类执行成功与失败 pars
|
||||
新增生成目标或静态检查可复用同一 TypeGraph,业务类目也可在 PackageModel 上扩展,而无需再次解析 AST。保留计算前类型和独立 face 的代价是模型比打平后的 schema 更复杂,emitter 必须显式声明支持范围并对缺失能力失败。
|
||||
|
||||
包级显式 opt-in 使产物发布与 exports 由各包自行管理。仓库编排仍可为每个已 opt-in 的包运行全仓 Host 约定 pass;该 pass 仍由后续 Remote Gateway Agent Note 负责说明。静态 Cordis catalog 可从标准模型复现,同时不把 `tool-cordis` 与运行时注册表状态耦合。`ctx.typert` 只反映当前运行时中已挂载的产物;对于消费方物化后仍持有的 Zod 实例,卸载流程无法控制。
|
||||
|
||||
普通 Host 与 Remote 产物复用此模型,为每个端点生成一份静态方法 spec。每个参数都有从同一 checker 解析后类型模型派生的普通 `codec` 和 `strictCodec` 变体;分析过程与模型均不携带逐方法输入策略。纯 factory 返回惰性 info 实例,其直接方法索引与描述符列表共享对象;既有 Registry 保留这些描述符,而 `TYPERT_REMOTE.methods` 提供精确静态类型。[统一 Typert/Remote 元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md)记录该交付机制及尚未完成的下游适配。声明与编译面隔离规则保持不变;独立 Program 和进程不共享对象身份。
|
||||
+12
-12
@@ -12,14 +12,14 @@
|
||||
en: 44d946b53496974c
|
||||
zh: 403447c25a5b2d7c
|
||||
/agent-note-typert-gateway-targeted-method-calls/components-and-cordis-services:
|
||||
en: aa7f509189ade32e
|
||||
zh: ef2841bc793f31b2
|
||||
en: 02c20706e48467e6
|
||||
zh: 2b0916ae0cee351b
|
||||
/agent-note-typert-gateway-targeted-method-calls/business-declarations:
|
||||
en: c6b70d442b137d2e
|
||||
zh: 6d0dbefe89937ef1
|
||||
en: b9394d5ef98cc82a
|
||||
zh: 1fce07fe150722ee
|
||||
/agent-note-typert-gateway-targeted-method-calls/decorators-and-the-explicit-gateway-facet:
|
||||
en: 155afbe4b8af600b
|
||||
zh: 14705698a9cdbd84
|
||||
en: 161dccf2b4cf565d
|
||||
zh: fa587fe0b46bf551
|
||||
/agent-note-typert-gateway-targeted-method-calls/lookup-and-remote-scope-registration:
|
||||
en: 9f7448898cf5cadc
|
||||
zh: 419573c589ed6f7d
|
||||
@@ -33,8 +33,8 @@
|
||||
en: a244f6a88fdabf3d
|
||||
zh: e4feedb7297f4865
|
||||
/agent-note-typert-gateway-targeted-method-calls/three-artifact-kinds-and-two-typescript-programs:
|
||||
en: 6ea4bddcf35a834d
|
||||
zh: 4af474c6818b1cf0
|
||||
en: c5bcd6eb76bf3d42
|
||||
zh: 9df2fbb364643186
|
||||
/agent-note-typert-gateway-targeted-method-calls/the-remote-package-entry:
|
||||
en: 75e977fe0fb71d59
|
||||
zh: 00760c79da173cb7
|
||||
@@ -48,8 +48,8 @@
|
||||
en: 5d14e1b7afc53efb
|
||||
zh: e2f4b5814083738f
|
||||
/agent-note-typert-gateway-targeted-method-calls/src-and-lib-operating-modes:
|
||||
en: 1e366dcfab74831b
|
||||
zh: 14199f25f20a9352
|
||||
en: f1b8426d1cfaed35
|
||||
zh: a370376923c98d6e
|
||||
/agent-note-typert-gateway-targeted-method-calls/host-gateway-resolution:
|
||||
en: 88a95d7ab4621caf
|
||||
zh: 89b63fb6440fca79
|
||||
@@ -66,8 +66,8 @@
|
||||
en: b1c0c3dd77c56ce8
|
||||
zh: 769ffd4aeefd949c
|
||||
/agent-note-typert-gateway-targeted-method-calls/alternatives-considered:
|
||||
en: 0d68c499b7780f4f
|
||||
zh: bdd1e57232b996e2
|
||||
en: eaa3ffe5ca14005c
|
||||
zh: 1f0b455e30ade872
|
||||
/agent-note-typert-gateway-targeted-method-calls/verification:
|
||||
en: 7433bdb522bb2083
|
||||
zh: 545c17613b636f00
|
||||
|
||||
@@ -28,7 +28,7 @@ The Remote consumer projection contains `.d.ts`, `.d.ts.map`, and `.js` files. T
|
||||
|
||||
| Component | Cordis service | Responsibility |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-typert-protocol` | Declares only the minimal `ctx.typert` protocol | `TypertRemoteService`, decorators, binding fallback, descriptors, lookup/Context, and the Remote map; no dependency on the compiler, Zod, Connection, or Browser |
|
||||
| `@deepseek-ai/dsh-typert-protocol` | Declares only the minimal `ctx.typert` protocol | `TypertRemoteService`, decorators, binding fallback, descriptors, lookup/Context, the Remote map, and Zod-backed lazy info assembly; no dependency on the compiler, Connection, or Browser |
|
||||
| Typert registry | `ctx.typert` | Separately stores reflection for the current environment, imported Remote contributions, lookup providers, and Context providers |
|
||||
| Typert generator/loader | No new business service | Generates three kinds of `lib` artifacts from the Host/Client Programs and registers the current environment's artifacts with `ctx.typert` |
|
||||
| API Gateway's Host face | `ctx.typertGateway` | Associates Host definitions with live Services, decodes parameters, resolves receivers, invokes methods, and projects unary results into carrier-neutral fields and attachments |
|
||||
@@ -81,16 +81,18 @@ export class ScopedGoalService extends TypertRemoteService {
|
||||
|
||||
An endpoint selects exactly one invocation mode. A flow that needs an explicit `Agent` parameter uses `@Remote`. A flow that first switches to an Agent Context and then resolves a scoped receiver uses `@RemoteScope('agent')`. Typert does not infer either mode from the method body or from a missing parameter.
|
||||
|
||||
Business packages depend only on the lightweight `@deepseek-ai/dsh-typert-protocol`. It provides `TypertRemoteService` and declaration protocols for decorators, the binding fallback, lookup, Remote Scope, and descriptors, without depending on the TypeScript compiler, Zod, HTTP, or the Client runtime.
|
||||
Business packages depend only on `@deepseek-ai/dsh-typert-protocol`. It provides `TypertRemoteService` and declaration protocols for decorators, the binding fallback, lookup, Remote Scope, and descriptors, without depending on the TypeScript compiler, HTTP, or the Client runtime. Its pure `./remote-info` entry uses Zod to assemble lazy named-input schemas without importing Cordis; the root entry also re-exports it alongside Cordis-backed declarations.
|
||||
|
||||
A method that cooperatively supports cancellation declares `signal: AbortSignal` as its final Host parameter. This reserved parameter is not a business value, lookup, or JSON field. The generated consumer method exposes it as a final optional parameter so ordinary calls remain unchanged while callers that own cancellation can pass a signal.
|
||||
|
||||
## Decorators and the explicit Gateway facet
|
||||
|
||||
A decorator only states that a method participates in the Remote contract. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteScope('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `TypertRemoteService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertGateway` field keeps the binding visible on the runtime instance.
|
||||
A decorator states Remote participation, an optional export name, and stream delivery; `@Remote` options contain only `name` and `mode`. It performs no runtime type reflection and injects no hidden symbol into a Service constructor. The arguments to `@Remote('create')` and `@RemoteScope('agent', 'create')` are external method names; the decorated member may be the business method itself or an adapter such as `remoteExportCreate`. The member name becomes the external method name only when no alias is provided. Inheriting `TypertRemoteService` is the normal explicit declaration that a Service has joined the Gateway; its public readonly `typertRemote` field keeps the binding visible on the runtime instance.
|
||||
|
||||
In SRC mode, the decorator records the method name and invocation mode in a versioned descriptor on the Service prototype. The descriptor uses a stable string property name, so `remoteMethods()` can read markers produced by another installed copy of `dsh-typert-protocol`; it writes nothing to the Service instance, constructor, or method function.
|
||||
|
||||
Ordinary artifacts construct a lazy Remote info instance from one static method spec per endpoint. Its direct `TypertRemoteMethod` objects retain descriptor fields and add named `input(options?)` and `invoke`. Consumers select matching strict parsing and projection with `input({ strict: true })`; `input()` preserves normal handling. Host parameter codecs and the single normal `result` codec are unchanged. Existing Registry queries retain those objects, while `TYPERT_REMOTE.methods` supplies exact static types. Strict source startup prebuilds and loads ordinary Typert artifacts; fallback cannot recover erased schemas. The [unified metadata note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.md) records this delivery mechanism and the incomplete consumer adaptation.
|
||||
|
||||
In LIB mode, the Typert compiler performs strict method discovery, type resolution, and descriptor generation. It accepts a literal service key in `TypertRemoteService`'s direct `super()` call or the explicit binding fallback; generation neither rewrites business source nor injects hidden registration metadata.
|
||||
|
||||
## Lookup and Remote Scope registration
|
||||
@@ -215,7 +217,7 @@ Host lib build
|
||||
→ Vite 构建 Web
|
||||
```
|
||||
|
||||
The existing top-level `build` still runs `build:lib` before `build:web`, but `build:lib` must complete the Host and Remote artifacts before starting Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build.
|
||||
The top-level `build` runs the step graph in `scripts/build.ts`, which completes the Host bundle and the Remote artifacts before starting Client TypeScript compilation. `build:lib` remains the complete serial lib build for `build:bench`, and its Host half `build:lib:host` serves the vendor release workflows. `typecheck` writes the Remote artifacts through `scripts/generate-typert.ts`, beside Host compilation, before Client TypeScript compilation. A clean build must not depend on stale `.d.ts` files from an earlier build.
|
||||
|
||||
Compiler-backed repository gates that resolve the consumer surface have the same prerequisite even when their primary inputs are source files. The public `typecheck`, `lint`, and `doc-typecheck` commands run the Host contract pass first. The gate scheduler may use their `*:contracts-ready` variants only after an explicit Typert-contract or complete-build dependency, so parallel lanes neither read missing declarations nor run concurrent generators against the same outputs.
|
||||
|
||||
@@ -349,9 +351,9 @@ The Web already depends on build artifacts such as `lib/client.js`, so it requir
|
||||
|
||||
## SRC and LIB operating modes
|
||||
|
||||
SRC supports local source startup. The versioned prototype descriptors created by `@Remote` and `@RemoteScope()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor.
|
||||
SRC supports local source startup. For methods without registered strict definitions, the versioned prototype descriptors created by `@Remote` and `@RemoteScope()` provide method names and invocation modes. At runtime, the system reads ordered parameter names from the JavaScript function signature and combines them with registered lookup/Context providers to produce a permissive descriptor.
|
||||
|
||||
For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. SRC does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object.
|
||||
For example, `@Remote('create') remoteExportCreate(agent, request, signal)` resolves to the external method `create`, implementation member `remoteExportCreate`, two top-level business parameters, and one cancellation injection point. Lookup registration rewrites `agent` to the wire field `agentId`, `request` is passed as a same-named JSON parameter, and the final `signal` stays outside the payload. The conservative fallback does not start a `ts.Program`, use a preload or loader hook, generate or rewrite source, or inspect the internal structure of an ordinary JSON object.
|
||||
|
||||
A signature that SRC cannot resolve unambiguously fails on the first invocation that resolves its descriptor; Service mounting records only the decorator marker and does not inspect the JavaScript signature. SRC does not guess at object destructuring, ambiguity caused by default parameters, rest parameters, nested lookups, or complex types.
|
||||
|
||||
@@ -474,7 +476,7 @@ The package topology is `api/remotes → api/gateway → client/connection → h
|
||||
|
||||
**Perform strict reflection through decorators at runtime.** JavaScript decorators cannot recover erased TypeScript types, public symbol identity, or complete Zod codecs. Injecting a compiler-private symbol into a constructor would also hide the business class's real dependencies, so Typert generates strict information at compile time.
|
||||
|
||||
**Use a preload, loader hook, or complete `ts.Program` during SRC startup.** This could reuse LIB analysis but would add requirements to every source startup entry. SRC needs only a usable permissive descriptor, so it uses decorator markers, function parameter names, and explicit providers; strict checks remain in the LIB contract pass.
|
||||
**Use a preload, loader hook, or complete `ts.Program` during SRC startup.** This could reuse LIB analysis but would add requirements to every source startup entry. Source startup can load ordinary prebuilt artifacts or use conservative markers without loading a TypeScript Program.
|
||||
|
||||
**Hand-write the Client interface.** A hand-written interface cannot guarantee that it contains only Remote-marked methods and can drift from Host signatures, lookup IDs, and Zod schemas. Client types are therefore projected automatically from the Host Program.
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Remote 消费端投影同时包含 `.d.ts`、`.d.ts.map` 和 `.js`。`.d.ts` 只
|
||||
|
||||
| 组件 | Cordis 服务 | 职责 |
|
||||
|---|---|---|
|
||||
| `@deepseek-ai/dsh-typert-protocol` | 只声明 `ctx.typert` 的最小协议 | `TypertRemoteService`、decorator、binding 回退、descriptor、lookup/Context 和 Remote map;不依赖 compiler、Zod、Connection 或 Browser |
|
||||
| `@deepseek-ai/dsh-typert-protocol` | 只声明 `ctx.typert` 的最小协议 | `TypertRemoteService`、decorator、binding 回退、descriptor、lookup/Context、Remote map 和基于 Zod 的惰性 info 组装;不依赖 compiler、Connection 或 Browser |
|
||||
| Typert registry | `ctx.typert` | 分开保存当前环境 reflection、导入的 Remote contribution、lookup provider 和 Context provider |
|
||||
| Typert generator/loader | 无新增业务服务 | 从 Host/Client Program 生成三类 `lib` 产物,并把当前环境产物注册到 `ctx.typert` |
|
||||
| API Gateway 的 Host face | `ctx.typertGateway` | 关联 Host definition 与活 Service,解码参数、解析 receiver、调用方法,并将一元结果投影为载体无关的字段与附件 |
|
||||
@@ -81,16 +81,18 @@ export class ScopedGoalService extends TypertRemoteService {
|
||||
|
||||
同一个 endpoint 只能选择一种调用模式。需要显式 `Agent` 参数的流程使用 `@Remote`;需要切换到 Agent Context 再解析 scoped receiver 的流程使用 `@RemoteScope('agent')`,两者不会由 Typert 根据方法体或参数缺失自动猜测。
|
||||
|
||||
业务包只依赖轻量的 `@deepseek-ai/dsh-typert-protocol`。它提供 `TypertRemoteService`,以及 decorator、binding 回退、lookup、Remote Scope 和 descriptor 的声明协议,不依赖 TypeScript compiler、Zod、HTTP 或 Client runtime。
|
||||
业务包只依赖 `@deepseek-ai/dsh-typert-protocol`。它提供 `TypertRemoteService`,以及 decorator、binding 回退、lookup、Remote Scope 和 descriptor 的声明协议,不依赖 TypeScript compiler、HTTP 或 Client runtime。其纯 `./remote-info` 入口使用 Zod 组装惰性具名输入 schema,不导入 Cordis;根入口在提供依赖 Cordis 的声明之余,也重新导出该能力。
|
||||
|
||||
支持协作式取消的方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。这个保留参数不是业务值、lookup 或 JSON 字段。生成的消费方方法将其暴露为最后一个可选参数,因此普通调用保持不变,而拥有取消控制权的调用方可以传入 signal。
|
||||
|
||||
## Decorator 与显式 Gateway facet
|
||||
|
||||
Decorator 只表达“该方法参与 Remote 约定”,不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteScope('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `TypertRemoteService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertGateway` 字段使运行时实例上的绑定保持可见。
|
||||
Decorator 表达 Remote 参与、可选导出名称和流式投递;`@Remote` 选项仅包含 `name` 和 `mode`。它不负责运行时类型反射,也不向 Service constructor 注入隐藏 symbol。`@Remote('create')` 和 `@RemoteScope('agent', 'create')` 的参数是外部方法名;被装饰成员既可以是业务方法本身,也可以是 `remoteExportCreate` 这样的适配器。未给别名时才使用成员名作为外部方法名。继承 `TypertRemoteService` 是 Service 加入 Gateway 的常规显式声明;其 public readonly `typertRemote` 字段使运行时实例上的绑定保持可见。
|
||||
|
||||
SRC 模式下,decorator 把方法名和调用模式记录在 Service prototype 上的带版本描述符中。描述符使用稳定的字符串属性名,因此 `remoteMethods()` 可以读取 `dsh-typert-protocol` 另一个已安装副本生成的标记;它不会向 Service 实例、constructor 或方法函数写入任何内容。
|
||||
|
||||
普通产物从每个端点唯一的静态方法 spec 构造惰性 Remote info 实例。其直接 `TypertRemoteMethod` 对象保留描述符字段,并增加具名 `input(options?)` 和 `invoke`。消费方通过 `input({ strict: true })` 选择配套的严格解析与投影;`input()` 保留普通处理。Host 参数 codec 与唯一的普通 `result` codec 保持不变。既有 Registry 查询保留这些对象,`TYPERT_REMOTE.methods` 则提供精确静态类型。严格源码启动预构建并加载普通 Typert 产物;fallback 无法恢复被擦除的 schema。[统一元数据 note](../../proposed/architecture/2026-09-20-unified-typert-remote-metadata.zh.md)记录该交付机制及尚未完成的消费方适配。
|
||||
|
||||
LIB 的严格方法发现、类型解析和 descriptor 生成由 Typert compiler 完成。它接受 `TypertRemoteService` 直接 `super()` 调用中的字面量 service key,或显式 binding 回退;生成过程不改写业务源码,也不注入隐藏注册元数据。
|
||||
|
||||
## Lookup 与 Remote Scope 注册
|
||||
@@ -215,7 +217,7 @@ Host lib build
|
||||
→ Vite 构建 Web
|
||||
```
|
||||
|
||||
现有顶层 `build` 仍表现为先 `build:lib`、再 `build:web`,但 `build:lib` 内部必须先完成 Host 与 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。
|
||||
顶层 `build` 运行 `scripts/build.ts` 中的步骤图,先完成 Host bundle 与 Remote artifact,再启动 Client TypeScript 编译。`build:lib` 仍是 `build:bench` 使用的完整串行 lib 构建,其 Host 部分 `build:lib:host` 供 vendor 发布 workflow 使用。`typecheck` 与 Host 编译并行,经 `scripts/generate-typert.ts` 写出 Remote artifact,再启动 Client TypeScript 编译。一次干净构建不能依赖上次残留的 `.d.ts`。
|
||||
|
||||
即使主要输入是源文件,需要通过编译器解析消费方 surface 的仓库门禁也有相同的前置条件。公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先执行 Host 约定 pass。门禁调度器仅可在显式的 Typert 约定依赖或完整构建依赖完成后使用对应的 `*:contracts-ready` 变体,使并行 lane 既不会读取缺失的声明,也不会针对同一输出并发运行多个生成器。
|
||||
|
||||
@@ -349,9 +351,9 @@ Web 本身依赖 `lib/client.js` 等构建产物,因此启动 Web 前要求完
|
||||
|
||||
## SRC 与 LIB 运行模式
|
||||
|
||||
SRC 面向本地源码启动。`@Remote` 和 `@RemoteScope()` 创建的带版本 prototype 描述符给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。
|
||||
SRC 面向本地源码启动。对于没有已注册严格定义的方法,`@Remote` 和 `@RemoteScope()` 创建的带版本 prototype 描述符给出方法名和调用模式,运行时从 JavaScript 函数签名读取顺序参数名,并结合已注册 lookup/Context provider 生成弱 descriptor。
|
||||
|
||||
例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。SRC 不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。
|
||||
例如 `@Remote('create') remoteExportCreate(agent, request, signal)` 解析为外部方法 `create`、实现成员 `remoteExportCreate`、两个顶层业务参数和一个取消注入点;lookup 注册把 `agent` 改写为 wire 字段 `agentId`,`request` 按同名 JSON 参数传递,最后一个 `signal` 则留在 payload 之外。保守回退不启动 `ts.Program`,不使用 preload、loader hook、源码生成或模块改写,也不检查普通 JSON 对象的内部结构。
|
||||
|
||||
SRC 无法明确解析的签名会在首次调用解析其 descriptor 时失败;Service 挂载只记录 decorator 标记,不检查 JavaScript 签名。SRC 不会猜测对象解构、默认参数造成的歧义、rest 参数、嵌套 lookup 或复杂类型。
|
||||
|
||||
@@ -474,7 +476,7 @@ Connection 提供共享 channel interceptor 与当前 HTTP carrier 映射。WebS
|
||||
|
||||
**让 decorator 在运行时完成严格反射。** JavaScript decorator 无法恢复擦除后的 TypeScript 类型、公共符号身份和完整 Zod codec;向 constructor 注入 compiler 私有 symbol 又会隐藏业务类的真实依赖,因此严格信息由 Typert compiler 生成。
|
||||
|
||||
**SRC 启动时使用 preload、loader hook 或完整 `ts.Program`。** 这能复用 LIB 分析,但增加所有源码启动入口的要求。SRC 只需要可用的弱 descriptor,因此采用 decorator 标记、函数参数名和显式 provider;严格检查留给 LIB 约定 pass。
|
||||
**SRC 启动时使用 preload、loader hook 或完整 `ts.Program`。** 这能复用 LIB 分析,但增加所有源码启动入口的要求。源码启动可加载普通预构建产物或使用保守标记,而无需加载 TypeScript Program。
|
||||
|
||||
**手写 Client interface。** 手写接口不能保证只包含 Remote 标记的方法,也会与 Host 签名、lookup ID 和 Zod schema 漂移,因此 Client 类型从 Host Program 自动投影。
|
||||
|
||||
|
||||
+2
-2
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md
|
||||
/agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide:
|
||||
en: 1f6ee603f36c2e09
|
||||
zh: e08197958663219c
|
||||
en: 35dea8a3a8bdc842
|
||||
zh: 7a1cdf184ffd889f
|
||||
/agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide/problem:
|
||||
en: d94bb81479bfa4a1
|
||||
zh: af44ceaecf84ee51
|
||||
|
||||
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
English | [中文](2026-08-04-configuration-source-ownership.zh.md)
|
||||
|
||||
The non-secret settings tier below is superseded by [profile-owned live configuration](2026-09-19-profile-owned-live-configuration.md). Credential ordering and environment restrictions remain current.
|
||||
The non-secret settings tier below is superseded by [profile-owned live configuration](2026-09-19-profile-owned-live-configuration.md), and the credential ordering by [stored credential precedence](2026-10-09-stored-credential-precedence.md). Environment restrictions remain current.
|
||||
|
||||
## Problem
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ Status: implemented
|
||||
|
||||
[English](2026-08-04-configuration-source-ownership.md) | 中文
|
||||
|
||||
下文的非秘密 settings 层已由 [profile 所属实时配置](2026-09-19-profile-owned-live-configuration.zh.md) 替代。凭据优先级与环境限制仍然适用。
|
||||
下文的非秘密 settings 层已由 [profile 所属实时配置](2026-09-19-profile-owned-live-configuration.zh.md) 替代,凭据优先级已由[存储凭据优先级](2026-10-09-stored-credential-precedence.zh.md)替代。环境限制仍然适用。
|
||||
|
||||
## Problem
|
||||
|
||||
|
||||
+8
-8
@@ -9,14 +9,14 @@
|
||||
en: 1fe344c95a0de36d
|
||||
zh: 656c3e0f1117f64e
|
||||
/agent-note-forked-children-preserve-the-parent-request-prefix/decision:
|
||||
en: b70aafc9ce30dd00
|
||||
zh: 74e3ff915bd968a9
|
||||
en: 3f5b3a4646c9466b
|
||||
zh: 690172d6a4025b2a
|
||||
/agent-note-forked-children-preserve-the-parent-request-prefix/alternatives-considered:
|
||||
en: 80504ffd8331e6c5
|
||||
zh: 193261bdde24b4bb
|
||||
en: bc437309b64997ab
|
||||
zh: 947010eed8d6fac4
|
||||
/agent-note-forked-children-preserve-the-parent-request-prefix/consequences:
|
||||
en: 0738d36f17c889b5
|
||||
zh: b71ee47f75d6d4e0
|
||||
en: 0f4d735328b3211d
|
||||
zh: db4adb0f46b33bba
|
||||
/agent-note-forked-children-preserve-the-parent-request-prefix/consequences/accepted-risks:
|
||||
en: f634caa9973eaedb
|
||||
zh: 820291096c8e7352
|
||||
en: 72f4913b603e8596
|
||||
zh: 50924c62200070d1
|
||||
@@ -18,7 +18,7 @@ The model-facing `send_message` tool is registered globally for every Agent in a
|
||||
|
||||
The base bundle and the `cordis`, `standard`, and `ptc` CLI presets bind fork to the continuable lifecycle. Base-backed profiles, including headless, inherit that default. `ForkInProcessProvider.prepareContinuable()` and `ctx.subagents.startActivation()` preserve the same request-prefix behavior across these compositions.
|
||||
|
||||
Byte-identical prefix reuse is qualified by explicit deployment choices. A fork delegation that applies a child persona or `toolFilter` may still change the request head. In particular, filtering out `send_message` removes both the schema and the return guidance from the child; the runtime does not bypass an explicit allow-list.
|
||||
Byte-identical prefix reuse depends on the assembled inputs. Structured output or generated SDK differences can change the request head. Historical child descriptors can also restore persona or tool restrictions during cold resume; filtering out `send_message` removes both its schema and return guidance. New fork starts inherit the parent preset without these override fields.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -28,11 +28,11 @@ Byte-identical prefix reuse is qualified by explicit deployment choices. A fork
|
||||
|
||||
**Add the return instruction to the system prompt.** This would place child-only bytes ahead of inherited messages. Appending it to the initial user task preserves the inherited prefix and keeps the parent id next to the task that needs it.
|
||||
|
||||
**Ignore an explicit child `toolFilter`.** Structural return tools previously bypassed the child allow-list. Rejected because a declared tool restriction must determine both schema visibility and guidance; hidden authority would make the model-facing roster inaccurate.
|
||||
**Ignore a historical child `toolFilter` on resume.** A stored restriction must continue to determine both schema visibility and guidance; silently discarding it changes the restored agent’s behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Parent and continuable-fork child expose byte-identical ordered tool schemas when the delegation does not request a persona or tool filter.
|
||||
- Parent and continuable-fork child expose byte-identical ordered tool schemas when they use the same preset and have no child-only structured output or restored historical overrides.
|
||||
- The inherited Session seed precedes the child's initial task and return guidance.
|
||||
- The base bundle and full CLI presets use local continuable fork for both parent and caller delivery, without a child-only request-head addition.
|
||||
- The child can send messages to its direct parent through `send_message`. Parent delivery adds a separate runtime settlement notice; caller delivery returns the result through the activation receipt.
|
||||
@@ -40,4 +40,4 @@ Byte-identical prefix reuse is qualified by explicit deployment choices. A fork
|
||||
|
||||
### Accepted risks
|
||||
|
||||
Provider-side prefix reuse still depends on the selected provider and model and on the absence of explicit persona or tool-filter differences. The harness proves equality of its assembled request-head inputs, not a provider's cache behavior.
|
||||
Provider-side prefix reuse still depends on the selected provider and model and matching assembled inputs. The harness tests equality of its assembled request-head inputs, not a provider’s cache behavior.
|
||||
@@ -18,7 +18,7 @@ fork 与 spawn 的差异在于:fork 会用 parent 已完成轮次的前缀作
|
||||
|
||||
base 组合包与 `cordis`、`standard` 和 `ptc` CLI preset 将 fork 绑定为可继续生命周期。基于 base 的 profile(包括 headless)继承这一默认值。`ForkInProcessProvider.prepareContinuable()` 与 `ctx.subagents.startActivation()` 在这些组合中保持相同的请求前缀行为。
|
||||
|
||||
逐字节相同的前缀复用受显式部署选择约束。配置 child persona 或 `toolFilter` 的 fork 委派仍可能改变请求头。尤其是过滤掉 `send_message` 时,child 会同时失去该 schema 与返回指引;运行时不会绕过显式 allow-list。
|
||||
逐字节相同的前缀复用取决于组装输入。结构化输出或生成 SDK 差异可以改变请求头。历史子级 descriptor 也可在冷恢复时恢复 persona 或工具限制;过滤掉 `send_message` 会同时移除其 schema 和返回指引。新 fork 启动继承父级 preset,不含这些覆盖字段。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -28,11 +28,11 @@ base 组合包与 `cordis`、`standard` 和 `ptc` CLI preset 将 fork 绑定为
|
||||
|
||||
**把返回指令放进系统提示词。** 这会在继承消息之前加入 child-only 字节。将其追加到初始用户任务,可以保留继承前缀,并让 parent id 紧邻需要它的任务。
|
||||
|
||||
**忽略显式 child `toolFilter`。** 结构性返回工具过去会绕过 child allow-list。否决该方案,因为声明的工具限制必须同时决定 schema 可见性与指引;隐藏权限会让面向模型的工具清单失真。
|
||||
**恢复时忽略历史子级 `toolFilter`。** 存储的限制必须继续决定 schema 可见性和指引;静默丢弃它会改变恢复 agent 的行为。
|
||||
|
||||
## 后果
|
||||
|
||||
- 未请求 persona 或工具过滤时,parent 与可继续 fork child 暴露逐字节相同且顺序一致的工具 schema。
|
||||
- 使用相同 preset 且没有子级专属结构化输出或恢复的历史覆盖时,父级与可继续的 fork 子级公开逐字节相同、顺序一致的工具 schema。
|
||||
- 继承的 Session 种子位于 child 初始任务及其返回指引之前。
|
||||
- base 组合包与完整 CLI preset 对 parent 和 caller 投递均使用本地可继续 fork,不增加 child-only 请求头字段。
|
||||
- child 可以通过 `send_message` 向直接 parent 发消息。parent 投递会追加独立的运行时结算通知;caller 投递通过 activation 回执返回结果。
|
||||
@@ -40,4 +40,4 @@ base 组合包与 `cordis`、`standard` 和 `ptc` CLI preset 将 fork 绑定为
|
||||
|
||||
### 已接受风险
|
||||
|
||||
提供方侧前缀复用仍取决于选定的提供方和模型,以及是否不存在显式 persona 或工具过滤差异。harness 证明的是自己组装出的请求头输入相等,而不是提供方的缓存行为。
|
||||
提供方侧前缀复用仍取决于所选提供方和模型,以及组装输入是否相同。harness 测试的是自身组装请求头输入相等,而非提供方的缓存行为。
|
||||
+2
-2
@@ -18,5 +18,5 @@
|
||||
en: ca7c9088d0793e1e
|
||||
zh: ea648bac9e726c64
|
||||
/agent-note-what-stays-host-plane-once-presets-own-the-agent-plane/consequences:
|
||||
en: de26cde94adda770
|
||||
zh: 7f39760b77958966
|
||||
en: 0a426923c2a1f774
|
||||
zh: 29626804b99b35ff
|
||||
+1
-1
@@ -38,6 +38,6 @@ Projection key presence is not a per-session capability signal ([session project
|
||||
|
||||
## Consequences
|
||||
|
||||
The context meter becomes a per-session fact instead of a function of mount history. A preset can no longer opt out of token accounting; no shipped preset did, and `minimal` now says it drops auto-compaction rather than the accounting.
|
||||
The context meter is a per-session fact independent of mount history. Presets resolve the host meter for token accounting while selecting their own compaction backend; `minimal` supports both automatic compaction and the human `/compact` command.
|
||||
|
||||
Production entry points must compose Agents explicitly; installing the registry alone does not bind them. An unjoined Agent receives no tools, prompt sections, or skills and reports no error.
|
||||
+1
-1
@@ -38,6 +38,6 @@ Status: implemented
|
||||
|
||||
## 后果
|
||||
|
||||
context meter 成为逐会话的事实,而不再是挂载历史的函数。代价是 preset 不能再选择不做 token 记账;随附的 preset 没有一个这么做,`minimal` 现在也写明它放弃的是自动压缩而非记账。
|
||||
context meter 是独立于挂载历史的逐会话事实。Preset 使用宿主 meter 进行 token 记账,并选择自己的压缩后端;`minimal` 同时支持自动压缩和用户 `/compact` 命令。
|
||||
|
||||
生产入口必须显式组装 Agent;仅安装注册表不会自动绑定。未加入的 Agent 不会获得工具、提示词段落或 skill,也不会报告错误。
|
||||
+4
-4
@@ -21,11 +21,11 @@
|
||||
en: 29def175ab33b7d6
|
||||
zh: 87e55ec28c20c7c1
|
||||
/agent-note-package-and-update-the-electron-desktop-application/updates-and-recovery:
|
||||
en: e9f140cd22670dfa
|
||||
zh: bf85cfe9c12a0345
|
||||
en: 4cb7014acfe71762
|
||||
zh: 2178e0337cba0324
|
||||
/agent-note-package-and-update-the-electron-desktop-application/security-and-release-policy:
|
||||
en: 97ee608e96fc3602
|
||||
zh: 932b98e7d344cd87
|
||||
en: 5faaa53b58121311
|
||||
zh: e1c3fc8d21a27d1a
|
||||
/agent-note-package-and-update-the-electron-desktop-application/implementation:
|
||||
en: 041a6d35ed61b149
|
||||
zh: 617c6b90a6f4fa4d
|
||||
|
||||
+3
-3
@@ -75,13 +75,13 @@ The [immediate-window reference](../../../../apps/desktop/README.md) owns the lo
|
||||
|
||||
`DSH_DESKTOP_AUTO_UPDATE_ENV` selects the test deployment by default or the production deployment for both the target-specific generic-provider URL and COS destination. Release automation supplies the test HTTPS origin through `DOWNLOAD_TEST_ORIGIN` and each deployment's bucket through `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`; keeping mutable test routing and COS storage identities out of source lets deployment infrastructure change without a code release, while the public production origin remains fixed. Packaging resolves only the public updater URL, disables electron-builder publishing, removes every COS credential field from its subprocess environment, and writes a completion record only after electron-builder and every signing or notarization hook succeeds. Target upload additionally requires the selected bucket, then requires the completion record, root dsh version, Desktop version, version-derived channel metadata, artifact names, sizes, and SHA-512 values to agree before it reads the selected credentials or sends data. It uploads immutable versioned updater payloads and any separate blockmaps before replacing the channel metadata emitted by electron-builder, and it never deletes historical objects. Stable versions use the `latest` metadata name; prereleases use the first semantic-version prerelease identifier. NSIS embeds its blockmap in the signed executable; the macOS ZIP carries a separate blockmap. Both let electron-updater download changed blocks when supported, while application replacement and the local pnpm package operation remain separate operations.
|
||||
|
||||
Test releases require `DOWNLOAD_TEST_RELEASE_ID` from the platform dotenv file. Their feeds and binaries share `dsh-desk/<release-id>/`; production retains its fixed directories. The ID is part of the packaged update URL, so upload rejects a different ID. Internal distribution can supply a new installer after rotation; installed clients retain their existing feed. Random directories reduce guessing but provide no authorization for link holders. The [release configuration](../../../../apps/desktop/README.md#upload-updates) defines generation and reuse.
|
||||
Test releases require `DOWNLOAD_TEST_RELEASE_ID` from the platform dotenv file. Their feeds and binaries share `dsh-desk/<release-id>/`; production retains its fixed directories. The ID is part of the packaged update URL, so upload rejects a different ID. Internal distribution can supply a new installer after rotation; installed clients retain their existing feed. Random directories reduce guessing but provide no authorization for link holders. The [release configuration](../../../../apps/desktop/publishing/README.md#upload-updates) defines generation and reuse.
|
||||
|
||||
## Security and release policy
|
||||
|
||||
Core dsh and the private Desktop Host come only from the signed application resource tree. Plugin installation forwards package specs to pnpm, including local and remote sources, but never accepts raw pnpm commands. pnpm owns dependency resolution and the profile’s `allowBuilds` policy; the Host loads activated bundles.
|
||||
|
||||
Electron release artifacts are signed; macOS artifacts are notarized. Release package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. The target file exclusively owns release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS release packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap.
|
||||
Electron release artifacts are signed; macOS artifacts are notarized. Release package and upload commands read release settings from the Git-ignored target `.env.windows` or `.env.macos`; subprocesses receive the fields selected by orchestration. Windows packaging can instead select a machine-owned public `presets/<preset>.env`, plus `secrets/windows-signing.env` for signed builds; unsigned and preparation-only modes omit the signing file. The selected files exclusively own release fields so stale shell or system credentials cannot override local selection, and loading does not mutate the parent environment. Packaging validates the mode-specific application ID, update origin, signing identity, and local files before builds, downloads, or release-record cleanup; macOS also requires one complete notarization strategy. The separate `check:package` command runs the same validation without accessing the token or Apple; actual signing and notarization still verify authentication. Configuration loading rejects missing or malformed identifiers and incomplete notarization credentials, while macOS release packaging requires signing so certificate discovery cannot silently select another installed identity or emit an unsigned release. Runtime preparation verifies the exact Authority and Team ID plus the timestamp and hardened-runtime flags on every embedded Mach-O file. An after-sign hook performs Apple's deep strict application verification and requires the same leaf Authority and Team ID before artifact creation continues. The fixed-target installer command uses [isolated App copies for parallel notarization](../process/2026-09-09-parallel-macos-notarization.md): the ZIP contains a stapled App, while the signed DMG carries the ticket covering its unstapled inner App. The DMG artifact-completion hook requires the configured identity, a valid ticket, and Gatekeeper acceptance. Both artifact lanes must succeed before the command promotes their outputs and writes the release completion record; directory-only commands still notarize and staple the App. DMG blockmaps are disabled because macOS updates consume the signed ZIP, and stapling would otherwise invalidate an already-generated DMG blockmap.
|
||||
|
||||
The [pinned osx-sign patch](../../../../patches/@[email protected]) uses `lstat` in both published module builds, so Framework file and directory aliases do not trigger duplicate signing. The patch remains necessary until the selected upstream release skips those aliases. PAK files are resources sealed by the enclosing bundle; individual signatures add serial timestamp requests without additional resource integrity. Desktop preserves all locale files and skips only their standalone signatures. Executable code retains Developer ID signatures, secure timestamps, and hardened runtime. The [signer traversal regression](../../../../apps/desktop/tests/macos-signing-walk.spec.ts) exercises the installed dependency with real Framework aliases; release qualification still requires strict application verification, notarization, and startup.
|
||||
|
||||
@@ -91,7 +91,7 @@ Windows package invocations force `ELECTRON_BUILDER_7Z_FILTER=BCJ`. The bundled
|
||||
|
||||
Local Windows and macOS installation testing uses an explicit `--unsigned` package invocation with the same build and runtime preparation. It strips certificate inputs, isolates artifacts in `unsigned-artifacts`, and omits updater configuration and the release completion record. macOS local builds ignore dotenv files and inherited release settings, use the standard application ID, and omit mandatory-update policy so local testing needs no deployment configuration. Their App receives a certificate-free ad-hoc signature; their DMG is unsigned and unnotarized. Windows local builds retain their platform configuration requirements. The regular package command explicitly selects signed mode even when its parent environment requests unsigned mode. This separation permits installation diagnosis without release credentials while preventing local test output from qualifying for release upload.
|
||||
|
||||
Windows application replacement follows the [directory-installation reference](../../../../apps/desktop/README.md): extract beside the destination with a command-line tool that returns failure status, then rename complete directories on the same volume. The installer retains the old directory through staging and restores it if promotion fails. Registration, shortcuts, and the signed uninstaller remain owned by electron-builder.
|
||||
Windows application replacement follows the [directory-installation reference](../../../../apps/desktop/packaging/README.md#runtime-file-selection): extract beside the destination with a command-line tool that returns failure status, then rename complete directories on the same volume. The installer retains the old directory through staging and restores it if promotion fails. Registration, shortcuts, and the signed uninstaller remain owned by electron-builder.
|
||||
|
||||
Packaged applications ignore development resource and project environment overrides. Only an unpackaged Electron process can replace the pnpm entry, dsh resources, or active project.
|
||||
|
||||
|
||||
+3
-3
@@ -75,13 +75,13 @@ Electron 更新只使用一个 `electron-updater` 发布流和签名 `electron-b
|
||||
|
||||
`DSH_DESKTOP_AUTO_UPDATE_ENV` 默认为测试部署,也可以选择生产部署,并同时决定目标专用的 generic-provider URL 与 COS 目标。发布自动化通过 `DOWNLOAD_TEST_ORIGIN` 提供测试 HTTPS origin,并通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供各部署的 bucket;可变的测试路由与 COS 存储身份不写入源码,部署基础设施变更时无需发布新代码,而公开的生产 origin 仍固定。打包只解析公开更新 URL、禁止 electron-builder 发布、从子进程环境中删除每个 COS 凭据字段,并且只有在 electron-builder 以及每个签名或公证 hook 成功后才写入完成记录。目标上传还必须提供所选 bucket,随后会先要求完成记录、根 dsh 版本、Desktop 版本、根据版本得出的频道元数据、产物名称、大小与 SHA-512 全部一致,再读取所选凭据或发送数据。它先上传不可变且带版本的更新载荷与所有独立 blockmap,最后替换 electron-builder 生成的频道元数据,并且不会删除历史对象。稳定版本使用 `latest` 元数据名称,预发布版本则使用语义化版本的第一个预发布标识符。NSIS 把 blockmap 嵌入已签名的可执行文件,macOS ZIP 则使用独立 blockmap;两者都让 electron-updater 在平台支持时只下载变化的数据块,而应用替换与本地 pnpm 包操作仍是两个独立操作。
|
||||
|
||||
测试发布必须从平台 dotenv 文件提供 `DOWNLOAD_TEST_RELEASE_ID`。清单和安装包共用 `dsh-desk/<release-id>/`,生产环境保留固定目录。ID 是打包更新 URL 的一部分,因此上传会拒绝不同的 ID。轮换后可通过内部分发提供新安装包,已安装客户端保留原清单地址。随机目录降低被猜中的概率,但不对持有链接者进行鉴权。[发布配置](../../../../apps/desktop/README.zh.md#upload-updates)定义生成与复用方式。
|
||||
测试发布必须从平台 dotenv 文件提供 `DOWNLOAD_TEST_RELEASE_ID`。清单和安装包共用 `dsh-desk/<release-id>/`,生产环境保留固定目录。ID 是打包更新 URL 的一部分,因此上传会拒绝不同的 ID。轮换后可通过内部分发提供新安装包,已安装客户端保留原清单地址。随机目录降低被猜中的概率,但不对持有链接者进行鉴权。[发布配置](../../../../apps/desktop/publishing/README.zh.md#upload-updates)定义生成与复用方式。
|
||||
|
||||
## 安全与发布策略
|
||||
|
||||
核心 dsh 和私有 Desktop Host 只来自签名应用的资源树。插件安装把包规格交给 pnpm,包括本地和远程来源,但不接受原始 pnpm 命令。pnpm 负责依赖解析和 profile 的 `allowBuilds` 策略;Host 加载已启用的 bundle。
|
||||
|
||||
Electron 发布产物必须签名;macOS 产物必须公证。发布打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。目标文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 发布打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。
|
||||
Electron 发布产物必须签名;macOS 产物必须公证。发布打包与上传命令从 Git 忽略的目标 `.env.windows` 或 `.env.macos` 读取发布配置,子进程通过编排器选择的环境字段接收配置。Windows 打包也可选择机器管理的公开 `presets/<preset>.env`,签名构建另读 `secrets/windows-signing.env`;未签名和仅准备模式不读取签名文件。所选文件是发布字段的唯一来源,避免旧的 shell 或系统凭据覆盖本地选择;配置加载不修改父进程环境。打包在构建、下载或清理发布记录前校验该模式必需的应用 ID、更新地址、签名身份及本地文件,macOS 还要求一套完整公证凭据。单独的 `check:package` 执行同一校验而不访问 Token 或 Apple;凭据真实性仍由实际签名与公证验证。配置加载会拒绝缺失或格式错误的标识符和不完整的公证凭据,macOS 发布打包还会强制签名,避免证书发现过程静默选择其他已安装身份或生成未签名发布。运行时准备会验证每个内嵌 Mach-O 文件的精确 Authority 与 Team ID,以及时间戳和 hardened-runtime 标记。签名后钩子会执行 Apple 的深度严格应用验证,并要求同一叶证书 Authority 与 Team ID 完全匹配,验证通过后才继续生成产物。固定目标安装包命令使用[隔离的 App 副本并行公证](../process/2026-09-09-parallel-macos-notarization.zh.md):ZIP 包含已钉票的 App,签名 DMG 则携带覆盖其中未钉票 App 的票据。DMG 的 artifact-completion hook 要求其使用配置的身份、具备有效票据并通过 Gatekeeper。只有两条产物流都成功,命令才会移入其输出并写入发布完成记录;仅生成目录的命令仍会公证 App 并钉票。macOS 更新使用签名 ZIP,因此 DMG 不生成 blockmap;否则钉票会让已经生成的 DMG blockmap 失效。
|
||||
|
||||
[固定版本的 osx-sign 补丁](../../../../patches/@[email protected])在两种已发布模块构建中使用 `lstat`,因此 Framework 的文件和目录别名不会触发重复签名。选定的上游版本能够跳过这些别名前,仍需保留该补丁。PAK 文件由外层 bundle 签名记录完整性;逐个签名会增加串行时间戳请求,但不会增加资源完整性保护。Desktop 保留全部语言文件,只跳过其单独签名。可执行代码仍使用 Developer ID 签名、安全时间戳和 hardened runtime。[签名器遍历回归测试](../../../../apps/desktop/tests/macos-signing-walk.spec.ts)使用真实 Framework 别名执行已安装依赖;发布验收仍要求严格应用验证、公证和启动。
|
||||
|
||||
@@ -91,7 +91,7 @@ Windows 打包调用强制设置 `ELECTRON_BUILDER_7Z_FILTER=BCJ`。内置的 7-
|
||||
|
||||
本地 Windows 和 macOS 安装测试使用显式的 `--unsigned` 打包调用,并执行相同的构建和运行时准备。它清除证书输入,将产物隔离到 `unsigned-artifacts`,并省略更新器配置和发布完成记录。macOS 本地构建忽略 dotenv 文件和继承的发布配置,使用标准应用 ID,并省略强制更新策略,使本地测试无需部署配置。其 App 使用无需证书的 ad-hoc 签名,DMG 不签名也不公证。Windows 本地构建保留平台配置要求。即使父进程环境请求未签名模式,常规打包命令也会显式选择签名模式。这样既能在没有发布凭据时诊断安装问题,也能防止本地测试产物通过发布上传校验。
|
||||
|
||||
Windows 应用替换遵循[目录安装参考](../../../../apps/desktop/README.zh.md):使用能返回失败状态的命令行工具解压到目标旁边,再在同卷内改名替换完整目录。安装器在暂存期间保留旧目录,正式替换失败时恢复旧目录。注册信息、快捷方式和签名卸载器仍由 electron-builder 持有。
|
||||
Windows 应用替换遵循[目录安装参考](../../../../apps/desktop/packaging/README.zh.md#runtime-file-selection):使用能返回失败状态的命令行工具解压到目标旁边,再在同卷内改名替换完整目录。安装器在暂存期间保留旧目录,正式替换失败时恢复旧目录。注册信息、快捷方式和签名卸载器仍由 electron-builder 持有。
|
||||
|
||||
打包应用会忽略开发资源和项目环境变量覆盖。只有未打包的 Electron 进程可以替换 pnpm 入口、dsh 资源 或活跃项目。
|
||||
|
||||
|
||||
+2
-2
@@ -12,8 +12,8 @@
|
||||
en: 1188c699ce6e6ca2
|
||||
zh: 4d5b974cd1216f06
|
||||
/agent-note-adjacent-agents-share-one-steer-send-message-operation/decision/one-model-tool-and-one-return-instruction:
|
||||
en: 8a4be155a4984ca8
|
||||
zh: 00c2a61da2443530
|
||||
en: 4b0a25389324fa91
|
||||
zh: c2cdd9f7e40ef43b
|
||||
/agent-note-adjacent-agents-share-one-steer-send-message-operation/decision/complete-removal-and-reintroduction-condition:
|
||||
en: 5a9ec6478cf7430c
|
||||
zh: f4c2f067cc72040e
|
||||
|
||||
@@ -46,7 +46,7 @@ interface SendMessageInput {
|
||||
}
|
||||
```
|
||||
|
||||
Parents and children inherit the same definition in the same registry order. The standard definition carries a process-stable internal identity that a scoped same-name tool does not satisfy. A child `toolFilter` may explicitly remove the inherited tool, and a scoped replacement may provide different semantics; neither case receives the standard call instruction. When the standard tool remains visible, the continuation manager appends the JSON-encoded direct parent id and the instruction to send one self-contained result before finishing, plus earlier actionable findings, to the child's initial user task. For a fork child this task follows the inherited completed-turn prefix; no child-only system-prompt section or tool schema precedes that prefix.
|
||||
Parents and children inherit the same definition in the same registry order. The standard definition carries a process-stable internal identity that a scoped same-name tool does not satisfy. An independently selected preset may omit the tool, a historical descriptor filter may remove it on resume, and a scoped replacement may provide different semantics; neither case receives the standard call instruction. When the standard tool remains visible, the continuation manager appends the JSON-encoded direct parent id and the instruction to send one self-contained result before finishing, plus earlier actionable findings, to the child's initial user task. For a fork child this task follows the inherited completed-turn prefix; no child-only system-prompt section or tool schema precedes that prefix.
|
||||
|
||||
The instruction is guidance, not settlement enforcement. Sending does not end the child's turn, zero or several calls remain mechanically valid, and the runtime never rejects a child for staying silent. The manager-owned `subagent-settled` notice remains unconditional and separately attributed because it records how an Activation ended and preserves terminal output when the child cannot cooperate.
|
||||
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ interface SendMessageInput {
|
||||
}
|
||||
```
|
||||
|
||||
parent 与 child 以相同注册表顺序继承相同定义。标准定义携带进程稳定的内部身份,同名的作用域工具不满足该身份。child `toolFilter` 可以显式移除继承的工具,作用域替代工具也可以提供不同语义;两种情况都不会收到标准调用指令。当标准工具仍可见时,继续执行管理器会把经过 JSON 编码的直接 parent id、结束前发送一份自包含结果的指令,以及更早发送可操作发现的指令追加到 child 初始用户任务。对 fork child 而言,该任务位于继承的已完成轮次前缀之后;没有 child 专属系统提示词 section 或工具 schema 位于此前缀之前。
|
||||
parent 与 child 以相同注册表顺序继承相同定义。标准定义携带进程稳定的内部身份,同名的作用域工具不满足该身份。独立选择的 preset 可以省略该工具,历史 descriptor 过滤器可在恢复时移除该工具,作用域替代工具也可以提供不同语义;两种情况都不会收到标准调用指令。当标准工具仍可见时,继续执行管理器会把经过 JSON 编码的直接 parent id、结束前发送一份自包含结果的指令,以及更早发送可操作发现的指令追加到 child 初始用户任务。对 fork child 而言,该任务位于继承的已完成轮次前缀之后;没有 child 专属系统提示词 section 或工具 schema 位于此前缀之前。
|
||||
|
||||
该指令是指导,不是结算强制。发送不会结束 child 轮次,机制仍允许零次或多次调用,runtime 绝不会因 child 保持沉默而拒绝它。由管理器负责的 `subagent-settled` 通知仍无条件发送并采用独立来源,因为它记录 Activation 如何结束,并在 child 无法配合时保留终态输出。
|
||||
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
en: 09be2a1cf1d52fd5
|
||||
zh: 0bf0e18c73542012
|
||||
/agent-note-one-output-ring-one-projection-one-event-stream-for-background-jobs/consequences:
|
||||
en: 464eb843a71eb04e
|
||||
zh: 783062f5b288b1b4
|
||||
en: 3263801e173b6b86
|
||||
zh: f6f51bb3f77a817e
|
||||
@@ -35,3 +35,5 @@ The job seam had grown by accretion. Producers declared their output twice — a
|
||||
The model-visible text is unchanged: status lines, tool descriptions and schemas, and notice wording are byte-identical, and the only new model-visible fact is the kill reason a `killed` status line now carries. A producer that needs to stream output picks pull sources or `append`; it never formats a consuming delta. The Web client renders every live job as expandable, so a job that never writes shows an empty running panel instead of a static row. `JobKindMap` merges are host-side, so a browser program types `JobView.kind` as the kinds it knows and treats the rest as opaque strings, which is all the roster does with it.
|
||||
|
||||
Review follow-ups after the master merge tightened four of these edges. A job's first event is always `registered`: the announcement precedes the pump, whose first drain runs synchronously and may announce output. A settled ring keeps every byte the model cursor has not consumed until the first terminal model read, so a job that finishes before its first `job_output` loses nothing the live cap retained. `dsh-tool-jobs` holds one claim per wait call with a `live` flag that the caller's abort clears synchronously from the abort event, so a settlement announced before the registry's rejection reaches the tool still delivers its notice, and a timed-out wait drops only its own claim while a concurrent wait keeps the settlement covered. The browser records an observation failure that precedes the anchor as an errored view, so the panel shows the notice instead of staying blank. The spill file a pull source keeps is job metadata (`JobView.output.spillPaths`, reported by every pump read and withdrawn once a read stops naming it), not per-chunk metadata, and the model's read renders the shell tools' dropped-output notice for a producer-side gap as well as for ring eviction, naming those files even after the gap chunk itself was evicted — the `before this read` variant this change had introduced is gone. An observation generation that sees its job removed mid-stream (the owner's teardown) closes with the removed job's terminal projection instead of a failed read. A rejected background spawn produces no process output, so the shell providers now serve the `spawn failed: …` note as the whole observed stderr stream (the consuming read still folds it in once), and `ShellProcess.observed` with both readers is required: the model's first `job_output` after such a failure reads exactly what master's consuming read rendered.
|
||||
|
||||
The [Session lifecycle decision](../bug-fix/2026-09-30-job-session-lifecycle.md) records registration and settlement for durable SDK consumers while preserving this live event stream and output ownership.
|
||||
@@ -35,3 +35,5 @@ job seam 是逐步堆出来的。生产者要把输出声明两次——模型
|
||||
模型可见文本保持不变:状态行、工具描述与 schema、播报措辞逐字节相同,唯一新增的模型可见事实是 `killed` 状态行现在携带的 kill 原因。需要流式输出的生产者选择拉取源或 `append`,从不格式化消耗式增量。Web 客户端把每个进行中的 job 渲染为可展开,因此从不写输出的 job 显示为空的运行中面板而非静态行。`JobKindMap` 的合并发生在宿主侧,浏览器程序把 `JobView.kind` 视为它认识的 kind 集合,其余当作不透明字符串——名册对它也只做这些。
|
||||
|
||||
合并 master 之后的评审跟进收紧了其中四条边。job 的第一个事件永远是 `registered`:通告先于泵,而泵的首轮 drain 同步执行且可能通告输出。已结算的环在模型的首次终态读取之前保留模型游标尚未消费的全部字节,因此在首次 `job_output` 之前结束的 job 不会丢失运行期上限保留的任何内容。`dsh-tool-jobs` 为每次等待调用保存一条带 `live` 标记的认领,调用方 abort 时在 abort 事件里同步清掉该标记,因此注册表的拒绝尚未传回工具之前宣布的结算仍会投递通知;超时的等待只丢弃自己的认领,并发的另一个等待仍覆盖该结算。浏览器把先于锚点发生的观察失败记录为带错误的视图,面板显示提示而不是留白。拉取源保留的 spill 文件是 job 元数据(`JobView.output.spillPaths`,每次泵读取都会上报,读取不再点名时即撤回),不是逐块元数据;模型读取对生产者侧缺口与环淘汰都渲染 shell 工具的丢失输出提示并列出这些文件,即使缺口块本身已被淘汰——本次变更曾引入的 `before this read` 变体已删除。观察生成过程若在流中看到其 job 被移除(拥有者 teardown),以被移除 job 的终态投影收尾,而不是一次失败的读取。被拒绝的后台 spawn 不产生任何进程输出,因此 shell provider 现在把 `spawn failed: …` 提示作为 observed stderr 的整条流提供(消耗式读取仍只折入一次),且 `ShellProcess.observed` 及其两个读取器改为必填:这类失败之后模型的首次 `job_output` 读到的正是 master 的消耗式读取所渲染的内容。
|
||||
|
||||
[Session 生命周期决策](../bug-fix/2026-09-30-job-session-lifecycle.zh.md)为持久化 SDK 消费方记录注册与结算,同时保留这里的运行期事件流和输出归属。
|
||||
+2
-2
@@ -18,8 +18,8 @@
|
||||
en: 53d48f71fcc1c52e
|
||||
zh: 7d61923484dbec1c
|
||||
/agent-note-bundle-the-desktop-runtime-and-retain-external-plugins/alternatives-considered:
|
||||
en: ead5bbd94044c3de
|
||||
zh: 057cf069413d2a49
|
||||
en: 67b3ba2b37e92f88
|
||||
zh: 785083e5de098af8
|
||||
/agent-note-bundle-the-desktop-runtime-and-retain-external-plugins/consequences:
|
||||
en: 6dfa581ec6879c5b
|
||||
zh: f6c735ad6a00b2b3
|
||||
+1
-1
@@ -48,7 +48,7 @@ The [immediate-window reference](../../../../apps/desktop/README.md) owns direct
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Full runtime verification belongs to packaging. Startup reads the resource descriptor and checks shared package records and required Host entries. The [release-validation reference](../../../../apps/desktop/README.md) assigns release and target compatibility checks to packaging. Startup neither enumerates nor hashes installed runtime files. Reading every file before backend loading adds I/O proportional to distribution size; unusable modules instead fail when loaded. Build-time verification rejects changed, missing, extra, or linked files against the recorded inventory.
|
||||
Full runtime verification belongs to packaging. Startup reads the resource descriptor and checks shared package records and required Host entries. The [release-validation reference](../../../../apps/desktop/packaging/README.md#desktop-runtime-preparation) assigns release and target compatibility checks to packaging. Startup neither enumerates nor hashes installed runtime files. Reading every file before backend loading adds I/O proportional to distribution size; unusable modules instead fail when loaded. Build-time verification rejects changed, missing, extra, or linked files against the recorded inventory.
|
||||
|
||||
- **Install the bundled offline seed at startup.** This preserves an ordinary pnpm installation procedure but repeats core extraction and installation on every affected machine. Materialized resources remove that work at the cost of more application files and release-builder responsibility.
|
||||
- **Force host dependency versions into plugins.** This unnecessarily couples ordinary plugin dependencies to the host. The runtime resolution supplies missing packages while pnpm-owned entries retain independent versions.
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ profile manifest 分别记录 pnpm 安装的依赖及已启用 bundle 列表。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
完整运行时验证属于打包流程。启动读取资源描述文件,并检查共享包记录及必要的 Host 入口。[发布验证参考](../../../../apps/desktop/README.zh.md)将发布与目标兼容性检查交给打包流程。启动既不枚举已安装运行时文件,也不计算其哈希。后端加载前读取每个文件会增加与分发体积成正比的 I/O;不可用模块改由加载时失败暴露。构建时验证按记录清单拒绝内容变化、缺失、多余或链接文件。
|
||||
完整运行时验证属于打包流程。启动读取资源描述文件,并检查共享包记录及必要的 Host 入口。[发布验证参考](../../../../apps/desktop/packaging/README.zh.md#desktop-runtime-preparation)将发布与目标兼容性检查交给打包流程。启动既不枚举已安装运行时文件,也不计算其哈希。后端加载前读取每个文件会增加与分发体积成正比的 I/O;不可用模块改由加载时失败暴露。构建时验证按记录清单拒绝内容变化、缺失、多余或链接文件。
|
||||
|
||||
- **启动时安装内置离线 seed。** 这保留普通 pnpm 安装流程,但会在每台受影响机器上重复核心解压与安装。物化资源消除了这部分工作,代价是更多应用文件和发布构建器责任。
|
||||
- **强制插件使用 Host 依赖版本。** 这会让普通插件依赖不必要地耦合于 Host。runtime resolution 提供缺失的包,pnpm 拥有的条目保留独立版本。
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@
|
||||
en: 99308bd758859e02
|
||||
zh: 35f1e7719fe80d2e
|
||||
/agent-note-add-immutable-profile-resolution-generations/decision/additive-package-changes:
|
||||
en: 0e5e2006caec3c91
|
||||
zh: fb3b61dd5692d2e3
|
||||
en: 39d27cff0d419259
|
||||
zh: 6cbdc88a450614d0
|
||||
/agent-note-add-immutable-profile-resolution-generations/decision/filesystem-and-runtime-carriers:
|
||||
en: b0607713102a093d
|
||||
zh: 2de9c33bfaa0e3e2
|
||||
|
||||
@@ -107,7 +107,7 @@ New Workers inherit the latest published generation. Existing Workers keep the g
|
||||
|
||||
A caller adding a package completes its pnpm transaction before constructing a successor generation. Replacement rejects any generation that changes the directory or version of a retained package. Plugin Manager publishes the successor before mounting the new Loader row. A mount failure may leave the package installed but inactive.
|
||||
|
||||
A retained package keeps its normalized directory, version, and scope; installation entries also keep their declaring anchor and cannot be removed. Violating these restrictions requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. A profile entry may change its declaring anchor: deselecting the first bundle that reaches a shared dependency can leave another bundle selecting the same physical package. Profile-scoped mappings and profile-local package names may be removed; the caller stops their plugins before deleting package files. These restrictions do not prohibit removing a linked root from the interception scope. Generation replacement does not claim to unload modules.
|
||||
A retained package keeps its normalized directory, version, and scope; installation entries also keep their declaring anchor and cannot be removed. Violating these restrictions requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. A profile entry may change its declaring anchor: deselecting the first bundle that reaches a shared dependency can leave another bundle selecting the same physical package. Profile-scoped mappings and profile-local package names may be removed; the caller stops their plugins before deleting package files. A profile mapping moves to another directory only through removal and a new installation; a linked root may point to another target ([replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md)). These restrictions do not prohibit removing a linked root from the interception scope. Generation replacement does not claim to unload modules.
|
||||
|
||||
### Filesystem and runtime carriers
|
||||
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ runtime resolution 列出它提供的包;Loader entries 组成活动插件列
|
||||
|
||||
添加包的调用方先完成 pnpm 事务,再构造下一代。替换操作会拒绝改变任何保留包的目录或版本。Plugin Manager 先发布后继 generation,再挂载新的 Loader 配置项。挂载失败可以留下已安装但未启用的包。
|
||||
|
||||
保留包的规范化目录、版本和作用域必须不变;安装条目还必须保留声明锚点,且不能移除。违反这些限制需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。profile 条目可以改变声明锚点:取消选入最先找到共享依赖的组合包后,另一个组合包仍可能选中同一物理包。profile 范围的映射和 profile 本地包名可以移除;调用方在删除包文件之前先停止相关插件。这些限制不禁止从拦截范围移除 linked root。generation 换代不声称卸载模块。
|
||||
保留包的规范化目录、版本和作用域必须不变;安装条目还必须保留声明锚点,且不能移除。违反这些限制需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。profile 条目可以改变声明锚点:取消选入最先找到共享依赖的组合包后,另一个组合包仍可能选中同一物理包。profile 范围的映射和 profile 本地包名可以移除;调用方在删除包文件之前先停止相关插件。profile 映射只能通过卸载和重新安装移到其他目录;linked root 可以指向其他目标([替换 Note](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md))。这些限制不禁止从拦截范围移除 linked root。generation 换代不声称卸载模块。
|
||||
|
||||
### 文件系统与运行时载体
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 3c2dd2c948ffb51b
|
||||
zh: d019197df0ee3dcf
|
||||
/agent-note-deepseek-account-browser-login/consequences:
|
||||
en: a6eff4a0350e9018
|
||||
zh: 2e3200ca089023ab
|
||||
en: 3c5681b492cd34cf
|
||||
zh: d7707fed9241f9bc
|
||||
/agent-note-deepseek-account-browser-login/verification:
|
||||
en: 881edcb454b941a6
|
||||
zh: 7dc5a9b7a066f623
|
||||
|
||||
@@ -46,7 +46,7 @@ Profile and recharge-wallet queries use independent getProfile and getBalance op
|
||||
|
||||
Deployment authentication uses explicit Host-only requestHeaders on the configured Platform origin. The provider rejects redirects and reserved-header overrides so deployment cookies cannot replace account authorization or follow a browser destination. Environment-specific authentication protocols remain outside the account provider.
|
||||
|
||||
The bundled account UI is Desktop-only: its preload bridge enables the account launcher, settings and sign-in onboarding. Plain Web retains API-key onboarding and the standard Settings launcher without account subscription or login. The Host protocol accepts login_source (desktop or web) in auth_init; the bundled UI sends desktop. The backend accepts localhost callbacks. DSH preserves the browser-supplied localhost hostname and port without DNS resolution or conversion to an IP literal.
|
||||
The bundled account UI mounts the account launcher, settings and sign-in onboarding in both Desktop and plain Web. The Host sends login_source (desktop or web) in auth_init so Platform can choose the completion experience; the UI sends desktop when the Desktop preload bridge is present and web otherwise. Web failures close the authorization tab while the original tab receives Host state; no Web UI return URL is used. The backend accepts localhost callbacks. DSH preserves the browser-supplied localhost hostname and port without DNS resolution or conversion to an IP literal.
|
||||
|
||||
The macOS development launcher registers an isolated, ad-hoc-signed application bundle for `dsh://open`. The bundle retains the workspace entry and development paths for Launch Services cold starts without copying credentials or changing the package-manager-owned Electron application. Protocol registration targets the latest launched development or packaged application.
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ DSH 授权通过 x-dsh-auth-token 请求头鉴权 Platform、推理和 Files 请
|
||||
|
||||
开发环境认证通过配置的平台来源上的显式 Host 请求头 requestHeaders 完成。提供者拒绝重定向和保留请求头覆盖,防止开发环境 Cookie 替换账号授权或跟随浏览器跳转地址。特定环境的认证协议不属于账号提供者。
|
||||
|
||||
内置账号界面仅用于 Desktop:preload 桥接启用账号入口、设置和登录引导。普通 Web 保留 API Key 引导及标准设置入口,不订阅账号状态或显示登录。Host 协议在 auth_init 中接受 login_source(desktop 或 web);内置界面发送 desktop。后端接受 localhost 回调。DSH 保留浏览器提供的 localhost 主机名和端口,不做 DNS 解析或 IP 字面量转换。
|
||||
内置账号界面在 Desktop 和普通 Web 中都挂载账号入口、设置和登录引导。Host 在 auth_init 中发送 login_source(desktop 或 web),以便 Platform 选择完成页体验;存在 Desktop preload 桥接时界面发送 desktop,否则发送 web。Web 登录失败时关闭授权标签页,原标签页通过 Host 状态获知结果;不使用 Web UI 返回 URL。后端接受 localhost 回调。DSH 保留浏览器提供的 localhost 主机名和端口,不做 DNS 解析或 IP 字面量转换。
|
||||
|
||||
macOS 开发启动器为 `dsh://open` 注册独立、经临时签名的应用包。该应用包保留工作区入口和开发路径,使 Launch Services 能够冷启动;凭证不会被复制,也不修改包管理器安装的 Electron 应用。协议注册指向最近启动的开发版或打包版应用。
|
||||
|
||||
|
||||
+6
-6
@@ -9,14 +9,14 @@
|
||||
en: 837ebb0071a5611b
|
||||
zh: 23d0f0713889ead6
|
||||
/agent-note-writer-acquisition-for-blank-session-reuse/decision:
|
||||
en: 7f2676feb224e7ea
|
||||
zh: babfda5b31dfd0fe
|
||||
en: a54db0d4a97aa9b8
|
||||
zh: a52d257b657fe99b
|
||||
/agent-note-writer-acquisition-for-blank-session-reuse/alternatives-considered:
|
||||
en: 2a67a53f7f396ba8
|
||||
zh: 6df6ccc071fb0de2
|
||||
/agent-note-writer-acquisition-for-blank-session-reuse/consequences:
|
||||
en: 2e1f1894337689a2
|
||||
zh: d0cab07cc48083ee
|
||||
en: 8307612b7476e8f3
|
||||
zh: 87fa39e58d33d4f1
|
||||
/agent-note-writer-acquisition-for-blank-session-reuse/verification:
|
||||
en: 4680e845e2ab294e
|
||||
zh: ea5890c5ffb09831
|
||||
en: 4367d55ae9dce5c1
|
||||
zh: c610626f4e15eb77
|
||||
@@ -10,7 +10,7 @@ Two Hosts sharing Session storage can select the same blank Session when opening
|
||||
|
||||
## Decision
|
||||
|
||||
The Session catalog includes persisted blanks. Startup restoration selects the saved blank; ordinary Workspace navigation selects the first eligible unarchived member in catalog order. Explicit-id `session.create` resumes that Session and acquires its writer before opening history. Only `session/writer-held` triggers creation of a new Session. Other errors propagate. Concurrent Workspace connects in one Client share the acquisition and fallback. Later navigation cancels a pending startup selection.
|
||||
The Session catalog includes persisted blanks. Startup restoration selects the saved blank; ordinary Workspace navigation selects the first eligible unarchived member in catalog order. Explicit-id `session.create` names that Session; the Host resumes it and acquires its writer before opening history, and creates a new Session when another owner holds the writer ([reuse and preset rules](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.md)). Other errors propagate. A Workspace connect in one Client joins the latest in-flight connect to that Workspace only when both request the same preset. Later navigation cancels a pending startup selection.
|
||||
|
||||
The Agent retains the acquired handle throughout its lifetime. Checking ownership and releasing a probe lock would leave a race before resume. No PID or process identity enters persisted data or the Remote schema.
|
||||
|
||||
@@ -28,8 +28,8 @@ This decision extends the blank-reuse policy in the [Client Session scope refere
|
||||
|
||||
## Consequences
|
||||
|
||||
A Host can reuse its own live blank or reclaim an unowned persisted blank with its slash state. Separate Hosts create distinct blanks while existing writers remain held. A held selected blank causes fresh creation even when another blank is free; navigation does not search for another candidate. No cleanup is added. Opening the same established conversation from two Hosts can still encounter writer contention. Unknown projection hints remain visible without cold body scans; automatic reuse requires known blank metadata. A non-contention acquisition failure aborts New Session and is currently reported only to the console; hidden blanks have no recovery action in this flow.
|
||||
A Host can reuse its own live blank or reclaim an unowned persisted blank with its slash state. Separate Hosts create distinct blanks while existing writers remain held. A held selected blank causes fresh creation even when another blank is free; navigation does not search for another candidate. No cleanup is added. Opening the same established conversation from two Hosts can still encounter writer contention. Unknown projection hints remain visible without cold body scans; automatic reuse requires known blank metadata. A non-contention acquisition failure aborts the navigation; a hero Workspace pick or explicit draft preparation reports it as a notice and startup restoration only to the console, and a hidden blank that fails this way has no recovery action in this flow.
|
||||
|
||||
## Verification
|
||||
|
||||
Navigation tests cover saved selection, single-candidate contention fallback, non-contention failures, overlapping acquisition, and superseding navigation. A shipped Web-composition test holds a real persistence writer, verifies fresh creation despite another free blank, and reclaims the released selected blank with its plan state. The fresh-round-trip recorded Session scenario reloads its blank before submitting the recorded prompt.
|
||||
Navigation tests cover saved selection, opening the Session the Host returns for a held candidate, non-contention failures, overlapping acquisition, and superseding navigation. A shipped Web-composition test holds a real persistence writer, verifies fresh creation despite another free blank, and reclaims the released selected blank with its plan state. The fresh-round-trip recorded Session scenario reloads its blank before submitting the recorded prompt.
|
||||
+3
-3
@@ -10,7 +10,7 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
Session 列表包含持久化空白会话。启动恢复选择已保存的空白会话;普通 Workspace 导航按列表顺序选择第一个符合条件且未归档的成员。显式 ID 的 `session.create` 会恢复该 Session,并在打开历史前取得写锁。只有 `session/writer-held` 会触发新建 Session。其他错误返回调用方。同一 Client 的并发 Workspace 连接共享获取与回退过程。后续导航会取消尚未完成的启动选择。
|
||||
Session 列表包含持久化空白会话。启动恢复选择已保存的空白会话;普通 Workspace 导航按列表顺序选择第一个符合条件且未归档的成员。显式 ID 的 `session.create` 指明该 Session;Host 恢复它并在打开历史前取得写锁,写锁被其他写者持有时改为新建 Session([复用与 preset 规则](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md))。其他错误返回调用方。同一 Client 中的 Workspace 连接仅在与该 Workspace 最近一次进行中的连接请求相同 preset 时与之合并。后续导航会取消尚未完成的启动选择。
|
||||
|
||||
Agent 在整个生命周期内持有取得的句柄。检查占用后释放探测锁,会在恢复前留下竞态。持久化数据与 Remote schema 均不引入 PID 或进程身份。
|
||||
|
||||
@@ -28,8 +28,8 @@ Agent 在整个生命周期内持有取得的句柄。检查占用后释放探
|
||||
|
||||
## Consequences
|
||||
|
||||
Host 可以复用自己的活动空白会话,也可以接管无人占用的持久化空白会话并保留其 slash 状态。现有写锁被持有时,不同 Host 创建不同的空白会话。所选空白会话被占用时,即使另有空闲空白会话也会新建;导航不再寻找其他候选。不增加清理逻辑。两个 Host 打开同一个已有对话时仍可能遇到写锁争用。未知投影提示仍可见且不扫描冷日志正文;自动复用要求已知的空白元数据。非锁争用的获取错误会中止新建会话,目前只报告到控制台;此流程没有针对隐藏空白会话的恢复操作。
|
||||
Host 可以复用自己的活动空白会话,也可以接管无人占用的持久化空白会话并保留其 slash 状态。现有写锁被持有时,不同 Host 创建不同的空白会话。所选空白会话被占用时,即使另有空闲空白会话也会新建;导航不再寻找其他候选。不增加清理逻辑。两个 Host 打开同一个已有对话时仍可能遇到写锁争用。未知投影提示仍可见且不扫描冷日志正文;自动复用要求已知的空白元数据。非锁争用的获取错误会中止该次导航;在 hero 中选择工作区或显式准备草稿时以提示展示该错误,启动恢复只报告到控制台,以此方式失败的隐藏空白会话在此流程中没有恢复操作。
|
||||
|
||||
## Verification
|
||||
|
||||
导航测试覆盖已保存选择、单候选锁争用回退、其他错误、重叠获取以及后续导航替代。实际 Web 组合测试持有真实持久化写锁,验证另有空闲空白会话时仍新建,并接管已释放的所选空白会话及其 plan 状态。fresh-round-trip 的录制 Session 场景在提交已录制的提示词前刷新空白会话。
|
||||
导航测试覆盖已保存选择、为被占用候选打开 Host 返回的 Session、其他错误、重叠获取以及后续导航替代。实际 Web 组合测试持有真实持久化写锁,验证另有空闲空白会话时仍新建,并接管已释放的所选空白会话及其 plan 状态。fresh-round-trip 的录制 Session 场景在提交已录制的提示词前刷新空白会话。
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
en: 725f773bb350218e
|
||||
zh: dd8fbc09ef48208d
|
||||
/agent-note-declarative-agent-presets-and-retained-revisions/decision:
|
||||
en: 53fd72bf4bbe4eb3
|
||||
zh: 406eee6e4f818e7c
|
||||
en: 88edb925b556300c
|
||||
zh: c6f05b641513d768
|
||||
/agent-note-declarative-agent-presets-and-retained-revisions/alternatives-considered:
|
||||
en: 09f40eab4659e9f6
|
||||
zh: 7a70da0d0ae6845b
|
||||
|
||||
@@ -16,7 +16,7 @@ The registry owns each revision's scope and Loader tree. Agents link their own s
|
||||
|
||||
The Web editor and `agent_preset` tool accept only child plugin YAML. Saving writes the active profile's user patch, preserving display metadata and unrelated configuration. Revision checks and profile locking prevent overwriting concurrent saves; validation rejects a higher-priority override that would defeat the edit. Persistence and activation outcomes are reported separately. Tool writes require approval or full access because configuration executes in the Host.
|
||||
|
||||
Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition. Old executable revisions are process-local, not serialized. New sessions without an explicit preset resolve `selectedDefault`; Client choice filtering and default correction follow [the preset UI policy](../../../../packages/client/ui-agent-preset/README.md).
|
||||
Session data continues to record the preset identity. Restart resolves that identity against current configuration and rejects a missing definition; reusing a blank Session for a new conversation does not resolve it ([decision](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.md)). Old executable revisions are process-local, not serialized. New sessions without an explicit preset resolve `selectedDefault`; Client choice filtering and default correction follow [the preset UI policy](../../../../packages/client/ui-agent-preset/README.md).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ Preset 目录重复承担 Cordis 配置的所有权。独立的发现、元数
|
||||
|
||||
Web 编辑器与 `agent_preset` 工具只接受子插件 YAML。保存写入当前 profile 的用户补丁,保留显示元数据和无关配置。版本检查和 profile 锁防止覆盖并发保存;验证拒绝会使编辑失效的更高优先级覆盖。持久化与激活结果分别报告。配置在 Host 中执行,因此工具写入需要批准或完全访问权限。
|
||||
|
||||
会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复。旧的可执行代际仅保留在进程内,不会序列化。未显式指定 preset 的新会话解析 `selectedDefault`;客户端选项过滤和默认值修正遵循[预设 UI 策略](../../../../packages/client/ui-agent-preset/README.zh.md)。
|
||||
会话数据继续记录 preset 标识。重启时根据当前配置解析该标识,定义缺失则拒绝恢复;为新对话复用空白会话时不解析该标识([决策](../bug-fix/2026-09-29-reused-blank-runs-requested-preset.zh.md))。旧的可执行代际仅保留在进程内,不会序列化。未显式指定 preset 的新会话解析 `selectedDefault`;客户端选项过滤和默认值修正遵循[预设 UI 策略](../../../../packages/client/ui-agent-preset/README.zh.md)。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+2
-2
@@ -15,5 +15,5 @@
|
||||
en: 4089464ad1fb60bd
|
||||
zh: 3168e9e4a205145d
|
||||
/agent-note-keep-live-configuration-in-the-owning-profile/consequences:
|
||||
en: 6033aaa12b2a521b
|
||||
zh: 43934413c97670de
|
||||
en: 41bb170694c785d6
|
||||
zh: 17419fdd6d937dce
|
||||
+1
-1
@@ -24,6 +24,6 @@ The rejected alternative is retaining settings as a second store behind a config
|
||||
|
||||
## Consequences
|
||||
|
||||
This supersedes only the non-secret settings tier in [configuration source ownership](2026-08-04-configuration-source-ownership.md); its environment restrictions and credential ordering remain applicable. [Volatile references](2026-09-18-volatile-config-references.md) own reference lifetime and ordinary update behavior.
|
||||
This supersedes only the non-secret settings tier in [configuration source ownership](2026-08-04-configuration-source-ownership.md); its environment restrictions remain applicable, and [stored credential precedence](2026-10-09-stored-credential-precedence.md) supersedes its credential ordering. [Volatile references](2026-09-18-volatile-config-references.md) own reference lifetime and ordinary update behavior.
|
||||
|
||||
Real profile tests cover multiple instances, persisted restart, invalid edits, stale revisions, higher-layer rejection, secret redaction, and configuration expressions. Browser tests exercise form writes and reset through the assembled application. Runtime reference identity is checked across live edits.
|
||||
+1
-1
@@ -24,6 +24,6 @@ Cordis config patch 替换完整 entry config。编辑会保留普通字段、
|
||||
|
||||
## 后果
|
||||
|
||||
本决定只替代 [配置来源所有权](2026-08-04-configuration-source-ownership.zh.md) 中的非秘密 settings 层;其中的环境限制和凭据优先级仍适用。[Volatile 引用](2026-09-18-volatile-config-references.zh.md) 负责引用生命周期和普通更新行为。
|
||||
本决定只替代 [配置来源所有权](2026-08-04-configuration-source-ownership.zh.md) 中的非秘密 settings 层;其中的环境限制仍适用,其凭据优先级已由[存储凭据优先级](2026-10-09-stored-credential-precedence.zh.md)替代。[Volatile 引用](2026-09-18-volatile-config-references.zh.md) 负责引用生命周期和普通更新行为。
|
||||
|
||||
真实 profile 测试覆盖多实例、持久化重启、非法编辑、过期修订、高优先级层拒绝、秘密脱敏及配置表达式。浏览器测试通过完整应用执行表单写入和重置,并检查实时编辑前后的运行时引用身份。
|
||||
@@ -9,14 +9,14 @@
|
||||
en: 70a969dce3283824
|
||||
zh: 18c61bf840a6518a
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/current-state:
|
||||
en: 8fb06638898de59e
|
||||
zh: 79ed0cfe79e40be1
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/three-unrelated-uplink-mechanisms:
|
||||
en: 386be7b691ab63e7
|
||||
zh: 935e0db22ae1cf5d
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/concrete-costs:
|
||||
en: f374976b43645d15
|
||||
zh: 26b2c7aaafa41fba
|
||||
en: 2de84c3fc0a3552e
|
||||
zh: da9cd565588bf2dd
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/uplink-consumers:
|
||||
en: 403103ef08958529
|
||||
zh: 2813d9d38c2c55b7
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/consumer-responsibilities:
|
||||
en: 28f8a3112d6ec5fe
|
||||
zh: c70d68233e3f6b7f
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/problem/relation-to-background-jobs:
|
||||
en: 5e67475010d52ddb
|
||||
zh: ce23aad2d4aa0d67
|
||||
@@ -30,8 +30,8 @@
|
||||
en: 943208904785f7eb
|
||||
zh: bda1969132a16101
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/terms:
|
||||
en: 401703adbd9cc1c2
|
||||
zh: 56d960c0b6fdff57
|
||||
en: a22cd84756773075
|
||||
zh: 49efa0c5a37d4b57
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/type-signatures:
|
||||
en: 5f9cd00890b6f4c3
|
||||
zh: 600ecba7e60b63f6
|
||||
@@ -42,8 +42,8 @@
|
||||
en: a7180d3c194cf9d9
|
||||
zh: 0dd7833e0e5d0a71
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/peerscope:
|
||||
en: 202aa6e8e8b04bd6
|
||||
zh: b1fc897c09074a7c
|
||||
en: a162af337571f56e
|
||||
zh: 0ae0b9d62d2cb16e
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/client-face:
|
||||
en: 9f10347a73402b8b
|
||||
zh: b939c7a254a8e579
|
||||
@@ -75,8 +75,8 @@
|
||||
en: 46a0c0b3ce1e5671
|
||||
zh: 7743707de42b1d1d
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/in-process-and-worker-carriers:
|
||||
en: ec339e57dc2c0603
|
||||
zh: 3570be8c094ea24c
|
||||
en: df4ae60d8403cdbb
|
||||
zh: 70c6024a2a9a1e84
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/decision/relation-to-events:
|
||||
en: a011793e5a4400ed
|
||||
zh: df8b20115f0348b8
|
||||
@@ -102,8 +102,8 @@
|
||||
en: 26dcd8bebdc8d974
|
||||
zh: e74bcef7c549e70c
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/placement:
|
||||
en: 41128746334320c7
|
||||
zh: 558a676340aae3a2
|
||||
en: ecfaff2b9b2ef021
|
||||
zh: 85b392d79364e0c3
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/key-sequences:
|
||||
en: 130e4f9ec7296a10
|
||||
zh: 2a5aa1302a8db61a
|
||||
@@ -126,17 +126,17 @@
|
||||
en: b42ab0932f8b31b4
|
||||
zh: 7fed8cb5b6e36788
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/alternatives-considered:
|
||||
en: 04e72789417d6e3a
|
||||
zh: b57c5cc68dff45a5
|
||||
en: b0046c9f7c12a876
|
||||
zh: f3c37b466746b0fd
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/consequences:
|
||||
en: 07524e0c2dc22ad5
|
||||
zh: 5bf4f27160d78b43
|
||||
en: dbc54ce699ef8e71
|
||||
zh: 83144f07ae8f8759
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/testing:
|
||||
en: 5c8188930b9d4fdb
|
||||
zh: 1d94e6563cedde13
|
||||
en: 06fbeed7b82ded32
|
||||
zh: 66accb4bd2bf64a2
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/deferred:
|
||||
en: 3b99a327464edc3e
|
||||
zh: dcffdc75369925dc
|
||||
en: 8368c592f30af97f
|
||||
zh: 2ff89acb7b3ed477
|
||||
/agent-note-remote-duplex-stream-one-kind-of-stream-an-uplink-channel-and-an-invocation-context/related:
|
||||
en: 85d77fb246564024
|
||||
zh: 8b400818a91a3e3a
|
||||
en: 475d212ae1f9c1c9
|
||||
zh: 21fe6c6cdb900a6b
|
||||
@@ -8,41 +8,41 @@ English | [中文](2026-09-19-remote-duplex-stream.zh.md)
|
||||
|
||||
### Current state
|
||||
|
||||
`dsh-api-gateway` multiplexes every Typert Remote stream over one WebSocket at the fixed path `/api/remote.mux` (`packages/api/gateway/src/stream-protocol.ts`). A Host method decorated with `@Remote({ mode: 'stream' })` that returns an `Iterable` or `AsyncIterable` becomes one Host-to-Client logical stream; the optional final `signal: AbortSignal` is the only reserved parameter, never enters the wire arguments, and is appended by the Gateway after the decoded business parameters (`packages/api/gateway/src/index.ts`). The generated method the Client receives returns a bare `AsyncIterable`.
|
||||
`dsh-api-gateway` multiplexes Typert Remote streams over one WebSocket at `/api/remote.mux`. A Host method declares both directions with `@Remote({ mode: 'stream' })` and `RemoteStream<Out, In>`; the generated Client method returns `RemoteStreamHandle<Out, In>`. Existing `Iterable` and `AsyncIterable` return types declare downlink-only methods. The optional final `signal: AbortSignal` is supplied by the Gateway and does not enter the wire arguments.
|
||||
|
||||
There are only five wire frames:
|
||||
Both directions share one `streamId`:
|
||||
|
||||
| Direction | Frame | Definition |
|
||||
| --- | --- | --- |
|
||||
| Client to Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts` |
|
||||
| Client to Host | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` |
|
||||
| Client to Host | `{ type: 'end', streamId }` | `stream-protocol.ts` |
|
||||
| Client to Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts` |
|
||||
| Host to Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` |
|
||||
| Host to Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts` |
|
||||
| Host to Client | `{ type: 'end', streamId }` | `stream-protocol.ts` |
|
||||
|
||||
The Client can send only open and cancel. The parser `parseRemoteStreamClientMessage` (`stream-protocol.ts`) throws on any other frame, and a Host connection that receives one closes the whole socket with 1008 (`stream-server.ts`). In short, the Gateway today supports only "a backend iterable sent to the frontend". Every feature that needs the frontend to send data to the backend continuously takes its own detour.
|
||||
The Client handle's `send()` supplies uplink items, `end()` half-closes the uplink, and `dispose()` cancels the logical stream. The Host reads decoded items through `this.ctx.invocation.uplink<In>()`; the invocation also carries the admitted Peer and cancellation signal.
|
||||
|
||||
A Host method also has no notion of who initiated this call: `InvokeRemoteRequest = { namespace, method, args, signal }` (`packages/api/gateway/src/types.ts`) has no caller slot, `RemoteStreamOpener` has the signature `(endpoint, payload, signal)` (`stream-server.ts`), and the WebSocket checks the cookie once, at the handshake.
|
||||
The transport owns validation, cancellation, and its bounded inbox. Consumer protocols own input authority, acknowledgements, and recovery after disconnection.
|
||||
|
||||
### Three unrelated uplink mechanisms
|
||||
### Uplink consumers
|
||||
|
||||
| Feature | Uplink path | Location | Cost |
|
||||
| --- | --- | --- | --- |
|
||||
| Web terminal keystrokes | One unary RPC `terminal.write(agent, id, attachmentId, data)` per xterm `onData`; the Client serializes them through a promise chain and caps its own byte budget | `packages/api/terminal-controller/src/client/model.ts`; Host `src/index.ts` | One HTTP round trip and one authentication per keystroke; two carriers correlated by hand through `attachmentId`; `inputFull` when the budget is exhausted |
|
||||
| Web terminal input and resize | `terminal.attach` carries `TerminalInput` uplink items and `TerminalFrame` downlink items; the Client pipelines commands and releases its raw-input byte budget on `ack` | `packages/api/terminal-controller/src/client/model.ts`; Host `src/terminal.ts` | The current attachment owns input; `inputFull` when the Client budget is exhausted; input is never replayed after reconnection |
|
||||
| File upload | A `Blob` or `ReadableStream` goes to the native `POST /api/session/uploadFileBinary` outside the Remote layer, with `duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts`; route `src/index.ts` | Bypasses Typert descriptors; no type projection |
|
||||
| Answers to approvals and questions | The Host sends waterfall frames down the `$events` stream, the Client answers through a separate unary RPC `$events/result`, correlated by `clientId` plus `eventId` | `packages/api/gateway/src/client/remote-events.ts`; Host `index.ts` | A second correlation key; `index.ts` records the race this creates |
|
||||
|
||||
These three mechanisms solve the same problem: the Client wants to write into a logical stream that is already open.
|
||||
These consumers need Client-to-Host data while a related operation remains active. Web terminals use the duplex stream; file upload and event answers retain their own transports.
|
||||
|
||||
### Concrete costs
|
||||
### Consumer responsibilities
|
||||
|
||||
- **Aligning two carriers.** The terminal uses `attachmentId` to correlate the `follow` stream with the `write` RPC and to decide who owns input. Approvals use `clientId` to correlate the `$events` stream with the `$events/result` RPC.
|
||||
- **No ordering guarantee.** A unary RPC travels over HTTP and is unordered relative to the WebSocket downlink; the terminal can preserve order only by serializing writes on the Client.
|
||||
- **Separate authentication and lookup per uplink.** Every `write(agent, id, attachmentId, data)` resolves `agent`, looks up the terminal, and validates the attachment.
|
||||
- **No EOF.** The Client cannot express "I am done writing"; a half-close needs yet another RPC.
|
||||
- **Cancellation does not cover both halves.** Cancelling the downlink stream does not cancel queued uplink writes, and vice versa.
|
||||
- **Backpressure implemented three times.** The downlink pulls the iterator at the pace of the socket write callback (`stream-server.ts`); the terminal uses a bounded follower that makes a slow consumer fail explicitly (`terminal-controller/src/stream.ts`); the uplink relies on the Client capping `maxInputBytes` itself.
|
||||
- **The documentation is already stale.** `docs/api-gateway.md:160` still says Remote handles one request and one result, a sentence that lags the stream mode the README records.
|
||||
- **Input authority.** A terminal retains its controlling attachment as a Host-local follower. Snapshot and state frames report each attachment's `writable` state; older attachments keep receiving output after control transfers.
|
||||
- **Ordering and acknowledgement.** Terminal writes and resizes share a consecutive command sequence and the terminal's operation queue. An `ack` reports provider acceptance or a control refusal; it never authorizes replay after disconnection.
|
||||
- **Independent lifetimes.** Cancelling `attach` stops its input and output delivery, drops unstarted commands, and joins an in-flight input operation. The PTY lifetime is owned separately by retention and explicit close.
|
||||
- **Coordinated byte limits.** The Gateway exposes its complete-frame inbox capacity and this stream's JSON envelope cost through `invocation.uplinkWindow`. The terminal publishes that window in its snapshot and uses provider acknowledgements to release in-flight capacity. Writes and resizes wait in a bounded Client queue when the window is full. `maxInputBytes` limits raw UTF-8 input; `maxBufferedBytes` separately limits the complete encoded input retained by the Client and output retained by each follower.
|
||||
- **Remaining separate carriers.** Approval answers still correlate `$events` with the `$events/result` RPC through `clientId` and `eventId`. Moving this protocol onto the duplex stream remains deferred.
|
||||
|
||||
### Relation to background jobs
|
||||
|
||||
@@ -66,7 +66,7 @@ Every Remote stream accepts a Client uplink. The method's return type `RemoteStr
|
||||
Client Host
|
||||
───────────────────────────────── ───────────────────────────────────────────
|
||||
const s = remote.job.attach(req, signal)
|
||||
│ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control)
|
||||
│ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control, window)
|
||||
│ │ prepareInvocation
|
||||
│ s.send(v1) → item { streamId, v1 } ───────▶ inbox.push(v1) │ invocation = { request, service, peer, signal, uplink() }
|
||||
│ s.send(v2) → item { streamId, v2 } ───────▶ inbox.push(v2) │ receiverContext.extend({ invocation }).get(service)
|
||||
@@ -88,7 +88,7 @@ Every Remote stream accepts a Client uplink. The method's return type `RemoteStr
|
||||
| Half-close | One direction has ended while the other remains open |
|
||||
| Item | One business value in one direction; every item is validated by the codec independently |
|
||||
| inbox | The bounded uplink queue the Host keeps per logical stream |
|
||||
| Peer | The party the connection layer admitted, one `PeerScope`; this Host has exactly one Peer, the operator |
|
||||
| Peer | The party one connection speaks for, one `PeerScope` carrying its principal; every browser connection shares `connection.browserPeer`, and in-process code creates its own through `connection.createPeer()` ([Peer principals](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)) |
|
||||
| Invocation context | `RemoteInvocation`: the request, receiving service, Peer, cancellation signal, and uplink entry of this call |
|
||||
|
||||
### Type signatures
|
||||
@@ -135,10 +135,11 @@ export interface RemoteInvocation {
|
||||
}
|
||||
/** Cordis service key of the receiving service. */
|
||||
readonly service: string
|
||||
/** The Peer that initiated the call. In-process carriers and unadmitted calls are the operator. */
|
||||
/** The Peer that initiated the call; every carrier names it explicitly. */
|
||||
readonly peer: PeerScope
|
||||
/** Carrier cancellation: Client cancel, socket closure, uplink failure. */
|
||||
readonly signal: AbortSignal
|
||||
readonly uplinkWindow?: RemoteUplinkWindow | undefined
|
||||
/**
|
||||
* The uplink items of this call. Can be taken once; the second call throws. With an uplink codec
|
||||
* in the descriptor each item is decoded to In; without one, items are delivered as unknown with
|
||||
@@ -179,24 +180,28 @@ The mechanism relies on two existing Cordis behaviors:
|
||||
/** The opaque identity of one Peer. */
|
||||
export type PeerId = Branded<'PeerId'>
|
||||
|
||||
/** One Peer's session on this Host: opened and released by the admitting side; ctx owns connection-lifetime registrations. */
|
||||
/** One Peer's session on this Host: opened and released by its creator; ctx owns connection-lifetime registrations. */
|
||||
export interface PeerScope {
|
||||
readonly id: PeerId
|
||||
readonly ctx: Context
|
||||
/** Caller identity fixed at creation. */
|
||||
readonly principal: Principal
|
||||
/** Aborted once the Peer is disposed. */
|
||||
readonly signal: AbortSignal
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
This Host has exactly one Peer: the operator. `@deepseek-ai/dsh-client-connection` creates it with `createScope(connectionCtx, peer)` when the service applies, the same mechanism an Agent uses to create its own scope: the Peer object is the ScopeKey, `peer.ctx` carries connection-lifetime effects, and `dispose()` runs with the Connection's release and quiets the fiber. It is exposed as `connection.operator`.
|
||||
`@deepseek-ai/dsh-client-connection` implements the one Peer class, `Peer(owner, { principal })`, with `createScope(owner, peer)`, the same mechanism an Agent uses to create its own scope: the Peer object is the ScopeKey, `peer.ctx` carries connection-lifetime effects, `peer.signal` aborts when the scope ends, and `dispose()` quiets the fiber. Connection creates `connection.browserPeer` with the owner principal when the service applies; [Peer principals and method-level Remote access control](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns principals, `createPeer()`, and access checks.
|
||||
|
||||
| Member | Semantics |
|
||||
| --- | --- |
|
||||
| `connection.operator: PeerScope` | The only Peer; lives and dies with the Connection |
|
||||
| `connection.admit(request): PeerAdmission` | Runs the existing `requestRejection`: returns `{ rejection: 401 \| 403 }` when refused, otherwise `{ peer: operator }`. Future admitters plug in here |
|
||||
| `connection.browserPeer: PeerScope` | The Peer every authenticated browser connection shares; owner principal; lives and dies with the Connection |
|
||||
| `connection.admit(request): PeerAdmission` | Runs the existing `requestRejection`: returns `{ rejection: 401 \| 403 }` when refused, otherwise `{ peer: browserPeer }`. Future admitters plug in here |
|
||||
|
||||
The Gateway's `handleUpgrade(req, socket, head, peer)` binds an opener with a fixed Peer to that socket and registers socket closure in `peer.ctx.effect`; when the scope is already inactive, it closes the socket directly with 1001 instead. When `InvokeRemoteRequest.peer?` or the `peer` parameter of `RemoteStreamOpener` is absent, the answer is the operator, so existing in-process callers change nothing; in a composition without a Connection, the operator stand-in the Gateway builds for itself is likewise created through `createScope`, with the same `dispose()` contract. Looking up a Peer by id, associating a carrier with a second Peer, and Peer open and close events all wait until the first consumer that needs multiple Peers appears.
|
||||
The Gateway's `handleUpgrade(req, socket, head, peer)` binds an opener with a fixed Peer to that socket and registers socket closure in `peer.ctx.effect`; when the scope is already inactive, it closes the socket directly with 1001 instead. `InvokeRemoteRequest.peer` and the `peer` parameter of `RemoteStreamOpener` and `wireStream.open` are required, so every in-process caller names its Peer, and the Gateway creates none. Looking up a Peer by id and Peer open and close events wait until the first consumer that needs them appears.
|
||||
|
||||
The Peer carries no access model: who the Peer is and what it may do are attached by business plugins on their own through `peer.ctx`.
|
||||
The Peer carries its principal; what that principal may call is decided by the roles, bindings, and policies that [Peer principals and method-level Remote access control](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) defines.
|
||||
|
||||
### Client face
|
||||
|
||||
@@ -311,7 +316,7 @@ Credit frames wait until a consumer that needs sustained high-volume uplink appe
|
||||
| Worker tunnel (`packages/experimental/webworker-runtime`) | The page adds `stream-uplink-item` / `stream-uplink-end` frames; `serveStream` assembles them into an `uplink` handed to `seams.openStream`. Same-origin trusted boundary: no inbox limit, and items after `end` are dropped |
|
||||
| `remote-mock` (`packages/test-support/remote-mock`) | `StreamHandle.uplink: AsyncIterable<unknown>`; `rpc.open` passes it through |
|
||||
|
||||
An in-process carrier with no `peer` means the operator.
|
||||
An in-process carrier passes its Peer explicitly; the in-page Host of the worker tunnel passes `connection.browserPeer`.
|
||||
|
||||
### Relation to `$events`
|
||||
|
||||
@@ -325,7 +330,7 @@ An in-process carrier with no `peer` means the operator.
|
||||
// types.ts
|
||||
export type RemoteStream<Out, In = never> = AsyncIterable<Out> & { readonly [STREAM_UPLINK]?: In }
|
||||
export type PeerId = Branded<'PeerId'>
|
||||
export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise<void> }
|
||||
export interface PeerScope { readonly id: PeerId; readonly ctx: Context; readonly principal: Principal; readonly signal: AbortSignal; dispose(): Promise<void> }
|
||||
export interface RemoteInvocation { … } // see Host face
|
||||
declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } }
|
||||
|
||||
@@ -362,13 +367,13 @@ export interface InvokeRemoteRequest {
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
/** Uplink items; absent is equivalent to an iterator that ends immediately. */
|
||||
readonly uplink?: AsyncIterable<unknown>
|
||||
/** The Peer that initiated the call; absent means the operator. */
|
||||
readonly peer?: PeerScope
|
||||
/** The Peer that initiated the call; required on every path. */
|
||||
readonly peer: PeerScope
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface TypertGatewayWireStream {
|
||||
open: (endpoint: string, payload: unknown, uplink: AsyncIterable<unknown>, peer: PeerScope | undefined, signal: AbortSignal) => Promise<AsyncIterable<unknown>>
|
||||
open: (endpoint: string, payload: unknown, uplink: AsyncIterable<unknown>, peer: PeerScope, signal: AbortSignal) => Promise<AsyncIterable<unknown>>
|
||||
failure: (error: unknown) => RemoteStreamFailure
|
||||
}
|
||||
|
||||
@@ -379,6 +384,7 @@ export type RemoteStreamOpener = (
|
||||
uplink: AsyncIterable<unknown>,
|
||||
peer: PeerScope,
|
||||
control: AbortController,
|
||||
window: RemoteUplinkWindow,
|
||||
) => Promise<AsyncIterable<unknown>>
|
||||
|
||||
// index.ts Config addition
|
||||
@@ -394,7 +400,7 @@ const signal = request.signal ?? NEVER_ABORTED_SIGNAL
|
||||
const invocation = new GatewayInvocation(
|
||||
{ namespace: request.namespace, method: request.method, args: request.args },
|
||||
descriptor.service,
|
||||
request.peer ?? this.operatorPeer(),
|
||||
request.peer,
|
||||
signal,
|
||||
new UplinkDecoder(request.uplink ?? EMPTY_ASYNC_ITERABLE, descriptor.uplink?.codec, endpoint, control),
|
||||
)
|
||||
@@ -444,8 +450,8 @@ cancel : missing → ignore; otherwise control.abort(new Error('Remote stream ca
|
||||
| `dsh-typert-protocol` (`types.ts`, `index.ts`) | `RemoteStream`, `RemoteStreamHandle`, `PeerId`, `PeerScope`, `RemoteInvocation`, the `ctx.invocation` declaration merge, `InvocationDescriptor.uplink`; the decorator recognizes only `mode: 'stream'`; `TypertRemoteService` registers the `invocation` accessor when constructed |
|
||||
| `dsh-typert-registry` | Load-time validation that `uplink.codec` is a valid strict codec |
|
||||
| `dsh-typert-generator` (`model.ts`, `analyzer.ts`, `emitter.ts`) | `remoteResultType` recognizes `RemoteStream<Out, In>` and generates the `uplink` boundary; the descriptor emits `uplink: { codec }`; the generated Client signature returns `RemoteStreamHandle<Out, In>`; the parameter loop accepts only business parameters and a final `signal` |
|
||||
| `dsh-client-connection` (`operator-peer.ts`, `rpc.ts`, `rpc-host.ts`, `index.ts`) | The operator `PeerScope`; `connection.operator` and `admit`; the `/api` and upgrade routes obtain the Peer through `admit`; the optional `uplink` of `rpc.open` |
|
||||
| `dsh-api-gateway` Host (`stream-protocol.ts`, `stream-server.ts`, `types.ts`, `index.ts`) | `item` / `end` frames; `UplinkInbox`; `Config.streamInboxBytes`; `GatewayInvocation` and `extend({ invocation })`; `UplinkDecoder`; `handleUpgrade(req, socket, head, peer)`; `operatorPeer()` |
|
||||
| `dsh-client-connection` (`peer.ts`, `rpc.ts`, `rpc-host.ts`, `index.ts`) | The `Peer` class; `connection.browserPeer` and `admit`; the `/api` and upgrade routes obtain the Peer through `admit`; the optional `uplink` of `rpc.open` |
|
||||
| `dsh-api-gateway` Host (`stream-protocol.ts`, `stream-server.ts`, `types.ts`, `index.ts`) | `item` / `end` frames; `UplinkInbox`; `Config.streamInboxBytes`; `GatewayInvocation` and `extend({ invocation })`; `UplinkDecoder`; `handleUpgrade(req, socket, head, peer)` |
|
||||
| `dsh-api-gateway` Client (`client/index.ts`, `client/stream-client.ts`) | `invokeStream` returns the handle; the uplink pump is driven by the handle's queue |
|
||||
| `dsh-webworker-runtime`, `dsh-remote-mock` | The worker tunnel's two uplink frames and the `serveStream` assembly; `StreamHandle.uplink`; the direct proxy returns the real handle |
|
||||
| Documentation | The READMEs of gateway, the api group, connection, protocol, and generator; `docs/api-gateway`; the type-equiv blocks of `docs/subsystems/typert`; the config and Cordis catalogs |
|
||||
@@ -560,7 +566,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
**Putting the Client's `send` on the `AsyncIterable` the generated method returns.** Hanging methods on an `AsyncIterable` is unconventional; the handle type `RemoteStreamHandle` extends it explicitly.
|
||||
|
||||
**Copying the Access mechanism.** Access control is business semantics, served by `vouch` and the `remote/invoke` waterfall; this Note only needs "whose stream is this", and `PeerScope` suffices.
|
||||
**Copying the Access mechanism.** Access control is business semantics, served by `vouch` and the `remote/invoke` waterfall; this Note only needs "whose stream is this", and `PeerScope` suffices. Method-level access control later moved to [Peer principals and method-level Remote access control](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md), which supersedes this part.
|
||||
|
||||
**Keeping unary RPC for the uplink.** One HTTP round trip, one authentication, and one lookup per uplink; two carriers with no ordering relation that only Client-side serialization can order; no EOF; cancellation that does not cover both halves; a hand-made correlation key per feature.
|
||||
|
||||
@@ -574,10 +580,11 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
- **Bought**: one stream with two directions, so interactive scenarios such as terminal keystrokes, approval answers, and background-job stdin no longer each build their own "one stream plus one unary"; a Host method knows its caller; the uplink and downlink types are declared in one place, the return type; uplink items come from the browser and are validated strictly per item at the Host, while downlink items are Host-produced typed values that pass straight through; the generated output and wire frames of existing `AsyncIterable<Out>` methods and unary methods are completely unchanged.
|
||||
- **`this.ctx.invocation` is `undefined` outside a Remote call**, so a method that reads it must handle the optional; a service method invoked only directly in process correctly reads `undefined`.
|
||||
- **Web terminals use `attach`** for screen output and input commands, with provider acknowledgements and per-attachment writable state; their Client retains the current generation's handle and never replays input.
|
||||
- **The inbox limit applies to every stream**: a method that does not read its uplink fails as a whole when it receives many uplink frames. This is a deliberate explicit failure, recorded in the README.
|
||||
- **The operator is the only Peer**, and `admit` is the only admission point; the first consumer that needs a second Peer must add opening, association, and events in `dsh-client-connection`.
|
||||
- **Every browser connection shares `browserPeer`**, and `admit` is the only network admission point; in-process code creates further Peers with `connection.createPeer()`.
|
||||
- **No cross-direction ordering guarantee between uplink and downlink**; a protocol that needs request-response pairing carries its own sequence numbers.
|
||||
- **Upper-layer protocols do not yet expose `send`**: consumers of `$stream`, snapshot, and journal use only the downlink today; the object they receive is already the handle.
|
||||
- **Upper-layer supervisors expose only the downlink**: `$stream`, snapshot and journal do not expose `send`; a domain that writes uplink retains its generated physical handle for that generation.
|
||||
- **The worker tunnel behaves differently from the WebSocket carrier**: no inbox limit, and items after `end` are dropped, because page to worker is a same-origin trusted boundary.
|
||||
|
||||
## Testing
|
||||
@@ -588,17 +595,15 @@ Retention cases in `gateway-stream.host.spec.ts` and `gateway.client.spec.ts` co
|
||||
- Gateway Host: exact-key validation in frame parsing; `UplinkInbox` buffering during opening, `item` after `end`, overflow, ignored unknown `streamId`, and cleanup on socket closure; `UplinkDecoder` delivery with and without a codec and abort on decode failure; `uplink()` taken only once; the `extend({ invocation })` view and the origin of `peer`; `cancellableStream` closing the uplink before `return()`.
|
||||
- Gateway Client: the handle's `send` (JSON validation, queued before open, throws after termination), idempotent `end`, `dispose` driving the `cancel` frame and ending later reads; the pump's three exits; the pump stopping as soon as a terminal frame arrives.
|
||||
- typert: the analyzer's handling of `RemoteStream<Out, In>`, `RemoteStream<Out>`, `never`, and the existing `AsyncIterable<Out>`; decorator options; registry load-time validation.
|
||||
- connection: the three outcomes of `admit`, 401, 403, and the operator; the operator reaching handlers through the bridge and the shared handler; release with the Connection.
|
||||
- connection: the three outcomes of `admit`, 401, 403, and `browserPeer`; `browserPeer` reaching handlers through the bridge and the shared handler; release with the Connection.
|
||||
- remote-mock and the worker tunnel: uplink pass-through; the direct proxy returning the real handle.
|
||||
- Existing stream and unary method tests pass without changed expectations.
|
||||
|
||||
## Deferred
|
||||
|
||||
- `$stream` / snapshot / journal expose the handle's `send` to upper-layer protocols.
|
||||
- Web terminal: one `attach` stream replaces `follow` plus `write` plus `resize`, and `attachmentId` retires.
|
||||
- Duplex `$events`: waterfall answers become uplink items on the same `streamId`, and `$events/result` and `clientId` retire.
|
||||
- Credit frames: wait for a consumer that needs sustained high-volume uplink; the inbox limit is the initial credit.
|
||||
- The `streamInboxBytes` default of 262144 is far more than keyboard and stdin need; if the first consumer is terminal paste, it may be aligned with the `maxInputBytes` of `terminal-controller`.
|
||||
- A Host-initiated half-close frame (the Host stops reading the uplink but keeps writing the downlink): no consumer; not added.
|
||||
- `uplink()` on unary methods: it can read items that arrive while the method runs, semantically valid but of doubtful use; allowed, noted in the README.
|
||||
|
||||
@@ -606,4 +611,4 @@ Retention cases in `gateway-stream.host.spec.ts` and `gateway.client.spec.ts` co
|
||||
|
||||
- [Remote event delivery](../../../../packages/api/remotes/README.md): the unary `$events/result` answer path; the duplex `$events` item under Deferred partially supersedes it once it lands.
|
||||
- [Session history and event transport](2026-08-18-session-history-and-event-transport.md): the `$stream()` supervisor and the journal / snapshot protocols above it, which this note leaves unchanged.
|
||||
- [Web sidebar terminal](../feature/2026-09-09-web-sidebar-terminal.md): the unary terminal `write` and `attachmentId`; the `attach` stream under Deferred replaces it once it lands.
|
||||
- [Web sidebar terminal](../feature/2026-09-09-web-sidebar-terminal.md): the `attach` stream carries input commands, provider acknowledgements and per-attachment writable state; reconnect never replays input.
|
||||
@@ -8,41 +8,41 @@ Status: implemented
|
||||
|
||||
### 现状
|
||||
|
||||
`dsh-api-gateway` 在一条 WebSocket 上复用全部 Typert Remote 流,路径固定为 `/api/remote.mux`(`packages/api/gateway/src/stream-protocol.ts`)。一个 Host 方法用 `@Remote({ mode: 'stream' })` 修饰并返回 `Iterable` 或 `AsyncIterable`,就成为一条 Host 到 Client 的逻辑流;可选的末位 `signal: AbortSignal` 是唯一的保留参数,不进线路参数,由网关在解码后的业务参数之后追加(`packages/api/gateway/src/index.ts`)。客户端拿到的生成方法返回一个裸 `AsyncIterable`。
|
||||
`dsh-api-gateway` 在 `/api/remote.mux` 的一条 WebSocket 上复用 Typert Remote 流。Host 方法通过 `@Remote({ mode: 'stream' })` 和 `RemoteStream<Out, In>` 声明两个方向;生成的 Client 方法返回 `RemoteStreamHandle<Out, In>`。既有的 `Iterable` 和 `AsyncIterable` 返回类型声明仅下行的方法。可选的末位 `signal: AbortSignal` 由 Gateway 提供,不进入线路参数。
|
||||
|
||||
线路帧只有五种:
|
||||
两个方向共用一个 `streamId`:
|
||||
|
||||
| 方向 | 帧 | 定义 |
|
||||
| --- | --- | --- |
|
||||
| Client 到 Host | `{ type: 'open', streamId, endpoint, payload }` | `stream-protocol.ts` |
|
||||
| Client 到 Host | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` |
|
||||
| Client 到 Host | `{ type: 'end', streamId }` | `stream-protocol.ts` |
|
||||
| Client 到 Host | `{ type: 'cancel', streamId }` | `stream-protocol.ts` |
|
||||
| Host 到 Client | `{ type: 'item', streamId, value? }` | `stream-protocol.ts` |
|
||||
| Host 到 Client | `{ type: 'error', streamId, error: { code, message, details } }` | `stream-protocol.ts` |
|
||||
| Host 到 Client | `{ type: 'end', streamId }` | `stream-protocol.ts` |
|
||||
|
||||
客户端能发的只有打开与取消。解析器 `parseRemoteStreamClientMessage`(`stream-protocol.ts`)对其他任何帧抛错,Host 连接收到后以 1008 关闭整个 socket(`stream-server.ts`)。结论:网关今天只支持"后端 iterable 发到前端"。需要前端向后端持续发数据的功能,各自绕路。
|
||||
Client 句柄的 `send()` 提供上行项,`end()` 半关闭上行,`dispose()` 取消逻辑流。Host 通过 `this.ctx.invocation.uplink<In>()` 读取解码后的项;调用上下文同时携带获准接入的 Peer 和取消信号。
|
||||
|
||||
Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteRequest = { namespace, method, args, signal }`(`packages/api/gateway/src/types.ts`)没有调用者槽位,`RemoteStreamOpener` 的签名是 `(endpoint, payload, signal)`(`stream-server.ts`),WebSocket 只在握手时校一次 cookie。
|
||||
传输层负责校验、取消和有界 inbox。消费者协议负责输入权限、确认以及断线后的恢复。
|
||||
|
||||
### 三套各自为政的上行机制
|
||||
### 上行消费者
|
||||
|
||||
| 功能 | 上行方式 | 位置 | 代价 |
|
||||
| --- | --- | --- | --- |
|
||||
| Web 终端按键 | 每次 xterm `onData` 一个 unary RPC `terminal.write(agent, id, attachmentId, data)`,客户端用 promise 链串行化并自限字节预算 | `packages/api/terminal-controller/src/client/model.ts`;Host `src/index.ts` | 一次按键一次 HTTP 往返与鉴权;两条载体靠 `attachmentId` 手工关联;满了报 `inputFull` |
|
||||
| Web 终端输入与尺寸调整 | `terminal.attach` 承载上行 `TerminalInput` 和下行 `TerminalFrame`;Client 连续发送命令,并在收到 `ack` 后释放原始输入字节预算 | `packages/api/terminal-controller/src/client/model.ts`;Host `src/terminal.ts` | 当前附件拥有输入权;Client 预算耗尽时报 `inputFull`;重连后绝不重放输入 |
|
||||
| 文件上传 | `Blob` 或 `ReadableStream` 走 Remote 层之外的原生 `POST /api/session/uploadFileBinary`,`duplex: 'half'` | `packages/client/file-upload/src/client/runtime.ts`;路由 `src/index.ts` | 不经 Typert 描述符,没有类型投影 |
|
||||
| 审批与提问的回答 | Host 在 `$events` 流上下发 waterfall 帧,Client 用一个独立的 unary RPC `$events/result` 回答,靠 `clientId` 加 `eventId` 关联 | `packages/api/gateway/src/client/remote-events.ts`;Host `index.ts` | 第二套关联键;`index.ts` 记录了由此产生的竞态 |
|
||||
|
||||
这三套机制解决的是同一个问题:一条已经打开的逻辑流,客户端想往里写。
|
||||
这些消费者都需要在相关操作仍进行时从 Client 向 Host 传送数据。Web 终端使用双工流;文件上传和事件回答保留各自的传输方式。
|
||||
|
||||
### 具体代价
|
||||
### 消费者职责
|
||||
|
||||
- **两条载体对齐**。终端要用 `attachmentId` 把 `follow` 流与 `write` RPC 关联起来,并用它决定谁拥有输入权。审批要用 `clientId` 把 `$events` 流与 `$events/result` RPC 关联起来。
|
||||
- **没有顺序保证**。unary RPC 走 HTTP,与 WebSocket 下行互不排序;终端只能在客户端串行化写入来自保顺序。
|
||||
- **每次上行单独鉴权与查找**。`write(agent, id, attachmentId, data)` 每次解析 `agent`、查终端、校验 attachment。
|
||||
- **没有 EOF**。客户端无法表达"我这边写完了",半关闭要再加一个 RPC。
|
||||
- **取消不覆盖两半**。取消下行流不会取消排队中的上行写;反之亦然。
|
||||
- **背压各写一套**。下行按 socket 写回调节拍拉迭代器(`stream-server.ts`);终端用有界 follower 让慢消费者显式失败(`terminal-controller/src/stream.ts`);上行靠客户端自限 `maxInputBytes`。
|
||||
- **文档已经过时**。`docs/api-gateway.md:160` 仍写着 Remote 只处理一请求一结果,这一句已经落后于 README 记录的 stream 模式。
|
||||
- **输入权限**。终端通过 Host 本地的 follower 持有控制附件。快照和状态帧报告各附件的 `writable` 状态;控制权转移后,旧附件继续接收输出。
|
||||
- **顺序与确认**。终端输入和尺寸调整共用连续命令序列及终端操作队列。`ack` 报告提供者已接受操作或输入权限拒绝,绝不授权断线后重放。
|
||||
- **独立生命周期**。取消 `attach` 会停止其输入和输出传递、丢弃尚未开始的命令,并等待在途输入操作收尾。PTY 的生命周期由保留机制和显式关闭独立管理。
|
||||
- **协调字节限额**。Gateway 通过 `invocation.uplinkWindow` 提供完整帧 inbox 容量及本流的 JSON 封装开销。终端在快照中公布窗口,并通过提供者确认释放在途额度。窗口满时,输入和尺寸调整命令在有界 Client 队列中等待。`maxInputBytes` 限制原始 UTF-8 输入;`maxBufferedBytes` 分别限制 Client 保留的完整编码输入及每个 follower 保留的输出。
|
||||
- **仍独立的载体**。审批回答仍通过 `clientId` 和 `eventId` 将 `$events` 与 `$events/result` RPC 关联。将该协议迁入双工流仍属后续工作。
|
||||
|
||||
### 与后台 job 的关系
|
||||
|
||||
@@ -66,7 +66,7 @@ Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteReq
|
||||
Client Host
|
||||
───────────────────────────────── ───────────────────────────────────────────
|
||||
const s = remote.job.attach(req, signal)
|
||||
│ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control)
|
||||
│ open { streamId, endpoint, payload } ───────▶ opener(endpoint, payload, uplink, peer, control, window)
|
||||
│ │ prepareInvocation
|
||||
│ s.send(v1) → item { streamId, v1 } ───────▶ inbox.push(v1) │ invocation = { request, service, peer, signal, uplink() }
|
||||
│ s.send(v2) → item { streamId, v2 } ───────▶ inbox.push(v2) │ receiverContext.extend({ invocation }).get(service)
|
||||
@@ -88,7 +88,7 @@ Host 方法也没有"这次调用是谁发起的"这个概念:`InvokeRemoteReq
|
||||
| 半关闭 | 一个方向已结束而另一个方向仍开放 |
|
||||
| 项 | 一个方向上的一个业务值;每项独立经 codec 校验 |
|
||||
| inbox | Host 侧每逻辑流一个的有界上行队列 |
|
||||
| Peer | 连接层被接纳的一方,一个 `PeerScope`;本 Host 只有操作者一个 Peer |
|
||||
| Peer | 一条连接代表的一方,一个携带 principal 的 `PeerScope`;所有浏览器连接共享 `connection.browserPeer`,进程内代码经 `connection.createPeer()` 自建([Peer 身份属性](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md)) |
|
||||
| 调用上下文 | `RemoteInvocation`:本次调用的请求、接收服务、Peer、取消信号与上行入口 |
|
||||
|
||||
### 类型签名
|
||||
@@ -135,10 +135,11 @@ export interface RemoteInvocation {
|
||||
}
|
||||
/** Cordis service key of the receiving service. */
|
||||
readonly service: string
|
||||
/** The Peer that initiated the call. In-process carriers and unadmitted calls are the operator. */
|
||||
/** The Peer that initiated the call; every carrier names it explicitly. */
|
||||
readonly peer: PeerScope
|
||||
/** Carrier cancellation: Client cancel, socket closure, uplink failure. */
|
||||
readonly signal: AbortSignal
|
||||
readonly uplinkWindow?: RemoteUplinkWindow | undefined
|
||||
/**
|
||||
* The uplink items of this call. Can be taken once; the second call throws. With an uplink codec
|
||||
* in the descriptor each item is decoded to In; without one, items are delivered as unknown with
|
||||
@@ -179,24 +180,28 @@ declare module '@deepseek-ai/cordis' {
|
||||
/** The opaque identity of one Peer. */
|
||||
export type PeerId = Branded<'PeerId'>
|
||||
|
||||
/** One Peer's session on this Host: opened and released by the admitting side; ctx owns connection-lifetime registrations. */
|
||||
/** One Peer's session on this Host: opened and released by its creator; ctx owns connection-lifetime registrations. */
|
||||
export interface PeerScope {
|
||||
readonly id: PeerId
|
||||
readonly ctx: Context
|
||||
/** Caller identity fixed at creation. */
|
||||
readonly principal: Principal
|
||||
/** Aborted once the Peer is disposed. */
|
||||
readonly signal: AbortSignal
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
本 Host 只有一个 Peer:操作者。`@deepseek-ai/dsh-client-connection` 在服务 apply 时用 `createScope(connectionCtx, peer)` 建它,与 Agent 建自己的 scope 是同一机制:Peer 对象就是 ScopeKey,`peer.ctx` 承接连接期效果,`dispose()` 随 connection 释放让 fiber 静默。它以 `connection.operator` 暴露。
|
||||
`@deepseek-ai/dsh-client-connection` 实现唯一的 Peer 类 `Peer(owner, { principal })`,用 `createScope(owner, peer)` 建立,与 Agent 建自己的 scope 是同一机制:Peer 对象就是 ScopeKey,`peer.ctx` 承接连接期效果,scope 结束时 `peer.signal` 中止,`dispose()` 让 fiber 静默。Connection 在服务 apply 时以 owner principal 创建 `connection.browserPeer`;principal、`createPeer()` 与访问检查归 [Peer 身份属性与 Remote 方法级访问控制](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md)。
|
||||
|
||||
| 成员 | 语义 |
|
||||
| --- | --- |
|
||||
| `connection.operator: PeerScope` | 唯一的 Peer,随 connection 生灭 |
|
||||
| `connection.admit(request): PeerAdmission` | 过既有的 `requestRejection`:被拒返回 `{ rejection: 401 \| 403 }`,否则返回 `{ peer: operator }`。将来的接纳方在这里接入 |
|
||||
| `connection.browserPeer: PeerScope` | 所有通过认证的浏览器连接共享的 Peer;principal 为 owner;随 connection 生灭 |
|
||||
| `connection.admit(request): PeerAdmission` | 过既有的 `requestRejection`:被拒返回 `{ rejection: 401 \| 403 }`,否则返回 `{ peer: browserPeer }`。将来的接纳方在这里接入 |
|
||||
|
||||
网关 `handleUpgrade(req, socket, head, peer)` 为该 socket 绑一个固定 Peer 的 opener,并在 `peer.ctx.effect` 里登记 socket 关闭;scope 已失效时改为直接以 1001 关闭 socket。`InvokeRemoteRequest.peer?` 与 `RemoteStreamOpener` 的 `peer` 形参缺席时按操作者作答,现有进程内调用方零改动;没有 connection 的组合里网关自建的操作者替身同样经 `createScope` 建立,`dispose()` 契约一致。按 id 查 Peer、把载体关联到第二个 Peer、Peer 的开关事件,都等第一个需要多 Peer 的消费者出现时再加。
|
||||
网关 `handleUpgrade(req, socket, head, peer)` 为该 socket 绑一个固定 Peer 的 opener,并在 `peer.ctx.effect` 里登记 socket 关闭;scope 已失效时改为直接以 1001 关闭 socket。`InvokeRemoteRequest.peer`、`RemoteStreamOpener` 与 `wireStream.open` 的 `peer` 形参均为 required,每个进程内调用方都显式给出 Peer,网关不自建 Peer。按 id 查 Peer、Peer 的开关事件,都等第一个需要它们的消费者出现时再加。
|
||||
|
||||
Peer 不携带访问模型:Peer 是谁、能做什么,由业务插件通过 `peer.ctx` 自行附加。
|
||||
Peer 携带自己的 principal;该 principal 能调用什么,由 [Peer 身份属性与 Remote 方法级访问控制](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 定义的 role、binding 与 policy 判定。
|
||||
|
||||
### Client 面
|
||||
|
||||
@@ -311,7 +316,7 @@ credit 帧留待需要持续大流量上行的消费者出现时再加;inbox
|
||||
| worker 隧道(`packages/experimental/webworker-runtime`) | 页面新增 `stream-uplink-item` / `stream-uplink-end` 帧;`serveStream` 把它们组装成 `uplink` 交给 `seams.openStream`。同源可信边界:没有 inbox 上限,`end` 之后的项丢弃 |
|
||||
| `remote-mock`(`packages/test-support/remote-mock`) | `StreamHandle.uplink: AsyncIterable<unknown>`;`rpc.open` 透传 |
|
||||
|
||||
进程内载体的 `peer` 缺席即操作者。
|
||||
进程内载体显式传入 Peer;worker 隧道的页内 Host 传 `connection.browserPeer`。
|
||||
|
||||
### 与 `$events` 的关系
|
||||
|
||||
@@ -325,7 +330,7 @@ credit 帧留待需要持续大流量上行的消费者出现时再加;inbox
|
||||
// types.ts
|
||||
export type RemoteStream<Out, In = never> = AsyncIterable<Out> & { readonly [STREAM_UPLINK]?: In }
|
||||
export type PeerId = Branded<'PeerId'>
|
||||
export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise<void> }
|
||||
export interface PeerScope { readonly id: PeerId; readonly ctx: Context; readonly principal: Principal; readonly signal: AbortSignal; dispose(): Promise<void> }
|
||||
export interface RemoteInvocation { … } // see Host face
|
||||
declare module '@deepseek-ai/cordis' { interface Context { readonly invocation: RemoteInvocation | undefined } }
|
||||
|
||||
@@ -362,13 +367,13 @@ export interface InvokeRemoteRequest {
|
||||
readonly args: Readonly<Record<string, unknown>>
|
||||
/** Uplink items; absent is equivalent to an iterator that ends immediately. */
|
||||
readonly uplink?: AsyncIterable<unknown>
|
||||
/** The Peer that initiated the call; absent means the operator. */
|
||||
readonly peer?: PeerScope
|
||||
/** The Peer that initiated the call; required on every path. */
|
||||
readonly peer: PeerScope
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface TypertGatewayWireStream {
|
||||
open: (endpoint: string, payload: unknown, uplink: AsyncIterable<unknown>, peer: PeerScope | undefined, signal: AbortSignal) => Promise<AsyncIterable<unknown>>
|
||||
open: (endpoint: string, payload: unknown, uplink: AsyncIterable<unknown>, peer: PeerScope, signal: AbortSignal) => Promise<AsyncIterable<unknown>>
|
||||
failure: (error: unknown) => RemoteStreamFailure
|
||||
}
|
||||
|
||||
@@ -379,6 +384,7 @@ export type RemoteStreamOpener = (
|
||||
uplink: AsyncIterable<unknown>,
|
||||
peer: PeerScope,
|
||||
control: AbortController,
|
||||
window: RemoteUplinkWindow,
|
||||
) => Promise<AsyncIterable<unknown>>
|
||||
|
||||
// index.ts Config addition
|
||||
@@ -394,7 +400,7 @@ const signal = request.signal ?? NEVER_ABORTED_SIGNAL
|
||||
const invocation = new GatewayInvocation(
|
||||
{ namespace: request.namespace, method: request.method, args: request.args },
|
||||
descriptor.service,
|
||||
request.peer ?? this.operatorPeer(),
|
||||
request.peer,
|
||||
signal,
|
||||
new UplinkDecoder(request.uplink ?? EMPTY_ASYNC_ITERABLE, descriptor.uplink?.codec, endpoint, control),
|
||||
)
|
||||
@@ -444,8 +450,8 @@ cancel : missing → ignore; otherwise control.abort(new Error('Remote stream ca
|
||||
| `dsh-typert-protocol`(`types.ts`、`index.ts`) | `RemoteStream`、`RemoteStreamHandle`、`PeerId`、`PeerScope`、`RemoteInvocation`、`ctx.invocation` 声明合并、`InvocationDescriptor.uplink`;修饰器只认 `mode: 'stream'`;`TypertRemoteService` 构造时注册 `invocation` accessor |
|
||||
| `dsh-typert-registry` | 加载校验 `uplink.codec` 为有效的严格 codec |
|
||||
| `dsh-typert-generator`(`model.ts`、`analyzer.ts`、`emitter.ts`) | `remoteResultType` 识别 `RemoteStream<Out, In>` 并生成 `uplink` boundary;描述符输出 `uplink: { codec }`;生成的 Client 签名返回 `RemoteStreamHandle<Out, In>`;参数循环只认业务参数与末位 `signal` |
|
||||
| `dsh-client-connection`(`operator-peer.ts`、`rpc.ts`、`rpc-host.ts`、`index.ts`) | 操作者 `PeerScope`;`connection.operator` 与 `admit`;`/api` 与升级路由经 `admit` 取 Peer;`rpc.open` 的可选 `uplink` |
|
||||
| `dsh-api-gateway` Host(`stream-protocol.ts`、`stream-server.ts`、`types.ts`、`index.ts`) | `item` / `end` 帧;`UplinkInbox`;`Config.streamInboxBytes`;`GatewayInvocation` 与 `extend({ invocation })`;`UplinkDecoder`;`handleUpgrade(req, socket, head, peer)`;`operatorPeer()` |
|
||||
| `dsh-client-connection`(`peer.ts`、`rpc.ts`、`rpc-host.ts`、`index.ts`) | `Peer` 类;`connection.browserPeer` 与 `admit`;`/api` 与升级路由经 `admit` 取 Peer;`rpc.open` 的可选 `uplink` |
|
||||
| `dsh-api-gateway` Host(`stream-protocol.ts`、`stream-server.ts`、`types.ts`、`index.ts`) | `item` / `end` 帧;`UplinkInbox`;`Config.streamInboxBytes`;`GatewayInvocation` 与 `extend({ invocation })`;`UplinkDecoder`;`handleUpgrade(req, socket, head, peer)` |
|
||||
| `dsh-api-gateway` Client(`client/index.ts`、`client/stream-client.ts`) | `invokeStream` 返回句柄;上行泵由句柄的队列驱动 |
|
||||
| `dsh-webworker-runtime`、`dsh-remote-mock` | worker 隧道的两种上行帧与 `serveStream` 组装;`StreamHandle.uplink`;直接代理返回真句柄 |
|
||||
| 文档 | gateway、api 组、connection、protocol、generator 的 README;`docs/api-gateway`;`docs/subsystems/typert` 的 type-equiv 块;config 与 Cordis 目录 |
|
||||
@@ -560,7 +566,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
**客户端 `send` 放在生成方法返回的 `AsyncIterable` 上。** `AsyncIterable` 上挂方法不常规;句柄类型 `RemoteStreamHandle` 显式扩展它。
|
||||
|
||||
**照搬 Access 机制。** 访问控制是业务语义,`vouch` 与 `remote/invoke` waterfall 为它服务;本 Note 只需要"这条流是谁的",`PeerScope` 就够。
|
||||
**照搬 Access 机制。** 访问控制是业务语义,`vouch` 与 `remote/invoke` waterfall 为它服务;本 Note 只需要"这条流是谁的",`PeerScope` 就够。方法级访问控制后来归入 [Peer 身份属性与 Remote 方法级访问控制](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md),该部分由其 supersede。
|
||||
|
||||
**继续用 unary RPC 做上行。** 每次上行一次 HTTP 往返、一次鉴权、一次查找;两条载体没有顺序关系,只能靠客户端串行化;没有 EOF;取消不覆盖两半;每个功能各造一套关联键。
|
||||
|
||||
@@ -574,10 +580,11 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
- **买到的**:一条流两个方向,终端按键、审批回答、后台任务 stdin 这类交互式场景不再各造一套"一条流加一个 unary";Host 方法知道调用者;上行类型与下行类型在返回类型里一处声明,上行项来自浏览器,在 Host 逐项严格校验,下行项是 Host 自产的类型值,直接透传;既有 `AsyncIterable<Out>` 方法与 unary 方法的生成物与线路帧完全不变。
|
||||
- **`this.ctx.invocation` 在非 Remote 调用下是 `undefined`**,方法读它要处理可选;一个只被进程内直接调用的服务方法读到 `undefined` 是正确行为。
|
||||
- **Web 终端使用 `attach`** 传输屏幕输出和输入命令,并提供提供者确认和附件专属可写状态;Client 保留当前代次的句柄,绝不重放输入。
|
||||
- **inbox 上限对所有流生效**:一个不读上行的方法收到大量上行帧会整流失败。这是刻意的显式失败,README 记明。
|
||||
- **Peer 只有操作者一个**,`admit` 是唯一的接纳点;第一个需要第二个 Peer 的消费者要在 `dsh-client-connection` 加上打开、关联与事件。
|
||||
- **所有浏览器连接共享 `browserPeer`**,`admit` 是唯一的网络接纳点;进程内代码经 `connection.createPeer()` 创建其他 Peer。
|
||||
- **上行与下行之间没有跨方向顺序保证**;需要请求应答配对的协议自带序号。
|
||||
- **上层协议尚未透出 `send`**:`$stream`、snapshot、journal 的消费者今天只用下行;它们拿到的对象已经是句柄。
|
||||
- **上层监督器仅暴露下行**:`$stream`、snapshot 和 journal 不暴露 `send`;需要写入上行的领域保留该代次的生成物理句柄。
|
||||
- **worker 隧道与 WebSocket 载体行为不同**:没有 inbox 上限,`end` 之后的项丢弃,因为页面到 worker 是同源可信边界。
|
||||
|
||||
## 测试
|
||||
@@ -588,17 +595,15 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
- 网关 Host:帧解析的精确键校验;`UplinkInbox` 的 opening 缓冲、`end` 后 `item`、超限、未知 `streamId` 忽略、socket 关闭时的收尾;`UplinkDecoder` 有无 codec 两种交付与解码失败中止;`uplink()` 只能取一次;`extend({ invocation })` 视图与 `peer` 的来源;`cancellableStream` 先关 uplink 再 `return()`。
|
||||
- 网关 Client:句柄的 `send`(JSON 校验、open 前入队、终止后抛错)、`end` 幂等、`dispose` 驱动 `cancel` 帧并结束后续读取;泵的三种退出;终止帧到达即停泵。
|
||||
- typert:analyzer 对 `RemoteStream<Out, In>`、`RemoteStream<Out>`、`never` 与既有 `AsyncIterable<Out>` 的处理;修饰器选项;registry 加载校验。
|
||||
- connection:`admit` 的 401、403 与操作者三种结果;操作者经桥与共享处理器到达;随 connection 释放。
|
||||
- connection:`admit` 的 401、403 与 `browserPeer` 三种结果;`browserPeer` 经桥与共享处理器到达;随 connection 释放。
|
||||
- remote-mock、worker 隧道:上行透传;直接代理返回真句柄。
|
||||
- 既有 stream 与 unary 方法的测试不改期望值即通过。
|
||||
|
||||
## 后续
|
||||
|
||||
- `$stream` / snapshot / journal 把句柄的 `send` 透出给上层协议。
|
||||
- Web 终端:一条 `attach` 流取代 `follow` 加 `write` 加 `resize`,`attachmentId` 退场。
|
||||
- 双工 `$events`:waterfall 的回答成为同一 `streamId` 上的上行项,`$events/result` 与 `clientId` 退场。
|
||||
- credit 帧:等需要持续大流量上行的消费者出现;inbox 上限就是初始授信。
|
||||
- `streamInboxBytes` 默认值 262144 对键盘与 stdin 远超需要;若第一个消费者是终端粘贴,可能与 `terminal-controller` 的 `maxInputBytes` 对齐。
|
||||
- Host 发起的半关闭帧(Host 不再读上行但继续写下行):没有消费者,不加。
|
||||
- unary 方法的 `uplink()`:能读到方法运行期间到达的项,语义成立但用处存疑;允许,README 注明。
|
||||
|
||||
@@ -606,4 +611,4 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
|
||||
|
||||
- [Remote 事件投递](../../../../packages/api/remotes/README.zh.md):`$events/result` 的 unary 回答路径;「后续」里的双工 `$events` 落地后部分取代它。
|
||||
- [会话历史与事件传输](2026-08-18-session-history-and-event-transport.zh.md):`$stream()` 监督器与其上的 journal / snapshot 协议,本 Note 不改它们。
|
||||
- [Web 侧边栏终端](../feature/2026-09-09-web-sidebar-terminal.zh.md):终端按键的 unary `write` 与 `attachmentId`;「后续」里的 `attach` 流落地后取代它。
|
||||
- [Web 侧边栏终端](../feature/2026-09-09-web-sidebar-terminal.zh.md):`attach` 流承载输入命令、提供者确认和附件专属可写状态;重连绝不重放输入。
|
||||
+10
-10
@@ -15,8 +15,8 @@
|
||||
en: adccc5dc758ad8f6
|
||||
zh: 50304d68db42e8b1
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/ownership:
|
||||
en: 0543678ad9d26fc5
|
||||
zh: aa6ba207e661c567
|
||||
en: a28e37fda6e9eaff
|
||||
zh: 9ca622e09ab24b4d
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/foreground-wait-and-claim-lifetime:
|
||||
en: 447713bbdf1ab4d8
|
||||
zh: 2757678ae47822aa
|
||||
@@ -30,14 +30,14 @@
|
||||
en: 3a33f2583e30e32a
|
||||
zh: 0ad7e8d1e47dc510
|
||||
/agent-note-timed-user-questions-as-two-settlements/decision/reply-node-and-grouping:
|
||||
en: 10323b0e0340a40b
|
||||
zh: a17fc3a4d4d820a7
|
||||
en: d87d7432c37405a7
|
||||
zh: 49f1bbf53856851a
|
||||
/agent-note-timed-user-questions-as-two-settlements/alternatives-considered:
|
||||
en: bf61d0ec80c63bb4
|
||||
zh: d4ce4d4438ffb013
|
||||
en: 1a070542bfa40e68
|
||||
zh: 82e32964da912dd8
|
||||
/agent-note-timed-user-questions-as-two-settlements/verification:
|
||||
en: d912a73a5ff409eb
|
||||
zh: 7b626794773e2301
|
||||
en: 1d19d196c363a273
|
||||
zh: 7cb0475f0cb554d1
|
||||
/agent-note-timed-user-questions-as-two-settlements/consequences:
|
||||
en: 2bc99c99be8ca9da
|
||||
zh: 8f262ec8b9b0945e
|
||||
en: 0580e85cc12439c5
|
||||
zh: a4b151d2c9e5b747
|
||||
+5
-6
@@ -29,7 +29,7 @@ Foreground answers use the existing Remote Event waterfall. A timeout ends that
|
||||
| `UserQuestionService` | Foreground waits and Client claims, unattended deadlines, late-answer validation and steering, and the durable question projection. |
|
||||
| Gateway and Remote runtime | Existing request delivery, settlement, cancellation, and Remote stream transport; no question-specific timer or event-name branch. |
|
||||
| `ui-user-questions` | One card per Session and call, answer-channel selection, local countdown and drafts, and the reply Definition and renderer. |
|
||||
| `ui-chat` | Message-id-based presentation aggregation and ordinary process/Turn folding. |
|
||||
| `ui-chat` | Ordinary process/Turn folding. |
|
||||
| Question-specific tool row | Pending/answered presentation and panel actions, composed in `QuestionToolRow` without adding `rowAction` to generic `ToolRow`. |
|
||||
|
||||
The [question service](../../../../packages/interaction/user-questions/README.md), [tool](../../../../packages/interaction/tool-ask-user/README.md), and [questions UI](../../../../packages/client/ui-user-questions/README.md) own their package contracts.
|
||||
@@ -72,11 +72,11 @@ Autofocus applies only when the answer channel is ready, no countdown is active,
|
||||
|
||||
The questions UI identifies a late reply from `source.kind`, then parses its recorded JSON for question-and-answer presentation. Pasting identical JSON in an ordinary user message does not identify it as a reply. Unreadable recorded payloads retain their raw-text presentation.
|
||||
|
||||
The ordinary message Definition and the question-reply Definition both match the appended message and retain separate Node Store projections with the same message id. Chat business grouping omits the `turn-trigger` presentation when a `question-reply` with that id is present. The duplicate trigger neither renders nor splits a group; the reply renders once. The same aggregation applies to unscoped historical input.
|
||||
The question-reply Definition claims the appended reply message on the Chat target through an [exclusive match](2026-10-12-exclusive-conversation-matches.md), so the ordinary message Definition builds no `turn-trigger` or context projection for it and the reply renders once, including for unscoped historical input.
|
||||
|
||||
The reply participates in ordinary process grouping and whole-Turn folding. It is not independently visible: reading it can require expanding both its Turn and process group. Turn disclosure stays before the group it controls, including a reply that opens the Turn.
|
||||
|
||||
This decision adds no process-role field, independent-layout flag, user-message structure change, or target-local fallback dispatch. The independent-kind rules stay in Chat's existing grouping logic. A node-data-owned declaration of grouping and overlap semantics is deferred infrastructure, not part of the question feature.
|
||||
This decision adds no independent-layout flag or user-message structure change. The reply declares no group-tree position, so it follows the default placement of process content.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -90,7 +90,7 @@ This decision adds no process-role field, independent-layout flag, user-message
|
||||
|
||||
**Reclassify JSON inside generic `MessageItem`, borrow the relay form, or use the default context row.** Parsing text to identify the business message confuses its source with its content; the relay form describes another agent's message, and a generic context row exposes JSON. A dedicated source, Definition, and renderer own the question presentation.
|
||||
|
||||
**Independent reply layout through generic node metadata.** It broadens a question feature into a conversation-infrastructure change. Ordinary folding and Chat-owned aggregation are sufficient for this decision; independently declared node-data grouping semantics remain a separate design task.
|
||||
**Independent reply layout through generic node metadata.** It broadens a question feature into a conversation-infrastructure change. Ordinary folding is sufficient for this decision; placing the reply as user input through its group-tree position is separate work.
|
||||
|
||||
**A log-only close event or a manufactured dismissal message on panel close.** A log-only close hides the user's decision from the model and adds another durable mechanism. Manufacturing a reply treats hiding the panel as an answer. Panel close therefore leaves a timed question answerable.
|
||||
|
||||
@@ -101,7 +101,7 @@ This decision adds no process-role field, independent-layout flag, user-message
|
||||
- [Service tests](../../../../packages/interaction/user-questions/tests/user-questions.spec.ts) and [wait tests](../../../../packages/interaction/user-questions/tests/timed-wait.spec.ts) cover claimed and unclaimed waits, last-claim disconnect, cancellation, timeout error mapping, continued-only answers, and exact answer-id validation.
|
||||
- [Projection tests](../../../../packages/interaction/user-questions/tests/projection.spec.ts) distinguish legacy and timed schemas and cover native and PTC pending calls, other failures, resume repair, queued reply cancellation, and admitted settlement.
|
||||
- [Client listener tests](../../../../packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts) and [composer tests](../../../../packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx) cover claim retention through Host acceptance, focus before handshake, indefinite waits, draft retention, RPC resubmission, and panel actions.
|
||||
- [Grouping tests](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts) and [browser replay](../../../../apps/web/tests/question-composer.e2e.ts) cover one rendered reply with both projections retained, ordinary collapsed placement, disclosure ordering, and the original tool row's read-only late-answer panel.
|
||||
- [Grouping tests](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts) and [browser replay](../../../../apps/web/tests/question-composer.e2e.ts) cover ordinary collapsed placement, disclosure ordering, and the original tool row's read-only late-answer panel.
|
||||
- Legacy round-trip, cancellation, plan-review intent, and Session-remount regressions remain covered by their owning tests. Live multi-Client delivery races and real Host restart remain integration coverage gaps beyond isolated lifecycle and projection tests.
|
||||
|
||||
## Consequences
|
||||
@@ -111,7 +111,6 @@ The model can continue independent work while the question remains answerable. B
|
||||
- Take time affects only one Client. Another Client can answer or expire the shared waterfall; local focus does not establish a global hold.
|
||||
- A terminal claim-stream failure can reject the foreground question rather than produce pending. No unattended-wait fallback is provided for that failure.
|
||||
- Host and Client clocks need not align, but Client system-clock changes can move expiry and transport latency can delay the local countdown's start.
|
||||
- The duplicate projections remain stored. Aggregation adds linear scans during structural grouping rebuilds; ordinary text-only streaming updates retain their incremental path. This is an algorithmic bound, not a measured latency claim.
|
||||
- Unanswered continued questions can remain after reopening a Session. Panel close records nothing, and visibility follows normal folding rather than an always-visible reply guarantee.
|
||||
- A queued late reply can be canceled before the agent admits it. The question stays answerable until an admitted `user/message` records the answer.
|
||||
- A PTC sub-call interrupted before its `tool/ptc-dispatch` result is recorded cannot be reconstructed from the parent `run_code` failure, so it cannot take a late answer after recovery.
|
||||
+5
-6
@@ -29,7 +29,7 @@ Status: implemented
|
||||
| `UserQuestionService` | 前台等待与 Client 接手、无人接手时的 deadline、迟到回答校验与 steering(中途引导),以及持久问题投影。 |
|
||||
| Gateway 与 Remote 运行时 | 现有请求投递、结算、取消及 Remote stream 传输;不包含问题专属计时器或按事件名分支。 |
|
||||
| `ui-user-questions` | 每个 Session 和调用的一张卡片、回答通道选择、本地倒计时与草稿,以及回复 Definition 和渲染器。 |
|
||||
| `ui-chat` | 基于消息 id 的呈现聚合,以及普通过程/轮次折叠。 |
|
||||
| `ui-chat` | 普通过程/轮次折叠。 |
|
||||
| 问题专用工具行 | pending/已回答呈现与面板操作,在 `QuestionToolRow` 中组合,不向通用 `ToolRow` 添加 `rowAction`。 |
|
||||
|
||||
[问题服务](../../../../packages/interaction/user-questions/README.zh.md)、[工具](../../../../packages/interaction/tool-ask-user/README.zh.md)和[问题 UI](../../../../packages/client/ui-user-questions/README.zh.md)分别拥有各包的约定。
|
||||
@@ -72,11 +72,11 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
|
||||
|
||||
问题 UI 通过 `source.kind` 识别迟到回复,再解析已记录的 JSON 来呈现问答。普通用户消息粘贴相同 JSON 不会被识别为回复。无法读取的已记录载荷保留原始文本呈现。
|
||||
|
||||
普通消息 Definition 和问答回复 Definition 都匹配追加的消息,在 Node Store 中保留消息 id 相同的独立投影。存在同 id 的 `question-reply` 时,Chat 业务分组省略 `turn-trigger` 呈现。重复触发节点既不渲染,也不切开分组,回复只显示一次。相同聚合也适用于无轮次归属的历史输入。
|
||||
问答回复 Definition 通过[独占匹配](2026-10-12-exclusive-conversation-matches.zh.md)在 Chat target 上认领追加的回复消息,普通消息 Definition 因此不再为它建立 `turn-trigger` 或 context 投影,回复只显示一次;无轮次归属的历史输入同样如此。
|
||||
|
||||
回复参与普通过程分组和整轮折叠,不保证独立可见:阅读时可能需要展开所属轮次与过程分组。轮次折叠控件始终在所控制的分组之前,包括由回复开启轮次的情况。
|
||||
|
||||
本决策不添加 process-role 字段、独立布局标记、用户消息结构变更或目标内 fallback 派发。独立 kind 规则保留在 Chat 现有分组逻辑中。由节点 data 声明分组与重叠语义的机制是留待拆分的基础设施,不属于问题功能。
|
||||
本决策不添加独立布局标记或用户消息结构变更。回复不声明分组树位置,因此按过程内容的默认位置放置。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -90,7 +90,7 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
|
||||
|
||||
**在通用 `MessageItem` 内重新分类 JSON、借用 relay form,或使用默认 context 行。** 解析文本来识别业务消息混淆了来源与内容;relay form 表示其他 agent 的消息,通用 context 行则会暴露 JSON。专用来源、Definition 和渲染器拥有问题呈现。
|
||||
|
||||
**通过通用节点元数据让回复独立布局。** 这会把问题功能扩大为对话基础设施变更。普通折叠与 Chat 内聚合足以满足本决策;由节点 data 独立声明分组语义仍是单独的设计任务。
|
||||
**通过通用节点元数据让回复独立布局。** 这会把问题功能扩大为对话基础设施变更。普通折叠足以满足本决策;通过分组树位置把回复放置为用户输入是单独的工作。
|
||||
|
||||
**面板关闭时写仅入日志的关闭事件,或伪造放弃消息。** 仅入日志会让模型看不到用户决定,还增加另一套持久机制。伪造回复则把隐藏面板当成回答。因此关闭面板仍让计时问题保持可回答。
|
||||
|
||||
@@ -101,7 +101,7 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
|
||||
- [服务测试](../../../../packages/interaction/user-questions/tests/user-questions.spec.ts)与[等待测试](../../../../packages/interaction/user-questions/tests/timed-wait.spec.ts)覆盖有人与无人接手、最后接手方断开、取消、超时错误映射、仅接受已继续问题的回答,以及答案 id 的精确校验。
|
||||
- [投影测试](../../../../packages/interaction/user-questions/tests/projection.spec.ts)区分 legacy 与 timed schema,并覆盖原生及 PTC pending 调用、其他失败、恢复修复、排队回复取消和准入后结算。
|
||||
- [Client 监听器测试](../../../../packages/client/ui-user-questions/tests/browser-plugin.client.spec.ts)与[composer 测试](../../../../packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx)覆盖接手保留到 Host 接受结果、握手前聚焦、无限期等待、草稿保留、RPC 重提及面板操作。
|
||||
- [分组测试](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts)与[浏览器回放](../../../../apps/web/tests/question-composer.e2e.ts)覆盖保留两个投影但只渲染一次回复、普通折叠位置、折叠控件顺序,以及原工具行的迟到答案只读面板。
|
||||
- [分组测试](../../../../packages/client/ui-chat/tests/process-groups.client.spec.ts)与[浏览器回放](../../../../apps/web/tests/question-composer.e2e.ts)覆盖普通折叠位置、折叠控件顺序,以及原工具行的迟到答案只读面板。
|
||||
- legacy 问答往返、取消、计划评审意图及 Session 重新挂载的回归仍由各自测试覆盖。真实多 Client 投递竞争与真实 Host 重启仍是独立生命周期和投影测试之外的集成覆盖缺口。
|
||||
|
||||
## Consequences
|
||||
@@ -111,7 +111,6 @@ Client 计算 `Date.now() + remainingMs`。手动聚焦未编辑的回答区会
|
||||
- 「慢慢回答」只影响一个 Client。其他 Client 可以回答或让共享 waterfall 到期,本地聚焦不构成全局暂停。
|
||||
- 接手 stream 的终止性失败可能让前台问题失败,而不是产生 pending。不为该失败提供无人接手等待的 fallback。
|
||||
- Host 与 Client 时钟不必对齐,但 Client 系统时钟变化可能移动到期时间,传输延迟可能推迟本地倒计时的开始。
|
||||
- 重复投影仍保留在存储中。聚合在结构性分组重建时增加线性扫描;普通纯文本 stream 更新保留增量路径。这是算法复杂度界限,不是实测延迟结论。
|
||||
- 无人回答的已继续问题可以在重新打开 Session 后继续存在。关闭面板不写记录,可见性遵循普通折叠,而不是保证回复始终可见。
|
||||
- 排队的迟到回复可能在 agent 准入前被取消。只有已准入的 `user/message` 记录答案,问题在此之前保持可回答。
|
||||
- PTC 子调用若在记录 `tool/ptc-dispatch` 结果前中断,无法从父 `run_code` 的失败中重建,因此恢复后不能接受迟到回答。
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-24-deepseek-account-remote-model-catalog.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-deepseek-account-remote-model-catalog.md
|
||||
/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version:
|
||||
en: e6d82714ac89a7ae
|
||||
zh: 54c14278e749e7e1
|
||||
/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/problem:
|
||||
en: 2eeb916eb65c66e7
|
||||
zh: 7bf5c0abb8bb61e4
|
||||
/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/decision:
|
||||
en: 42e800c4cd3b6ebe
|
||||
zh: 06fabe5a9681fa27
|
||||
/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/alternatives-considered:
|
||||
en: 7c7880fdb0cc95f6
|
||||
zh: 2b8a539192626789
|
||||
/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/consequences:
|
||||
en: 352ece6c2decaaf7
|
||||
zh: 1d0c3620fd26a35c
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Agent Note: DeepSeek account models come from the account listing and gate by client version
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-24-deepseek-account-remote-model-catalog.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The account route's catalog was deployment configuration: a build advertised the models it shipped with, a new model reached users only through a release, and nothing kept an older build from requesting a model it cannot drive. The account service publishes per-account model metadata at `GET /models`, and that listing can name models an older build does not understand.
|
||||
|
||||
## Decision
|
||||
|
||||
- [`llm-deepseek-account`](../../../../packages/llm/llm-deepseek-account/README.md) loads the listing lazily for each credential, waits for it once up to `modelsTimeoutMs`, refreshes it in the background after `modelsMaxAgeMs`, keeps the last good listing when a load fails or returns no model, and serves the bundled catalog before any listing loads. The cache lives in memory and is keyed by endpoint and token fingerprint, so per-account rollouts stay separate.
|
||||
- A listed model is available when its `client_requirements["deepseek-harness"].min_version` is at most the current DeepSeek Harness version in semantic-version order. Without that declaration, only ids the built-in catalog knows are available; the others stay hidden, because a client cannot tell which future version supports them. A request for a refused listed model fails before network I/O with `CLIENT_UPGRADE_REQUIRED` or `MODEL_UNSUPPORTED`.
|
||||
- Listed capabilities replace bundled ones field by field; the bundled entry keeps client-side knowledge such as tool-update mode and image budgets; `modelOverrides` applies last and reuses the `models` entry fields and validation. `max_output_tokens` bounds the request default instead of becoming it.
|
||||
- The shared transport gains only two provider hooks, `connect` and `admit`; listing, gating, and merging stay in the account package.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Server-side filtering by client version only.** Codex sends `client_version` and lets its backend drop models. An older build then cannot show that a newer version supports a model, and a listing that omits a declaration would expose an unknown model.
|
||||
|
||||
**Default parameters for unknown models.** Reasonix and Pi drive unknown ids with generic defaults, and MiniMax treats that as a defect. A model that needs client changes would run with the wrong behavior.
|
||||
|
||||
**Whole-listing validation.** Codex, ZCode, and Kimi reject the whole listing on one bad field. One malformed row would then send every account back to the bundled catalog.
|
||||
|
||||
**Persisting the last good listing.** A durable cache adds a persistence format and its migration duty; the bundled catalog already covers offline starts.
|
||||
|
||||
**Keeping `models` with merge-by-id semantics.** The API-key route's `models` replaces its catalog, so the same field name with merge semantics would invert its meaning between sibling routes.
|
||||
|
||||
**Generalizing the shared transport.** Per-model effort levels, an output-capability field, and catalog-change announcements would widen the Messages package for one consumer. The listed effort levels match the bundled defaults, and a model that needs different ones needs a release, which the gate enforces.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An older build never offers a model it does not know unless the service declares a minimum version that build satisfies, and the service must declare `min_version` for a future build to show that an upgrade enables a model.
|
||||
- A modified client can still request any id; the service enforces version floors only if it rejects such requests itself.
|
||||
- The first account access after sign-in may wait up to `modelsTimeoutMs`; an offline start uses bundled capabilities until a listing loads; a listing change during a running process reaches the Web picker at its next catalog refresh.
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Agent Note: DeepSeek 账号模型来自账号列表,并按客户端版本门控
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-24-deepseek-account-remote-model-catalog.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
账号路由的目录原本是部署配置:每个构建只提供它发布时自带的模型,新模型只能随发版到达用户,也没有办法阻止旧构建请求它驱动不了的模型。账号服务在 `GET /models` 上按账号发布模型元数据,这份列表可能包含旧构建不认识的模型。
|
||||
|
||||
## 决策
|
||||
|
||||
- [`llm-deepseek-account`](../../../../packages/llm/llm-deepseek-account/README.zh.md) 按凭据懒加载列表,首次访问最多等待 `modelsTimeoutMs`,超过 `modelsMaxAgeMs` 后在后台刷新;获取失败或返回的列表中没有模型时保留上一份,还没有加载到任何列表时使用内置目录。缓存只在内存中,以端点和 token 指纹为键,因此按账号灰度互不干扰。
|
||||
- 列出的模型在其 `client_requirements["deepseek-harness"].min_version` 按 semver 顺序不高于当前 DeepSeek Harness 版本时可用。没有这个声明时,只有内置目录认识的 id 可用,其余保持隐藏,因为客户端无法判断将来哪个版本会支持它们。请求被拒绝的已列出模型时,在网络请求之前以 `CLIENT_UPGRADE_REQUIRED` 或 `MODEL_UNSUPPORTED` 失败。
|
||||
- 列表中的能力逐字段覆盖内置能力;内置条目保留工具更新模式、图片预算等客户端知识;`modelOverrides` 最后叠加,复用 `models` 条目的字段与校验。`max_output_tokens` 只限制请求默认值,不直接成为默认值。
|
||||
- 共享传输层只新增 `connect` 与 `admit` 两个提供方钩子;列表、门控与合并都留在账号包中。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**只在服务端按客户端版本过滤。** Codex 发送 `client_version`,由后端去掉模型。这样旧构建无法提示新版本支持某个模型,而且列表漏掉声明时会暴露未知模型。
|
||||
|
||||
**未知模型套用默认参数。** Reasonix 和 Pi 用通用默认值驱动未知 id,MiniMax 把这种做法当作缺陷。需要客户端改动的模型会以错误的方式运行。
|
||||
|
||||
**整份列表严格校验。** Codex、ZCode 和 Kimi 会因为一个坏字段拒绝整份列表。一条格式错误的记录就会让所有账号退回内置目录。
|
||||
|
||||
**持久化上一次成功的列表。** 持久缓存要新增持久化格式并承担迁移责任;内置目录已经覆盖离线启动。
|
||||
|
||||
**保留 `models` 并改为按 id 合并。** API key 路由的 `models` 会替换整个目录,同名字段在兄弟路由上改成合并语义会让含义相反。
|
||||
|
||||
**扩展共享传输层。** 按模型的推理档位、输出能力字段和目录变更通知,会为一个使用方扩大 Messages 包的范围。列表中的推理档位与内置默认值一致;需要不同档位的模型需要发版适配,门控正是用来保证这一点。
|
||||
|
||||
## 影响
|
||||
|
||||
- 旧构建永远不会提供它不认识的模型,除非服务端声明了该构建满足的最低版本;服务端必须声明 `min_version`,将来的构建才能提示升级后可用。
|
||||
- 改过的客户端仍然可以请求任意 id;只有服务端自己拒绝这类请求时,版本下限才会被强制执行。
|
||||
- 登录后的第一次账号访问最多等待 `modelsTimeoutMs`;离线启动在加载到列表之前使用内置能力;进程运行中途的列表变化要等 Web 选择器下一次刷新目录时才会显示。
|
||||
+4
-4
@@ -15,8 +15,8 @@
|
||||
en: 5cfe619038339caa
|
||||
zh: c2d2651d304d867c
|
||||
/agent-note-preset-scoped-schedule-tools/consequences:
|
||||
en: 546d83d28487d1c1
|
||||
zh: 438922cd4f3ff9ec
|
||||
en: d7c7e6b8e6268559
|
||||
zh: 801e5be70e00cc90
|
||||
/agent-note-preset-scoped-schedule-tools/testing:
|
||||
en: 1c5f34cf30a24fd7
|
||||
zh: 6f7a372de52d0ecd
|
||||
en: d42cb866907a96b5
|
||||
zh: 6e66f8babe744bcb
|
||||
@@ -26,9 +26,9 @@ English | [中文](2026-09-24-preset-scoped-schedule-tools.zh.md)
|
||||
- Each preset that mounts the row pays the four schemas' fixed token cost, and tool availability is read from the composition instead of inferred from the Host service's presence.
|
||||
- A deployment can mount `dsh-schedule` for storage and delivery without granting its agents model-driven reminder management.
|
||||
- `dsh-schedule` injects no `ctx.tools` and registers no model-facing tool.
|
||||
- A delegated child cannot use the four tools through either layer: the `tool-subagent` and `tool-subagent-fork` rows in `standard`, `cordis`, and `ptc` deny all four names through `toolFilter`, and the provider applies that filter in the child scope through `ctx.tools.restrict()`, so the tools leave the child's prompt; `subagentCallerRefusal` in `dsh-tool-schedule` independently returns `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }` from every one of the four tools when the caller's delegation depth is above zero, so a call that reaches a tool anyway is refused.
|
||||
- A delegated child inherits the preset's four reminder schemas. `subagentCallerRefusal` in `dsh-tool-schedule` returns `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }` from each tool when the caller's delegation depth is above zero. The [preset composition decision](../simplification/2026-10-09-preset-owned-subagent-composition.md) removes the redundant child filter; visibility does not authorize execution.
|
||||
- A Session a delegated child owns can never receive a delivered reminder, so `ScheduleService.create` and `ScheduleService.update` refuse it with `subagent_session`. The refusal reads the delegation depth — the accounting the delegation cap itself enforces, carried by the persisted session header across a cold resume. The rule sits in the service, so other consumers reach it too — the Automation surface included.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` pins the four definitions and their error mapping, including `refuses every tool for a delegated child caller`. `apps/cli/tests/web-agent-presets.e2e.ts` asserts the `minimal` preset's tool list, and `snapshots/web/minimal-preset/tool-schemas.expected.json` records its bash-only tool table. `packages/schedule/schedule/tests/subagent-ownership.spec.ts` pins the delegation-depth refusal, and `scripts/optional-bundles.spec.ts` asserts that both delegation rows in the three presets deny all four tool names.
|
||||
`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` pins the four definitions and their error mapping, including `refuses every tool for a delegated child caller`. `apps/cli/tests/web-agent-presets.e2e.ts` asserts the `minimal` preset's tool list, and `snapshots/web/minimal-preset/tool-schemas.expected.json` records its tool table without reminder schemas. `packages/schedule/schedule/tests/subagent-ownership.spec.ts` pins the delegation-depth refusal, and `scripts/optional-bundles.spec.ts` asserts that both delegation rows in the three presets omit `toolFilter`. The Web preset e2e also checks equal parent/child tool schemas and refusal from all four reminder executors for fresh and seeded children. `snapshots/web/subagent-fork-schedule` records all four refusals in a seeded fork through the shipped Web composition and shares the standard preset's prompt and schema pins.
|
||||
+2
-2
@@ -26,9 +26,9 @@ Status: implemented
|
||||
- 挂载该行的每个 preset 承担四个 schema 的固定 token 成本,工具可用性取自组合,而不是由宿主服务是否存在推断。
|
||||
- 部署可以只为存储与投递挂载 `dsh-schedule`,而不授予其 Agent 由模型驱动的提醒管理能力。
|
||||
- `dsh-schedule` 不注入 `ctx.tools`,也不注册任何面向模型的工具。
|
||||
- 被委派的子 agent 无法通过两道中的任何一道使用这四个工具:`standard`、`cordis` 与 `ptc` 中的 `tool-subagent` 与 `tool-subagent-fork` 两行通过 `toolFilter` 拒绝全部四个名称,provider 会在子作用域内通过 `ctx.tools.restrict()` 应用该过滤,因此这些工具会从子 agent 的 prompt 中消失;`dsh-tool-schedule` 中的 `subagentCallerRefusal` 会在调用方委派深度大于零时,从四个工具中的每一个独立返回 `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }`,因此即使某个调用仍能到达工具,也会被拒绝。
|
||||
- 被委派的子 agent 继承 preset 的四个提醒 schema。调用方委派深度大于零时,`dsh-tool-schedule` 中的 `subagentCallerRefusal` 从每个工具返回 `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }`。[preset 组装决策](../simplification/2026-10-09-preset-owned-subagent-composition.zh.md)移除了重复的子级过滤;可见性不授予执行权限。
|
||||
- 被委派子 agent 拥有的 Session 永远收不到投递的提醒,因此 `ScheduleService.create` 与 `ScheduleService.update` 会以 `subagent_session` 拒绝它。该拒绝读取委派深度——委派封顶本身执行的记账,由持久化 session header 承载并跨冷恢复保留。这条规则在服务层,因此其它消费方(包括自动化任务页面)也会命中。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` 固定这四个定义及其错误映射,包括 `refuses every tool for a delegated child caller`。`apps/cli/tests/web-agent-presets.e2e.ts` 断言 `minimal` preset 的工具列表,`snapshots/web/minimal-preset/tool-schemas.expected.json` 记录其只含 `bash` 的工具表。`packages/schedule/schedule/tests/subagent-ownership.spec.ts` 固定委派深度拒绝,`scripts/optional-bundles.spec.ts` 断言三个 preset 的两行委派行都拒绝全部四个工具名。
|
||||
`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` 固定这四个定义及其错误映射,包括 `refuses every tool for a delegated child caller`。`apps/cli/tests/web-agent-presets.e2e.ts` 断言 `minimal` preset 的工具列表,`snapshots/web/minimal-preset/tool-schemas.expected.json` 记录其不含提醒工具 schema 的工具表。`packages/schedule/schedule/tests/subagent-ownership.spec.ts` 固定委派深度拒绝,`scripts/optional-bundles.spec.ts` 断言三个 preset 的两行委派行都不含 `toolFilter`。Web preset e2e 还检查父子工具 schema 相等,且四个提醒执行器对新建和带种子的子级均拒绝执行。`snapshots/web/subagent-fork-schedule` 通过出货 Web 组合记录带种子 fork 中的四次拒绝,并共享 standard preset 的提示和 schema 预期。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-27-progressive-office-skills.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-27-progressive-office-skills.md
|
||||
/agent-note-progressive-office-skills-with-ordinary-shell-execution:
|
||||
en: 42e2f062c37e9ef1
|
||||
zh: 5cd3d421adec1cac
|
||||
/agent-note-progressive-office-skills-with-ordinary-shell-execution/problem:
|
||||
en: a9984a594cdac98d
|
||||
zh: 38768e7699bc8d39
|
||||
/agent-note-progressive-office-skills-with-ordinary-shell-execution/decision:
|
||||
en: d057c558d6b9803c
|
||||
zh: acf3fdbc4cd7cee3
|
||||
/agent-note-progressive-office-skills-with-ordinary-shell-execution/alternatives-considered:
|
||||
en: 8bca955255e6d982
|
||||
zh: bc313a4bcab3f6f9
|
||||
/agent-note-progressive-office-skills-with-ordinary-shell-execution/consequences:
|
||||
en: cd4ff2423b2f5f9e
|
||||
zh: 63135aeae9c46415
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: Progressive Office skills with ordinary shell execution
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-27-progressive-office-skills.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Combined authoring and rendering instructions spend context on operations unrelated to ordinary editing. Rendering tool activation adds a second discovery path and durable state even though shell tools already own execution permissions and process management. A missing renderer can prevent loading otherwise usable editing guidance.
|
||||
|
||||
## Decision
|
||||
|
||||
|
||||
The [Office provider](../../../../packages/skill/skill-office/README.md) lists a compact router and six format-specific authoring or operation skills. The model loads full instructions by name and selects rendering for appearance-focused changes unless the user explicitly skips it. Rendering uses a private, randomly named temporary directory inside the task workspace and removes it after inspection. Skills invoke the shared Python checker and configured LibreOffice Kit CLI through ordinary Bash or PowerShell. SDK and Desktop own their Office composition separately; ordinary Web and the shared base do not mount these plugins.
|
||||
|
||||
Carriers own interpreter and resource locations. Operation skills append the configured Node and CLI paths only when their bodies are loaded; authoring skills use the selected Python and shared checker resource. Loading instructions does not prepare engines or authoring payloads. Ordinary shell execution retains its existing permissions and process management.
|
||||
|
||||
Office dependency ranges allow patch updates; the root lock records the resolved adapter and engine versions.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep a dedicated renderer tool.** A small typed schema constrains rendering arguments, but requires its own execution configuration, scoped registration, projection, and activation lifecycle. The CLI operation skills accept shell quoting and model-selected arguments in exchange for reusing the existing approval, sandbox, cancellation, and output mechanisms.
|
||||
|
||||
**Hide subskills from the catalog.** This requires a generic visibility policy independent of invocation permission. Listing seven short summaries preserves the existing skill API while full instruction bodies remain demand-loaded.
|
||||
|
||||
**Automatically inject guides for file extensions.** Extensions do not distinguish text edits, recalculation, conversion, and requested visual checks. Model-selected routing keeps unrelated operations out of context and permits explicit direct invocation.
|
||||
|
||||
## Consequences
|
||||
|
||||
Instruction loading appends ordinary logged skill results; completion does not erase them. Historical tool records remain readable and existing declaration reconciliation handles resumed sessions without a Session format change or agent-loop modification. A dedicated renderer would need a new independently justified user or security requirement before reintroduction.
|
||||
|
||||
Verification covers catalog entries, denied and explicit invocation, authoring without executable paths, native/PTC requests, resume and fork, ordinary and minimal profile composition, resource packaging, context budgets, and real-model operation selection. Rendering failures preserve usable sources and stop visual checks; successful commands alone cannot establish usable images.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Agent Note: Office 技能渐进加载与普通 shell 执行
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-27-progressive-office-skills.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
合并的创作和渲染说明会为普通编辑无关的操作消耗上下文。渲染工具激活增加第二条发现路径和持久状态,而 shell 工具已经负责执行权限及进程管理。缺少渲染器也可能阻止加载原本可用的编辑指南。
|
||||
|
||||
## 决策
|
||||
|
||||
|
||||
[Office 提供者](../../../../packages/skill/skill-office/README.zh.md) 列出精简路由和六个各格式创作或操作技能。模型按名称加载完整说明;以外观为目标的修改会选择渲染,除非用户明确要求跳过。渲染使用任务工作区内随机命名的私有临时目录,并在检查后删除。技能通过普通 Bash 或 PowerShell 调用共享 Python 检查器和配置的 LibreOffice Kit CLI。SDK 和 Desktop 分别拥有 Office 组合;普通 Web 和共享 base 不挂载这些插件。
|
||||
|
||||
载体负责解释器和资源位置。操作技能仅在加载正文时附加配置的 Node 与 CLI 路径;创作技能使用选定的 Python 和共享检查器资源。加载说明不准备引擎或创作 payload。普通 shell 执行保留现有权限和进程管理。
|
||||
|
||||
Office 依赖范围允许补丁版本更新;根锁文件记录实际解析的适配器与引擎版本。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保留专属渲染工具。** 小型类型化 schema 能约束渲染参数,但需要专属执行配置、作用域注册、投影和激活生命周期。CLI 操作技能接受 shell 引用和模型选择参数的成本,以复用现有审批、沙箱、取消及输出机制。
|
||||
|
||||
**从目录隐藏子技能。** 这需要独立于调用权限的通用可见性策略。列出七条简短摘要可保留现有技能 API,完整说明仍按需加载。
|
||||
|
||||
**按文件扩展名自动注入指南。** 扩展名无法区分文本编辑、重计算、转换和用户要求的视觉检查。模型选路使无关操作不进入上下文,并允许显式直接调用。
|
||||
|
||||
## 影响
|
||||
|
||||
说明加载追加普通的已记录技能结果;操作完成不会擦除这些结果。历史工具记录仍可读取,恢复会话使用现有声明更新机制,无需修改 Session 格式或 agent loop。重新引入专属渲染器需要新的、独立成立的用户或安全要求。
|
||||
|
||||
验证覆盖目录条目、拒绝及显式调用、无可执行文件路径时的创作、native/PTC 请求、恢复和 fork、常规及 minimal 组合、资源打包、上下文预算和真实模型操作选择。渲染失败时保留可用源文件并停止视觉检查;命令成功本身不能证明图片可用。
|
||||
+10
-10
@@ -12,23 +12,23 @@
|
||||
en: a15c7c13f167e5ac
|
||||
zh: a15c7c13f167e5ac
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/responsibilities:
|
||||
en: 3ffd0c78fa204b04
|
||||
zh: f468bab00a7ff357
|
||||
en: d53a850a2deabc08
|
||||
zh: 01c2f735f2a9562a
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/runtime-resolution:
|
||||
en: ca45b973e1968533
|
||||
zh: 002f23bb70e4fed7
|
||||
en: 42a3ed903fbb5483
|
||||
zh: 7e1f6df1137794c3
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/plugin-manager:
|
||||
en: 9b04926c175f22e8
|
||||
zh: fbf05dd4065b0738
|
||||
en: 7ec1ad2a691bbd12
|
||||
zh: 01057bbe0240056a
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/hmr-package-configuration-expiry:
|
||||
en: c7de8dafe47a5587
|
||||
zh: 6010a70d8451edd1
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/future-work:
|
||||
en: 31818f8f7f80470b
|
||||
zh: ecb7ac85bb49cf6d
|
||||
en: 8b5faf72418a43dd
|
||||
zh: 3d676c9b54697a2a
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/alternatives-considered:
|
||||
en: 7c1c6d21570cda0d
|
||||
zh: e6f8334c85f1b404
|
||||
en: f04a27b3525180b0
|
||||
zh: 9f3a01d0400c5a88
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/verification:
|
||||
en: 939cedceed200cb2
|
||||
zh: c7bb02d0da400c8d
|
||||
|
||||
+11
-13
@@ -17,9 +17,9 @@ Two pieces of Host state did not follow changes while the process ran:
|
||||
|
||||
| Owner | Does | Does not |
|
||||
|---|---|---|
|
||||
| resolver (`app-boot/src/profile-resolution/resolver.ts`) | `replace()` allows removing `scope: profile` entries and profile-local names | Touch any Node cache |
|
||||
| plugin-manager | Calls `pluginPackages.refresh()` at fixed points of package operations | Construct resolutions or manage module and package-configuration caches |
|
||||
| HMR (`packages/boot/hmr`) | Expires package configuration and preserves ordinary reloads for manifests loaded as JSON modules | Reload plugins for configuration-only manifests; handle `node_modules` package replacement |
|
||||
| resolver (`app-boot/src/profile-resolution/resolver.ts`) | `replace()` allows removing `scope: profile` entries and profile-local names, and changing a retained profile entry's version in place | Touch any Node cache |
|
||||
| plugin-manager | Without HMR, calls `pluginPackages.refresh()` at fixed points of package operations; with HMR, leaves publication to its recomposition | Construct resolutions or manage module and package-configuration caches |
|
||||
| HMR (`packages/boot/hmr`) | Expires package configuration and preserves ordinary reloads for manifests loaded as JSON modules; replaces plugins whose loaded package files changed during recomposition ([replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md)) | Reload plugins for configuration-only manifests |
|
||||
|
||||
### Runtime resolution
|
||||
|
||||
@@ -29,20 +29,18 @@ Validation of a new generation in `replace()`:
|
||||
|
||||
- `scope: profile` entries may be removed.
|
||||
- Profile-local package names may be removed.
|
||||
- Retained profile entries keep their normalized directory, version, and scope, but may change declaring anchor when another selected bundle supplies the same package. Installation mappings and declaring anchors remain unchanged. The profile scope cannot change; a new local name cannot override an existing entry; a published link name cannot select another real directory.
|
||||
- Retained profile entries keep their normalized directory and scope, but may change declaring anchor when another selected bundle supplies the same package, and may change version when the package is upgraded in place ([replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md)). Installation mappings and declaring anchors remain unchanged. The profile scope cannot change; a new local name cannot override an existing entry. A published link name may select another real directory; Node's cached real paths of the previous one are relocated.
|
||||
|
||||
### Plugin Manager
|
||||
|
||||
| Operation | Publication point |
|
||||
|---|---|
|
||||
| Install a new package | After pnpm succeeds and the bundle selection is saved, before reconciliation, whether or not the bundle is enabled |
|
||||
| Overwrite an installed package | No publication; the result is `restart-required` |
|
||||
| Enable a bundle | After the selection is saved, before reconciliation |
|
||||
| Disable a bundle | With HMR, after reconciliation stops its plugins; without HMR, no publication, and running plugins keep the current table |
|
||||
| Remove | After pnpm remove succeeds, inside the HMR transaction |
|
||||
| Failed or cancelled install, failed removal | No publication |
|
||||
| Install, enable, disable or remove with HMR | Inside HMR's recomposition, after the rows the generation drops stopped and before the generation applies ([replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md)) |
|
||||
| Install a new package or enable a bundle without HMR | After the profile files are saved, before reconciliation |
|
||||
| Remove without HMR | After pnpm remove succeeds |
|
||||
| Disable without HMR, an install over an installed package, a failed or cancelled install, a failed removal | No publication; an install over a package the spec names is refused before pnpm runs |
|
||||
|
||||
`createRuntimeResolution()` returns a `ProfileRuntimeResolution`, which privately keeps the installation anchor, Harness home, and profile directory it was computed from. Its `computeLatestResolution()` rereads that profile with the same inputs and returns a new object for the latest generation, leaving the original unchanged; Workers receive only the table fields. `PluginPackages.refresh()` calls the current resolution's `computeLatestResolution()` and then `replace()`; a resolution constructed as plain data cannot be refreshed and makes the call throw.
|
||||
`createRuntimeResolution()` returns a `ProfileRuntimeResolution`, which privately keeps the installation anchor, Harness home, and profile directory it was computed from. Its `computeLatestResolution()` rereads that profile with the same inputs and returns a new object for the latest generation, leaving the original unchanged; Workers receive only the table fields. `PluginPackages.refresh()` calls the current resolution's `computeLatestResolution()` and then `replace()`; a resolution constructed as plain data cannot be refreshed and makes the call throw. app-boot's `recomposeProfile()` first stops the rows the generation no longer lists, so their plugins dispose while their packages are still mapped, then publishes the latest generation once; a table that is already published is not published again.
|
||||
|
||||
Without HMR, deselecting a startup bundle does not stop its plugins. While such a bundle remains deselected, later package operations keep the existing runtime table instead of publishing its removal. Other no-HMR installations and removals of non-running bundles still publish normally.
|
||||
|
||||
@@ -79,7 +77,7 @@ The binding's `getNearestParentPackageJSON` is called only by `package_json_read
|
||||
|
||||
### Future Work
|
||||
|
||||
- Online replacement inside `node_modules`, same-path reinstall, changed link targets, and cross-package reload propagation remain unsupported. Package updates need process restart; this does not guarantee that every management result already reports that requirement correctly.
|
||||
- A retained profile mapping that moves to another directory waits for a restart; Plugin Manager moves a package by removing and installing it. In-place replacement inside `node_modules` is handled by the [replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md).
|
||||
- TSX versions using an asynchronous loader thread keep that thread's package configuration outside these hooks. Synchronizing it is deferred; this change does not provide general Worker cache synchronization.
|
||||
- CommonJS private-request-cache refresh with registered synchronous resolve hooks is deferred. Those requests retain their original loader behavior.
|
||||
- Disposing HMR restores the native readers, whose previous cached configuration can become visible again. Preserving invalidation state across HMR replacement is deferred.
|
||||
@@ -90,7 +88,7 @@ The binding's `getNearestParentPackageJSON` is called only by `package_json_read
|
||||
|
||||
**Reread every observed manifest when runtime resolution publishes (#5496).** This places package-configuration expiry in table publication, decoupled from module caches: overwriting an installed package reports restart, yet the next unrelated publication makes new imports load the new version while the old plugin still runs. Package configuration is content and belongs with module caches, in HMR.
|
||||
|
||||
**Let HMR reload packages inside `node_modules`.** It requires finding affected plugins over a module graph that includes `node_modules`, preventing duplicate evaluation of shared libraries and Cordis, and handling missing dynamic-import edges, module side effects, and Workers. It is not done here; those scenarios keep requiring restart.
|
||||
**Let HMR reload packages inside `node_modules`.** It requires finding affected plugins over a module graph that includes `node_modules`, preventing duplicate evaluation of shared libraries and Cordis, and handling missing dynamic-import edges, module side effects, and Workers. It is not done here; the [replacement Note](2026-10-01-replace-changed-package-modules-on-recomposition.md) adds it at profile recomposition.
|
||||
|
||||
**Reload every plugin for a package.json change.** Configuration-only manifests do not require module evaluation. Manifests actually imported as JSON modules retain ordinary dependency-driven reloads.
|
||||
|
||||
|
||||
+11
-13
@@ -17,9 +17,9 @@ Host 运行期间有两处状态没有随变化更新:
|
||||
|
||||
| 负责方 | 做什么 | 不做什么 |
|
||||
|---|---|---|
|
||||
| resolver(`app-boot/src/profile-resolution/resolver.ts`) | `replace()` 允许移除 `scope: profile` 条目和 profile 本地包名 | 不碰 Node 的任何缓存 |
|
||||
| plugin-manager | 在包操作的确定时点调用 `pluginPackages.refresh()` | 不构造 resolution;不管模块缓存与包配置缓存 |
|
||||
| HMR(`packages/boot/hmr`) | 使包配置失效,并保留作为 JSON 模块加载的 manifest 的普通重载 | 不因仅作为配置的 manifest 重载插件;不处理 `node_modules` 包替换 |
|
||||
| resolver(`app-boot/src/profile-resolution/resolver.ts`) | `replace()` 允许移除 `scope: profile` 条目和 profile 本地包名,并允许原地更改保留的 profile 条目的版本 | 不碰 Node 的任何缓存 |
|
||||
| plugin-manager | 没有 HMR 时,在包操作的确定时点调用 `pluginPackages.refresh()`;有 HMR 时,把发布交给其重新组合 | 不构造 resolution;不管模块缓存与包配置缓存 |
|
||||
| HMR(`packages/boot/hmr`) | 使包配置失效,并保留作为 JSON 模块加载的 manifest 的普通重载;重新组合时替换已加载包文件发生变化的插件([替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)) | 不因仅作为配置的 manifest 重载插件 |
|
||||
|
||||
### runtime resolution
|
||||
|
||||
@@ -29,20 +29,18 @@ Host 运行期间有两处状态没有随变化更新:
|
||||
|
||||
- `scope: profile` 的条目可以移除。
|
||||
- profile 本地包名可以移除。
|
||||
- 保留的 profile 条目保持规范化目录、版本与作用域不变;另一个已选 bundle 提供同一包时,可以更换声明锚点。安装范围的映射与声明锚点保持不变。profile 范围不能变;新本地包名不能覆盖已有条目;已发布的 link 名称不能换真实目录。
|
||||
- 保留的 profile 条目保持规范化目录与作用域不变;另一个已选 bundle 提供同一包时,可以更换声明锚点;包被原地升级时,可以更改版本([替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md))。安装范围的映射与声明锚点保持不变。profile 范围不能变;新本地包名不能覆盖已有条目。已发布的 link 名称可以选中其他真实目录;Node 缓存的原目录真实路径会被重新定位。
|
||||
|
||||
### plugin-manager
|
||||
|
||||
| 操作 | 发布时点 |
|
||||
|---|---|
|
||||
| 安装新包 | pnpm 成功、保存 bundle 选择之后,reconciliation 之前;无论是否立即启用都发布 |
|
||||
| 覆盖已安装的包 | 不发布,结果为 `restart-required` |
|
||||
| 启用 bundle | 保存选择后发布,再做 reconciliation |
|
||||
| 停用 bundle | 有 HMR 时,reconciliation 让插件退出后再发布;没有 HMR 时不发布,运行中的插件继续使用原表 |
|
||||
| 卸载 | pnpm remove 成功后,在 HMR 事务内发布 |
|
||||
| 安装失败、取消、卸载失败 | 不发布 |
|
||||
| 有 HMR 时的安装、启用、停用或卸载 | 在 HMR 的重新组合内,新一代丢弃的行停止之后、新一代应用之前([替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)) |
|
||||
| 没有 HMR 时安装新包或启用 bundle | 保存 profile 文件之后,reconciliation 之前 |
|
||||
| 没有 HMR 时卸载 | pnpm remove 成功后 |
|
||||
| 没有 HMR 时停用、覆盖已安装的包、安装失败或取消、卸载失败 | 不发布;spec 指明已安装的包时,覆盖安装在 pnpm 运行之前被拒绝 |
|
||||
|
||||
`createRuntimeResolution()` 返回 `ProfileRuntimeResolution`,它私有保存构造时的 installAnchor、home 和 profile 目录;`computeLatestResolution()` 用同一组输入重新读取 profile,返回表示最新代的新对象,原实例不变;Worker 只收到表数据。`PluginPackages.refresh()` 调用当前 resolution 的 `computeLatestResolution()` 再 `replace()`;以纯数据构造的 resolution 无法刷新,调用时抛错。
|
||||
`createRuntimeResolution()` 返回 `ProfileRuntimeResolution`,它私有保存构造时的 installAnchor、home 和 profile 目录;`computeLatestResolution()` 用同一组输入重新读取 profile,返回表示最新代的新对象,原实例不变;Worker 只收到表数据。`PluginPackages.refresh()` 调用当前 resolution 的 `computeLatestResolution()`,再调用 `replace()`;以纯数据构造的 resolution 无法刷新,调用时抛错。app-boot 的 `recomposeProfile()` 先停止新一代不再列出的行,使其插件在包仍有映射时完成释放,再发布一次最新一代;已发布的表不会再次发布。
|
||||
|
||||
没有 HMR 时,取消启动 bundle 的选择不会停止它的插件。只要该 bundle 仍未选中,后续包操作就保留当前 runtime 表,不发布其移除。其他无 HMR 的安装和未运行 bundle 卸载仍正常发布。
|
||||
|
||||
@@ -79,7 +77,7 @@ binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调
|
||||
|
||||
### 后续工作
|
||||
|
||||
- 不支持 `node_modules` 内在线替换、同路径重装、link 换目标及跨包重载传播。包更新需要重启进程;这不保证每个管理操作的结果都已正确报告这一要求。
|
||||
- 移动到其他目录的保留 profile 映射需要等待重启;Plugin Manager 通过卸载再安装来移动包。`node_modules` 内的原地替换由[替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)处理。
|
||||
- 使用异步 loader 线程的 TSX 版本,其线程内包配置不受这些 hook 管理。线程同步留待后续;本次不提供通用 Worker 缓存同步。
|
||||
- 注册了同步 resolve hooks 时,CommonJS 私有请求缓存刷新留待后续;这些请求保留原有 loader 行为。
|
||||
- 销毁 HMR 会恢复原生 reader,其此前缓存的配置可能重新可见。跨 HMR 替换保留失效状态留待后续。
|
||||
@@ -90,7 +88,7 @@ binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调
|
||||
|
||||
**在 runtime resolution 发布时全量重读已见过的 manifest(#5496)。** 它把包配置失效放进了绑定表发布,和模块缓存脱了钩:覆盖已安装的包时报需要重启,但下一次无关的发布会让新导入拿到新版本,旧插件仍在运行。包配置缓存属于内容,应该和模块缓存由同一方负责,也就是 HMR。
|
||||
|
||||
**让 HMR 支持 `node_modules` 内的包重载。** 这需要沿包含 `node_modules` 的模块图确定受影响插件,防止共享库和 Cordis 被重复求值,还要处理动态 import 漏边、模块副作用和 Worker。本次不做,这些场景继续要求重启。
|
||||
**让 HMR 支持 `node_modules` 内的包重载。** 这需要沿包含 `node_modules` 的模块图确定受影响插件,防止共享库和 Cordis 被重复求值,还要处理动态 import 漏边、模块副作用和 Worker。本次不做;[替换说明](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)在 profile 重新组合时加入了这一能力。
|
||||
|
||||
**package.json 变化时重载所有插件。** 仅作为配置的 manifest 不要求模块求值。实际作为 JSON 模块导入的 manifest 保留普通的依赖驱动重载。
|
||||
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Bilingual-pair consistency record for 2026-10-01-replace-changed-package-modules-on-recomposition.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-01-replace-changed-package-modules-on-recomposition.md
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition:
|
||||
en: 72fe06d4e304418d
|
||||
zh: bc3c96e957889bc1
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/problem:
|
||||
en: c43fb0d2df54b28f
|
||||
zh: 6636548449663fc5
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision:
|
||||
en: a15c7c13f167e5ac
|
||||
zh: a15c7c13f167e5ac
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision/one-recomposition-path:
|
||||
en: f2874c3fc946cf7a
|
||||
zh: 4c32b9f94e1f9709
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision/detecting-changed-modules:
|
||||
en: 1fedf2dde87f41bf
|
||||
zh: c2249267d1c2bc78
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision/replacing-them:
|
||||
en: 6fe2cbe5aa3a9786
|
||||
zh: fe30fbf672ea8c21
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/decision/plugin-manager-and-runtime-resolution:
|
||||
en: 794ed33204d91014
|
||||
zh: b08daa2a43cbe3b3
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/alternatives-considered:
|
||||
en: a67d0c6bc4957060
|
||||
zh: 32e02ab45b8e71cc
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/consequences:
|
||||
en: 2cb3f39c6a910685
|
||||
zh: e14938c868390f39
|
||||
/agent-note-replace-plugins-whose-loaded-package-files-changed-during-profile-recomposition/verification:
|
||||
en: d4750abb810faac2
|
||||
zh: 78c9c4d86d719b9b
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
# Agent Note: Replace plugins whose loaded package files changed during profile recomposition
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-01-replace-changed-package-modules-on-recomposition.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Node caches an evaluated module by file URL. A profile installs packages with pnpm's hoisted layout, so a package lives at `node_modules/<name>` whatever its version. Overwriting, upgrading or reinstalling it keeps every file URL, and a later import or Loader entry restart returns the cached module of the previous version. The [package-refresh Note](2026-09-30-profile-package-refresh-and-manifest-invalidation.md) refreshes runtime resolution and package configuration but leaves this module cache; Plugin Manager therefore reported overwrites as `restart-required`, and a reinstall at the same path after removal reported `applied` while the previous version kept running. A `link:` development package hits the same cache whenever its build output changes, unless its directory is listed in HMR's source watch roots.
|
||||
|
||||
## Decision
|
||||
|
||||
### One recomposition path
|
||||
|
||||
`Hmr.reconcileProfile()` is the profile recomposition used by HMR's profile watches and by Plugin Manager; the Config Editor still recomposes through app-boot directly. It runs inside `runExclusive()`. Plugin Manager only edits profile files and runs pnpm, and refuses to install over an installed package it can name before pnpm runs; another version is a removal and an installation, each with its own recomposition. The steps of a recomposition are:
|
||||
|
||||
1. Read the profile patches and compare loaded modules below the profile directory and its linked package targets with disk.
|
||||
2. Plan the replacement as below; a patch read or comparison failure throws or is reported before any module changes.
|
||||
3. Evict the replaceable changed modules and their importers. A changed module whose importers reach the process entry point's static graph or a plugin that no Loader entry owns stays loaded with its importers; the other changes proceed.
|
||||
4. Stop the Loader entries running the replaced plugins by deleting their runtimes, so the Loader does not record them as disabled.
|
||||
5. Call app-boot's `recomposeProfile()` once with the entries that ran before the stops. It records the activation state, stops the rows the generation no longer lists by removing them from their groups, publishes the runtime resolution, runs the compatibility preflight against the published packages, and applies the generation. The Loader imports the current files when it starts an entry: rows the generation keeps start once with the new code and their new configuration; rows it disables, refuses or removes stay stopped and never run the new code; an entry it inserts links against the current files. Stopped entries the update did not try to start, below an unchanged Include or outside the profile composition, start afterwards; an entry whose import failed during the update is imported once more. Activation is judged: a stopped entry that failed afterwards fails the operation, while an entry waiting for a service is a warning, since it starts once a provider appears. If no generation applied, the published resolution and the stopped rows are restored, and the stopped entries start again.
|
||||
6. Evict the loaded modules below the directories of the mappings the recomposition dropped, so a removed or deselected package, including its dynamically imported dependencies, is imported afresh when it returns. A bundle switched off while its package stays mapped keeps its unchanged modules.
|
||||
|
||||
### Detecting changed modules
|
||||
|
||||
[`ModuleFiles`](../../../../packages/boot/hmr/src/module-files.ts) records inode, size and modification time for the loaded ES and CommonJS modules below the profile directory and the real directories of its linked packages. Modules of the dsh installation are outside this scope. Change time is excluded: pnpm imports packages by hard link from its store, and adding a link elsewhere changes a loaded file's change time without changing its content. Records are taken at application readiness, after each recomposition, and for modules a source reload evaluated again. A recomposition records the identities it read before replacing anything, so a write during the recomposition stays detectable; modules first loaded during it are recorded after settlement to avoid an unnecessary restart during the next installation. An edit between first evaluation and that record can be missed; the timestamp fallback still covers modules first loaded outside recomposition. If the readiness observation fails, the fallback starts at HMR construction time, not the Unix epoch. A module is stale when its file disappeared or its identity differs. A module first loaded after the last recording has no record; it is stale when its file was modified at or after that recording, less a 2-second filesystem timestamp resolution, so a reinstall between loading and the next comparison is not missed. Version numbers are not consulted: lockfiles record no content identity for `file:` and `link:` dependencies, and file identity covers registry, tarball, directory and link installs alike.
|
||||
|
||||
### Replacing them
|
||||
|
||||
A stale module whose importer closure reaches the process entry point's static graph, including installed packages, stays loaded and is reported. The remaining stale modules and their static importers, across ES module links and CommonJS `require` children, form the affected set, and every manifest from a stale file up to its profile root or linked target expires through `PackageManifests.invalidate()`. The whole affected set is evicted without a backup before recomposition, so every entry that imports during recomposition reaches the current files. A plugin registered outside a Loader entry has no owner that can start its replacement, so the changes its modules import stay pending; plugins are read from the ES module namespace and from cached CommonJS exports, so a CommonJS plugin registered through `require` keeps its cache too. HMR imports no replacement code itself, so admission is the Loader's ordinary activation: a disabled, removed or refused row never evaluates the new module, and dynamic `disabled` expressions and nested Includes are evaluated as at startup. Every recomposition imports again each enabled entry whose last import failed, after expiring the package configuration below the profile roots and evicting the modules whose links or evaluation failed, which Node keeps cached, so a corrected file or a later-installed dependency takes effect without a restart. A plugin counts as owned only when an affected plugin's own Loader entry fiber starts it; a changed plugin another plugin registers through a dynamic import stays pending. Unchanged dependencies of a replaced plugin are not re-evaluated, so shared libraries keep one instance. Source reloads through `partialReload()` keep their own in-place replacement with rollback.
|
||||
|
||||
`reconcileProfile()` returns `pending`, each stale module that loads only after a restart with its reason (`host-module`, `unmanaged-plugin`, or `module-comparison` when planning failed). Pending modules stay stale, including modules first loaded after the last recording, so the next recomposition retries them. A module first loaded during a recomposition whose file was modified after the comparison also stays stale: activation may have evaluated it before that write. A failure while listing or comparing modules keeps the previous records and sets the profile-wide `restartReason: module-comparison`, even without known module URLs. Neither prevents recomposition. An activation failure, including a replacement that fails to import, is thrown with the Loader's import error; an unchanged failure is returned as a warning by later recompositions. Plugin Manager reports a thrown failure as `failed` with that diagnostic, and turns `pending` into `restart-required` with its `restartReason`, unless the operation reports `overridden`. The Web page localizes the reason, and the agent tool derives a `nextStep` sentence from it, so no surface parses a diagnostic string.
|
||||
|
||||
The [packed worker loader](../../../../packages/experimental/webworker-runtime/src/module-system/module-loader.ts) stores evaluated modules in a private map and its `require` exposes no cache. HMR cannot replace them through Node APIs and reports a restart requirement. Native Node still exposes its caches when using asynchronous module hooks.
|
||||
|
||||
### Plugin Manager and runtime resolution
|
||||
|
||||
Plugin Manager refuses to install over an installed package it can name before pnpm runs, so a new version or location is a removal and an installation: the removal stops the plugins, evicts their modules and drops the mappings, and the installation maps the package afresh. Runtime resolution accepts a retained profile mapping whose version changes while its normalized directory and scope stay, as a shared dependency upgraded by another installation does, and rejects one that moved to another directory. A linked root may point to another directory; a file Node's cached link real path selects below the previous target, for an importer outside it, is relocated below the current one. Rejected publications, including changes to installation mappings or local overrides, leave the resolution and the composition unchanged and report `restart-required` with reason `package-moved`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A separate package-replacement call from Plugin Manager.** Plugin Manager knows which package it installed, but not which Loader entries import it, and row additions, removals and configuration changes in the new bundle patch must apply in the same step. A second operation would split one profile change into two transactions with an undefined order. Recomposition already reads the new profile; comparing loaded files there covers Plugin Manager, CLI installs followed by a recomposition, and link builds.
|
||||
|
||||
**Compare package versions from the profile manifest or lockfile.** The profile manifest stores specs, not resolved versions, and `pnpm update` can change a version without changing it. Lockfiles carry no content identity for `file:` and `link:` dependencies, and a same-version reinstall of a tarball changes content without changing the version.
|
||||
|
||||
**Let app-boot call HMR from inside its recomposition.** Hooks for the steps before applying and before judging would split the order of one operation between two owners. HMR instead stops everything it replaces first and passes the stopped entries to one app-boot call.
|
||||
|
||||
**Expose the recomposition's stages to HMR.** Separate calls to record activation, prepare after publication, apply, restore and drop mappings let HMR insert its stops between them, but they leave callers ordering obligations that the types cannot express. Stopping before the call costs a restart of the stopped plugins when preparation fails.
|
||||
|
||||
**Publish twice, keeping the dropped mappings while the generation applies.** A temporary table with both the new and the dropped mappings lets one Loader update stop and start entries, but it is a third generation that must stay recomputable when the update fails. Stopping the dropped rows first lets one publication follow them.
|
||||
|
||||
**Overwrite an installed bundle in one operation.** Stopping the bundle before pnpm and starting it afterwards, or detecting moved packages from the loaded module files, keeps one call for an upgrade, but needs a restart path for every pnpm failure, a selection restored in its order, and a release rule for moved directories. Refusing the overwrite leaves a removal and an installation, which the existing operations already run in this order.
|
||||
|
||||
**Preload the replacement and keep the previous plugin when it fails to load.** HMR would import new code before stopping the previous runtime and restore the previous caches on failure. Preloading must not evaluate a row the generation disables, removes or refuses, so HMR would decide admission before the Loader does; dynamic `disabled` expressions and nested Includes cannot be decided without activation, so those changes would need a restart. Rollback must also remove new importers of restored modules, and the previous code would still receive the new configuration. A restart would load the same broken files, so keeping the previous plugin only delays a failure that needs a fix anyway.
|
||||
|
||||
**Restart replaced plugins in place before recomposition.** New code would start with the previous configuration, which an upgraded version may reject, and a row the new composition disables or refuses, for example an incompatible version left on disk by a failed install, would briefly run.
|
||||
|
||||
**Replace running plugins after recomposition.** Recomposition would restart the previous code with the new configuration, which the previous version may reject, so a successful upgrade would be reported as a failure; an entry inserted during recomposition would also link against the previous copy of a changed dependency still cached for a running plugin.
|
||||
|
||||
**Stop affected plugins before pnpm runs.** This prevents previous code from lazily loading files pnpm has already replaced, but adds downtime for the whole install and holds the HMR queue during pnpm.
|
||||
|
||||
**Re-evaluate every dependency of a replaced plugin.** Source reloads reload a plugin's non-`node_modules` dependency tree. Doing the same below `node_modules` would create second instances of unchanged shared libraries and Cordis packages used by other plugins.
|
||||
|
||||
**Record identities in a module load hook.** `module.registerHooks()` could record each file at load time and close the window for modules loaded between recordings, but it adds a hook to every module load and interacts with TSX and the vendored Loader's internals. The timestamp rule only causes an extra reload in that window.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Upgrades through a removal and an installation, and packages whose files changed in place, take effect without a restart when the replacement loads and starts; a replacement that fails to load or start stops the previous plugin and reports `failed` with the error, and it starts once its files or dependencies are corrected.
|
||||
- Each recomposition stats every loaded file of the profile; the module graph is walked when a file changed, and the host graph is collected on first use.
|
||||
- Dynamic `import()` edges are not followed: an importer keeps running while its next dynamic import evaluates the new file. Existing Workers and the TSX loader thread keep their caches.
|
||||
- Replaced Loader entries stop before recomposition and start once afterwards; the previous version does not return.
|
||||
- Loader entries outside the profile composition restart with replaced code without a new compatibility check ([limitation](../../../../packages/boot/hmr/README.md#known-limitations-and-deferred-work)).
|
||||
- `ModuleFiles` belongs to one HMR instance. A recomposition that restarts the `hmr` entry itself starts a new instance whose first observation records the current files, so changes pending at that point are no longer reported or retried.
|
||||
- `pending` describes the whole profile, so a host module brought into scope by a linked workspace package makes later unrelated operations report `restart-required` until the process restarts.
|
||||
- A file replaced by a hard link to an older store file keeps an older modification time; when its module was first loaded after the last recording, the change is not detected.
|
||||
- A change confined to `package.json` is not detected. HMR's profile watches do not recompose for dependency-only manifest changes, so installs performed outside Plugin Manager wait for the next recomposition or restart.
|
||||
|
||||
## Verification
|
||||
|
||||
| Coverage | Location |
|
||||
|---|---|
|
||||
| In-place upgrades with bundle patch changes, configurations the previous version rejects, inserted entries and disabled rows, plugins without a Loader entry, shared-dependency importers, linked packages, hard links, CommonJS `require` children and their failures, failed replacement and retry, nested groups and Includes, dynamic disabling, same-path reinstall, late-loaded modules, host-graph modules, comparison and recomposition failures, eviction of packages a recomposition stopped using or whose mappings it dropped, and the restart of stopped entries after a rejected publication or failed preparation | `packages/boot/hmr/tests/package-reload.spec.ts` |
|
||||
| Refused overwrites, removal then installation with HMR, failed installation, relinked and link-to-registry reinstalls, unmanaged plugins, comparison failures | `packages/boot/plugin-manager/tests/package-reload.spec.ts`, `packages/boot/plugin-manager/tests/manager.spec.ts` |
|
||||
| In-place profile mapping version change, retained directory and scope rejection including a relinked path inside the profiles tree, relinked roots and native lookups relocated below the current target | `packages/boot/app-boot/tests/profile-resolution.spec.ts` |
|
||||
|
||||
Tests need no API key and make no model calls.
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
# Agent Note: profile 重新组合时替换已加载包文件发生变化的插件
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-01-replace-changed-package-modules-on-recomposition.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Node 按文件 URL 缓存已求值的模块。profile 使用 pnpm 的 hoisted 布局安装包,因此无论版本如何,包都位于 `node_modules/<name>`。覆盖、升级或重装包时所有文件 URL 保持不变,之后的导入或 Loader entry 重启返回的是旧版本的缓存模块。[包刷新说明](2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md)刷新 runtime resolution 和包配置,但不处理这一模块缓存;因此 Plugin Manager 把覆盖报告为 `restart-required`,而删除后在同一路径重装时报告 `applied`,旧版本却仍在运行。`link:` 开发包的构建输出变化时也会命中同一缓存,除非其目录列在 HMR 的源码监听根目录中。
|
||||
|
||||
## Decision
|
||||
|
||||
### 唯一的重新组合路径
|
||||
|
||||
`Hmr.reconcileProfile()` 是 HMR 的 profile 监听和 Plugin Manager 共用的 profile 重新组合入口;Config Editor 仍直接通过 app-boot 重新组合。它在 `runExclusive()` 内运行。Plugin Manager 只编辑 profile 文件并运行 pnpm;对于在运行 pnpm 之前能确定包名的已安装包,它拒绝覆盖安装;安装另一个版本需要先卸载再安装,每一步各有一次重新组合。一次重新组合的步骤如下:
|
||||
|
||||
1. 读取 profile patch,将 profile 目录及其已链接包目标之下的已加载模块与磁盘比较。
|
||||
2. 按下文规划替换;patch 读取或比较失败时,在任何模块变化之前抛出错误或报告失败。
|
||||
3. 逐出可替换的已变化模块及其导入方。若已变化模块的导入方可到达进程入口的静态依赖图,或可到达没有 Loader entry 拥有的插件,该模块连同其导入方保持加载;其余变化照常处理。
|
||||
4. 通过删除运行时停止运行被替换插件的 Loader entry,使 Loader 不把它们记录为已停用。
|
||||
5. 以停止之前正在运行的条目调用一次 app-boot 的 `recomposeProfile()`。它记录激活状态,把新一代组合不再列出的行从其所在组中移除,以停止这些行,发布 runtime resolution,基于已发布的包运行兼容性预检,然后应用新一代组合。Loader 在启动条目时导入当前文件:新一代组合保留的行以新代码和新配置启动一次;停用、拒绝或删除的行保持停止,不会运行新代码;新插入的条目链接当前文件。更新未尝试启动的已停止条目(位于未变化 Include 之下或 profile 组合之外)随后启动;更新期间导入失败的条目会再导入一次。随后判定激活结果:之后失败的已停止条目使操作失败;等待服务的条目只是警告,因为出现提供方后它即启动。若没有新一代组合被应用,已发布的 resolution 和已停止的行被恢复,已停止的条目重新启动。
|
||||
6. 逐出重新组合删除的映射所在目录之下的已加载模块,使已删除或取消选入的包连同其动态导入的依赖在重新加入时重新导入。关闭组合包而其包仍有映射时,它未变化的模块保持加载。
|
||||
|
||||
### 检测已变化的模块
|
||||
|
||||
[`ModuleFiles`](../../../../packages/boot/hmr/src/module-files.ts) 为 profile 目录及其已链接包的真实目录之下已加载的 ES 和 CommonJS 模块记录 inode、大小和修改时间。dsh 安装目录中的模块不在此范围内。不记录状态变更时间:pnpm 通过硬链接从其 store 导入包,在其他位置添加链接会改变已加载文件的状态变更时间,而内容不变。记录发生在应用就绪时和每次重新组合之后;源码重载重新求值模块时,也为这些模块记录。重新组合记录的是它在替换任何模块之前读取的文件身份,因此重新组合期间发生的写入仍可被检测到;在重新组合期间首次加载的模块在结算后记录,避免下一次安装时发生不必要的重启。首次求值与记录之间的修改可能漏检;时间戳兜底仍覆盖重新组合之外首次加载的模块。就绪时的观察失败时,兜底从 HMR 构造时间开始,而非 Unix 纪元。文件已消失或身份不同的模块视为陈旧。在上次记录之后才首次加载的模块没有记录;若其文件的修改时间不早于该次记录时间减去 2 秒文件系统时间戳精度,则视为陈旧,因此不会漏掉在加载与下一次比较之间发生的重装。比较不参考版本号:lockfile 不为 `file:` 和 `link:` 依赖记录内容标识,而文件身份同样覆盖 registry、tarball、目录和 link 安装。
|
||||
|
||||
### 替换
|
||||
|
||||
导入方闭包可到达进程入口静态依赖图(包括已安装包)的陈旧模块保持加载,并被报告。其余陈旧模块及其静态导入方(沿 ES 模块链接和 CommonJS `require` 子模块)构成受影响集合,从每个陈旧文件到其 profile 根目录或链接目标的各级 manifest 都通过 `PackageManifests.invalidate()` 失效。整个受影响集合在重新组合之前被逐出且不保留备份,因此重新组合期间执行导入的每个条目都读取当前文件。在 Loader entry 之外注册的插件没有能启动其替换版本的所有者,因此其模块导入的变化保持待处理;插件从 ES 模块命名空间和已缓存的 CommonJS exports 中读取,因此通过 `require` 注册的 CommonJS 插件同样保留其缓存。HMR 本身不导入替换代码,因此是否允许加载由 Loader 的普通激活决定:停用、删除或被拒绝的行永远不会对新模块求值,动态 `disabled` 表达式和嵌套 Include 按启动时的方式求值。每次重新组合都会先让 profile 根目录下的包配置失效,并逐出链接或求值失败的模块(Node 会在缓存中保留这些模块),再次导入上次导入失败的每个已启用条目,因此修正后的文件或之后安装的依赖无需重启即生效。只有当受影响插件自身的 Loader entry fiber 启动某个插件时,该插件才算有所有者;由另一个插件通过动态导入注册的已变化插件保持待处理。被替换插件中未变化的依赖不会重新求值,因此共享库保持单一实例。通过 `partialReload()` 进行的源码重载保留其自身带回滚的原地替换。
|
||||
|
||||
`reconcileProfile()` 返回 `pending`,即每个只有重启后才能加载的陈旧模块及其原因(`host-module`、`unmanaged-plugin`,规划失败时为 `module-comparison`)。待处理模块保持陈旧,包括在上次记录之后才首次加载的模块,因此下一次重新组合会重试它们。在重新组合期间首次加载、且其文件在比较之后被修改的模块同样保持陈旧:激活可能在该写入之前已对其求值。列出或比较模块时发生的失败会保留原有记录,并设置 profile 级 `restartReason: module-comparison`,即使无法确定模块 URL。两种情况都不会阻止重新组合。激活失败(包括替换版本导入失败)会连同 Loader 的导入错误一起抛出;未变化的失败在后续重新组合中作为警告返回。Plugin Manager 把抛出的失败报告为带该诊断的 `failed`,把 `pending` 转为带其 `restartReason` 的 `restart-required`,除非操作报告 `overridden`。Web 页面本地化该原因,agent 工具据此生成一句 `nextStep`,因此没有任何界面解析诊断字符串。
|
||||
|
||||
[打包 worker loader](../../../../packages/experimental/webworker-runtime/src/module-system/module-loader.ts) 在私有映射中保存已求值模块,其 `require` 不暴露缓存。HMR 无法通过 Node API 替换它们,因此报告需要重启。原生 Node 使用异步模块 hook 时仍暴露其缓存。
|
||||
|
||||
### Plugin Manager 与 runtime resolution
|
||||
|
||||
对于在运行 pnpm 之前能确定包名的已安装包,Plugin Manager 拒绝覆盖安装,因此新版本或新位置需要先卸载再安装:卸载停止插件、逐出其模块并删除映射,安装再重新映射该包。runtime resolution 接受保留的 profile 映射在规范化目录和作用域不变时更改版本,即共享依赖被另一次安装升级的情形;移动到其他目录的映射被拒绝。linked root 可以指向其他目录;对于原目标之外的导入方,Node 缓存的链接真实路径若选中原目标之下的文件,该文件会被重新定位到当前目标之下。被拒绝的发布,包括对安装范围映射或本地覆盖的更改,不改变 resolution 和组合,并报告原因是 `package-moved` 的 `restart-required`。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**由 Plugin Manager 单独调用包替换。** Plugin Manager 知道自己安装了哪个包,但不知道哪些 Loader entry 导入了它;新组合包 patch 中的行增加、删除和配置变更也必须在同一步应用。第二个操作会把一次 profile 变更拆成两个顺序未定义的事务。重新组合本来就会读取新的 profile;在此处比较已加载文件,同时覆盖 Plugin Manager、之后执行重新组合的 CLI 安装以及 link 构建。
|
||||
|
||||
**比较 profile manifest 或 lockfile 中的包版本。** profile manifest 存储的是 spec,不是解析后的版本,`pnpm update` 可以在不修改 spec 的情况下改变版本。lockfile 不为 `file:` 和 `link:` 依赖记录内容标识,而同版本重装 tarball 会改变内容却不改变版本。
|
||||
|
||||
**让 app-boot 在其重新组合内部调用 HMR。** 为应用之前和判定之前的步骤提供 hook,会把一次操作的顺序拆分给两个所有者。HMR 改为先停止它替换的所有内容,再把已停止的条目传给一次 app-boot 调用。
|
||||
|
||||
**向 HMR 暴露重新组合的各个阶段。** 分别调用记录激活状态、发布后准备、应用、恢复和丢弃映射,可让 HMR 在其间插入停止操作,但会给调用方留下类型无法表达的顺序义务。在调用之前停止插件,代价是准备失败时需要重新启动已停止的插件。
|
||||
|
||||
**发布两次,在应用新一代组合期间保留被丢弃的映射。** 同时包含新映射和被丢弃映射的临时表,可以让一次 Loader 更新既停止又启动条目,但它是第三个 generation,更新失败时必须仍能重新计算。先停止被丢弃的行,之后只需发布一次。
|
||||
|
||||
**在一次操作中覆盖已安装的组合包。** 在 pnpm 运行之前停止该组合包、之后再启动,或根据已加载模块文件检测已移动的包,可以让升级只需一次调用;但这需要为每种 pnpm 失败提供重启路径、按原顺序恢复选择列表,并为已移动的目录制定释放规则。拒绝覆盖安装后只剩卸载和安装,现有操作已按此顺序执行。
|
||||
|
||||
**预加载替换版本,并在其加载失败时保留原插件。** HMR 将在停止旧运行时之前导入新代码,并在失败时恢复旧缓存。预加载不得对新一代组合停用、删除或拒绝的行求值,因此 HMR 要先于 Loader 决定是否允许加载;动态 `disabled` 表达式和嵌套 Include 不经激活就无法判定,因此这些变化需要重启。回滚还必须删除已恢复模块的新导入方,而旧代码仍会收到新配置。重启会加载相同的损坏文件,因此保留原插件只是推迟一个无论如何都需要修复的失败。
|
||||
|
||||
**在重新组合之前原地重启被替换的插件。** 新代码会先以旧配置启动,而升级后的版本可能拒绝旧配置;新组合停用或拒绝的行(例如安装失败后留在磁盘上的不兼容版本)也会短暂运行。
|
||||
|
||||
**在重新组合之后替换运行中的插件。** 重新组合会以新配置重启旧代码,而旧版本可能拒绝新配置,导致一次成功的升级被报告为失败;重新组合期间插入的条目也会链接到仍为运行中插件缓存的、已变化依赖的旧副本。
|
||||
|
||||
**在 pnpm 运行前停止受影响插件。** 这能防止旧代码惰性加载 pnpm 已经替换的文件,但会让整个安装期间插件不可用,并在 pnpm 运行期间占用 HMR 队列。
|
||||
|
||||
**重新求值被替换插件的全部依赖。** 源码重载会重载插件在 `node_modules` 之外的依赖树。若对 `node_modules` 之下做同样处理,会为其他插件使用的、未变化的共享库和 Cordis 包创建第二个实例。
|
||||
|
||||
**在模块加载 hook 中记录文件身份。** `module.registerHooks()` 可以在加载时记录每个文件,消除两次记录之间加载的模块的检测空窗,但这会给每次模块加载增加一个 hook,并与 TSX 和 vendored Loader 的内部机制相互影响。时间戳规则在该空窗内只会导致一次多余的重载。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 通过卸载再安装进行的升级,以及文件被原地更改的包,在替换版本成功加载并启动时无需重启即生效;替换版本加载或启动失败时,原插件停止并报告 `failed` 及该错误,其文件或依赖被修正后它即启动。
|
||||
- 每次重新组合对 profile 的所有已加载文件执行一次 stat;有文件变化时才遍历模块图,宿主依赖图在首次使用时收集。
|
||||
- 不跟踪动态 `import()` 边:导入方继续运行,而其下一次动态导入会求值新文件。已存在的 Worker 和 TSX loader 线程保留各自的缓存。
|
||||
- 被替换的 Loader entry 在重新组合之前停止,之后启动一次;旧版本不会恢复。
|
||||
- profile 组合之外的 Loader entry 以替换后的代码重启,不重新进行兼容性检查([限制](../../../../packages/boot/hmr/README.zh.md#known-limitations-and-deferred-work))。
|
||||
- `ModuleFiles` 属于单个 HMR 实例。重启 `hmr` 条目本身的重新组合会启动一个新实例,其首次观察记录当前文件,因此此时待处理的变化不再被报告或重试。
|
||||
- `pending` 描述整个 profile,因此由已链接工作区包纳入范围的宿主模块,会使后续无关操作也报告 `restart-required`,直到进程重启。
|
||||
- 若文件被替换为指向较旧 store 文件的硬链接,它保留较旧的修改时间;当其模块在上次记录之后才首次加载时,该变化不会被检测到。
|
||||
- 仅限于 `package.json` 的变化不会被检测到。HMR 的 profile 监听不会因仅涉及依赖的 manifest 变化而重新组合,因此在 Plugin Manager 之外执行的安装要等到下一次重新组合或重启。
|
||||
|
||||
## Verification
|
||||
|
||||
| 覆盖面 | 位置 |
|
||||
|---|---|
|
||||
| 伴随组合包 patch 变更的原地升级、旧版本拒绝的配置、新插入的条目和停用行、没有 Loader entry 的插件、共享依赖的导入方、已链接包、硬链接、CommonJS `require` 子模块及其失败、替换失败与重试、嵌套 group 和 Include、动态停用、同路径重装、延后加载的模块、宿主依赖图中的模块、比较失败与重新组合失败、逐出重新组合不再使用或已删除其映射的包,以及发布被拒绝或准备失败后已停止条目的重启 | `packages/boot/hmr/tests/package-reload.spec.ts` |
|
||||
| 被拒绝的覆盖安装、有 HMR 时先卸载再安装、安装失败、重新链接和从链接改为 registry 的重装、非托管插件、比较失败 | `packages/boot/plugin-manager/tests/package-reload.spec.ts`、`packages/boot/plugin-manager/tests/manager.spec.ts` |
|
||||
| profile 映射原地更改版本、保留目录和作用域的拒绝(包括 profiles 树内重新链接的路径)、重新链接的 root 以及被重新定位到当前目标之下的原生查询 | `packages/boot/app-boot/tests/profile-resolution.spec.ts` |
|
||||
|
||||
测试不需要 API key,不调用模型。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-10-09-stored-credential-precedence.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-09-stored-credential-precedence.md
|
||||
/agent-note-stored-credentials-take-precedence-over-the-launch-environment:
|
||||
en: 5253af6605ba0fac
|
||||
zh: 160ac751c664a502
|
||||
/agent-note-stored-credentials-take-precedence-over-the-launch-environment/problem:
|
||||
en: 878c68dcfe5b3771
|
||||
zh: 8a24d16118155245
|
||||
/agent-note-stored-credentials-take-precedence-over-the-launch-environment/decision:
|
||||
en: 7cd89bda9f793431
|
||||
zh: 93d26a948eea63f3
|
||||
/agent-note-stored-credentials-take-precedence-over-the-launch-environment/per-run-overrides:
|
||||
en: 7479ffc82280a1fb
|
||||
zh: ce3d42489191ab2b
|
||||
/agent-note-stored-credentials-take-precedence-over-the-launch-environment/alternatives-considered:
|
||||
en: 268add7fd8415280
|
||||
zh: 0765cbfd3fc7b57c
|
||||
/agent-note-stored-credentials-take-precedence-over-the-launch-environment/consequences:
|
||||
en: 2d71fd527b3060c4
|
||||
zh: fafed884315b9782
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: Stored credentials take precedence over the launch environment
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-09-stored-credential-precedence.zh.md)
|
||||
|
||||
The [headless environment authentication](2026-10-12-headless-environment-auth.md) decision partially supersedes this note's per-run override policy: the shipped headless profile omits the store. The ordering below remains the store's contract when mounted.
|
||||
|
||||
## Problem
|
||||
|
||||
[Configuration source ownership](2026-08-04-configuration-source-ownership.md) ranked the inherited process environment above `$DSH_HOME/.credentials.yaml`, and `credentials-local` refused `set` and `unset` for a name the launching shell exported. Two kinds of stored credential then had opposite precedence. pi-ai's resolvers read the credential record stored under `llm-pi-ai/<provider>` before any environment variable, so a stored OAuth grant or `api-key` record won over an exported key. A key that the Models page stored under a reference lost to the same exported name. Which credential a request used therefore depended on the provider family, and the Models page disabled its key field whenever the name was exported (#5527).
|
||||
|
||||
## Decision
|
||||
|
||||
**A stored credential wins for every provider.** `LocalCredentialProvider` resolves a reference in this order:
|
||||
|
||||
```text
|
||||
$DSH_HOME/.credentials.yaml (provider-managed, written by set/unset)
|
||||
> inherited process environment (fallback; the provider never writes it)
|
||||
> <invocation cwd>/.env (fallback; the provider never writes it)
|
||||
> $DSH_HOME/.env (fallback; the provider never writes it)
|
||||
```
|
||||
|
||||
A save always writes the winning layer, so `describe` reports `writable: true` for every reference, and `set` and `unset` never refuse a name because it is exported. `resolve` and `describe` still report the supplying layer as `source` (`file`, `env`, `project-env`, or `user-env`). `authContextFrom` in `llm-pi-ai` answers pi-ai's `env()` from the credential seam before the launch environment, so a pi-ai resolver reads its stored record first, then a stored reference, then the fallbacks in the order above.
|
||||
|
||||
The non-secret ordering and the environment restrictions in configuration source ownership are unchanged. This note supersedes only its credential ordering.
|
||||
|
||||
## Per-run overrides
|
||||
|
||||
The previous order existed so that CI, containers, and a one-shot `DEEPSEEK_API_KEY=… dsh` could override a credential without editing machine state. A fresh CI or container harness home has no `.credentials.yaml`, so its exported secret is still the effective value. A harness home holds a stored key only because someone saved it there, through the Models page or by editing the file. To use an exported value for that name, the user deletes the `refs` entry, or launches with a separate `DSH_HOME`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the launch environment on top.** It keeps per-run overrides without touching the store, but it keeps the opposite precedence of stored records and stored references, and the Models page cannot save or replace a key while the launching shell exports the name.
|
||||
|
||||
**Refuse a write while only the launch environment supplies the name.** Under the new order such a write takes effect, because the stored value outranks the environment. Refusing it would block the first save of a key for every exported name, and it would keep the read-only field state that the new order removes.
|
||||
|
||||
**Warn when a stored value hides an exported variable.** The provider would compare layers on every resolution and report the result for users who keep both values on purpose, such as a shell profile that exports a key for other tools. The Models page help text states the order, and `describe` reports the supplying layer.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A key left in `.credentials.yaml` overrides an exported variable with the same name, including a CI secret on a machine whose harness home stores a value for that name. The [upgrade guide](../../../../docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.md) tells affected users how to keep the exported account.
|
||||
- The Models page can always save, replace, or remove a key, and it does not disable the key field for an exported name.
|
||||
- There is no per-run flag above the store. A one-run override takes a separate `DSH_HOME`.
|
||||
- `packages/credentials/credentials-local/tests/local.spec.ts` covers a store over a launch environment value and its return after `unset`; `packages/llm/llm-pi-ai/tests/auth.spec.ts` covers a stored credential that wins over every launch layer.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Agent Note: 存储的凭据优先于启动环境
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-09-stored-credential-precedence.md) | 中文
|
||||
|
||||
[Headless 环境认证](2026-10-12-headless-environment-auth.zh.md)决策部分取代本文的单次覆盖策略:随附 headless profile 不挂载存储。存储挂载时仍遵循下述顺序。
|
||||
|
||||
## 问题
|
||||
|
||||
[配置来源所有权](2026-08-04-configuration-source-ownership.zh.md) 把继承的进程环境排在 `$DSH_HOME/.credentials.yaml` 之前,并且 `credentials-local` 会拒绝对启动 shell 已导出的名字执行 `set` 和 `unset`。于是两类存储的凭据具有相反的优先级。pi-ai 的解析器先读取存储在 `llm-pi-ai/<provider>` 下的凭据记录,再读取任何环境变量,因此存储的 OAuth 授权或 `api-key` 记录会压过导出的密钥;而 Models 页面以引用形式存储的密钥会输给同名的导出变量。一次请求使用哪个凭据因此取决于提供方类别,而且只要该名字被导出,Models 页面就会禁用其密钥输入框(#5527)。
|
||||
|
||||
## 决策
|
||||
|
||||
**存储的凭据对每个提供方都优先。** `LocalCredentialProvider` 按以下顺序解析引用:
|
||||
|
||||
```text
|
||||
$DSH_HOME/.credentials.yaml (provider-managed, written by set/unset)
|
||||
> inherited process environment (fallback; the provider never writes it)
|
||||
> <invocation cwd>/.env (fallback; the provider never writes it)
|
||||
> $DSH_HOME/.env (fallback; the provider never writes it)
|
||||
```
|
||||
|
||||
保存总是写入胜出的那一层,因此 `describe` 对每个引用都报告 `writable: true`,`set` 与 `unset` 也不会因为名字已被导出而拒绝。`resolve` 与 `describe` 仍以 `source`(`file`、`env`、`project-env` 或 `user-env`)报告提供值的层。`llm-pi-ai` 中的 `authContextFrom` 先从凭据 seam、再从启动环境回答 pi-ai 的 `env()`,因此 pi-ai 解析器依次读取其存储的记录、存储的引用,再按上面的顺序读取后备来源。
|
||||
|
||||
配置来源所有权中的非秘密顺序与环境限制保持不变。本 Note 只替代其中的凭据优先级。
|
||||
|
||||
## 按次覆盖
|
||||
|
||||
之前的顺序让 CI、容器和一次性的 `DEEPSEEK_API_KEY=… dsh` 无需修改机器状态就能覆盖凭据。全新的 CI 或容器 harness home 中没有 `.credentials.yaml`,因此其导出的机密仍是生效值。harness home 中存有密钥,只可能是有人通过 Models 页面或直接编辑该文件存入的。要对该名字使用导出的值,用户应删除对应的 `refs` 条目,或使用单独的 `DSH_HOME` 启动。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**保持启动环境在最上层。** 它无需改动存储就能保留按次覆盖,但会保留存储记录与存储引用之间相反的优先级,而且只要启动 shell 导出了该名字,Models 页面就无法保存或替换密钥。
|
||||
|
||||
**只有启动环境提供该名字时拒绝写入。** 在新顺序下这样的写入会生效,因为存储值优先于环境。拒绝它会阻止为每个已导出的名字首次保存密钥,也会保留新顺序已经去掉的只读输入框状态。
|
||||
|
||||
**存储值遮蔽导出变量时发出警告。** 提供方需要在每次解析时比较各层,并向有意同时保留两个值的用户报告结果,例如在 shell 配置文件中为其他工具导出密钥的用户。Models 页面的帮助文本说明了该顺序,`describe` 也会报告提供值的层。
|
||||
|
||||
## 后果
|
||||
|
||||
- 留在 `.credentials.yaml` 中的密钥会覆盖同名的导出变量,包括在 harness home 存有该名字的机器上运行的 CI 机密。[升级指南](../../../../docs/upgrade-guide/v0.2.1-alpha.2/stored-credentials-precedence/guide.zh.md)说明受影响的用户如何继续使用导出的账户。
|
||||
- Models 页面总能保存、替换或删除密钥,也不会因名字被导出而禁用密钥输入框。
|
||||
- 存储之上没有按次生效的标志。一次运行的覆盖需要使用单独的 `DSH_HOME`。
|
||||
- `packages/credentials/credentials-local/tests/local.spec.ts` 覆盖在启动环境值之上存储、以及 `unset` 后恢复该值;`packages/llm/llm-pi-ai/tests/auth.spec.ts` 覆盖存储的凭据压过每个启动层。
|
||||
@@ -0,0 +1,25 @@
|
||||
# Bilingual-pair consistency record for 2026-10-11-chat-group-tree-exchanges.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-11-chat-group-tree-exchanges.md
|
||||
/agent-note-chat-group-tree-positions-and-exchanges:
|
||||
en: b26212eda152d6c6
|
||||
zh: a801a75ee0c07911
|
||||
/agent-note-chat-group-tree-positions-and-exchanges/problem:
|
||||
en: 84858bc0e1e268ab
|
||||
zh: 39ebb2979968d6cc
|
||||
/agent-note-chat-group-tree-positions-and-exchanges/decision:
|
||||
en: ca3153ae96994baa
|
||||
zh: de6c7d925559efbb
|
||||
/agent-note-chat-group-tree-positions-and-exchanges/why-mark-the-opening-rather-than-the-notice:
|
||||
en: db8d77cd773ad646
|
||||
zh: ab2f03b8fc58e952
|
||||
/agent-note-chat-group-tree-positions-and-exchanges/alternatives-considered:
|
||||
en: fd9152c3f5edb035
|
||||
zh: 259337cdcc5969a5
|
||||
/agent-note-chat-group-tree-positions-and-exchanges/consequences:
|
||||
en: 2a98bb21bb1315d1
|
||||
zh: be4a320e9b427a79
|
||||
/agent-note-chat-group-tree-positions-and-exchanges/testing:
|
||||
en: e2e3982718e16ef4
|
||||
zh: 4f3802946e51c55c
|
||||
@@ -0,0 +1,56 @@
|
||||
# Agent Note: Chat group-tree positions and exchanges
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-11-chat-group-tree-exchanges.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
On next message folding kept only the last Turn of the loaded timeline expanded. A Turn woken by a notice (a settled subagent, goal round, schedule, or background job) is not submitted by this Client, yet its arrival made the preceding Turn historical and folded it. No own-input driver computed the exact remaining room for that fold, so the scroll layer reserved generic bottom space and a reader following the tail saw blank space. In the recorded Session the notice woke the next Turn 13–15 ms after the previous Turn ended; the minimal reproduction left 596 px blank that had not recovered after five seconds.
|
||||
|
||||
## Decision
|
||||
|
||||
A notice is neither body text nor a user message: folding and bottom advancement ignore it. The bottom changes from the last Turn to the latest exchange.
|
||||
|
||||
- **Group-tree positions.** `ChatConversationViewNode.groupTree` declares a Node's position in the group tree, typed `ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process'` and ordered outermost first; a Node at a position also has the effect of every inner position. `exchange` opens an exchange. `input` makes the Node an input of its Turn: before the Turn's process it is the opening input, after it an interleaved input that keeps the whole Turn open. `turn` keeps the Node outside the Turn's process disclosure, `process` keeps it inside the disclosure but outside step runs, and a Node without a position joins the surrounding step run. `isAtOrOutside(node, position)` is the single reader of that order: process grouping reads `process`, whole-Turn folding reads `turn`, Turn-input detection reads `input`, and exchanges read `exchange`.
|
||||
- **Exchanges.** A Turn holding an `exchange` Node opens an exchange, which lasts until the next opening Turn. The business message Definition marks only human input claimed in Step 1 (`user` or `steering`) as `exchange`; later human input and trigger notices stand at `input` and never open an exchange.
|
||||
- **Built-in positions.** `turn-error`, `turn-max-tokens`, `turn-tail`, and the `turn-process` control stand at `turn`; `model-retry` stands at `process`. Two placement rules stay with ui-chat's own `assistant-step` Node: its reasoning row joins the step run while its reply row stands outside it, and the Turn's process specification keeps the finalized answer's reply outside the disclosure because which step answers changes as later steps arrive.
|
||||
- **Mechanism.** `GroupTreeSegments(position)` splits the loaded Turns at the Turns holding a Node at that position. The latest segment starts at the last opening Turn, or at the first loaded Turn when the window holds none. It counts per Turn incrementally without scanning loaded Nodes, and a start move returns only the Turns that entered or left the latest segment. The Chat Builder's `bottomSource` is "the Node is visible and belongs to the latest exchange".
|
||||
|
||||
As a result, a notice-woken Turn no longer folds earlier Turns, so no room is reserved; the human's next ordinary message folds the whole exchange through the own-input path.
|
||||
|
||||
## Why mark the opening rather than the notice
|
||||
|
||||
The host claims the waking input right after `turn/start`, assembles the system prompt, and only then logs `step/start` and `user/message`; the recorded Session spent 187 ms in that gap. Marking only the notice leaves the new Turn untagged during the gap, so the bottom still advances and the previous Turn folds and then reopens. Marking the human opening and letting unmarked Turns continue the exchange keeps the gap inert.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Tag the notice as a continuation.** The 187 ms gap above makes it fold and reopen, and a claim that mixes a notice with human input needs extra classification.
|
||||
|
||||
**Name opening kinds in a list (`user`, `steering`).** A kind cannot separate Step 1 steering from mid-Turn steering, so a human steering into a notice-woken Turn would wrongly fold the previous exchange.
|
||||
|
||||
**Publish Turn-level Location data.** Only one Context may publish a key for a Turn, while opening input comes from several message Contexts; aggregating per Turn would repeat the inbox claim classification in a new Definition and read every Turn on each update.
|
||||
|
||||
**Declare a business role (`input`, `notice`) on Nodes.** That places business semantics in the grouping mechanism; group-tree positions describe structure only, and each business Definition chooses its position.
|
||||
|
||||
**Correct the scroll room for folds not caused by own input.** Notices should not fold at all, and correcting the room would not remove that unnecessary fold. Folds caused by human input without an echo on this page are handled by [one fold motion for collapsing on the next message](2026-10-13-chat-single-fold-motion.md).
|
||||
|
||||
**Read Turn inputs at `turn`.** Terminal errors, max-token notices, and the completed-turn footer also stand outside the process disclosure, so reading `turn` would treat them as opening input; Turn inputs need their own position.
|
||||
|
||||
**Derive the exchange from an `input` in Step 1.** A trigger notice is its Turn's opening input but must not open an exchange. Deriving the exchange would drop trigger notices from `input` and change how notice-woken Turns order their process rows.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Notice-woken Turns no longer cause a fold, reserved room, or blank space.
|
||||
- External Definitions place their Chat rows through `groupTree`. Apart from the two `assistant-step` rules, ui-chat names no kind when it groups, folds, or detects Turn inputs, and built-in kinds keep the placement their former kind lists gave them.
|
||||
- Under On next message, a long run of notice-woken Turns (up to 31 goal-mode Turns in sampled Sessions) stays expanded until the human types again.
|
||||
- When the loaded window holds no opening Turn, every loaded Turn belongs to the latest exchange.
|
||||
- Human input from another tab folds when its `user/message` arrives, about 200 ms later than the former `turn/start` fold; its landing follows [one fold motion for collapsing on the next message](2026-10-13-chat-single-fold-motion.md).
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`: position order, segmentation, count fallback, and the empty window.
|
||||
- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`: latest-exchange membership and notification scope.
|
||||
- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`: a real assembler across an opening Turn, mid-Turn steering, the pre-Step gap, a notice wake, and an idle steer, with non-opening inputs at `input`.
|
||||
- `packages/client/ui-chat/tests/process-groups.client.spec.ts`: a Node at any position splits the step run regardless of its kind, and a position change regroups.
|
||||
- `apps/web/tests/notice-wake-tail.e2e.ts`: after a long Turn a notice wakes the next Turn; the long Turn stays expanded through the pre-Step gap and after the notice reply, and a following reader sees the notice and the reply.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Agent Note: Chat 分组树位置与交互段
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-11-chat-group-tree-exchanges.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
「下次发送时收起」原本只让已加载时间线的最后一个 Turn 保持展开。由通知唤醒的 Turn(子 agent 完成、Goal Round、定时任务、后台作业)不是本客户端提交的,它一出现,上一轮就被当作历史折叠。这次折叠没有自己输入的驱动来计算精确的剩余补白,滚动层只能按通用规则预留底部空间,正在跟尾的读者因此看到一片空白。录制的真实 Session 中,通知在上一轮结束后 13–15 ms 唤醒新轮次;最小复现里空白高 596 px,五秒后仍未恢复。
|
||||
|
||||
## Decision
|
||||
|
||||
通知不算正文,也不算用户消息:折叠和推进底部时都忽略它。底部从"最后一个 Turn"改为"最新交互段"。
|
||||
|
||||
- **分组树位置。** `ChatConversationViewNode.groupTree` 声明节点在分组树中的位置,类型为 `ChatGroupTree = 'exchange' | 'input' | 'turn' | 'process'`,由外到内有序;站在某个位置的节点同时具有所有内层位置的效果。`exchange` 开启一个交互段。`input` 表示节点是所在 Turn 的输入:出现在该轮过程之前即为开场输入,之后出现即为中途输入,使整个 Turn 保持展开。`turn` 让节点留在 Turn 的整轮折叠之外,`process` 让节点留在整轮折叠之内、步骤组之外,没有位置的节点并入相邻的步骤组。`isAtOrOutside(node, position)` 是读取这一顺序的唯一入口:过程分组读 `process`,整轮折叠读 `turn`,轮次输入判断读 `input`,交互段读 `exchange`。
|
||||
- **交互段。** 含 `exchange` 节点的 Turn 开启一个交互段,交互段持续到下一个开启 Turn。业务层的 message Definition 只给 Step 1 中被认领的真人输入(`user` 或 `steering`)标 `exchange`;之后的真人输入和触发通知位于 `input`,不开启交互段。
|
||||
- **内置位置。** `turn-error`、`turn-max-tokens`、`turn-tail` 与 `turn-process` 控件位于 `turn`;`model-retry` 位于 `process`。ui-chat 自己的 `assistant-step` 节点保留两条放置规则:推理行并入步骤组、回复行在步骤组之外;该轮的过程规格让最终答案的回复留在整轮折叠之外,因为哪一步是最终答案会随后续步骤到来而变化。
|
||||
- **机制。** `GroupTreeSegments(position)` 按含该位置节点的 Turn 切分已加载的 Turn,最新一段从最后一个开启 Turn 开始,窗口中没有开启 Turn 时从第一个已加载 Turn 开始。它按 Turn 增量计数,不扫描已加载节点;起点移动时只返回进入或离开最新一段的 Turn。Chat Builder 的 `bottomSource` 为"节点可见且属于最新交互段"。
|
||||
|
||||
结果是:通知唤醒的 Turn 不再折叠前面的 Turn,没有折叠就没有补白;真人下一次普通输入时,整个交互段经由自己输入的路径一起折叠。
|
||||
|
||||
## 为什么标开头而不是标通知
|
||||
|
||||
Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示,再写 `step/start` 和 `user/message`。录制的 Session 中这段间隔为 187 ms。只给通知打标的话,这段时间新 Turn 还没有标签,底部照样推进,上一轮会先折叠再展开。把标签放在真人开头上、未标的 Turn 默认续接,空白间隔里什么都不会发生。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**给通知打"续接"标签。** 上面那 187 ms 的间隔会让它先折后展;同一次认领里通知和真人输入并存时还要额外判断。
|
||||
|
||||
**按 kind 写一张开启名单(`user`、`steering`)。** kind 区分不了 Step 1 的 steering 与 Turn 中途的 steering,真人往通知唤醒的 Turn 里插话时会错误地折叠前一个交互段。
|
||||
|
||||
**发布到 Turn 级 Location data。** 同一 Turn 的同一个 key 只允许一个 Context 发布,而开启输入来自多个消息 Context;改成按 Turn 汇总就得在新的 Definition 里重复 inbox 认领分类,并且每次都要读取所有 Turn。
|
||||
|
||||
**节点声明业务角色(`input`、`notice`)。** 这会把业务语义放进分组机制;分组树位置只描述结构,业务 Definition 自行决定位置。
|
||||
|
||||
**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠。本页没有回显的真人输入引起的折叠,由[「下条消息时收起」只用一条折叠动画](2026-10-13-chat-single-fold-motion.zh.md)处理。
|
||||
|
||||
**在 `turn` 位置上判断轮次输入。** 终止错误、最大 token 提示和已完成轮次页脚同样在整轮折叠之外,读 `turn` 会把它们误当作开场输入;轮次输入需要自己的位置。
|
||||
|
||||
**由 Step 1 中的 `input` 推导交互段。** 触发通知是所在 Turn 的开场输入,却不能开启交互段。推导交互段就得把触发通知移出 `input`,从而改变通知唤醒的 Turn 中过程行的排列。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 通知唤醒的 Turn 不再产生折叠、补白和空白。
|
||||
- 外部 Definition 通过 `groupTree` 放置自己的 Chat 行。除 `assistant-step` 的两条规则外,ui-chat 在分组、折叠和判断轮次输入时不点名任何 kind,内置 kind 保持原 kind 名单给出的放置。
|
||||
- 「下次发送时收起」下,一长串通知唤醒的 Turn(抽样会话中 Goal 模式最长 31 轮)会一直展开,直到真人再次输入。
|
||||
- 已加载窗口里没有开启 Turn 时,所有已加载 Turn 都属于最新交互段。
|
||||
- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;其落点按[「下条消息时收起」只用一条折叠动画](2026-10-13-chat-single-fold-motion.zh.md)决定。
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`:位置顺序、切分、计数回退与空窗口。
|
||||
- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`:最新交互段的成员与通知范围。
|
||||
- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`:真实 assembler 下的开轮、中途 steering、Step 1 前的间隔、通知唤醒与空闲 steer,非开启输入位于 `input`。
|
||||
- `packages/client/ui-chat/tests/process-groups.client.spec.ts`:任意位置上的节点不论 kind 都会切开步骤组,位置变化会触发重新分组。
|
||||
- `apps/web/tests/notice-wake-tail.e2e.ts`:长 Turn 之后由通知唤醒,在 Step 1 前的间隔和通知回复之后,上一轮都保持展开,跟尾读者看到通知与回复。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-10-12-exclusive-conversation-matches.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-12-exclusive-conversation-matches.md
|
||||
/agent-note-exclusive-conversation-matches:
|
||||
en: 6f78605cc85cf7f2
|
||||
zh: f73e26bb3f9d1896
|
||||
/agent-note-exclusive-conversation-matches/problem:
|
||||
en: 38ed7541d4cb7019
|
||||
zh: 98ed08f8c0c7c5ba
|
||||
/agent-note-exclusive-conversation-matches/decision:
|
||||
en: f2bfa3d0cc6b429a
|
||||
zh: c1dd501c79086d71
|
||||
/agent-note-exclusive-conversation-matches/alternatives-considered:
|
||||
en: efd209a2c0e97381
|
||||
zh: 8b6f10ff4de49449
|
||||
/agent-note-exclusive-conversation-matches/consequences:
|
||||
en: 414ae7349bd4cc03
|
||||
zh: 9861b3e72bdfc548
|
||||
/agent-note-exclusive-conversation-matches/testing:
|
||||
en: a88ed18065da4d28
|
||||
zh: 5bf4448a8b1f4763
|
||||
@@ -0,0 +1,52 @@
|
||||
# Agent Note: Exclusive Conversation matches
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-12-exclusive-conversation-matches.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Every event Definition matches each event independently, and a target may hold a generic Definition that presents a whole event family. When a business Definition presents one kind of that family itself, both Definitions match the same event and each builds a Context and a Node. Nothing let the business Definition say that the event belongs to it on that target.
|
||||
|
||||
The late question reply is the shipped case. Chat's generic `input-message` Definition matches every appended `user/message`, and the questions UI's `question-reply` Definition matches the reply message it presents. Chat process grouping therefore dropped the duplicate `turn-trigger` presentation by message id, in both the Turn-owned and unscoped paths, and anchored the Turn control to the first group member so it stayed above the reply. The duplicate Node remained in the Node Store, and the generic Definition kept treating the reply as context or a trigger notice.
|
||||
|
||||
## Decision
|
||||
|
||||
A match result may claim its event on the Definition's target:
|
||||
|
||||
```ts
|
||||
interface ConversationMatchResult {
|
||||
readonly id: string
|
||||
readonly role: 'start' | 'update'
|
||||
readonly exclusive?: true
|
||||
}
|
||||
```
|
||||
|
||||
- **Dispatch.** `ConversationNodeAssembler.dispatchInput()` evaluates every match of one event into two reused arrays before accepting any, then settles claims. Other Definitions of a claimed target build no Context for the event, and that target's unmatched-event fallback does not run. Definitions of other targets and Definitions without a target still receive it. The outcome is independent of registration order. Live append and window collection share this dispatch.
|
||||
- **Failures.** Two claims on one target, or a claim from a Definition without a target, throw during dispatch and name the event, its `seq`, and the claiming Definition kinds.
|
||||
- **Scope.** A claim belongs to one event and comes from `match()`, so a Definition claims only the events it recognizes. The generic Definition declares nothing.
|
||||
- **Current use.** `question-reply` returns `exclusive: true` for a recorded `user-question-reply` message. Chat no longer aggregates by message id, and the reply's placement is unchanged.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Definition precedence (`specific` / `default`).** A generic Definition would declare itself overridable, and dispatch would become a priority order rather than a statement of which Definition presents an event. A claim needs no change to the generic Definition, and two claims fail instead of silently ranking.
|
||||
|
||||
**Declare human sources in a registry and render the reply through user-text rules.** The reply would stay a generic message Node with customized text. Chat's message and Inbox Definitions would need a service-backed predicate, registry changes would have to reassemble every bound Conversation, and the text rule would need the message source as a fact. A claim keeps the reply in its owning Definition.
|
||||
|
||||
**Mark human-authored sources in durable data.** A generic field on the message source would let the generic Definition classify the reply, but it changes a persisted type, and replies recorded by released builds do not carry it.
|
||||
|
||||
**Skip the reply kind in `input-message`.** The generic Chat Definition would name a plugin's source kind.
|
||||
|
||||
**Deduplicate in the grouping output.** This is the replaced mechanism: the duplicate Node stays stored and visible to every other Chat reader, and each grouping path repeats the aggregation.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A claimed event has exactly one Context per claimed target, so no hidden duplicate remains in the Node Store.
|
||||
- A conflicting claim fails at dispatch rather than at registration, because claims are decided per event from event data.
|
||||
- Dispatch makes one extra pass over an event's matches, usually one to three, and allocates nothing for events without a claim.
|
||||
- A plugin that presents part of a generic Definition's event family on one target uses a claim; it does not need the generic Definition's cooperation.
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts` ("exclusive matches"): a claim withholds the event from other Definitions of its target in either registration order and on live append; other targets, target-less Definitions, and no fallback; two claims and a target-less claim throw.
|
||||
- `packages/client/ui-user-questions/tests/question-reply.client.spec.ts`: the reply match claims its event, and an assembler holding both message Definitions produces one Chat Node for the reply.
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# Agent Note: Conversation 独占匹配
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-12-exclusive-conversation-matches.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
每个事件 Definition 各自独立地匹配每个事件,同一个 target 上可能有一个呈现整类事件的通用 Definition。业务 Definition 自己呈现这类事件中的一种时,两个 Definition 会命中同一个事件,各自建立 Context 和 Node。业务 Definition 没有办法声明这个事件在该 target 上归它呈现。
|
||||
|
||||
迟到的问题回答是已上线的实例。Chat 的通用 `input-message` Definition 匹配每条追加的 `user/message`,问题 UI 的 `question-reply` Definition 也匹配它要呈现的回答消息。Chat 过程分组因此在有轮次归属和无轮次归属两条路径上都按消息 id 丢掉重复的 `turn-trigger` 呈现,并把整轮控件锚到组首成员,让它留在回答之上。重复的 Node 仍留在 Node Store 中,通用 Definition 也仍把回答当作 context 或触发通知。
|
||||
|
||||
## Decision
|
||||
|
||||
match 结果可以在 Definition 的 target 上认领它的事件:
|
||||
|
||||
```ts
|
||||
interface ConversationMatchResult {
|
||||
readonly id: string
|
||||
readonly role: 'start' | 'update'
|
||||
readonly exclusive?: true
|
||||
}
|
||||
```
|
||||
|
||||
- **派发。** `ConversationNodeAssembler.dispatchInput()` 先把一个事件的全部 match 求入两个复用数组,再结算认领,之后才逐个接收。被认领 target 上的其他 Definition 不为该事件建立 Context,该 target 的未匹配事件 fallback 也不运行;其他 target 的 Definition 与没有 target 的 Definition 照常接收。结果与注册顺序无关。实时追加和窗口收集共用这一派发。
|
||||
- **失败。** 同一 target 被认领两次,或没有 target 的 Definition 发起认领,都会在派发时抛错,错误信息包含事件、其 `seq` 与认领方 Definition 的 kind。
|
||||
- **范围。** 认领只对一个事件生效,由 `match()` 返回,因此 Definition 只认领它识别出的事件;通用 Definition 不需要声明任何东西。
|
||||
- **当前用法。** `question-reply` 对已记录的 `user-question-reply` 消息返回 `exclusive: true`。Chat 不再按消息 id 聚合,回答的放置不变。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Definition 优先级(`specific` / `default`)。** 通用 Definition 得声明自己可被覆盖,派发也变成优先级排序,而不是声明由哪个 Definition 呈现事件。认领不要求改动通用 Definition,两个认领冲突时直接失败,而不是静默排序。
|
||||
|
||||
**用注册表声明真人来源,并通过用户文本规则渲染回答。** 回答会保持为通用消息 Node,只定制文本。Chat 的 message 与 Inbox Definition 需要一个依赖 service 的判断函数,注册表变化必须重新组装所有已绑定的 Conversation,文本规则还要把消息来源当作 fact 读取。认领让回答留在它自己的 Definition 中。
|
||||
|
||||
**在持久数据中标记真人撰写的来源。** 消息来源上的通用字段可以让通用 Definition 自行分类回答,但这会改动持久化类型,已发布版本记录的回答也不带这个字段。
|
||||
|
||||
**在 `input-message` 中跳过回答的 kind。** 通用的 Chat Definition 会点名插件的来源 kind。
|
||||
|
||||
**在分组输出层去重。** 这是被替换的机制:重复 Node 仍留在存储里,对其他所有 Chat 读取方可见,每条分组路径都要重复这一聚合。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 被认领的事件在每个被认领 target 上恰好只有一个 Context,Node Store 中不再残留隐藏的重复节点。
|
||||
- 认领冲突在派发时失败,而不是在注册时失败,因为认领由事件数据逐个决定。
|
||||
- 派发对每个事件的 match 多遍历一次,通常一到三个;没有认领的事件不产生额外分配。
|
||||
- 插件在一个 target 上呈现通用 Definition 事件族中的一部分时,使用认领即可,不需要通用 Definition 配合。
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts`("exclusive matches"):两种注册顺序与实时追加下,认领都会对同 target 的其他 Definition 屏蔽该事件;其他 target 与无 target 的 Definition 照常接收,fallback 不运行;两次认领与无 target 认领会抛错。
|
||||
- `packages/client/ui-user-questions/tests/question-reply.client.spec.ts`:回答的 match 认领其事件;同时持有两个消息 Definition 的 assembler 只为回答产生一个 Chat Node。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-12-headless-environment-auth.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-12-headless-environment-auth.md
|
||||
/agent-note-headless-jobs-omit-the-interactive-credential-store:
|
||||
en: c768db5a7e821bf0
|
||||
zh: 423b3e423d071ddc
|
||||
/agent-note-headless-jobs-omit-the-interactive-credential-store/problem:
|
||||
en: 829df36d87975148
|
||||
zh: 171d29ef2f2e5079
|
||||
/agent-note-headless-jobs-omit-the-interactive-credential-store/decision:
|
||||
en: af275ffea713f76b
|
||||
zh: 12a3bacfcb147df7
|
||||
/agent-note-headless-jobs-omit-the-interactive-credential-store/alternatives-considered:
|
||||
en: f6f8dfde4789adc8
|
||||
zh: ced62f3d1ec402e6
|
||||
/agent-note-headless-jobs-omit-the-interactive-credential-store/consequences:
|
||||
en: 1c1a699dc9f5681f
|
||||
zh: 4a72b09d24f9a005
|
||||
@@ -0,0 +1,30 @@
|
||||
# Agent Note: Headless jobs omit the interactive credential store
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-12-headless-environment-auth.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [stored credential precedence](2026-10-09-stored-credential-precedence.md) decision makes GUI saves effective even when the shell exports a key for another application. A headless job sharing that Harness home can then use the GUI account instead of the key explicitly supplied to the job. Changing reference precedence alone would leave pi-ai provider records and saved grants ahead of the environment.
|
||||
|
||||
## Decision
|
||||
|
||||
The shipped headless bundle disables the base `credentials`, `authorization` and `deepseek-account` rows. API-key adapters already support compositions without the credentials service and resolve through the launch snapshot: process environment, invocation-directory `.env`, then Harness-home `.env`. The profile does not read, migrate, write or delete `.credentials.yaml`. Dependent authorization and account rows are disabled explicitly so ordinary jobs do not emit pending-service warnings.
|
||||
|
||||
This partially supersedes the per-run override policy in stored credential precedence for the shipped headless profile. Other profiles and an explicitly restored headless store retain stored-first resolution. The headless [README](../../../../packages/bundle/headless/README.md#credentials) owns the restoration patch.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Put the environment first in every profile.** It prevents a GUI save from replacing a key left in the launching environment and restores inconsistent reference and provider-record precedence.
|
||||
|
||||
**Disable only the credentials row.** It achieves environment selection but leaves authorization and the account provider pending, producing warnings on every launch.
|
||||
|
||||
**Add a second precedence mode to the store.** It introduces configuration and record-handling policy into the shared provider when existing adapter fallbacks and profile overrides already supply the required behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Headless ignores saved API keys, provider records and grants. A provider requiring stored sign-in or the DeepSeek account route fails until the user restores stored authentication; restoring it also restores saved-first precedence.
|
||||
- Environment and external provider credential chains remain available. Sessions, settings and tools stay composed; omission of the store does not deny filesystem reads by tools.
|
||||
- Web, SDK and ACP retain the credential store. Their authentication, login and refresh implementations are unchanged.
|
||||
- Profile-level process tests cover all three environment layers, named and native pi-ai authentication, missing keys, ignored malformed storage, restoration and preservation of the document. Existing headless transcripts and real-model workspace tests cover the remaining composition.
|
||||
@@ -0,0 +1,30 @@
|
||||
# Agent Note: Headless 任务不挂载交互式凭据存储
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-12-headless-environment-auth.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[已保存凭据优先级](2026-10-09-stored-credential-precedence.zh.md)的决策保证 GUI 保存的密钥生效,即使 shell 为其他应用导出了密钥。共享同一个 Harness home 的 headless 任务可能因此使用 GUI 的账户,而不是显式传给任务的密钥。只调整引用优先级仍会让 pi-ai 提供方记录和已保存 grant 排在环境变量之前。
|
||||
|
||||
## 决策
|
||||
|
||||
随附 headless 组合包禁用 base 中的 `credentials`、`authorization` 和 `deepseek-account` 行。API 密钥适配器已经支持不挂载凭据服务的组合,按启动快照解析:进程环境、运行命令的目录 `.env`、Harness home `.env`。该 profile 不读取、迁移、写入或删除 `.credentials.yaml`。显式禁用依赖它的 authorization 和 account 行,避免普通任务输出等待服务的警告。
|
||||
|
||||
这部分取代了已保存凭据优先级中针对随附 headless profile 的单次覆盖策略。其他 profile,以及显式恢复存储的 headless,仍按已保存凭据优先解析。headless [README](../../../../packages/bundle/headless/README.zh.md#credentials)负责恢复 patch 的用法。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在所有 profile 中让环境变量优先。** 这会使 GUI 保存的密钥无法替换启动环境中遗留的密钥,并恢复引用与提供方记录之间不一致的优先级。
|
||||
|
||||
**只禁用 credentials 行。** 这能选用环境变量,但 authorization 与 account 提供方会保持 pending,每次启动都产生警告。
|
||||
|
||||
**给存储增加第二种优先级模式。** 现有适配器后备与 profile 覆盖已经满足需求;这种做法却会把额外配置和记录处理策略引入共享提供方。
|
||||
|
||||
## 后果
|
||||
|
||||
- Headless 忽略已保存 API 密钥、提供方记录和 grant。依赖已保存登录凭据的提供方或 DeepSeek account 路由会失败,直到用户恢复存储认证;恢复后也恢复已保存凭据优先。
|
||||
- 环境变量与提供方外部凭据链仍然可用。会话、settings 与工具继续组合;不挂载存储不会禁止工具读取文件系统。
|
||||
- Web、SDK 与 ACP 保留凭据存储;其认证、登录和刷新实现不变。
|
||||
- Profile 进程测试覆盖三个环境层、具名与原生 pi-ai 认证、缺失密钥、忽略畸形存储、恢复存储,以及文件保持原样。现有 headless 录制与真实模型工作区测试覆盖组合中的其余行为。
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
# Bilingual-pair consistency record for 2026-10-12-peer-principal-and-remote-access.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control:
|
||||
en: 0c0fd0f567bd5c89
|
||||
zh: 464e34d05737a7bc
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/problem:
|
||||
en: f5b70ee2c0a3e32a
|
||||
zh: 68776a916cb41f11
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision:
|
||||
en: a15c7c13f167e5ac
|
||||
zh: 63bcc2ef60c2f344
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/scope:
|
||||
en: 86061e5578d06871
|
||||
zh: 08eb605fcd38329d
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/two-layers-principal-and-peer:
|
||||
en: 6663ea33561eee82
|
||||
zh: 722fd3b8fae0ef90
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/peer-model:
|
||||
en: db5b0cf33679e5ed
|
||||
zh: 1b04328268a95abc
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/identity-types:
|
||||
en: 31430c5b95295ead
|
||||
zh: 8f4ba35b4406558d
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/peer-lifetime-and-revocation:
|
||||
en: a01ac57fc71a84f0
|
||||
zh: 3e488ef3da5e4b8c
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control:
|
||||
en: a55516339b0d49dd
|
||||
zh: 3281df9ea7878fc0
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/correspondence-with-rbac:
|
||||
en: a78a2410eb52f777
|
||||
zh: a868bf1b0e690c9f
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/resources-and-resource-readers-deepseek-ai-dsh-typert-protocol-deepseek-ai-dsh-typert-registry:
|
||||
en: 625b20552962e268
|
||||
zh: 1136c0fd7cdc6901
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/endpoint-resources:
|
||||
en: ec1677d8e338e115
|
||||
zh: 6bedd163f208eaa9
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/roles-bindings-and-policies-deepseek-ai-dsh-api-remote-access:
|
||||
en: e57f396239ea9f56
|
||||
zh: ddb26278b1521ceb
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/example-share-viewer:
|
||||
en: 3c911f57f0f6ca4b
|
||||
zh: 1509781eeda4c922
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/host-access-check-order:
|
||||
en: 6c58dc8777e107ef
|
||||
zh: bc14605c96e4d8f3
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/forwarded-events:
|
||||
en: 5fe34ee5ec31942a
|
||||
zh: ef39b020b0539b8f
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/list-and-aggregate-methods:
|
||||
en: 7865e159528e2d30
|
||||
zh: 1399d54632f4d926
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/access-snapshot-stream:
|
||||
en: 41d7ac1faf2448d7
|
||||
zh: 6d523895a9abae68
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/client-face:
|
||||
en: 7be28cd7e19e5377
|
||||
zh: 30b5aee957271d6c
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/security-invariants:
|
||||
en: 2094587c320b6ccc
|
||||
zh: 3a1d2a6b343500f4
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/recording-the-message-source:
|
||||
en: 2ee3abed24d418f4
|
||||
zh: 25653dd24b95c220
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/code-review-rule-and-documentation:
|
||||
en: e9a2e0d1534b8fe7
|
||||
zh: 085813c0b2b3c0d8
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/out-of-scope-and-future-work:
|
||||
en: 4bb0314c20e4f25c
|
||||
zh: 6ede1dd2193a01d7
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/relation-to-existing-notes:
|
||||
en: 305ec45a7cd85ed7
|
||||
zh: d2cd4209a56b1d0c
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/alternatives-considered:
|
||||
en: 130df2981ba550df
|
||||
zh: e9a0a89af6fb50a3
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences:
|
||||
en: 89452c3c3f042e75
|
||||
zh: 9bcad00a60c65303
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification:
|
||||
en: 048067eb02e6bf8a
|
||||
zh: 12508b59f6b206b8
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits:
|
||||
en: 1e254cec680b1a37
|
||||
zh: 06a31e057ba965aa
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/migration-list-for-pr-5945:
|
||||
en: f904c62b68d46bb5
|
||||
zh: 34eb45b97302badb
|
||||
+672
@@ -0,0 +1,672 @@
|
||||
# Agent Note: Peer principals and method-level Remote access control
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-12-peer-principal-and-remote-access.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A Remote method on the Host could not tell whom one call spoke for, and could not restrict callable methods by caller. The [`PeerScope`](../../../../packages/typert/protocol/src/types.ts) contract stated "Who the Peer is and what it may do are not recorded here"; [Connection](../../../../packages/client/connection/src/rpc-host.ts) had a single operator Peer, `admit()` returned it for every authenticated request, and the `/api` route and the interceptor's Fetch handler used it unconditionally instead of the admission result; [Gateway](../../../../packages/api/gateway/src/index.ts) silently fell back to `operatorPeer()` when a caller named no Peer.
|
||||
|
||||
The first non-owner caller is a liveshare viewer. Viewer requests reach the owner's liveshare Host plugin through the Relay, and the plugin dispatched them in the owner's process as the operator, so the owner's Session treated a viewer's prompt as the owner's own input. PR #3854 pushed liveshare semantics into session-controller for this: the request body carries `origin`, every prompt writes a required `prompt/origin` event correlated by requestId, the controller folds origins, `serializeAdmission` extends to every prompt, and a new `api-session/external-admission` hook appears; liveshare maintains its own `READ_ENDPOINTS` allowlist and trims list/follow results; the frontend `clientWritePolicy` lives in ui-conversation, with three ways of reading it across five packages and seven places that hide UI by whether a callback was injected.
|
||||
|
||||
One general mechanism is needed:
|
||||
|
||||
- In-process Host code assigns the caller identity; no wire field can set or override it.
|
||||
- Every call path names its Peer explicitly; there is no implicit fallback.
|
||||
- Method-level permission is computed and enforced in one place on the Host; the frontend reads the same result to show or hide entries and follows rule changes automatically.
|
||||
- A prompt records its caller with the user message, so a later check can tell who initiated each turn.
|
||||
|
||||
## Decision
|
||||
|
||||
### Scope
|
||||
|
||||
| Part | Content |
|
||||
|---|---|
|
||||
| Peer identity | `PeerScope.principal` and `PeerScope.signal`; one Peer implementation `Peer` (renamed from `OperatorPeer`) with an options constructor; removal of the operator concept and every implicit Peer fallback; `connection.browserPeer`, `connection.createPeer()`, and `fetch(request, peer)`; a disposed Peer is always refused with `gateway/peer-disposed`; the `user-rpc` source records `principal?` |
|
||||
| Method-level access control | `resources` readers on `@Remote`, `TypertResourceMap` resource kinds, and the `typert.resources` registry (typert-protocol, typert-registry); Gateway resolution of calls and forwarded events to resources; new package `@deepseek-ai/dsh-api-remote-access` (Host and Client faces): RBAC decisions over roles, bindings, and policies through `decide(request)`, list filtering through `allows()`, the first snapshot in the index and the access snapshot stream, and access hooks |
|
||||
| Documentation | One code-review rule and terminology |
|
||||
|
||||
### Two layers: principal and peer
|
||||
|
||||
| Layer | Concept | Example | Cardinality |
|
||||
|---|---|---|---|
|
||||
| Authorization | principal: the caller identity that permission decisions read | `share-viewer{shareId, actor}` proven by a share token; the local user `{ kind: 'owner' }` | one principal has N peers |
|
||||
| Connection | peer: the lifetime scope of one connection | a browser connection, one connection of a liveshare viewer | one peer per connection |
|
||||
|
||||
| Concern | Layer |
|
||||
|---|---|
|
||||
| Method-level decisions, access hooks, access snapshots | principal |
|
||||
| Recording `source.principal` | principal |
|
||||
| Subscriptions, streams, connection-lifetime registrations (`peer.ctx`) | peer |
|
||||
| Revocation | both: after revocation the policy gives that principal no binding, so its calls are all refused; the creator disposes every peer under it |
|
||||
|
||||
The creator owns the principal→peers table; liveshare, for example, keeps one object per share grant that records every peer opened under it.
|
||||
|
||||
### Peer model
|
||||
|
||||
There is one Peer concept and one implementation; the operator concept is removed.
|
||||
|
||||
```text
|
||||
// @deepseek-ai/dsh-typert-protocol
|
||||
export interface PeerScope {
|
||||
readonly id: PeerId
|
||||
/** Connection-lifetime registrations; disposed with the Peer. */
|
||||
readonly ctx: Context
|
||||
/** Caller identity fixed at creation by in-process Host code; no wire field can set it. */
|
||||
readonly principal: Principal
|
||||
/** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */
|
||||
readonly signal: AbortSignal
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
// @deepseek-ai/dsh-client-connection
|
||||
export class Peer implements PeerScope {
|
||||
constructor(owner: Context, options: { readonly principal: Principal })
|
||||
}
|
||||
|
||||
interface HostConnectionHandle {
|
||||
/** The Peer every authenticated browser connection shares; principal { kind: 'owner' }. */
|
||||
readonly browserPeer: PeerScope
|
||||
/** Network admission: the admitted Peer or the refusal status. */
|
||||
admit(request: ConnectionTrustRequest): PeerAdmission
|
||||
/** In-process only; disposed with the Context that read `ctx.connection`, or earlier by dispose(). */
|
||||
createPeer(options: { readonly principal: Principal }): PeerScope
|
||||
}
|
||||
|
||||
interface ConnectionFetchHandler {
|
||||
fetch(request: Request, peer: PeerScope): Promise<Response>
|
||||
}
|
||||
bridge(req, res, apiHandler, peer, maxRequestBodyBytes?)
|
||||
|
||||
// @deepseek-ai/dsh-api-gateway
|
||||
interface InvokeRemoteRequest { …; readonly peer: PeerScope } // was `peer?`
|
||||
wireStream.open(endpoint, payload, uplink, peer: PeerScope, signal) // was `PeerScope | undefined`
|
||||
```
|
||||
|
||||
| Rule | Detail |
|
||||
|---|---|
|
||||
| Every path names its Peer | Network paths take it from `admit()`; in-process paths receive it from the caller. Gateway deletes `operatorPeer()` and `request.peer ?? …` and never creates a Peer |
|
||||
| Browser connections share `browserPeer` | Every browser connection shares one Peer, and every use site names `connection.browserPeer` explicitly; there is no fallback variable. The in-page Host (webworker-runtime) also passes `browserPeer` explicitly |
|
||||
| In-process Host automation | Code that calls Remote methods creates its own Peer with `createPeer({ principal })`. No production caller exists yet, so no fixed Host Peer is pre-provisioned |
|
||||
| Multiple Peers | Each `createPeer()` returns an independent Peer; liveshare is to create one per viewer connection (wired on the #3854 side) |
|
||||
| Exact Fetch routes | Exact routes such as file-upload, present-open, media-references, session-log-export, and inspector bypass the Gateway; the shared Fetch handler answers 403 to every non-owner Peer |
|
||||
|
||||
### Identity types
|
||||
|
||||
```text
|
||||
// @deepseek-ai/dsh-typert-protocol
|
||||
|
||||
/**
|
||||
* Merge-extensible caller identities. Each driver declares its own entry.
|
||||
* Every entry is a JSON value with a unique literal `kind`, carries no
|
||||
* credential, and is safe to persist in a Session log.
|
||||
*/
|
||||
export interface PrincipalMap {
|
||||
owner: { readonly kind: 'owner' }
|
||||
}
|
||||
export type Principal = PrincipalMap[keyof PrincipalMap]
|
||||
|
||||
/**
|
||||
* @param invocation - `ctx.invocation`; `undefined` outside a Remote call.
|
||||
* @returns the caller's principal when it is not the owner; `undefined` for the
|
||||
* owner and for trusted in-process code outside a Remote call.
|
||||
*/
|
||||
export function principalOf(invocation: RemoteInvocation | undefined): Principal | undefined
|
||||
```
|
||||
|
||||
The single read point is `this.ctx.invocation.peer.principal`; an owner test is written inline as `principal.kind === 'owner'`, and an unknown kind is never the owner. External drivers extend `PrincipalMap` by declaration merging in their own package; liveshare's `share-viewer{ shareId, actor }` is declared on the #3854 side, and liveshare itself is unchanged here.
|
||||
|
||||
### Peer lifetime and revocation
|
||||
|
||||
| Event | Behavior |
|
||||
|---|---|
|
||||
| Gateway dispatch | The first step checks `peer.signal.aborted` and refuses a disposed Peer with `gateway/peer-disposed`, on HTTP, WebSocket, and in-process paths alike; wire paths check before they read the payload, so a malformed payload from a disposed Peer gets the same code |
|
||||
| Streams when a Peer is disposed | The signal a Remote stream method observes joins `peer.signal`, and so does the `$events` stream lifetime; a WebSocket still closes with 1001 |
|
||||
| Unary calls when a Peer is disposed | A unary call already running is not cancelled; later calls are refused |
|
||||
| Revocation | After a business package revokes a grant, its policy gives that principal no binding, and the business package calls `invalidate(principal)`; the creator disposes every peer in its principal→peers table |
|
||||
|
||||
### Method-level access control
|
||||
|
||||
Resource types and the `resources` readers of `@Remote` live in `@deepseek-ai/dsh-typert-protocol`; resource kinds live in typert beside lookups and Contexts: `TypertResourceMap` declares the kinds, `typert.resources` holds each kind's default wire field, and lookups and Host Context adapters declare the resource kind their wire identity names. The Gateway resolves one call or forwarded event to the resources it touches. Decisions, list filtering, and the access snapshot live in the new package `@deepseek-ai/dsh-api-remote-access` (`packages/api/remote-access`), which reads no argument field. The Gateway and domain packages depend on remote-access only for types and inject `remoteAccess`; their only value dependency is `dsh-typert-protocol`, so `package-dependency-policy` gains no remote-access entry. Business packages define and register roles, bindings, and policies; liveshare is the first user. Apart from the built-in `global`, no resource-kind name appears in remote-access or the Gateway, which follows the [`packages/api` carries generic orchestration only](../../../../docs/defensive-patterns/remote-and-api.md) review rule.
|
||||
|
||||
#### Correspondence with RBAC
|
||||
|
||||
| This design | RBAC / IAM term | Meaning |
|
||||
|---|---|---|
|
||||
| principal | Subject / Principal | The caller identity, one entry of `PrincipalMap` |
|
||||
| Peer | Session (RBAC session) | One connection of a principal; one principal has N Peers |
|
||||
| owner | Superuser | Skips every check |
|
||||
| action | Action / Permission | A Remote endpoint `ns/method`, or a forwarded event `event:<name>`; the Gateway still calls it an endpoint internally |
|
||||
| resource | Resource / Object | `{ kind: 'global' }`, or `{ kind, id }` of a declared resource kind |
|
||||
| role | Role | An action set over one resource kind; a plain value of the business package |
|
||||
| binding | RoleBinding (role assignment) | The principal holds a role on a resource, optionally with a condition |
|
||||
| condition | Condition (IAM Conditions) | Attached to a binding; allows by the request's action, resource, and arguments, or refuses with a reason |
|
||||
| policy | Allow policy | The bindings and optional `explain` a business package gives for one non-owner principal kind |
|
||||
| explain | Denial explanation | Names a refusal reason when no binding allows; never changes the decision |
|
||||
| Gateway | PEP (Policy Enforcement Point) | Resolves resources and enforces the decision |
|
||||
| remote-access | PDP (Policy Decision Point) | Computes decisions, list filtering, and access snapshots |
|
||||
|
||||
| Rule | Content |
|
||||
|---|---|
|
||||
| Default deny | A non-owner is allowed only when the role of some binding explicitly lists the action; an action no role lists, a resource without a binding, and a principal kind without a policy are all refused. Every Remote method is under access control; there is no separate accessibility marker |
|
||||
| No inheritance between resources | `global` is not a parent of other resources: a grant on `global` implies no grant on any Session or Workspace, and a grant on a Workspace implies no grant on its Sessions |
|
||||
| A `global` grant is an explicit global grant | A call that resolves to no resource touches `global`. Putting an action in a `global` role lets that principal call it without resource restriction, which is a high-risk grant; once the method is allowed, it can still decide each item inside with `allows(resource)` |
|
||||
|
||||
#### Resources and resource readers (`@deepseek-ai/dsh-typert-protocol`, `@deepseek-ai/dsh-typert-registry`)
|
||||
|
||||
```text
|
||||
/** Merge-extensible resource kinds: the key is the kind, the value declares its identity type. */
|
||||
export interface TypertResourceMap {}
|
||||
// @deepseek-ai/dsh-session/types: session: TypertResource<SessionId>
|
||||
// @deepseek-ai/dsh-workspace/types: workspace: TypertResource<WorkspaceId>
|
||||
|
||||
// Runtime registries in typert
|
||||
ctx.typert.resources.register(kind, { field }): TypertDisposer
|
||||
ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names
|
||||
ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter
|
||||
|
||||
export interface TypertHostContextAdapter<Wire = unknown> {
|
||||
readonly wire: string
|
||||
readonly wireTypeSymbol: string
|
||||
/** Resource kind the wire identity names; access checks read the identity from args before resolve(). */
|
||||
readonly resource?: TypertResourceKind
|
||||
resolve(id: Wire): Context | undefined | Promise<Context | undefined>
|
||||
}
|
||||
|
||||
export type RemoteAccessResourceKind = 'global' | TypertResourceKind
|
||||
export type RemoteAccessResource =
|
||||
| { readonly kind: 'global' }
|
||||
| { readonly kind: K; readonly id: TypertResourceId<TypertResourceMap[K]> } // for each declared kind K
|
||||
export type RemoteAccessResourceOf<K extends RemoteAccessResourceKind> = Extract<RemoteAccessResource, { readonly kind: K }>
|
||||
|
||||
/** Per-kind readers, called with the method's positional wire arguments. */
|
||||
export type TypertResourceReaders<WireArgs extends readonly unknown[]> = {
|
||||
readonly [K in TypertResourceKind]?: (...args: WireArgs) => Id | readonly Id[] | undefined // Id = TypertResourceId<TypertResourceMap[K]>
|
||||
}
|
||||
|
||||
export interface RemoteMethodOptions<WireArgs extends unknown[]> {
|
||||
readonly name?: string
|
||||
readonly mode?: 'stream'
|
||||
readonly resources?: TypertResourceReaders<WireArgs>
|
||||
}
|
||||
|
||||
export function Remote<This extends object, Args extends unknown[], Result>(
|
||||
option: string | RemoteMethodOptions<NoInfer<RemoteWireArgs<Args>>>,
|
||||
): (method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>) => void
|
||||
|
||||
export interface RemoteScopeOptions<WireArgs extends unknown[]> {
|
||||
readonly name?: string
|
||||
readonly resources?: TypertResourceReaders<WireArgs>
|
||||
}
|
||||
|
||||
export function RemoteScope<This extends object, Args extends unknown[], Result>(
|
||||
key: Extract<keyof TypertContextMap, string>,
|
||||
option?: string | RemoteScopeOptions<NoInfer<RemoteWireArgs<Args>>>,
|
||||
): (method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>) => void
|
||||
```
|
||||
|
||||
| Rule | Content |
|
||||
|---|---|
|
||||
| Syntax | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution |
|
||||
| Resource kinds | The lower-level packages that declare the identity types merge the resource kinds: `session: TypertResource<SessionId>` in `@deepseek-ai/dsh-session/types` and `workspace: TypertResource<WorkspaceId>` in `@deepseek-ai/dsh-workspace/types`. session-controller references the commands package, so a merge in session-controller would make commands reference session-controller and form a project-reference cycle. remote-access builds in `global`, which has no id and stays out of `TypertResourceMap`; typert reserves the name, and `typert.resources.register('global', …)` throws |
|
||||
| Default fields and declarations | The domain that owns a kind registers its default wire field when its controller is constructed: the `SessionController` constructor calls `typert.resources.register('session', { field: 'sessionId' })` and the `WorkspaceController` constructor registers `workspace` → `workspaceId`; registering a kind twice throws. The Agent, Session, and `workspaceFileScope` lookups declare `resource: 'session'`, and the agent package's Agent Context declares `resource: 'session'` |
|
||||
| Resolution precedence | Each resource kind takes, in order: ① that kind's reader in the method's `resources`; ② every lookup parameter that declares that kind as its `resource` contributes its wire identity, and an omitted optional lookup parameter contributes none; when the Context of a Context method declares that kind as its `resource`, the Context's wire identity contributes too; ③ that kind's default field: a top-level named argument of that name when one exists, otherwise that field of the first object argument. A default field names one identity, so an array there is an invalid value |
|
||||
| Argument values | Resource resolution and the `args` of `decide()` read the named arguments validated against their Typert-compiled types: each JSON argument is decoded once by its codec, which drops object fields its type does not declare, and dispatch reuses that value, so a caller cannot change the resources with an undeclared field; lookup parameters and the Context identity keep their wire identities, and no lookup or Agent is resolved at that point |
|
||||
| Resolution result | The resources of every kind together are the resources the call touches; with none, the call touches `[global]`; when resources resolve, only those are used and `global` is not added |
|
||||
| Readers | Called with the method's positional arguments: the named arguments mapped by the descriptor back to the method's parameter order (the validated value in a JSON position, the wire identity in a lookup position, and the call's signal in the cancellation position); return one id, an id array (every one must be allowed), or `undefined` (no resource of that kind) |
|
||||
| Invalid values | A reader that throws or returns an empty array, or a read value (including a Context's wire identity) that is not a non-empty string, makes the resources invalid: the Gateway refuses the call with `remote-access/denied` (reason `resource-invalid`) without asking `decide()` |
|
||||
| Completeness | `resources` and automatic resolution must cover every resource the method touches. A call that resolves no resource falls to `global`, which only a `global` role can allow, so it is refused by default; when only some resources resolve, the rest go unchecked and a principal granted on the resolved resources can reach them, which the method author must prevent |
|
||||
| Type inference | The parameter types of `@Remote` and `@RemoteScope` readers are inferred from the decorated method: the factory parameter uses `NoInfer`, so `Args` is inferred only from the returned decorator; lookup parameters map to wire types through `TypertLookupMap` (for a method whose first parameter is `agent: Agent`, the reader's first parameter is `SessionId`). A missing property, more parameters than the method has, and a `global` key fail compilation |
|
||||
| Storage | `resources` readers live on the class prototype with the other `@Remote` / `@RemoteScope` metadata, are recorded on instance construction, and are safe across duplicate installations; they do not enter Typert artifacts |
|
||||
| Context methods | `@RemoteScope(key, option?)` matches `@Remote`: a string is the export name and an object is `{ name?, resources? }`. A Context's wire identity takes part in resolution like a lookup parameter that declares a `resource`, with the same precedence, and a `resources` reader for that kind overrides it; the Gateway reads that identity directly from args before `resolveReceiverContext`, so no Agent is activated. A Context that declares no `resource` contributes no resource from its identity, and the method resolves by the ordinary rules |
|
||||
|
||||
#### Endpoint resources
|
||||
|
||||
The table lists the endpoints #3854 viewers use and where their resources come from. Only `session/page` and `session/follow` need explicit `resources`; the rest rely on a lookup's `resource` or the default field:
|
||||
|
||||
| Endpoint | Resource | Resolution source | Filtering inside the method |
|
||||
|---|---|---|---|
|
||||
| `workspace/follow` | `global` | no resource | yes: Workspaces and Sessions |
|
||||
| `session/control` | `global` | no resource | yes: Sessions |
|
||||
| `session/list` | `global` (`_request` has no `sessionId`) | no resource | yes: Session rows |
|
||||
| `session/modelCatalog` | `global` | no resource | no |
|
||||
| `session/canOpenWorkspacePath` | `global` | no resource | no |
|
||||
| `llm/listConfigurableProviders` | `global` | no resource | no |
|
||||
| `llm/listProviders` | `global` | no resource | no |
|
||||
| `commands/list` | session: `args.agentId` (the Agent lookup's wire field) | Agent lookup `resource: 'session'` | no |
|
||||
| `goals/get` | session: `args.agentId` | Agent lookup `resource: 'session'` | no |
|
||||
| `session/attachment` | session: `args.request.sessionId` | default field | no |
|
||||
| `skills/list` | session: `args.request.sessionId` | default field | no |
|
||||
| `session/fork` | session: `args.request.sessionId` | default field | no |
|
||||
| `session/prompt` | session: `args.request.sessionId` | default field | no |
|
||||
| `session/cancel` | session: `args.request.sessionId` | default field | no |
|
||||
| `session/projections` | session: `args.request.sessionId` | default field | no |
|
||||
| `session/page` | session: `args.request.address.sessionId` for a Session address; `args.request.address.childSessionId` for a subagent address | `resources` | no |
|
||||
| `session/follow` | same as above | `resources` | no |
|
||||
| `remoteAccess/follow` | `global` | no resource; remote-access allows it for every principal | no |
|
||||
|
||||
A subagent address reads the child transcript, so its resource is the child Session. A Client rereads the projections of known Sessions after a reconnect, including the parent of a subagent Session; a reader without a grant on that parent has its projection read refused. Whether a grant on a parent Session covers its subagent children is the policy's decision, that is, whether it gives a binding for the child Session; the generic layer derives no parent resource.
|
||||
|
||||
Methods that need no non-owner access declare nothing, and a method no role lists is refused. The request fields of subagent `prompt` are `parentSessionId` / `childSessionId`, so automatic resolution finds no Session and resolves it to `global`; it must therefore stay out of every `global` role. `session/search` filters its hits per Session inside the method. `subagents/list` is not a Remote endpoint (`SubagentRuntime.list()` carries no `@Remote`).
|
||||
|
||||
#### Roles, bindings, and policies (`@deepseek-ai/dsh-api-remote-access`)
|
||||
|
||||
```text
|
||||
/** Remote endpoint `ns/method`, or `event:<name>` for one forwarded event; endpoint segments never contain `:`. */
|
||||
export type RemoteAccessAction = RemoteAccessEndpoint | `event:${string}`
|
||||
|
||||
/** A named action set over one resource kind; a plain value owned by the business package. */
|
||||
export interface RemoteAccessRole<K extends RemoteAccessResourceKind> {
|
||||
readonly name: string
|
||||
readonly resource: K
|
||||
readonly actions: readonly RemoteAccessAction[]
|
||||
}
|
||||
|
||||
/** Answer of decide() and of a condition. */
|
||||
export type RemoteAccessDecision =
|
||||
| { readonly kind: 'allow' }
|
||||
| { readonly kind: 'deny'; readonly reason: string }
|
||||
|
||||
/** Refines one binding for one action on its resource; `args` only for a concrete call. */
|
||||
export type RemoteAccessCondition<K extends RemoteAccessResourceKind> = (request: {
|
||||
readonly action: RemoteAccessAction
|
||||
readonly resource: RemoteAccessResourceOf<K>
|
||||
readonly args?: Readonly<Record<string, unknown>>
|
||||
}) => RemoteAccessDecision
|
||||
|
||||
/** One role on one resource of the role's kind; the resource type follows from the role. */
|
||||
export type RemoteAccessBinding = {
|
||||
[K in RemoteAccessResourceKind]: {
|
||||
readonly role: RemoteAccessRole<K>
|
||||
readonly resource: RemoteAccessResourceOf<K>
|
||||
readonly condition?: RemoteAccessCondition<K>
|
||||
}
|
||||
}[RemoteAccessResourceKind]
|
||||
|
||||
/** One access question; the Gateway passes only valid resources, [global] when none. */
|
||||
export interface RemoteAccessRequest<P extends Principal> {
|
||||
readonly principal: P
|
||||
readonly action: RemoteAccessAction
|
||||
/** Allow only when the action is allowed on every listed resource. */
|
||||
readonly resources: readonly RemoteAccessResource[]
|
||||
/** Named arguments of a concrete call, validated against their Typert-compiled types with lookup parameters as wire identities; absent for events, snapshots, and list filtering. */
|
||||
readonly args?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** Business rules for one non-owner principal kind; synchronous over in-memory state. */
|
||||
export interface RemoteAccessPolicy<P extends Principal> {
|
||||
bindings(principal: P): readonly RemoteAccessBinding[]
|
||||
/** Reason for a request no binding allows; never changes the decision. */
|
||||
explain?(request: RemoteAccessRequest<P>): string | undefined
|
||||
}
|
||||
|
||||
/** details of `remote-access/denied` */
|
||||
export interface RemoteAccessDeniedDetails {
|
||||
readonly action: string
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
class RemoteAccessService { // ctx.remoteAccess; Remote namespace remoteAccess
|
||||
registerPolicy<K extends Exclude<keyof PrincipalMap, 'owner'>>(kind: K, policy: RemoteAccessPolicy<PrincipalMap[K]>): () => void
|
||||
invalidate(principal?: Principal): void
|
||||
decide(request: RemoteAccessRequest<Principal>): RemoteAccessDecision // Gateway-only
|
||||
allows(resource: RemoteAccessResource): boolean // reads the accessing ctx's invocation
|
||||
snapshotFor(principal: Principal): RemoteAccessBaseline // a page's opening baseline
|
||||
follow(signal): AsyncIterable<RemoteAccessFrame> // remoteAccess/follow
|
||||
}
|
||||
```
|
||||
|
||||
| Rule | Content |
|
||||
|---|---|
|
||||
| Owner | The Gateway does not resolve resources or call `decide()` for the owner, and reads no policy |
|
||||
| Registration | `registerPolicy(kind, policy)` returns a disposer, and a business package calls it inside `ctx.effect()`. Each non-owner principal kind has at most one policy, and a second registration for the same kind throws; `bindings()` and `explain()` receive principals typed by the kind |
|
||||
| Built-in allow | `remoteAccess/follow` is allowed for every principal, so every Peer can read its own snapshot |
|
||||
| Decision | A principal kind without a policy is refused with `no-allow`. Otherwise `bindings(principal)` is read, and a linear scan finds for each requested resource one binding whose resource is equal (the same `kind` and, for a non-`global` resource, the same `id`), whose role's `actions` contains the action, and that has no condition or whose condition allows; the request is allowed only when every resource has one (AND). A condition runs only for a binding whose resource is equal and whose role contains the action |
|
||||
| Refusal reason | The first resource not allowed decides the reason, in this precedence: when matching bindings exist on that resource but all their conditions refuse, the first condition's reason; otherwise the return value of `explain(request)`; without `explain`, or when it returns `undefined`, `no-allow`. `explain` runs only after the decision is already a refusal and never changes it |
|
||||
| Failure isolation | When `bindings()`, a condition, or `explain()` throws, the decision is refused with `policy-failed`; in a snapshot computation, a throwing `bindings()` leaves that principal no grant and a throwing condition excludes that action. A faulty rule therefore never breaks the shared `$events` source, a snapshot stream, or page rendering; after each `invalidate()` or policy change, the first failure of each policy is logged once |
|
||||
| Synchronous | `bindings()`, conditions, and `explain()` return synchronously from state the business package preloads in memory, so list filtering can decide per item and snapshots can be computed |
|
||||
| Evaluation timing | Each `decide()`, each `allows()`, and each snapshot computation calls `bindings()` once, with no caching; time-dependent state (such as expiry) applies immediately, and a long stream (such as `workspace/follow` or `session/control`) reflects added or removed bindings at its next per-item decision; snapshots are recomputed only after `invalidate()`, so a business package also calls `invalidate()` when a time event such as expiry happens |
|
||||
| Notification | When a business state change (grant, revocation, mode switch) alters a binding or a condition's answer, the business package must call `invalidate(principal?)`; snapshots are recomputed only after `invalidate()` and policy registration or removal |
|
||||
| `remote-access/denied` | Only a call refusal becomes an error, with details `{ action, reason }`, where reason is the reason a condition or `explain` gave, `no-allow`, `policy-failed`, or the `resource-invalid` the Gateway gives for invalid resources; a refused event is simply not pushed |
|
||||
| Required service | The Gateway declares `static inject = ['typert', 'remoteAccess']`; a composition that mounts the Gateway mounts remote-access, which [`bundle/base`](../../../../packages/bundle/base/cordis.patch.yml) assembles right after `typert-gateway`; the plugin manager protects it like the Gateway, so it cannot be disabled from the Plugins page |
|
||||
|
||||
#### Example: share viewer
|
||||
|
||||
The `share-viewer` principal that liveshare declares shows that the derivation from business state to bindings stays in the business package; `shares` in the example stands for the share state the business package holds in memory:
|
||||
|
||||
```text
|
||||
const globalReader = { name: 'share:global-reader', resource: 'global', actions: ['session/list', 'session/control', 'workspace/follow', 'session/modelCatalog', 'llm/listProviders', 'commands/list'] }
|
||||
const sessionReader = { name: 'share:session-reader', resource: 'session', actions: ['session/page', 'session/follow', 'session/list', 'session/control', 'workspace/follow', 'commands/list', 'event:api-session/status', 'event:api-session/added'] }
|
||||
const workspaceReader = { name: 'share:workspace-reader', resource: 'workspace', actions: ['workspace/follow'] }
|
||||
const forker = { name: 'share:forker', resource: 'session', actions: ['session/fork'] }
|
||||
const writer = { name: 'share:writer', resource: 'session', actions: ['session/prompt', 'session/cancel'] }
|
||||
|
||||
ctx.effect(() => ctx.remoteAccess.registerPolicy('share-viewer', {
|
||||
bindings(viewer) {
|
||||
const share = shares.serving(viewer) // undefined once stopped, expired, or no longer admitted
|
||||
if (share === undefined) return []
|
||||
const source = { kind: 'session', id: share.sessionId }
|
||||
return [
|
||||
{ role: globalReader, resource: { kind: 'global' } },
|
||||
{ role: sessionReader, resource: source },
|
||||
...(share.allowsFork ? [{ role: forker, resource: source }] : []),
|
||||
...(share.mode === 'write' ? [{ role: writer, resource: source, condition: sourceWrite(share) }] : []),
|
||||
...shares.forksOf(viewer).flatMap(child => [
|
||||
{ role: sessionReader, resource: { kind: 'session', id: child.id } },
|
||||
...(child.revoked ? [] : [{ role: writer, resource: { kind: 'session', id: child.id }, condition: childPrompt(child) }]),
|
||||
]),
|
||||
...shares.workspacesShowing(viewer).map(id => ({ role: workspaceReader, resource: { kind: 'workspace', id } })),
|
||||
]
|
||||
},
|
||||
explain({ principal, action }) {
|
||||
return shares.refusal(principal) // 'share-expired', 'share-stopped', …
|
||||
?? (action === 'session/prompt' ? 'share-not-writable' : undefined)
|
||||
},
|
||||
}))
|
||||
|
||||
// Without args (snapshots, list filtering) a condition answers for the action alone.
|
||||
const sourceWrite = share => () => share.confined ? allow : deny('source-write-unavailable')
|
||||
const childPrompt = child => ({ action, args }) =>
|
||||
action === 'session/prompt' && args !== undefined && !child.allowsPrompt() ? deny('fork-prompt-refused') : allow
|
||||
```
|
||||
|
||||
| Owned by | Content |
|
||||
|---|---|
|
||||
| Business package | The action lists of roles; the derivation from share state to bindings (stop, expiry, admission, mode, the viewer's own forks, revocation, Workspaces containing visible Sessions); per-request conditions; refusal reasons for the viewer page |
|
||||
| Generic layer | Resource resolution, AND across resources, role and action matching, condition calls, reason precedence, list filtering, snapshot computation, and failure isolation |
|
||||
|
||||
That viewer's snapshot contains grants on `global`, the source Session, each of its own forks, and each visible Workspace; `session/list` appears in both a `global` role and a session role, see [List and aggregate methods](#list-and-aggregate-methods).
|
||||
|
||||
#### Host access-check order
|
||||
|
||||
Every call path (HTTP `/api`, WebSocket streams, in-process `fetch(request, peer)` / `invoke` / `stream` / `wireStream.open`) passes through one Gateway sequence:
|
||||
|
||||
| Step | Action | Failure |
|
||||
|---|---|---|
|
||||
| 1 | Admission yields the Peer: network paths from Connection `admit()` (`browserPeer`), in-process paths from the caller. The Gateway checks `peer.signal` before it reads the wire payload | Failed network admission answers HTTP 401 / 403 without entering the Gateway; a disposed Peer: `gateway/peer-disposed` |
|
||||
| 2 | Resolve the endpoint and validate the argument field set (`resolveDescriptor`, `assertExactArguments`), then validate the JSON arguments against their Typert-compiled types, decoding each once | `gateway/invocation-unavailable`, `gateway/ambiguous-endpoint`, `gateway/definition-unavailable`; extra or missing fields: `gateway/arguments-invalid`; a JSON argument failing validation: `gateway/input-invalid` (for owners and non-owners alike, before the access check) |
|
||||
| 3 | Owner pass-through: for `principal.kind === 'owner'` go straight to step 5 | — |
|
||||
| 4 | Non-owner: the Gateway resolves resources from the arguments validated in step 2 (`resources` readers, then lookup parameters and the `resource` a Context declares, then default fields; lookup and Context identities are wire identities, and neither a lookup nor the Context is resolved), then asks `remoteAccess.decide({ principal, action: endpoint, resources, args })` with the validated arguments as `args`; invalid resources are refused with `resource-invalid` without asking `decide()`; a `deny` decision becomes the thrown error | `remote-access/denied` with details `{ action, reason }` |
|
||||
| 5 | Dispatch: `resolveReceiverContext` (which can activate an Agent), lookup resolution (JSON arguments reuse the step 2 values), and the method call | Existing codes unchanged, such as `gateway/lookup-not-found`, `gateway/context-not-found`, `gateway/input-invalid`, `gateway/service-unavailable`, `gateway/method-unavailable`, and business errors |
|
||||
|
||||
The access check (steps 3–4) precedes lookup and Context resolution, so a refused call activates no Session and causes no other side effect. The only new Gateway error code is `gateway/peer-disposed`; remote-access declares the refusal code `remote-access/denied`.
|
||||
|
||||
Gateway-owned endpoints: a non-owner may subscribe to `$events` but never receives waterfall frames it could answer; `$events/result` from a non-owner is refused with the existing `gateway/invocation-unavailable`; both run the step-1 check.
|
||||
|
||||
#### Forwarded events
|
||||
|
||||
An `emit` entry of `API_REMOTE_FORWARDED_EVENTS` may declare `resources`: readers keyed by resource kind and typed by that event's own parameters through `TypertForwardableEventEntry`, for example `{ event: 'api-session/added', mode: 'emit', resources: { session: (summary) => summary.sessionId } }`. API Remotes attaches the readers to each forwarded frame; for every non-owner `$events` stream the Gateway resolves the resources once per frame and pushes the frame only when `remoteAccess.decide({ principal, action: 'event:<name>', resources })` allows it.
|
||||
|
||||
| Rule | Content |
|
||||
|---|---|
|
||||
| Declared events | `api-session/activity`, `api-session/added`, `api-session/error`, `api-session/removed`, and `api-session/status` declare their Session |
|
||||
| Undeclared resources | An event without `resources`, or with an empty `resources: {}`, touches `global` and reaches a non-owner only when a `global` role lists `event:<name>` |
|
||||
| Missing resource | An event carries every resource it declares, so a reader returning `undefined` makes the event's resources invalid, and the Gateway delivers it to no non-owner without asking `decide()`; a call missing a resource touches `global` instead |
|
||||
| Whole or nothing | An allowed event arrives with its payload unchanged; no payload is trimmed or projected per Peer |
|
||||
| One resource per event | A notification carries at most one resource; collection changes travel on follow streams (`session/follow`, `session/control`, `workspace/follow`), whose methods decide each item inline through `allows()` |
|
||||
| Waterfalls | Non-owners never receive waterfall frames, and no policy decides them |
|
||||
| Non-owner stream facts | A non-owner `$events` stream opens with the same `host` as an owner stream, including `home`; `hasLiveClient()` counts only owner streams, the ones that can answer waterfalls |
|
||||
|
||||
<a id="list-and-aggregate-methods"></a>
|
||||
#### List and aggregate methods
|
||||
|
||||
Calls of list, paging, and aggregate methods resolve to `global` and are allowed by a `global` role; inside, the method calls `this.ctx.remoteAccess.allows(resource)` for every item to decide whether the current call's action is granted on that resource (without args). A list action therefore appears both in a `global` role (allowing the call) and in resource roles (allowing each item): `session/list` must be granted on `global` to be called, and each Session row also requires `session/list` granted on that Session. `allows()` reads the invocation on the Context that accessed the service; it is always true for the owner and for in-process code outside a Remote call, which are not filtered.
|
||||
|
||||
| Method | Filtered items |
|
||||
|---|---|
|
||||
| `session/list` | Session rows (`session` resource) |
|
||||
| `session/search` | search hits (`session` resource) |
|
||||
| `session/control` | baseline projections and projection increments (`session` resource) |
|
||||
| `workspace/follow` | Workspaces in the baseline, `upsert` frames, and `order` ids (`workspace` resource), the Sessions inside Workspaces, and the archived / pinned Session sets (`session` resource). The stream tracks the Workspaces it delivered; when one is no longer allowed at its next change or in a new order, it sends a `replace` increment with the complete filtered state instead of a `remove`, because the Client never resurrects a removed id, and the Workspace reappears through an ordinary `upsert` once access returns. A `remove` frame reports a deletion and reaches only a caller that received that Workspace |
|
||||
|
||||
#### Access snapshot stream
|
||||
|
||||
```text
|
||||
// Remote: remoteAccess/follow (stream; every Peer may read its own snapshot)
|
||||
type RemoteAccessFrame =
|
||||
| { readonly type: 'baseline'; readonly owner: true }
|
||||
| { readonly type: 'baseline'; readonly owner: false; readonly grants: readonly RemoteAccessGrant[] }
|
||||
| { readonly type: 'upsert'; readonly grant: RemoteAccessGrant }
|
||||
| { readonly type: 'remove'; readonly resource: RemoteAccessResource }
|
||||
|
||||
interface RemoteAccessGrant {
|
||||
readonly resource: RemoteAccessResource // global, or one declared-kind resource
|
||||
readonly actions: readonly string[] // endpoint actions allowed on this resource without args, sorted
|
||||
}
|
||||
|
||||
/** Window global carrying a page's opening baseline. */
|
||||
export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__'
|
||||
```
|
||||
|
||||
| Behavior | Detail |
|
||||
|---|---|
|
||||
| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to the owner baseline `{ type: 'baseline', owner: true }`, to every index the webServer renders, through `webserver/index-inject`. A business package that renders a non-owner page (such as liveshare on the owner Host) places an assignment of that global to `snapshotFor(principal)` after every index injection row; the later assignment wins, and the Client reads the global when it mounts |
|
||||
| Subscription | Each Peer subscribes to one access snapshot stream, whose baseline replaces the injected one |
|
||||
| Computation | All bindings of the policy for the principal's kind are merged by equal resource; each resource's `actions` are the endpoint actions of those bindings' roles that the same decision as a call allows (one resource, without args, conditions called without args); only a resource with at least one allowed endpoint action gets a grant. Decisions and snapshots share one source: for the same principal, action, and resource (without arguments), the snapshot answer equals `decide()` |
|
||||
| Endpoint actions only | `event:<name>` actions do not enter the snapshot; the frontend decides entry visibility from endpoint actions only |
|
||||
| Increments | `invalidate()` or a policy registration or removal recomputes and pushes `upsert` / `remove`, and a resource whose allowed set becomes empty pushes `remove`; unchanged resources push nothing |
|
||||
| Lifetime | A generation ends when its signal aborts or when the remote-access service unloads, so a reloaded service answers every reader from a fresh baseline; the Client treats the end as a carrier loss and reconnects |
|
||||
| Owner | Receives only `{ owner: true }`, so every frontend answer is true |
|
||||
| Wire type | `actions` is `readonly string[]`: Typert cannot generate a codec for template-literal types |
|
||||
|
||||
Access hooks judge only by action and resource and never look at call arguments; at call time a condition may also read args for a finer decision, and the Host result prevails.
|
||||
|
||||
#### Client face
|
||||
|
||||
The Client uses framework standard-hook injection and no React context. Components receive hooks only through props and import no remote-access value.
|
||||
|
||||
```text
|
||||
// @deepseek-ai/dsh-api-remote-access/client
|
||||
interface ClientRemoteAccess { // Client service ctx.remoteAccess
|
||||
source(kind: TypertResourceKind, id: string, action: string): HostObservable<boolean>
|
||||
}
|
||||
GlobalStandardProps.useRemoteAccess: (action: RemoteEndpoint) => boolean // remote-access; the global resource
|
||||
|
||||
// @deepseek-ai/dsh-client-ui-session (injects remoteAccess)
|
||||
GlobalStandardProps.useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean
|
||||
SessionStandardProps.useAccess: (action: RemoteEndpoint) => boolean
|
||||
SessionMaybeStandardProps.useAccess: (action: RemoteEndpoint) => boolean | undefined
|
||||
|
||||
// @deepseek-ai/dsh-client-ui-workspace (injects remoteAccess)
|
||||
GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean
|
||||
|
||||
// RemoteEndpoint = Extract<keyof TypertRemoteMap, string>
|
||||
```
|
||||
|
||||
| Concern | Decision |
|
||||
|---|---|
|
||||
| Snapshot and mount | The remote-access Client plugin starts from the baseline the index injected (read synchronously at startup and validated), follows the snapshot stream, applies each baseline in one pass, provides the Client service `ctx.remoteAccess.source(kind, id, action)` (an identity-stable boolean source), and injects `useRemoteAccess` on the `global` resource. The entry module imports the generated `/remote` contribution and hands it with the injected baseline to `mountAccess(ctx, contribution, initial)`, which mounts it through `ctx.remote.$mount` (unit specs pass a fake contribution), following experimental client-ui-cot-translation, voice-input, and claude-code-mods; a central mount in api/remotes would form the project-reference cycle ui-session → api/remotes → remote-access → ui-session |
|
||||
| Session resource | ui-session gives the bound Session's answer through `uiSession.provide({ keyedHooks: ['access'], resolve })`; the framework binds the Session as it does for `useProjection`. `useSessionAccess` is injected through `provideRoot` |
|
||||
| Workspace resource | ui-workspace injects `useWorkspaceAccess` through `provideRoot` |
|
||||
| Distinct names | The `global` answer is `useRemoteAccess` and the Session answer is `useAccess`, so neither shadows the other; in a session-maybe scope without a Session, `useAccess` is `undefined`, which callers treat as not allowed, and a `global` decision reads `useRemoteAccess` |
|
||||
| Two-argument calls | Framework keyed hooks take a standard second argument: `KeyedStandardSource` is `(key, member?) => source`; a string second argument makes the hook read the resolver's `(key, member)` source without a selector, and the resolver returns the same source for the same arguments. Existing `(key, selector?, equal?)` and `(selector, equal?)` calls are unchanged. Curried forms are forbidden |
|
||||
| Return value | Every answer on a resource is a boolean; action types come from the `ctx.remote` namespaces and methods |
|
||||
| No fallback | Asking a Session resource about a non-session method (such as `useAccess('session/create')`) reads the decision on that Session resource and never falls back to the `global` answer |
|
||||
| No snapshot | True for the owner, also after its stream fails terminally; a non-owner page without an injected baseline, or whose stream fails terminally, answers false, and the terminal failure is reported with its error and the advice to reload; a reconnect keeps the last snapshot; a malformed injected baseline fails the mount; the Host still checks every call. Hooks never issue requests |
|
||||
| Example consumers | The ui-workspace Session row menu's fork entry reads `useSessionAccess(sessionId, 'session/fork')` and is not rendered when it is false; the ui-chat turn tail's branch button reads `useAccess('session/fork')` on the Session resource and is not rendered when it is false. Replacing #3854's `clientWritePolicy` stays with #3854 |
|
||||
| Tests | `@deepseek-ai/dsh-client-test-runtime` exports `allowAllAccess` props and `allowAllAccessPlugin`; TestClient drops the remote-access row as it drops api-remotes and installs the allow-all service, and SlotTestRuntime installs it before ui-session |
|
||||
|
||||
### Security invariants
|
||||
|
||||
| Invariant | Basis and enforcement point |
|
||||
|---|---|
|
||||
| Only in-process Host code supplies a principal, when it creates a Peer | `PeerScope.principal` is read-only; `createPeer()` has no route |
|
||||
| No wire field can set or override a principal | Principals never come from args, the envelope, headers, the query, or cookies. Extra top-level args fields fail `assertExactArguments`; the payload must be exactly `{ args }`; [`clientRequestSchema`](../../../../packages/client/connection/src/rpc-schema.ts) drops unknown envelope fields; a same-named field inside a request body is business data |
|
||||
| Browser network admission yields the owner | Connections that pass [browser token authentication](../../implemented/architecture/2026-08-24-browser-token-authentication.md) receive `browserPeer` |
|
||||
| No implicit Peer | Neither the Gateway nor Connection supplies a default Peer; required parameters expose a missing Peer at compile time |
|
||||
| A disposed Peer cannot dispatch | `gateway/peer-disposed` |
|
||||
| A non-owner cannot call without authorization | A call is allowed only when a binding's role explicitly lists the action and its resource matches; a kind without a policy is refused; the Gateway depends on `remoteAccess` and does not start without it |
|
||||
| A `global` grant implies no resource grant | Resources do not inherit; `allows()` inside a method decides each item independently |
|
||||
| An unknown principal kind is not the owner | Only `kind === 'owner'` is the owner |
|
||||
| Non-owners answer no waterfall | `$events` gives non-owners no waterfall frame, and `$events/result` refuses them |
|
||||
| A non-owner receives only authorized notifications | Each forwarded frame is decided by `event:<name>` and its resources, and reaches a non-owner only when allowed |
|
||||
|
||||
### Recording the message source
|
||||
|
||||
The `MessageSourceMap` entry in the [session-controller types](../../../../packages/api/session-controller/src/types.ts) gains an optional field:
|
||||
|
||||
```text
|
||||
'user-rpc': {
|
||||
kind: 'user'
|
||||
rpcId: SessionRequestId
|
||||
clientTimeZone?: string
|
||||
/** Non-owner caller that submitted this prompt; absent means the owner. */
|
||||
principal?: Principal
|
||||
}
|
||||
```
|
||||
|
||||
Two producers build a `kind: 'user'` + `rpcId` user-message source inside a Remote call. Both call `principalOf(this.ctx.invocation)` in the Remote method and write `principal` only when it is defined:
|
||||
|
||||
| Producer | Location |
|
||||
|---|---|
|
||||
| `SessionController.prompt()` builds the source and `SessionCommandController.prompt(request, source)` only delivers it | [`session-controller/src/index.ts`](../../../../packages/api/session-controller/src/index.ts), [`commands.ts`](../../../../packages/api/session-controller/src/commands.ts) |
|
||||
| subagent `prompt()` (`BrowserPromptSource`); no role lists it, so a non-owner reaches it only through in-process Host code, not through the Gateway | [`subagent/src/index.ts`](../../../../packages/subagent/subagent/src/index.ts) |
|
||||
|
||||
Business code does not pass a principal down as a function parameter: the identity is read once from the invocation in the Remote method, and the built source travels down. The Agent inbox has no interception point that could add the field on enqueue (`followup` / `steer` accept built messages, and in-process producers such as the SDK and scheduling run outside Remote calls), so each producer records it, and the code-review rule covers new producers.
|
||||
|
||||
| Fact | Detail |
|
||||
|---|---|
|
||||
| `kind` is unchanged | Still `'user'`; existing `kind === 'user'` checks are unaffected |
|
||||
| Owner messages are unchanged | An owner call writes no field, so its source is byte-identical to the source before principals existed; a message without the field (including every older Session) is the owner's |
|
||||
| Not part of model requests | LLM adapters read only the model source ([`llm-deepseek/src/replay.ts`](../../../../packages/llm/llm-deepseek/src/replay.ts)) |
|
||||
| Session format | `SESSION_FORMAT_VERSION` is not bumped; per the [persistence type-change cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.md) the change records an acknowledgement |
|
||||
|
||||
Example consumer use, outside this Note's scope: tool guards and pre-step rechecks read `source.principal` of the user messages in a turn and judge by the non-owner caller when any message carries one; a turn mixing owner and viewer messages takes the strictest.
|
||||
|
||||
### Code-review rule and documentation
|
||||
|
||||
The review rule's home is the "`@Remote` marks capability methods" section of [Remote and API rules](../../../../docs/defensive-patterns/remote-and-api.md): `@Remote({ resources })` and automatic resolution must cover every resource a method touches; aggregate results filter every item through `allows()`; the caller identity is read only from `invocation.peer.principal`, never from arguments; every call path names its Peer explicitly; business code does not pass a principal as a function parameter.
|
||||
|
||||
| Document | Content |
|
||||
|---|---|
|
||||
| [`docs/subsystems/typert.md`](../../../../docs/subsystems/typert.md), [`docs/api-gateway.md`](../../../../docs/api-gateway.md) | `PeerScope`, `RemoteInvocation`, `InvokeRemoteRequest`, explicit Peers, `resources` on `@Remote`, and the access-check order |
|
||||
| READMEs of connection, gateway, typert-protocol, typert-registry, api-remotes, remote-access, session-controller, workspace-controller, ui-session, ui-workspace, ui-slots, and client-test-runtime | Peer / `browserPeer` in place of the operator; the remote-access role, binding, and policy contract; access hooks |
|
||||
|
||||
### Out of scope and future work
|
||||
|
||||
- The `/connect` handshake, API cookies, one Peer per API connection, and WebSocket lifetimes belong to pr-5945.
|
||||
- Multiple browser Peers (such as one per tab): every browser connection shares `browserPeer`.
|
||||
- Interaction participation (interactions) and approval isolation by principal come with later work; non-owners currently receive only authorized `$events` broadcast frames.
|
||||
- `agent-preset/selected` and the other forwarded events declare no resources and are decided on `global`; when its viewers need a Session event, #3854 declares that event's resources and lists `event:<name>` in a role.
|
||||
- Liveshare integration (declaring `share-viewer`, one Peer per viewer connection, defining roles and registering a policy, removing `origin` / `prompt/origin` / `READ_ENDPOINTS`) happens on the #3854 side.
|
||||
- `session/cancel` ownership checks, the fork `childSessionId`, and `ToolExecutionInput.turn` do not change.
|
||||
- Precise resource recognition (for example, the Typert generator locating request fields by branded id type and writing them into descriptors): this change uses the `resource` that lookups and Contexts declare, default fields, and `resources` readers, and resource resolution can later be replaced without changing the policy interface.
|
||||
- Answering 403 to every non-owner on exact Fetch routes is temporary; each route is to declare its own access rule later.
|
||||
- The resource declaration of subagent `prompt` is open: its request fields are `parentSessionId` / `childSessionId`, so it currently resolves to `global`. The producer records `source.principal` for in-process callers; no role lists it, so non-owners cannot reach it through the Gateway.
|
||||
- Predefined and aggregate roles: when a second non-owner principal kind appears, domain packages declare the generic roles a method belongs to (such as `session:viewer`) with `@Remote({ roles })`, and each policy binds those roles; roles are currently defined by the business package as explicit action lists.
|
||||
|
||||
### Relation to existing Notes
|
||||
|
||||
- [Remote duplex stream](../../implemented/architecture/2026-09-19-remote-duplex-stream.md): partially superseded. The replaced parts are "the Peer records no identity or permission", "this Host has exactly one Peer, the operator", "an in-process carrier without a Peer means the operator", and its alternative stating that "the Access mechanism is business semantics"; the rest (the duplex stream, uplink, and `ctx.invocation` binding) stands. Both Notes stay active and cross-linked, and that Note's current facts are updated.
|
||||
- [Browser token authentication](../../implemented/architecture/2026-08-24-browser-token-authentication.md): unaffected. Its rejection of per-method privilege concerns owner authentication; an authenticated browser is still the owner and is not checked per method.
|
||||
- [Typert Remote method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md): unaffected; `@Remote` and `@RemoteScope` gain optional `resources` readers (the second argument of `@RemoteScope` may be `{ name?, resources? }`), read only by the Gateway's access check, and change neither endpoint naming, call modes, nor generated artifacts.
|
||||
- [Producer-owned message sources](../../implemented/architecture/2026-09-09-producer-owned-message-sources.md): unaffected; the producer that builds a source writes `principal`.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A separate `prompt/origin` event correlated by requestId (the #3854 approach).** Every prompt writes a required event, so even ordinary Sessions become unreadable by older versions; correlation serializes every prompt and maintains an origin fold, and messages outside the fold window lose their initiator.
|
||||
|
||||
**Principal and peer as parallel invocation fields.** One caller's identity would have two sources; every admission point would have to produce and pass both, and a path that passes only one would mismatch them.
|
||||
|
||||
**Keep "the Peer records no identity".** When business plugins attach identity through `peer.ctx`, every reader must know who attached it and how to read it, and the wire admission path cannot guarantee the attachment happened.
|
||||
|
||||
**Keep a default-Peer fallback (`request.peer ?? …`).** An implicit default silently grants owner identity to a call that forgot its Peer; a required parameter turns the omission into a compile error.
|
||||
|
||||
**A new source kind.** Every existing `kind === 'user'` check would need review, and one miss makes viewer messages disappear from titles, outlines, queues, or chat rendering.
|
||||
|
||||
**Identity in a request-body field.** Any caller able to build a request body could forge or override it.
|
||||
|
||||
**React context for frontend resources.** Business components read no React context; the framework binds standard hooks per resource, and components receive them only through props.
|
||||
|
||||
**Driver-maintained endpoint allowlists that trim results (the #3854 approach).** The allowlist and the result trimming both live in the driver, the Gateway does not enforce them, and the snapshot and the frontend each derive their own; the generic layer enforces role action lists, and `allows()` inside the method performs list filtering.
|
||||
|
||||
**Business packages implement `decide()` / `abstain` providers whose answers are merged.** Each business package hand-writes action × resource branches plus a separate resource list for snapshots, and the two stay consistent only by hand; a policy gives only bindings, decisions and snapshots are computed from the same bindings, and each principal kind has one policy, so no merge rule is needed.
|
||||
|
||||
**A separate `@RemoteAccess` marker.** Role action lists already guarantee default deny, so a marker is only a second consent; a method that resolves no resource falls to `global`, and only an explicit global grant allows it. Resource readers describe the method, so they live on `@Remote`.
|
||||
|
||||
**Calling resource-less calls `page` or `host`.** `page` describes a frontend location, not a resource; `host` reads easily as the parent of every resource on this Host. `global` means a grant tied to no resource.
|
||||
|
||||
**Resource inheritance (GCP style: a grant on `global` or on a Workspace covers lower resources).** List methods allowed by a `global` grant must decide each item, and inheritance would make any `global` grant imply every Session; without inheritance, a binding grants each resource explicitly.
|
||||
|
||||
**A role registry and predefined aggregate roles.** The only non-owner principal today is managed by one business package, so roles can be plain values of that package; aggregate roles would let methods a new package declares enter a share viewer's reach automatically, so they wait for a second non-owner principal kind.
|
||||
|
||||
**Conditions attached to a role's actions.** A role stays reusable plain data; a refinement by business state belongs to the binding that grants the role to a principal.
|
||||
|
||||
**Refusal reasons from conditions only.** Without a binding there is no condition to give a reason, so a viewer page could not tell "share expired" from "share not writable"; `explain` gives a reason only after the decision is already a refusal and never changes it.
|
||||
|
||||
**A global resource-resolver registry (domain packages register recognizers by field name).** Field recognition separates from the method, nothing flags a method signature change, and same-named fields in other packages are misread; resource resolution uses a default convention on the method plus per-method `resources`, and a domain package registers only its own kind's default field.
|
||||
|
||||
**Dot-path resource declarations (`'request.address.sessionId'`).** Types cannot constrain a path; the parameter types of `resources` readers are inferred from the decorated method, so a typo fails compilation.
|
||||
|
||||
**Generator recognition of resource fields by branded id type.** Requires changing Typert artifacts and the descriptor format, and SRC descriptors carry no types; kept as future work.
|
||||
|
||||
**A central mount of the remote-access generated contribution in api/remotes.** The remote-access Client depends on ui-session and ui-session depends on api/remotes, so a central mount forms a project-reference cycle; remote-access mounts its own contribution instead.
|
||||
|
||||
**Decisions inside the Gateway (a `check()` with rules written in the Gateway).** The Gateway would carry access-control semantics and extra error codes; it resolves resources as it resolves lookups and calls `remoteAccess.decide()` before dispatch, and the rules and refusal code belong to remote-access.
|
||||
|
||||
**Asynchronous policy decisions.** List filtering and snapshot computation need synchronous answers; business packages preload asynchronous state into memory.
|
||||
|
||||
**On-demand permission queries from the frontend.** Request volume grows with the number of entries and cannot follow rule changes; the Host pushes a snapshot stream and the frontend reads it synchronously.
|
||||
|
||||
**An access check after lookup resolution.** A lookup can activate an Agent, so an unauthorized call would cause side effects first.
|
||||
|
||||
**Resource fields registered in remote-access (`registerScope`).** The access package would recognize argument fields although lookups already know which wire field names a Session; typert holds resource kinds beside lookups and Contexts, so the Gateway resolves calls and events in one place and remote-access reads no argument field.
|
||||
|
||||
**Trimming forwarded event payloads per Peer.** A per-event projection would repeat list filtering inside every notification; an event carries at most one resource and is delivered whole or not at all, while collection changes travel on follow streams that filter inline.
|
||||
|
||||
**Answering true until the first snapshot arrives.** A non-owner page would briefly show entries it cannot use; the index carries the first snapshot, so a page without one has no access.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Verification
|
||||
|
||||
| Case | Behavior | Test location |
|
||||
|---|---|---|
|
||||
| The wire tries to set a principal (top-level args, a payload sibling, the envelope, inside the request body) | The first two fail argument or payload validation; the rest are dropped or treated as business data; `invocation.peer` remains the admitted Peer | Gateway host spec |
|
||||
| Every call path names its Peer | `InvokeRemoteRequest.peer`, the `wireStream.open` peer, `fetch(request, peer)`, and the `bridge` peer are required; the Gateway has no fallback; the `/api` route passes `admission.peer` | typecheck, Connection host spec |
|
||||
| Browser connections and `createPeer()` | `browserPeer` has the owner principal; each `createPeer()` returns an independent Peer released with the Context that read Connection; exact routes answer 403 to non-owners | Connection host spec |
|
||||
| Disposed Peers | Later unary, stream, and `$events` dispatches fail with `gateway/peer-disposed`, also when the wire payload is malformed; its streams end; a running unary call completes; other Peers are unaffected | Gateway host spec |
|
||||
| Check order | The owner calls any method normally without reading a policy; a method no role lists refuses non-owners with `remote-access/denied` (`no-allow`); the access check precedes lookups, so a refused call activates no Session | Gateway host spec |
|
||||
| Policy registration | One policy per non-owner principal kind, and a second registration throws; once the disposer withdraws it, that kind is refused with `no-allow`; it is withdrawn with its fiber | remote-access host spec |
|
||||
| Decisions | Every resource needs a matching binding (AND); a role without the action, an unequal resource, or a refusing condition does not allow; the refusal reason follows condition > `explain` > `no-allow`; `explain` never changes the decision; conditions receive args for calls but not for snapshots and list filtering; a throwing `bindings()`, condition, or `explain()` refuses with `policy-failed`, each policy is logged once between `invalidate()` calls, and on the event path the owner stream and the shared event source continue; `remoteAccess/follow` is allowed for every principal | remote-access host spec, Gateway host spec |
|
||||
| Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) |
|
||||
| Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec |
|
||||
| Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:<name>` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) |
|
||||
| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the owner baseline into every rendered index; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs |
|
||||
| Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) |
|
||||
| List filtering | `session/list`, `session/search`, `session/control`, and `workspace/follow` return non-owners only items for which `allows()` is true; `workspace/follow` sends a `replace` increment when a delivered Workspace loses visibility, and the Workspace reappears once access returns; owner results are unchanged | session-controller / workspace-controller host spec |
|
||||
| Access snapshots | The allowed set in a snapshot equals the Host decision for the same principal, action, and resource without arguments; it contains endpoint actions only; a resource with no allowed action gets no grant, and one whose allowed set becomes empty is removed; `invalidate()` and policy registration or removal push increments; a wait that a change ends leaves no abort listener behind; unloading the service ends every open snapshot stream; the owner receives only `{ owner: true }` | remote-access host spec |
|
||||
| Access hooks | `source()`, `useRemoteAccess`, `useAccess`, `useSessionAccess`, and `useWorkspaceAccess` follow the snapshot; `useAccess` on a Session resource reads that Session's answer; the owner answers true; a non-owner without a snapshot or after its snapshot stream fails answers false; hooks issue no requests | remote-access client spec, ui-session / ui-workspace client spec |
|
||||
| Two-argument keyed hooks | A string second argument reads the `(key, member)` source; existing call forms are unchanged | ui-renderer client spec |
|
||||
| A non-owner dispatches `session/prompt` | The recorded user message's `source.principal` equals that principal, and a cold restore reads the same value | session-controller host spec |
|
||||
| An in-process non-owner invocation of subagent `prompt` | The child Session's recorded user message has `source.principal` equal to that principal | subagent host spec |
|
||||
| Owner messages | The source is byte-identical to the source before this change; existing keyless recorded-session snapshots are unchanged | `pnpm run test:snapshot` needs no re-recording |
|
||||
| Persistence record | `verify-persistence-changes` passes | doc-sync |
|
||||
|
||||
The SDK projections are unaffected: the TypeScript SDK server builds its own `{ kind: 'user' }` messages ([`sdk/server/src/server.ts`](../../../../packages/sdk/server/src/server.ts)) outside the Gateway and does not project the source; the Python SDK does not read the user source.
|
||||
|
||||
### Costs and limits
|
||||
|
||||
- The change spans protocol, Connection, Gateway, two domain controllers, subagent, a new package, and the frontend, with breaking API renames; it necessarily conflicts with pr-5945 in Connection and the Gateway, handled by the migration list.
|
||||
- The Gateway depends on `remoteAccess`: a custom composition that mounts the Gateway without remote-access leaves the Gateway waiting.
|
||||
- Session logs store viewer identities and travel with exported or shared Sessions, so no principal kind may carry a credential.
|
||||
- Every browser connection shares one Peer, so disposal cannot distinguish tabs until multiple browser Peers exist.
|
||||
- The default field convention reads by field name: a method that gains a same-named field touches that resource, which review must watch; precise recognition is future work.
|
||||
- A method that resolves only some resources lets a principal exceed its grant: unresolved resources go unchecked, and the method author ensures `resources` is complete.
|
||||
- Role actions are strings and are not checked against loaded endpoints: a misspelled or renamed action never matches and shows up as a refusal.
|
||||
- `api-session/added` carries the whole `SessionSummary`, so a non-owner allowed to receive it also learns the Session's `parentSessionId`.
|
||||
- A granted Session's cwd and tool paths already contain the owner's absolute paths, so the Gateway does not hide `home` from non-owner `$events` streams separately.
|
||||
- `workspace/follow` notices a Workspace whose visibility a policy revoked only at that Workspace's next change or the next order change, and then sends the non-owner a `replace` increment; an access change alone sends no frame.
|
||||
|
||||
### Migration list for pr-5945
|
||||
|
||||
| File | Migration |
|
||||
|---|---|
|
||||
| `packages/client/connection/src/peer.ts` | Replaces `operator-peer.ts`; construct `OperatorPeer` as `new Peer(owner, { principal })` |
|
||||
| `packages/client/connection/src/rpc-host.ts` | `this.operator` becomes `browserPeer` with principal `{ kind: 'owner' }` |
|
||||
| `packages/client/connection/src/rpc.ts` | `HostConnectionHandle` merges `admitConnection` with `browserPeer` / `createPeer` |
|
||||
| `packages/api/gateway/src/index.ts` | Replace `peer ?? this.operatorPeer()` where `$events` opens with an explicit Peer; reuse `gateway/peer-disposed` |
|
||||
| The operator-connect Note, upgrade guide, and subsystem changes | Rewrite operator terms as Peer / `browserPeer`; an API connection's Peer has principal `{ kind: 'owner' }` |
|
||||
+672
@@ -0,0 +1,672 @@
|
||||
# Agent Note: Peer 身份属性与 Remote 方法级访问控制
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-12-peer-principal-and-remote-access.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Host 上的 Remote 方法此前无法得知本次调用代表谁,也无法按调用方限制可调用的方法。[`PeerScope`](../../../../packages/typert/protocol/src/types.ts) 的契约曾写明「Who the Peer is and what it may do are not recorded here」;[Connection](../../../../packages/client/connection/src/rpc-host.ts) 只有一个 operator Peer,`admit()` 对每个通过认证的请求都返回它,`/api` 路由与 interceptor 的 Fetch handler 固定使用它而不使用准入结果;[Gateway](../../../../packages/api/gateway/src/index.ts) 在调用方未给出 Peer 时隐式回退到 `operatorPeer()`。
|
||||
|
||||
首个非 owner 调用方是 liveshare 读者。读者请求经 Relay 到达 owner 的 liveshare Host 插件,插件此前在 owner 进程内以 operator 身份分派,owner 的 Session 因此把读者的 prompt 当作 owner 本人的输入。PR #3854 为此把 liveshare 语义塞进 session-controller:请求体携带 `origin`,每条 prompt 都写 required 的 `prompt/origin` 事件并按 requestId 关联,controller 内做来源 fold,`serializeAdmission` 扩到全部 prompt,新增 `api-session/external-admission` 钩子;liveshare 自己维护接口白名单 `READ_ENDPOINTS` 并裁剪 list/follow 结果;前端的 `clientWritePolicy` 定义在 ui-conversation,5 个包三种读法,7 处靠「是否注入某回调」隐藏界面。
|
||||
|
||||
需要一套通用机制:
|
||||
|
||||
- 调用方身份由 Host 进程内代码赋予,wire 上任何字段都无法设置或覆盖。
|
||||
- 每条调用路径都显式给出 Peer,不存在隐式兜底。
|
||||
- 方法级权限在后端一处计算、一处把关;前端读同一份结果决定入口显隐,规则变化时自动同步。
|
||||
- prompt 投递时身份随用户消息落盘,供之后按 turn 判定发起者。
|
||||
|
||||
## 决策
|
||||
|
||||
### 范围
|
||||
|
||||
| 部分 | 内容 |
|
||||
|---|---|
|
||||
| Peer 身份 | `PeerScope.principal` 与 `PeerScope.signal`;唯一 Peer 实现 `Peer`(`OperatorPeer` 改名)与 options 构造;删除 operator 概念与所有隐式 Peer 兜底;`connection.browserPeer`、`connection.createPeer()` 与 `fetch(request, peer)`;已 dispose 的 Peer 一律以 `gateway/peer-disposed` 拒绝;`user-rpc` source 落盘 `principal?` |
|
||||
| 方法级访问控制 | `@Remote` 的 `resources` 读取函数、`TypertResourceMap` 资源种类与 `typert.resources` 注册表(typert-protocol、typert-registry);Gateway 把调用与转发事件解析为资源;新包 `@deepseek-ai/dsh-api-remote-access`(Host 面 + Client 面):按 role、binding 与 policy 的 RBAC 判定 `decide(request)`、列表过滤 `allows()`、index 中的首份快照与访问快照流、access hooks |
|
||||
| 文档 | 一条代码评审标准与术语 |
|
||||
|
||||
### 两层概念:principal 与 peer
|
||||
|
||||
| 层 | 概念 | 例子 | 数量关系 |
|
||||
|---|---|---|---|
|
||||
| 授权层 | principal:调用方身份,权限判定的依据 | 一份分享授权 token 证明的 `share-viewer{shareId, actor}`;本机用户 `{ kind: 'owner' }` | 一个 principal 对应 N 个 peer |
|
||||
| 连接层 | peer:一条连接的生命周期作用域 | 浏览器连接、liveshare 读者的一条连接 | 每条连接一个 peer |
|
||||
|
||||
| 事项 | 归属层 |
|
||||
|---|---|
|
||||
| 方法级权限判定、access hooks、访问快照 | principal |
|
||||
| `source.principal` 落盘 | principal |
|
||||
| 订阅、stream、连接期注册(`peer.ctx`) | peer |
|
||||
| 撤销授权 | 两层:撤权后 policy 不为该 principal 给出 binding,其调用一律被拒;创建方 dispose 其名下全部 peer |
|
||||
|
||||
principal→peers 的对应表由创建方持有,例如 liveshare 每份授权一个对象,记录该授权下建立的全部 peer。
|
||||
|
||||
### Peer 模型
|
||||
|
||||
只有 Peer 一个概念、一个实现,删除 operator 概念。
|
||||
|
||||
```text
|
||||
// @deepseek-ai/dsh-typert-protocol
|
||||
export interface PeerScope {
|
||||
readonly id: PeerId
|
||||
/** Connection-lifetime registrations; disposed with the Peer. */
|
||||
readonly ctx: Context
|
||||
/** Caller identity fixed at creation by in-process Host code; no wire field can set it. */
|
||||
readonly principal: Principal
|
||||
/** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */
|
||||
readonly signal: AbortSignal
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
// @deepseek-ai/dsh-client-connection
|
||||
export class Peer implements PeerScope {
|
||||
constructor(owner: Context, options: { readonly principal: Principal })
|
||||
}
|
||||
|
||||
interface HostConnectionHandle {
|
||||
/** The Peer every authenticated browser connection shares; principal { kind: 'owner' }. */
|
||||
readonly browserPeer: PeerScope
|
||||
/** Network admission: the admitted Peer or the refusal status. */
|
||||
admit(request: ConnectionTrustRequest): PeerAdmission
|
||||
/** In-process only; disposed with the Context that read `ctx.connection`, or earlier by dispose(). */
|
||||
createPeer(options: { readonly principal: Principal }): PeerScope
|
||||
}
|
||||
|
||||
interface ConnectionFetchHandler {
|
||||
fetch(request: Request, peer: PeerScope): Promise<Response>
|
||||
}
|
||||
bridge(req, res, apiHandler, peer, maxRequestBodyBytes?)
|
||||
|
||||
// @deepseek-ai/dsh-api-gateway
|
||||
interface InvokeRemoteRequest { …; readonly peer: PeerScope } // was `peer?`
|
||||
wireStream.open(endpoint, payload, uplink, peer: PeerScope, signal) // was `PeerScope | undefined`
|
||||
```
|
||||
|
||||
| 规则 | 说明 |
|
||||
|---|---|
|
||||
| 每条路径显式给出 Peer | 网络路径由 `admit()` 的结果给出;进程内路径由调用方传入。Gateway 删除 `operatorPeer()` 与 `request.peer ?? …`,不再自建 Peer |
|
||||
| 浏览器连接共享 `browserPeer` | 所有浏览器连接共享一个 Peer,每个使用处都显式引用 `connection.browserPeer`,不存在兜底变量;页内 Host(webworker-runtime)同样显式传 `browserPeer` |
|
||||
| Host 进程内自动化 | 需要调用 Remote 方法时用 `createPeer({ principal })` 创建自己的 Peer。目前没有这类生产调用方,因此不预置固定的 Host Peer |
|
||||
| 多 Peer | `createPeer()` 每次返回独立 Peer;liveshare 将用它为读者的每条连接建一个 Peer(在 #3854 侧接入) |
|
||||
| exact Fetch route | file-upload、present-open、media-references、session-log-export、inspector 等 exact route 不经 Gateway;共享 Fetch handler 对非 owner Peer 一律返回 403 |
|
||||
|
||||
### 身份类型
|
||||
|
||||
```text
|
||||
// @deepseek-ai/dsh-typert-protocol
|
||||
|
||||
/**
|
||||
* Merge-extensible caller identities. Each driver declares its own entry.
|
||||
* Every entry is a JSON value with a unique literal `kind`, carries no
|
||||
* credential, and is safe to persist in a Session log.
|
||||
*/
|
||||
export interface PrincipalMap {
|
||||
owner: { readonly kind: 'owner' }
|
||||
}
|
||||
export type Principal = PrincipalMap[keyof PrincipalMap]
|
||||
|
||||
/**
|
||||
* @param invocation - `ctx.invocation`; `undefined` outside a Remote call.
|
||||
* @returns the caller's principal when it is not the owner; `undefined` for the
|
||||
* owner and for trusted in-process code outside a Remote call.
|
||||
*/
|
||||
export function principalOf(invocation: RemoteInvocation | undefined): Principal | undefined
|
||||
```
|
||||
|
||||
读取入口统一为 `this.ctx.invocation.peer.principal`;owner 判断就地写 `principal.kind === 'owner'`,未知 kind 永远不是 owner。外部 driver 在自己的包内用声明合并扩展 `PrincipalMap`;liveshare 的 `share-viewer{ shareId, actor }` 在 #3854 侧声明,此处不改 liveshare。
|
||||
|
||||
### Peer 生命周期与撤权
|
||||
|
||||
| 事件 | 行为 |
|
||||
|---|---|
|
||||
| Gateway 分派 | 第一步检查 `peer.signal.aborted`,已 dispose 即以 `gateway/peer-disposed` 拒绝。对 HTTP、WebSocket 与进程内路径都生效;wire 路径在读取 payload 之前检查,已 dispose 的 Peer 携带畸形 payload 也得到同一错误码 |
|
||||
| Peer dispose 时的 stream | Remote stream 方法看到的 signal 并入 `peer.signal`,`$events` 流的生命周期同样并入;WebSocket 照旧以 1001 关闭 |
|
||||
| Peer dispose 时的 unary | 已在执行的 unary 调用不被取消;之后的调用被拒绝 |
|
||||
| 撤销授权 | 业务包撤权后 policy 不为该 principal 给出 binding,业务包调用 `invalidate(principal)`;创建方按 principal→peers 表 dispose 全部 peer |
|
||||
|
||||
### 方法级访问控制
|
||||
|
||||
资源类型与 `@Remote` 的 `resources` 读取函数在 `@deepseek-ai/dsh-typert-protocol`;资源种类在 typert 中与 lookups、Contexts 并列:`TypertResourceMap` 声明种类,`typert.resources` 保存每个种类的默认 wire 字段,lookup 与 Host Context adapter 声明其 wire 身份所属的资源种类。Gateway 把一次调用或一个转发事件解析为它访问的资源。判定、列表过滤与访问快照在新包 `@deepseek-ai/dsh-api-remote-access`(`packages/api/remote-access`),该包不读取任何参数字段。Gateway 与领域包对 remote-access 只做 type-only 依赖并 inject `remoteAccess`,值依赖只有 `dsh-typert-protocol`,不为 remote-access 新增 `package-dependency-policy` 条目。role、binding 与 policy 由业务包定义和注册,liveshare 为首个使用方。除内置的 `global` 外,remote-access 与 Gateway 中不出现任何资源种类名,符合 [`packages/api` 只承载通用编排](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的评审规则。
|
||||
|
||||
#### 与 RBAC 的对应
|
||||
|
||||
| 本设计 | RBAC / IAM 术语 | 含义 |
|
||||
|---|---|---|
|
||||
| principal | Subject / Principal | 调用方身份,`PrincipalMap` 的一个条目 |
|
||||
| Peer | Session(RBAC 会话) | principal 的一条连接;一个 principal 对应 N 个 Peer |
|
||||
| owner | 超级用户 | 跳过全部检查 |
|
||||
| action | Action / Permission | Remote 端点 `ns/method`,或转发事件 `event:<name>`;Gateway 内部仍称端点 |
|
||||
| resource | Resource / Object | `{ kind: 'global' }`,或某个已声明资源种类的 `{ kind, id }` |
|
||||
| role | Role | 限定一种资源种类的 action 集合,是业务包的普通值 |
|
||||
| binding | RoleBinding(角色指派) | principal 在某个资源上拥有某个 role,可附 condition |
|
||||
| condition | Condition(IAM Conditions) | 挂在 binding 上,按请求的 action、资源与参数放行或带原因拒绝 |
|
||||
| policy | Allow policy | 业务包为一种非 owner principal kind 给出 binding 与可选 `explain` |
|
||||
| explain | 拒绝说明 | 没有 binding 放行时给出拒绝原因,不改变结论 |
|
||||
| Gateway | PEP(Policy Enforcement Point) | 解析资源、执行结论 |
|
||||
| remote-access | PDP(Policy Decision Point) | 计算结论、列表过滤与访问快照 |
|
||||
|
||||
| 规则 | 内容 |
|
||||
|---|---|
|
||||
| 默认拒绝 | 非 owner 只有在某个 binding 的 role 显式列出该 action 时才被放行;role 未列出的 action、没有 binding 的资源、没有 policy 的 principal kind 一律被拒。所有 Remote 方法都受访问控制,没有单独的可访问标记 |
|
||||
| 资源之间无继承 | `global` 不是其他资源的上级:`global` 上的授权不蕴含任何会话或工作区上的授权,工作区上的授权也不蕴含其中会话的授权 |
|
||||
| `global` 授权即显式全局授权 | 调用解析不出任何资源即访问 `global`。把某个 action 放进 `global` 级 role,等于允许该 principal 不受资源限制地调用它,属高危授权;方法被放行后仍可在内部用 `allows(resource)` 逐项细判 |
|
||||
|
||||
#### 资源与资源读取(`@deepseek-ai/dsh-typert-protocol`、`@deepseek-ai/dsh-typert-registry`)
|
||||
|
||||
```text
|
||||
/** Merge-extensible resource kinds: the key is the kind, the value declares its identity type. */
|
||||
export interface TypertResourceMap {}
|
||||
// @deepseek-ai/dsh-session/types: session: TypertResource<SessionId>
|
||||
// @deepseek-ai/dsh-workspace/types: workspace: TypertResource<WorkspaceId>
|
||||
|
||||
// Runtime registries in typert
|
||||
ctx.typert.resources.register(kind, { field }): TypertDisposer
|
||||
ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names
|
||||
ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter
|
||||
|
||||
export interface TypertHostContextAdapter<Wire = unknown> {
|
||||
readonly wire: string
|
||||
readonly wireTypeSymbol: string
|
||||
/** Resource kind the wire identity names; access checks read the identity from args before resolve(). */
|
||||
readonly resource?: TypertResourceKind
|
||||
resolve(id: Wire): Context | undefined | Promise<Context | undefined>
|
||||
}
|
||||
|
||||
export type RemoteAccessResourceKind = 'global' | TypertResourceKind
|
||||
export type RemoteAccessResource =
|
||||
| { readonly kind: 'global' }
|
||||
| { readonly kind: K; readonly id: TypertResourceId<TypertResourceMap[K]> } // for each declared kind K
|
||||
export type RemoteAccessResourceOf<K extends RemoteAccessResourceKind> = Extract<RemoteAccessResource, { readonly kind: K }>
|
||||
|
||||
/** Per-kind readers, called with the method's positional wire arguments. */
|
||||
export type TypertResourceReaders<WireArgs extends readonly unknown[]> = {
|
||||
readonly [K in TypertResourceKind]?: (...args: WireArgs) => Id | readonly Id[] | undefined // Id = TypertResourceId<TypertResourceMap[K]>
|
||||
}
|
||||
|
||||
export interface RemoteMethodOptions<WireArgs extends unknown[]> {
|
||||
readonly name?: string
|
||||
readonly mode?: 'stream'
|
||||
readonly resources?: TypertResourceReaders<WireArgs>
|
||||
}
|
||||
|
||||
export function Remote<This extends object, Args extends unknown[], Result>(
|
||||
option: string | RemoteMethodOptions<NoInfer<RemoteWireArgs<Args>>>,
|
||||
): (method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>) => void
|
||||
|
||||
export interface RemoteScopeOptions<WireArgs extends unknown[]> {
|
||||
readonly name?: string
|
||||
readonly resources?: TypertResourceReaders<WireArgs>
|
||||
}
|
||||
|
||||
export function RemoteScope<This extends object, Args extends unknown[], Result>(
|
||||
key: Extract<keyof TypertContextMap, string>,
|
||||
option?: string | RemoteScopeOptions<NoInfer<RemoteWireArgs<Args>>>,
|
||||
): (method: (this: This, ...args: Args) => Result,
|
||||
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>) => void
|
||||
```
|
||||
|
||||
| 规则 | 内容 |
|
||||
|---|---|
|
||||
| 写法 | `@Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } })`;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 |
|
||||
| 资源种类 | 资源种类由声明 id 类型的底层包合并:`session: TypertResource<SessionId>` 在 `@deepseek-ai/dsh-session/types`,`workspace: TypertResource<WorkspaceId>` 在 `@deepseek-ai/dsh-workspace/types`。session-controller 引用 commands 包,若在 session-controller 合并,commands 要引用 session-controller,形成 project reference 环。`global` 由 remote-access 内置,没有 id,不进 `TypertResourceMap`;typert 保留该名,`typert.resources.register('global', …)` 抛错 |
|
||||
| 默认字段与声明 | 拥有该种类的领域在控制器构造时登记默认 wire 字段:`SessionController` 构造函数调用 `typert.resources.register('session', { field: 'sessionId' })`,`WorkspaceController` 构造函数登记 `workspace` → `workspaceId`;同一种类重复登记即抛错。Agent、Session 与 `workspaceFileScope` lookup 声明 `resource: 'session'`,agent 包的 Agent Context 声明 `resource: 'session'` |
|
||||
| 解析优先级 | 对每个资源种类依次取:① 该方法 `resources` 中该种类的读取函数;② 声明了该种类 `resource` 的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Context 方法的 Context 声明了该种类 `resource` 时,其 wire 身份同样贡献;③ 该种类的默认字段:命名参数中有同名顶层参数就读它,否则读第一个对象参数的同名字段。默认字段只表示一个身份,读到数组即为非法值 |
|
||||
| 参数来源 | 资源解析与 `decide()` 的 `args` 读取经 Typert 编译类型校验后的命名参数:每个 JSON 参数由其 codec 解码一次,类型未声明的对象字段被移除,分派复用该值,因此调用方无法用未声明字段改变资源;lookup 参数与 Context 身份取 wire 身份,此时不解析 lookup、不激活 Agent |
|
||||
| 解析结果 | 各种类的资源合在一起即本次调用访问的资源;一个都没有即 `[global]`;解析出资源时只用这些资源,不附加 `global` |
|
||||
| 读取函数 | 以方法的位置参数调用:按描述符把命名参数映射回方法参数顺序(JSON 位为校验后的值,lookup 位为 wire 身份,取消参数位为调用的 signal);返回一个 id、id 数组(每个都须被允许)或 `undefined`(该种类无资源) |
|
||||
| 非法值 | 读取函数抛错或返回空数组,或读到的值(含 Context 的 wire 身份)不是非空字符串即资源非法:Gateway 不询问 `decide()`,直接以 `remote-access/denied`(reason `resource-invalid`)拒绝调用 |
|
||||
| 完整性 | `resources` 与自动解析必须覆盖方法访问的全部资源。一个资源都没解析出的调用落到 `global`,只有 `global` 级 role 能放行,因而默认被拒;只解析出部分资源时,其余资源不受检查,持有已解析资源授权的 principal 可越权访问它们,由方法作者负责避免 |
|
||||
| 类型推断 | `@Remote` 与 `@RemoteScope` 读取函数的参数类型从被装饰方法自动推断:工厂参数用 `NoInfer`,`Args` 只从返回的装饰器推断;lookup 参数按 `TypertLookupMap` 映射为 wire 类型(首个参数为 `agent: Agent` 的方法,其读取函数的第一个参数为 `SessionId`)。访问不存在的属性、参数多于方法参数与 `global` 键均为编译错误 |
|
||||
| 存放 | `resources` 读取函数与 `@Remote` / `@RemoteScope` 的其他元数据一同存在类原型上,随实例构造登记,跨重复安装安全;不进入 Typert 生成物 |
|
||||
| Context 方法 | `@RemoteScope(key, option?)` 与 `@Remote` 一致:字符串为导出名,对象为 `{ name?, resources? }`。Context 的 wire 身份按声明了 `resource` 的 lookup 参数参与解析,优先级与 lookup 相同,该种类有 `resources` 读取函数时被覆盖;Gateway 在 `resolveReceiverContext` 之前直接从 args 读取该身份,不激活 Agent。Context 未声明 `resource` 时其身份不贡献资源,方法按普通规则解析 |
|
||||
|
||||
#### 端点的资源
|
||||
|
||||
下表列出 #3854 读者用到的端点及其资源来源。只有 `session/page` 与 `session/follow` 需要显式 `resources`,其余靠 lookup 的 `resource` 或默认字段:
|
||||
|
||||
| 端点 | 资源 | 解析来源 | 方法内过滤 |
|
||||
|---|---|---|---|
|
||||
| `workspace/follow` | `global` | 无资源 | 是:工作区与会话 |
|
||||
| `session/control` | `global` | 无资源 | 是:会话 |
|
||||
| `session/list` | `global`(`_request` 无 `sessionId`) | 无资源 | 是:会话行 |
|
||||
| `session/modelCatalog` | `global` | 无资源 | 否 |
|
||||
| `session/canOpenWorkspacePath` | `global` | 无资源 | 否 |
|
||||
| `llm/listConfigurableProviders` | `global` | 无资源 | 否 |
|
||||
| `llm/listProviders` | `global` | 无资源 | 否 |
|
||||
| `commands/list` | session:`args.agentId`(Agent lookup 的 wire 字段) | Agent lookup `resource: 'session'` | 否 |
|
||||
| `goals/get` | session:`args.agentId` | Agent lookup `resource: 'session'` | 否 |
|
||||
| `session/attachment` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `skills/list` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `session/fork` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `session/prompt` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `session/cancel` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `session/projections` | session:`args.request.sessionId` | 默认字段 | 否 |
|
||||
| `session/page` | session:Session 地址读 `args.request.address.sessionId`;subagent 地址读 `args.request.address.childSessionId` | `resources` | 否 |
|
||||
| `session/follow` | 同上 | `resources` | 否 |
|
||||
| `remoteAccess/follow` | `global` | 无资源;remote-access 对任何 principal 放行 | 否 |
|
||||
|
||||
subagent 地址读取的是子会话转录,因此其资源是子会话。Client 在重连后会重新读取已知会话的 projections,其中包括 subagent 会话的父会话;没有该父会话授权的读者,其 projection 读取被拒。父会话的授权是否覆盖其 subagent 子会话由 policy 决定,即是否为子会话给出 binding;通用层不推导父资源。
|
||||
|
||||
不需要非 owner 访问的方法不做任何声明,没有 role 列出它即被拒。subagent `prompt` 的请求字段为 `parentSessionId` / `childSessionId`,自动解析不到会话,解析为 `global`,因此不得放进任何 `global` 级 role;`session/search` 在方法内按会话逐项过滤命中。`subagents/list` 不是 Remote 端点(`SubagentRuntime.list()` 未标 `@Remote`)。
|
||||
|
||||
#### role、binding 与 policy(`@deepseek-ai/dsh-api-remote-access`)
|
||||
|
||||
```text
|
||||
/** Remote endpoint `ns/method`, or `event:<name>` for one forwarded event; endpoint segments never contain `:`. */
|
||||
export type RemoteAccessAction = RemoteAccessEndpoint | `event:${string}`
|
||||
|
||||
/** A named action set over one resource kind; a plain value owned by the business package. */
|
||||
export interface RemoteAccessRole<K extends RemoteAccessResourceKind> {
|
||||
readonly name: string
|
||||
readonly resource: K
|
||||
readonly actions: readonly RemoteAccessAction[]
|
||||
}
|
||||
|
||||
/** Answer of decide() and of a condition. */
|
||||
export type RemoteAccessDecision =
|
||||
| { readonly kind: 'allow' }
|
||||
| { readonly kind: 'deny'; readonly reason: string }
|
||||
|
||||
/** Refines one binding for one action on its resource; `args` only for a concrete call. */
|
||||
export type RemoteAccessCondition<K extends RemoteAccessResourceKind> = (request: {
|
||||
readonly action: RemoteAccessAction
|
||||
readonly resource: RemoteAccessResourceOf<K>
|
||||
readonly args?: Readonly<Record<string, unknown>>
|
||||
}) => RemoteAccessDecision
|
||||
|
||||
/** One role on one resource of the role's kind; the resource type follows from the role. */
|
||||
export type RemoteAccessBinding = {
|
||||
[K in RemoteAccessResourceKind]: {
|
||||
readonly role: RemoteAccessRole<K>
|
||||
readonly resource: RemoteAccessResourceOf<K>
|
||||
readonly condition?: RemoteAccessCondition<K>
|
||||
}
|
||||
}[RemoteAccessResourceKind]
|
||||
|
||||
/** One access question; the Gateway passes only valid resources, [global] when none. */
|
||||
export interface RemoteAccessRequest<P extends Principal> {
|
||||
readonly principal: P
|
||||
readonly action: RemoteAccessAction
|
||||
/** Allow only when the action is allowed on every listed resource. */
|
||||
readonly resources: readonly RemoteAccessResource[]
|
||||
/** Named arguments of a concrete call, validated against their Typert-compiled types with lookup parameters as wire identities; absent for events, snapshots, and list filtering. */
|
||||
readonly args?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** Business rules for one non-owner principal kind; synchronous over in-memory state. */
|
||||
export interface RemoteAccessPolicy<P extends Principal> {
|
||||
bindings(principal: P): readonly RemoteAccessBinding[]
|
||||
/** Reason for a request no binding allows; never changes the decision. */
|
||||
explain?(request: RemoteAccessRequest<P>): string | undefined
|
||||
}
|
||||
|
||||
/** details of `remote-access/denied` */
|
||||
export interface RemoteAccessDeniedDetails {
|
||||
readonly action: string
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
class RemoteAccessService { // ctx.remoteAccess; Remote namespace remoteAccess
|
||||
registerPolicy<K extends Exclude<keyof PrincipalMap, 'owner'>>(kind: K, policy: RemoteAccessPolicy<PrincipalMap[K]>): () => void
|
||||
invalidate(principal?: Principal): void
|
||||
decide(request: RemoteAccessRequest<Principal>): RemoteAccessDecision // Gateway-only
|
||||
allows(resource: RemoteAccessResource): boolean // reads the accessing ctx's invocation
|
||||
snapshotFor(principal: Principal): RemoteAccessBaseline // a page's opening baseline
|
||||
follow(signal): AsyncIterable<RemoteAccessFrame> // remoteAccess/follow
|
||||
}
|
||||
```
|
||||
|
||||
| 规则 | 内容 |
|
||||
|---|---|
|
||||
| owner | Gateway 对 owner 不解析资源、不调用 `decide()`,也不读取任何 policy |
|
||||
| 注册 | `registerPolicy(kind, policy)` 返回 disposer,业务包在 `ctx.effect()` 中调用。每个非 owner principal kind 至多一个 policy,同一 kind 重复注册抛错;`bindings()` 与 `explain()` 收到的 principal 按 kind 定型 |
|
||||
| 内置放行 | `remoteAccess/follow` 对任何 principal 放行,每个 Peer 都能读自己的快照 |
|
||||
| 判定 | 没有该 principal kind 的 policy 以 `no-allow` 拒绝。否则取 `bindings(principal)`,对请求的每个资源线性扫描找一个 binding:资源相等(`kind` 相同,且非 `global` 时 `id` 相同)、role 的 `actions` 含该 action、没有 condition 或 condition 放行;每个资源都找到才放行(AND)。condition 只对资源相等且 role 含该 action 的 binding 调用 |
|
||||
| 拒绝原因 | 第一个未被放行的资源决定原因,优先级为:该资源上存在匹配 binding 但其 condition 全部拒绝时,取第一个 condition 的原因;否则取 `explain(request)` 的返回值;没有 `explain` 或其返回 `undefined` 时为 `no-allow`。`explain` 只在结论已为拒绝后调用,不改变结论 |
|
||||
| 失败隔离 | `bindings()`、condition 或 `explain()` 抛错时,本次判定以 `policy-failed` 拒绝;计算快照时 `bindings()` 抛错即该 principal 没有 grant,condition 抛错即该 action 不放行。有问题的规则因此不会破坏共享的 `$events` 事件源、快照流或页面渲染;每次 `invalidate()` 或 policy 变更之后,每个 policy 的第一次失败只记录一次日志 |
|
||||
| 同步 | `bindings()`、condition 与 `explain()` 同步返回,状态由业务包预先载入内存,以便列表逐项过滤与快照计算 |
|
||||
| 求值时机 | 每次 `decide()`、`allows()` 与快照计算都调用一次 `bindings()`,不缓存;依赖时间的状态(如过期)即时生效,长流(如 `workspace/follow`、`session/control`)在 binding 增删后的下一次逐项判定即反映;快照只在 `invalidate()` 后重算,业务包在过期等时间事件发生时也须调用 `invalidate()` |
|
||||
| 通知 | 业务状态变化(授权、撤权、模式切换)改变 binding 或 condition 的结论时,业务包必须调用 `invalidate(principal?)`;快照只在 `invalidate()` 与 policy 注册、撤回后重算 |
|
||||
| `remote-access/denied` | 只有调用的拒绝变成错误,details `{ action, reason }`,reason 为 condition 或 `explain` 给出的原因、`no-allow`、`policy-failed`,或 Gateway 对非法资源给出的 `resource-invalid`;被拒的事件只是不推送 |
|
||||
| 必需服务 | Gateway `static inject = ['typert', 'remoteAccess']`;挂载 Gateway 的组合必须挂载 remote-access,[`bundle/base`](../../../../packages/bundle/base/cordis.patch.yml) 紧随 `typert-gateway` 装配;plugin manager 像保护 Gateway 一样保护它,插件页面无法禁用 |
|
||||
|
||||
#### 示例:分享读者
|
||||
|
||||
以 liveshare 声明的 `share-viewer` principal 为例,说明业务状态到 binding 的推导留在业务包;示例中的 `shares` 代表业务包在内存中持有的分享状态:
|
||||
|
||||
```text
|
||||
const globalReader = { name: 'share:global-reader', resource: 'global', actions: ['session/list', 'session/control', 'workspace/follow', 'session/modelCatalog', 'llm/listProviders', 'commands/list'] }
|
||||
const sessionReader = { name: 'share:session-reader', resource: 'session', actions: ['session/page', 'session/follow', 'session/list', 'session/control', 'workspace/follow', 'commands/list', 'event:api-session/status', 'event:api-session/added'] }
|
||||
const workspaceReader = { name: 'share:workspace-reader', resource: 'workspace', actions: ['workspace/follow'] }
|
||||
const forker = { name: 'share:forker', resource: 'session', actions: ['session/fork'] }
|
||||
const writer = { name: 'share:writer', resource: 'session', actions: ['session/prompt', 'session/cancel'] }
|
||||
|
||||
ctx.effect(() => ctx.remoteAccess.registerPolicy('share-viewer', {
|
||||
bindings(viewer) {
|
||||
const share = shares.serving(viewer) // undefined once stopped, expired, or no longer admitted
|
||||
if (share === undefined) return []
|
||||
const source = { kind: 'session', id: share.sessionId }
|
||||
return [
|
||||
{ role: globalReader, resource: { kind: 'global' } },
|
||||
{ role: sessionReader, resource: source },
|
||||
...(share.allowsFork ? [{ role: forker, resource: source }] : []),
|
||||
...(share.mode === 'write' ? [{ role: writer, resource: source, condition: sourceWrite(share) }] : []),
|
||||
...shares.forksOf(viewer).flatMap(child => [
|
||||
{ role: sessionReader, resource: { kind: 'session', id: child.id } },
|
||||
...(child.revoked ? [] : [{ role: writer, resource: { kind: 'session', id: child.id }, condition: childPrompt(child) }]),
|
||||
]),
|
||||
...shares.workspacesShowing(viewer).map(id => ({ role: workspaceReader, resource: { kind: 'workspace', id } })),
|
||||
]
|
||||
},
|
||||
explain({ principal, action }) {
|
||||
return shares.refusal(principal) // 'share-expired', 'share-stopped', …
|
||||
?? (action === 'session/prompt' ? 'share-not-writable' : undefined)
|
||||
},
|
||||
}))
|
||||
|
||||
// Without args (snapshots, list filtering) a condition answers for the action alone.
|
||||
const sourceWrite = share => () => share.confined ? allow : deny('source-write-unavailable')
|
||||
const childPrompt = child => ({ action, args }) =>
|
||||
action === 'session/prompt' && args !== undefined && !child.allowsPrompt() ? deny('fork-prompt-refused') : allow
|
||||
```
|
||||
|
||||
| 归属 | 内容 |
|
||||
|---|---|
|
||||
| 业务包 | role 的 action 清单;分享状态到 binding 的推导(停止、过期、准入、模式、读者自己的分支、撤销、包含可见会话的工作区);按请求细化的 condition;面向读者页面的拒绝原因 |
|
||||
| 通用层 | 资源解析、按资源 AND、role 与 action 匹配、condition 调用、原因优先级、列表过滤、快照计算与失败隔离 |
|
||||
|
||||
该读者的快照包含 `global`、源会话、自己的每个分支与每个可见工作区上的 grant;`session/list` 同时出现在 `global` 级与 session 级 role 中,见[列表与聚合方法](#list-and-aggregate-methods)。
|
||||
|
||||
#### Host 侧鉴权调用顺序
|
||||
|
||||
所有调用路径(HTTP `/api`、WebSocket stream、进程内 `fetch(request, peer)` / `invoke` / `stream` / `wireStream.open`)都经过同一段 Gateway 逻辑:
|
||||
|
||||
| 步 | 动作 | 失败结果 |
|
||||
|---|---|---|
|
||||
| 1 | 准入得到 Peer:网络路径由 Connection `admit()` 给出(`browserPeer`);进程内路径由调用方传入。Gateway 在读取 wire payload 之前检查 `peer.signal` | 网络准入失败返回 HTTP 401 / 403(不进入 Gateway);Peer 已 dispose:`gateway/peer-disposed` |
|
||||
| 2 | 解析端点并做参数字段集校验(`resolveDescriptor`、`assertExactArguments`),再按 Typert 编译类型校验 JSON 参数,每个参数解码一次 | `gateway/invocation-unavailable`、`gateway/ambiguous-endpoint`、`gateway/definition-unavailable`;字段多余或缺失:`gateway/arguments-invalid`;JSON 参数校验失败:`gateway/input-invalid`(owner 与非 owner 相同,先于访问检查) |
|
||||
| 3 | owner 直通:`principal.kind === 'owner'` 时直接进入第 5 步 | — |
|
||||
| 4 | 非 owner:Gateway 从第 2 步校验后的参数解析资源(`resources` 读取函数、其次 lookup 参数与 Context 声明的 `resource`、再次默认字段;lookup 与 Context 身份取 wire 身份,不解析 lookup 与 Context),再以校验后的参数为 `args` 询问 `remoteAccess.decide({ principal, action: endpoint, resources, args })`;资源非法时不询问 `decide()`,直接以 `resource-invalid` 拒绝;`deny` 结论变为抛出的错误 | `remote-access/denied`,details `{ action, reason }` |
|
||||
| 5 | 分派:`resolveReceiverContext`(可能激活 Agent)、lookup 解析(JSON 参数复用第 2 步的值)、调用方法 | 既有错误码不变,如 `gateway/lookup-not-found`、`gateway/context-not-found`、`gateway/input-invalid`、`gateway/service-unavailable`、`gateway/method-unavailable` 与业务错误 |
|
||||
|
||||
鉴权(第 3–4 步)位于 lookup 与 Context 解析之前,未授权调用不会激活会话或产生其他副作用。Gateway 新增的错误码只有 `gateway/peer-disposed`;拒绝码 `remote-access/denied` 由 remote-access 声明。
|
||||
|
||||
Gateway 自有端点:非 owner 可订阅 `$events`,但从不收到需要回答的 waterfall 帧;`$events/result` 对非 owner 以既有的 `gateway/invocation-unavailable` 拒绝;两者都做第 1 步的检查。
|
||||
|
||||
#### 转发事件
|
||||
|
||||
`API_REMOTE_FORWARDED_EVENTS` 的 `emit` 条目可以声明 `resources`:以资源种类为键、经 `TypertForwardableEventEntry` 按该事件自身参数定型的读取函数,例如 `{ event: 'api-session/added', mode: 'emit', resources: { session: (summary) => summary.sessionId } }`。API Remotes 把读取函数附在每个转发帧上;对每条非 owner 的 `$events` 流,Gateway 每帧解析一次资源,只有 `remoteAccess.decide({ principal, action: 'event:<name>', resources })` 允许时才推送。
|
||||
|
||||
| 规则 | 内容 |
|
||||
|---|---|
|
||||
| 已声明事件 | `api-session/activity`、`api-session/added`、`api-session/error`、`api-session/removed` 与 `api-session/status` 声明其 Session |
|
||||
| 未声明资源 | 未声明 `resources` 或声明为空 `resources: {}` 的事件访问 `global`,只有 `global` 级 role 列出 `event:<name>` 时才送达非 owner |
|
||||
| 缺失资源 | 事件携带其声明的每个资源,因此读取函数返回 `undefined` 时该事件的资源非法,Gateway 不询问 `decide()`,不送达任何非 owner;调用缺少资源时则为 `global` |
|
||||
| 整条放行或拒绝 | 允许的事件 payload 原样送达;不按 Peer 裁剪或投影 payload |
|
||||
| 每个事件一个资源 | 通知至多携带一个资源;集合类变化走 follow 类流(`session/follow`、`session/control`、`workspace/follow`),由这些方法内联经 `allows()` 逐项判定 |
|
||||
| waterfall | 非 owner 从不收到 waterfall 帧,不经 policy 判定 |
|
||||
| 非 owner 流的 Host 信息 | 非 owner 的 `$events` 流与 owner 流一样以完整 `host`(含 `home`)打开;`hasLiveClient()` 只统计能回答 waterfall 的 owner 流 |
|
||||
|
||||
<a id="list-and-aggregate-methods"></a>
|
||||
#### 列表与聚合方法
|
||||
|
||||
列表、分页与聚合类方法的调用解析为 `global`,由 `global` 级 role 放行;方法内部对每一项调用 `this.ctx.remoteAccess.allows(resource)`,判定当前调用的 action 在该资源上是否被授权(不带 args)。因此列表类 action 同时出现在 `global` 级 role(放行调用)与资源级 role(放行逐项)中:`session/list` 在 `global` 上被授权才能调用,每个会话行还要求 `session/list` 在该会话上被授权。`allows()` 读访问该服务的 Context 上的 invocation,owner 与 Remote 调用之外的进程内代码恒为 true,不过滤。
|
||||
|
||||
| 方法 | 过滤对象 |
|
||||
|---|---|
|
||||
| `session/list` | 会话行(`session` 资源) |
|
||||
| `session/search` | 搜索命中(`session` 资源) |
|
||||
| `session/control` | baseline 的 projections 与 projection 增量(`session` 资源) |
|
||||
| `workspace/follow` | baseline、`upsert` 帧与 `order` id 中的工作区(`workspace` 资源)、工作区内会话、archived / pinned 会话集合(`session` 资源)。流记录已下发的工作区;某个工作区在下一次变化或新顺序中已不被允许时,发送一个携带完整过滤后状态的 `replace` 增量帧而不是 `remove`,因为 Client 不会复活已移除的 id,访问恢复后该工作区经普通 `upsert` 重新出现。`remove` 帧表示删除,只发给收到过该工作区的调用方 |
|
||||
|
||||
#### 访问快照流
|
||||
|
||||
```text
|
||||
// Remote: remoteAccess/follow (stream; every Peer may read its own snapshot)
|
||||
type RemoteAccessFrame =
|
||||
| { readonly type: 'baseline'; readonly owner: true }
|
||||
| { readonly type: 'baseline'; readonly owner: false; readonly grants: readonly RemoteAccessGrant[] }
|
||||
| { readonly type: 'upsert'; readonly grant: RemoteAccessGrant }
|
||||
| { readonly type: 'remove'; readonly resource: RemoteAccessResource }
|
||||
|
||||
interface RemoteAccessGrant {
|
||||
readonly resource: RemoteAccessResource // global, or one declared-kind resource
|
||||
readonly actions: readonly string[] // endpoint actions allowed on this resource without args, sorted
|
||||
}
|
||||
|
||||
/** Window global carrying a page's opening baseline. */
|
||||
export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__'
|
||||
```
|
||||
|
||||
| 行为 | 说明 |
|
||||
|---|---|
|
||||
| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。remote-access 经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如 owner Host 上的 liveshare)在所有 index 注入行之后放置一条把该全局变量赋值为 `snapshotFor(principal)` 的语句;后写的赋值生效,Client 在挂载时读取该全局变量 |
|
||||
| 订阅 | 每个 Peer 订阅一条访问快照流,其 baseline 取代注入的快照 |
|
||||
| 计算 | 取该 principal kind 的 policy 的全部 binding,按资源相等归并;每个资源的 `actions` 为这些 binding 的 role 中、经与调用相同的判定(单个资源、不带 args,condition 以无 args 调用)放行的端点 action;只为至少放行一个端点 action 的资源给出 grant。判定与快照同源:同一 principal、action 与资源(不带参数)上,快照答案与 `decide()` 一致 |
|
||||
| 只含端点 action | `event:<name>` action 不进入快照;前端只据端点 action 决定入口显隐 |
|
||||
| 增量 | `invalidate()` 或 policy 注册、撤回时重算并推送 `upsert` / `remove`,放行集合变空的资源推送 `remove`;未变化的资源不推送 |
|
||||
| 生命周期 | 一代快照流在其 signal abort 或 remote-access 服务卸载时结束,因此重载后的服务从新的 baseline 回答每个读者;Client 把结束视为载体中断并重连 |
|
||||
| owner | 只推送 `{ owner: true }`,前端全部为 true |
|
||||
| wire 类型 | `actions` 为 `readonly string[]`:Typert 无法为模板字面量类型生成 codec |
|
||||
|
||||
access hooks 只按 action 与资源判断,不看调用参数;调用时 condition 可额外读取 args 做更细判断,以服务端结果为准。
|
||||
|
||||
#### Client 面
|
||||
|
||||
Client 面走框架标准 hook 注入,不引入 React context。组件只经 props 拿到 hook,不 import remote-access 的值。
|
||||
|
||||
```text
|
||||
// @deepseek-ai/dsh-api-remote-access/client
|
||||
interface ClientRemoteAccess { // Client service ctx.remoteAccess
|
||||
source(kind: TypertResourceKind, id: string, action: string): HostObservable<boolean>
|
||||
}
|
||||
GlobalStandardProps.useRemoteAccess: (action: RemoteEndpoint) => boolean // remote-access; the global resource
|
||||
|
||||
// @deepseek-ai/dsh-client-ui-session (injects remoteAccess)
|
||||
GlobalStandardProps.useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean
|
||||
SessionStandardProps.useAccess: (action: RemoteEndpoint) => boolean
|
||||
SessionMaybeStandardProps.useAccess: (action: RemoteEndpoint) => boolean | undefined
|
||||
|
||||
// @deepseek-ai/dsh-client-ui-workspace (injects remoteAccess)
|
||||
GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean
|
||||
|
||||
// RemoteEndpoint = Extract<keyof TypertRemoteMap, string>
|
||||
```
|
||||
|
||||
| 事项 | 定稿 |
|
||||
|---|---|
|
||||
| 快照与挂载 | remote-access Client 插件从 index 注入的 baseline 起步(启动时同步读取并校验),订阅快照流,对每个 baseline 一次构建完成,提供 Client 服务 `ctx.remoteAccess.source(kind, id, action)`(身份稳定的布尔 source)并注入 `global` 资源上的 `useRemoteAccess`。入口模块 import 生成的 `/remote` contribution,连同注入的 baseline 交给 `mountAccess(ctx, contribution, initial)` 经 `ctx.remote.$mount` 自行挂载(单测传入假 contribution),先例为 experimental client-ui-cot-translation、voice-input、claude-code-mods;若由 api/remotes 集中挂载,会形成 ui-session → api/remotes → remote-access → ui-session 的 project reference 环 |
|
||||
| 会话资源 | ui-session 经 `uiSession.provide({ keyedHooks: ['access'], resolve })` 给出本会话答案,会话由框架绑定,与 `useProjection` 相同;`useSessionAccess` 经 `provideRoot` 注入 |
|
||||
| 工作区资源 | ui-workspace 经 `provideRoot` 注入 `useWorkspaceAccess` |
|
||||
| 名称区分 | `global` 答案是 `useRemoteAccess`,会话答案是 `useAccess`,两者互不遮蔽;session-maybe 无会话时 `useAccess` 为 `undefined`,调用方按不允许处理,需要 `global` 判断时读 `useRemoteAccess` |
|
||||
| 双参数调用 | 框架 keyed hook 的标准双参数:`KeyedStandardSource` 为 `(key, member?) => source`;第二个参数为字符串时 hook 读取 resolver 对 `(key, member)` 返回的 source,不带 selector;resolver 对同一参数返回同一 source。既有 `(key, selector?, equal?)` 与 `(selector, equal?)` 调用不变。禁止柯里化形式 |
|
||||
| 返回值 | 有资源的答案均为布尔值;action 类型来自 `ctx.remote` 的 namespace 与方法 |
|
||||
| 不回退 | 会话资源上查询非会话方法(如 `useAccess('session/create')`)读该会话资源上的判定结果,不回退 `global` 答案 |
|
||||
| 无快照 | owner 为 true,快照流终止失败后亦然;没有注入 baseline 或快照流终止失败的非 owner 页面为 false,终止失败会连同错误与“刷新页面”的提示一并报告;断线重连期间保留上次快照;注入的 baseline 格式错误时挂载失败;Host 仍对每次调用执行检查。hook 从不发请求 |
|
||||
| 示例消费方 | ui-workspace 会话行菜单的 fork 入口读 `useSessionAccess(sessionId, 'session/fork')`,不允许时不渲染;ui-chat turn tail 的分叉按钮读会话资源上的 `useAccess('session/fork')`,不允许时不渲染。替换 #3854 的 `clientWritePolicy` 留给 #3854 |
|
||||
| 测试 | `@deepseek-ai/dsh-client-test-runtime` 导出 `allowAllAccess` props 与 `allowAllAccessPlugin`;TestClient 像 api-remotes 一样去掉 remote-access 行并装入全允许服务,SlotTestRuntime 在 ui-session 之前装入 |
|
||||
|
||||
### 安全不变量
|
||||
|
||||
| 不变量 | 依据与落点 |
|
||||
|---|---|
|
||||
| principal 只能由进程内 Host 代码在创建 Peer 时给出 | `PeerScope.principal` 只读;`createPeer()` 不对应任何路由 |
|
||||
| wire 上任何字段都不能设置或覆盖 principal | principal 不来自 args、envelope、header、query 或 cookie。args 顶层多余字段被 `assertExactArguments` 拒绝;payload 必须恰为 `{ args }`;envelope 未知字段被 [`clientRequestSchema`](../../../../packages/client/connection/src/rpc-schema.ts) 丢弃;请求体内同名字段只是业务数据 |
|
||||
| 浏览器网络准入为 owner | 通过[浏览器 token 认证](../../implemented/architecture/2026-08-24-browser-token-authentication.zh.md)的连接得到 `browserPeer` |
|
||||
| 没有隐式 Peer | Gateway 与 Connection 都不补默认 Peer;required 参数让漏传在编译期暴露 |
|
||||
| 已 dispose 的 Peer 不能再分派 | `gateway/peer-disposed` |
|
||||
| 非 owner 未经授权不能调用 | 只有 binding 的 role 显式列出该 action 且资源匹配时才放行;没有该 kind 的 policy 即拒;Gateway 依赖 `remoteAccess`,缺失时 Gateway 不启动 |
|
||||
| `global` 授权不蕴含资源授权 | 资源之间无继承;方法内 `allows()` 对每一项独立判定 |
|
||||
| 未知 principal kind 不是 owner | 只有 `kind === 'owner'` 是 owner |
|
||||
| 非 owner 不回答 waterfall | `$events` 不给非 owner waterfall 帧,`$events/result` 拒绝非 owner |
|
||||
| 非 owner 只收到被授权的通知 | 每个转发帧按 `event:<name>` 与其资源判定,被允许才转发给非 owner |
|
||||
|
||||
### 消息来源落盘
|
||||
|
||||
[session-controller 类型](../../../../packages/api/session-controller/src/types.ts) 的 `MessageSourceMap` 条目增加可选字段:
|
||||
|
||||
```text
|
||||
'user-rpc': {
|
||||
kind: 'user'
|
||||
rpcId: SessionRequestId
|
||||
clientTimeZone?: string
|
||||
/** Non-owner caller that submitted this prompt; absent means the owner. */
|
||||
principal?: Principal
|
||||
}
|
||||
```
|
||||
|
||||
在 Remote 调用中构造 `kind: 'user'` + `rpcId` 用户消息 source 的生产者共两处,都在 Remote 方法内调用 `principalOf(this.ctx.invocation)`,返回值非 `undefined` 才写 `principal`:
|
||||
|
||||
| 生产者 | 位置 |
|
||||
|---|---|
|
||||
| `SessionController.prompt()` 构造 source,`SessionCommandController.prompt(request, source)` 只负责投递 | [`session-controller/src/index.ts`](../../../../packages/api/session-controller/src/index.ts)、[`commands.ts`](../../../../packages/api/session-controller/src/commands.ts) |
|
||||
| subagent `prompt()`(`BrowserPromptSource`);没有 role 列出它,非 owner 只能经进程内 Host 代码到达,不能经 Gateway 到达 | [`subagent/src/index.ts`](../../../../packages/subagent/subagent/src/index.ts) |
|
||||
|
||||
业务代码不把 principal 当函数参数逐层传递:身份只在 Remote 方法内从 invocation 读一次,向下传的是已构造的 source。Agent inbox 没有可在消息入队时统一补字段的拦截点(`followup` / `steer` 接受已构造的消息,SDK、调度等进程内生产者不在 Remote 调用中),因此按生产者落实,并由代码评审标准约束新增的生产者。
|
||||
|
||||
| 事实 | 说明 |
|
||||
|---|---|
|
||||
| `kind` 不变 | 仍为 `'user'`;现有 `kind === 'user'` 判断不受影响 |
|
||||
| owner 消息不变 | owner 调用时不写字段,source 与引入 principal 之前逐字节相同;没有该字段的消息(含全部旧会话)即为 owner |
|
||||
| 不进入模型请求 | llm 适配器只读 model source([`llm-deepseek/src/replay.ts`](../../../../packages/llm/llm-deepseek/src/replay.ts)) |
|
||||
| 会话格式 | 不 bump `SESSION_FORMAT_VERSION`;按 [persistence 类型变更 cookbook](../../../../docs/cookbook/reviewing-persistence-type-changes.zh.md) 为该持久化类型变更记录 acknowledgement |
|
||||
|
||||
消费方用法示例(不在本 Note 范围内实现):工具守卫与 pre-step 复检按 turn 读取该 turn 内用户消息的 `source.principal`,任一条带非 owner principal 即按该调用方判定;同一 turn 混有 owner 与读者消息时取最严格者。
|
||||
|
||||
### 代码评审标准与文档
|
||||
|
||||
评审标准的正家为 [Remote and API rules](../../../../docs/defensive-patterns/remote-and-api.zh.md) 的「`@Remote` 标在能力方法上」一节:`@Remote({ resources })` 与自动解析必须覆盖方法访问的全部资源;聚合结果逐项经 `allows()` 过滤;调用方身份只读 `invocation.peer.principal`,不从参数读取;每条调用路径显式给出 Peer;业务代码不把 principal 当函数参数传递。
|
||||
|
||||
| 文档 | 内容 |
|
||||
|---|---|
|
||||
| [`docs/subsystems/typert.md`](../../../../docs/subsystems/typert.zh.md)、[`docs/api-gateway.md`](../../../../docs/api-gateway.zh.md) | `PeerScope`、`RemoteInvocation`、`InvokeRemoteRequest`、显式 Peer、`@Remote` 的 `resources`、访问检查顺序 |
|
||||
| connection、gateway、typert-protocol、typert-registry、api-remotes、remote-access、session-controller、workspace-controller、ui-session、ui-workspace、ui-slots、client-test-runtime 的 README | 以 Peer / `browserPeer` 取代 operator;remote-access 的 role、binding、policy 契约;access hooks |
|
||||
|
||||
### 不在范围与 Future work
|
||||
|
||||
- `/connect` 握手、API Cookie、每条 API 连接一个 Peer、WebSocket 生命周期:归 pr-5945。
|
||||
- 浏览器多 Peer(如每标签页一个):所有浏览器连接共享 `browserPeer`。
|
||||
- 交互参与(interactions)与按 principal 隔离审批由后续工作引入;目前非 owner 只收被授权的 `$events` 广播帧。
|
||||
- `agent-preset/selected` 等其余转发事件未声明资源,按 `global` 判定;读者需要某个会话事件时,#3854 为其声明资源并在 role 中列出 `event:<name>`。
|
||||
- liveshare 接入(声明 `share-viewer`、每条读者连接一个 Peer、定义 role 并注册 policy、删除 `origin` / `prompt/origin` / `READ_ENDPOINTS`):#3854 侧。
|
||||
- `session/cancel` 归属检查、fork 的 `childSessionId`、`ToolExecutionInput.turn`:不改。
|
||||
- 资源识别的精确方式(例如 Typert 生成器按 branded id 类型在请求对象中定位字段并写入描述符):目前用 lookup 与 Context 声明的 `resource`、默认字段加 `resources` 读取函数,以后可替换资源解析而不改 policy 接口。
|
||||
- exact Fetch route 对非 owner 一律 403 是临时处理;以后由各路由声明访问规则。
|
||||
- subagent `prompt` 的资源声明待定:其请求字段为 `parentSessionId` / `childSessionId`,目前解析为 `global`。生产者为进程内调用方记录 `source.principal`;没有 role 列出它,非 owner 不能经 Gateway 到达。
|
||||
- 预定义与聚合 role:出现第二种非 owner principal kind 时,由各领域包以 `@Remote({ roles })` 声明方法所属的通用 role(如 `session:viewer`),各 policy 绑定这些 role;目前 role 由业务包以显式 action 清单定义。
|
||||
|
||||
### 与现有 Note 的关系
|
||||
|
||||
- [Remote 双工流](../../implemented/architecture/2026-09-19-remote-duplex-stream.zh.md):部分 supersede。被替换的部分包括:「Peer 不记录身份与权限」「本 Host 只有一个 Peer:operator」「in-process carrier 不给 Peer 即 operator」,以及其替代方案中「Access 机制属于业务语义」的表述;其余(双工流、uplink、`ctx.invocation` 绑定)保持。两篇保持 active 并互链,该 Note 的现行事实已同步更新。
|
||||
- [浏览器 token 认证](../../implemented/architecture/2026-08-24-browser-token-authentication.zh.md):不受影响。该 Note 否决「按方法区分特权」针对的是 owner 的认证;通过认证的浏览器仍是 owner 且不受方法级检查。
|
||||
- [Typert Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md):不受影响;`@Remote` 与 `@RemoteScope` 增加可选的 `resources` 读取函数(`@RemoteScope` 的第二个参数可为 `{ name?, resources? }`),只供 Gateway 的访问检查读取,不改变端点命名、调用模式与生成物。
|
||||
- [生产方拥有的消息来源](../../implemented/architecture/2026-09-09-producer-owned-message-sources.zh.md):不受影响;`principal` 由构造 source 的生产者写入。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**独立 `prompt/origin` 事件并按 requestId 关联(#3854 做法)。** 每条 prompt 都写 required 事件,普通会话也无法被旧版本读取;关联要求全部 prompt 串行并维护来源 fold,fold 窗口外的消息失去发起者。
|
||||
|
||||
**principal 与 peer 并列挂在 invocation 上。** 同一调用方的身份拆成两处来源,准入点要同时产出并传递两者,任一路径只传其一就会错配。
|
||||
|
||||
**保留「Peer 不记录身份」的原约定。** 身份由业务插件经 `peer.ctx` 附加时,每个读取方都要知道由谁附加、如何读取,wire 准入路径也无法保证附加发生。
|
||||
|
||||
**保留默认 Peer 兜底(`request.peer ?? …`)。** 隐式默认让漏传 Peer 的调用静默获得 owner 身份;required 参数把漏传变成编译错误。
|
||||
|
||||
**新开 source kind。** 现有 `kind === 'user'` 判断都要逐个审视,漏一处就让读者消息在标题、大纲、队列或聊天渲染中消失。
|
||||
|
||||
**请求体字段携带身份。** 任何能构造请求体的调用方都能伪造或覆盖它。
|
||||
|
||||
**前端 React context 提供资源。** 业务组件不得读 React context;标准 hook 由框架按资源绑定,组件只经 props 拿到。
|
||||
|
||||
**由驱动方自行维护端点白名单并裁剪结果(#3854 做法)。** 白名单与结果裁剪都在驱动方内,Gateway 不执行,快照与前端各自推导;role 的 action 清单由通用层执行,列表过滤由方法内的 `allows()` 完成。
|
||||
|
||||
**业务包实现 `decide()` / `abstain` 的 provider,并合并多个 provider 的答案。** 每个业务包手写 action × 资源的判别分支,另写一份资源列表供快照,两者靠人工保持一致;policy 只给出 binding,判定与快照由同一份 binding 算出,每种 principal kind 只有一个 policy,不需要合并规则。
|
||||
|
||||
**独立的 `@RemoteAccess` 标记。** 默认拒绝已由 role 的显式 action 清单保证,标记只是第二道同意;一个资源都没解析出的方法落到 `global`,只有显式的全局授权能放行。资源读取函数是方法描述信息,放在 `@Remote` 上。
|
||||
|
||||
**把无资源的调用称为 `page` 或 `host`。** `page` 描述前端位置而不是资源;`host` 容易被读作这台 Host 上全部资源的上级。`global` 表示不关联任何资源的授权。
|
||||
|
||||
**资源继承(GCP 式:`global` 或工作区上的授权覆盖下级资源)。** 列表方法被 `global` 授权放行后需要逐项细判,继承会让任何 `global` 授权蕴含全部会话;无继承时每个资源的授权都由 binding 显式给出。
|
||||
|
||||
**role 注册表与预定义聚合 role。** 目前唯一的非 owner principal 由一个业务包管理,role 作为该包的普通值即可;聚合 role 会让新包声明的方法自动进入分享读者的可访问范围,推迟到出现第二种非 owner principal。
|
||||
|
||||
**condition 挂在 role 的 action 上。** role 保持为可复用的纯数据;按业务状态细化的判断属于把 role 授予某个 principal 的 binding。
|
||||
|
||||
**拒绝原因只由 condition 给出。** 没有 binding 时没有 condition 可以给出原因,读者页面无法区分「分享已过期」「分享不允许写入」等情况;`explain` 只在结论已为拒绝后给出原因,不改变结论。
|
||||
|
||||
**全局资源 resolver 注册表(领域包按字段名注册识别函数)。** 字段识别与方法分离,方法改签名时无人提醒,跨包同名字段也会被误认;资源解析采用方法上的默认约定加 per-method `resources`,领域包只登记自己种类的默认字段。
|
||||
|
||||
**按点路径声明资源(`'request.address.sessionId'`)。** 路径无法由类型推导约束;`resources` 读取函数的参数类型从被装饰方法推断,拼错即编译失败。
|
||||
|
||||
**由生成器按 branded id 类型识别资源字段。** 需要改 Typert 生成物与描述符格式,SRC 描述符没有类型信息;留作 Future work。
|
||||
|
||||
**由 api/remotes 集中挂载 remote-access 的 generated contribution。** remote-access Client 依赖 ui-session,ui-session 依赖 api/remotes,集中挂载形成 project reference 环;改为自挂载。
|
||||
|
||||
**在 Gateway 内实现判定(带规则的 `check()` 写在 Gateway)。** Gateway 会承载访问控制语义并多出错误码;Gateway 像解析 lookup 一样解析资源,在分派前调用 `remoteAccess.decide()`,规则与拒绝码归 remote-access。
|
||||
|
||||
**policy 异步判定。** 列表逐项过滤与快照计算需要同步答案;异步状态由业务包预先载入内存。
|
||||
|
||||
**前端按需查询权限。** 请求量随入口数增长,且与规则变化无法同步;快照流由 Host 主动推送,前端同步读取。
|
||||
|
||||
**访问检查放在 lookup 解析之后。** lookup 可能激活 Agent,未授权调用会先产生副作用。
|
||||
|
||||
**在 remote-access 中登记资源字段(`registerScope`)。** 访问控制包要识别参数字段,而 lookup 本已知道哪个 wire 字段指向 Session;typert 把资源种类与 lookups、Contexts 并列保存,Gateway 在一处解析调用与事件,remote-access 不读取任何参数字段。
|
||||
|
||||
**按 Peer 裁剪转发事件的 payload。** 逐事件投影会在每个通知里重复一遍列表过滤;一个事件至多携带一个资源,整条放行或拒绝,集合类变化走内联过滤的 follow 类流。
|
||||
|
||||
**首份快照到达前一律回答 true。** 非 owner 页面会短暂显示无权使用的入口;index 携带首份快照,没有快照的页面即无权限。
|
||||
|
||||
## 后果
|
||||
|
||||
### 验证
|
||||
|
||||
| 用例 | 行为 | 测试落点 |
|
||||
|---|---|---|
|
||||
| wire 试图设置 principal(args 顶层、payload 并列、envelope、请求体内) | 前两种以参数或 payload 校验拒绝;其余被丢弃或当作业务数据;`invocation.peer` 仍为准入给出的 Peer | Gateway host spec |
|
||||
| 所有调用路径显式给出 Peer | `InvokeRemoteRequest.peer`、`wireStream.open` 的 peer、`fetch(request, peer)`、`bridge` 的 peer 为 required;Gateway 中不存在兜底;`/api` 路由传递 `admission.peer` | typecheck、Connection host spec |
|
||||
| 浏览器连接与 `createPeer()` | `browserPeer` 的 principal 为 owner;`createPeer()` 每次返回独立 Peer,随读取 Connection 的 Context 释放;exact route 对非 owner 返回 403 | Connection host spec |
|
||||
| 已 dispose 的 Peer | 之后的 unary、stream 与 `$events` 分派以 `gateway/peer-disposed` 拒绝,wire payload 畸形时亦然;其名下 stream 结束;运行中的 unary 完成;不影响其他 Peer | Gateway host spec |
|
||||
| 鉴权顺序 | owner 调用任何方法照常分派且不读取 policy;没有 role 列出的方法对非 owner 以 `remote-access/denied`(`no-allow`)拒绝;访问检查早于 lookup,被拒调用不激活会话 | Gateway host spec |
|
||||
| policy 注册 | 每个非 owner principal kind 一个 policy,重复注册抛错;disposer 撤回后该 kind 以 `no-allow` 拒绝;随 fiber 撤回 | remote-access host spec |
|
||||
| 判定 | 每个资源都需要匹配的 binding(AND);role 不含该 action、资源不相等或 condition 拒绝都不放行;拒绝原因按 condition > `explain` > `no-allow` 取;`explain` 不改变结论;condition 在调用时收到 args,在快照与列表过滤时不收;`bindings()`、condition 或 `explain()` 抛错以 `policy-failed` 拒绝,每个 policy 在两次 `invalidate()` 之间只记录一次日志,事件路径上 owner 流与共享事件源照常;`remoteAccess/follow` 对任何 principal 放行 | remote-access host spec、Gateway host spec |
|
||||
| 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) |
|
||||
| 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec |
|
||||
| 转发事件 | 非 owner 的 `$events` 流只在 `event:<name>` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) |
|
||||
| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为渲染的每个 index 注入 owner baseline;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec |
|
||||
| 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) |
|
||||
| 列表过滤 | `session/list`、`session/search`、`session/control`、`workspace/follow` 对非 owner 只返回 `allows()` 为 true 的项;已下发工作区失去可见性时 `workspace/follow` 发送 `replace` 增量帧,访问恢复后该工作区重新出现;owner 结果不变 | session-controller / workspace-controller host spec |
|
||||
| 访问快照 | 快照中的允许集合与 Host 对同一 principal、action、资源(不带参数)的判定一致;只含端点 action;没有放行 action 的资源不出 grant,放行集合变空时推送 `remove`;`invalidate()` 与 policy 注册、撤回后推送增量;被变化结束的等待不残留 abort 监听器;卸载服务即结束所有打开的快照流;owner 只收 `{ owner: true }` | remote-access host spec |
|
||||
| access hooks | `source()`、`useRemoteAccess`、`useAccess`、`useSessionAccess`、`useWorkspaceAccess` 与快照一致;会话资源上的 `useAccess` 读本会话答案;owner 为 true;没有快照或快照流失败的非 owner 为 false;hook 不发请求 | remote-access client spec、ui-session / ui-workspace client spec |
|
||||
| keyed hook 双参数 | 第二个参数为字符串时读取 `(key, member)` source;既有调用形式不变 | ui-renderer client spec |
|
||||
| 非 owner 分派 `session/prompt` | 落盘 user 消息 `source.principal` 等于该 principal;冷恢复后仍读到同一值 | session-controller host spec |
|
||||
| 进程内以非 owner invocation 调用 subagent `prompt` | 子会话落盘的 user 消息 `source.principal` 等于该 principal | subagent host spec |
|
||||
| owner 消息 | source 与本变更之前逐字节相同;现有 keyless recorded-session snapshot 不变 | `pnpm run test:snapshot` 不需要重录 |
|
||||
| 持久化记录 | `verify-persistence-changes` 通过 | doc-sync |
|
||||
|
||||
SDK 投影不受影响:TypeScript SDK server 自行构造 `{ kind: 'user' }` 消息([`sdk/server/src/server.ts`](../../../../packages/sdk/server/src/server.ts)),不经 Gateway,也不投影 source;Python SDK 不读取 user source。
|
||||
|
||||
### 代价与限制
|
||||
|
||||
- 改动面横跨 protocol、Connection、Gateway、两个领域控制器、subagent、新包与前端,并带破坏性 API 改名;与 pr-5945 在 Connection 与 Gateway 上必然冲突,按迁移清单处理。
|
||||
- Gateway 依赖 `remoteAccess`:自建组合若挂载 Gateway 而不挂载 remote-access,Gateway 停在等待状态。
|
||||
- Session 日志会保存读者身份;导出或分享会话时随之外传,每个 principal 种类不得携带凭据。
|
||||
- 浏览器所有连接共享一个 Peer:dispose 无法按标签页区分,直到浏览器多 Peer 落地。
|
||||
- 默认字段约定按字段名读取:方法新增同名字段即访问对应资源,评审需留意;精确识别列为 Future work。
|
||||
- 只解析出部分资源的方法会放行越权:未解析的资源不受检查,由方法作者保证 `resources` 完整。
|
||||
- role 的 action 是字符串,不与已加载端点核对:拼错或改名后的 action 永不匹配,表现为拒绝。
|
||||
- `api-session/added` 携带完整的 `SessionSummary`,因此被允许接收它的非 owner 也会得知该会话的 `parentSessionId`。
|
||||
- 被授权会话的 cwd 与工具路径本就包含 owner 的绝对路径,因此 Gateway 不对非 owner 的 `$events` 流单独隐藏 `home`。
|
||||
- policy 撤销某个工作区的可见性后,`workspace/follow` 只在该工作区下一次变化或顺序下一次变化时察觉并向非 owner 发送 `replace` 增量帧;仅访问变化不会发送帧。
|
||||
|
||||
### 留给 pr-5945 的迁移清单
|
||||
|
||||
| 文件 | 迁移 |
|
||||
|---|---|
|
||||
| `packages/client/connection/src/peer.ts` | 取代 `operator-peer.ts`;`OperatorPeer` 构造改为 `new Peer(owner, { principal })` |
|
||||
| `packages/client/connection/src/rpc-host.ts` | `this.operator` 改为 `browserPeer`,principal 为 `{ kind: 'owner' }` |
|
||||
| `packages/client/connection/src/rpc.ts` | `HostConnectionHandle` 合并 `admitConnection` 与 `browserPeer` / `createPeer` |
|
||||
| `packages/api/gateway/src/index.ts` | `$events` 打开处的 `peer ?? this.operatorPeer()` 改为显式 Peer;复用 `gateway/peer-disposed` |
|
||||
| operator-connect 的 Note、升级指南与 subsystems 改动 | operator 术语改写为 Peer / `browserPeer`;API 连接的 Peer 为 `{ kind: 'owner' }` |
|
||||
@@ -0,0 +1,31 @@
|
||||
# Bilingual-pair consistency record for 2026-10-13-chat-single-fold-motion.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-13-chat-single-fold-motion.md
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message:
|
||||
en: 45594545dabfaa78
|
||||
zh: a1eac669647b2bac
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/problem:
|
||||
en: 88faaf0c589e12cb
|
||||
zh: dda7efc5eaed2788
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision:
|
||||
en: 972bbe45fe8ac28e
|
||||
zh: 593b851c142894e1
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/batches:
|
||||
en: f44b784545c6d9a2
|
||||
zh: 3eb6fcb9f348dec4
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/landings:
|
||||
en: b8278678170f1507
|
||||
zh: 113a51ba5cf27de1
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/decision/own-input:
|
||||
en: ca6a5c372bb3cad1
|
||||
zh: 1cd5028269b58c65
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/testing:
|
||||
en: 0423a607ee0589b5
|
||||
zh: 61df892a01c5aa43
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/alternatives-considered:
|
||||
en: d8e1ea66e293cd93
|
||||
zh: 0aaa9e693f441673
|
||||
/agent-note-one-fold-motion-for-collapsing-on-the-next-message/consequences:
|
||||
en: 89148df30a9a1772
|
||||
zh: d484cc7c9e0cc3f1
|
||||
@@ -0,0 +1,92 @@
|
||||
# Agent Note: One fold motion for collapsing on the next message
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-13-chat-single-fold-motion.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
When "When to Collapse Work Details" is "On next message", the previous exchange's process rows close when the next human input arrives. The scroll layer had two fold paths and chose one by `ownInput` ([use-chat-scroll.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts)):
|
||||
|
||||
- **Input fold path:** when the fold starts, it measures the closing rows, computes the final landing and the required bottom room, and moves the rows and the scrollport on one frame clock to a single landing.
|
||||
- **Generic path:** scrollTop stays put, the closing height is reserved as bottom room in one block, and following scrolls once more after the fold ends. This holds only when the fold happens below the reader. When the folding region is above the reader, the content the reader is looking at moves up as a whole and the viewport shows only the room.
|
||||
|
||||
`ownInput` holds for three signals only: a local echo in the transcript (`submissionId`), a steer echo (`steeringId`), or a user message as the last list row (`lastIsUser`). In the commit where the Host records the opening input, the Turn's process control is ordered after that input, so `lastIsUser` does not hold. Every human input without a transcript echo on this page therefore took the generic path:
|
||||
|
||||
| Source | Echo on this page | Reader at the bottom | Reader in the middle of the body |
|
||||
|---|---|---|---|
|
||||
| Idle send from the main composer | Yes, in the transcript | Correct | Correct |
|
||||
| Main-Session queued message delivered after the previous Turn | Yes, but in the queue, not consulted | Whole viewport blank | Whole viewport blank |
|
||||
| Send from another tab | No | Whole viewport blank | Whole viewport blank |
|
||||
| Send from a subagent child's composer in the right Sidebar | No (the composer skipped `beginSubmission`) | Whole viewport blank | Whole viewport blank |
|
||||
| Human send after parent messages woke several child Turns | No | Whole viewport blank (about 6000 px of room) | — |
|
||||
|
||||
Notice-woken Turns no longer fold anything ([group-tree positions and exchanges](2026-10-11-chat-group-tree-exchanges.md)); the table above covers the human input that still folded through the generic path. The data comes from [fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts).
|
||||
|
||||
Subagent children showed no local echo because, when echoes were introduced, `subagents.prompt` carried no request id, so an echo could not be matched with the message the child recorded. Subagent control later moved to Remote, and the Host now records `SubagentPromptRequest.requestId` as the child message's `source.rpcId`; the client's `Session.prompt` still minted a separate id.
|
||||
|
||||
## Decision
|
||||
|
||||
"On next message" keeps a single fold motion: [flow-motion.ts](../../../../packages/client/ui-chat/src/client/chat/flow-motion.ts) hands every batch of closing rows to the `InputFoldDriver` that the viewport supplies, and [use-chat-viewport.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) chooses one of two landings when the batch begins.
|
||||
|
||||
### Batches
|
||||
|
||||
- Rows that start closing in one commit, together with rows that start revealing in it, form one batch. The driver's four steps are:
|
||||
- `begin`: measure the boxes and choose the landing;
|
||||
- `prepare`: grow the room before box writes so the browser does not clamp an intermediate frame;
|
||||
- `frame`: write box heights and scrollTop together;
|
||||
- `finish`: install the final room.
|
||||
|
||||
Content added during the batch updates the remaining movement by starting a new segment.
|
||||
- A row that starts closing or revealing while a batch runs keeps its current size, without a transition of its own, until that batch settles; the next flush drives it in a new batch, or starts a held revealing row's own transition when nothing closes. Rows therefore never change outside a batch while following continues, which would follow the bottom room without the growth that should have consumed it. A batch takes over every member at its current size: a closing row shrinks from the height it has now, and a revealing row grows from the height its own transition already reached, so only the remaining growth enters the landing; a closing row with no size left finishes without animation.
|
||||
- A batch with only revealing rows needs no room and keeps CSS transitions. When the viewport has no reading policy attached, closing rows also keep CSS transitions, and held rows finish immediately; under reduced motion closing rows finish immediately.
|
||||
- Closing rows commit their hidden state first, the driver then installs the final geometry, and only then are fold-idle listeners notified (for example, clearing room after a display-mode change).
|
||||
- The generic path is removed: the `reserve` callback, the `pendingReserve` and `pendingGrowth` reservation arithmetic, `reserveBelow`, and the post-fold `followAfterFold`. `reclaimBelow` (returning room after new content or reader scrolling) and `resetBelow` (clearing room after a display-mode change) remain.
|
||||
|
||||
### Landings
|
||||
|
||||
Let `start` be the scrollTop when the batch begins, `above` the height of closing rows above the viewport top minus revealing rows there, `base = max(0, start − above)`, and `bottom` the floor after the fold.
|
||||
|
||||
| Landing | When | Target scrollTop | After the batch |
|
||||
|---|---|---|---|
|
||||
| **Tail** | This client's own input, or the reader was following the tail when the batch began and had no reading movement awaiting sampling | `max(base, bottom)` | Commit follow ownership |
|
||||
| **Reader content still** | Otherwise | `base` | Keep the reader's reading state and acknowledge the new position |
|
||||
|
||||
- Both landings install only the exact deficit as room: `max(0, target + viewport height − folded height)`. The tail landing reads the committed layout when it finishes: it lands at the larger of the target and the real floor, and keeps only the room that target needs.
|
||||
- Content never slides back down: content already above the viewport top does not return. When the retained body and the new message cannot fill the viewport, the deficit remains as room until new content fills it.
|
||||
- Own input during a running batch starts a new segment from the current position toward the tail; it takes over even when a reading gesture cancelled the automatic target. A tail request counts only while a batch runs or is scheduled; rows still closing on CSS transitions after a batch leave own input to ordinary following.
|
||||
- A reading gesture cancels a tail landing's automatic target and keeps the reader's position. During a reader-content-still landing, the reader's own scrolling between frames moves the position the batch keeps still.
|
||||
- For the reader-content-still landing, the driver takes the first visible top-level row outside the batch as a reference, found by binary search over the ordered top-level rows. Neighbouring spacing changes instantly when the batch begins and when closing rows commit their hidden state; the driver measures the reference's displacement at those two points and offsets scrollTop by it.
|
||||
- When a tail landing scrolls forward past closing rows that start below the viewport top, those rows stay opaque, scroll with the content, and clip while they shrink; nested closing rows keep their size until the batch ends. Other closing rows fade in place. A newer tail request that takes over a running batch adds the closing rows still below the viewport top; a row that has already faded becomes opaque again for its remaining height. Scrolling forward therefore never crosses space that is already transparent but not yet closed.
|
||||
|
||||
### Own input
|
||||
|
||||
| Source | Determination | Landing |
|
||||
|---|---|---|
|
||||
| Idle send from the main composer | Transcript echo | Tail |
|
||||
| Idle send from a subagent child's composer | The child also registers a local echo; `subagents.prompt` uses the echo's `requestId` | Tail |
|
||||
| Queued while running, delivered after the previous Turn | Not own input | Tail when following, otherwise reader content still |
|
||||
| Send from another tab | Not own input | Same as above |
|
||||
| Parent Agent messages a child | The notice opens no exchange and folds nothing | — |
|
||||
|
||||
"On completion" is outside the motion scope; its folds apply immediately and are unaffected.
|
||||
|
||||
## Testing
|
||||
|
||||
[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) covers 19 main-Session cases and 8 right-Sidebar child cases across reader position (bottom, middle of the body, top of the body, inside the steps, previous Turn), input source (composer, Turn failing before its message is recorded, another tab, queued delivery, steer, parent message, several Turns folding at once), body length, and Standard and "On completion" controls. The layout gap is the tallest band between the scrollport top and the composer that no visible row's box covers: at most 120 px once settled, and over that for at most one animation frame. It counts boxes, so a closing row that has faded but not finished shrinking still covers its space. Tail cases assert the floor landing; reader-content-still cases assert that the reader's anchor row moves at most 2 px on screen. Short-body cases assert only that no room remains after the reply arrives.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the generic path and fix only own-input detection.** The two paths keep diverging geometry rules; for input that is not own input, a reader in the middle is either pulled to the bottom or still sees a blank.
|
||||
|
||||
**Compensate the generic path by reader position but implement it separately from the input path.** The compensation and room geometry would exist twice and could diverge again.
|
||||
|
||||
**Detect own input from the exchange-opening human node and keep children without echoes.** Input from another tab would count as own input and pull a reader in the middle to the bottom; child sends still would not show the sender's message immediately.
|
||||
|
||||
**Let content slide back to fill the deficit a short body leaves.** Content would move up and then down within one animation.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Benefit: the source of whole-viewport blanks (the generic path) no longer exists; the landing depends only on whether the input is own input and whether the reader was following, not on which page sent the message or when it was delivered. Child sends show the sender's message immediately.
|
||||
- Cost: with a short body, content never sliding back still leaves the deficit until new content fills it.
|
||||
- When focus is inside a closing row, focus protection keeps that row open. If it is a height-capped process group, its body shrinks to the cap immediately. That shrink happens before the batch begins, so the reader-content-still landing cannot measure it and the reader's content moves once.
|
||||
@@ -0,0 +1,92 @@
|
||||
# Agent Note: 「下条消息时收起」只用一条折叠动画
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-13-chat-single-fold-motion.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
「何时收起工作详情」选「下条消息时收起」时,上一个交互段的过程行在下一条人类输入到来时收起。滚动层原本有两条折叠路径,按 `ownInput`([use-chat-scroll.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-scroll.ts))二选一:
|
||||
|
||||
- **输入折叠路径**:折叠开始时测量收起行,算出最终落点和所需的底部补白,收起与滚动在同一个帧时钟上推进,一次落定。
|
||||
- **通用路径**:scrollTop 不动,把收起的高度整块预留为底部补白,折叠结束后再按跟随状态补滚一次。它只对折叠发生在读者下方的情况成立。折叠区在读者上方时,读者正在看的内容整体上移,视口里只剩补白。
|
||||
|
||||
`ownInput` 只在三种信号下成立:对话流里的本地回显(`submissionId`)、steer 回显(`steeringId`)、列表最后一行是用户消息(`lastIsUser`)。Host 写回开场输入的那次提交里,该轮的过程控制行排在输入之后,`lastIsUser` 不成立。于是,凡是不经过本页对话流回显的人类输入都走通用路径:
|
||||
|
||||
| 来源 | 本页回显 | 读者在底部 | 读者在正文中段 |
|
||||
|---|---|---|---|
|
||||
| 主会话输入框空闲发送 | 有,在对话流 | 正常 | 正常 |
|
||||
| 主会话排队消息在上一轮结束后自动投递 | 有,但在排队区,不参与判定 | 整屏空白 | 整屏空白 |
|
||||
| 另一个标签页发送 | 无 | 整屏空白 | 整屏空白 |
|
||||
| 右侧栏子会话输入框发送 | 无(composer 跳过 `beginSubmission`) | 整屏空白 | 整屏空白 |
|
||||
| 子会话里父消息唤醒多轮后人类发送 | 无 | 整屏空白(补白约 6000 px) | — |
|
||||
|
||||
通知唤醒的 Turn 已不再触发折叠([分组树位置与交互段](2026-10-11-chat-group-tree-exchanges.zh.md));上表是仍经由通用路径折叠的人类输入。数据来自 [fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts)。
|
||||
|
||||
子会话不显示本地回显,是因为引入回显时 `subagents.prompt` 不携带 request id,回显无法与子会话写回的消息对应。此后子 agent 控制迁到 Remote,`SubagentPromptRequest.requestId` 已被 Host 写入子会话用户消息的 `source.rpcId`;客户端 `Session.prompt` 却仍另行生成 id。
|
||||
|
||||
## Decision
|
||||
|
||||
「下条消息时收起」只保留一条折叠动画:[flow-motion.ts](../../../../packages/client/ui-chat/src/client/chat/flow-motion.ts) 把每一批收起交给 viewport 提供的 `InputFoldDriver`,[use-chat-viewport.ts](../../../../packages/client/ui-chat/src/client/chat/use-chat-viewport.ts) 在批次开始时选择两种落点之一。
|
||||
|
||||
### 批次
|
||||
|
||||
- 同一次提交中开始收起的行,连同同批开始展开的行,组成一个批次。驱动器的四步依次为:
|
||||
- `begin`:测量这些盒子,选定落点;
|
||||
- `prepare`:在写盒子之前扩大补白,避免中间帧被浏览器截断;
|
||||
- `frame`:同步写盒子高度和 scrollTop;
|
||||
- `finish`:安装最终补白。
|
||||
|
||||
批次中途新增的内容按分段规则更新剩余运动。
|
||||
- 批次运行期间开始收起或展开的行保持当前尺寸、不走自己的过渡,等当前批次落定后由下一次刷新处理:有收起行时进入新批次,只剩展开行时启动它自己的过渡。因此不会出现「批次之外有行在变化、同时跟随照常进行」的情况;否则跟随会把底部空间整块跟进去,而本该抵扣它的新内容没有被计入。批次按每个成员的当前尺寸接管:收起行从现有高度收缩,展开行从它自己的过渡已达到的高度继续增长,只有剩余的增长量计入落点;已无尺寸的收起行直接完成、不做动画。
|
||||
- 只含展开行的批次不需要补白,仍用 CSS 过渡。viewport 未挂载阅读策略时,收起行也用 CSS 过渡、被挂起的行立即完成;减少动态效果时收起行立即完成。
|
||||
- 收起行先提交 hidden,驱动器再安装最终几何,之后才通知 fold-idle 监听者(例如切换展示模式后清空补白)。
|
||||
- 通用路径已删除:`reserve` 回调、`pendingReserve` 与 `pendingGrowth` 的预留计算、`reserveBelow`、折叠结束后再跟随的 `followAfterFold`。保留 `reclaimBelow`(新内容到来或读者滚动后归还补白)与 `resetBelow`(切换展示模式后清空补白)。
|
||||
|
||||
### 落点
|
||||
|
||||
记 `start` 为批次开始时的 scrollTop,`above` 为视口顶部以上收起行的高度减去展开行的高度,`base = max(0, start − above)`,`bottom` 为折叠后的底部位置。
|
||||
|
||||
| 落点 | 何时 | 目标 scrollTop | 结束后 |
|
||||
|---|---|---|---|
|
||||
| **到底部** | 本客户端的自己输入,或批次开始时读者正在跟随底部、且没有尚待采样的阅读滚动 | `max(base, bottom)` | 提交跟随状态 |
|
||||
| **读者内容不动** | 其余情况 | `base` | 保持读者的阅读状态,确认新位置 |
|
||||
|
||||
- 两种落点都只安装精确差额作为补白:`max(0, 目标 + 视口高 − 折叠后高度)`。到底部落点在结束时读取已提交的实际排版:落在目标与真实底部中较大的位置,只保留该目标所需的空间。
|
||||
- 「内容只上不下」:已经在视口顶部以上的内容不会回滑。保留的正文和新消息不足一屏时留出差额,新内容到来后填满。
|
||||
- 批次进行中出现自己输入时,从当前位置开新段,改为到底部;即使阅读手势已取消自动目标,也由新输入接管。只有批次正在运行或已排定时才接受到底部请求;批次结束后仍在用 CSS 过渡收起的行,不接管自己的输入,交给普通跟随。
|
||||
- 读者的滚动手势会取消「到底部」的自动目标,保留读者位置。「读者内容不动」时,帧间读者自己的滚动会并入批次保持的位置。
|
||||
- 「读者内容不动」时,驱动器对有序的顶层行二分查找,选取视口里第一行不属于本批次的可见顶层行作为参照。批次开始和收起行提交 hidden 时,相邻行的间距会瞬间改变;驱动器在这两处测量参照行的位移,抵消到 scrollTop 上。
|
||||
- 「到底部」且需要向前滚过视口顶部以下的收起行时,这些行保持不透明、随内容滚动,在收缩中裁剪显示,嵌套的收起行在批次结束前保持原尺寸。其余收起行原地淡出。新的到底部请求接管运行中的批次时,会把仍在视口顶部以下的收起行加入随行;已经淡出的行会以剩余高度重新变为不透明。这样向前滚动不会穿过一片已经透明、还没收完的空位。
|
||||
|
||||
### 自己输入的判定
|
||||
|
||||
| 来源 | 判定 | 落点 |
|
||||
|---|---|---|
|
||||
| 主会话输入框空闲发送 | 对话流回显 | 到底部 |
|
||||
| 子会话输入框空闲发送 | 子会话同样注册本地回显;`subagents.prompt` 使用回显的 `requestId` | 到底部 |
|
||||
| 运行中排队、上一轮结束后自动投递 | 不是自己输入 | 跟随底部时到底部,否则内容不动 |
|
||||
| 另一个标签页发送 | 不是自己输入 | 同上 |
|
||||
| 父 agent 给子会话发消息 | 通知不开启交互段,不折叠 | — |
|
||||
|
||||
「完成时收起」不在动画作用域内,折叠立即生效,不受影响。
|
||||
|
||||
## Testing
|
||||
|
||||
[fold-blank-matrix.e2e.ts](../../../../apps/web/tests/fold-blank-matrix.e2e.ts) 覆盖主会话 19 例与右侧栏子会话 8 例,维度包括读者位置(底部、正文中段、正文开头、步骤中间、上一轮)、输入来源(输入框、回合在写入消息前失败、另一个标签页、排队投递、steer、父消息、一次折叠多轮)、正文长短,以及 Standard 与「完成时收起」对照。布局空洞指视口顶部到输入框之间没有任何可见行的盒子覆盖的最大连续高度:落定后不超过 120 px,动画中至多 1 帧超过。它按盒子计算,已经淡出但尚未收完的收起行仍算作覆盖。「到底部」的用例断言落到底部;「内容不动」的用例断言读者锚定行的屏幕位置变化不超过 2 px。正文很短的用例只断言回复到达后不再留空。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留通用路径,只修自己输入的识别。** 两条路径的几何规则继续分叉;非自己输入时,中段读者要么被拉到底部,要么仍然看到空白。
|
||||
|
||||
**通用路径改为按读者位置补偿,但与输入路径分开实现。** 补偿与补白的几何代码重复一份,两边容易再次不一致。
|
||||
|
||||
**用开启交互段的人类输入节点识别自己输入,子会话不补回显。** 另一个标签页的输入会被当成自己输入,把中段读者拉到底部;子会话发送后仍然不能立即看到自己的消息。
|
||||
|
||||
**允许内容回滑,以填满短正文留下的差额。** 同一次动画里内容先上后下。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 收益:整屏空白的来源(通用路径)不再存在;落点只看「是不是自己的输入」和「读者是否在跟随」,与消息从哪个页面、何时投递无关。子会话发送立即显示自己的消息。
|
||||
- 代价:正文很短时,「内容只上不下」仍会留出差额,直到新内容填满。
|
||||
- 焦点停在正在收起的行里时,焦点保护会让该行保持展开。若它是被封顶高度的过程组,正文会立即缩到封顶高度。这次缩小发生在批次开始之前,「读者内容不动」的落点测不到,读者内容会移动一次。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-10-13-context-breakdown-system-prompt-state.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-13-context-breakdown-system-prompt-state.md
|
||||
/agent-note-context-breakdown-retains-only-system-prompts:
|
||||
en: 9b155f87202e037e
|
||||
zh: 8d4190c00412b892
|
||||
/agent-note-context-breakdown-retains-only-system-prompts/problem:
|
||||
en: 7cbbdfbe7da9ae7a
|
||||
zh: 50368b272d7b5ebc
|
||||
/agent-note-context-breakdown-retains-only-system-prompts/decision:
|
||||
en: 52d5c42112c5c6dc
|
||||
zh: 744dbbcc52193300
|
||||
/agent-note-context-breakdown-retains-only-system-prompts/verification:
|
||||
en: 68a7375ca7fe99e8
|
||||
zh: e3c7d108740e8952
|
||||
/agent-note-context-breakdown-retains-only-system-prompts/alternatives-considered:
|
||||
en: 2bd0e83c5298da2b
|
||||
zh: 336259b6985eb7b5
|
||||
/agent-note-context-breakdown-retains-only-system-prompts/consequences:
|
||||
en: 839a919ff16bc53c
|
||||
zh: bf0d4f3a783ead1d
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Agent Note: Context breakdown retains only system prompts
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-13-context-breakdown-system-prompt-state.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`contextBreakdown` kept one `{ seq, heuristicTokens, system }` entry per retained surface node so that any replacement could subtract the exact price of its range and re-select the last nonempty system prompt. Its checkpoint therefore grew with the number of messages in the model context. In a local corpus of 836 Sessions, 151,122 entries made the unit's rows 7.13 MB of the projection cache and up to 103 KB for one Session, and every cached Session listing validated all of them. Long tool-heavy runs below the compaction threshold kept about 2,000 entries each.
|
||||
|
||||
## Decision
|
||||
|
||||
[`breakdown-projection.ts`](../../../../packages/llm/token-meter/src/breakdown-projection.ts) retains the surviving `system/message` nodes in surface order with their heuristic prices, one running surface total, and at most one armed shadow-price claim. State version 6 discards version-5 rows; other projection keys keep their rows.
|
||||
|
||||
- An append adds its price; a `system/message` append also joins the end of the system list.
|
||||
- `compaction/summary` and `compaction/prune` arm a claim from `shadowedRange`, `shadowedTokenCount`, and the retained system prompts named by `shadowedSeqs`. The next event consumes or expires it. A replacement over the claimed range subtracts the claim price and drops those prompts; a replacement naming another range, or putting a `system/message` in place of a metered range, throws.
|
||||
- An unmetered replacement of one retained system prompt subtracts that prompt's own price. A `system/message` replacement keeps its position; any other replacement removes it.
|
||||
- Any other unmetered replacement folds at zero delta, the rule `contextPressure` already applies.
|
||||
|
||||
`systemTokens` is the last retained prompt with a positive price, and `messageTokens` is the surface total minus it; both are floored at zero. `shadowedSeqs` is the authoritative set of shadowed nodes, so membership decides which prompts leave without locating non-system endpoints in surface order.
|
||||
|
||||
## Verification
|
||||
|
||||
Folding all 836 stored logs with the version-5 and version-6 definitions produced identical breakdowns after every event. The logs held 2,206 metered replacements, 106 system-prompt rewrites, and no other replacement. Retained state fell from 7.13 MB to 110 KB, with at most five surviving system prompts in one Session.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep per-node entries with a faster validator.** A hand-written node check cut listing validation from 75 ms to 4.5 ms but kept 7.13 MB of rows, an O(surface) array copy per surface event, and checkpoint writes proportional to the message count.
|
||||
|
||||
**Columnar per-node entries.** Parallel arrays shrink rows about 5.7 times but keep O(surface) state for information the shadow-price events already carry.
|
||||
|
||||
**Exact pricing of unmetered non-system replacements.** That requires per-node prices. Current producers meter every non-system replacement, and released formats from v0 carry the shadow-price fields, so the remaining case is a log that breaks producer adjacency; it drifts like `contextPressure`.
|
||||
|
||||
## Consequences
|
||||
|
||||
Checkpoint size and per-event cost follow the number of surviving system prompts rather than messages, and cached listings no longer validate per-message entries. The figures stay exact for metered replacements and system-prompt rewrites, and their sum equals the `measure()` heuristic total under those producers. An unmetered non-system replacement leaves the figures off by that replacement's price difference. A future range replacement that inserts a system prompt is rejected until the fold can order it among the retained prompts. This supersedes the positional-entry choice for this unit in the [in-history system-prompt replacement](../feature/2026-09-02-in-history-system-prompt-replacement.md) note; `measure()` keeps positional nodes for route pricing.
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
# Agent Note: 上下文构成只保留系统提示词
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-13-context-breakdown-system-prompt-state.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`contextBreakdown` 曾为每个保留的 surface 节点存一条 `{ seq, heuristicTokens, system }`,以便任意替换都能减去其范围的精确价格,并重新选出最后一个非空系统提示词。因此它的检查点随模型上下文中的消息数增长。在一个包含 836 个 Session 的本地语料中,151,122 条条目让该单元的行占用 projection cache 的 7.13 MB,单个 Session 最多 103 KB,而每次缓存会话列表都要校验全部条目。低于压缩阈值、工具调用密集的长时间运行各自保留约 2,000 条条目。
|
||||
|
||||
## Decision
|
||||
|
||||
[`breakdown-projection.ts`](../../../../packages/llm/token-meter/src/breakdown-projection.ts) 按 surface 顺序保留存活的 `system/message` 节点及其启发式价格、一个 surface 运行总量,以及至多一个已武装的影子价 claim。状态版本 6 丢弃版本 5 的行;其他 projection key 保留各自的行。
|
||||
|
||||
- 追加加上其价格;`system/message` 追加还会加入系统列表末尾。
|
||||
- `compaction/summary` 和 `compaction/prune` 依据 `shadowedRange`、`shadowedTokenCount` 以及 `shadowedSeqs` 中列出的已保留系统提示词武装 claim。下一个事件消费或使其失效。覆盖所 claim 范围的替换减去 claim 价格并移除这些提示词;指向其他范围的替换,或以 `system/message` 取代已计量范围的替换,会抛出错误。
|
||||
- 对单个已保留系统提示词的未计量替换减去该提示词自身的价格。`system/message` 替换保持其位置;其他替换将其移除。
|
||||
- 其他未计量替换以零增量折叠,这与 `contextPressure` 已采用的规则相同。
|
||||
|
||||
`systemTokens` 是最后一个价格为正的保留提示词,`messageTokens` 是 surface 总量减去它;两者都不低于零。`shadowedSeqs` 是被遮蔽节点的权威集合,因此由成员关系决定哪些提示词离开,无需在 surface 顺序中定位非系统端点。
|
||||
|
||||
## Verification
|
||||
|
||||
用版本 5 和版本 6 的定义折叠全部 836 份已存日志,每个事件之后的构成数字都完全相同。这些日志包含 2,206 次已计量替换、106 次系统提示词改写,没有其他替换。保留状态从 7.13 MB 降到 110 KB,单个 Session 最多有五个存活的系统提示词。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留逐节点条目,换用更快的校验器。** 手写节点检查把列表校验从 75 ms 降到 4.5 ms,但仍保留 7.13 MB 的行、每个 surface 事件一次 O(surface) 数组复制,以及与消息数成正比的检查点写入。
|
||||
|
||||
**列式逐节点条目。** 并行数组让行缩小约 5.7 倍,但仍为影子价事件已经携带的信息保留 O(surface) 状态。
|
||||
|
||||
**精确定价未计量的非系统替换。** 这需要逐节点价格。当前生产方会计量每一次非系统替换,自 v0 起的已发布格式都携带影子价字段,因此剩余情况只是破坏生产方相邻性的日志;它像 `contextPressure` 一样产生偏差。
|
||||
|
||||
## Consequences
|
||||
|
||||
检查点大小和每个事件的成本取决于存活系统提示词的数量而不是消息数,缓存列表也不再校验逐消息条目。对已计量替换和系统提示词改写,数字保持精确,其和在这些生产方下等于 `measure()` 的启发式总量。未计量的非系统替换会让数字偏离该替换的价格差。未来插入系统提示词的范围替换会被拒绝,直到折叠能确定它在已保留提示词中的顺序。这取代了 [历史内系统提示词替换](../feature/2026-09-02-in-history-system-prompt-replacement.zh.md) 说明中针对该单元的位置条目选择;`measure()` 仍为路由定价保留位置节点。
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-13-team-identity-runtime-context.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-13-team-identity-runtime-context.md
|
||||
/agent-note-team-identity-in-required-runtime-context:
|
||||
en: 8c88759320ae8481
|
||||
zh: 4068b18723907eb9
|
||||
/agent-note-team-identity-in-required-runtime-context/problem:
|
||||
en: c9a81cb731ddf7b2
|
||||
zh: 428c0e6f8937d7c5
|
||||
/agent-note-team-identity-in-required-runtime-context/decision:
|
||||
en: 6c73479973c1e378
|
||||
zh: 98677c77f77f8d81
|
||||
/agent-note-team-identity-in-required-runtime-context/alternatives-considered:
|
||||
en: 774566ffbf9e9db8
|
||||
zh: ea761d876af462f2
|
||||
/agent-note-team-identity-in-required-runtime-context/consequences:
|
||||
en: 18d1aa67936f1227
|
||||
zh: 7e06c1f51553ecdd
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: Team identity in required runtime context
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-13-team-identity-runtime-context.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
A teammate identity embedded in its initial task depends on ordinary history retention. Compaction can remove it, and an ordinary fork from that teammate can retain the old role even though the fork is an independent Lead. Identity must reflect current membership while keeping shared system instructions uniform.
|
||||
|
||||
## Decision
|
||||
|
||||
`tool-agent-team` contributes `team:identity` through the Agent-scoped `systemPrompt.context()` registry with `required: true`. Its provider reads the existing Team membership and renders the member's role, name, and addressing instructions. Optional-context suppression cannot remove it. Fixed collaboration policy stays in the system prompt; roster and task state remain tool queries. Initial tasks contain only their supplied content.
|
||||
|
||||
The loop owns logging, retained-snapshot comparison, and re-admission after compaction. A changed role appends the current full runtime-context snapshot after inherited history; unchanged retained context adds no message. No Team-specific retention state, event type, or log migration is introduced. Existing recorded reminders remain historical messages.
|
||||
|
||||
This supersedes only the initial-task identity decision in [Agent Teams](../feature/2026-08-05-agent-teams.md#team-identity); its roster, mailbox, and task decisions remain active.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep identity in the initial task.** Preserves a uniform system prompt but cannot restore removed identity or correct a fork's inherited role without another mechanism.
|
||||
|
||||
**Put identity in the system prompt.** Makes system text differ across members and can rewrite the inherited request prefix on routes without in-history system updates.
|
||||
|
||||
**Add custom reminder retention and reinjection.** Duplicates the loop's existing runtime-context projection and recovery responsibilities.
|
||||
|
||||
## Consequences
|
||||
|
||||
Members pay for identity within the full runtime-context snapshot. Other context changes repeat it, while stable retained context adds no new message. Forks preserve inherited history and append current identity; actual provider cache hits remain best-effort. Existing Team authorization remains in tool execution.
|
||||
|
||||
Verification covers fresh and fork teammates, an independent fork from a teammate, optional-context suppression, compaction restoration, cold resume, and registration disposal. The recorded `team-targets` scenario pins Lead context; the keyless headless Team workflow pins teammate context through the shipped profile.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Agent Note: 必需 runtime context 中的 Team 身份
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-13-team-identity-runtime-context.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
嵌入初始任务的 teammate 身份依赖普通历史保留。压缩可能移除它;从 teammate 创建的普通 fork 即使已成为独立 Lead,也可能保留旧角色。身份必须反映当前 membership,同时保持共享系统指令一致。
|
||||
|
||||
## Decision
|
||||
|
||||
`tool-agent-team` 通过 Agent 作用域的 `systemPrompt.context()` 注册 `team:identity`,并设置 `required: true`。提供方读取现有 Team membership,渲染成员角色、名字与寻址说明。关闭可选 context 不能移除它。固定协作策略保留在 system prompt;roster 和 task 状态仍通过工具查询。初始任务只包含传入的正文。
|
||||
|
||||
Loop 负责日志记录、保留快照比较和压缩后的重新接纳。角色变化会在继承历史之后追加当前完整 runtime-context 快照;保留的 context 未变化时不追加消息。不引入 Team 专属的保留状态、事件类型或日志迁移。已有身份提醒仍作为历史消息保留。
|
||||
|
||||
本决策仅取代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md#team-identity) 中的初始任务身份决策;其 roster、mailbox 和 task 决策仍然有效。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**身份留在初始任务中。** 保持 system prompt 一致,但恢复被移除的身份或纠正 fork 继承的角色需要额外机制。
|
||||
|
||||
**身份放进 system prompt。** 成员之间的 system 文本会不同;不支持历史内 system 更新的路由可能重写继承的请求前缀。
|
||||
|
||||
**添加自定义提醒保留与重新注入。** 重复了 loop 已有的 runtime-context 投影和恢复职责。
|
||||
|
||||
## Consequences
|
||||
|
||||
成员承担完整 runtime-context 快照中的身份 token 成本。其他 context 变化会重复身份,保留的 context 稳定时则不追加消息。Fork 保留继承历史并追加当前身份;提供方实际缓存命中仍为尽力而为。现有 Team 授权仍在工具执行时落实。
|
||||
|
||||
验证覆盖 fresh 和 fork teammate、从 teammate 创建的独立 fork、关闭可选 context、压缩后恢复、冷恢复和注册释放。录制的 `team-targets` 场景固定 Lead context;无密钥 headless Team 工作流通过内置 profile 固定 teammate context。
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Bilingual-pair consistency record for 2026-09-29-reused-blank-runs-requested-preset.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-29-reused-blank-runs-requested-preset.md
|
||||
/agent-note-a-reused-blank-session-runs-the-requested-preset:
|
||||
en: 06173bc82e1d9f59
|
||||
zh: 637352fd92142310
|
||||
/agent-note-a-reused-blank-session-runs-the-requested-preset/problem:
|
||||
en: 7514025504dc2ddd
|
||||
zh: 96249d3472005023
|
||||
/agent-note-a-reused-blank-session-runs-the-requested-preset/decision:
|
||||
en: e396c291f742a5f3
|
||||
zh: c67be0fd305215fe
|
||||
/agent-note-a-reused-blank-session-runs-the-requested-preset/alternatives-considered:
|
||||
en: ae99345e8df800e4
|
||||
zh: a4d0c257c4216337
|
||||
/agent-note-a-reused-blank-session-runs-the-requested-preset/consequences:
|
||||
en: bc7dda06670b4d96
|
||||
zh: 24f381b8cb6ed3f8
|
||||
/agent-note-a-reused-blank-session-runs-the-requested-preset/testing:
|
||||
en: 1aefca569f5dce81
|
||||
zh: 527c9aacf5a2f6b8
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: A reused blank Session runs the requested preset
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-29-reused-blank-runs-requested-preset.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Web New Session, Workspace navigation, and startup restoration reused a blank Session by naming it in `session.create`. The Host resumed that Session under the preset its log recorded, so a new conversation inherited the preset the blank had been created or switched under. When that preset was no longer declared, such as a removed or renamed definition or a legacy `$DSH_HOME/.agent-presets/<id>/` directory that nothing reads any more, every New Session in that Workspace and every startup restoration of that draft failed with `agent-preset/not-found` although the default preset was valid (issue #2566). A blank created under an earlier default silently kept that default. The client retried only `session/writer-held`, so the stale blank stayed first in line.
|
||||
|
||||
## Decision
|
||||
|
||||
`session.create` returns a Session for a new conversation. A named `sessionId` is a reuse candidate that the Host verifies: it must not have started a turn, must belong to the requested directory, must be an unarchived member of the requested Workspace, and must not be written by another owner. For a persisted candidate the Host repeats the started check under the writer it acquires. Requests naming one identity run in arrival order, including their preset resolution, and the directory and ownership checks precede that resolution. The reused Session runs the requested `agentPreset`, or the configured default when the request names none; the Host does not resolve the recorded preset for reuse. When the target differs, a live Session switches through the preset registry's `select`, and a persisted Session mounts the target in its unpublished resume setup and appends `agent-preset/selected` there, so the log names the composition before the Agent is published. A candidate that has started, fails placement, or is written by another owner stays unchanged, and the Host creates a new Session; the response names the Session used. An explicit request for an undeclared preset still fails with `agent-preset/not-found`, and resuming a started Session still resolves its recorded identity.
|
||||
|
||||
The Web client names the first eligible blank when it connects a Workspace or prepares an explicit draft, and passes the preset a flow knows before it starts, such as the creator entry's `cordis`; a plain New Session creates a fresh Session instead. Startup restoration names the saved blank with the preset its log recorded, reloading the Session's projections first because listed values are cached hints that can lag the log after an unclean Host exit, so a draft keeps its selection; after `agent-preset/not-found` or `agent-preset/invalid` it asks again without a preset, and the draft continues under the default. When the Host declines a named blank, the client reloads that Session's projections, because stale cached list metadata can still mark a started conversation blank and hide it from the Session tree.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Fall back to the default only when the recorded preset is missing.** This removes the failure but keeps an inherited preset whenever it still resolves, so a New Session after a default change runs the old default.
|
||||
|
||||
**Retry `agent-preset/not-found` in the client.** The stale blank stays the first reuse candidate, so later New Sessions fail once before creating another Session, and startup restoration and other callers still fail.
|
||||
|
||||
**Skip blanks whose preset is missing from the roster.** Navigation would need the roster, stale Sessions would never be repaired, and other callers would still fail.
|
||||
|
||||
**Let the Host choose the blank.** The Host would scan the catalog on every request, while the client already holds the ordered catalog; verification under the writer covers a stale client view.
|
||||
|
||||
**Keep a restored draft's preset on the Host when it resolves.** A request that names a blank without a preset would then mean "keep" for restoration and "default" for New Session. Passing the recorded preset gives each request one meaning.
|
||||
|
||||
## Consequences
|
||||
|
||||
A reused blank runs the default or the preset its flow names, including after the default changes or a definition disappears. A preset picked on a draft survives a page reload but not a later Workspace connection or draft preparation that reuses the draft, and reusing a draft that another tab shows changes that tab's preset too. Writer contention no longer reaches the client: the Host creates the replacement Session. A blank whose first prompt was accepted but whose turn has not started still counts as blank, as it does for `select`. A declined stale blank is corrected only in the client that named it, until the Session is next activated. The preset registry's `select` leaves a blank Session that records the requested preset and runs its current revision unchanged, so a client that also selects that preset after the creation request adds no second record.
|
||||
|
||||
This note replaces the client-side contention fallback of [Writer acquisition for blank Session reuse](../architecture/2026-09-17-process-local-blank-sessions.md); writer acquisition and candidate order remain as decided there.
|
||||
|
||||
## Testing
|
||||
|
||||
Session Controller tests cover live and persisted reuse, a recorded preset that is no longer declared, named presets, started and overtaken Sessions, writer contention, Workspace placement, storage failures, arrival order when an earlier preset resolves later, and directory checks before preset resolution. Workspace navigation tests cover restoration with and without a usable recorded preset, restoration when the listed preset lags the log, connection coalescing that a different preset request supersedes, and the projection reload for a declined blank; the preset plugin tests cover the creator entry, and the registry tests cover a repeated selection with and without a replaced definition. The shipped Web composition test reuses a blank recorded under an undeclared preset under the default when startup connects its Workspace and when startup restores it, keeps a restored draft's declared preset, and checks the durable `agent-preset/selected` record; the stale blank cache scenario checks that startup opens a new Session and still lists the declined conversation.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: 被复用的空白会话运行请求的 preset
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-29-reused-blank-runs-requested-preset.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Web 的新建会话、工作区导航与启动恢复过去通过在 `session.create` 中指明空白会话来复用它。Host 过去按日志记录的 preset 恢复该会话,因此新对话继承了空白会话创建时或切换后的 preset。当该 preset 已不再声明时,例如定义被删除或改名,或是如今已无人读取的旧版 `$DSH_HOME/.agent-presets/<id>/` 目录,即使默认 preset 有效,该工作区中的每次新建会话以及该草稿的每次启动恢复都会以 `agent-preset/not-found` 失败(issue #2566)。在更早默认值下创建的空白会话则会静默保留那个旧默认值。客户端只对 `session/writer-held` 重试,因此过期的空白会话始终排在复用候选首位。
|
||||
|
||||
## Decision
|
||||
|
||||
`session.create` 返回一个用于新对话的会话。请求指明的 `sessionId` 是由 Host 核实的复用候选:它必须尚未开始任何轮次、属于请求的目录、是请求的工作区中未归档的成员,且未被其他写者占用。对于持久化候选,Host 会在取得写锁后再次检查它是否已开始。指明同一标识的请求按到达顺序执行,preset 解析也包含在内,且目录与归属检查先于该解析。被复用的会话运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset;Host 复用时不解析记录的 preset。目标与当前不同时,活动会话通过 preset 注册表的 `select` 切换;持久化会话在尚未发布的恢复 setup 中挂载目标 preset,并在同一处追加 `agent-preset/selected`,使日志在 Agent 发布前就写明其组成。已开始、不满足归属或被其他写者占用的候选保持不变,Host 改为新建会话;响应指明实际使用的会话。显式请求未声明的 preset 仍以 `agent-preset/not-found` 失败,恢复已开始的会话仍解析其记录的标识。
|
||||
|
||||
Web 客户端在连接工作区或显式准备草稿时指明第一个符合条件的空白会话,并传入流程在开始前已确定的 preset,例如创造模式入口的 `cordis`;普通新建会话则直接新建 Session。启动恢复指明已保存的空白会话及其日志记录的 preset,并先重新加载该会话的投影,因为列表中的值是缓存提示,Host 非正常退出后可能落后于日志,从而使草稿保留其选择;遇到 `agent-preset/not-found` 或 `agent-preset/invalid` 后改为不带 preset 再请求一次,草稿在默认 preset 下继续。Host 拒绝复用某个指明的空白会话时,客户端会重新读取该会话的投影,因为过期的列表缓存元数据可能仍把已开始的对话标为空白,并把它从会话树中隐藏。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**只在记录的 preset 缺失时回退到默认值。** 这能消除失败,但只要继承的 preset 仍可解析就会保留它,因此默认值变更后的新建会话仍运行旧默认值。
|
||||
|
||||
**在客户端重试 `agent-preset/not-found`。** 过期的空白会话仍是第一个复用候选,之后每次新建会话都会先失败一次再新建,启动恢复和其他调用方仍会失败。
|
||||
|
||||
**跳过 preset 不在列表中的空白会话。** 导航需要读取 preset 列表,过期会话永远得不到修复,其他调用方仍会失败。
|
||||
|
||||
**由 Host 选择空白会话。** Host 每次请求都要扫描会话列表,而客户端已持有排好序的列表;在写锁下的核实足以覆盖客户端视图过期的情况。
|
||||
|
||||
**恢复草稿时由 Host 在记录的 preset 可解析时保留它。** 这样一个指明空白会话但未带 preset 的请求,在恢复时表示「保留」,在新建会话时表示「默认」。传入记录的 preset 让每个请求只有一种含义。
|
||||
|
||||
## Consequences
|
||||
|
||||
被复用的空白会话运行默认 preset 或其流程指明的 preset,默认值变更或定义消失之后也是如此。在草稿上选择的 preset 在刷新页面后仍保留,但之后复用该草稿的工作区连接或草稿准备不会保留它;复用另一个标签页正在显示的草稿也会改变那个标签页的 preset。写锁争用不再到达客户端:由 Host 新建替代会话。首条提示词已被接受但轮次尚未开始的空白会话仍算空白,与 `select` 一致。被拒绝复用的过期空白会话只在指明它的客户端中得到纠正,直到该会话下次被激活。若空白会话已记录所请求的 preset 且正运行其当前版本,preset 注册表的 `select` 不做改动,因此客户端在创建请求之后再选择同一 preset 也不会追加第二条记录。
|
||||
|
||||
本注记取代 [复用空白 Session 时获取写锁](../architecture/2026-09-17-process-local-blank-sessions.zh.md) 中的客户端锁争用回退;写锁获取与候选顺序仍按该决策执行。
|
||||
|
||||
## Testing
|
||||
|
||||
Session Controller 测试覆盖活动会话与持久化会话的复用、记录的 preset 已不再声明、指明的 preset、已开始与被抢先开始的会话、写锁争用、工作区归属、存储失败、较早请求的 preset 解析较慢时仍按到达顺序执行,以及目录检查先于 preset 解析。工作区导航测试覆盖记录的 preset 可用与不可用时的恢复、列表中的 preset 落后于日志时的恢复、被不同 preset 请求取代的连接不再合并,以及被拒绝的空白会话的投影重读;preset 插件测试覆盖创造模式入口,注册表测试覆盖定义未替换与已替换时的重复选择。实际 Web 组合测试在启动连接工作区与启动恢复时,把记录了未声明 preset 的空白会话复用到默认 preset 下,验证恢复的草稿保留已声明的 preset,并检查持久化的 `agent-preset/selected` 记录;过期空白缓存场景验证启动时打开新会话,并仍列出被拒绝复用的对话。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-30-job-session-lifecycle.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-30-job-session-lifecycle.md
|
||||
/agent-note-persist-owned-job-lifecycle-facts:
|
||||
en: 79d83b765acf6e24
|
||||
zh: afe6ea9af6d96d10
|
||||
/agent-note-persist-owned-job-lifecycle-facts/problem:
|
||||
en: b3d0591cc559e466
|
||||
zh: aa4c385e84f7eaa1
|
||||
/agent-note-persist-owned-job-lifecycle-facts/decision:
|
||||
en: c982a59cbe5202d3
|
||||
zh: 9315962d2e4b8f0a
|
||||
/agent-note-persist-owned-job-lifecycle-facts/alternatives-considered:
|
||||
en: 140cf7152a35bb27
|
||||
zh: 001aa96687836914
|
||||
/agent-note-persist-owned-job-lifecycle-facts/consequences:
|
||||
en: 3761af53744aa0c7
|
||||
zh: 876cb056c248c43c
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: Persist owned job lifecycle facts
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-30-job-session-lifecycle.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
SDK consumers receive Session events, while job registration and settlement previously existed only in the live registry. A background tool can return before work finishes, and output collection can happen much later or never. Tool invocation times therefore cannot reconstruct a job's execution interval. Runtime-local ids also repeat after a registry restart.
|
||||
|
||||
## Decision
|
||||
|
||||
The local provider captures `job/registered` and `job/settled` facts for owned jobs, retaining the registry's epoch-millisecond timestamps, original owner, producer kind, label, and optional creating tool call. A UUID-backed `JobId` identifies both live controls and historical records across registry restarts and copied history. Unowned work has no Session journal.
|
||||
|
||||
The terminal record distinguishes a producer report from forced bookkeeping: `producerReported` is true when `JobHooks.done` fulfills, and false when that promise rejects or teardown cancellation throws. It reports fulfillment, not independently confirmed resource release. Missing terminal records remain incomplete and are not reconstructed from later reads or process restart.
|
||||
|
||||
Facts are appended synchronously at the registry commit before live hub reactions and completion waiters resume. Recording errors warn independently: already-started work still has its control id, and a failed journal append cannot prevent cleanup or release of waiters. The Session's ordinary persistence and SDK notification paths own delivery after append. SDK `run()` results cover bounded observation windows and can receive a settlement whose registration belongs to an earlier run; settlement records retain the registration facts for that consumer.
|
||||
|
||||
The [registry contract](../../../../packages/jobs/jobs/README.md) and [seam consolidation note](../architecture/2026-09-03-jobs-seam-consolidation.md) own live execution, resource ownership, and event routing. This journal adds durable observations without replacing those responsibilities.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Add timestamps only to `job_output`.** A model may never collect a job or may collect it after a long delay. Logging lifecycle commits records both cases without additional model calls or consuming output.
|
||||
|
||||
**Keep a reusable control id alongside an execution UUID.** Both values would identify one job. A unique `JobId` serves live controls and historical pairing.
|
||||
|
||||
**Add a launcher cleanup registry.** A separate pre-exit disposer path duplicates Cordis resource ownership and adds a launcher requirement to headless. Keeping persistence available through the final job teardown requires a separate cross-fiber lifecycle change; this journal retains the existing disposal mechanism.
|
||||
|
||||
**Defer publication or propagate recording errors.** Deferral lets a synchronous hub subscriber journal a completion notice before the settlement that caused it. Throwing after the starter succeeds can hide an already-running job from its caller. Synchronous, contained recording preserves causal order without abandoning execution.
|
||||
|
||||
## Consequences
|
||||
|
||||
Application teardown can stop Session routing or close its writer before the final job settlement reaches storage. Normal one-shot completion and signal shutdown can therefore leave incomplete history, as can hard process loss. This change does not enforce cross-fiber disposal order or synthesize a missing terminal record.
|
||||
|
||||
The log and both SDKs expose execution intervals without changing tool schemas, output cursors, or job scheduling. Model-facing controls and notices carry the unique JobId; a bounded notice points to job_list when the full id cannot fit. These intervals run from registration commit through registry settlement, including the producer work and cleanup between those points; they are neither CPU time nor a directly additive rollout-latency metric. An append failure can also leave incomplete history. Persisted facts do not resume an external process or its output ring.
|
||||
|
||||
Focused tests cover original timestamps, nested call identity, output preservation, delayed cancellation, producer rejection, forced teardown, late completion, unique control ids, causal publication order, and recording-failure containment. SDK snapshots cover the durable and protocol projections.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Agent Note: 持久化有所有者 job 的生命周期事实
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-30-job-session-lifecycle.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
SDK 消费方接收 Session 事件,而 job 注册和结算此前只存在于运行期注册表中。后台工具可能在工作结束前返回,输出收集则可能晚得多,甚至永远不发生。因此工具调用时间无法重建 job 的执行区间。运行期 id 也会在注册表重启后重复。
|
||||
|
||||
## Decision
|
||||
|
||||
本地提供方为有所有者的 job 捕获 `job/registered` 与 `job/settled` 事实,保留注册表的 Unix 毫秒时间戳、原始所有者、生产方 kind、label 和可选的创建工具调用。由 UUID 构成的 `JobId` 在注册表重启和历史复制后同时标识运行中的控制对象和历史记录。无主工作没有 Session 日志。
|
||||
|
||||
终态记录区分生产方报告与强制记账:`producerReported` 在 `JobHooks.done` 兑现时为 true,在该 promise 拒绝或销毁取消抛错时为 false。它记录兑现情况,不独立确认资源已释放。缺少终态记录时保持不完整,不根据较晚的读取或进程重启重建。
|
||||
|
||||
事实在注册表提交时同步追加,先于运行期 hub 的反应和完成等待方恢复。记录错误单独发出警告:已启动的工作仍有可控制的 id,日志追加失败不能阻止清理或释放等待方。追加后的投递由 Session 既有的持久化和 SDK 通知路径负责。SDK `run()` 结果只覆盖有界观察窗口,可能收到注册属于更早运行的结算;结算记录为这个消费方保留注册事实。
|
||||
|
||||
[注册表约定](../../../../packages/jobs/jobs/README.zh.md)和[seam 收敛说明](../architecture/2026-09-03-jobs-seam-consolidation.zh.md)负责运行期执行、资源归属和事件路由。本日志增加持久化观测,不替代这些职责。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**仅向 `job_output` 添加时间戳。** 模型可能永远不收集某个 job,也可能很晚才收集。记录生命周期提交能覆盖这两种情况,无需额外模型调用或消费输出。
|
||||
|
||||
**保留可重用的控制 id,另设执行 UUID。** 两个值会标识同一个 job。唯一的 `JobId` 同时服务运行期控制和历史配对。
|
||||
|
||||
**增加启动器清理注册表。** 独立的退出前 disposer 路径会重复 Cordis 的资源归属机制,并增加 headless 对启动器的要求。让持久化持续可用直至最后一个 job 销毁,需要单独修改跨 fiber 生命周期;本日志保留既有销毁机制。
|
||||
|
||||
**延后发布或传播记录错误。** 延后发布让同步 hub 订阅者可能先记录完成通知、后记录产生通知的结算事实。starter 成功后抛错可能使调用方拿不到已运行 job 的 id。同步并隔离记录错误既保留因果顺序,也不会放弃执行。
|
||||
|
||||
## Consequences
|
||||
|
||||
应用销毁可能在最后一条 job 结算写入存储前停止 Session 路由或关闭写句柄。因此正常的一次性运行结束、信号关停和进程硬退出都可能留下不完整历史。本次变更不强制跨 fiber 的销毁顺序,也不虚构缺失的终态记录。
|
||||
|
||||
日志与两种 SDK 暴露执行区间,不改变工具 schema、输出游标或 job 调度。模型侧控制和通知使用唯一的 JobId;有界通知无法容纳完整 id 时指向 job_list。区间从注册提交延续到注册表结算,包含这两个时刻之间的生产方工作与清理;它既不是 CPU 时间,也不是可直接相加的 rollout 延迟指标。追加失败也可能留下不完整历史。持久化事实不会恢复外部进程或其输出环。
|
||||
|
||||
定向测试覆盖原始时间戳、嵌套调用标识、输出保留、延后取消、生产方拒绝、强制销毁、较晚完成、唯一控制 id、因果发布顺序,以及记录错误隔离。SDK 快照覆盖持久化与协议投影。
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# Bilingual-pair consistency record for 2026-10-11-process-specific-prompt-values-in-runtime-context.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-10-11-process-specific-prompt-values-in-runtime-context.md
|
||||
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context:
|
||||
en: 711ce3e3afb1f905
|
||||
zh: 2d4e4aea44043c4a
|
||||
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/problem:
|
||||
en: f4e050c7dbf645da
|
||||
zh: 722bcdc409d09bec
|
||||
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision:
|
||||
en: 5222f00fd05d3801
|
||||
zh: fcfa430a0fa5588c
|
||||
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision/ownership:
|
||||
en: c23d5c27f1f160f0
|
||||
zh: 5c2464666f1e6c29
|
||||
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision/new-sessions:
|
||||
en: 1ee144c39905bb89
|
||||
zh: a6669d7ad394e41c
|
||||
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/decision/earlier-format-sessions:
|
||||
en: 27c11e7ced286898
|
||||
zh: 944d051494b1498d
|
||||
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/alternatives-considered:
|
||||
en: c324412be4034c40
|
||||
zh: 923ad612fc884be1
|
||||
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/consequences:
|
||||
en: 8aa41ff08b848916
|
||||
zh: bc1c1647ca0eb86a
|
||||
/agent-note-process-specific-checkout-path-and-web-url-move-to-runtime-context/testing:
|
||||
en: 9667d1749bdf1547
|
||||
zh: 598dc39120c91228
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
# Agent Note: Process-specific checkout path and Web URL move to runtime context
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-11-process-specific-prompt-values-in-runtime-context.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh web` wrote two process-specific values directly into the system prompt: the implementation checkout path in the `harness:source` section (four directories above the web-app module) and the Web GUI URL in the `app:web-surface` section (the bound port or `publicUrl`). These operations change them without the user perceiving a system-prompt change:
|
||||
|
||||
| Operation | Changed value |
|
||||
|---|---|
|
||||
| Every Desktop launch (`--port 0` picks a random port) | Web URL |
|
||||
| A different `--port`, or alternating A/B checkouts | Web URL, checkout path |
|
||||
| npm and Desktop installations sharing one `DSH_HOME` | Checkout path |
|
||||
|
||||
On resume the loop renders the prompt again. On a route without `systemPromptUpdate: 'in-history'`, such as a hand-declared pi-ai `anthropic-messages` route, the changed prompt rewrites the surface head node. Claude binds historical thinking-block signatures to the conversation prefix, system prompt included, so the upstream answers 400 (`Invalid signature … bound to a different conversation`) and the Session cannot continue until the environment is restored. In-history routes do not fail, but every change appends a complete prompt.
|
||||
|
||||
## Decision
|
||||
|
||||
Both values become runtime context and the system prompt keeps only value-free guidance; a Session whose log already holds the earlier format keeps its logged paragraphs.
|
||||
|
||||
### Ownership
|
||||
|
||||
| Package | Responsibility |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-harness-dev-context` (`packages/context/harness-dev-context`) | Both sections, both contexts, and the earlier-format projection; all module-private, exporting only the plugin protocol (`name`, `inject`, `apply`) |
|
||||
| `@deepseek-ai/dsh-host-webserver` | `WebServer.getOrigin()` (the bind address's URL form with protocol and bound port) and `bindUrlHost(host)` |
|
||||
| `@deepseek-ai/dsh-web-app` | The `web-runtime` row provides `webApp`, whose `getAdvertisedUrl()` returns the configured `publicUrl`, otherwise the listener origin, the same value as `DSH_WEB_URL` and the startup URL; the bundle patch mounts the `harness-dev-context` row |
|
||||
| `@deepseek-ai/dsh-system-prompt` | `CONTEXT_ORDERS.HARNESS_SOURCE = 200` and `WEB_SURFACE = 210` |
|
||||
|
||||
The plugin injects `systemPrompt`, `sessionProjections`, and `webApp` and reads those services in code; its patch row carries no configuration expression. The checkout path is four directories above this package's source or built entry, the same depth as the former web-app location, so source, npm, and Desktop installations resolve the same root. The URL is `webApp.getAdvertisedUrl()`, so a profile that overrides the `web-runtime` row's `publicUrl` reaches the model as well. The plugin reaches the `webApp` type through `import type {} from '@deepseek-ai/dsh-web-app'`; the import is erased at runtime, so the web-app bundle's dependency on this package remains the only runtime edge.
|
||||
|
||||
### New Sessions
|
||||
|
||||
| Registration | Content |
|
||||
|---|---|
|
||||
| Section `harness:source` (the `HARNESS_SOURCE` slot) | `The runtime context names the DeepSeek Harness implementation checkout. …`, the remaining guidance unchanged |
|
||||
| Context `harness:source` (`CONTEXT_ORDERS.HARNESS_SOURCE = 200`) | `Current DeepSeek Harness implementation checkout: "<path>".` |
|
||||
| Section `app:web-surface` (the `WEB_SURFACE` slot) | `You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. …`, the remaining guidance unchanged |
|
||||
| Context `app:web-surface` (`CONTEXT_ORDERS.WEB_SURFACE = 210`) | `Current DeepSeek Harness Web GUI URL: "<url>".` |
|
||||
|
||||
Both contexts are optional (no `required`). The only complete persona, web-app's minimal preset, also disables optional runtime context, so it still sees neither value. Runtime context follows the existing mechanism: an unchanged value emits no snapshot, a change appends a complete snapshot before the step's user message, and resume compares against the latest logged snapshot, never rewriting a committed message.
|
||||
|
||||
### Earlier-format Sessions
|
||||
|
||||
The plugin registers the host-only session projection `harnessDevLegacyPrompt`, folding every nonempty `system/message` in event order into `{ source, web }`: the paragraphs of that prompt that match an earlier template verbatim, or `null`.
|
||||
|
||||
| Earlier template | Matching rule |
|
||||
|---|---|
|
||||
| `harness:source` | Starts with `The DeepSeek Harness implementation checkout is at `, ends with the earlier fixed guidance, no line break between |
|
||||
| `app:web-surface` | Starts with `You are interacting with the user through the DeepSeek Harness Web GUI at `, ends with `. ` plus the guidance as earlier releases rendered it, frozen as a literal, no line break between |
|
||||
|
||||
Both section text providers read the projection state of `AssembleContext.agent.session`: with an earlier paragraph they return it verbatim (`interpolate: false`), otherwise the current guidance. Diagnostic assemblies without an agent use the current guidance. `sessionProjections` is a required injection and the plugin registers its own unit, so the state is readable whenever an agent exists.
|
||||
|
||||
An earlier-format Session therefore satisfies both requirements:
|
||||
|
||||
| Scenario | System prompt | Runtime context |
|
||||
|---|---|---|
|
||||
| First resume after upgrading, same port | Byte-identical to the log; the loop emits no `system/message` | Appends a snapshot with both current values |
|
||||
| Port, slot, or installation change after upgrading | Still the logged earlier paragraphs | Appends a snapshot with the new values |
|
||||
|
||||
The projection computes only on each committed `system/message`, adding no synchronous read of historical events; its state persists with projection checkpoints. When compaction or a new request series rewrites the head, the rendering still contains the same earlier paragraphs, so the projection state does not change.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Move both values to runtime context without keeping earlier paragraphs.** The upgrade itself changes the prompt text, so every existing Claude Session rewrites its head and fails with 400 on its first turn after the upgrade.
|
||||
|
||||
**Offer `supportsMidConvoSystemMessages` on hand-declared Anthropic routes.** The upstream accepts mid-conversation system messages in a live probe, but the pi-ai catalog already supplies that capability for named Anthropic routes and hand-declared routes withhold catalog fields by convention; in-history routes would still append a complete prompt on every value change.
|
||||
|
||||
**Ask the upstream to drop mismatched thinking blocks (`prefix_mismatch_behavior: "drop_block"`).** It silently loses historical reasoning and covers one vendor.
|
||||
|
||||
**Freeze the whole system prompt per Session.** Legitimate prompt changes such as plan mode, skills, and tool guidance would also be frozen and never reach the model.
|
||||
|
||||
**Keep the implementation in web-app and app-boot.** Development-facing context and earlier-format compatibility would enlarge the public API of two core packages; app-boot's `addHarnessSourceSection` had web-app as its only caller.
|
||||
|
||||
**Read `webStartup.publicUrl`.** It carries only the `--public-url` flag, so a profile override of the `web-runtime` row's `publicUrl` would reach `DSH_WEB_URL` but not the model; the effective value exists only inside web-app, which now provides it as `webApp`.
|
||||
|
||||
**Read the head node text from Session history directly.** The synchronous arbitrary-history read APIs are deprecated and new calls are prohibited; a projection is the established way to obtain that state after resume.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A new Session's system prompt no longer changes with the port, slot, or installation; both current values arrive as runtime-context snapshots, and a change appends a few lines.
|
||||
- An earlier-format Session keeps the old values of its earlier paragraphs for its whole lifetime; the later `Current …` runtime context in the same request gives the current values.
|
||||
- Only these two paragraphs are protected. When other prompt text changes between releases, routes without in-history updates still rewrite the head.
|
||||
- A release without this fix rewrites the head of a new-format Session with the earlier template on resume; both A/B checkouts need this fix before they alternate.
|
||||
- app-boot no longer exports `addHarnessSourceSection` or `HARNESS_SOURCE_SECTION`; web-app's `surfaceContext` controls only `DSH_WEB_URL`, and disabling the `harness-dev-context` row turns both contexts off.
|
||||
- Runtime context in recorded Web e2e Sessions gains two lines; the test scaffold normalizes the URL and checkout path to placeholders.
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/context/harness-dev-context/tests/harness-dev-context.spec.ts`: on a production AgentLoop, an earlier-format Session resumed on another port emits no `system/message`, the request's system prompt is byte-identical to the log, and runtime context carries the current URL; a second port change keeps this; replace-only and in-history routes each run. When a series consolidation only empties a later version, the next turn keeps the earlier paragraphs. Similar text without the fixed ending or broken across lines receives the current guidance. A new Session keeps its system prompt across a port change and only appends a runtime-context snapshot. Sections carry no values, the context carries the advertised URL, and disposing the plugin fiber removes every registration.
|
||||
- `packages/host/webserver/tests/webserver.spec.ts`: `bindUrlHost` across address forms; `getOrigin()` on HTTP and HTTPS listeners, and its errors before binding and for a zoned non-loopback address.
|
||||
- `packages/bundle/web-app/tests/web-app.spec.ts`: `webApp.getAdvertisedUrl()` returns the listener origin, or the row's configured `publicUrl`.
|
||||
- `packages/core/system-prompt/tests/system-prompt.spec.ts`: the added context positions are unique integers.
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
# Agent Note: 进程相关的 checkout 路径与 Web URL 改走运行时上下文
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-11-process-specific-prompt-values-in-runtime-context.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
`dsh web` 曾把两项随进程变化的值直接写进 system prompt:`harness:source` 段里的实现 checkout 路径(web-app 模块位置上溯四层),以及 `app:web-surface` 段里的 Web GUI URL(实际监听端口或 `publicUrl`)。下列操作都会改变它们,而用户感知不到 system prompt 变了:
|
||||
|
||||
| 操作 | 变化的值 |
|
||||
|---|---|
|
||||
| Desktop 每次启动(`--port 0` 取随机端口) | Web URL |
|
||||
| 换 `--port`、A/B 两个 checkout 交替运行 | Web URL、checkout 路径 |
|
||||
| npm 安装与 Desktop 安装共用同一个 `DSH_HOME` | checkout 路径 |
|
||||
|
||||
会话恢复时 loop 重新渲染 prompt。对不声明 `systemPromptUpdate: 'in-history'` 的路由(例如手写的 pi-ai `anthropic-messages` 路由),变化的 prompt 会改写 surface 头节点。Claude 的历史 thinking 块签名绑定包括 system prompt 在内的对话前缀,前缀一变上游返回 400(`Invalid signature … bound to a different conversation`),会话在环境恢复原样之前无法继续。声明 in-history 的路由不报错,但每次变化都追加一份完整 prompt。
|
||||
|
||||
## 决定
|
||||
|
||||
两项值改为运行时上下文,system prompt 只保留不含值的说明;已经按旧格式写入日志的会话继续沿用日志里的旧段落。
|
||||
|
||||
### 归属
|
||||
|
||||
| 包 | 职责 |
|
||||
|---|---|
|
||||
| `@deepseek-ai/dsh-harness-dev-context`(`packages/context/harness-dev-context`) | 两个 section、两个 context、旧格式投影;全部模块私有,只导出插件协议(`name`、`inject`、`apply`) |
|
||||
| `@deepseek-ai/dsh-host-webserver` | `WebServer.getOrigin()`(绑定地址的 URL 形式加协议与实际端口)与 `bindUrlHost(host)` |
|
||||
| `@deepseek-ai/dsh-web-app` | `web-runtime` 行提供 `webApp`,其 `getAdvertisedUrl()` 返回配置的 `publicUrl`,否则返回监听器 origin,与 `DSH_WEB_URL` 和启动 URL 同值;bundle patch 挂载 `harness-dev-context` 行 |
|
||||
| `@deepseek-ai/dsh-system-prompt` | `CONTEXT_ORDERS.HARNESS_SOURCE = 200`、`WEB_SURFACE = 210` |
|
||||
|
||||
插件 `inject` 为 `systemPrompt`、`sessionProjections`、`webApp`,在代码里直接读取这些服务;patch 行不写任何配置表达式。checkout 路径取本包源码或构建入口上溯四层,与原 web-app 位置同深度,源码、npm 与 Desktop 安装下结果不变。URL 取 `webApp.getAdvertisedUrl()`,因此 profile 覆盖 `web-runtime` 行的 `publicUrl` 时模型也能看到。插件通过 `import type {} from '@deepseek-ai/dsh-web-app'` 取得 `webApp` 类型;该导入在运行时被擦除,因此 web-app bundle 对本包的依赖仍是唯一的运行时依赖。
|
||||
|
||||
### 新会话
|
||||
|
||||
| 注册 | 内容 |
|
||||
|---|---|
|
||||
| section `harness:source`(`HARNESS_SOURCE` 位) | `The runtime context names the DeepSeek Harness implementation checkout. …`,其余说明不变 |
|
||||
| context `harness:source`(`CONTEXT_ORDERS.HARNESS_SOURCE = 200`) | `Current DeepSeek Harness implementation checkout: "<path>".` |
|
||||
| section `app:web-surface`(`WEB_SURFACE` 位) | `You are interacting with the user through the DeepSeek Harness Web GUI whose URL the runtime context names. …`,其余说明不变 |
|
||||
| context `app:web-surface`(`CONTEXT_ORDERS.WEB_SURFACE = 210`) | `Current DeepSeek Harness Web GUI URL: "<url>".` |
|
||||
|
||||
两个 context 都是可选项(不设 `required`)。唯一的 complete persona(web-app 的 minimal 预设)同时关闭了可选运行时上下文,因此它仍然看不到这两项。运行时上下文按既有机制工作:值不变不发新快照,变化时在本轮 user 消息前追加完整快照,恢复时从会话日志取上一份快照比较,不改写任何已提交消息。
|
||||
|
||||
### 旧格式会话
|
||||
|
||||
插件注册仅限宿主的会话投影 `harnessDevLegacyPrompt`,按事件顺序折叠每个非空 `system/message`,状态为 `{ source, web }`:该 prompt 中按旧模板逐字匹配到的段落,未匹配为 `null`。
|
||||
|
||||
| 旧模板 | 匹配规则 |
|
||||
|---|---|
|
||||
| `harness:source` | `The DeepSeek Harness implementation checkout is at ` 开头、旧版固定说明结尾、中间不含换行 |
|
||||
| `app:web-surface` | `You are interacting with the user through the DeepSeek Harness Web GUI at ` 开头、`. ` 加早期版本渲染的说明结尾(冻结为字面量)、中间不含换行 |
|
||||
|
||||
两个 section 的文本提供函数读取 `AssembleContext.agent.session` 的投影状态:有旧段落时原样返回该段落(`interpolate: false`),否则返回新说明。没有 agent 的诊断性组装使用新说明。`sessionProjections` 是插件的必需注入,投影单元由插件自己注册,因此存在 agent 时状态必然可读。
|
||||
|
||||
旧会话因此满足两点:
|
||||
|
||||
| 场景 | system prompt | 运行时上下文 |
|
||||
|---|---|---|
|
||||
| 升级后首次恢复,端口不变 | 与日志逐字相同,loop 不产生 `system/message` | 追加含两项当前值的快照 |
|
||||
| 升级后换端口、slot 或安装位置 | 仍是日志里的旧段落 | 追加新值快照 |
|
||||
|
||||
投影只在每次提交的 `system/message` 上计算,不新增同步读取历史事件的调用;状态随投影检查点持久化。压缩或新请求序列重写头节点时,渲染结果仍含同一旧段落,投影状态不变。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**只把两项值移到运行时上下文,不保留旧段落。** 升级本身会改变 prompt 文本,所有既有 Claude 会话在升级后第一轮改写头节点并报 400。
|
||||
|
||||
**为手写 Anthropic 路由开放 `supportsMidConvoSystemMessages`。** 上游实测接受对话中途的 system 消息,但 pi-ai 目录已为具名 Anthropic 路由提供该能力,手写路由按既有约定不开放目录字段;而且 in-history 路由每次值变化仍追加一份完整 prompt。
|
||||
|
||||
**请求上游丢弃不匹配的 thinking 块(`prefix_mismatch_behavior: "drop_block"`)。** 会静默丢失历史推理,且只覆盖一个供应商。
|
||||
|
||||
**会话内冻结整份 system prompt。** plan 模式、技能和工具说明等合法的 prompt 变化也会被冻结,无法到达模型。
|
||||
|
||||
**留在 web-app 与 app-boot 中实现。** 面向开发的上下文与旧格式兼容逻辑会扩大两个核心包的公开 API;app-boot 的 `addHarnessSourceSection` 当时只有 web-app 一个调用方。
|
||||
|
||||
**读取 `webStartup.publicUrl`。** 它只携带 `--public-url` 参数,profile 覆盖 `web-runtime` 行的 `publicUrl` 时只会影响 `DSH_WEB_URL`,到不了模型;生效值只存在于 web-app 内部,现在由它以 `webApp` 提供。
|
||||
|
||||
**直接读取会话历史中的头节点文本。** 同步读取任意历史事件的 API 已弃用,新调用被禁止;投影是恢复后取得该状态的既定方式。
|
||||
|
||||
## 后果
|
||||
|
||||
- 新会话的 system prompt 不再随端口、slot 或安装位置变化;两项当前值以运行时上下文快照送达,变化时只追加几行。
|
||||
- 旧会话在整个生命周期内保留旧段落中的旧值;同一请求里更晚的 `Current …` 运行时上下文给出当前值。
|
||||
- 只保护这两段。其他提示文本在版本之间变化时,不支持 in-history 的路由仍会改写头节点。
|
||||
- 不含本修复的旧版本恢复新格式会话时会按旧模板改写头节点;A/B 两个 checkout 都需包含本修复后再交替运行。
|
||||
- app-boot 不再导出 `addHarnessSourceSection` 与 `HARNESS_SOURCE_SECTION`;web-app 的 `surfaceContext` 只控制 `DSH_WEB_URL`,关闭这两项上下文改为禁用 `harness-dev-context` 行。
|
||||
- Web e2e 录制会话里的运行时上下文多出两行;测试脚手架把 URL 与 checkout 路径规范化为占位符。
|
||||
|
||||
## 测试
|
||||
|
||||
- `packages/context/harness-dev-context/tests/harness-dev-context.spec.ts`:真实 AgentLoop 上,旧格式会话在另一端口恢复时不产生 `system/message`、请求中的 system prompt 与日志逐字相同、运行时上下文携带当前 URL;再换一次端口仍成立;不支持与支持 in-history 的路由各跑一遍。series 合并只清空后续版本时,下一轮仍沿用旧段落。缺少固定结尾或跨行的相似文本得到新说明。新会话换端口后 system prompt 不变、只追加运行时上下文快照。section 不含值、context 携带公告 URL,销毁插件 fiber 后全部注册消失。
|
||||
- `packages/host/webserver/tests/webserver.spec.ts`:`bindUrlHost` 各地址形式;`getOrigin()` 在 HTTP 与 HTTPS 监听上的值,绑定前与带 zone 的非回环地址抛错。
|
||||
- `packages/bundle/web-app/tests/web-app.spec.ts`:`webApp.getAdvertisedUrl()` 返回监听器 origin,或该行配置的 `publicUrl`。
|
||||
- `packages/core/system-prompt/tests/system-prompt.spec.ts`:新增的 context 位置唯一且为整数。
|
||||
+14
-14
@@ -9,29 +9,29 @@
|
||||
en: 311bc04b9c36ab53
|
||||
zh: 0c52e6493e9350a5
|
||||
/agent-note-configure-subagent-persona-tool-visibility-and-depth/decision:
|
||||
en: 79b3fcfaaaaccf9e
|
||||
zh: 0287bfb41c7b9707
|
||||
en: 45111f900708aaf9
|
||||
zh: e75bed4951516963
|
||||
/agent-note-configure-subagent-persona-tool-visibility-and-depth/decision/persona-is-a-scoped-shadow:
|
||||
en: 8b299c99d26aa5c3
|
||||
zh: 6956efbd236cef8e
|
||||
en: f9ee31a1fdcdd38f
|
||||
zh: c9d05721cef80659
|
||||
/agent-note-configure-subagent-persona-tool-visibility-and-depth/decision/tool-filtering-is-one-live-global-view-rule:
|
||||
en: a9fe46283ea28722
|
||||
zh: 7b7d1be7c070b893
|
||||
/agent-note-configure-subagent-persona-tool-visibility-and-depth/decision/depth-is-an-absolute-tree-cap:
|
||||
en: 9145bec4e3f7eb5b
|
||||
zh: 986276925cb064d9
|
||||
en: c1d92ec60342c8b7
|
||||
zh: 063c9eaba331312b
|
||||
/agent-note-configure-subagent-persona-tool-visibility-and-depth/decision/capability-gating-keeps-providers-honest:
|
||||
en: 5334afafefdb0f59
|
||||
zh: 7e96c9a08bbc3d1a
|
||||
en: 1669c3372b728fc8
|
||||
zh: 349ee461c9307811
|
||||
/agent-note-configure-subagent-persona-tool-visibility-and-depth/decision/unpublished-setup-makes-the-first-request-correct:
|
||||
en: 3c988dfff07c8da3
|
||||
zh: 8ce2fbcc438e7dd8
|
||||
en: 32b0648b901c49ad
|
||||
zh: b72f29a8be987479
|
||||
/agent-note-configure-subagent-persona-tool-visibility-and-depth/visibility-is-not-authority:
|
||||
en: d0db5310a87ff8e4
|
||||
zh: 4b9f502238cd4bae
|
||||
/agent-note-configure-subagent-persona-tool-visibility-and-depth/alternatives-considered:
|
||||
en: 78ae41331d8fcc3e
|
||||
zh: a93ee10b3e30c267
|
||||
en: cf5006aa2b345a37
|
||||
zh: f26a9a1ab9a83a46
|
||||
/agent-note-configure-subagent-persona-tool-visibility-and-depth/consequences:
|
||||
en: d5f05d63b7a01dd8
|
||||
zh: f0be1e88b06d8cc9
|
||||
en: 58a53e46a6ce8307
|
||||
zh: 6e552d8b6ff4b186
|
||||
+12
-10
@@ -12,7 +12,7 @@ These controls affect the child's first model request and therefore cannot be in
|
||||
|
||||
## Decision
|
||||
|
||||
Subagent starts have three independent composition controls: `persona`, `toolFilter`, and `maxDepth`. A provider advertises support for each control, the service rejects unsupported requests before starting a run, and an in-process provider installs the requested composition while the child is still unpublished.
|
||||
This note retains the depth policy and the persona/tool-filter semantics needed to restore historical child descriptors. New children use [preset-owned composition](../simplification/2026-10-09-preset-owned-subagent-composition.md); new requests do not accept persona or tool filters. Historical overrides are applied while a resumed child is unpublished.
|
||||
|
||||
The controls answer different questions:
|
||||
|
||||
@@ -22,16 +22,18 @@ The controls answer different questions:
|
||||
| `toolFilter` | Which deployment-global tools enter this child's visible tool view? | A scoped restriction filters globals before child-local tools are added |
|
||||
| `maxDepth` | How deep may this delegation tree grow? | A start whose child depth exceeds the absolute cap is rejected |
|
||||
|
||||
`dsh-tool-subagent` exposes the controls as plugin configuration and copies them into each request it creates. Direct `SubagentRuntime` callers may choose them per request. The provider capability descriptor remains the source of truth for whether a backend can honor each field.
|
||||
`dsh-tool-subagent` and direct `SubagentRuntime` callers expose `maxDepth`. Its provider capability determines whether a backend can enforce the cap. Persona and tool-filter values come only from historical descriptors during cold resume.
|
||||
|
||||
### Persona is a scoped shadow
|
||||
|
||||
The persona control changes one child without changing deployment-wide prompt assembly. During unpublished setup, an in-process provider registers a child-scoped section named `deployment:persona-prefix`; ordinary most-specific-wins resolution replaces the global section only in that child's assemblies.
|
||||
A historical persona changes one resumed child without changing deployment-wide prompt assembly. During unpublished setup, the manager registers a child-scoped section named `deployment:persona-prefix`; ordinary most-specific-wins resolution replaces the inherited section only in that child’s assemblies.
|
||||
|
||||
The value has the same strict template semantics as the deployment persona. Omitting it inherits the deployment section through the global layer; an explicit empty string shadows the global persona with an empty section. Parent and sibling personas never enter the child's flat scope.
|
||||
The value has the same strict template semantics as the deployment persona. Omitting it preserves the composed persona; an explicit empty string shadows that persona with an empty section.
|
||||
|
||||
This uses the normal system-prompt registration mechanism rather than a second persona channel. The first prompt therefore sees the same named contribution that later prompts and prompt-inspection tools see.
|
||||
|
||||
<a id="tool-filtering-is-one-live-global-view-rule"></a>
|
||||
|
||||
### Tool filtering is one live global-view rule
|
||||
|
||||
The tool filter controls capability visibility and executable lookup together. An in-process provider installs `ToolRuntime.restrict()` in the child's scope before publication, and the registry's single resolver applies the same result to wire tool schemas, lookup, execution, and PTC mode SDK generation. Independently registered system-prompt sections remain owned by their plugins. The filesystem, search, and web tool plugins use the existing `PromptSection.text({ scope })` callback and `ctx.tools.get(name, scope)` to omit guidance for unavailable tools and select applicable cross-tool text. This keeps the original wording and ordering for a supported tool set and works for any agent scope, including underlying PTC capabilities whose wire presentation is `run_code`. It adds no section-ownership metadata or assembly pass; unrelated static prose is not automatically rewritten by `restrict()`.
|
||||
@@ -53,19 +55,19 @@ The depth limit bounds recursive delegation independently of tool visibility. A
|
||||
|
||||
The effective parent depth is the greater of durable `SessionHeader.delegationDepth` and runtime `AgentOptions.subagentDepth`. An in-process child records its derived depth in the session header, and resume restores that header, so a restart cannot lower the recursion count.
|
||||
|
||||
Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; loader-resolved `dsh-tool-subagent` configuration instead defaults to `3`, accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. Three is a small finite default that still permits a root plus three descendant generations, and the [base profile](../../../../packages/bundle/base/cordis.patch.yml) uses that general policy. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`.
|
||||
Every public entry validates the domain rather than relying on one model-facing configuration path. Negative values, fractions, negative zero, non-finite values, unsafe integers, malformed stored parent depth, and derived overflow all reject. A direct `SubagentStartRequest` may omit the cap to leave depth unbounded; `dsh-tool-subagent` instead reads the live Host depth setting (initially `1`), accepts a numeric override, and uses explicit `'provider-managed'` to omit the cap for an out-of-process provider whose deployment owns its recursion budget. The [base profile](../../../../packages/bundle/base/cordis.patch.yml) uses the Host setting. A numeric tool cap fails at provider mount when the provider lacks `depthLimit`.
|
||||
|
||||
A deployment can combine depth and filtering, but the numeric cap does not synthesize a filter. The delegation tool stays visible at the cap because authorization may depend on runtime state; every attempted start checks the calling agent's current durable and runtime depth, and a rejected start returns an errored tool result without publishing a child. A deployment may separately deny delegation tools in children when its visibility policy is static. Neither choice changes the provider's conversation-history behavior.
|
||||
The numeric cap does not synthesize a filter. The delegation tool stays visible at the cap because authorization may depend on runtime state; every attempted start checks the calling agent’s current durable and runtime depth, and a rejected start returns an errored tool result without publishing a child. A preset can omit delegation tool plugins independently of the depth policy. At the cap, the tool contributes an optional runtime-context notice telling the model to work directly. `includeRuntimeContext: false` suppresses this advisory notice; the runtime depth guard remains active. When enabled, the notice reads the live cap and durable depth at request time to keep resumed and reconfigured agents informed without changing their system prompt or tool schemas; unchanged context is not repeatedly appended.
|
||||
|
||||
### Capability gating keeps providers honest
|
||||
|
||||
Capabilities separate a requested feature from a provider implementation. `SubagentCapabilities` advertises `persona`, `toolFilter`, and `depthLimit`; `SubagentRuntime.start()` checks every present request field against those flags before calling the provider.
|
||||
Capabilities separate a requested feature from a provider implementation. `SubagentCapabilities.depthLimit` advertises depth enforcement; `SubagentRuntime.startActivation()` checks a requested cap before calling the provider.
|
||||
|
||||
This lets spawn and fork providers share the in-process implementation while external providers advertise only what they can enforce. A request never degrades silently: selecting an unsupported control produces `UNSUPPORTED_CAPABILITY`, and no run or lifecycle event exists.
|
||||
|
||||
### Unpublished setup makes the first request correct
|
||||
|
||||
All child-local composition is complete before the child becomes observable. The in-process provider supplies one setup callback to agent creation; that callback installs persona, tool restriction, and structured-output contributions in the child's scope. Only after setup succeeds does creation publish the session and agent and allow the driver to start.
|
||||
All child-local composition is complete before the child becomes observable. The manager supplies a setup callback to agent creation that composes the preset, restores historical persona and tool restrictions on cold resume, and installs structured output. Only after setup succeeds does creation publish the session and agent and allow the driver to start.
|
||||
|
||||
A setup failure rolls back the private child. No observer can acquire a child whose first prompt used the deployment persona or unfiltered tool set and whose later prompts use the requested configuration.
|
||||
|
||||
@@ -79,7 +81,7 @@ A security design would need a separate authority representation, propagation ru
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Create one provider per persona or tool set.** This multiplies providers that share the same transport and lifecycle implementation, makes dynamic deployment configuration awkward, and still needs a recursion mechanism. Providers remain about execution transport; requests carry per-child composition.
|
||||
**Create one provider per persona or tool set.** This multiplies providers that share the same transport and lifecycle implementation and still needs a recursion mechanism. Providers remain about execution transport; presets own new child composition, while descriptors retain historical overrides.
|
||||
|
||||
**Copy the parent's complete tool view.** Registration scope is flat by design, and lifetime ownership does not imply visibility inheritance. Copying a resolved view would also freeze dynamic global registrations and conflate composition with authority without defining either contract fully.
|
||||
|
||||
@@ -91,6 +93,6 @@ A security design would need a separate authority representation, propagation ru
|
||||
|
||||
## Consequences
|
||||
|
||||
Contributors can configure child role, visible global tools, and recursion without defining new providers. Capability checks fail before ownership starts, unpublished setup makes the first request consistent, and one tool resolver prevents presentation/execution drift.
|
||||
Historical child composition survives cold resume without defining new providers. Capability checks fail before ownership starts, unpublished setup makes the first request consistent, and one tool resolver prevents presentation/execution drift.
|
||||
|
||||
The cost is that deployments must understand live allow/deny behavior and the distinction between visibility and authority. A model may call a visible delegation tool after the current depth policy forbids another child and receive an error. Provider authors must advertise each supported control accurately, and in-process providers must install every requested contribution before publication. The controls deliberately do not solve security confinement or parent-to-child non-escalation.
|
||||
+10
-10
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `maxDepth`。提供方声明对每个控制的支持情况,服务在启动运行之前拒绝不受支持的请求,进程内提供方在子 agent 尚未发布时安装所请求的组合。
|
||||
本记录保留深度策略,以及恢复历史子级 descriptor 所需的 persona/工具过滤语义。新子级使用 [preset 拥有的组装](../simplification/2026-10-09-preset-owned-subagent-composition.zh.md);新请求不接受 persona 或工具过滤。历史覆盖在恢复的子级尚未发布时应用。
|
||||
|
||||
这些控制回答不同的问题:
|
||||
|
||||
@@ -22,13 +22,13 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma
|
||||
| `toolFilter` | 部署全局工具中哪些进入该子 agent 的可见工具视图? | 一个有作用域的限制在添加子 agent 局部工具之前过滤全局工具 |
|
||||
| `maxDepth` | 这棵委派树最深可以长到多少层? | 子 agent 深度超过绝对上限时,启动请求被拒绝 |
|
||||
|
||||
`dsh-tool-subagent` 将这些控制作为插件配置暴露,并复制到它创建的每个请求中。直接调用 `SubagentRuntime` 的调用方可以按请求选择这些控制。提供方的能力描述符仍然是后端能否兑现各字段的真源。
|
||||
`dsh-tool-subagent` 和直接调用 `SubagentRuntime` 的调用方公开 `maxDepth`。对应的提供方能力决定后端是否能强制执行上限。persona 和工具过滤值仅在冷恢复时来自历史 descriptor。
|
||||
|
||||
### 人设是有作用域的遮蔽
|
||||
|
||||
人设控制改变一个子 agent 的行为,而不改变部署级的提示词组装。在未发布的设置阶段,进程内提供方在子 agent 作用域中注册一个名为 `deployment:persona-prefix` 的段落;普通的最具体者优先解析规则仅在该子 agent 的组装中替换全局段落。
|
||||
历史 persona 改变一个恢复子级的行为,而不改变部署级提示词组装。在未发布的设置阶段,管理器注册子级作用域中名为 `deployment:persona-prefix` 的段落;普通的最具体者优先解析规则只在该子级的组装中替换继承段落。
|
||||
|
||||
其值与部署人设具有相同的严格模板语义。省略时通过全局层继承部署段落;显式空字符串则以空段落遮蔽全局人设。父级和兄弟级的人设永远不会进入子 agent 的扁平作用域。
|
||||
该值与部署 persona 具有相同的严格模板语义。省略时保留组装的 persona;显式空字符串则以空段落遮蔽该 persona。
|
||||
|
||||
这使用的是常规的系统提示词注册机制,而非第二条人设通道。因此第一次提示词看到的命名贡献与后续提示词和提示词检查工具看到的一致。
|
||||
|
||||
@@ -55,19 +55,19 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma
|
||||
|
||||
有效父级深度取持久 `SessionHeader.delegationDepth` 与运行时 `AgentOptions.subagentDepth` 中的较大值。进程内子 agent 把推导出的深度记录在会话 header 中,恢复时会重新载入该 header,因此重启无法降低递归计数。
|
||||
|
||||
每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `SubagentStartRequest` 可以省略上限,让此机制不约束深度;经 loader 解析的 `dsh-tool-subagent` 配置则默认值为 `3`、接受数值覆盖,并使用显式的 `'provider-managed'` 来省略由进程外提供方部署拥有递归预算时的上限。三是一个较小的有限默认值,仍允许 root 加三代后代,[base profile](../../../../packages/bundle/base/cordis.patch.yml)采用这项通用策略。提供方缺少 `depthLimit` 时,数值工具上限会在提供方挂载阶段失败。
|
||||
每个公开入口都自行验证值域,而非依赖单一的面向模型配置路径。负值、小数、负零、非有限值、不安全整数、格式错误的存储父级深度以及推导溢出均被拒绝。直接的 `SubagentStartRequest` 可以省略上限,让此机制不约束深度;`dsh-tool-subagent` 则读取实时 Host 深度设置(初始值为 `1`)、接受数值覆盖,并使用显式的 `'provider-managed'` 来省略由进程外提供方部署拥有递归预算时的上限。[base profile](../../../../packages/bundle/base/cordis.patch.yml)采用 Host 设置。提供方缺少 `depthLimit` 时,数值工具上限会在提供方挂载阶段失败。
|
||||
|
||||
部署可以组合深度与过滤,但数值上限不会合成过滤器。委派工具在上限处仍然可见,因为授权可能依赖运行时状态;每次尝试启动都会检查调用方 agent 当前的持久与运行时深度,被拒绝的启动返回错误工具结果,且不发布子 agent。可见性策略固定的部署可以另外在子 agent 中 deny 委派工具。两种选择都不改变提供方的对话历史行为。
|
||||
数值上限不会合成过滤器。委派工具在上限处仍然可见,因为授权可能依赖运行时状态;每次尝试启动都会检查调用方 agent 当前的持久与运行时深度,被拒绝的启动返回错误工具结果,且不发布子 agent。preset 可独立于深度策略省略委派工具插件。达到上限时,工具贡献一条可选的运行时上下文提示,让模型直接完成工作。`includeRuntimeContext: false` 会抑制这条建议性提示;运行时的深度拦截仍然生效。提示启用时,每次请求读取实时上限与持久深度,使恢复会话或调整配置后的 agent 获知当前限制,同时不改变其系统提示词或工具 schema;未变化的上下文不会重复追加。
|
||||
|
||||
### 能力门控保持提供方诚实
|
||||
|
||||
能力将请求的功能与提供方实现分离。`SubagentCapabilities` 声明 `persona`、`toolFilter` 和 `depthLimit`;`SubagentRuntime.start()` 在调用提供方之前,对照这些标志检查请求中每个存在的字段。
|
||||
能力将请求的功能与提供方实现分离。`SubagentCapabilities.depthLimit` 声明深度强制执行能力;`SubagentRuntime.startActivation()` 在调用提供方前检查请求的上限。
|
||||
|
||||
这使 spawn 和 fork 提供方可以共享进程内实现,而外部提供方只声明自己能强制执行的部分。请求永远不会静默降级:选择不受支持的控制会产生 `UNSUPPORTED_CAPABILITY`,不会有运行或生命周期事件存在。
|
||||
|
||||
### 未发布设置使第一次请求正确
|
||||
|
||||
所有子 agent 局部的组合在子 agent 变得可观察之前完成。进程内提供方向 agent 创建提供一个设置回调;该回调在子 agent 作用域中安装人设、工具限制和结构化输出贡献。只有设置成功后,创建才发布会话和 agent 并允许驱动器启动。
|
||||
所有子级局部组装均在子级可观察前完成。管理器向 agent 创建提供设置回调,组装 preset,在冷恢复时恢复历史 persona 和工具限制,并安装结构化输出。只有设置成功后才发布会话和 agent,并允许驱动器启动。
|
||||
|
||||
设置失败会回滚私有子 agent。没有观察者能获取到一个「第一次提示词使用了部署人设或未过滤工具集、后续提示词才使用所请求配置」的子 agent。
|
||||
|
||||
@@ -81,7 +81,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**为每种人设或工具集创建一个提供方。** 这会使共享相同传输和生命周期实现的提供方成倍增加,使动态部署配置变得笨拙,且仍需要递归机制。提供方的职责是执行传输;请求承载每个子 agent 的组合。
|
||||
**为每种 persona 或工具集创建一个提供方。** 这会使共享同一传输和生命周期实现的提供方成倍增加,且仍需要递归机制。提供方负责执行传输;preset 拥有新子级组装,descriptor 保留历史覆盖。
|
||||
|
||||
**复制父级的完整工具视图。** 注册作用域设计上是扁平的,生命周期所有权不意味着可见性继承。复制已解析视图还会冻结动态全局注册,并在未完整定义任一约定的情况下混淆组合与授权。
|
||||
|
||||
@@ -93,6 +93,6 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma
|
||||
|
||||
## 后果
|
||||
|
||||
贡献者可以配置子 agent 的角色、可见全局工具和递归深度,而无需定义新的提供方。能力检查在所有权开始之前失败,未发布设置使第一次请求一致,单一工具解析器防止呈现/执行漂移。
|
||||
历史子级组装在冷恢复后保留,无需定义新的提供方。能力检查在所有权开始前失败,未发布设置使第一次请求一致,单一工具解析器防止呈现与执行不一致。
|
||||
|
||||
代价是部署方必须理解活跃的 allow/deny 行为以及可见性与授权的区别。当前深度策略禁止再创建子 agent 后,模型仍可能调用可见的委派工具并收到错误。提供方作者必须准确声明每个受支持的控制,进程内提供方必须在发布前安装所有请求的贡献。这些控制有意不解决安全隔离或父到子的非升权问题。
|
||||
@@ -12,11 +12,11 @@
|
||||
en: 95e7d723d6b00930
|
||||
zh: d18fdf9b2bf69bdb
|
||||
/agent-note-durable-agent-teams-over-continuable-children/profile-delegation:
|
||||
en: 3a66308576f74247
|
||||
zh: 3453f0bcce88033d
|
||||
en: 4835fb8c0c0d8a0d
|
||||
zh: 5f9438a0d58104c4
|
||||
/agent-note-durable-agent-teams-over-continuable-children/team-identity:
|
||||
en: 83e446c928a20c47
|
||||
zh: b0a2bd24419dd815
|
||||
en: f82812d6ac0ac78d
|
||||
zh: 7dd80a6ad13674ca
|
||||
/agent-note-durable-agent-teams-over-continuable-children/provisioning-and-recovery:
|
||||
en: 4345ac9a6ac7ad04
|
||||
zh: 1d19ecbb128a75a3
|
||||
|
||||
Loaded 100 of 1913 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user