Merge remote-tracking branch 'origin/master' into xtr/session-projection-registration-handles

# Conflicts:
#	benchmarks/session-corpus/projection-list.worker.ts
#	benchmarks/session-corpus/session-corpus.worker.ts
#	docs/config-catalog.md
#	docs/config-catalog.zh.md
#	packages/api/session-controller/README.i18n.yaml
#	packages/api/session-controller/src/index.ts
#	packages/api/session-controller/tests/list-scheduling.host.spec.ts
#	packages/client/ui-chat/README.i18n.yaml
#	packages/client/ui-chat/README.md
#	packages/client/ui-chat/README.zh.md
#	packages/client/ui-chat/src/client/chat/use-chat-viewport.ts
#	packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts
This commit is contained in:
_Kerman
2026-10-14 11:12:24 +08:00
472 changed files with 32380 additions and 4225 deletions
@@ -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
@@ -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 换代不声称卸载模块。
### 文件系统与运行时载体
@@ -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,14 +126,14 @@
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: 35326fed46e31d74
zh: 6f47baa645a1e370
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: 8368c592f30af97f
zh: 2ff89acb7b3ed477
@@ -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,7 +135,7 @@ 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
@@ -180,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
@@ -312,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`
@@ -326,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 } }
@@ -363,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
}
@@ -396,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),
)
@@ -446,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 |
@@ -562,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.
@@ -578,7 +582,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
- **`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 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.
@@ -591,7 +595,7 @@ 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.
@@ -88,7 +88,7 @@ Client 句柄的 `send()` 提供上行项,`end()` 半关闭上行,`dispose()
| 半关闭 | 一个方向已结束而另一个方向仍开放 |
| 项 | 一个方向上的一个业务值;每项独立经 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,7 +135,7 @@ 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
@@ -180,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 面
@@ -312,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` 的关系
@@ -326,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 } }
@@ -363,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
}
@@ -396,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),
)
@@ -446,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 目录 |
@@ -562,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;取消不覆盖两半;每个功能各造一套关联键。
@@ -578,7 +582,7 @@ for await (const reply of stream) replies.push(reply) // ['> a', '> b']
- **`this.ctx.invocation` 在非 Remote 调用下是 `undefined`**,方法读它要处理可选;一个只被进程内直接调用的服务方法读到 `undefined` 是正确行为。
- **Web 终端使用 `attach`** 传输屏幕输出和输入命令,并提供提供者确认和附件专属可写状态;Client 保留当前代次的句柄,绝不重放输入。
- **inbox 上限对所有流生效**:一个不读上行的方法收到大量上行帧会整流失败。这是刻意的显式失败,README 记明。
- **Peer 只有操作者一个**,`admit` 是唯一的接纳点;第一个需要第二个 Peer 的消费者要在 `dsh-client-connection` 加上打开、关联与事件。
- **所有浏览器连接共享 `browserPeer`**,`admit` 是唯一的网络接纳点;进程内代码经 `connection.createPeer()` 创建其他 Peer。
- **上行与下行之间没有跨方向顺序保证**;需要请求应答配对的协议自带序号。
- **上层监督器仅暴露下行**:`$stream`、snapshot 和 journal 不暴露 `send`;需要写入上行的领域保留该代次的生成物理句柄。
- **worker 隧道与 WebSocket 载体行为不同**:没有 inbox 上限,`end` 之后的项丢弃,因为页面到 worker 是同源可信边界。
@@ -591,7 +595,7 @@ 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 方法的测试不改期望值即通过。
@@ -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
@@ -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.
@@ -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 保留普通的依赖驱动重载。
@@ -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
@@ -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.
@@ -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,不调用模型。
@@ -15,11 +15,11 @@
en: db8d77cd773ad646
zh: ab2f03b8fc58e952
/agent-note-chat-group-tree-positions-and-exchanges/alternatives-considered:
en: a2b48738666a7d5f
zh: 53377f4f1109af26
en: fd9152c3f5edb035
zh: 259337cdcc5969a5
/agent-note-chat-group-tree-positions-and-exchanges/consequences:
en: f51678f9f30bad59
zh: b525e9ed10b9928c
en: 2a98bb21bb1315d1
zh: be4a320e9b427a79
/agent-note-chat-group-tree-positions-and-exchanges/testing:
en: e2e3982718e16ef4
zh: 4f3802946e51c55c
@@ -33,7 +33,7 @@ The host claims the waking input right after `turn/start`, assembles the system
**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; it remains a residual issue for human input from another tab.
**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.
@@ -45,7 +45,7 @@ The host claims the waking input right after `turn/start`, assembles the system
- 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; that fold still uses generic room and is unchanged here.
- 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
@@ -33,7 +33,7 @@ Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示
**节点声明业务角色(`input`、`notice`)。** 这会把业务语义放进分组机制;分组树位置只描述结构,业务 Definition 自行决定位置。
**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠;它仍是另一个标签页里真人输入的残留问题。
**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠。本页没有回显的真人输入引起的折叠,由[「下条消息时收起」只用一条折叠动画](2026-10-13-chat-single-fold-motion.zh.md)处理。
**在 `turn` 位置上判断轮次输入。** 终止错误、最大 token 提示和已完成轮次页脚同样在整轮折叠之外,读 `turn` 会把它们误当作开场输入;轮次输入需要自己的位置。
@@ -45,7 +45,7 @@ Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示
- 外部 Definition 通过 `groupTree` 放置自己的 Chat 行。除 `assistant-step` 的两条规则外,ui-chat 在分组、折叠和判断轮次输入时不点名任何 kind,内置 kind 保持原 kind 名单给出的放置。
- 「下次发送时收起」下,一长串通知唤醒的 Turn(抽样会话中 Goal 模式最长 31 轮)会一直展开,直到真人再次输入。
- 已加载窗口里没有开启 Turn 时,所有已加载 Turn 都属于最新交互段。
- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;这类折叠仍使用通用补白,本次没有修改。
- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;其落点按[「下条消息时收起」只用一条折叠动画](2026-10-13-chat-single-fold-motion.zh.md)决定。
## Testing
@@ -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
@@ -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' }` |
@@ -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
- 收益:整屏空白的来源(通用路径)不再存在;落点只看「是不是自己的输入」和「读者是否在跟随」,与消息从哪个页面、何时投递无关。子会话发送立即显示自己的消息。
- 代价:正文很短时,「内容只上不下」仍会留出差额,直到新内容填满。
- 焦点停在正在收起的行里时,焦点保护会让该行保持展开。若它是被封顶高度的过程组,正文会立即缩到封顶高度。这次缩小发生在批次开始之前,「读者内容不动」的落点测不到,读者内容会移动一次。
@@ -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
@@ -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.
@@ -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()` 仍为路由定价保留位置节点。
@@ -18,14 +18,14 @@
en: 554d01427ebebee1
zh: 664c9bf0a9337a04
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/presentation-and-accounting:
en: 00a91b4fb592e1e4
zh: dac2e4f0a3ee9ecf
en: 800d579e2fcf2cce
zh: 1e92354d9161c50a
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/decision/compaction:
en: c21a3e7a773860e5
zh: 566669e245ccdbaf
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/alternatives-considered:
en: f3dec242b2550529
zh: 0397fa07cc1806bb
en: 02b49911bc4601d0
zh: bfa19fd7c4337812
/agent-note-in-history-system-prompt-replacement-for-cache-stable-prompt-changes/consequences:
en: beda064bcd194a2a
zh: 8a3bb81d5edcd60a
@@ -43,7 +43,7 @@ An empty head with no active later system node represents no prompt. Dormant emp
Web presents an appended in-history node at its own position. `SystemPromptNode` carries `{ seq, time, turn, step, text, update }`, `update` being true for an appended `system/message` that follows an earlier system node in the loaded window. Chat renders a non-empty update as a collapsed `system-prompt` card titled by the locale key `message.systemPromptUpdate`, and a `request/header` in the same turn and step does not repeat the prompt card; `inspectRequestPrompt` reports no system change for a header that follows an update. Trajectory folds an update following a loaded request header into a synthetic request-header fact with `promptChange.kind = 'system'`, so later requests show the effective prompt without a real header change. When the loaded window lacks the earlier system node, the update is presented as an initial prompt. Transcript projections skip it like every `system/message`.
`dsh-token-meter` prices the last nonempty surviving system node in surface order as `contextBreakdown.systemTokens`; every other visible node, including superseded prompts, contributes to `messageTokens`. Empty dormant nodes are ignored. The sum equals the fixed-heuristic surface total after every replacement, whether or not a shadow-price claim exists. Compact retained entries reuse the measurement surface planner: state and transitions cost O(current retained surface), not O(1) or O(total historical log). Replaced entries and message bodies are discarded, and state version 4 rejects scalar checkpoints. `cacheReadTokens` on subsequent assistant usage remains the observable provider-cache effect.
`dsh-token-meter` prices the last nonempty surviving system node in surface order as `contextBreakdown.systemTokens`; every other visible node, including superseded prompts, contributes to `messageTokens`. Empty dormant nodes are ignored. The unit retains only the surviving system prompts and a running surface total, and prices replacements from the adjacent shadow-price event ([system-prompt state](../architecture/2026-10-13-context-breakdown-system-prompt-state.md)). `cacheReadTokens` on subsequent assistant usage remains the observable provider-cache effect.
Trajectory chooses the newer of the preceding real header and the preceding synthetic system header as the comparison state. A real header owns configuration and tools; an appended prompt can advance that state without another real header. Comparing only real headers would report A rather than B as the previous prompt for an A → B → C sequence.
@@ -73,7 +73,7 @@ Chat and Trajectory interpret the effective prompt through the pure `uiConversat
**Clear only the latest system node.** Empty nodes project to no message, so an older prompt would become effective again. Clearing all active versions preserves the meaning of an empty rendering without deleting conversation history. Rejected.
**Keep only scalar totals or prompt ancestry.** A scalar shadow price cannot identify which bucket lost the newest prompt or restore its predecessor. Prompt-only entries cannot locate arbitrary nonprompt replacement endpoints; `sourceEventSeqs` may also cite surviving prompts, and event sequence order differs from surface order after rewrites. Retaining compact current surface entries reuses the existing planner without full-log access, a second validator, or consumer-specific durable events. Summing all surviving prompts as system tokens would change the intended effective-prompt meaning rather than fix classification.
**Keep only scalar totals.** A scalar shadow price cannot identify which bucket lost the newest prompt or restore its predecessor. Summing all surviving prompts as system tokens would change the intended effective-prompt meaning rather than fix classification. Retained system prompts with the shadow-price event's `shadowedSeqs` answer both questions; [the system-prompt state note](../architecture/2026-10-13-context-breakdown-system-prompt-state.md) records that choice over positional entries.
## Consequences
@@ -43,7 +43,7 @@ Status: implemented
Web 在追加的历史内节点自己的位置呈现它。`SystemPromptNode` 携带 `{ seq, time, turn, step, text, update }`,其中 `update` 对已加载窗口内跟在更早系统节点之后的追加 `system/message` 为真。Chat 把非空的更新渲染为一张折叠的 `system-prompt` 卡片,标题取自 locale 键 `message.systemPromptUpdate`,同一 turn 与 step 内的 `request/header` 不会重复提示词卡片;`inspectRequestPrompt` 对跟在更新之后的 header 不报告系统变更。Trajectory 把跟在已加载请求 header 之后的更新折叠为一条合成的请求 header 事实,`promptChange.kind = 'system'`,因此之后的请求无需真实的 header 变更就能显示有效提示词。已加载窗口缺少更早的系统节点时,更新按初始提示词呈现。转录投影像对待所有 `system/message` 一样跳过它。
`dsh-token-meter` 把 surface 顺序中最后一个非空且存活的系统节点计入 `contextBreakdown.systemTokens`;其余可见节点(包括被取代的提示词)计入 `messageTokens`。休眠空节点被忽略。每次替换后,两者之和都等于固定启发式 surface 总量,无论是否存在影子价 claim。紧凑的保留条目复用测量服务的 surface 规划器:状态和转换成本为 O(当前保留 surface),不是 O(1) 或 O(完整历史日志)。被替换条目和消息正文被丢弃,状态版本 4 拒绝标量检查点。后续 assistant 用量中的 `cacheReadTokens` 仍是可观察的提供方缓存效果。
`dsh-token-meter` 把 surface 顺序中最后一个非空且存活的系统节点计入 `contextBreakdown.systemTokens`;其余可见节点(包括被取代的提示词)计入 `messageTokens`。休眠空节点被忽略。该单元只保留存活的系统提示词和一个 surface 运行总量,并依据紧邻的影子价事件为替换定价([系统提示词状态](../architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md))。后续 assistant 用量中的 `cacheReadTokens` 仍是可观察的提供方缓存效果。
Trajectory 选择前一条真实 header 与前一条合成系统 header 中较新的一个作为比较状态。真实 header 拥有配置与工具;追加的提示词可以在没有另一条真实 header 时推进该状态。只比较真实 header 会在 A → B → C 序列中把 A 而不是 B 报告为先前提示词。
@@ -73,7 +73,7 @@ Chat 与 Trajectory 通过纯操作 `uiConversation.inspectSystemPrompt` 解释
**仅清除最新系统节点。** 空节点不投影为消息,因此更早的提示词会重新生效。清除所有生效版本才能保留空渲染文本的含义,同时不删除对话历史。被否决。
**只保留标量总量或提示词祖先链。** 标量影子价无法判断最新提示词从哪个分类消失,也无法恢复其前一个版本。仅有提示词条目无法定位任意非提示词替换端点;`sourceEventSeqs` 还可能引用存活提示词,改写后的事件序号顺序也不同于 surface 顺序。保留紧凑的当前 surface 条目可以复用现有规划器,无需完整日志访问、第二套验证器或消费方专用持久事件。把所有存活提示词之和归入系统数字会改变有效提示词的含义,而不是修复分类。
**只保留标量总量。** 标量影子价无法判断最新提示词从哪个分类消失,也无法恢复其前一个版本。把所有存活提示词之和归入系统数字会改变有效提示词的含义,而不是修复分类。保留系统提示词并结合影子价事件的 `shadowedSeqs` 可以回答这两个问题;[系统提示词状态说明](../architecture/2026-10-13-context-breakdown-system-prompt-state.zh.md)记录了为何以这种方式取代位置条目。
## Consequences
@@ -21,11 +21,11 @@
en: f07bb6fbbdd01fe4
zh: 9313a0ef2a22af12
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate:
en: 6a303115f85ed4e8
zh: e894f3e9d630bf57
en: 29b8f8c1ad73c77b
zh: 8293b21ec08d3896
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered:
en: 1e21f5f9ca959f7e
zh: 401e303071b8d426
en: 7600c1671b5dc73c
zh: b38e3ae76a5a32d1
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences:
en: 8f9d25b72d3e7660
zh: e5c6ae0194905876
@@ -43,15 +43,18 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md)
| Item | Content |
|---|---|
| Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time |
| Timing | Each sample builds a fresh registry, document, and matcher instance and times evaluation only; each workload is measured with and without the credential matcher, keeping the fastest of several samples and the process CPU time over the same interval |
| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations; a 5,000-message sample evaluates the batch once. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo |
| Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval |
| Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character |
| Excluded | React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report |
| Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report |
Expectations come from an x64 development machine. CI time budgets are converted with the shared scale and headroom; time ratios, doubling ratios, and allocation budgets do not scale with machine speed. Negative controls: the same benchmark fails when pointed at the regular-expression implementation or at an implementation that rebuilds its memo per query. Equivalence tests compare this implementation with the regular-expression one ([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)) node by node through the production scheduler and from every query offset.
The reference constants are calibrated for minima of five single evaluations. Minimum-of-five sample means use the existing absolute headroom without recalibration. Averaging can move the ratio of independently selected minima in either direction; a threefold matcher-work negative control verifies that the JSON ratio limit still rejects extra scanning.
## Alternatives considered
- **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state.
- **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure.
- **One large quantified pattern for all formats**: one scan finds the leftmost match, but branch quantifiers still backtrack on failure.
- **Scanning for rare characters first, then looking back for prefixes**: faster on ordinary text, but cost depends on character distribution, and text dense in `-` or `_` becomes clearly slower.
@@ -43,15 +43,18 @@ Status: implemented
| 项 | 内容 |
|---|---|
| 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 |
| 计时 | 每个样本新建注册表、文档和匹配器实例,只计求值;每个负载分别测有无凭据匹配器,取多个样本中最快的一个,并记录同一区间的进程 CPU 时间 |
| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值,5,000 条消息的样本则整批求值一次。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 |
| 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC |
| 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 |
| 排除 | React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 |
| 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 |
预期值来自 x64 开发机。CI 时间预算按共享比例与余量换算;耗时比、翻倍比和分配预算不随机器缩放。负对照:同一 benchmark 指向正则实现或"每次查询新建记忆"的实现时都会失败。等价性测试经生产调度器逐节点比较本实现与正则实现([regex-oracle.client.ts](../../../../packages/client/ui-secret-mask/tests/regex-oracle.client.ts)),并对每个查询起点单独比较。
参考常量按五次单独求值的最小值校准;五组样本均值的最小值使用既有绝对耗时余量,不重新校准。求平均值可能使两侧独立最小值的比值向任一方向变化;将匹配器工作量增至三倍的负对照验证 JSON 比值上限仍能拒绝额外扫描。
## 考虑过的替代方案
- **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。
- **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。
- **把全部格式合成一个带量词的大正则**:一次扫描能找到最左匹配,但各分支的量词失败时仍回溯。
- **先找稀有字符再回看前缀**:普通文本扫描更快,但成本取决于字符分布,`-`、`_` 密集的文本明显变慢。
+2
View File
@@ -3,3 +3,5 @@
Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows build and process checks contribute to the pull-request `all checks passed` verdict; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](workflows/ci-master.yml)). `ci.yml` is pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches (values `selfhosted` for the in-house standbys and `blacksmith` for Blacksmith's hosted runners) and the Dependabot hosted fallback under the `selfhosted` values ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md), [blacksmith failover leg note](../.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md)).
The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes.
The Desktop publication workflow routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job.
+131
View File
@@ -0,0 +1,131 @@
name: Publish Desktop · COS
on:
workflow_run:
workflows: ['Package Desktop · macOS', 'Package Desktop · Windows']
types: [completed]
permissions:
contents: read
actions: read
jobs:
route:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
outputs:
publish: ${{ steps.route.outputs.publish }}
route: ${{ steps.route.outputs.route }}
runner: ${{ steps.route.outputs.runner }}
preset: ${{ steps.route.outputs.preset }}
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- uses: pnpm/action-setup@v5.0.0
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
- run: pnpm install --frozen-lockfile
- name: Resolve original package and machine
id: route
env:
GH_TOKEN: ${{ github.token }}
DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }}
run: node --import tsx apps/desktop/scripts/resolve-desktop-publish-ci.ts
prepare:
needs: route
if: needs.route.outputs.publish == 'true'
runs-on: [self-hosted, '${{ needs.route.outputs.runner }}']
timeout-minutes: 30
outputs:
approval: ${{ steps.check.outputs.approval }}
label: ${{ steps.check.outputs.label }}
env:
DESKTOP_PUBLISH_ROUTE: ${{ needs.route.outputs.route }}
DSH_TELEMETRY_DISABLED: '1'
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
clean: false
path: desktop-publisher
- uses: pnpm/action-setup@v5.0.0
with:
package_json_file: desktop-publisher/package.json
dest: ${{ runner.temp }}/publish-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
- run: pnpm install --frozen-lockfile
working-directory: desktop-publisher
- name: Verify local package without upload credentials
id: check
working-directory: desktop-publisher
run: node --import tsx apps/desktop/scripts/publish-desktop-ci.ts --check
publish:
name: Approve ${{ needs.prepare.outputs.label }}
needs: [route, prepare]
if: needs.route.outputs.publish == 'true'
runs-on: [self-hosted, '${{ needs.route.outputs.runner }}']
environment: ${{ needs.route.outputs.preset == 'production' && 'desktop-production' || 'desktop-test' }}
concurrency:
group: desktop-publish-${{ needs.route.outputs.runner }}-${{ needs.route.outputs.preset }}
cancel-in-progress: false
timeout-minutes: 90
env:
DESKTOP_APPROVED_PACKAGE: ${{ needs.prepare.outputs.approval }}
DESKTOP_PUBLISH_ROUTE: ${{ needs.route.outputs.route }}
DSH_TELEMETRY_DISABLED: '1'
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
clean: false
path: desktop-publisher
- uses: pnpm/action-setup@v5.0.0
with:
package_json_file: desktop-publisher/package.json
dest: ${{ runner.temp }}/publish-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
- run: pnpm install --frozen-lockfile
working-directory: desktop-publisher
- name: Verify local package without upload credentials
id: check
working-directory: desktop-publisher
run: node --import tsx apps/desktop/scripts/publish-desktop-ci.ts --check
- name: Read machine GitHub App key
if: needs.route.outputs.preset == 'production'
id: key
env:
APP_KEY_FILE: ${{ steps.check.outputs.app-key-file }}
working-directory: desktop-publisher
run: node apps/desktop/scripts/read-desktop-app-key.mjs
- name: Create short-lived tag token
if: needs.route.outputs.preset == 'production'
id: app
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ steps.check.outputs.app-client-id }}
private-key: ${{ steps.key.outputs.private-key }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
- name: Publish files, update feeds and record release tag
id: publish
working-directory: desktop-publisher
env:
DESKTOP_TAG_TOKEN: ${{ steps.app.outputs.token }}
run: node --import tsx apps/desktop/scripts/publish-desktop-ci.ts --publish
- name: Retain publication audit
if: always() && steps.publish.outputs.record != ''
uses: actions/upload-artifact@v7.0.1
with:
name: desktop-publication-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.publish.outputs.record }}/
if-no-files-found: error
retention-days: 30
+59 -7
View File
@@ -14,11 +14,38 @@ on:
type: boolean
default: false
required: true
build_version:
description: 'Version: auto recommended; explicit version must match the checkout product prefix'
type: string
default: auto
publish:
description: 'Publish successful signed packages and update the feed; test and production require approval'
type: boolean
default: false
required: true
test_release_id:
description: '测试环境的前缀,用于隔离外部用户访问,留空使用默认配置;测试连续升级时需使用相同值;建议定期轮换默认值。'
type: string
default: ''
required: false
source_ref:
description: 'Source: master or a dsh-v* tag containing the Desktop CI adapters'
type: string
default: master
required: true
update_channel:
description: 'Installer update channel; automatic updates stay on this channel'
type: choice
options: [nightly, latest]
default: nightly
required: true
version_mode:
description: 'default: production follows product, test auto; manual uses build_version'
type: choice
options: [default, product, auto, manual]
default: default
required: true
build_version:
description: 'Manual version only: product version or its supported prerelease suffix'
type: string
default: ''
required: false
permissions:
contents: read
@@ -36,14 +63,27 @@ jobs:
shell: bash
env:
PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }}
PACKAGE_SOURCE_REF: ${{ inputs.source_ref }}
PACKAGE_VERSION_MODE: ${{ inputs.version_mode }}
DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }}
DSH_TELEMETRY_DISABLED: '1'
DSH_PACKAGING_PRESET: ${{ inputs.preset }}
PACKAGE_PUBLISH: ${{ inputs.publish }}
PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }}
steps:
- uses: actions/checkout@v7.0.1
id: checkout
with:
persist-credentials: false
clean: true
ref: ${{ inputs.source_ref }}
- name: Validate selected source and adapter support
run: >-
node -e "const fs=require('node:fs'); const ref=process.env.PACKAGE_SOURCE_REF;
if(ref!=='master' && !/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref)) throw Error('Select master or dsh-v*');
const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;
if(ref!=='master' && ref!=='dsh-v'+v) throw Error('Source tag must match product version');
if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');"
- uses: pnpm/action-setup@v5.0.0
with:
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
@@ -71,6 +111,12 @@ jobs:
args=()
if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi
pnpm run package:desktop:mac:arm64 --build-version "$PACKAGE_VERSION" "${args[@]}"
- name: Retain complete package outside the checkout
id: retained
env:
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts
- name: Retain packaging diagnostics
if: always() && steps.checkout.outcome == 'success'
uses: actions/upload-artifact@v7.0.1
@@ -86,8 +132,14 @@ jobs:
with:
name: macos-arm64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
include-hidden-files: true
path: |
apps/desktop/.desktop-build/targets/mac-arm64/${{ steps.settings.outputs.artifacts }}/*.dmg
apps/desktop/.desktop-build/targets/mac-arm64/${{ steps.settings.outputs.artifacts }}/*.zip
path: ${{ steps.retained.outputs.directory }}/*
if-no-files-found: error
retention-days: 14
- name: Retain publication request
if: success() && inputs.publish
uses: actions/upload-artifact@v7.0.1
with:
name: desktop-publish-request-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.retained.outputs.directory }}/manifest.json
if-no-files-found: error
retention-days: 14
+60 -6
View File
@@ -15,11 +15,38 @@ on:
options: [unsigned, signed]
default: unsigned
required: true
build_version:
description: 'Version: auto recommended; or checkout product version / e.g. 0.2.1-alpha.1.20261009.1 (prefix must match checkout)'
type: string
default: auto
publish:
description: 'Publish successful signed packages and update the feed; test and production require approval'
type: boolean
default: false
required: true
test_release_id:
description: '测试环境的前缀,用于隔离外部用户访问,留空使用默认配置;测试连续升级时需使用相同值;建议定期轮换默认值。'
type: string
default: ''
required: false
source_ref:
description: 'Source: master or a dsh-v* tag containing the Desktop CI adapters'
type: string
default: master
required: true
update_channel:
description: 'Installer update channel; automatic updates stay on this channel'
type: choice
options: [nightly, latest]
default: nightly
required: true
version_mode:
description: 'default: production follows product, test auto; manual uses build_version'
type: choice
options: [default, product, auto, manual]
default: default
required: true
build_version:
description: 'Manual version only: product version or its supported prerelease suffix'
type: string
default: ''
required: false
permissions:
contents: read
@@ -36,8 +63,13 @@ jobs:
run:
shell: pwsh
env:
PACKAGE_SOURCE_REF: ${{ inputs.source_ref }}
PACKAGE_VERSION_MODE: ${{ inputs.version_mode }}
DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }}
DSH_TELEMETRY_DISABLED: '1'
DSH_PACKAGING_PRESET: ${{ inputs.preset }}
PACKAGE_PUBLISH: ${{ inputs.publish }}
PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }}
PACKAGE_SIGNING: ${{ inputs.signing }}
steps:
- name: Reset packaging checkout
@@ -56,6 +88,14 @@ jobs:
with:
persist-credentials: false
clean: true
ref: ${{ inputs.source_ref }}
- name: Validate selected source and adapter support
run: >-
node -e "const fs=require('node:fs'); const ref=process.env.PACKAGE_SOURCE_REF;
if(ref!=='master' && !/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref)) throw Error('Select master or dsh-v*');
const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;
if(ref!=='master' && ref!=='dsh-v'+v) throw Error('Source tag must match product version');
if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');"
- uses: pnpm/action-setup@v5.0.0
with:
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
@@ -87,6 +127,12 @@ jobs:
if ($env:PACKAGE_SIGNING -eq 'unsigned') { $packageArgs += '--unsigned' }
pnpm @packageArgs
exit $LASTEXITCODE
- name: Retain complete package outside the checkout
id: retained
env:
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts
- name: Retain packaging diagnostics
if: always() && steps.checkout.outcome == 'success'
uses: actions/upload-artifact@v7.0.1
@@ -102,7 +148,7 @@ jobs:
with:
name: windows-x64-unsigned-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
include-hidden-files: true
path: apps/desktop/.desktop-build/targets/win-x64/unsigned-artifacts/*.exe
path: ${{ steps.retained.outputs.directory }}/*
if-no-files-found: error
retention-days: 14
- name: Retain verified signed installer
@@ -111,6 +157,14 @@ jobs:
with:
name: windows-x64-signed-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
include-hidden-files: true
path: apps/desktop/.desktop-build/targets/win-x64/artifacts/*.exe
path: ${{ steps.retained.outputs.directory }}/*
if-no-files-found: error
retention-days: 14
- name: Retain publication request
if: success() && inputs.publish
uses: actions/upload-artifact@v7.0.1
with:
name: desktop-publish-request-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.retained.outputs.directory }}/manifest.json
if-no-files-found: error
retention-days: 14
+1
View File
@@ -114,6 +114,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT |
| [`node-pty`](https://github.com/microsoft/node-pty) | MIT |
| [`open`](https://github.com/sindresorhus/open) | MIT |
| [`p-limit`](https://github.com/sindresorhus/p-limit) | MIT |
| [`papaparse`](https://github.com/mholt/PapaParse) | MIT |
| [`pdfjs-dist`](https://github.com/mozilla/pdf.js) | Apache-2.0 |
| [`picomatch`](https://github.com/micromatch/picomatch) | MIT |
+3
View File
@@ -30,6 +30,8 @@ flowchart LR
cfg --> plugin_dsh_base_typert_loader
plugin_dsh_base_typert_gateway["typert-gateway<br/>@deepseek-ai/dsh-api-gateway"]
cfg --> plugin_dsh_base_typert_gateway
plugin_dsh_base_remote_access["remote-access<br/>@deepseek-ai/dsh-api-remote-access"]
cfg --> plugin_dsh_base_remote_access
plugin_dsh_base_session_title["session-title<br/>@deepseek-ai/dsh-session-title"]
cfg --> plugin_dsh_base_session_title
plugin_dsh_base_session_title_llm["session-title-llm<br/>@deepseek-ai/dsh-session-title-first-prompt-llm"]
@@ -211,6 +213,7 @@ flowchart LR
| `typert` | `@deepseek-ai/dsh-typert-registry` |
| `typert-loader` | `@deepseek-ai/dsh-typert-loader` |
| `typert-gateway` | `@deepseek-ai/dsh-api-gateway` |
| `remote-access` | `@deepseek-ai/dsh-api-remote-access` |
| `session-title` | `@deepseek-ai/dsh-session-title` |
| `session-title-llm` | `@deepseek-ai/dsh-session-title-first-prompt-llm` |
| `user-questions` | `@deepseek-ai/dsh-user-questions` |
+4 -4
View File
@@ -18,11 +18,11 @@
en: 17c6bcdefa05a8f8
zh: 4ee18f832276144d
/desktop-packaging/release-versions:
en: d80a92c02a583b80
zh: b27f75ff29fcea1a
en: 3f38ab26ee195b39
zh: f00eddafa8f3b9ca
/desktop-packaging/configuration-and-local-builds:
en: e4f526604231ed43
zh: d412179ffdc402ba
en: f5dc36baf414ecc5
zh: 8984741b356ac1d3
/desktop-packaging/windows-actions-packaging:
en: e3f51e7130a0fe05
zh: 70fd6a40d5899871
+3 -1
View File
@@ -61,7 +61,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609
A production release publishes the product version and takes no `--build-version`. Uploading one tags the packaged commit as `desktop-v<version>` once the artifacts are public; a build from a modified checkout is not tagged, and a tagging failure prints the command to run by hand rather than failing a completed upload. Test and local builds are deliberately left untagged, and every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest so a build handed over directly remains traceable.
Version derivation does not change the fixed update channel or `nightly.yml` / `nightly-mac.yml` filenames. SemVer orders `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`, and a stable base's test version precedes that stable release. Clients only accept a greater version: replacing a feed cannot move an installed higher version to a lower corrected version. Such clients need manual installation; keep automatic downgrade disabled. The [archived version decision](../../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md) explains why the channel does not supply the prerelease identifier.
The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `latest`) independently of version and deployment. Automatic updates remain on that channel even for a stable version; manually installing the other channel switches it. Latest packages reject prerelease versions. A stable product can produce a nightly `-test.<date>.<sequence>` build, but that version ranks below the stable base and cannot upgrade an already installed higher version. Keep automatic downgrade disabled; stable corrective releases require a product version bump. CI version selection and publication are documented in [CI publication](../publishing/ci/README.md#package-and-publish).
<a id="configuration-and-local-builds"></a>
@@ -69,6 +69,8 @@ Version derivation does not change the fixed update channel or `nightly.yml` / `
Set `DSH_PACKAGING_CONFIG_DIR` to the absolute machine configuration directory and `DSH_PACKAGING_PRESET` to `test` or `production` for local Windows commands. The loader reads `presets/<preset>.env`; signed commands additionally read `secrets/windows-signing.env`. Relative certificate and SignTool paths resolve beside the file declaring them. Public presets reject credentials, and the signing file cannot override public settings. Missing configuration fails without falling back to `.env.windows`; commands without a preset retain the existing dotenv behavior. Unsigned and preparation-only presets never read the signing file. Cache maintenance also reads only public preset settings. Test signed packages also require the existing download origin and release ID, but no upload credentials. Local preset commands with `--build-version auto` number from local artifacts only; Actions uses its run number and attempt.
Both manual packaging workflows accept `test_release_id`. Blank uses the machine preset; a nonblank value must contain 32 lowercase hexadecimal characters and requires the test preset. The loader applies it through `PACKAGE_TEST_RELEASE_ID` without changing the preset file. Keep the same value for successive upgrade tests on that platform. Rotating the default affects future builds only: installed clients retain their original update address, and old objects remain accessible until separately removed. A random prefix reduces discoverability but does not authenticate downloads.
Outside machine-preset mode, packaging, upload, and manual macOS signature verification (except local unsigned macOS builds) read `apps/desktop/.env.windows` or `.env.macos`, selected by target platform. Copy the [Windows template](../.env.windows.example) or [macOS template](../.env.macos.example) and fill in the local settings; Git ignores both local files, and packaged artifacts exclude them. Release fields come only from the selected configuration files, without fallback to system or shell variables; `PATH`, proxies, and build-tool settings remain inherited. The published version is an argument rather than a release field, and upload reads it from the completion record the packaging run wrote. Files use UTF-8 with optional BOM; relative certificate, SignTool, Apple API key, and keychain paths resolve from `apps/desktop`, values are not shell-expanded, and passwords containing `#` or spaces need quotes. Windows CI reads the same machine presets.
Every package command checks the application ID and its mode-specific update and signing configuration before building or downloading, then probes the external tools the run will use: the archive reader, and on a Windows target the installer compiler. macOS checks the identity, Team ID, one complete notarization strategy, readable local `CSC_LINK` p12 file, explicit `CSC_KEY_PASSWORD`, and referenced API key and keychain files; Windows checks the public code-signing certificate, SignTool file, container name, and PIN format. Windows preparation-only builds and explicit unsigned builds on either platform do not require signing credentials. Configuration checks do not authenticate the PIN, log in to the token, unlock a keychain, or contact Apple; actual signing and notarization perform those checks. Without a machine preset, `--build-version auto` contacts the destination bucket, including under `--check`. Run the same checks separately:
+3 -1
View File
@@ -61,7 +61,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609
production 发布使用产品版本本身,不传 `--build-version`。其上传成功后会把打包所用 commit 打成 `desktop-v<版本>` 标签;来自有改动工作区的构建不打标签,打标签失败也只打印手工命令,不会让已完成的上传变成失败。test 与本地构建有意不留标签,而所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`,直接分发的构建同样可溯源。
版本派生不改变固定更新通道,也不改变 `nightly.yml` / `nightly-mac.yml` 文件名。SemVer 排序为 `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`,稳定基础版本的测试版低于该稳定版。客户端只接受更高版本:替换 feed 无法让已安装的较高版本更新到较低的纠正版。这类客户端需要手动安装;保持自动降级关闭。[已归档的版本决策](../../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md)解释为什么不能用通道名替换预发布标识。
安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `latest`)。自动更新始终保留该通道,即使安装的是稳定版本;手动安装另一通道的包才会切换。latest 包拒绝预发布版本。稳定产品可生成 nightly 的 `-test.<date>.<sequence>` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。
<a id="configuration-and-local-builds"></a>
@@ -69,6 +69,8 @@ production 发布使用产品版本本身,不传 `--build-version`。其上传
本地 Windows 命令通过 `DSH_PACKAGING_CONFIG_DIR` 指定机器配置绝对目录,通过 `DSH_PACKAGING_PRESET` 选择 `test` 或 `production`。加载器读取 `presets/<preset>.env`;签名命令另读 `secrets/windows-signing.env`。相对证书和 SignTool 路径以声明它们的文件所在目录为基准。公开 preset 拒绝凭据,签名文件不能覆盖公开配置。配置缺失立即失败,不回退到 `.env.windows`;不选择 preset 的命令保留原 dotenv 行为。未签名和仅准备 preset 不读取签名文件。缓存维护也只读取公开 preset 配置。签名测试包还需现有下载 origin 和 release ID,但无需上传凭据。本地 preset 命令使用 `--build-version auto` 时仅按本地产物编号;Actions 使用运行编号和重试次数。
两个手动打包工作流都接受 `test_release_id`。留空沿用机器 preset;非空值必须是 32 位小写十六进制字符串,且仅允许 test preset。加载器通过 `PACKAGE_TEST_RELEASE_ID` 应用覆盖值,不修改 preset 文件。同一平台连续升级测试需使用相同值。轮换默认值只影响后续构建:已安装客户端保留原更新地址,旧对象仍可访问,除非另行删除。随机前缀降低地址的可发现性,但不提供下载身份认证。
不选择机器 preset 时,打包、上传以及手动 macOS 签名检查(本地 macOS 未签名构建除外)使用 `apps/desktop/.env.windows` 或 `.env.macos`,由目标平台选择。复制对应的 [Windows 模板](../.env.windows.example) 或 [macOS 模板](../.env.macos.example),填写本机配置;Git 忽略这两个本地文件,安装产物也不包含它们。发布字段只从选定的配置文件读取,不回退到系统或 shell 中的同名变量;`PATH`、代理和构建工具环境仍保留。发布版本是命令参数而非发布字段,上传从打包写下的完成记录中读取它。文件使用 UTF-8,支持 BOM;相对证书、SignTool、Apple API Key 和钥匙串路径以 `apps/desktop` 为基准,变量值不做 shell 展开,包含 `#` 或空格的密码需要引号。Windows CI 读取相同的机器 preset。
每条打包命令在构建与下载前检查应用 ID 以及该模式需要的更新与签名配置,随后探测本次运行要用的外部工具:归档读取工具,以及 Windows 目标的安装器编译器。macOS 检查身份、Team ID、一套完整公证凭据、`CSC_LINK` 指定的可读本地 p12 文件、显式配置的 `CSC_KEY_PASSWORD`,以及引用的 API Key 和钥匙串文件;Windows 检查公开代码签名证书、SignTool 文件、容器名称和 PIN 格式。仅准备 Windows 资源以及两个平台上的显式未签名打包均不要求签名凭据。配置检查不验证 PIN 是否正确、Token 是否登录、钥匙串是否解锁或 Apple 是否接受凭据;实际签名与公证负责这些检查。未选择机器 preset 时,`--build-version auto` 会访问目标 bucket,`--check` 下同样如此。单独运行相同检查:
+2 -2
View File
@@ -12,8 +12,8 @@
en: 722633597f74ace6
zh: 5303bcf5567a8511
/desktop-publication/table-of-contents:
en: 64af8f421bc63580
zh: 69844c0a0d4771f6
en: 6217df1d38ff46aa
zh: 22375410c0816130
/desktop-publication/publication-prerequisites:
en: b407de63118c9bf9
zh: de19f1ba9feacbbf
+2
View File
@@ -19,6 +19,8 @@ Publish completed Desktop installers and update feeds to the selected download s
- [Upload credentials](#credentials)
- [Upload audit and recovery](#audit-and-recovery)
For retained self-hosted builds, use the [CI publication workflow](ci/README.md), which keeps installers on their original machine and supports resumable publication. The commands below remain the existing manually authorized local uploader: they retain checkout-local audit records and use the operator's Git identity. They do not share the CI publisher's locks, approval or tag-only recovery. Do not run both writers against one channel concurrently. Both use the same upload-plan validation; separate orchestration preserves existing manual commands while adding immutable-attempt recovery for CI. Consolidating or retiring the manual commands is separate work.
<a id="prerequisites"></a>
## Publication prerequisites
+2
View File
@@ -19,6 +19,8 @@ description: "将完整 Desktop 安装包与更新清单发布到选定下载服
- [上传凭据](#credentials)
- [上传审计与恢复](#audit-and-recovery)
自托管留存构建使用 [CI 发布工作流](ci/README.zh.md),在原机器保留安装包并支持恢复发布。 以下命令继续作为现有人工授权的本地上传器使用:它们保留 checkout 内的审计记录,并使用操作员的 Git 身份,不共享 CI 发布器的锁、审批或仅补 tag 恢复机制。不要让两种上传器并发写入同一频道。两者复用相同的上传计划校验;分开编排可保留现有手动命令,同时为 CI 增加不可变 attempt 恢复能力。统一或退役手动命令属于独立工作。
<a id="prerequisites"></a>
## 发布前提
@@ -0,0 +1,31 @@
# Bilingual-pair consistency record for README.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 apps/desktop/publishing/ci/README.md
/:
en: 5bbdeb4ea94502b4
zh: 107894e10c74d22d
/desktop-ci-publication:
en: a610b66106849734
zh: bc1760791f56fa28
/desktop-ci-publication/summary:
en: 470bdf1487a739f9
zh: cd80330eaa8f730c
/desktop-ci-publication/table-of-contents:
en: 1b6a4d7146879eec
zh: f2520fc412cd2d2a
/desktop-ci-publication/machine-setup:
en: 990ff7e56dd6005a
zh: 18f9dc23c520dc8c
/desktop-ci-publication/package-and-publish:
en: 6e29eb0dff23e2c8
zh: 65d5d57b63b86904
/desktop-ci-publication/recovery-and-records:
en: 370f21dc25fc1098
zh: cf7f433b8eaa3dcd
/desktop-ci-publication/restore-a-channel:
en: d82e2ccf70cce397
zh: d93dcb77cc4b463e
/desktop-ci-publication/dev-note:
en: b75c8458928b72b9
zh: 003ba4e17b4b5269
+68
View File
@@ -0,0 +1,68 @@
---
description: "Operate retained Desktop package publication from dedicated macOS and Windows runners."
---
# Desktop CI publication
English | [中文](README.zh.md)
## Summary
Publish a completed package from its original machine without downloading its installer again. Both test and production publish binaries followed by update YAML. Both deployments require GitHub Environment approval; only production creates a GitHub App tag. Real COS, CDN and administrator-permission acceptance remain deployment checks; local fixtures do not establish those permissions.
## Table of Contents
- [Machine setup](#machine-setup)
- [Package and publish](#package-and-publish)
- [Recovery and records](#recovery-and-records)
- [Restore a channel](#restore-a-channel)
<a id="machine-setup"></a>
## Machine setup
Keep `DSH_PACKAGING_CONFIG_DIR` pointed at the existing machine packaging directory. Completed attempts are copied to its sibling `releases/<run-id>/<attempt>/`; `DSH_DESKTOP_RELEASES_DIR` overrides that root with an absolute path. The inventory includes installers, update archives, blockmaps, completion metadata and a credential-free manifest. Debug packages are retained but cannot publish. Copies are checked before the attempt directory becomes visible, and an existing attempt cannot be replaced. GitHub retains the same file inventory for 14 days. Local files remain until an operator removes them; do not delete an attempt while it is being verified or published.
Create `publishing/test.json` and, when authorized, `publishing/production.json` below the configuration directory. Each declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. These files stay outside Git. The uploader supports permanent COS credentials, not STS session credentials. Bucket versioning must be disabled for immutable binary writes.
Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`. Assign that label to exactly that runner. Windows uses target `win-x64`. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other.
An administrator must create `desktop-test` and `desktop-production`, each with required reviewers and an allowed `master` deployment branch. The workflow checks reviewers for either deployment. Test can allow self-approval. The GitHub App needs installation access to this repository and Contents write permission, plus permission under the `desktop-v*` tag ruleset to create release tags. Reviewer approval does not transfer the reviewer's tag permissions to the App. Missing configuration stops publication; there is no personal-token fallback. The App action creates and later revokes a short-lived token. Protect the local private key and COS credential files with the publishing account's filesystem permissions. The token action accepts key contents, so the masked PEM also passes through the runner's temporary `GITHUB_OUTPUT` file during the job; masking hides logs, not local file access.
Environment approval gates this workflow, not arbitrary code running under the same machine account. Account isolation and forced source-branch restrictions are separate operational work. Keep existing release Environments unchanged. The test job uses its own reviewers and never creates a real release tag; local simulated transports exercise tag creation and recovery without verifying App permissions.
<a id="package-and-publish"></a>
## Package and publish
Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts `master` or `dsh-v*` matching the selected product version; the selected source must contain these CI adapters, so older tags fail before dependency installation. The publisher runs from the default branch while recording the actual source commit separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A latest package requires a stable version, so test/latest needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits.
Select `publish` to request publication after packaging succeeds. Windows must be signed; macOS must finish notarization and stapling. The separate `Publish Desktop · COS` workflow reads the small request manifest from GitHub, binds it to the source run and attempt, and routes to the original machine. It uses the locally retained installers. A failed build or one without a publication request produces no upload.
Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. Preparation writes an approval summary with version, channel, platform, deployment, source ref/SHA, package run/attempt, object keys and proposed tag. Both publishing jobs wait for **Review deployments → Approve and deploy**, then recheck the approved manifest and upload plan digest. Changed bytes or destinations reject. Approval cannot edit a version; reject and rebuild instead. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v<version>` at the recorded packaging commit; an existing tag must name the same commit.
<a id="recovery-and-records"></a>
## Recovery and records
Re-run the publishing workflow after resolving a failure; do not rebuild solely to retry an upload. Its source package run and attempt remain fixed. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, latest writes `latest.yml` / `latest-mac.yml`. Latest binaries use a `latest/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit.
The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; raw provider responses are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate.
If the feed completed but tagging failed, the result is `published-tag-pending` and the job fails visibly. On retry, durable feed-completion evidence permits only tag recovery, even if a later release has advanced the channel. It never re-publishes old YAML. An existing conflicting tag requires administrator investigation and is never moved automatically.
Normal publication and manual restoration acquire the same local channel lock. A crash can leave a lock directory under the sibling `publish-locks/`; verify no writer is running before manually removing it. A runner label alone does not coordinate writers on other machines. Keep one publishing machine per channel and preserve its records when migrating.
<a id="restore-a-channel"></a>
## Restore a channel
Run the repository script `apps/desktop/scripts/rollback-desktop-channel.ts` through `node --import tsx` on the publishing machine. Keep `GITHUB_REPOSITORY`, `RUNNER_NAME`, `DSH_PACKAGING_CONFIG_DIR` and `DESKTOP_PUBLISH_ROUTE` set to the original package context. The route JSON contains `runId`, `attempt`, `commit`, `runnerName`, `target`, `preset`, `sourceRef` and `channel`, available in the retained manifest's source fields. Pass `--record` with that successful publication's audit directory, `--expected-version` with the version currently online, and `--reason` with the operational reason.
Without `--execute`, the script reads remote objects and prints the exact YAML change without writing. Preview requires COS read credentials. With `--execute`, it verifies historical files, preserves before/after YAML in a new audit record, checks the current channel again, and restores only YAML. It neither uploads binaries nor changes tags. A channel already matching the target bytes is skipped; other channels must match the expected current version. This permits recovery after one feed was restored before an interruption. A different current version aborts the operation. Restoration is not a multi-object transaction; inspect the audit after any partial failure before continuing.
The Desktop client disables automatic downgrade. Restoring an old feed can stop additional clients receiving a bad release; clients already on a newer version do not downgrade. A higher-version corrective release is a separate recovery path.
## Dev Note
None.
+68
View File
@@ -0,0 +1,68 @@
---
description: "在专用 macOS 和 Windows runner 上发布留存的 Desktop 安装包。"
---
# Desktop CI 发布
[English](README.md) | 中文
## 概述
在原打包机器上发布完整安装包,无需再次下载安装包。test 和 production 都先发布二进制文件,再更新 YAML。两种环境都需要 GitHub Environment 审批;只有 production 通过 GitHub App 创建 tag。真实 COS、CDN 和管理员权限仍需部署验收;本地 fixture 不证明这些权限可用。
## 目录
- [机器配置](#machine-setup)
- [打包与发布](#package-and-publish)
- [恢复与记录](#recovery-and-records)
- [恢复频道](#restore-a-channel)
<a id="machine-setup"></a>
## 机器配置
保持 `DSH_PACKAGING_CONFIG_DIR` 指向现有机器打包目录。完成的构建复制到其同级 `releases/<run-id>/<attempt>/`;`DSH_DESKTOP_RELEASES_DIR` 可用绝对路径覆盖该根目录。文件清单包含安装包、更新归档、blockmap、完成记录和不含凭据的 manifest。调试包会留存,但不能发布。复制内容通过校验后才开放 attempt 目录,已有 attempt 不能被替换。GitHub 保留相同文件清单 14 天。本地文件保留到操作员删除;校验或发布期间不要删除对应 attempt。
在配置目录下创建 `publishing/test.json`,获得授权后再创建 `publishing/production.json`。每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。这些文件不进入 Git。上传器支持长期 COS 凭据,不支持 STS 会话凭据。不可覆盖的二进制写入要求 bucket 版本控制关闭。
将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`。该标签只能分配给这台 runner。Windows 使用目标 `win-x64`。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。
管理员必须创建 `desktop-test` 和 `desktop-production`,分别设置必需审批人并允许 `master` 部署分支。工作流会检查两种环境的审批人配置。test 可允许发起者自行审批。GitHub App 需要安装到本仓库、具有 Contents write 权限,并获准在 `desktop-v*` tag ruleset 下创建发布 tag。审批不会把审批人的 tag 权限传给 App。缺少配置会停止发布,不回退到个人 token。App action 创建短期 token,并在结束后撤销。使用发布账户的文件系统权限保护本机私钥及 COS 凭据文件。token action 需要密钥内容,因此已屏蔽日志的 PEM 在 job 期间也会经过 runner 临时 `GITHUB_OUTPUT` 文件;日志屏蔽不限制本地文件访问。
Environment 审批限制此工作流,不限制同一机器账户运行的任意代码。账户隔离和强制源分支限制属于独立运维工作。保持现有发布 Environment 不变。test job 使用自己的审批人,不创建真实发布 tag;本地模拟接口覆盖 tag 创建与恢复,但不验证 App 权限。
<a id="package-and-publish"></a>
## 打包与发布
选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。`source_ref` 接受 `master` 或与所选产品版本一致的 `dsh-v*`;源码必须包含这些 CI 适配脚本,因此旧 tag 会在安装依赖前失败。发布器从默认分支运行,单独记录实际源码提交。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。latest 包要求稳定版本,因此 test/latest 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。
选中 `publish`,请求在打包成功后发布。Windows 必须签名;macOS 必须完成公证和 stapling。独立的 `Publish Desktop · COS` 工作流从 GitHub 读取小型请求 manifest,将其与原 run 和 attempt 核对,再路由到原机器。安装包使用本机留存文件。构建失败或未请求发布时不会上传。
上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。准备阶段生成审批摘要,列出版本、通道、平台、环境、源码 ref/SHA、package run/attempt、对象路径及拟创建的 tag。两种发布 job 均等待 **Review deployments → Approve and deploy**,随后重新核对已批准的 manifest 与上传计划摘要。文件或目标变化会被拒绝。审批不能修改版本;应拒绝并重新打包。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v<version>`;已有 tag 必须指向同一提交。
<a id="recovery-and-records"></a>
## 恢复与记录
解决故障后重跑发布工作流,不要仅为重试上传而重新打包。原 package run 和 attempt 保持不变。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,latest 写入 `latest.yml` / `latest-mac.yml`。latest 二进制位于目标二进制前缀下的 `latest/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。
同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断,不保存原始服务响应。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。
频道更新完成但 tag 失败时,结果为 `published-tag-pending`,job 明确失败。重试时,已落盘的频道完成证据只允许恢复 tag,即使后来发布已推进频道,也不会重新发布旧 YAML。已有冲突 tag 需要管理员调查,不会自动移动。
正常发布和手动恢复获取同一把本地频道锁。崩溃可能在同级 `publish-locks/` 下留下锁目录;手动删除前确认没有写入任务运行。runner 标签本身不能协调其他机器的写入。每个频道保持单一发布机器,迁移时保留记录。
<a id="restore-a-channel"></a>
## 恢复频道
在发布机器上通过 `node --import tsx` 运行仓库脚本 `apps/desktop/scripts/rollback-desktop-channel.ts`。保持 `GITHUB_REPOSITORY`、`RUNNER_NAME`、`DSH_PACKAGING_CONFIG_DIR` 和 `DESKTOP_PUBLISH_ROUTE` 对应原打包上下文。route JSON 包含 `runId`、`attempt`、`commit`、`runnerName`、`target`、`preset`、`sourceRef` 和 `channel`,可从留存 manifest 的 source 字段取得。`--record` 指定该次成功发布的审计目录,`--expected-version` 指定当前线上版本,`--reason` 填写操作原因。
不带 `--execute` 时,脚本读取远端对象并打印确切 YAML 变更,不执行写入。预览需要 COS 读取凭据。带 `--execute` 时,脚本校验历史文件,将前后 YAML 保存在新的审计记录中,再次检查当前频道,然后仅恢复 YAML。它不上传二进制文件,也不改变 tag。已经与目标字节完全相同的频道会跳过,其余频道必须匹配预期当前版本,因此可以恢复写完一个 feed 后中断的操作。其他当前版本会中止操作。恢复不是多对象事务;部分失败后需先检查审计记录再继续。
Desktop 客户端禁用自动降级。恢复旧 feed 可以阻止更多客户端获得问题版本,已经安装更高版本的客户端不会降级。发布更高版本号的修正版是独立恢复方式。
## 开发备注
无。
@@ -10,6 +10,7 @@ export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64'
/** Public updater URL and object prefixes for one release target. */
export interface DesktopAutoUpdateConfig {
readonly environment: DesktopAutoUpdateEnvironment
readonly channel: 'nightly' | 'latest'
readonly target: DesktopAutoUpdateTarget
readonly origin: string
readonly publicUrl: string
@@ -60,6 +61,7 @@ export function desktopBuildRecordFilename(target: DesktopAutoUpdateTarget): str
export function desktopUpdateMetadataFilename(
version: string,
platform: NodeJS.Platform,
channel?: 'nightly' | 'latest',
): string
/**
@@ -89,3 +91,10 @@ export function resolveDesktopUploadConfig(
platform: NodeJS.Platform,
arch: string,
): DesktopUploadConfig
/**
* Resolve the packaged update channel, defaulting omitted local settings to nightly.
* @param env Packaging settings.
* @returns Validated channel.
*/
export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'latest'
@@ -5,6 +5,17 @@ import { valid } from 'semver'
/** Environment variable that selects the Desktop update deployment. */
export const DESKTOP_AUTO_UPDATE_ENV = 'DSH_DESKTOP_AUTO_UPDATE_ENV'
/**
* Resolve the channel embedded in the installer; omitted local settings retain nightly.
* @param {NodeJS.ProcessEnv} env Packaging or upload settings.
* @returns {'nightly' | 'latest'} Validated channel.
*/
export function resolveDesktopUpdateChannel(env) {
const channel = env.DSH_DESKTOP_UPDATE_CHANNEL || 'nightly'
if (channel !== 'nightly' && channel !== 'latest') throw new Error('desktop auto-update: invalid update channel')
return channel
}
const UPDATE_ENVIRONMENTS = {
test: {
originEnvName: 'DOWNLOAD_TEST_ORIGIN',
@@ -68,16 +79,18 @@ export function desktopBuildRecordFilename(target) {
* Return the electron-builder channel metadata filename for an application version.
* @param {string} version - Desktop semantic version.
* @param {NodeJS.Platform} platform - Target platform.
* @param {'nightly' | 'latest'} channel - Selected channel; omitted means nightly.
* @returns {string} Channel metadata filename emitted for the target.
*/
export function desktopUpdateMetadataFilename(version, platform) {
export function desktopUpdateMetadataFilename(version, platform, channel = 'nightly') {
if (valid(version) === null) {
throw new Error(`desktop auto-update: invalid Desktop version ${JSON.stringify(version)}`)
}
if (platform !== 'darwin' && platform !== 'win32') {
throw new Error(`desktop auto-update: unsupported metadata platform ${platform}`)
}
return `nightly${platform === 'darwin' ? '-mac' : ''}.yml`
resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: channel })
return `${channel}${platform === 'darwin' ? '-mac' : ''}.yml`
}
/**
@@ -124,10 +137,11 @@ function httpsOrigin(value, name) {
* @param {NodeJS.ProcessEnv} env - Packaging or upload environment.
* @param {NodeJS.Platform} platform - Target Node.js platform.
* @param {string} arch - Target Node.js architecture.
* @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration.
* @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'latest', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration.
* @throws {Error} When the test deployment lacks a valid HTTPS origin or a 32-character lowercase hexadecimal release ID.
*/
export function resolveDesktopAutoUpdateConfig(env, platform, arch) {
const channel = resolveDesktopUpdateChannel(env)
const environment = resolveDesktopAutoUpdateEnvironment(env)
const target = resolveDesktopAutoUpdateTarget(platform, arch)
const deployment = UPDATE_ENVIRONMENTS[environment]
@@ -148,10 +162,11 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) {
const keyPrefix = `${releasePrefix}/feeds/${target}`
return {
environment,
channel,
target,
origin,
keyPrefix,
binaryKeyPrefix: `${releasePrefix}/bin/${target}`,
binaryKeyPrefix: `${releasePrefix}/bin/${target}${channel === 'latest' ? '/latest' : ''}`,
publicUrl: `${origin}/${keyPrefix}/`,
}
}
@@ -161,7 +176,7 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) {
* @param {NodeJS.ProcessEnv} env - Upload environment.
* @param {NodeJS.Platform} platform - Target Node.js platform.
* @param {string} arch - Target Node.js architecture.
* @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration.
* @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'latest', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration.
* @throws {Error} When the selected deployment lacks a bucket or valid updater configuration.
*/
export function resolveDesktopUploadConfig(env, platform, arch) {
+8 -2
View File
@@ -9,7 +9,11 @@ import { desktopBuildVersionPrefix, validateDesktopBuildVersion } from './deskto
* @returns {string} Validated build version.
*/
export function desktopCiVersion(environment, productVersion, now = new Date()) {
const requested = environment.PACKAGE_BUILD_VERSION?.trim()
const mode = environment.PACKAGE_VERSION_MODE
const selected = mode === 'default' ? (environment.DSH_PACKAGING_PRESET === 'production' ? 'product' : 'auto') : mode
if (selected !== undefined && !['product', 'auto', 'manual'].includes(selected)) throw new Error('Invalid version mode')
const requested = selected === 'product' ? productVersion : selected === 'auto' ? 'auto' : environment.PACKAGE_BUILD_VERSION?.trim()
if (selected === 'manual' && (!requested || requested === 'auto')) throw new Error('Manual version mode requires an explicit version')
const date = now.toISOString().slice(0, 10).replaceAll('-', '')
const example = `${desktopBuildVersionPrefix(productVersion)}${date}.1`
if (!requested) throw new Error(`Build version is required: use auto, ${productVersion}, or e.g. ${example}`)
@@ -20,8 +24,10 @@ export function desktopCiVersion(environment, productVersion, now = new Date())
}
candidate = `${desktopBuildVersionPrefix(productVersion)}${date}.${environment.GITHUB_RUN_NUMBER}.${environment.GITHUB_RUN_ATTEMPT}`
}
if (environment.DSH_DESKTOP_UPDATE_CHANNEL === 'latest' && candidate.includes('-')) throw new Error('Latest channel requires a stable version; select product or manual stable version')
try {
return validateDesktopBuildVersion(candidate, productVersion)
const resolved = validateDesktopBuildVersion(candidate, productVersion)
return resolved
} catch {
throw new Error(`Invalid build version for this checkout: use auto, ${productVersion}, or e.g. ${example}`)
}
@@ -9,6 +9,7 @@ export function localMacOSPackageEnvironment(environment?: NodeJS.ProcessEnv): N
/**
* Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback.
* PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID.
* @param platform Target platform.
* @param environment Parent environment, retained only for unrelated build tools.
* @param appRoot Desktop application directory; relative credential paths resolve here.
@@ -35,6 +35,7 @@ export function localMacOSPackageEnvironment(environment = process.env) {
/**
* Read a machine preset when selected, otherwise the target's required dotenv; no ambient release fallback.
* PACKAGE_TEST_RELEASE_ID overrides the test preset only; blank retains its configured ID.
* @param {'win32' | 'darwin'} platform Target platform.
* @param {NodeJS.ProcessEnv} environment Parent environment, retained only for unrelated build tools.
* @param {string} appRoot Desktop application directory; relative credential paths resolve here.
@@ -67,9 +68,16 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en
: MACOS_SETTING.test(name) && !publicPlatformSetting.test(name)))
}
}
const releaseId = environment.PACKAGE_TEST_RELEASE_ID?.trim()
if (releaseId) {
if (preset !== 'test') throw new Error('desktop package: test release ID override requires the test preset')
if (!/^[a-f0-9]{32}$/u.test(releaseId)) throw new Error('desktop package: test release ID must contain 32 lowercase hexadecimal characters')
settings.DOWNLOAD_TEST_RELEASE_ID = releaseId
}
return {
...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))),
...settings,
...(environment.DSH_DESKTOP_UPDATE_CHANNEL === undefined ? {} : { DSH_DESKTOP_UPDATE_CHANNEL: environment.DSH_DESKTOP_UPDATE_CHANNEL }),
...(preset === undefined ? {} : { DSH_PACKAGING_PRESET: preset }),
}
}
@@ -0,0 +1,30 @@
/** Safe failure details shared by publication diagnostics and durable journals. */
import { ZodError } from 'zod'
import { DesktopPublishError } from './desktop-publish.ts'
const publicCodes = new Set(['AccessDenied', 'InternalError', 'NoSuchBucket', 'BadDigest', 'SignatureDoesNotMatch',
'RequestTimeout', 'TimeoutError', 'AbortError', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ENOSPC', 'EACCES', 'EPERM', 'ENOENT'])
/**
* Keep actionable local validation details without recording SDK response bodies or credentials.
* @param error Failure from validation, filesystem, subprocess or remote transport.
* @param phase Operation stage known by the entrypoint.
* @returns Credential-free diagnostic suitable for stderr and an audit event.
*/
export function desktopPublicationFailure(error: unknown, phase: string): { phase: string; code: string; message: string } {
if (error instanceof DesktopPublishError) return { phase, code: error.code, message: error.message }
if (error instanceof ZodError) return { phase, code: 'invalid-data',
message: `Invalid fields: ${error.issues.map(issue => `${issue.path.join('.') || '(root)'} (${issue.code})`).join(', ')}` }
if (error instanceof SyntaxError) return { phase, code: 'invalid-json', message: 'Invalid JSON; check the configuration or retained record for this stage.' }
const ownedMessage = /^(desktop (?:publish|release store|retention|rollback|tag|upload|auto-update|macOS signing|build)[^\n]*:|Usage:)/u
if (error instanceof Error && ownedMessage.test(error.message)) {
return { phase, code: 'validation', message: error.message }
}
const record = typeof error === 'object' && error !== null ? error : {}
const candidate = 'code' in record ? record.code : 'name' in record ? record.name : undefined
const code = typeof candidate === 'string' && publicCodes.has(candidate) ? candidate : 'operation-failed'
const status = 'statusCode' in record && typeof record.statusCode === 'number' ? ` (HTTP ${record.statusCode})` : ''
const path = ['ENOENT', 'EACCES', 'EPERM', 'ENOSPC'].includes(code) && 'path' in record && typeof record.path === 'string'
? `: ${record.path}` : ''
return { phase, code, message: `${code}${status}${path}. Check this stage's local files, signature verification or service permissions; raw provider output is omitted.` }
}
@@ -0,0 +1,46 @@
/** Read public deployment settings independently from upload and GitHub App secrets. */
import { readFile } from 'node:fs/promises'
import { isAbsolute, join } from 'node:path'
import { z } from 'zod'
const singleLine = z.string().min(1).regex(/^[^\r\n\0]+$/u, 'expected a single-line value')
const absolutePath = singleLine.refine(isAbsolute, 'expected an absolute path')
const schema = z.strictObject({
bucket: z.string().regex(/^[a-z0-9-]+-\d+$/u),
origin: z.url().refine((value) => { const url = new URL(value); return url.protocol === 'https:' && !url.username && !url.password && url.pathname === '/' && !url.search && !url.hash }, 'expected an HTTPS origin'),
credentialsFile: absolutePath,
macOS: z.strictObject({ signingIdentity: z.string().min(1), teamId: z.string().regex(/^[A-Z0-9]{10}$/u) }).optional(),
windows: z.strictObject({ certificateThumbprint: z.string().regex(/^[A-Fa-f0-9]{40}$/u) }).optional(),
githubApp: z.strictObject({ clientId: singleLine, privateKeyFile: absolutePath }).optional(),
})
/** Public machine configuration; all secret fields are file references. */
export type DesktopPublishConfig = z.infer<typeof schema>
/**
* Read one named deployment without opening referenced credential files.
* @param configRoot Absolute machine packaging configuration directory.
* @param preset Selected deployment from the retained package.
* @returns Validated public configuration; production requires App configuration.
*/
export async function readDesktopPublishConfig(configRoot: string, preset: 'test' | 'production'): Promise<DesktopPublishConfig> {
if (!isAbsolute(configRoot)) throw new Error('desktop publish: configuration root must be absolute')
const config = schema.parse(JSON.parse(await readFile(join(configRoot, 'publishing', `${preset}.json`), 'utf8')))
if (preset === 'production' && (config.origin !== 'https://download.deepseek.com' || !config.githubApp)) {
throw new Error('desktop publish: production requires its fixed origin and GitHub App configuration')
}
return config
}
/**
* Read upload credentials only after authorization and local validation have passed.
* @param config Previously validated public deployment configuration.
* @returns Explicit COS credential pair; contents never enter audit records.
*/
export async function readDesktopUploadCredentials(config: DesktopPublishConfig): Promise<{ secretId: string; secretKey: string }> {
try {
return z.strictObject({ secretId: z.string().min(1), secretKey: z.string().min(1) })
.parse(JSON.parse(await readFile(config.credentialsFile, 'utf8')))
} catch {
throw new Error('desktop publish: cannot read valid upload credentials from the configured file')
}
}
@@ -0,0 +1,84 @@
/** COS transport for resumable Desktop publication; callers authorize writes and own the channel lock. */
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { Readable, Writable } from 'node:stream'
import type COS from 'cos-nodejs-sdk-v5'
import { createDesktopCos, DESKTOP_COS_REGION, type DesktopCosCredentials } from './desktop-cos.ts'
import { cosOperation } from './cos-operation.ts'
import type { DesktopUploadPlan } from './desktop-upload-plan.ts'
import { desktopArtifactDigest, type DesktopPublishTransport } from './desktop-publish.ts'
function missing(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'NoSuchKey'
}
/**
* Create a bounded COS transport restricted to the validated plan's object keys.
* @param plan Destination and exact object inventory; arbitrary keys are rejected.
* @param credentials Credentials for this deployment only.
* @param createClient Client factory; absent uses the shared production client, tests supply a loopback client.
* @returns Streaming reads and single-attempt writes; transport construction performs no requests.
*/
export function desktopPublishCos(
plan: DesktopUploadPlan, credentials: DesktopCosCredentials,
createClient: (credentials: DesktopCosCredentials) => COS = createDesktopCos,
): DesktopPublishTransport {
const keys = new Map(plan.artifacts.map(artifact => [artifact.key, artifact.channelMetadata]))
const client = () => createClient(credentials)
const location = (key: string) => {
if (!keys.has(key)) throw new Error('desktop publish: object is outside the selected plan')
return { Bucket: plan.bucket, Region: DESKTOP_COS_REGION, Key: key }
}
return {
async read(key, channel) {
const address = location(key)
if (keys.get(key) !== channel) throw new Error('desktop publish: object role mismatch')
const sha512 = createHash('sha512')
const chunks: Buffer[] = []
let size = 0
const output = new Writable({
write(bytes: Buffer, _encoding, done) {
size += bytes.length
if (channel && size > 1024 * 1024) { done(new Error('desktop publish: channel metadata exceeds 1 MiB')); return }
sha512.update(bytes)
if (channel) chunks.push(Buffer.from(bytes))
done()
},
})
try {
const cos = client()
await cosOperation(cos, 900_000, () => cos.getObject({ ...address, Output: output }))
return { size, sha512: sha512.digest('base64'), ...(channel ? { contents: Buffer.concat(chunks).toString('utf8') } : {}) }
} catch (error) {
if (missing(error)) return null
throw error
} finally { output.destroy() }
},
async put(artifact, digest, forbidOverwrite) {
const address = location(artifact.key)
if (keys.get(artifact.key) !== artifact.channelMetadata || forbidOverwrite === artifact.channelMetadata) {
throw new Error('desktop publish: object write policy mismatch')
}
const actual = await desktopArtifactDigest(artifact)
if (actual.size !== digest.size || actual.sha512 !== digest.sha512) throw new Error('desktop publish: local input changed')
const cos = client()
// COS overwrite prevention requires versioning to be absent, not enabled or suspended.
if (forbidOverwrite) {
const state = await cosOperation(cos, 30_000, () => cos.getBucketVersioning({ Bucket: plan.bucket, Region: DESKTOP_COS_REGION }))
if (state.statusCode !== 200 || state.VersioningConfiguration.Status !== undefined) {
throw new Error('desktop publish: immutable objects require bucket versioning to be disabled')
}
}
const md5 = createHash('md5')
const bytes = artifact.contents === undefined ? createReadStream(artifact.path) : [Buffer.from(artifact.contents)]
for await (const chunk of bytes) md5.update(chunk)
const body = artifact.contents === undefined ? createReadStream(artifact.path) : Readable.from([Buffer.from(artifact.contents)])
try {
await cosOperation(cos, 900_000, () => cos.putObject({
...address, Body: body, ContentLength: digest.size, ContentType: artifact.contentType,
Headers: { 'Content-MD5': md5.digest('base64'), ...(forbidOverwrite ? { 'x-cos-forbid-overwrite': 'true' } : {}) },
}))
} finally { body.destroy() }
},
}
}
@@ -0,0 +1,110 @@
/** Machine-local channel exclusion and durable, credential-free publication records. */
import { createHash } from 'node:crypto'
import { appendFile, mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { z } from 'zod'
import type { DesktopPublishEvent } from './desktop-publish.ts'
/**
* Execute one publication or rollback while excluding other writers of its exact destination.
* @param root Machine-owned lock directory shared by automatic and manual entrypoints.
* @param destination Bucket and channel prefix identifying the serialized destination.
* @param operation Work to finish before releasing the lock.
* @returns Operation result. A held or abandoned lock rejects; operators inspect stale locks explicitly.
*/
export async function withDesktopChannelLock<T>(root: string, destination: string, operation: () => Promise<T>): Promise<T> {
await mkdir(root, { recursive: true, mode: 0o700 })
const path = join(root, `${createHash('sha256').update(destination).digest('hex')}.lock`)
try { await mkdir(path, { mode: 0o700 }) } catch (cause) {
throw new Error(`desktop publish: channel is locked or inaccessible; inspect ${path}`, { cause })
}
try {
await writeFile(join(path, 'owner.json'), JSON.stringify({ pid: process.pid,
startedAt: new Date().toISOString(), destination }), { flag: 'wx', mode: 0o600, flush: true })
return await operation()
} finally { await rm(path, { recursive: true }) }
}
/** An operation journal retained independently of disposable checkout and artifact files. */
export interface DesktopPublicationRecord {
readonly directory: string
record(event: DesktopPublishEvent): Promise<void>
finish(status: 'complete' | 'preview' | 'failed' | 'published-tag-pending'): Promise<void>
}
/**
* Allocate a private journal before performing any remote writes.
* @param root Persistent audit root, outside the build checkout.
* @param identity Public source, actor and destination metadata; never pass credentials.
* @returns Durable event writer and explicit terminal-status writer.
*/
export async function createDesktopPublicationRecord(
root: string,
identity: { repository: string; runId: string; attempt: string; actor: string; destination: string; operation: 'publish' | 'rollback'; reason: string },
): Promise<DesktopPublicationRecord> {
await mkdir(root, { recursive: true, mode: 0o700 })
const staging = await mkdtemp(join(root, '.initializing-'))
const directory = join(root, `${identity.operation}-${basename(staging).slice('.initializing-'.length)}`)
const startedAt = new Date().toISOString()
try {
await writeFile(join(staging, 'operation.json'), `${JSON.stringify({ ...identity,
startedAt }, null, 2)}\n`, { flag: 'wx', mode: 0o600, flush: true })
await writeFile(join(staging, 'events.jsonl'), '', { flag: 'wx', mode: 0o600, flush: true })
await rename(staging, directory)
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}
return {
directory,
async record(event) {
await appendFile(join(directory, 'events.jsonl'), `${JSON.stringify({ ...event, at: new Date().toISOString() })}\n`, { flush: true })
},
async finish(status) {
await writeFile(join(directory, 'result.json'), `${JSON.stringify({ status,
startedAt, finishedAt: new Date().toISOString() })}\n`, { flag: 'wx', mode: 0o600, flush: true })
},
}
}
/**
* Find durable evidence that this immutable attempt finished updating its feeds.
* @param root Persistent publication journal root.
* @param expected Exact source and destination for the current retained package.
* @returns True permits tag-only recovery; false requires normal remote reconciliation.
*/
export async function wasDesktopAttemptPublished(root: string, expected: {
repository: string
runId: string
attempt: string
destination: string
}): Promise<boolean> {
let entries
try { entries = await readdir(root, { withFileTypes: true }) } catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return false
throw error
}
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.startsWith('publish-')) continue
const directory = join(root, entry.name)
let operation
try { operation = JSON.parse(await readFile(join(directory, 'operation.json'), 'utf8')) } catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') continue
throw error
}
const parsed = z.object({ operation: z.string(), repository: z.string(),
runId: z.string(), attempt: z.string(), destination: z.string() }).parse(operation)
if (parsed.operation !== 'publish' || parsed.repository !== expected.repository || parsed.runId !== expected.runId
|| parsed.attempt !== expected.attempt || parsed.destination !== expected.destination) continue
let events
try { events = await readFile(join(directory, 'events.jsonl'), 'utf8') } catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') continue
throw error
}
// Only newline-terminated entries were completely appended before an interruption.
for (const line of events.split('\n').slice(0, -1)) {
if (z.object({ type: z.string() }).parse(JSON.parse(line)).type === 'feeds-complete') return true
}
}
return false
}
@@ -0,0 +1,66 @@
/** Validate a completed packaging attempt and route publication back to its original runner. */
import { z } from 'zod'
const positive = z.number().int().positive()
const runSchema = z.object({
id: positive, run_attempt: positive, head_sha: z.string().regex(/^[a-f0-9]{40}$/u),
path: z.string(), conclusion: z.literal('success'), event: z.literal('workflow_dispatch'),
repository: z.object({ full_name: z.string() }),
})
const requestSchema = z.object({ source: z.object({
repository: z.string(), runId: z.string(), attempt: z.string(), commit: z.string().regex(/^[a-f0-9]{40}$/u),
workflowCommit: z.string(), sourceRef: z.string().regex(/^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u),
channel: z.enum(['nightly', 'latest']),
runnerName: z.string(), target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']),
publishRequested: z.literal(true), qualification: z.enum(['signed', 'notarized']),
}) })
const jobsSchema = z.array(z.object({ runner_name: z.string().nullable(), conclusion: z.string().nullable() }))
const routesSchema = z.record(z.string(), z.object({ label: z.string().regex(/^dsh-[a-z0-9-]+$/u), target: z.enum(['mac-arm64', 'win-x64']) }))
/** Validated values handed to the privileged publisher; no caller-selected shell fragments. */
export interface DesktopPublishRoute {
readonly runId: string
readonly attempt: string
readonly commit: string
readonly sourceRef: string
readonly channel: 'nightly' | 'latest'
readonly runnerName: string
readonly runnerLabel: string
readonly target: 'mac-arm64' | 'win-x64'
readonly preset: 'test' | 'production'
}
/**
* Match a public publication request to GitHub's attempt/job evidence and configured machine labels.
* @param repository Current repository, not a value selected by the artifact.
* @param runValue GitHub response for the exact packaging attempt.
* @param requestValue Retained manifest downloaded from that attempt's request artifact.
* @param jobsValue GitHub jobs for the same packaging attempt.
* @param routesValue Administrator-maintained runner-name to unique-label mapping.
* @returns Route to the original machine. Unknown runners, modes and mismatches reject.
*/
export function resolveDesktopPublishRoute(
repository: string, runValue: unknown, requestValue: unknown, jobsValue: unknown, routesValue: unknown,
): DesktopPublishRoute {
const run = runSchema.parse(runValue)
const { source } = requestSchema.parse(requestValue)
const jobs = jobsSchema.parse(jobsValue)
const routes = routesSchema.parse(routesValue)
const workflow = source.target === 'win-x64' ? '.github/workflows/windows-package.yml' : '.github/workflows/macos-package.yml'
if (run.repository.full_name !== repository || source.repository !== repository || run.path !== workflow
|| source.runId !== String(run.id) || source.attempt !== String(run.run_attempt)
|| source.workflowCommit !== run.head_sha
|| source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) {
throw new Error('desktop publish: request does not match the successful packaging attempt')
}
const route = routes[source.runnerName]
if (!route || route.target !== source.target) throw new Error('desktop publish: original machine has no configured publishing route')
if (new Set(Object.values(routes).map(value => value.label)).size !== Object.keys(routes).length) {
throw new Error('desktop publish: each configured machine needs its own unique runner label')
}
const matches = jobs.filter(job => job.runner_name === source.runnerName && job.conclusion === 'success')
if (matches.length !== 1) throw new Error('desktop publish: original job does not establish a single successful packaging runner')
return { runId: source.runId, attempt: source.attempt, commit: source.commit, sourceRef: source.sourceRef, channel: source.channel,
runnerName: source.runnerName,
runnerLabel: route.label, target: source.target, preset: source.preset }
}
@@ -0,0 +1,47 @@
/** Create immutable Desktop release tags using the explicitly supplied GitHub App installation token. */
import { valid } from 'semver'
import { z } from 'zod'
import { desktopReleaseTag } from './desktop-release-tag.ts'
const refSchema = z.object({ object: z.object({ type: z.string(), sha: z.string().regex(/^[a-f0-9]{40}$/u) }) })
/**
* Create a lightweight release tag, or verify that an existing tag names the same packaged commit.
* @param repository Owner/name of the package source repository.
* @param version Validated packaged version.
* @param commit Actual packaged commit, never the publishing workflow checkout commit.
* @param installationToken Explicit GitHub App token; no ambient authentication fallback is used.
* @param request HTTP implementation; defaults to fetch, tests inject an isolated fake.
* @returns Created or already-present status. Conflicts and unknown responses reject without updating a ref.
*/
export async function publishDesktopTag(
repository: string, version: string, commit: string, installationToken: string,
request: typeof fetch = fetch,
): Promise<'created' | 'present'> {
if (!/^[\w.-]+\/[\w.-]+$/u.test(repository) || valid(version) !== version || !/^[a-f0-9]{40}$/u.test(commit)) {
throw new Error('desktop tag: invalid repository, version or packaged commit')
}
if (!installationToken.trim()) throw new Error('desktop tag: GitHub App installation token is required')
const tag = desktopReleaseTag(version)
const base = `https://api.github.com/repos/${repository}/git`
const headers = { Authorization: `Bearer ${installationToken}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' }
const get = () => request(`${base}/ref/tags/${encodeURIComponent(tag)}`, { headers, redirect: 'error', signal: AbortSignal.timeout(30_000) })
const matches = async (response: Response) => {
if (!response.ok) throw new Error(`desktop tag: cannot verify tag (HTTP ${response.status})`)
const ref = refSchema.parse(await response.json())
if (ref.object.type !== 'commit') throw new Error('desktop tag: existing ref is not a lightweight commit tag; inspect it manually, refusing to replace it')
if (ref.object.sha !== commit) throw new Error('desktop tag: existing tag points to another commit; refusing to move it')
}
const existing = await get()
if (existing.status !== 404) { await matches(existing); return 'present' }
await existing.body?.cancel()
const response = await request(`${base}/refs`, {
method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ref: `refs/tags/${tag}`, sha: commit }), redirect: 'error', signal: AbortSignal.timeout(30_000),
})
if (response.status === 201) { await matches(response); return 'created' }
// A parallel platform release may have created the same version tag after our first read.
if (response.status === 422) { await response.body?.cancel(); await matches(await get()); return 'present' }
await response.body?.cancel()
throw new Error(`desktop tag: creation failed (HTTP ${response.status}); reconcile the tag before retrying`)
}
+132
View File
@@ -0,0 +1,132 @@
/** Resume Desktop publication by reconciling object bytes before changing update feeds. */
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { load } from 'js-yaml'
import { gt, valid } from 'semver'
import { z } from 'zod'
import type { DesktopUploadArtifact, DesktopUploadPlan } from './desktop-upload-plan.ts'
/** File identity computed from bytes, never inferred from COS ETags. */
export interface DesktopObjectDigest { readonly size: number; readonly sha512: string }
/** Remote object observation; channel objects additionally carry their exact YAML. */
export interface DesktopRemoteObject extends DesktopObjectDigest { readonly contents?: string }
/** Transport owned by the caller holding the destination channel lock. */
export interface DesktopPublishTransport {
/** Read and hash remote bytes; only a confirmed missing object returns null. */
read(key: string, channel: boolean): Promise<DesktopRemoteObject | null>
/** Write once. Immutable binaries must use COS overwrite prevention. */
put(artifact: DesktopUploadArtifact, digest: DesktopObjectDigest, forbidOverwrite: boolean): Promise<void>
}
/** Credential-free, durably flushed publication event. */
export interface DesktopPublishEvent {
readonly type: 'object-present' | 'put-intent' | 'put-verified' | 'feeds-complete' | 'tag-pending' | 'complete' | 'failed'
readonly failure?: { readonly phase: string; readonly code: string; readonly message: string }
readonly key?: string
readonly before?: string | null
readonly after?: string
}
/** Caller owns audit persistence and the separately authorized GitHub App identity. */
export interface DesktopPublishOperations {
readonly transport: DesktopPublishTransport
record(event: DesktopPublishEvent): Promise<void>
/** Ensure the production tag names the packaged commit; never move an existing tag. */
tag(): Promise<void>
}
/** Published files and feeds can succeed even when tag creation remains outstanding. */
export type DesktopPublishResult = { readonly status: 'complete' } | { readonly status: 'published-tag-pending'; readonly cause: unknown }
/** A publication failure that requires reconciliation rather than a blind retry. */
export class DesktopPublishError extends Error {
constructor(readonly code: 'conflict' | 'superseded' | 'invalid-plan' | 'remote-changed' | 'verification-failed', message: string) {
super(message)
this.name = 'DesktopPublishError'
}
}
/**
* Hash exactly the file or generated metadata selected by a validated upload plan.
* @param artifact File-backed installer or in-memory channel metadata.
* @returns Byte length and SHA-512 for input reconciliation.
*/
export async function desktopArtifactDigest(artifact: DesktopUploadArtifact): Promise<DesktopObjectDigest> {
const hash = createHash('sha512')
let size = 0
const stream = artifact.contents === undefined ? createReadStream(artifact.path) : [Buffer.from(artifact.contents)]
for await (const bytes of stream) { hash.update(bytes); size += bytes.length }
return { size, sha512: hash.digest('base64') }
}
function same(left: DesktopObjectDigest | null, right: DesktopObjectDigest | null): boolean {
return left === null || right === null ? left === right : left.size === right.size && left.sha512 === right.sha512
}
function version(contents: string, remote = false): string {
try {
const parsed = z.object({ version: z.string() }).parse(load(contents))
if (valid(parsed.version) !== null) return parsed.version
} catch (error) { /* Discard parser error details, which may include raw remote YAML. */ }
throw new DesktopPublishError(remote ? 'verification-failed' : 'invalid-plan',
`desktop publish: invalid ${remote ? 'remote' : 'planned'} channel version`)
}
/**
* Reconcile every binary, then publish feeds, then create the production tag.
* The caller must hold the channel lock through this entire operation. No writes are retried here;
* a later invocation hashes remote bytes to recover confirmed and ambiguous earlier writes.
* @param plan Validated package plan containing both binaries and channel metadata.
* @param operations Locked transport, durable audit sink, and production tag operation.
* @returns Complete or published-with-tag-pending; other failures stop all dependent work.
*/
export async function publishDesktopRelease(plan: DesktopUploadPlan, operations: DesktopPublishOperations): Promise<DesktopPublishResult> {
const files = plan.artifacts.filter(file => !file.channelMetadata)
const feeds = plan.artifacts.filter(file => file.channelMetadata)
if (files.length === 0 || feeds.length === 0 || new Set(plan.artifacts.map(file => file.key)).size !== plan.artifacts.length) {
throw new DesktopPublishError('invalid-plan', 'desktop publish: complete files and update feeds are required')
}
if (plan.environment === 'production' && (!plan.commit || !/^[a-f0-9]{40}$/u.test(plan.commit) || plan.dirty !== false)) {
throw new DesktopPublishError('invalid-plan', 'desktop publish: production requires a clean packaged commit')
}
const snapshots = new Map<string, DesktopRemoteObject | null>()
// Reject obsolete requests before even uploading their missing binary files.
for (const feed of feeds) {
if (feed.contents === undefined || version(feed.contents) !== plan.version) {
throw new DesktopPublishError('invalid-plan', 'desktop publish: channel version differs from package')
}
const remote = await operations.transport.read(feed.key, true)
if (remote !== null) {
if (remote.contents === undefined) throw new DesktopPublishError('verification-failed', 'desktop publish: channel bytes were not returned')
if (gt(version(remote.contents, true), plan.version)) throw new DesktopPublishError('superseded', 'desktop publish: a newer version already owns this channel')
if (version(remote.contents, true) === plan.version && !same(remote, await desktopArtifactDigest(feed))) {
throw new DesktopPublishError('conflict', 'desktop publish: same version has different channel metadata')
}
}
snapshots.set(feed.key, remote)
}
for (const artifact of [...files, ...feeds]) {
const digest = await desktopArtifactDigest(artifact)
const remote = await operations.transport.read(artifact.key, artifact.channelMetadata)
if (same(remote, digest)) {
await operations.record({ type: 'object-present', key: artifact.key })
continue
}
if (!artifact.channelMetadata && remote !== null) throw new DesktopPublishError('conflict', `desktop publish: object differs: ${artifact.key}`)
if (artifact.channelMetadata && !same(remote, snapshots.get(artifact.key) ?? null)) {
throw new DesktopPublishError('remote-changed', 'desktop publish: channel changed after preflight; inspect before retrying')
}
await operations.record({ type: 'put-intent', key: artifact.key,
...(artifact.channelMetadata ? { before: remote?.contents ?? null, after: artifact.contents } : {}) })
await operations.transport.put(artifact, digest, !artifact.channelMetadata)
const actual = await operations.transport.read(artifact.key, artifact.channelMetadata)
if (!same(actual, digest)) throw new DesktopPublishError('verification-failed', `desktop publish: remote bytes differ: ${artifact.key}`)
await operations.record({ type: 'put-verified', key: artifact.key })
}
await operations.record({ type: 'feeds-complete' })
if (plan.environment === 'production') {
try { await operations.tag() } catch (cause) {
await operations.record({ type: 'tag-pending' })
return { status: 'published-tag-pending', cause }
}
}
await operations.record({ type: 'complete' })
return { status: 'complete' }
}
@@ -0,0 +1,143 @@
/** Retain immutable Desktop build files outside disposable runner checkouts. */
import { createHash } from 'node:crypto'
import { createReadStream, constants } from 'node:fs'
import { copyFile, lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'
import { basename, isAbsolute, join } from 'node:path'
import { z } from 'zod'
const positiveId = z.string().regex(/^[1-9]\d*$/u)
const filename = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/u)
const sourceSchema = z.strictObject({
repository: z.string().regex(/^[\w.-]+\/[\w.-]+$/u),
runId: positiveId,
attempt: positiveId,
commit: z.string().regex(/^[a-f0-9]{40}$/u),
workflowCommit: z.string().regex(/^[a-f0-9]{40}$/u),
sourceRef: z.string().regex(/^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u),
channel: z.enum(['nightly', 'latest']),
runnerName: z.string().min(1),
// Storage accepts every Desktop target; CI routing separately limits currently provisioned platforms.
target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']),
preset: z.enum(['test', 'production']),
productVersion: z.string().min(1),
version: z.string().min(1),
testReleaseId: z.string().regex(/^[a-f0-9]{32}$/u).nullable(),
publishRequested: z.boolean(),
qualification: z.enum(['signed', 'notarized', 'unsigned', 'unnotarized']),
})
const manifestSchema = z.strictObject({
schemaVersion: z.literal(1),
source: sourceSchema,
files: z.array(z.strictObject({ name: filename, size: z.number().int().positive(),
sha512: z.string().regex(/^[A-Za-z0-9+/]{86}==$/u) })).min(1),
})
/** Public, credential-free identity of one packaging attempt. */
export type DesktopRetainedSource = z.infer<typeof sourceSchema>
/** File inventory bound to the source packaging attempt. */
export type DesktopRetainedManifest = z.infer<typeof manifestSchema>
async function fingerprint(path: string) {
const info = await lstat(path)
if (!info.isFile() || info.isSymbolicLink()) throw new Error('desktop release store: expected a regular file')
const hash = createHash('sha512')
let size = 0
for await (const bytes of createReadStream(path)) { size += bytes.length; hash.update(bytes) }
if (size === 0) throw new Error('desktop release store: empty artifact')
return { size, sha512: hash.digest('base64') }
}
async function directory(path: string): Promise<string> {
const info = await lstat(path)
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('desktop release store: expected a real directory')
return realpath(path)
}
/**
* Resolve an attempt beneath an absolute, machine-owned releases root.
* @param root Persistent releases directory, outside the checkout.
* @param runId GitHub packaging run ID.
* @param attempt Packaging attempt, not the publishing attempt.
* @returns Deterministic path after validating both identifiers.
*/
export function retainedDesktopPath(root: string, runId: string, attempt: string): string {
if (!isAbsolute(root)) throw new Error('desktop release store: root must be absolute')
positiveId.parse(runId)
positiveId.parse(attempt)
return join(root, runId, attempt)
}
/**
* Copy an explicit file inventory, verify the copies, then expose one complete attempt atomically.
* @param root Persistent releases root owned by this runner.
* @param source Packaging identity and selected public settings.
* @param paths Complete artifact and completion-record paths; directory trees are not accepted.
* @returns Published attempt directory. An existing attempt is never replaced.
*/
export async function retainDesktopRelease(root: string, source: DesktopRetainedSource, paths: readonly string[]): Promise<string> {
sourceSchema.parse(source)
const destination = retainedDesktopPath(root, source.runId, source.attempt)
const names = paths.map(path => filename.parse(basename(path)))
if (names.length === 0 || new Set(names).size !== names.length) throw new Error('desktop release store: empty or duplicate file inventory')
await mkdir(root, { recursive: true, mode: 0o700 })
await directory(root)
const parent = join(root, source.runId)
await mkdir(parent, { recursive: true, mode: 0o700 })
await directory(parent)
const claim = join(parent, `${source.attempt}.lock`)
await mkdir(claim, { mode: 0o700 })
let staging: string | undefined
try {
try {
await lstat(destination)
throw new Error('desktop release store: attempt already exists')
} catch (error) {
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error
}
staging = await mkdtemp(join(parent, '.retaining-'))
const files = []
for (const path of paths) {
const name = basename(path)
if (name === 'manifest.json') throw new Error('desktop release store: manifest filename is reserved')
const before = await fingerprint(path)
const copied = join(staging, name)
await copyFile(path, copied, constants.COPYFILE_EXCL)
const after = await fingerprint(copied)
if (before.size !== after.size || before.sha512 !== after.sha512) throw new Error('desktop release store: source changed during retention')
files.push({ name, ...after })
}
const manifest: DesktopRetainedManifest = { schemaVersion: 1, source, files }
manifestSchema.parse(manifest)
await writeFile(join(staging, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`, { flag: 'wx', mode: 0o600, flush: true })
await rename(staging, destination)
staging = undefined
return destination
} finally {
if (staging !== undefined) await rm(staging, { recursive: true, force: true })
await rm(claim, { recursive: true })
}
}
/**
* Read an attempt and rehash every retained file before a consumer uses it.
* @param root Persistent releases root.
* @param runId Expected source run ID.
* @param attempt Expected source attempt.
* @returns Parsed inventory; missing, linked, changed or mismatched files reject.
*/
export async function verifyRetainedDesktopRelease(root: string, runId: string, attempt: string): Promise<DesktopRetainedManifest> {
const path = retainedDesktopPath(root, runId, attempt)
await directory(root)
await directory(join(root, runId))
await directory(path)
const manifestPath = join(path, 'manifest.json')
if (!(await lstat(manifestPath)).isFile() || (await lstat(manifestPath)).isSymbolicLink()) throw new Error('desktop release store: invalid manifest file')
const manifest = manifestSchema.parse(JSON.parse(await readFile(manifestPath, 'utf8')))
if (manifest.source.runId !== runId || manifest.source.attempt !== attempt) throw new Error('desktop release store: attempt identity mismatch')
if (new Set(manifest.files.map(file => file.name)).size !== manifest.files.length) throw new Error('desktop release store: duplicate file inventory')
for (const file of manifest.files) {
const current = await fingerprint(join(path, file.name))
if (current.size !== file.size || current.sha512 !== file.sha512) throw new Error(`desktop release store: changed file ${file.name}`)
}
return manifest
}
+58
View File
@@ -0,0 +1,58 @@
/** Restore historical update feeds without uploading binaries or moving release tags. */
import { load } from 'js-yaml'
import { z } from 'zod'
import type { DesktopUploadPlan } from './desktop-upload-plan.ts'
import { desktopArtifactDigest, type DesktopPublishOperations, type DesktopRemoteObject } from './desktop-publish.ts'
/** Outcome of an explicitly requested channel restoration. */
export interface DesktopRollbackResult {
readonly status: 'preview' | 'complete'
readonly feeds: readonly { key: string; before: string; after: string }[]
}
/**
* Verify historical binaries and restore only the selected feed YAML under a caller-held channel lock.
* @param plan Previously published release reconstructed from retained immutable files.
* @param expectedVersion Version the operator expects to find online; another version stops the operation.
* @param execute False only inspects and previews; true writes after all historical bytes are verified.
* @param operations Remote transport and audit sink. The tag operation is never called.
* @returns Before/after YAML and whether it was written. Partial writes remain recorded for reconciliation.
*/
export async function rollbackDesktopChannel(
plan: DesktopUploadPlan, expectedVersion: string, execute: boolean, operations: DesktopPublishOperations,
): Promise<DesktopRollbackResult> {
const files = plan.artifacts.filter(file => !file.channelMetadata)
const feeds = plan.artifacts.filter(file => file.channelMetadata)
if (files.length === 0 || feeds.length === 0) throw new Error('desktop rollback: a complete historical release is required')
const equal = (left: DesktopRemoteObject | null, right: { size: number; sha512: string }) =>
left !== null && left.size === right.size && left.sha512 === right.sha512
for (const file of files) {
if (!equal(await operations.transport.read(file.key, false), await desktopArtifactDigest(file))) {
throw new Error(`desktop rollback: historical object is missing or different: ${file.key}`)
}
}
const changes = []
for (const feed of feeds) {
const before = await operations.transport.read(feed.key, true)
if (!before || before.contents === undefined || feed.contents === undefined) throw new Error('desktop rollback: channel YAML is unavailable')
if (equal(before, await desktopArtifactDigest(feed))) {
await operations.record({ type: 'object-present', key: feed.key })
continue
}
const current = z.object({ version: z.string() }).parse(load(before.contents)).version
if (current !== expectedVersion) throw new Error('desktop rollback: current version differs from the expected version')
changes.push({ key: feed.key, before: before.contents, after: feed.contents, observed: before, artifact: feed })
}
if (execute) {
for (const change of changes) {
if (!equal(await operations.transport.read(change.key, true), change.observed)) throw new Error('desktop rollback: channel changed after preview')
await operations.record({ type: 'put-intent', key: change.key, before: change.before, after: change.after })
const digest = await desktopArtifactDigest(change.artifact)
await operations.transport.put(change.artifact, digest, false)
if (!equal(await operations.transport.read(change.key, true), digest)) throw new Error('desktop rollback: restored channel could not be verified')
await operations.record({ type: 'put-verified', key: change.key })
}
await operations.record({ type: 'complete' })
}
return { status: execute ? 'complete' : 'preview', feeds: changes.map(({ key, before, after }) => ({ key, before, after })) }
}
+7 -8
View File
@@ -5,7 +5,6 @@ import { createReadStream } from 'node:fs'
import { readFile, stat } from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import { dump, load } from 'js-yaml'
import { prerelease } from 'semver'
import type { DesktopPackageTargetName } from './package-target.ts'
import {
desktopBuildRecordFilename,
@@ -63,6 +62,8 @@ export interface DesktopUploadPlanOptions {
readonly repositoryRoot?: string
readonly appRoot?: string
readonly artifactsRoot?: string
/** Original product version from a verified retained build; absent reads the checkout manifests. */
readonly packagedProductVersion?: string
}
interface UpdateFileInfo {
@@ -188,8 +189,8 @@ export async function createDesktopUploadPlan(
const repositoryRoot = options.repositoryRoot ?? REPOSITORY_ROOT
const appRoot = options.appRoot ?? APP_ROOT
const artifactsRoot = options.artifactsRoot ?? desktopTargetBuildPaths(targetName).artifacts
const dshVersion = await manifestVersion(join(repositoryRoot, 'package.json'), 'dsh package')
const desktopVersion = await manifestVersion(join(appRoot, 'package.json'), 'desktop package')
const dshVersion = options.packagedProductVersion ?? await manifestVersion(join(repositoryRoot, 'package.json'), 'dsh package')
const desktopVersion = options.packagedProductVersion ?? await manifestVersion(join(appRoot, 'package.json'), 'desktop package')
if (dshVersion !== desktopVersion) {
throw new Error(`desktop upload: desktop version ${desktopVersion} does not match current dsh version ${dshVersion}`)
}
@@ -211,12 +212,14 @@ export async function createDesktopUploadPlan(
}
if (buildRecord.schemaVersion !== 1
|| buildRecord.target !== targetName
|| (buildRecord.channel ?? 'nightly') !== update.channel
|| buildRecord.environment !== update.environment
|| buildRecord.publicUrl !== update.publicUrl) {
throw new Error(`desktop upload: ${targetName} package completion record for ${buildVersion} does not match the ${update.environment} update destination`)
}
const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform)
if (update.channel === 'latest' && buildVersion.includes('-')) throw new Error('desktop upload: latest requires a stable version')
const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform, update.channel)
const metadataPath = join(artifactsRoot, metadataFilename)
let metadataValue: unknown
try {
@@ -274,10 +277,6 @@ export async function createDesktopUploadPlan(
contents: dump(published),
}
artifacts.push(channelArtifact)
if (prerelease(buildVersion) === null) {
const stableFilename = metadataFilename.replace('nightly', 'latest')
artifacts.push({ ...channelArtifact, filename: stableFilename, key: `${update.keyPrefix}/${stableFilename}` })
}
const latestFilename = `dsh-latest-${target.platform === 'darwin' ? 'macos' : 'windows'}-${target.arch}.${target.platform === 'darwin' ? 'dmg' : 'exe'}`
const latestKey = `desktop/${latestFilename}`
return {
@@ -101,12 +101,14 @@ export function createElectronBuilderConfig(
// the artifact names, the update feed, and the installed app.getVersion() the updater compares against.
const productVersion = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version
const buildVersion = resolveDesktopBuildVersion(env, productVersion)
if (update?.channel === 'latest' && buildVersion.includes('-')) throw new Error('desktop package: latest requires a stable version')
const packaged = resolveDesktopBuildCommit(env)
return {
appId,
protocols: [{ name: 'DeepSeek Harness', schemes: ['dsh'] }],
extraMetadata: {
dshDesktopAppId: appId,
dshDesktopUpdateChannel: update?.channel ?? 'nightly',
...(policy === undefined ? {} : { dshMandatoryUpdatePolicy: policy }),
...buildVersion === productVersion ? {} : { version: buildVersion },
...packaged === undefined ? {} : { dshBuildCommit: packaged.commit, dshBuildDirty: packaged.dirty },
@@ -259,6 +261,6 @@ export function createElectronBuilderConfig(
differentialPackage: true,
},
detectUpdateChannel: false,
publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl, channel: 'nightly' }],
publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl, channel: update.channel }],
}
}
@@ -1,13 +1,14 @@
/** Resolved fields required to embed a macOS updater feed. */
export interface MacOSAppUpdateFeed {
readonly publicUrl: string
readonly channel?: 'nightly' | 'latest'
}
/** Packaged electron-updater configuration for macOS. */
export interface MacOSAppUpdateConfig {
readonly provider: 'generic'
readonly url: string
readonly channel: 'nightly'
readonly channel: 'nightly' | 'latest'
readonly updaterCacheDirName: string
}
@@ -5,7 +5,7 @@ import { join } from 'node:path'
import { dump, load } from 'js-yaml'
const CONFIG_FILENAME = 'app-update.yml'
const CHANNEL = 'nightly'
import { resolveDesktopUpdateChannel } from './desktop-auto-update-environment.mjs'
function object(value, label) {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
@@ -24,30 +24,30 @@ function nonEmptyString(value, label) {
/**
* Resolve the one generic macOS feed from the final electron-builder configuration.
* @param {unknown} publish - Final electron-builder publish setting.
* @returns {{ publicUrl: string }} Resolved feed used by the packaged App.
* @returns {{ publicUrl: string, channel?: 'nightly' | 'latest' }} Resolved feed used by the packaged App.
*/
export function resolveMacOSAppUpdateFeed(publish) {
if (!Array.isArray(publish) || publish.length !== 1) {
throw new Error('desktop macOS update config: publish must contain exactly one provider')
}
const provider = object(publish[0], 'publish provider')
if (provider.provider !== 'generic' || provider.channel !== CHANNEL) {
throw new Error('desktop macOS update config: publish provider must be generic Nightly')
if (provider.provider !== 'generic' || !['nightly', 'latest'].includes(provider.channel)) {
throw new Error('desktop macOS update config: publish provider must be generic with nightly or latest channel')
}
return { publicUrl: nonEmptyString(provider.url, 'publish provider URL') }
return { publicUrl: nonEmptyString(provider.url, 'publish provider URL'), channel: resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: provider.channel }) }
}
/**
* Create the electron-updater configuration embedded before code signing.
* @param {{ publicUrl: string }} update - Resolved update feed.
* @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Resolved update feed.
* @param {string} updaterCacheDirName - electron-builder application cache directory.
* @returns {{ provider: 'generic', url: string, channel: 'nightly', updaterCacheDirName: string }} Packaged updater fields.
* @returns {{ provider: 'generic', url: string, channel: 'nightly' | 'latest', updaterCacheDirName: string }} Packaged updater fields.
*/
export function createMacOSAppUpdateConfig(update, updaterCacheDirName) {
return {
provider: 'generic',
url: nonEmptyString(update.publicUrl, 'public URL'),
channel: CHANNEL,
channel: resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: update.channel }),
updaterCacheDirName: nonEmptyString(updaterCacheDirName, 'updater cache directory'),
}
}
@@ -55,7 +55,7 @@ export function createMacOSAppUpdateConfig(update, updaterCacheDirName) {
/**
* Write the updater configuration into an assembled App before signing.
* @param {string} resourcesDir - App Contents/Resources directory.
* @param {{ publicUrl: string }} update - Resolved update feed.
* @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Resolved update feed.
* @param {string} updaterCacheDirName - electron-builder application cache directory.
* @returns {Promise<void>} Resolves after the configuration is durable.
*/
@@ -67,7 +67,7 @@ export async function writeMacOSAppUpdateConfig(resourcesDir, update, updaterCac
/**
* Verify the updater configuration inside an assembled macOS App.
* @param {string} appPath - Application bundle path.
* @param {{ publicUrl: string }} update - Expected update feed.
* @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Expected update feed.
* @param {string | undefined} updaterCacheDirName - Exact cache directory when known.
* @returns {Promise<void>} Resolves when the packaged configuration matches the release destination.
*/
@@ -81,7 +81,7 @@ export async function verifyMacOSAppUpdateConfig(appPath, update, updaterCacheDi
throw new Error(`desktop macOS update config: cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`)
}
const config = object(parsed, CONFIG_FILENAME)
if (config.provider !== 'generic' || config.url !== update.publicUrl || config.channel !== CHANNEL) {
if (config.provider !== 'generic' || config.url !== update.publicUrl || config.channel !== resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: update.channel })) {
throw new Error(`desktop macOS update config: ${path} does not match ${update.publicUrl}`)
}
const actualCacheDirName = nonEmptyString(config.updaterCacheDirName, `${CONFIG_FILENAME}.updaterCacheDirName`)
+1 -1
View File
@@ -115,7 +115,7 @@ export async function packageMacOSArtifacts(
[dmgOutput, `${base}.dmg`],
[zipOutput, `${base}.zip`],
[zipOutput, `${base}.zip.blockmap`],
[zipOutput, desktopUpdateMetadataFilename(version, 'darwin')],
[zipOutput, desktopUpdateMetadataFilename(version, 'darwin', update.channel)],
] as const
for (const [output, filename] of artifacts) {
const file = join(output, filename)
+1
View File
@@ -153,6 +153,7 @@ function writeReleaseRecord(
target: target.name,
version: buildVersion,
environment: update.environment,
channel: update.channel,
publicUrl: update.publicUrl,
// Upload reads this to tag the commit a production release was packaged from.
...packaged === undefined ? {} : { commit: packaged.commit, dirty: packaged.dirty },
+3 -1
View File
@@ -1,6 +1,7 @@
/** Validate machine-owned macOS signing settings and record a manual Actions build version. */
import { appendFileSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { execFileSync } from 'node:child_process'
import { desktopCiVersion } from './desktop-ci-version.mjs'
import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs'
@@ -9,6 +10,7 @@ const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8'
const version = desktopCiVersion(process.env, product.version)
if (!process.env.DSH_PACKAGING_PRESET) throw new Error('Select a machine preset')
const skipNotarization = process.env.PACKAGE_SKIP_NOTARIZATION === 'true'
if (process.env.PACKAGE_PUBLISH === 'true' && skipNotarization) throw new Error('Publishing requires notarization and stapling')
if (!['true', 'false', undefined].includes(process.env.PACKAGE_SKIP_NOTARIZATION)) throw new Error('Invalid skip notarization selection')
const environment = loadDesktopPackageEnvironment('darwin', process.env, appRoot)
for (const [name, value] of Object.entries(environment)) {
@@ -20,5 +22,5 @@ for (const [name, value] of Object.entries(environment)) {
validateDesktopPackageEnvironment(environment, { platform: 'darwin', arch: 'arm64' }, { skipNotarization })
if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions')
appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\nartifacts=${skipNotarization ? 'unnotarized-artifacts' : 'artifacts'}\nmode=${skipNotarization ? 'unnotarized-debug' : 'signed'}\n`)
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `macOS arm64 signed installer\n\nVersion: ${version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`)
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `macOS arm64 signed installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`)
if (skipNotarization) appendFileSync(process.env.GITHUB_STEP_SUMMARY, '\nWARNING: signed but NOT notarized or stapled. Internal debugging only; Gatekeeper may block downloaded copies. Not qualified for distribution or update publishing.\n')
+3 -1
View File
@@ -1,6 +1,7 @@
/** Resolve a manual Windows build version and validate the selected machine preset. */
import { appendFileSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { execFileSync } from 'node:child_process'
import { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs'
export { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs'
import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs'
@@ -10,6 +11,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi
const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8'))
const version = windowsCiVersion(process.env, product.version)
const mode = process.env.PACKAGE_SIGNING
if (process.env.PACKAGE_PUBLISH === 'true' && mode !== 'signed') throw new Error('Publishing requires signed Windows packages')
if (mode !== 'unsigned' && mode !== 'signed') throw new Error('Select unsigned or signed packaging')
if (!process.env.DSH_PACKAGING_PRESET) throw new Error('Select a machine preset')
const environment = loadDesktopPackageEnvironment('win32', process.env, appRoot, { unsigned: mode === 'unsigned' })
@@ -23,5 +25,5 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi
validateDesktopPackageEnvironment(environment, { platform: 'win32', arch: 'x64' }, { unsigned: mode === 'unsigned' })
if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions')
appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`)
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 ${mode} installer\n\nVersion: ${version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`)
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 ${mode} installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`)
}
+126
View File
@@ -0,0 +1,126 @@
/** Publish a retained package selected by the routing job; --check never reads secrets or contacts COS. */
import { createHash } from 'node:crypto'
import { appendFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { z } from 'zod'
import { readDesktopPublishConfig, readDesktopUploadCredentials } from './desktop-publish-config.ts'
import { desktopReleasesRoot } from './retain-desktop-ci.ts'
import { retainedDesktopPath, verifyRetainedDesktopRelease } from './desktop-release-store.ts'
import { verifyRetainedDesktopSignatures } from './verify-retained-desktop.ts'
import { createDesktopUploadPlan } from './desktop-upload-plan.ts'
import { desktopPublishCos } from './desktop-publish-cos.ts'
import { createDesktopPublicationRecord, wasDesktopAttemptPublished, withDesktopChannelLock } from './desktop-publish-record.ts'
import { publishDesktopRelease } from './desktop-publish.ts'
import { desktopPublicationFailure } from './desktop-publication-failure.ts'
import { publishDesktopTag } from './desktop-publish-tag.ts'
const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(),
sourceRef: z.string(), channel: z.enum(['nightly', 'latest']),
target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']) })
/**
* Check retained bytes, original machine, destination and signatures before reading any upload secret.
* @param env Router output and machine configuration directory.
* @param verifySignatures Platform verifier; tests inject a verifier for fixture bytes.
* @returns Validated local publication context; no network operation is performed.
*/
export async function prepareDesktopPublication(env: NodeJS.ProcessEnv, verifySignatures = verifyRetainedDesktopSignatures) {
const route = routeSchema.parse(JSON.parse(env.DESKTOP_PUBLISH_ROUTE || '{}'))
if (route.runnerName !== env.RUNNER_NAME) throw new Error('desktop publish: task was dispatched to a different machine')
const root = desktopReleasesRoot(env)
const manifest = await verifyRetainedDesktopRelease(root, route.runId, route.attempt)
const source = manifest.source
if (source.repository !== env.GITHUB_REPOSITORY || source.runnerName !== route.runnerName || source.target !== route.target
|| source.channel !== route.channel || source.sourceRef !== route.sourceRef || source.commit !== route.commit
|| source.preset !== route.preset || !source.publishRequested) {
throw new Error('desktop publish: local package differs from the routed request')
}
if (source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) throw new Error('desktop publish: debugging artifacts cannot be published')
const config = await readDesktopPublishConfig(env.DSH_PACKAGING_CONFIG_DIR!, source.preset)
const directory = retainedDesktopPath(root, route.runId, route.attempt)
const environment = source.preset === 'test'
? { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: config.origin,
DOWNLOAD_TEST_RELEASE_ID: source.testReleaseId ?? '', DOWNLOAD_TEST_COS_BUCKET: config.bucket }
: { DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: config.bucket }
const plan = await createDesktopUploadPlan(source.target, { artifactsRoot: directory,
packagedProductVersion: source.productVersion, environment: { ...environment, DSH_DESKTOP_UPDATE_CHANNEL: source.channel } })
if (plan.version !== source.version || plan.commit !== source.commit || plan.dirty !== false) throw new Error('desktop publish: completion record differs from retained source or has uncommitted changes')
await verifySignatures(directory, manifest, config)
const approval = createHash('sha256').update(JSON.stringify({ manifest, plan })).digest('hex')
if (env.DESKTOP_APPROVED_PACKAGE && env.DESKTOP_APPROVED_PACKAGE !== approval) throw new Error('desktop publish: package or destination changed after approval')
return { route, manifest, config, plan, root, approval }
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
let phase = 'local package, destination and signature verification'
try {
const mode = process.argv[2]
if (process.argv.length !== 3 || (mode !== '--check' && mode !== '--publish')) throw new Error('Usage: publish-desktop-ci.ts --check|--publish')
const env = process.env
const { route, manifest, config, plan, root, approval } = await prepareDesktopPublication(env)
if (mode === '--publish' && !env.DESKTOP_APPROVED_PACKAGE) throw new Error('desktop publish: approved package digest is required')
if (mode === '--check') {
if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `approval=${approval}\nlabel=${plan.version} → ${manifest.source.channel} / ${plan.target} / ${plan.environment}\n`)
if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY,
`\n## Confirm Desktop publication\n\nVersion: **${plan.version}**; channel: **${manifest.source.channel}**; environment: **${plan.environment}**; platform: **${plan.target}**\n\nSource: ${manifest.source.sourceRef} @ ${route.commit}\n\nPackage: ${route.runId}, attempt ${route.attempt}\n\nTag: ${plan.environment === 'production' ? `desktop-v${plan.version}` : 'none (test)'}\n\nApproved package SHA-256: ${approval}\n\n` +
plan.artifacts.map(file => `- ${file.channelMetadata ? 'Feed' : 'Artifact'}: ${file.key}`).join('\n') + '\n')
if (env.GITHUB_OUTPUT && config.githubApp) await appendFile(env.GITHUB_OUTPUT,
`app-client-id=${config.githubApp.clientId}\napp-key-file=${config.githubApp.privateKeyFile}\n`)
process.stdout.write(`desktop publish: verified ${plan.target} ${plan.version}; no credentials read or COS requests made\n`)
} else {
if (plan.environment === 'production' && !env.DESKTOP_TAG_TOKEN) throw new Error('desktop publish: GitHub App token is required before production upload')
phase = 'upload credential loading'
const credentials = await readDesktopUploadCredentials(config)
const destination = `${plan.bucket}/${plan.publicUrl}${manifest.source.channel}`
const recordsRoot = join(dirname(root), 'upload-records')
phase = 'channel lock and publication journal acquisition'
await withDesktopChannelLock(join(dirname(root), 'publish-locks'), destination, async () => {
const published = await wasDesktopAttemptPublished(recordsRoot,
{ repository: manifest.source.repository, runId: route.runId, attempt: route.attempt, destination })
const journal = await createDesktopPublicationRecord(recordsRoot, {
repository: manifest.source.repository, runId: route.runId, attempt: route.attempt,
actor: env.GITHUB_TRIGGERING_ACTOR || env.GITHUB_ACTOR || 'unknown',
destination, operation: 'publish', reason: 'Actions publication',
})
if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `record=${journal.directory}\n`)
let status: 'complete' | 'failed' | 'published-tag-pending' = 'failed'
try {
phase = 'remote object reconciliation and publication'
const tag = async () => { await publishDesktopTag(manifest.source.repository,
plan.version, route.commit, env.DESKTOP_TAG_TOKEN || '') }
const result = published
? await (async () => {
await journal.record({ type: 'feeds-complete' })
if (plan.environment === 'production') {
try { await tag() } catch (cause) {
await journal.record({ type: 'tag-pending' })
return { status: 'published-tag-pending' as const, cause }
}
}
await journal.record({ type: 'complete' })
return { status: 'complete' as const }
})()
: await publishDesktopRelease(plan, {
transport: desktopPublishCos(plan, credentials), record: event => journal.record(event),
tag,
})
status = result.status
if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY,
`\nDesktop publication: **${status}**\n\n${plan.target} ${plan.version}\n\nSource: ${route.runId}, attempt ${route.attempt}\n`)
if (result.status === 'published-tag-pending') {
const failure = desktopPublicationFailure(result.cause, 'release tag creation')
await journal.record({ type: 'failed', failure })
process.stderr.write(`${JSON.stringify(failure)}\n`)
process.exitCode = 1
}
} catch (error) {
await journal.record({ type: 'failed', failure: desktopPublicationFailure(error, phase) })
throw error
} finally { await journal.finish(status) }
})
}
} catch (error) {
process.stderr.write(`${JSON.stringify(desktopPublicationFailure(error, phase))}\n`)
process.exitCode = 1
}
}
@@ -0,0 +1,14 @@
/** Hand a machine App key to the token action after Environment approval, masking before output. */
import { readFileSync, appendFileSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
try {
if (!process.env.APP_KEY_FILE || !process.env.GITHUB_OUTPUT) throw new Error('Actions key file and output are required')
const key = readFileSync(process.env.APP_KEY_FILE, 'utf8')
console.log('::add-mask::' + key.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A'))
const delimiter = randomUUID()
appendFileSync(process.env.GITHUB_OUTPUT, `private-key<<${delimiter}\n${key}\n${delimiter}\n`)
} catch {
process.stderr.write('Desktop publication: cannot hand the configured App key to the token action.\n')
process.exitCode = 1
}
@@ -0,0 +1,57 @@
/** Resolve a workflow_run publication request without reading machine or COS credentials. */
import { execFileSync } from 'node:child_process'
import { appendFile, mkdir, readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { z } from 'zod'
import { resolveDesktopPublishRoute } from './desktop-publish-route.ts'
const eventSchema = z.object({ workflow_run: z.object({ id: z.number().int().positive(), run_attempt: z.number().int().positive() }) })
const artifactsSchema = z.object({ artifacts: z.array(z.object({ name: z.string(), expired: z.boolean() })) })
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
const env = process.env
if (!env.GITHUB_EVENT_PATH || !env.GITHUB_REPOSITORY || !env.GITHUB_OUTPUT || !env.RUNNER_TEMP) throw new Error('desktop publish: Actions context required')
const event = eventSchema.parse(JSON.parse(await readFile(env.GITHUB_EVENT_PATH, 'utf8')))
const repository = env.GITHUB_REPOSITORY
if (!/^[\w.-]+\/[\w.-]+$/u.test(repository)) throw new Error('desktop publish: invalid repository')
const { id, run_attempt: attempt } = event.workflow_run
const api = (path: string): unknown => JSON.parse(execFileSync('gh', ['api',
path], { encoding: 'utf8', timeout: 60_000, maxBuffer: 8 * 1024 * 1024 }))
const base = `repos/${repository}/actions/runs/${id}`
const name = `desktop-publish-request-${id}-${attempt}`
const pages: unknown = JSON.parse(execFileSync('gh', ['api', '--paginate', '--slurp',
`${base}/artifacts?per_page=100`], { encoding: 'utf8', timeout: 60_000, maxBuffer: 8 * 1024 * 1024 }))
const match = z.array(artifactsSchema).parse(pages).flatMap(page => page.artifacts)
.find(artifact => artifact.name === name)
if (match === undefined) {
await appendFile(env.GITHUB_OUTPUT, 'publish=false\n')
} else {
if (match.expired) throw new Error('desktop publish: request artifact expired')
const destination = join(env.RUNNER_TEMP, `desktop-request-${id}-${attempt}-${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`)
await mkdir(destination, { recursive: false, mode: 0o700 })
execFileSync('gh', ['run', 'download', String(id), '--repo', repository,
'--name', name, '--dir', destination], { timeout: 60_000, stdio: 'pipe' })
const request: unknown = JSON.parse(await readFile(join(destination, 'manifest.json'), 'utf8'))
const jobs = z.object({ jobs: z.array(z.unknown()) }).parse(api(`${base}/attempts/${attempt}/jobs?per_page=100`))
const route = resolveDesktopPublishRoute(repository, api(`${base}/attempts/${attempt}`),
request, jobs.jobs, JSON.parse(env.DESKTOP_PUBLISH_RUNNERS || '{}'))
const source = z.object({ sha: z.string() }).parse(api(`repos/${repository}/commits/${route.commit}`))
if (source.sha !== route.commit) throw new Error('desktop publish: source commit not found')
if (route.sourceRef === 'master') {
const comparison = z.object({ status: z.string() }).parse(api(`repos/${repository}/compare/${route.commit}...master`))
if (!['ahead', 'identical'].includes(comparison.status)) throw new Error('desktop publish: source is not on master')
} else {
const tagged = z.object({ sha: z.string() }).parse(api(`repos/${repository}/commits/${route.sourceRef}`))
if (tagged.sha !== route.commit) throw new Error('desktop publish: source tag changed')
}
{
const environment = z.object({ protection_rules: z.array(z.object({ type: z.string(),
reviewers: z.array(z.unknown()).optional() })) })
.parse(api(`repos/${repository}/environments/desktop-${route.preset}`))
if (!environment.protection_rules.some(rule => rule.type === 'required_reviewers' && (rule.reviewers?.length ?? 0) > 0)) {
throw new Error(`desktop publish: desktop-${route.preset} requires configured reviewers`)
}
}
await appendFile(env.GITHUB_OUTPUT, `publish=true\nroute=${JSON.stringify(route)}\nrunner=${route.runnerLabel}\npreset=${route.preset}\n`)
}
}
+81
View File
@@ -0,0 +1,81 @@
/** Retain completed CI packages and a credential-free request for the publishing workflow. */
import { appendFile, readFile } from 'node:fs/promises'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs'
import { desktopBuildRecordFilename, desktopUpdateMetadataFilename, resolveDesktopUpdateChannel } from './desktop-auto-update-environment.mjs'
import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
import { retainDesktopRelease, type DesktopRetainedSource } from './desktop-release-store.ts'
import { validateDesktopBuildVersion } from './desktop-build-version.mjs'
function required(env: NodeJS.ProcessEnv, name: string): string {
const value = env[name]
if (!value) throw new Error(`desktop retention: ${name} is required`)
return value
}
/**
* Resolve the persistent release root without assuming a particular user's home directory.
* @param env Machine packaging configuration; an explicit releases directory overrides its sibling default.
* @returns Absolute directory outside the disposable checkout.
*/
export function desktopReleasesRoot(env: NodeJS.ProcessEnv): string {
const configured = required(env, 'DSH_PACKAGING_CONFIG_DIR')
const root = env.DSH_DESKTOP_RELEASES_DIR || join(dirname(configured), 'releases')
if (!isAbsolute(configured) || !isAbsolute(root)) throw new Error('desktop retention: machine directories must be absolute')
return root
}
/**
* Save the complete installer inventory for a successful package job, without reading signing secrets.
* @param env Actions identity, resolved package version and machine preset selection.
* @param appRoot Desktop checkout directory; defaults to this script's owning app.
* @returns Retained directory. Publish requests for debugging packages reject before copying.
*/
export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve(import.meta.dirname, '..')): Promise<string> {
const target = resolveDesktopBuildTarget(env)
const windows = target === 'win-x64'
const platform = windows ? 'win32' : 'darwin'
const settings = loadDesktopPackageEnvironment(platform, env, appRoot, { unsigned: true })
const preset = settings.DSH_PACKAGING_PRESET
if (preset !== 'test' && preset !== 'production') throw new Error('desktop retention: a machine preset is required')
const publish = env.PACKAGE_PUBLISH === 'true'
if (!['true', 'false', undefined].includes(env.PACKAGE_PUBLISH)) throw new Error('desktop retention: invalid publish selection')
if (windows && !['signed', 'unsigned'].includes(env.PACKAGE_SIGNING ?? '')) throw new Error('desktop retention: invalid signing selection')
if (!windows && !['true', 'false'].includes(env.PACKAGE_SKIP_NOTARIZATION ?? '')) throw new Error('desktop retention: invalid notarization selection')
const qualification = windows ? (env.PACKAGE_SIGNING === 'signed' ? 'signed' : 'unsigned')
: (env.PACKAGE_SKIP_NOTARIZATION === 'true' ? 'unnotarized' : 'notarized')
if (publish && (qualification === 'unsigned' || qualification === 'unnotarized')) {
throw new Error('desktop retention: publishing requires signed Windows or notarized macOS packages')
}
const product: { version: string } = JSON.parse(await readFile(join(appRoot, 'package.json'), 'utf8'))
const version = validateDesktopBuildVersion(required(env, 'PACKAGE_VERSION'), product.version)
const source: DesktopRetainedSource = {
repository: required(env, 'GITHUB_REPOSITORY'), runId: required(env, 'GITHUB_RUN_ID'),
attempt: required(env, 'GITHUB_RUN_ATTEMPT'), commit: required(env, 'PACKAGE_SOURCE_SHA'),
workflowCommit: required(env, 'GITHUB_SHA'), sourceRef: required(env, 'PACKAGE_SOURCE_REF'),
channel: resolveDesktopUpdateChannel(settings),
runnerName: required(env, 'RUNNER_NAME'), target, preset, productVersion: product.version, version,
testReleaseId: preset === 'test' ? (settings.DOWNLOAD_TEST_RELEASE_ID || null) : null,
publishRequested: publish, qualification,
}
const paths = desktopTargetBuildPaths(target)
// The CLI's checkout owns these paths; alternate app roots are used by fixtures.
const mode = qualification === 'unsigned' ? 'unsigned-artifacts' : qualification === 'unnotarized' ? 'unnotarized-artifacts' : 'artifacts'
const artifacts = appRoot === resolve(import.meta.dirname, '..')
? join(paths.root, mode) : join(appRoot, '.desktop-build', 'targets', target, mode)
const base = `deepseek-harness-${version}-${windows ? 'win-x64' : target}`
const debugSuffix = qualification === 'unnotarized' ? '-unnotarized' : qualification === 'unsigned' ? '-unsigned' : ''
const names = windows ? [`${base}${debugSuffix}.exe`] : [`${base}${debugSuffix}.dmg`, `${base}${debugSuffix}.zip`]
if (qualification === 'signed' || qualification === 'notarized') {
names.push(`${base}.${windows ? 'exe' : 'zip'}.blockmap`, desktopBuildRecordFilename(target), desktopUpdateMetadataFilename(version, platform, source.channel))
}
const retained = await retainDesktopRelease(desktopReleasesRoot(env), source, names.map(name => join(artifacts, name)))
if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `directory=${retained}\n`)
if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY,
`\nRetained package: ${env.GITHUB_RUN_ID}/${env.GITHUB_RUN_ATTEMPT}\n\nPublish requested: ${publish}\n`)
return retained
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
await retainDesktopCi(process.env)
}
@@ -0,0 +1,60 @@
/** Manual channel restoration: read-only preview unless --execute is explicitly present. */
import { readFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { z } from 'zod'
import { prepareDesktopPublication } from './publish-desktop-ci.ts'
import { readDesktopUploadCredentials } from './desktop-publish-config.ts'
import { desktopPublishCos } from './desktop-publish-cos.ts'
import { createDesktopPublicationRecord, withDesktopChannelLock } from './desktop-publish-record.ts'
import { rollbackDesktopChannel } from './desktop-rollback.ts'
import { desktopPublicationFailure } from './desktop-publication-failure.ts'
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
let phase = 'rollback arguments and local publication verification'
try {
const { values } = parseArgs({ options: {
record: { type: 'string' }, 'expected-version': { type: 'string' },
reason: { type: 'string' }, execute: { type: 'boolean', default: false },
} })
if (!values.record || !values['expected-version'] || !values.reason?.trim()) {
throw new Error('Usage: rollback-desktop-channel.ts --record <successful-publication-directory> --expected-version <online-version> --reason <reason> [--execute]')
}
const operation = z.object({ repository: z.string(), runId: z.string(),
attempt: z.string(), destination: z.string(), operation: z.literal('publish') })
.parse(JSON.parse(await readFile(join(values.record, 'operation.json'), 'utf8')))
z.object({ status: z.enum(['complete', 'published-tag-pending']) })
.parse(JSON.parse(await readFile(join(values.record, 'result.json'), 'utf8')))
const { plan, config, root, manifest } = await prepareDesktopPublication(process.env)
const route = z.object({ runId: z.string(), attempt: z.string() }).parse(JSON.parse(process.env.DESKTOP_PUBLISH_ROUTE || '{}'))
const destination = `${plan.bucket}/${plan.publicUrl}${manifest.source.channel}`
if (operation.runId !== route.runId || operation.attempt !== route.attempt
|| operation.repository !== process.env.GITHUB_REPOSITORY || operation.destination !== destination) {
throw new Error('desktop rollback: successful publication record does not match the selected package')
}
const credentials = await readDesktopUploadCredentials(config)
phase = 'rollback channel lock and audit creation'
await withDesktopChannelLock(join(dirname(root), 'publish-locks'), destination, async () => {
const journal = await createDesktopPublicationRecord(join(dirname(root), 'upload-records'), {
...operation, actor: process.env.USER || process.env.USERNAME || 'unknown', operation: 'rollback', reason: values.reason!,
})
process.stdout.write(`desktop rollback: audit ${journal.directory}\n`)
let complete = false
try {
phase = 'historical object verification and channel restoration'
const outcome = await rollbackDesktopChannel(plan, values['expected-version']!, values.execute!, {
transport: desktopPublishCos(plan, credentials), record: event => journal.record(event),
tag: async () => { throw new Error('desktop rollback never changes tags') },
})
process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`)
complete = true
} catch (error) {
await journal.record({ type: 'failed', failure: desktopPublicationFailure(error, phase) })
throw error
} finally { await journal.finish(complete ? (values.execute ? 'complete' : 'preview') : 'failed') }
})
} catch (error) {
process.stderr.write(`${JSON.stringify(desktopPublicationFailure(error, phase))}\n`)
process.exitCode = 1
}
}
@@ -0,0 +1,16 @@
# Verify the existing installer; never sign or access the hardware token.
param(
[Parameter(Mandatory = $true)][string]$Installer,
[Parameter(Mandatory = $true)][string]$Thumbprint
)
$ErrorActionPreference = 'Stop'
$signature = Get-AuthenticodeSignature -LiteralPath $Installer
if ($signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate) {
throw 'Installer does not have a valid Authenticode signature.'
}
if ($signature.SignerCertificate.Thumbprint -ne $Thumbprint) {
throw 'Installer signer does not match the configured release certificate.'
}
if ($null -eq $signature.TimeStamperCertificate) {
throw 'Installer has no verified signing timestamp.'
}
@@ -0,0 +1,44 @@
/** Recheck distribution signatures on retained installers without signing credentials. */
import { execFileSync } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { DesktopRetainedManifest } from './desktop-release-store.ts'
import type { DesktopPublishConfig } from './desktop-publish-config.ts'
import { verifyMacOSDiskImage, verifyMacOSNotarizedApplication } from './verify-macos-signature.mjs'
/**
* Verify Windows Authenticode or macOS DMG and archived app notarization on the original platform.
* @param directory Retained attempt whose file hashes were already checked.
* @param manifest Validated inventory and packaging mode.
* @param config Public expected signer configuration.
* @returns Resolves after platform verification; extracted ZIP contents are removed on all exits.
*/
export async function verifyRetainedDesktopSignatures(
directory: string, manifest: DesktopRetainedManifest, config: DesktopPublishConfig,
): Promise<void> {
const { source } = manifest
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/KEY|SECRET|TOKEN|PASSWORD/iu.test(key)))
const run = (command: string, args: string[]) => execFileSync(command, args, { env, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 })
if (source.target === 'win-x64') {
if (process.platform !== 'win32' || source.qualification !== 'signed' || !config.windows) throw new Error('desktop publish: signed Windows package and signer configuration required on Windows')
const installer = manifest.files.find(file => file.name.endsWith('.exe'))
if (!installer) throw new Error('desktop publish: installer missing')
run('pwsh', ['-NoProfile', '-NonInteractive', '-File', join(import.meta.dirname, 'verify-published-windows.ps1'),
'-Installer', join(directory, installer.name), '-Thumbprint', config.windows.certificateThumbprint])
return
}
if (process.platform !== 'darwin' || source.qualification !== 'notarized' || !config.macOS) throw new Error('desktop publish: notarized package and signer configuration required on macOS')
const dmg = manifest.files.find(file => file.name.endsWith('.dmg'))
const zip = manifest.files.find(file => file.name.endsWith('.zip'))
if (!dmg || !zip) throw new Error('desktop publish: DMG and ZIP are required')
verifyMacOSDiskImage(join(directory, dmg.name), config.macOS)
const zipPath = join(directory, zip.name)
const listing = run('/usr/bin/unzip', ['-Z1', zipPath]).trim().split('\n')
if (listing.some(name => name.startsWith('/') || name.includes('\\') || name.split('/').includes('..'))) throw new Error('desktop publish: unsafe ZIP path')
const extracted = await mkdtemp(join(tmpdir(), 'desktop-publish-verify-'))
try {
run('/usr/bin/ditto', ['-x', '-k', zipPath, extracted])
verifyMacOSNotarizedApplication(join(extracted, 'DeepSeek Harness.app'), config.macOS)
} finally { await rm(extracted, { recursive: true, force: true }) }
}
+5 -2
View File
@@ -33,6 +33,7 @@ import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } fro
import { readDeviceInfo } from './device-info.ts'
import { desktopUpdateReadyConfirmation, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale } from './locale.ts'
import { claimDesktopSingleInstance } from './single-instance.ts'
import { desktopUpdateChannel } from './update-channel.ts'
import { DesktopUpdateCoordinator } from './update-coordinator.ts'
import { DesktopCommandManager } from './command-management.ts'
import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
@@ -583,6 +584,8 @@ async function main(): Promise<void> {
return startup
}
const manifest: unknown = JSON.parse(await readFile(join(app.getAppPath(), 'package.json'), 'utf8'))
const updateChannel = desktopUpdateChannel(manifest)
const updates = new DesktopUpdateCoordinator(
publishUpdate,
async () => {
@@ -636,7 +639,7 @@ async function main(): Promise<void> {
},
undefined, undefined, undefined,
(success, reason) => { void track('desktop_upgrade_download_result', { is_success: success, ...reason === undefined ? {} : { error_reason: reason } }) },
updateChannel,
)
const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env))
@@ -1285,7 +1288,6 @@ async function main(): Promise<void> {
})
mainWindow = createMainWindow()
const manifest: unknown = JSON.parse(await readFile(join(app.getAppPath(), 'package.json'), 'utf8'))
if (typeof manifest !== 'object' || manifest === null) throw new Error('desktop policy: invalid application manifest')
const developmentPolicy = app.isPackaged ? undefined : process.env.DSH_DESKTOP_MANDATORY_UPDATE_CONFIG
const policyInput: unknown = app.isPackaged
@@ -1302,6 +1304,7 @@ async function main(): Promise<void> {
let wasBlocking = false
mandatoryPolicy = new DesktopMandatoryUpdatePolicy(policyConfig, {
platform: process.platform as 'win32' | 'darwin', arch: process.arch as 'x64' | 'arm64',
channel: updateChannel,
bundledDshVersion: app.isPackaged ? readDesktopRuntime(resources.dsh).release.version : app.getVersion(),
}, (state) => {
if (state.error !== 'authentication-required') policyAuthenticationQueued = false
+2 -1
View File
@@ -8,6 +8,7 @@ export interface DesktopPolicyIdentity {
readonly platform: 'win32' | 'darwin'
readonly bundledDshVersion: string
readonly arch: 'x64' | 'arm64'
readonly channel: 'nightly' | 'latest'
}
/** Validated deployment choices; test authentication is explicitly enabled, never inferred from a redirect. */
@@ -158,7 +159,7 @@ export class DesktopMandatoryUpdatePolicy {
private requestHeaders(): Record<string, string> {
return {
...platformClientHeaders(this.identity.platform, this.client()),
'x-client-arch': this.identity.arch, 'x-client-update-channel': 'nightly',
'x-client-arch': this.identity.arch, 'x-client-update-channel': this.identity.channel,
'x-client-bundled-dsh-version': this.identity.bundledDshVersion,
}
}
+17
View File
@@ -0,0 +1,17 @@
/** Resolve the update channel sealed into the installed application manifest. */
/** Installer-owned channel, independent of release version and upload environment. */
export type DesktopUpdateChannel = 'nightly' | 'latest'
/**
* Read the packaged channel; older manifests retain their existing nightly behavior.
* @param manifest Parsed application manifest.
* @returns Validated channel. Invalid explicit values reject instead of changing feeds.
*/
export function desktopUpdateChannel(manifest: unknown): DesktopUpdateChannel {
if (typeof manifest !== 'object' || manifest === null) throw new Error('desktop update: invalid application manifest')
if (!('dshDesktopUpdateChannel' in manifest)) return 'nightly'
const channel = manifest.dshDesktopUpdateChannel
if (channel !== 'nightly' && channel !== 'latest') throw new Error('desktop update: invalid packaged channel')
return channel
}
+5 -2
View File
@@ -45,6 +45,7 @@ export class DesktopUpdateCoordinator {
* @param updater - Process-owned Electron updater, replaceable at the network/platform test boundary.
* @param enabled - Whether this process has a packaged update source.
* @param currentVersion - Actual installed application version.
* @param channel - Installed package channel; omitted test/local callers retain nightly.
* @param downloadResult - Once per completed download attempt, including platform preparation failures.
*/
constructor(
@@ -54,6 +55,7 @@ export class DesktopUpdateCoordinator {
private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')),
private readonly currentVersion: () => string = () => app.getVersion(),
private readonly downloadResult?: (success: boolean, reason?: string) => void,
private readonly channel: 'nightly' | 'latest' = 'nightly',
) {
if (updater === autoUpdater) {
// electron-updater omits this internal transport property from its public declarations.
@@ -66,8 +68,8 @@ export class DesktopUpdateCoordinator {
}
this.updater.autoDownload = false
this.updater.autoInstallOnAppQuit = false
this.updater.channel = 'nightly'
this.updater.allowPrerelease = true
this.updater.channel = channel
this.updater.allowPrerelease = channel === 'nightly'
// Selecting a channel can enable downgrade in electron-updater.
this.updater.allowDowngrade = false
this.updater.on('download-progress', this.onProgress)
@@ -187,6 +189,7 @@ export class DesktopUpdateCoordinator {
if (result === null) throw new Error('desktop update: no check result was returned')
const version = result.updateInfo.version
if (valid(version) === null) throw new Error('desktop update: feed version is invalid')
if (this.channel === 'latest' && version.includes('-')) throw new Error('desktop update: latest feed contains a prerelease')
this.candidate = result.isUpdateAvailable && gt(version, this.currentVersion()) ? version : undefined
return this.setState(this.candidate === undefined ? { phase: 'idle' } : { phase: 'available', version })
} catch (error) {
@@ -40,7 +40,7 @@ describe('desktop auto-update environment', () => {
DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/',
DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID,
}, 'darwin', 'arm64')).toEqual({
environment: 'test',
environment: 'test', channel: 'nightly',
target: 'mac-arm64',
origin: 'https://desktop-updates.example.com',
publicUrl: `https://desktop-updates.example.com/dsh-desk/${RELEASE_ID}/feeds/mac-arm64/`,
@@ -0,0 +1,36 @@
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { expect, it } from 'vitest'
import { z } from 'zod'
import { desktopPublicationFailure } from '../scripts/desktop-publication-failure.ts'
import { DesktopPublishError } from '../scripts/desktop-publish.ts'
it('preserves actionable validation and filesystem diagnostics', () => {
expect(desktopPublicationFailure(new DesktopPublishError('conflict', 'desktop publish: object differs: bin/app.exe'), 'upload'))
.toMatchObject({ code: 'conflict', message: 'desktop publish: object differs: bin/app.exe' })
expect(desktopPublicationFailure(new Error('Usage: rollback-desktop-channel.ts --record <directory>'), 'arguments').message).toContain('Usage:')
expect(desktopPublicationFailure(Object.assign(new Error('raw'), { code: 'ENOENT', path: '/fixture/config.json' }), 'config').message)
.toContain('/fixture/config.json')
})
it('does not retain provider bodies, unknown messages, JSON input or invalid field contents', () => {
const result = z.object({ preset: z.enum(['test', 'production']) }).safeParse({ preset: 'fixture-secret' })
if (result.success) throw new Error('fixture must reject')
for (const error of [result.error, new SyntaxError('fixture-secret'), new Error('fixture-secret'),
{ code: 'AccessDenied', statusCode: 403, body: 'fixture-secret', message: 'fixture-secret' }]) {
expect(JSON.stringify(desktopPublicationFailure(error, 'fixture'))).not.toContain('fixture-secret')
}
expect(desktopPublicationFailure(result.error, 'config').message).toContain('preset')
expect(desktopPublicationFailure({ code: 'AccessDenied', statusCode: 403 }, 'upload').message).toContain('HTTP 403')
})
it('prints rollback usage through the real CLI before any audit or remote operation', () => {
const result = spawnSync(process.execPath, ['--import', 'tsx/esm',
fileURLToPath(new URL('../scripts/rollback-desktop-channel.ts', import.meta.url))], { encoding: 'utf8', timeout: 30_000 })
expect(result.error).toBeUndefined()
expect(result.signal).toBeNull()
expect(result.status).toBe(1)
const diagnostic = z.object({ code: z.string(), message: z.string() }).parse(JSON.parse(result.stderr))
expect(diagnostic.code).toBe('validation')
expect(diagnostic.message).toContain('Usage: rollback-desktop-channel.ts')
})
@@ -0,0 +1,60 @@
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import { retainDesktopCi } from '../scripts/retain-desktop-ci.ts'
import { prepareDesktopPublication } from '../scripts/publish-desktop-ci.ts'
it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s build without credentials or source checkout manifests', async (target) => {
const root = await mkdtemp(join(tmpdir(), 'desktop-preflight-'))
try {
const app = join(root, 'app')
const configRoot = join(root, 'packaging')
const artifacts = join(app, '.desktop-build/targets', target, 'artifacts')
await mkdir(artifacts, { recursive: true })
await mkdir(join(configRoot, 'presets'), { recursive: true })
await mkdir(join(configRoot, 'publishing'))
await writeFile(join(app, 'package.json'), JSON.stringify({ version: '1.0.0' }))
const releaseId = 'a'.repeat(32)
await writeFile(join(configRoot, 'presets/test.env'), `DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDOWNLOAD_TEST_RELEASE_ID=${releaseId}\n`)
await writeFile(join(configRoot, 'publishing/test.json'), JSON.stringify({
bucket: 'fixture-1250000000', origin: 'https://example.com', credentialsFile: join(root, 'absent-credentials.json'),
}))
const commit = 'b'.repeat(40)
const base = `deepseek-harness-1.0.0-${target}`
const extension = target === 'win-x64' ? 'exe' : 'zip'
const bytes = 'fixture installer bytes'
await writeFile(join(artifacts, `${base}.${extension}`), bytes)
await writeFile(join(artifacts, `${base}.${extension}.blockmap`), 'fixture blockmap')
if (target !== 'win-x64') await writeFile(join(artifacts, `${base}.dmg`), 'fixture DMG')
await writeFile(join(artifacts, `${target}-release.json`), JSON.stringify({
schemaVersion: 1, target, environment: 'test', version: '1.0.0', commit, dirty: false,
publicUrl: `https://example.com/dsh-desk/${releaseId}/feeds/${target}/`,
}))
await writeFile(join(artifacts, target === 'win-x64' ? 'nightly.yml' : 'nightly-mac.yml'), JSON.stringify({
version: '1.0.0', files: [{ url: `${base}.${extension}`, size: Buffer.byteLength(bytes),
sha512: createHash('sha512').update(bytes).digest('base64') }],
}))
const env = {
DSH_PACKAGING_CONFIG_DIR: configRoot, DSH_PACKAGING_PRESET: 'test',
DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin', DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64',
PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0', PACKAGE_PUBLISH: 'true', PACKAGE_SIGNING: 'signed', PACKAGE_SKIP_NOTARIZATION: 'false',
GITHUB_RUN_ID: '10', GITHUB_RUN_ATTEMPT: '2', GITHUB_REPOSITORY: 'owner/repo', GITHUB_SHA: commit, RUNNER_NAME: 'fixture-runner',
DESKTOP_PUBLISH_ROUTE: JSON.stringify({ sourceRef: 'master', channel: 'nightly', runId: '10', attempt: '2', commit, runnerName: 'fixture-runner', target, preset: 'test' }),
}
const retained = await retainDesktopCi(env, app)
await rm(app, { recursive: true })
let signatureChecks = 0
const signature = async () => { signatureChecks++ }
const prepared = await prepareDesktopPublication(env, signature)
expect(prepared.plan.version).toBe('1.0.0')
expect(prepared.plan.artifacts.every(file => file.path.startsWith(retained))).toBe(true)
expect(signatureChecks).toBe(1)
await expect(prepareDesktopPublication({ ...env, DESKTOP_APPROVED_PACKAGE: 'wrong' }, signature)).rejects.toThrow(/changed after approval/u)
await expect(prepareDesktopPublication({ ...env, RUNNER_NAME: 'other-machine' }, signature)).rejects.toThrow(/different machine/u)
await writeFile(join(retained, `${base}.${extension}`), 'changed')
await expect(prepareDesktopPublication(env, signature)).rejects.toThrow(/changed file/u)
expect(signatureChecks).toBe(2)
} finally { await rm(root, { recursive: true, force: true }) }
})
@@ -0,0 +1,47 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import { readDesktopPublishConfig, readDesktopUploadCredentials } from '../scripts/desktop-publish-config.ts'
it('reads public settings without opening the absent credential file and requires production App settings', async () => {
const root = await mkdtemp(join(tmpdir(), 'publish-config-'))
try {
await mkdir(join(root, 'publishing'))
const config = { bucket: 'test-1250000000', origin: 'https://example.com', credentialsFile: join(root, 'absent.json') }
await writeFile(join(root, 'publishing/test.json'), JSON.stringify(config))
const selected = await readDesktopPublishConfig(root, 'test')
expect(selected).toEqual(config)
await expect(readDesktopUploadCredentials(selected)).rejects.toThrow(/cannot read valid upload credentials/u)
await writeFile(join(root, 'publishing/production.json'), JSON.stringify({ ...config, origin: 'https://download.deepseek.com' }))
await expect(readDesktopPublishConfig(root, 'production')).rejects.toThrow(/GitHub App configuration/u)
await writeFile(config.credentialsFile, JSON.stringify({ secretId: 'fixture', secretKey: 'fixture-only' }))
await expect(readDesktopUploadCredentials(selected)).resolves.toEqual({ secretId: 'fixture', secretKey: 'fixture-only' })
} finally { await rm(root, { recursive: true, force: true }) }
})
it('does not include malformed credential contents in errors', async () => {
const root = await mkdtemp(join(tmpdir(), 'publish-config-'))
try {
const credentialsFile = join(root, 'credentials.json')
await writeFile(credentialsFile, '{secret-value-should-not-appear')
await expect(readDesktopUploadCredentials({ credentialsFile, bucket: 'test-1', origin: 'https://example.com' }))
.rejects.not.toThrow(/secret-value/u)
} finally { await rm(root, { recursive: true, force: true }) }
})
it('rejects multiline App outputs before writing Actions output fields', async () => {
const root = await mkdtemp(join(tmpdir(), 'publish-config-'))
try {
await mkdir(join(root, 'publishing'))
const config = { bucket: 'test-1', origin: 'https://download.deepseek.com', credentialsFile: join(root, 'absent.json'),
githubApp: { clientId: 'app-id', privateKeyFile: join(root, 'key.pem') } }
for (const githubApp of [
{ ...config.githubApp, clientId: 'app-id\nother=value' },
{ ...config.githubApp, privateKeyFile: join(root, 'key\nother=value') },
]) {
await writeFile(join(root, 'publishing/production.json'), JSON.stringify({ ...config, githubApp }))
await expect(readDesktopPublishConfig(root, 'production')).rejects.toThrow(/single-line/u)
}
} finally { await rm(root, { recursive: true, force: true }) }
})
@@ -0,0 +1,56 @@
import { expect, it } from 'vitest'
import { createDesktopCos } from '../scripts/desktop-cos.ts'
import { desktopPublishCos } from '../scripts/desktop-publish-cos.ts'
import { desktopArtifactDigest } from '../scripts/desktop-publish.ts'
import type { DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts'
import { answer, cosError, startCosLoopback } from './cos-loopback.ts'
it('uses overwrite prevention, streams verified bytes, and hashes remote readback through the SDK', async () => {
const objects = new Map<string, Buffer>()
const server = await startCosLoopback((request, response) => {
if (request.path.includes('versioning')) { answer(response, 200, '<VersioningConfiguration/>'); return }
if (request.method === 'PUT') {
expect(request.headers['x-cos-forbid-overwrite']).toBe('true')
objects.set(request.path, request.body)
answer(response)
} else {
const bytes = objects.get(request.path)
if (bytes === undefined) answer(response, 404, cosError('NoSuchKey'))
else answer(response, 200, bytes)
}
})
try {
const artifact = { path: '', filename: 'file.exe', key: 'bin/file.exe', contents: 'fixture bytes', contentType: 'application/octet-stream', channelMetadata: false }
const plan: DesktopUploadPlan = {
environment: 'test', target: 'win-x64', version: '1.0.0', bucket: 'fixture-1250000000', publicUrl: 'https://example.com/',
secretIdEnvName: 'ID', secretKeyEnvName: 'KEY', artifacts: [artifact],
}
const transport = desktopPublishCos(plan, { secretId: 'fixture-id', secretKey: 'fixture-secret' }, (credentials) => {
const client = createDesktopCos(credentials)
server.redirect(client)
return client
})
expect(await transport.read(artifact.key, false)).toBeNull()
const digest = await desktopArtifactDigest(artifact)
await transport.put(artifact, digest, true)
expect(await transport.read(artifact.key, false)).toEqual(digest)
await expect(transport.read('elsewhere/file.exe', false)).rejects.toThrow(/outside/u)
expect(server.requests.filter(request => request.method === 'PUT')).toHaveLength(1)
} finally { await server.close() }
})
it('propagates an oversized channel stream error through the SDK and closes the request', async () => {
const server = await startCosLoopback((_request, response) => { answer(response, 200, Buffer.alloc(1024 * 1024 + 1, 65)) })
try {
const artifact = { path: '', filename: 'nightly.yml', key: 'feed/nightly.yml', contents: 'version: 1.0.0\n', contentType: 'application/yaml', channelMetadata: true }
const plan: DesktopUploadPlan = { environment: 'test', target: 'win-x64', version: '1.0.0', bucket: 'fixture-1250000000',
publicUrl: 'https://example.com/', secretIdEnvName: 'ID', secretKeyEnvName: 'KEY', artifacts: [artifact] }
const transport = desktopPublishCos(plan, { secretId: 'fixture-id', secretKey: 'fixture-secret' }, (credentials) => {
const client = createDesktopCos(credentials)
server.redirect(client)
return client
})
await expect(transport.read(artifact.key, true)).rejects.toThrow(/exceeds 1 MiB/u)
expect(server.requests).toHaveLength(1)
} finally { await server.close() }
})
@@ -0,0 +1,80 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { execFile } from 'node:child_process'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { expect, it } from 'vitest'
import { createDesktopPublicationRecord, wasDesktopAttemptPublished, withDesktopChannelLock } from '../scripts/desktop-publish-record.ts'
it('excludes another process while a channel lock is held and admits it after release', async () => {
const root = await mkdtemp(join(tmpdir(), 'desktop-process-lock-'))
const script = `
const { withDesktopChannelLock } = await import(process.argv[1]);
try {
await withDesktopChannelLock(process.argv[2], 'bucket/mac', async () => process.stdout.write('acquired'));
} catch (error) {
if (!error.message.includes('locked')) throw error;
process.stdout.write('locked');
}
`
const contender = () => promisify(execFile)(process.execPath, ['--import', 'tsx/esm', '--input-type=module', '-e', script,
new URL('../scripts/desktop-publish-record.ts', import.meta.url).href, root], { encoding: 'utf8' })
try {
await withDesktopChannelLock(root, 'bucket/mac', async () => {
expect((await contender()).stdout).toBe('locked')
})
expect((await contender()).stdout).toBe('acquired')
} finally { await rm(root, { recursive: true, force: true }) }
})
it('excludes overlapping writers of one channel but permits independent channels and releases after failure', async () => {
const root = await mkdtemp(join(tmpdir(), 'desktop-lock-'))
try {
await withDesktopChannelLock(root, 'bucket/mac', async () => {
await expect(withDesktopChannelLock(root, 'bucket/mac', async () => {})).rejects.toThrow(/locked/u)
await expect(withDesktopChannelLock(root, 'bucket/win', async () => 'independent')).resolves.toBe('independent')
})
await expect(withDesktopChannelLock(root, 'bucket/mac', async () => { throw new Error('fixture') })).rejects.toThrow('fixture')
await expect(withDesktopChannelLock(root, 'bucket/mac', async () => 'recovered')).resolves.toBe('recovered')
} finally { await rm(root, { recursive: true, force: true }) }
})
it('persists intent, before/after bytes and terminal state outside the checkout', async () => {
const root = await mkdtemp(join(tmpdir(), 'desktop-audit-'))
try {
const record = await createDesktopPublicationRecord(root, { repository: 'owner/repo', runId: '1', attempt: '2',
actor: 'operator', destination: 'bucket/feed/mac', operation: 'rollback', reason: 'restore previous release' })
await record.record({ type: 'put-intent', key: 'feed/mac/nightly.yml', before: 'version: 2.0.0', after: 'version: 1.0.0' })
await record.finish('complete')
expect(JSON.parse(await readFile(join(record.directory, 'operation.json'), 'utf8'))).toMatchObject({ actor: 'operator', operation: 'rollback' })
expect(JSON.parse((await readFile(join(record.directory, 'events.jsonl'), 'utf8')).trim())).toMatchObject({ before: 'version: 2.0.0', after: 'version: 1.0.0' })
expect(JSON.parse(await readFile(join(record.directory, 'result.json'), 'utf8'))).toMatchObject({ status: 'complete' })
} finally { await rm(root, { recursive: true, force: true }) }
})
it('recognizes feed completion even when the later tag failed', async () => {
const root = await mkdtemp(join(tmpdir(), 'desktop-recovery-'))
try {
const identity = { repository: 'owner/repo', runId: '12', attempt: '2', destination: 'bucket/mac' }
const journal = await createDesktopPublicationRecord(root, { ...identity, actor: 'operator', operation: 'publish', reason: 'publish' })
expect(await wasDesktopAttemptPublished(root, identity)).toBe(false)
await journal.record({ type: 'feeds-complete' })
await journal.record({ type: 'tag-pending' })
await journal.finish('published-tag-pending')
expect(await wasDesktopAttemptPublished(root, identity)).toBe(true)
expect(await wasDesktopAttemptPublished(root, { ...identity, attempt: '1' })).toBe(false)
} finally { await rm(root, { recursive: true, force: true }) }
})
it('ignores interrupted initialization before an audit directory becomes visible', async () => {
const root = await mkdtemp(join(tmpdir(), 'desktop-init-'))
try {
await mkdir(join(root, '.initializing-interrupted'))
await writeFile(join(root, '.initializing-interrupted/operation.json'), '{')
const identity = { repository: 'owner/repo', runId: '1', attempt: '1', destination: 'bucket/mac' }
expect(await wasDesktopAttemptPublished(root, identity)).toBe(false)
const record = await createDesktopPublicationRecord(root, { ...identity, actor: 'fixture', operation: 'publish', reason: 'fixture' })
await record.record({ type: 'feeds-complete' })
expect(await wasDesktopAttemptPublished(root, identity)).toBe(true)
} finally { await rm(root, { recursive: true, force: true }) }
})
@@ -0,0 +1,47 @@
import { expect, it } from 'vitest'
import { resolveDesktopPublishRoute } from '../scripts/desktop-publish-route.ts'
function fixture() {
const run = { id: 42, run_attempt: 2, head_sha: 'a'.repeat(40), path: '.github/workflows/macos-package.yml',
conclusion: 'success', event: 'workflow_dispatch', repository: { full_name: 'owner/repo' } }
const source = { repository: 'owner/repo', runId: '42', attempt: '2', commit: run.head_sha, runnerName: 'mini',
workflowCommit: run.head_sha, sourceRef: 'master', channel: 'nightly',
target: 'mac-arm64', preset: 'test', publishRequested: true, qualification: 'notarized' }
const jobs = [{ runner_name: 'mini', conclusion: 'success', labels: ['self-hosted', 'dsh-mini'] }]
const routes = { mini: { label: 'dsh-mini', target: 'mac-arm64' } }
return { run, source, jobs, routes }
}
it('routes the exact successful attempt to its registered machine', () => {
const f = fixture()
expect(resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes))
.toMatchObject({ runId: '42', attempt: '2', runnerLabel: 'dsh-mini', preset: 'test' })
})
it.each(['attempt', 'repository', 'commit', 'mode', 'request', 'runner', 'workflow'] as const)('rejects mismatched %s evidence', (field) => {
const f = fixture()
if (field === 'attempt') f.source.attempt = '1'
if (field === 'repository') f.source.repository = 'other/repo'
if (field === 'commit') f.source.workflowCommit = 'b'.repeat(40)
if (field === 'mode') f.source.qualification = 'unnotarized'
if (field === 'request') f.source.publishRequested = false
if (field === 'runner') f.jobs[0]!.runner_name = 'laptop'
if (field === 'workflow') f.run.path = '.github/workflows/ci.yml'
expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)).toThrow()
})
it('fails closed when a publishing route or its unique label is missing', () => {
const f = fixture()
expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, {})).toThrow(/no configured/u)
const duplicates = { ...f.routes, laptop: { label: 'dsh-mini', target: 'mac-arm64' } }
expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, duplicates)).toThrow(/unique runner label/u)
})
it('keeps the artifact source separate from the workflow commit', () => {
const f = fixture()
f.source.commit = 'b'.repeat(40)
f.source.sourceRef = 'dsh-v1.0.0'
expect(resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes))
.toMatchObject({ commit: 'b'.repeat(40), sourceRef: 'dsh-v1.0.0', channel: 'nightly' })
})
@@ -0,0 +1,59 @@
import { expect, it } from 'vitest'
import { publishDesktopTag } from '../scripts/desktop-publish-tag.ts'
const commit = 'a'.repeat(40)
const tag = (sha = commit) => ({ object: { type: 'commit', sha } })
function transport(replies: Array<{ status: number; body?: object }>) {
const calls: Array<{ url: string; init: RequestInit | undefined }> = []
const request: typeof fetch = async (url, init) => {
calls.push({ url: typeof url === 'string' ? url : url instanceof URL ? url.href : url.url, init })
const reply = replies.shift()
if (!reply) throw new Error('unexpected HTTP request')
return new Response(JSON.stringify(reply.body ?? {}), { status: reply.status })
}
return { calls, request }
}
it('tags the packaged commit using explicit App authentication', async () => {
const http = transport([{ status: 404 }, { status: 201, body: tag() }])
await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).resolves.toBe('created')
expect(http.calls[1]?.init?.body).toBe(JSON.stringify({ ref: 'refs/tags/desktop-v1.2.3', sha: commit }))
expect(http.calls[1]?.init?.headers).toMatchObject({ Authorization: 'Bearer fixture-token' })
})
it('does not write when the same release tag already exists', async () => {
const http = transport([{ status: 200, body: tag() }])
await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).resolves.toBe('present')
expect(http.calls).toHaveLength(1)
})
it('rejects a conflicting tag without updating or deleting it', async () => {
const http = transport([{ status: 200, body: tag('b'.repeat(40)) }])
await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow(/refusing to move/u)
expect(http.calls).toHaveLength(1)
})
it('reconciles a create race between two platform releases', async () => {
const http = transport([{ status: 404 }, { status: 422 }, { status: 200, body: tag() }])
await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).resolves.toBe('present')
expect(http.calls.map(call => call.init?.method ?? 'GET')).toEqual(['GET', 'POST', 'GET'])
})
it('does not reinterpret authorization failure as a missing tag', async () => {
const http = transport([{ status: 403 }])
await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow(/HTTP 403/u)
expect(http.calls).toHaveLength(1)
})
it('rejects missing credentials and malformed inputs before any request', async () => {
const http = transport([])
await expect(publishDesktopTag('owner/repo', '1.2.3', commit, '', http.request)).rejects.toThrow(/required/u)
await expect(publishDesktopTag('owner/repo/other', '1.2.3', commit, 'fixture', http.request)).rejects.toThrow(/invalid/u)
expect(http.calls).toHaveLength(0)
})
it('explains an existing annotated tag without replacing it', async () => {
const http = transport([{ status: 200, body: { object: { type: 'tag', sha: commit } } }])
await expect(publishDesktopTag('owner/repo', '1.2.3', commit, 'fixture-token', http.request)).rejects.toThrow(/not a lightweight commit tag/u)
expect(http.calls).toHaveLength(1)
})
+114
View File
@@ -0,0 +1,114 @@
import { createHash } from 'node:crypto'
import { expect, it } from 'vitest'
import { publishDesktopRelease, type DesktopPublishEvent, type DesktopPublishOperations, type DesktopRemoteObject } from '../scripts/desktop-publish.ts'
import type { DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts'
function observed(contents: string): DesktopRemoteObject {
return { contents, size: Buffer.byteLength(contents), sha512: createHash('sha512').update(contents).digest('base64') }
}
function fixture(environment: 'test' | 'production' = 'test') {
const objects = new Map<string, DesktopRemoteObject>()
const writes: string[] = []
const events: DesktopPublishEvent[] = []
let tags = 0
const plan: DesktopUploadPlan = {
environment, target: 'win-x64', version: '1.0.0', bucket: 'fixture', publicUrl: 'https://example.com/feed/',
secretIdEnvName: 'ID', secretKeyEnvName: 'KEY', commit: 'a'.repeat(40), dirty: false,
artifacts: [
{ path: '', filename: 'installer.exe', key: 'bin/installer.exe', contentType: 'application/octet-stream', channelMetadata: false, contents: 'installer' },
{ path: '', filename: 'installer.blockmap', key: 'bin/installer.blockmap', contentType: 'application/octet-stream', channelMetadata: false, contents: 'blockmap' },
{ path: '', filename: 'nightly.yml', key: 'feed/nightly.yml', contentType: 'application/yaml', channelMetadata: true, contents: 'version: 1.0.0\n' },
],
}
const operations: DesktopPublishOperations = {
transport: {
async read(key) { return objects.get(key) ?? null },
async put(artifact, _digest, forbidOverwrite) {
if (forbidOverwrite && objects.has(artifact.key)) throw new Error('already exists')
writes.push(artifact.key)
objects.set(artifact.key, observed(artifact.contents!))
},
},
async record(event) { events.push(event) },
async tag() { tags++ },
}
return { plan, operations, objects, writes, events, tags: () => tags }
}
it('uploads files before the feed and skips identical bytes on a repeat invocation', async () => {
const f = fixture()
await expect(publishDesktopRelease(f.plan, f.operations)).resolves.toEqual({ status: 'complete' })
expect(f.writes).toEqual(['bin/installer.exe', 'bin/installer.blockmap', 'feed/nightly.yml'])
await publishDesktopRelease(f.plan, f.operations)
expect(f.writes).toHaveLength(3)
expect(f.tags()).toBe(0)
expect(f.events.find(event => event.key === 'feed/nightly.yml' && event.type === 'put-intent'))
.toMatchObject({ before: null, after: 'version: 1.0.0\n' })
})
it('recovers an ambiguous binary write without repeating it or publishing the feed early', async () => {
const f = fixture()
const put = f.operations.transport.put.bind(f.operations.transport)
let fail = true
f.operations.transport.put = async (...args) => {
await put(...args)
if (fail) { fail = false; throw new Error('connection lost after server stored bytes') }
}
await expect(publishDesktopRelease(f.plan, f.operations)).rejects.toThrow(/connection lost/u)
expect(f.objects.has('feed/nightly.yml')).toBe(false)
await publishDesktopRelease(f.plan, f.operations)
expect(f.writes).toEqual(['bin/installer.exe', 'bin/installer.blockmap', 'feed/nightly.yml'])
})
it('reports published but tag pending and retries only the tag', async () => {
const f = fixture('production')
let fail = true
const tag = f.operations.tag.bind(f.operations)
f.operations.tag = async () => { if (fail) throw new Error('GitHub unavailable'); await tag() }
await expect(publishDesktopRelease(f.plan, f.operations)).resolves.toMatchObject({ status: 'published-tag-pending' })
expect(f.events.at(-1)?.type).toBe('tag-pending')
fail = false
await expect(publishDesktopRelease(f.plan, f.operations)).resolves.toEqual({ status: 'complete' })
expect(f.writes).toHaveLength(3)
expect(f.tags()).toBe(1)
})
it.each(['binary', 'same-version-feed', 'newer-feed'] as const)('stops on %s conflict', async (kind) => {
const f = fixture()
if (kind === 'binary') f.objects.set('bin/installer.exe', observed('different'))
else f.objects.set('feed/nightly.yml', observed(kind === 'newer-feed' ? 'version: 2.0.0\n' : 'version: 1.0.0\nchanged: true\n'))
await expect(publishDesktopRelease(f.plan, f.operations)).rejects.toMatchObject({ code: kind === 'newer-feed' ? 'superseded' : 'conflict' })
expect(f.writes).toHaveLength(0)
})
it('rejects a files-only plan and a production build without clean commit evidence', async () => {
const f = fixture('production')
await expect(publishDesktopRelease({ ...f.plan, artifacts: f.plan.artifacts.slice(0, 2) }, f.operations)).rejects.toMatchObject({ code: 'invalid-plan' })
await expect(publishDesktopRelease({ ...f.plan, dirty: true }, f.operations)).rejects.toMatchObject({ code: 'invalid-plan' })
expect(f.writes).toHaveLength(0)
})
it('stops when a channel changes between preflight and writing', async () => {
const f = fixture()
const put = f.operations.transport.put.bind(f.operations.transport)
f.operations.transport.put = async (...args) => {
await put(...args)
f.objects.set('feed/nightly.yml', observed('version: 3.0.0\n'))
}
await expect(publishDesktopRelease(f.plan, f.operations)).rejects.toMatchObject({ code: 'remote-changed' })
expect(f.writes).not.toContain('feed/nightly.yml')
})
it('does not update the channel when readback cannot confirm a binary', async () => {
const f = fixture()
f.operations.transport.put = async () => {}
await expect(publishDesktopRelease(f.plan, f.operations)).rejects.toMatchObject({ code: 'verification-failed' })
expect(f.events.some(event => event.type === 'feeds-complete')).toBe(false)
})
it('classifies corrupted remote channel metadata separately from an invalid local plan', async () => {
const f = fixture()
f.objects.set('feed/nightly.yml', observed('version: invalid\n'))
await expect(publishDesktopRelease(f.plan, f.operations)).rejects.toMatchObject({ code: 'verification-failed' })
expect(f.writes).toEqual([])
})
@@ -0,0 +1,71 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { retainDesktopRelease, retainedDesktopPath, verifyRetainedDesktopRelease, type DesktopRetainedSource } from '../scripts/desktop-release-store.ts'
const roots: string[] = []
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) })
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'desktop-release-store-'))
roots.push(root)
const file = join(root, 'installer.exe')
await writeFile(file, 'signed installer fixture')
const source: DesktopRetainedSource = {
repository: 'owner/repository', runId: '100', attempt: '1', commit: 'a'.repeat(40), runnerName: 'windows-runner',
target: 'win-x64', preset: 'test', productVersion: '1.0.0', version: '1.0.0', testReleaseId: 'b'.repeat(32),
workflowCommit: 'a'.repeat(40), sourceRef: 'master', channel: 'nightly',
publishRequested: true, qualification: 'signed',
}
return { root: join(root, 'releases'), file, source }
}
it('retains independent attempts after source removal and rejects replacing an existing attempt', async () => {
const { root, file, source } = await fixture()
const first = await retainDesktopRelease(root, source, [file])
const second = await retainDesktopRelease(root, { ...source, attempt: '2' }, [file])
expect(first).not.toBe(second)
await expect(retainDesktopRelease(root, source, [file])).rejects.toThrow(/already exists/u)
await rm(file)
const manifest = await verifyRetainedDesktopRelease(root, '100', '1')
expect(manifest.source).toEqual(source)
expect(await readFile(join(first, 'installer.exe'), 'utf8')).toBe('signed installer fixture')
})
it('detects changed bytes even when file length is unchanged', async () => {
const { root, file, source } = await fixture()
const directory = await retainDesktopRelease(root, source, [file])
const path = join(directory, 'installer.exe')
await writeFile(path, Buffer.alloc((await readFile(path)).length))
await expect(verifyRetainedDesktopRelease(root, '100', '1')).rejects.toThrow(/changed file/u)
})
it('does not expose an incomplete copy and releases its claim after failure', async () => {
const { root, file, source } = await fixture()
await expect(retainDesktopRelease(root, source, [file, join(root, 'missing.zip')])).rejects.toThrow()
await expect(verifyRetainedDesktopRelease(root, '100', '1')).rejects.toThrow()
await retainDesktopRelease(root, source, [file])
await expect(verifyRetainedDesktopRelease(root, '100', '1')).resolves.toMatchObject({ source })
})
it('permits only one writer for a concurrently retained attempt', async () => {
const { root, file, source } = await fixture()
const results = await Promise.allSettled([
retainDesktopRelease(root, source, [file]), retainDesktopRelease(root, source, [file]),
])
expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1)
await expect(verifyRetainedDesktopRelease(root, '100', '1')).resolves.toMatchObject({ source })
})
it('rejects traversal and manifest identity or file inventory tampering', async () => {
const { root, file, source } = await fixture()
expect(() => retainedDesktopPath(root, '../100', '1')).toThrow()
expect(() => retainedDesktopPath(root, '100', '0')).toThrow()
const path = await retainDesktopRelease(root, source, [file])
const manifest = await verifyRetainedDesktopRelease(root, '100', '1')
await writeFile(join(path, 'manifest.json'), JSON.stringify({ ...manifest, source: { ...source, attempt: '2' } }))
await expect(verifyRetainedDesktopRelease(root, '100', '1')).rejects.toThrow(/identity mismatch/u)
await writeFile(join(path, 'manifest.json'), JSON.stringify({ ...manifest, files: [{ ...manifest.files[0], name: '../installer.exe' }] }))
await expect(verifyRetainedDesktopRelease(root, '100', '1')).rejects.toThrow()
})
@@ -0,0 +1,79 @@
import { createHash } from 'node:crypto'
import { expect, it } from 'vitest'
import { rollbackDesktopChannel } from '../scripts/desktop-rollback.ts'
import type { DesktopPublishOperations } from '../scripts/desktop-publish.ts'
import type { DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts'
function fixture() {
const objects = new Map([['bin/old.exe', 'old installer'], ['feed/nightly.yml', 'version: 2.0.0\n']])
const writes: string[] = []
const plan: DesktopUploadPlan = {
environment: 'test', target: 'win-x64', version: '1.0.0', bucket: 'fixture', publicUrl: 'https://example.com', secretIdEnvName: 'ID', secretKeyEnvName: 'KEY',
artifacts: [
{ key: 'bin/old.exe', path: '', filename: 'old.exe', contentType: 'application/octet-stream', channelMetadata: false, contents: 'old installer' },
{ key: 'feed/nightly.yml', path: '', filename: 'nightly.yml', contentType: 'application/yaml', channelMetadata: true, contents: 'version: 1.0.0\n' },
],
}
const operations: DesktopPublishOperations = {
transport: {
async read(key) {
const contents = objects.get(key)
return contents === undefined ? null : { contents, size: Buffer.byteLength(contents), sha512: createHash('sha512').update(contents).digest('base64') }
},
async put(artifact, _digest, forbidOverwrite) {
expect(forbidOverwrite).toBe(false)
writes.push(artifact.key)
objects.set(artifact.key, artifact.contents!)
},
},
async record() {},
async tag() { throw new Error('must not create a tag') },
}
return { plan, operations, writes, objects }
}
it('previews the exact YAML change without writes', async () => {
const f = fixture()
const result = await rollbackDesktopChannel(f.plan, '2.0.0', false, f.operations)
expect(result).toMatchObject({ status: 'preview', feeds: [{ before: 'version: 2.0.0\n', after: 'version: 1.0.0\n' }] })
expect(f.writes).toEqual([])
})
it('only restores the YAML after checking the historical binary', async () => {
const f = fixture()
await expect(rollbackDesktopChannel(f.plan, '2.0.0', true, f.operations)).resolves.toMatchObject({ status: 'complete' })
expect(f.writes).toEqual(['feed/nightly.yml'])
expect(f.objects.get('bin/old.exe')).toBe('old installer')
})
it.each(['missing-file', 'changed-file', 'changed-channel'] as const)('rejects %s without writes', async (problem) => {
const f = fixture()
if (problem === 'missing-file') f.objects.delete('bin/old.exe')
if (problem === 'changed-file') f.objects.set('bin/old.exe', 'changed')
if (problem === 'changed-channel') f.objects.set('feed/nightly.yml', 'version: 3.0.0\n')
await expect(rollbackDesktopChannel(f.plan, '2.0.0', true, f.operations)).rejects.toThrow()
expect(f.writes).toEqual([])
})
it('restores nightly when latest already matches and resumes a partially restored pair', async () => {
for (const latestAlreadyMatches of [true, false]) {
const f = fixture()
const nightly = f.plan.artifacts[1]!
const plan = { ...f.plan, artifacts: [...f.plan.artifacts, { ...nightly, key: 'feed/latest.yml', filename: 'latest.yml' }] }
f.objects.set('feed/latest.yml', latestAlreadyMatches ? nightly.contents! : 'version: 2.0.0\n')
if (!latestAlreadyMatches) {
const put = f.operations.transport.put.bind(f.operations.transport)
const interrupted = { ...f.operations, transport: { ...f.operations.transport,
async put(...args: Parameters<typeof put>) {
if (args[0].key === 'feed/latest.yml') throw new Error('fixture interruption')
return put(...args)
},
} }
await expect(rollbackDesktopChannel(plan, '2.0.0', true, interrupted)).rejects.toThrow('fixture interruption')
}
await expect(rollbackDesktopChannel(plan, '2.0.0', true, f.operations)).resolves.toMatchObject({ status: 'complete' })
expect(f.objects.get('feed/nightly.yml')).toBe(nightly.contents)
expect(f.objects.get('feed/latest.yml')).toBe(nightly.contents)
expect(f.writes.filter(key => key === 'feed/nightly.yml')).toHaveLength(1)
}
})
+30 -6
View File
@@ -38,6 +38,7 @@ async function fixture(
target: DesktopPackageTargetName,
version = '1.2.3',
environment: 'test' | 'production' = 'test',
channel: 'nightly' | 'latest' = 'nightly',
): Promise<Fixture> {
const root = await mkdtemp(join(tmpdir(), 'dsh-desktop-upload-'))
temporaryDirectories.push(root)
@@ -55,6 +56,7 @@ async function fixture(
: 'https://download.deepseek.com'
await writeFile(join(artifactsRoot, `${target}-release.json`), `${JSON.stringify({
schemaVersion: 1,
channel,
target,
version,
environment,
@@ -66,7 +68,7 @@ async function fixture(
await writeFile(join(artifactsRoot, `${base}.zip`), zip)
await writeFile(join(artifactsRoot, `${base}.zip.blockmap`), 'blockmap')
await writeFile(join(artifactsRoot, `${base}.dmg`), 'notarized DMG fixture')
await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'darwin')), `${JSON.stringify({
await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'darwin', channel)), `${JSON.stringify({
version,
path: `${base}.zip`,
files: [{ url: `${base}.zip`, size: Buffer.byteLength(zip), sha512: digest(zip) }],
@@ -78,7 +80,7 @@ async function fixture(
const info = await createBlockmap(join(artifactsRoot, `${base}.exe`), {},
{ info: { emitArtifactBuildCompleted: async () => {} } }, `${base}.exe`)
expect(Object.hasOwn(info, 'blockMapSize')).toBe(false)
await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'win32')), `${JSON.stringify({
await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'win32', channel)), `${JSON.stringify({
version,
path: `${base}.exe`,
files: [{
@@ -93,12 +95,14 @@ async function fixture(
artifactsRoot,
environment: environment === 'test'
? {
DSH_DESKTOP_UPDATE_CHANNEL: channel,
DSH_DESKTOP_AUTO_UPDATE_ENV: 'test',
DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN,
DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID,
DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET,
}
: {
DSH_DESKTOP_UPDATE_CHANNEL: channel,
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production',
DOWNLOAD_PROD_COS_BUCKET: PRODUCTION_BUCKET,
},
@@ -171,6 +175,16 @@ describe('desktop upload plan', () => {
.rejects.toThrow(failure === 'checksum' ? /SHA-512/u : /completion record/u)
})
it.each(['test', 'production'] as const)('isolates stable latest packages from nightly in %s', async (environment) => {
const nightly = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'nightly'))
const latest = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'latest'))
expect(latest.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['latest.yml'])
expect(nightly.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['nightly.yml'])
expect(latest.artifacts.every(file => !nightly.artifacts.some(other => other.key === file.key))).toBe(true)
const prerelease = await fixture('win-x64', '1.2.3-alpha.1', environment, 'latest')
await expect(createDesktopUploadPlan('win-x64', prerelease)).rejects.toThrow(/latest requires a stable version/u)
})
it('publishes fixed feeds referencing versioned binaries without overriding CDN cache policy', async () => {
const paths = await fixture('win-x64', '1.2.3', 'production')
const plan = await createDesktopUploadPlan('win-x64', paths)
@@ -178,7 +192,6 @@ describe('desktop upload plan', () => {
'dsh-desk/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe',
'dsh-desk/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe.blockmap',
'dsh-desk/feeds/win-x64/nightly.yml',
'dsh-desk/feeds/win-x64/latest.yml',
])
expect(load(plan.artifacts[2]!.contents!)).toMatchObject({
version: '1.2.3',
@@ -187,7 +200,6 @@ describe('desktop upload plan', () => {
sha512: digest('signed NSIS executable fixture'),
}],
})
expect(plan.artifacts[2]!.contents).toBe(plan.artifacts[3]!.contents)
expect(plan.artifacts.every(artifact => !('cacheControl' in artifact))).toBe(true)
})
@@ -205,7 +217,6 @@ describe('desktop upload plan', () => {
'deepseek-harness-1.2.3-mac-arm64.zip',
'deepseek-harness-1.2.3-mac-arm64.zip.blockmap',
'nightly-mac.yml',
'latest-mac.yml',
])
expect(plan.artifacts.at(-1)).toMatchObject({
channelMetadata: true,
@@ -258,7 +269,6 @@ describe('desktop upload plan', () => {
'deepseek-harness-2.0.0-win-x64.exe',
'deepseek-harness-2.0.0-win-x64.exe.blockmap',
'nightly.yml',
'latest.yml',
])
expect(plan).toMatchObject({
publicUrl: 'https://download.deepseek.com/dsh-desk/feeds/win-x64/',
@@ -292,6 +302,20 @@ describe('desktop upload plan', () => {
})).rejects.toThrow(/completion record.*test/u)
})
it('validates retained builds against their original product version after the checkout advances', async () => {
const paths = await fixture('mac-arm64', '1.2.3')
await writeFile(join(paths.repositoryRoot, 'package.json'), '{"version":"2.0.0"}\n')
await writeFile(join(paths.appRoot, 'package.json'), '{"version":"2.0.0"}\n')
await expect(createDesktopUploadPlan('mac-arm64', paths)).rejects.toThrow(/completion record/u)
const plan = await createDesktopUploadPlan('mac-arm64', { ...paths, packagedProductVersion: '1.2.3' })
expect(plan.version).toBe('1.2.3')
await expect(createDesktopUploadPlan('mac-arm64', { ...paths, packagedProductVersion: '1.2.4' }))
.rejects.toThrow(/completion record/u)
await writeFile(join(paths.artifactsRoot, 'deepseek-harness-1.2.3-mac-arm64.zip'), 'tampered')
await expect(createDesktopUploadPlan('mac-arm64', { ...paths, packagedProductVersion: '1.2.3' }))
.rejects.toThrow(/size.*metadata/u)
})
it('rejects stale architecture metadata and modified updater bytes', async () => {
const paths = await fixture('mac-arm64')
const metadataPath = join(paths.artifactsRoot, 'nightly-mac.yml')
@@ -11,9 +11,5 @@
{
"key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/nightly-mac.yml",
"contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n"
},
{
"key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/latest-mac.yml",
"contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n"
}
]
@@ -11,9 +11,5 @@
{
"key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-x64/nightly-mac.yml",
"contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n"
},
{
"key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-x64/latest-mac.yml",
"contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n"
}
]
@@ -8,9 +8,5 @@
{
"key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/nightly.yml",
"contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\n size: 30\n sha512: >-\n eVumVOW1yvT3fAgGKUnliEzSfhmn8jUfzpNaUPufU1yekWrwJe8wRPC+JOrHH2Hmc/eLynwxjX1yIiER8tVOTA==\n"
},
{
"key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/latest.yml",
"contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\n size: 30\n sha512: >-\n eVumVOW1yvT3fAgGKUnliEzSfhmn8jUfzpNaUPufU1yekWrwJe8wRPC+JOrHH2Hmc/eLynwxjX1yIiER8tVOTA==\n"
}
]
@@ -28,9 +28,9 @@ afterEach(async () => {
describe('macOS packaged updater configuration', () => {
it('uses the final generic Nightly provider configured for the build', () => {
expect(resolveMacOSAppUpdateFeed([{ provider: 'generic', url: update.publicUrl, channel: 'nightly' }]))
.toEqual(update)
.toEqual({ ...update, channel: 'nightly' })
for (const publish of [undefined, [], [{ provider: 'github', channel: 'nightly', url: update.publicUrl }],
[{ provider: 'generic', channel: 'latest', url: update.publicUrl }]]) {
[{ provider: 'generic', channel: 'invalid', url: update.publicUrl }]]) {
expect(() => resolveMacOSAppUpdateFeed(publish)).toThrow(/macOS update config/u)
}
})
@@ -64,3 +64,12 @@ describe('macOS packaged updater configuration', () => {
.rejects.toThrow(/expected deepseek-harness-updater/u)
})
})
it('seals latest into the macOS updater file and rejects a different expected channel', async () => {
const paths = await fixture()
const latest = resolveMacOSAppUpdateFeed([{ provider: 'generic', url: update.publicUrl, channel: 'latest' }])
await writeMacOSAppUpdateConfig(paths.resourcesDir, latest, 'fixture-updater')
await expect(verifyMacOSAppUpdateConfig(paths.appPath, latest)).resolves.toBeUndefined()
await expect(verifyMacOSAppUpdateConfig(paths.appPath, update)).rejects.toThrow(/does not match/u)
})
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AccountClientMetadata } from '@deepseek-ai/dsh-deepseek-account/types'
import { DesktopMandatoryUpdatePolicy, desktopPolicyPage, resolveDesktopPolicyConfig, type DesktopPolicyState } from '../src/mandatory-update-policy.ts'
const identity = { platform: 'win32', arch: 'x64', bundledDshVersion: '0.1.5-rc.1' } as const
const identity = { platform: 'win32', arch: 'x64', channel: 'nightly', bundledDshVersion: '0.1.5-rc.1' } as const
const client: AccountClientMetadata = { version: '1.2.3', locale: 'zh-CN', timezoneOffsetSeconds: 28_800 }
const force = { code: 40005, data: { show_content: { title: '<b>Update</b>', detail: 'Required upgrade' },
desktop_app_link: 'https://downloads.example.com/desktop?os=win' } }
@@ -20,3 +20,12 @@ describe('manual Windows CI version', () => {
expect(() => windowsCiVersion({ ...inputs, ...override }, '0.2.1', now)).toThrow(/positive integer/u)
})
})
it('resolves the selected version policy independently of the update channel', () => {
expect(windowsCiVersion({ ...inputs, PACKAGE_VERSION_MODE: 'default', DSH_PACKAGING_PRESET: 'production' }, '1.2.3', now)).toBe('1.2.3')
expect(windowsCiVersion({ ...inputs, PACKAGE_VERSION_MODE: 'default', DSH_PACKAGING_PRESET: 'test' }, '1.2.3', now)).toBe('1.2.3-test.20261009.42.2')
expect(windowsCiVersion({ PACKAGE_VERSION_MODE: 'product' }, '1.2.3-alpha.1', now)).toBe('1.2.3-alpha.1')
expect(() => windowsCiVersion({ PACKAGE_VERSION_MODE: 'manual', PACKAGE_BUILD_VERSION: 'auto' }, '1.2.3', now)).toThrow(/explicit version/u)
expect(() => windowsCiVersion({ PACKAGE_VERSION_MODE: 'invalid' }, '1.2.3', now)).toThrow(/version mode/u)
})
@@ -0,0 +1,26 @@
import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { expect, it } from 'vitest'
it('masks multiline key bytes and passes the exact fixture to the token action without a shell', async () => {
const root = await mkdtemp(join(tmpdir(), 'desktop-app-key-'))
try {
const path = join(root, 'fixture.pem')
const output = join(root, 'output')
const key = 'fixture%key\r\nsecond-line\n'
await writeFile(path, key)
const result = await promisify(execFile)(process.execPath,
[fileURLToPath(new URL('../scripts/read-desktop-app-key.mjs', import.meta.url))], {
env: { ...process.env, APP_KEY_FILE: path, GITHUB_OUTPUT: output }, encoding: 'utf8',
})
expect(result.stdout).toBe('::add-mask::fixture%25key%0D%0Asecond-line%0A\n')
expect(result.stderr).toBe('')
const contents = await readFile(output, 'utf8')
const delimiter = contents.slice('private-key<<'.length, contents.indexOf('\n'))
expect(contents).toBe(`private-key<<${delimiter}\n${key}\n${delimiter}\n`)
} finally { await rm(root, { recursive: true, force: true }) }
})
@@ -0,0 +1,37 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { desktopReleasesRoot, retainDesktopCi } from '../scripts/retain-desktop-ci.ts'
import { verifyRetainedDesktopRelease } from '../scripts/desktop-release-store.ts'
const roots: string[] = []
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) })
it.each(['mac-arm64', 'win-x64'] as const)('retains the complete %s release without signing or upload credentials', async (target) => {
const root = await mkdtemp(join(tmpdir(), 'retain-ci-'))
roots.push(root)
const appRoot = join(root, 'checkout')
const config = join(root, 'packaging')
const artifacts = join(appRoot, '.desktop-build', 'targets', target, 'artifacts')
await mkdir(artifacts, { recursive: true })
await mkdir(join(config, 'presets'), { recursive: true })
await writeFile(join(appRoot, 'package.json'), '{"version":"1.0.0"}')
await writeFile(join(config, 'presets/test.env'), `DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDOWNLOAD_TEST_RELEASE_ID=${'a'.repeat(32)}\n`)
const names = target === 'win-x64'
? ['deepseek-harness-1.0.0-win-x64.exe', 'deepseek-harness-1.0.0-win-x64.exe.blockmap', 'win-x64-release.json', 'nightly.yml']
: ['deepseek-harness-1.0.0-mac-arm64.dmg', 'deepseek-harness-1.0.0-mac-arm64.zip', 'deepseek-harness-1.0.0-mac-arm64.zip.blockmap', 'mac-arm64-release.json', 'nightly-mac.yml']
await Promise.all(names.map(name => writeFile(join(artifacts, name), 'fixture')))
const env = {
DSH_PACKAGING_CONFIG_DIR: config, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin',
DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64', PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0', PACKAGE_PUBLISH: 'true',
PACKAGE_SIGNING: 'signed', PACKAGE_SKIP_NOTARIZATION: 'false', GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '2',
GITHUB_REPOSITORY: 'owner/repository', GITHUB_SHA: 'b'.repeat(40), RUNNER_NAME: 'dedicated',
}
await retainDesktopCi(env, appRoot)
const manifest = await verifyRetainedDesktopRelease(desktopReleasesRoot(env), '123', '2')
expect(manifest.files.map(file => file.name)).toEqual(names)
expect(manifest.source).toMatchObject({ target, publishRequested: true, qualification: target === 'win-x64' ? 'signed' : 'notarized' })
await expect(retainDesktopCi({ ...env, PACKAGE_SIGNING: 'unsigned', PACKAGE_SKIP_NOTARIZATION: 'true' }, appRoot))
.rejects.toThrow(/publishing requires/u)
})
+10
View File
@@ -0,0 +1,10 @@
import { expect, it } from 'vitest'
import { desktopUpdateChannel } from '../src/update-channel.ts'
it('reads the installed channel independently from the current version', () => {
expect(desktopUpdateChannel({ version: '1.0.0', dshDesktopUpdateChannel: 'nightly' })).toBe('nightly')
expect(desktopUpdateChannel({ version: '1.0.0', dshDesktopUpdateChannel: 'latest' })).toBe('latest')
expect(desktopUpdateChannel({ version: '1.0.0' })).toBe('nightly')
expect(() => desktopUpdateChannel({ dshDesktopUpdateChannel: 'beta' })).toThrow(/channel/u)
expect(() => desktopUpdateChannel(null)).toThrow(/manifest/u)
})
+17 -2
View File
@@ -47,7 +47,7 @@ describe('desktop release metadata', () => {
const coordinators: InstanceType<typeof DesktopUpdateCoordinator>[] = []
afterEach(() => { for (const item of coordinators.splice(0)) item.dispose() })
function fixture() {
function fixture(channel: 'nightly' | 'latest' = 'nightly', version = '1.1.0-alpha.1') {
const events = new EventEmitter()
const checkForUpdates = vi.fn(async () => ({
isUpdateAvailable: true,
@@ -66,7 +66,7 @@ function fixture() {
const updater = Object.assign(events, { checkForUpdates, downloadUpdate, quitAndInstall }) as unknown as AppUpdater
const coordinator = new DesktopUpdateCoordinator(
(state) => { states.push(state); return state },
beforeRestart, updater, () => true, () => '1.1.0-alpha.1', downloadResult,
beforeRestart, updater, () => true, () => version, downloadResult, channel,
)
coordinators.push(coordinator)
return { coordinator, updater, events, states, checkForUpdates, downloadUpdate, quitAndInstall, beforeRestart, downloadResult }
@@ -242,3 +242,18 @@ it.each([new DesktopUpdatePreparationError('stop-failed', 'private diagnostic'),
expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(false, error instanceof DesktopUpdatePreparationError ? 'stop-failed' : 'download_failed')
expect(JSON.stringify(f.downloadResult.mock.calls)).not.toContain('private')
})
it('keeps a stable-version nightly installation on nightly and rejects prereleases on latest', async () => {
const nightly = fixture('nightly', '1.0.0')
expect(nightly.updater.channel).toBe('nightly')
expect(nightly.updater.allowPrerelease).toBe(true)
expect((await nightly.coordinator.check()).phase).toBe('available')
expect(nightly.updater.channel).toBe('nightly')
const latest = fixture('latest', '1.0.0')
expect(latest.updater.channel).toBe('latest')
expect(latest.updater.allowPrerelease).toBe(false)
expect(latest.updater.allowDowngrade).toBe(false)
expect((await latest.coordinator.check()).phase).toBe('error')
expect(latest.downloadUpdate).not.toHaveBeenCalled()
})

Some files were not shown because too many files have changed in this diff Show More