diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml index 0a502fbafe..5de15975be 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md index a8d89a9fe5..5555c46b04 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.md @@ -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 } ``` -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`; `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 = AsyncIterable & { readonly [STREAM_UPLINK]?: In } export type PeerId = Branded<'PeerId'> -export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise } +export interface PeerScope { readonly id: PeerId; readonly ctx: Context; readonly principal: Principal; readonly signal: AbortSignal; dispose(): Promise } 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> /** Uplink items; absent is equivalent to an iterator that ends immediately. */ readonly uplink?: AsyncIterable - /** 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, peer: PeerScope | undefined, signal: AbortSignal) => Promise> + open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope, signal: AbortSignal) => Promise> 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` and generates the `uplink` boundary; the descriptor emits `uplink: { codec }`; the generated Client signature returns `RemoteStreamHandle`; 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`, `RemoteStream`, `never`, and the existing `AsyncIterable`; 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. diff --git a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md index 37613ef7e5..4e5681b04d 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-remote-duplex-stream.zh.md @@ -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 } ``` -本 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`;`rpc.open` 透传 | -进程内载体的 `peer` 缺席即操作者。 +进程内载体显式传入 Peer;worker 隧道的页内 Host 传 `connection.browserPeer`。 ### 与 `$events` 的关系 @@ -326,7 +330,7 @@ credit 帧留待需要持续大流量上行的消费者出现时再加;inbox // types.ts export type RemoteStream = AsyncIterable & { readonly [STREAM_UPLINK]?: In } export type PeerId = Branded<'PeerId'> -export interface PeerScope { readonly id: PeerId; readonly ctx: Context; dispose(): Promise } +export interface PeerScope { readonly id: PeerId; readonly ctx: Context; readonly principal: Principal; readonly signal: AbortSignal; dispose(): Promise } 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> /** Uplink items; absent is equivalent to an iterator that ends immediately. */ readonly uplink?: AsyncIterable - /** 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, peer: PeerScope | undefined, signal: AbortSignal) => Promise> + open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope, signal: AbortSignal) => Promise> 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` 并生成 `uplink` boundary;描述符输出 `uplink: { codec }`;生成的 Client 签名返回 `RemoteStreamHandle`;参数循环只认业务参数与末位 `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`、`RemoteStream`、`never` 与既有 `AsyncIterable` 的处理;修饰器选项;registry 加载校验。 -- connection:`admit` 的 401、403 与操作者三种结果;操作者经桥与共享处理器到达;随 connection 释放。 +- connection:`admit` 的 401、403 与 `browserPeer` 三种结果;`browserPeer` 经桥与共享处理器到达;随 connection 释放。 - remote-mock、worker 隧道:上行透传;直接代理返回真句柄。 - 既有 stream 与 unary 方法的测试不改期望值即通过。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml new file mode 100644 index 0000000000..363cc2fb3c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md new file mode 100644 index 0000000000..a62f0a5e74 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md @@ -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 +} + +// @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 +} +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:`; 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 +// @deepseek-ai/dsh-workspace/types: workspace: TypertResource + +// 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 { + 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 +} + +export type RemoteAccessResourceKind = 'global' | TypertResourceKind +export type RemoteAccessResource = + | { readonly kind: 'global' } + | { readonly kind: K; readonly id: TypertResourceId } // for each declared kind K +export type RemoteAccessResourceOf = Extract + +/** Per-kind readers, called with the method's positional wire arguments. */ +export type TypertResourceReaders = { + readonly [K in TypertResourceKind]?: (...args: WireArgs) => Id | readonly Id[] | undefined // Id = TypertResourceId +} + +export interface RemoteMethodOptions { + readonly name?: string + readonly mode?: 'stream' + readonly resources?: TypertResourceReaders +} + +export function Remote( + option: string | RemoteMethodOptions>>, +): (method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>) => void + +export interface RemoteScopeOptions { + readonly name?: string + readonly resources?: TypertResourceReaders +} + +export function RemoteScope( + key: Extract, + option?: string | RemoteScopeOptions>>, +): (method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext 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` in `@deepseek-ai/dsh-session/types` and `workspace: TypertResource` 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:` 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 { + 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 = (request: { + readonly action: RemoteAccessAction + readonly resource: RemoteAccessResourceOf + readonly args?: Readonly> +}) => 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 + readonly resource: RemoteAccessResourceOf + readonly condition?: RemoteAccessCondition + } +}[RemoteAccessResourceKind] + +/** One access question; the Gateway passes only valid resources, [global] when none. */ +export interface RemoteAccessRequest

{ + 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> +} + +/** Business rules for one non-owner principal kind; synchronous over in-memory state. */ +export interface RemoteAccessPolicy

{ + bindings(principal: P): readonly RemoteAccessBinding[] + /** Reason for a request no binding allows; never changes the decision. */ + explain?(request: RemoteAccessRequest

): string | undefined +} + +/** details of `remote-access/denied` */ +export interface RemoteAccessDeniedDetails { + readonly action: string + readonly reason: string +} + +class RemoteAccessService { // ctx.remoteAccess; Remote namespace remoteAccess + registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void + invalidate(principal?: Principal): void + decide(request: RemoteAccessRequest): 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 // 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:', 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:` | +| 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 | + + +#### 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:` 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 +} +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 +``` + +| 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:` 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:` 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:` 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' }` | diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md new file mode 100644 index 0000000000..30d47c9ccb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md @@ -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 +} + +// @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 +} +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:`;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 +// @deepseek-ai/dsh-workspace/types: workspace: TypertResource + +// 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 { + 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 +} + +export type RemoteAccessResourceKind = 'global' | TypertResourceKind +export type RemoteAccessResource = + | { readonly kind: 'global' } + | { readonly kind: K; readonly id: TypertResourceId } // for each declared kind K +export type RemoteAccessResourceOf = Extract + +/** Per-kind readers, called with the method's positional wire arguments. */ +export type TypertResourceReaders = { + readonly [K in TypertResourceKind]?: (...args: WireArgs) => Id | readonly Id[] | undefined // Id = TypertResourceId +} + +export interface RemoteMethodOptions { + readonly name?: string + readonly mode?: 'stream' + readonly resources?: TypertResourceReaders +} + +export function Remote( + option: string | RemoteMethodOptions>>, +): (method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext Result>) => void + +export interface RemoteScopeOptions { + readonly name?: string + readonly resources?: TypertResourceReaders +} + +export function RemoteScope( + key: Extract, + option?: string | RemoteScopeOptions>>, +): (method: (this: This, ...args: Args) => Result, + context: ClassMethodDecoratorContext 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` 在 `@deepseek-ai/dsh-session/types`,`workspace: TypertResource` 在 `@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:` 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 { + 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 = (request: { + readonly action: RemoteAccessAction + readonly resource: RemoteAccessResourceOf + readonly args?: Readonly> +}) => 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 + readonly resource: RemoteAccessResourceOf + readonly condition?: RemoteAccessCondition + } +}[RemoteAccessResourceKind] + +/** One access question; the Gateway passes only valid resources, [global] when none. */ +export interface RemoteAccessRequest

{ + 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> +} + +/** Business rules for one non-owner principal kind; synchronous over in-memory state. */ +export interface RemoteAccessPolicy

{ + bindings(principal: P): readonly RemoteAccessBinding[] + /** Reason for a request no binding allows; never changes the decision. */ + explain?(request: RemoteAccessRequest

): string | undefined +} + +/** details of `remote-access/denied` */ +export interface RemoteAccessDeniedDetails { + readonly action: string + readonly reason: string +} + +class RemoteAccessService { // ctx.remoteAccess; Remote namespace remoteAccess + registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void + invalidate(principal?: Principal): void + decide(request: RemoteAccessRequest): 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 // 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:', resources })` 允许时才推送。 + +| 规则 | 内容 | +|---|---| +| 已声明事件 | `api-session/activity`、`api-session/added`、`api-session/error`、`api-session/removed` 与 `api-session/status` 声明其 Session | +| 未声明资源 | 未声明 `resources` 或声明为空 `resources: {}` 的事件访问 `global`,只有 `global` 级 role 列出 `event:` 时才送达非 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 流 | + + +#### 列表与聚合方法 + +列表、分页与聚合类方法的调用解析为 `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:` 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 +} +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 +``` + +| 事项 | 定稿 | +|---|---| +| 快照与挂载 | 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:` 与其资源判定,被允许才转发给非 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:`。 +- 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:` 于其资源上被授权时整条收到事件;未声明资源的事件按 `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' }` | diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 9888602296..8b2cfb83ab 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -30,6 +30,8 @@ flowchart LR cfg --> plugin_dsh_base_typert_loader plugin_dsh_base_typert_gateway["typert-gateway
@deepseek-ai/dsh-api-gateway"] cfg --> plugin_dsh_base_typert_gateway + plugin_dsh_base_remote_access["remote-access
@deepseek-ai/dsh-api-remote-access"] + cfg --> plugin_dsh_base_remote_access plugin_dsh_base_session_title["session-title
@deepseek-ai/dsh-session-title"] cfg --> plugin_dsh_base_session_title plugin_dsh_base_session_title_llm["session-title-llm
@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` | diff --git a/benchmarks/package.json b/benchmarks/package.json index 4a2be7ed1d..d36d79bfdb 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -15,6 +15,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", diff --git a/benchmarks/session-corpus/projection-list.worker.ts b/benchmarks/session-corpus/projection-list.worker.ts index 106c520be2..5e95356c12 100644 --- a/benchmarks/session-corpus/projection-list.worker.ts +++ b/benchmarks/session-corpus/projection-list.worker.ts @@ -7,6 +7,7 @@ import { monitorEventLoopDelay, performance } from 'node:perf_hooks' import { scheduler, setTimeout as delay } from 'node:timers/promises' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '@deepseek-ai/dsh-api-session-controller' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, type SessionHeader } from '@deepseek-ai/dsh-session' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -196,10 +197,15 @@ async function mountHost(ctx: Context, root: string, workload: typeof WORKLOADS[ await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:', openAt: 'never' }) // No Agents, uploads, model calls, or workspace actions occur on this list path. const dispose = (): void => {} - ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose } } as never) + ctx.provide('typert', { + lookups: { configure: () => dispose }, + contexts: { configureHost: () => dispose }, + resources: { register: () => dispose }, + } as never) ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'synthetic', model: 'generated-model' }) } as never) ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) + new RemoteAccessService(ctx) return new SessionController(ctx, { nativeOpen: false, ...(listWorkSliceMs === undefined ? {} : { listWorkSliceMs }) }, { canOpenPath: () => false }) } diff --git a/benchmarks/session-corpus/session-corpus.worker.ts b/benchmarks/session-corpus/session-corpus.worker.ts index c533852fa1..2d1acd56ba 100644 --- a/benchmarks/session-corpus/session-corpus.worker.ts +++ b/benchmarks/session-corpus/session-corpus.worker.ts @@ -8,6 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '@deepseek-ai/dsh-api-session-controller' import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections' import { Session, SessionLogOffset } from '@deepseek-ai/dsh-session' @@ -148,10 +149,15 @@ async function mountHost(root: string): Promise<{ readonly ctx: Context; readonl await ctx.plugin(AgentLoop, { agents: [] }) // Transport, upload, model-default, and Workspace services are outside every measured path. const dispose = (): void => {} - ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose } } as never) + ctx.provide('typert', { + lookups: { configure: () => dispose }, + contexts: { configureHost: () => dispose }, + resources: { register: () => dispose }, + } as never) ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) ctx.provide('agentDefaultModel', { currentSelection: () => BENCH_MODEL } as never) ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) + new RemoteAccessService(ctx) const controller = new SessionController(ctx, { nativeOpen: false }, { canOpenPath: () => false }) return { ctx, controller } } diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 3f9e8b79ab..65b0c3e196 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -6,17 +6,17 @@ en: 735eb4f22d5f6f71 zh: 697eef269b133039 /api-gateway/programming-model: - en: ea12b64211f0d377 - zh: 032e5c0d799c7529 + en: 78eb70d6c2ce6608 + zh: 826f0ccc206b7299 /api-gateway/component-responsibilities: - en: e29f1d7026291075 - zh: 15e361e797d44fce + en: fe382a9bcc768cc2 + zh: 78e3d50d59780c3d /api-gateway/strict-generation-pipeline: en: 9c4a98eb0539ec7f zh: ad2a145abd805fdd /api-gateway/runtime-invocation: - en: 2a2fed340aaa926c - zh: 46bb1f500c870a76 + en: 37b4858fa48a61b6 + zh: 9a296a2171f1ab7d /api-gateway/src-development-fallback: en: 9570ffe7a348e5ac zh: 2b70b61b1ff1fbb0 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 0a891cd244..484b866e95 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -12,6 +12,8 @@ Business services use `@Remote` or `@RemoteScope` to select the methods exposed `@RemoteScope(key)` first resolves an identity to a scoped Context through `ctx.typert.contexts`, then obtains the service from that Context and invokes the method. It applies when the method itself depends on scoped composition and does not need to receive objects such as `Agent` explicitly. +`@Remote` accepts an export name or `{ name?, mode?, resources? }`, and `@RemoteScope(key, option?)` accepts an export name, as in `@RemoteScope('agent', 'current')` below, or `{ name?, resources? }` as its second argument. `resources` holds per-kind readers that return the identities of the resources one call touches for access control; each reader receives the method's wire arguments as its own parameters, with types inferred from the decorated method. Most methods declare no readers, because the Gateway also reads identities from lookup parameters and scoped Contexts that declare a `resource`, and from the default fields in `typert.resources`; the [Typert subsystem](subsystems/typert.md) owns that order. + Services normally extend `TypertRemoteService` so the constructor explicitly binds the Cordis service key and default Remote namespace. A service that already has another base class can instead declare `readonly typertRemote = bindTypertRemote(this, serviceKey)`; both forms leave an inspectable public binding and do not depend on the compiler injecting a symbol into the constructor. ```ts @@ -88,6 +90,7 @@ The `api-remotes` assembly and the `ctx.remote` contract are React-independent; | Host | `@deepseek-ai/dsh-typert-registry` and Loader | Places generated Host descriptors, schemas, and business-package registrations in `ctx.typert`, and holds lookup and Context providers | | Host | `@deepseek-ai/dsh-api-session-controller` | Owns the application Agent/Session identity policy and configures the corresponding Typert lookups | | Host | `@deepseek-ai/dsh-api-gateway` | Provides `ctx.typertGateway`, claims Remote endpoints, validates request values, resolves objects or Contexts, and invokes live Cordis services | +| Host | `@deepseek-ai/dsh-api-remote-access` | Decides which Remote methods a non-owner Peer may call through business-registered role-based policies, filters list results, and streams each Peer's access snapshot; the Gateway requires it | | Client | `@deepseek-ai/dsh-api-gateway/client` | Provides `ctx.remote` and `remote.` child Services, mounts generated descriptors as concrete methods, and initiates and cancels calls through the Connection | | Client | `@deepseek-ai/dsh-api-remotes/client` | Explicitly selects and mounts the `/remote` contributions allowed by the application and brings the corresponding declaration merges into business code | | Both | `@deepseek-ai/dsh-client-connection` | Provides the RPC carrier, request correlation, trust boundary, cancellation, response envelope, and the `/api` HTTP bridge | @@ -124,7 +127,7 @@ Remote calls use the Connection's `/api` route. The Client Remote calls `connect The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler. The Typert Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; it projects binary Remote fields into JSON-compatible metadata and result-relative byte attachments, which Connection frames as multipart responses. Feature-owned exact Fetch routes handle responses outside the RPC envelope, and other requests return 404. Connection owns transport, RPC ids, response envelopes, and request cancellation, while Gateway owns the Remote data protocol and business dispatch. Replacing the Connection carrier does not require changes to Remote descriptors or the Client programming interface. -For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, and invokes the service method targeted by the binding. The Client Gateway validates decoded binary results with the generated result codec; ordinary JSON results retain their existing handling. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. +For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. Every call names the Peer it speaks for, and a disposed Peer is refused with `gateway/peer-disposed`. The Gateway requires the fields in `args` to match the descriptor exactly; for a call whose Peer principal is not the owner it then resolves the resources the call touches and asks `ctx.remoteAccess.decide()`, so a refused call resolves no lookup and activates no Agent. It validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, and invokes the service method targeted by the binding. The Client Gateway validates decoded binary results with the generated result codec; ordinary JSON results retain their existing handling. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `gateway/lookup-unavailable`, and unloading the configuration restores the provider's default policy. The Session Controller owns the standard resolver semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. A resume failure and an ownership fence raise a `RemoteError` carrying their own code, `session/not-found` or `session/agent-busy`, which the Gateway encodes onto the wire unchanged; only an unclassified throw folds into `gateway/internal`. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 4cc33ee24b..c44d6f403f 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -14,6 +14,8 @@ `@RemoteScope(key)` 表示先通过 `ctx.typert.contexts` 把 identity 解析为一个作用域 Context,再从该 Context 取得服务并调用方法。它适用于方法本身依赖作用域组合、而不需要显式接收 `Agent` 等对象的情形。 +`@Remote` 接受导出名或 `{ name?, mode?, resources? }`;`@RemoteScope(key, option?)` 的第二个参数接受导出名(如下文的 `@RemoteScope('agent', 'current')`)或 `{ name?, resources? }`。`resources` 按资源种类给出读取函数,返回一次调用所访问资源的身份,供访问控制使用;每个读取函数以方法的 wire 参数作为自身参数,类型从被装饰方法推断。多数方法不声明读取函数,因为 Gateway 还会从声明了 `resource` 的 lookup 参数与作用域 Context 以及 `typert.resources` 中的默认字段读取身份;[Typert 子系统](subsystems/typert.zh.md)负责该顺序。 + 服务通常继承 `TypertRemoteService`,让 Cordis 服务 key 与默认 Remote namespace 在构造器中显式绑定。已有其他基类的服务可以改为声明 `readonly typertRemote = bindTypertRemote(this, serviceKey)`;两种方式都会留下可检查的公开 binding,不依赖编译器向构造函数注入 symbol。 ```ts @@ -90,6 +92,7 @@ Client 应用只装配 `@deepseek-ai/dsh-api-remotes`。该包以运行时值导 | Host | `@deepseek-ai/dsh-typert-registry` 与 Loader | 把生成的 Host 描述符、schema 及业务包注册项放入 `ctx.typert`,并持有 lookup 与 Context 提供方 | | Host | `@deepseek-ai/dsh-api-session-controller` | 负责应用的 Agent/Session 身份策略,并配置对应的 Typert lookup | | Host | `@deepseek-ai/dsh-api-gateway` | 提供 `ctx.typertGateway`,认领 Remote endpoint,校验请求值,解析对象或 Context,并调用实时 Cordis 服务 | +| Host | `@deepseek-ai/dsh-api-remote-access` | 按业务方注册的基于 role 的 policy 判定非 owner Peer 可调用哪些 Remote 方法,过滤列表结果,并为每个 Peer 推送访问快照;Gateway 依赖它 | | Client | `@deepseek-ai/dsh-api-gateway/client` | 提供 `ctx.remote` 与 `remote.` 子服务,把生成的描述符挂成具体方法,并通过 Connection 发起和取消调用 | | Client | `@deepseek-ai/dsh-api-remotes/client` | 显式选择并挂载本应用允许使用的 `/remote` 贡献,向业务代码带入对应的声明合并 | | 双侧 | `@deepseek-ai/dsh-client-connection` | 提供 RPC carrier、请求关联、信任边界、取消、响应 envelope 与 `/api` HTTP bridge | @@ -126,7 +129,7 @@ Remote 调用使用 Connection 的 `/api` 路由。Client Remote 调用 `connect Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内分发。Typert Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;它把二进制 Remote 字段投影为 JSON 兼容元数据及相对于结果的字节附件,再由 Connection 封装为 multipart 响应。功能自有的精确 Fetch 路由处理 RPC 信封之外的响应,其他请求返回 404。Connection 拥有传输、RPC id、响应 envelope 和请求取消,Gateway 拥有 Remote 数据协议和业务分发。替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程接口。 -Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法。Client Gateway 使用生成的结果 codec 校验解码后的二进制结果;普通 JSON 结果保持原有处理方式。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 +Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。每次调用都给出其代表的 Peer,已 dispose 的 Peer 以 `gateway/peer-disposed` 被拒。Gateway 要求 `args` 的字段集合与描述符完全一致;对 Peer principal 不是 owner 的调用,随后解析调用访问的资源并询问 `ctx.remoteAccess.decide()`,被拒的调用不解析任何 lookup,也不激活 Agent。之后它先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法。Client Gateway 使用生成的结果 codec 校验解码后的二进制结果;普通 JSON 结果保持原有处理方式。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 lookup 提供方的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于提供方挂载;没有提供方时调用仍以 `gateway/lookup-unavailable` 失败,配置卸载后则恢复提供方默认策略。Session Controller 负责 `agent` 与 `session` 的标准 resolver 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败与 ownership fence 抛出携带自有码的 `RemoteError`(`session/not-found` 或 `session/agent-busy`),Gateway 原样编码上 wire;只有未归类的 throw 才折成 `gateway/internal`。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index ba4bb17387..49d05c0722 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: 2e9d4cb22f8f2a6b - zh: 81f523914e8531f7 + en: d7b1a50795bcac28 + zh: 68386bdbf1869342 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 95ab26504b..333f570d5e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -101,6 +101,8 @@ flowchart LR svc_typert["ctx.typert
Runtime type registry"] pkg_typert_loader["typert-loader"] svc_typertGateway["ctx.typertGateway
Typert Host invocation gateway"] + pkg_api_remote_access["api-remote-access"] + svc_remoteAccess["ctx.remoteAccess
Remote method access control"] svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_hooks_claude_code["hooks-claude-code"] @@ -294,6 +296,7 @@ flowchart LR pkg_agent_preset_registry --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_job_controller --> svc_jobController + pkg_api_remote_access --> svc_remoteAccess pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences pkg_api_session_controller --> svc_sessionSkillCatalog @@ -500,6 +503,9 @@ flowchart LR svc_profileContext --> pkg_plugin_manager svc_ptcRuntime --> pkg_tools svc_ptcRuntime --> pkg_workflow_ptc + svc_remoteAccess --> pkg_api_gateway + svc_remoteAccess --> pkg_api_session_controller + svc_remoteAccess --> pkg_api_workspace_controller svc_sandbox --> pkg_bash_sandbox svc_sandbox --> pkg_terminal_bash svc_sandboxPolicy --> pkg_bash_sandbox @@ -631,6 +637,7 @@ flowchart LR | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | +| `ctx.remoteAccess` | `core` | [`api-remote-access`](../packages/api/remote-access) | - | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller) | - | Decides which Remote methods a non-owner Peer may call through business-registered role-based policies, filters list results, and streams each Peer access snapshot. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | The JSONL backend persists the SessionEvent vocabulary as one artifact per Session. | | `ctx.configEditor` | `core` | [`config-editor`](../packages/boot/config-editor) | - | [`settings`](../packages/settings/settings), [`agent-default-model`](../packages/core/agent-default-model) | - | Persists profile config patches under the application file lock and HMR queue, then reconciles Loader entries. | | `ctx.settings` | `core` | [`settings`](../packages/settings/settings) | - | [`api-settings-controller`](../packages/api/settings-controller) | - | Forms project volatile Config fields from active profile entries and delegate validated edits to config-editor. Plugins consume their own Config references. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index ee3ec60466..d810b4e900 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -103,6 +103,8 @@ flowchart LR svc_typert["ctx.typert
Runtime type registry"] pkg_typert_loader["typert-loader"] svc_typertGateway["ctx.typertGateway
Typert Host invocation gateway"] + pkg_api_remote_access["api-remote-access"] + svc_remoteAccess["ctx.remoteAccess
Remote method access control"] svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] pkg_session_persistence_jsonl["session-persistence-jsonl"] pkg_hooks_claude_code["hooks-claude-code"] @@ -296,6 +298,7 @@ flowchart LR pkg_agent_preset_registry --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_job_controller --> svc_jobController + pkg_api_remote_access --> svc_remoteAccess pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences pkg_api_session_controller --> svc_sessionSkillCatalog @@ -502,6 +505,9 @@ flowchart LR svc_profileContext --> pkg_plugin_manager svc_ptcRuntime --> pkg_tools svc_ptcRuntime --> pkg_workflow_ptc + svc_remoteAccess --> pkg_api_gateway + svc_remoteAccess --> pkg_api_session_controller + svc_remoteAccess --> pkg_api_workspace_controller svc_sandbox --> pkg_bash_sandbox svc_sandbox --> pkg_terminal_bash svc_sandboxPolicy --> pkg_bash_sandbox @@ -633,6 +639,7 @@ flowchart LR | `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | +| `ctx.remoteAccess` | `core` | [`api-remote-access`](../packages/api/remote-access) | - | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller) | - | 通过业务方注册的基于 role 的 policy 判定非 owner Peer 可调用哪些 Remote 方法,过滤列表结果,并为每个 Peer 推送访问快照。 | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | JSONL backend 把 SessionEvent 词汇持久化为每个 Session 一份产物。 | | `ctx.configEditor` | `core` | [`config-editor`](../packages/boot/config-editor) | - | [`settings`](../packages/settings/settings), [`agent-default-model`](../packages/core/agent-default-model) | - | Persists profile config patches under the application file lock and HMR queue, then reconciles Loader entries. | | `ctx.settings` | `core` | [`settings`](../packages/settings/settings) | - | [`api-settings-controller`](../packages/api/settings-controller) | - | Forms project volatile Config fields from active profile entries and delegate validated edits to config-editor. Plugins consume their own Config references. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6a05ba6bb9..514968564b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -181,8 +181,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` -- `inject`: `typert` -- `source`: [`packages/api/gateway/src/index.ts:147`](../packages/api/gateway/src/index.ts) +- `inject`: `typert` · `remoteAccess` +- `source`: [`packages/api/gateway/src/index.ts:155`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ @@ -219,8 +219,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts) +- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` +- `source`: [`packages/api/session-controller/src/index.ts:94`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -300,8 +300,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-workspace-controller` -- `inject`: `typert` · `workspaceRegistry` -- `source`: [`packages/api/workspace-controller/src/index.ts:33`](../packages/api/workspace-controller/src/index.ts) +- `inject`: `remoteAccess` · `typert` · `workspaceRegistry` +- `source`: [`packages/api/workspace-controller/src/index.ts:37`](../packages/api/workspace-controller/src/index.ts) ```ts config-catalog /** First-use directory policy for the Host account. */ @@ -435,7 +435,7 @@ export type Config = LocalConfig ## `@deepseek-ai/dsh-client-connection` - `inject`: `credentials` -- `source`: [`packages/client/connection/src/index.ts:92`](../packages/client/connection/src/index.ts) +- `source`: [`packages/client/connection/src/index.ts:93`](../packages/client/connection/src/index.ts) ```ts config-catalog /** Browser authentication, request limits, and connection recovery configuration. */ @@ -3276,7 +3276,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' - `inject`: `workingDirectory` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/subagent/subagent/src/index.ts:184`](../packages/subagent/subagent/src/index.ts) +- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts) ```ts config-catalog /** Host configuration for continuable subagent capacity. */ @@ -4515,6 +4515,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) | | `@deepseek-ai/dsh-agent` | — | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) | | `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) | +| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) | | `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) | | `@deepseek-ai/dsh-authorization` | `credentials` | [`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts) | | `@deepseek-ai/dsh-browser-use` | — | [`packages/browser-use/browser-use/src/index.ts`](../packages/browser-use/browser-use/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 80bf1886b4..005ea98599 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -183,8 +183,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-gateway` -- `inject`: `typert` -- `source`: [`packages/api/gateway/src/index.ts:147`](../packages/api/gateway/src/index.ts) +- `inject`: `typert` · `remoteAccess` +- `source`: [`packages/api/gateway/src/index.ts:155`](../packages/api/gateway/src/index.ts) ```ts config-catalog /** Gateway transport configuration. */ @@ -221,8 +221,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts) +- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` +- `source`: [`packages/api/session-controller/src/index.ts:94`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -302,8 +302,8 @@ export interface Config { ## `@deepseek-ai/dsh-api-workspace-controller` -- `inject`: `typert` · `workspaceRegistry` -- `source`: [`packages/api/workspace-controller/src/index.ts:33`](../packages/api/workspace-controller/src/index.ts) +- `inject`: `remoteAccess` · `typert` · `workspaceRegistry` +- `source`: [`packages/api/workspace-controller/src/index.ts:37`](../packages/api/workspace-controller/src/index.ts) ```ts config-catalog /** First-use directory policy for the Host account. */ @@ -437,7 +437,7 @@ export type Config = LocalConfig ## `@deepseek-ai/dsh-client-connection` - `inject`: `credentials` -- `source`: [`packages/client/connection/src/index.ts:92`](../packages/client/connection/src/index.ts) +- `source`: [`packages/client/connection/src/index.ts:93`](../packages/client/connection/src/index.ts) ```ts config-catalog /** Browser authentication, request limits, and connection recovery configuration. */ @@ -3278,7 +3278,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' - `inject`: `workingDirectory` - `refs`: `Volatile` (`@deepseek-ai/cordis`) -- `source`: [`packages/subagent/subagent/src/index.ts:184`](../packages/subagent/subagent/src/index.ts) +- `source`: [`packages/subagent/subagent/src/index.ts:186`](../packages/subagent/subagent/src/index.ts) ```ts config-catalog /** Host configuration for continuable subagent capacity. */ @@ -4517,6 +4517,7 @@ export interface Config { | `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) | | `@deepseek-ai/dsh-agent` | — | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) | | `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) | +| `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) | | `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) | | `@deepseek-ai/dsh-authorization` | `credentials` | [`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts) | | `@deepseek-ai/dsh-browser-use` | — | [`packages/browser-use/browser-use/src/index.ts`](../packages/browser-use/browser-use/src/index.ts) | diff --git a/docs/defensive-patterns/remote-and-api.i18n.yaml b/docs/defensive-patterns/remote-and-api.i18n.yaml index e36bfb5699..c16a005c98 100644 --- a/docs/defensive-patterns/remote-and-api.i18n.yaml +++ b/docs/defensive-patterns/remote-and-api.i18n.yaml @@ -9,8 +9,8 @@ en: 5759a7b0f96ea4bc zh: 62465fbe91a4d2af /remote-and-api-rules/remote-marks-capability-methods: - en: e5613ec07023429d - zh: 9272c9ae5718060a + en: 86422ee69c63f339 + zh: 4a5e8d6f5d9a6ae3 /remote-and-api-rules/packages-api-carries-generic-orchestration-only: en: 12a964b9c04fccf9 zh: cf804f8044535727 diff --git a/docs/defensive-patterns/remote-and-api.md b/docs/defensive-patterns/remote-and-api.md index 25281e96fb..7f2d0646c9 100644 --- a/docs/defensive-patterns/remote-and-api.md +++ b/docs/defensive-patterns/remote-and-api.md @@ -44,13 +44,14 @@ One object then answers for both domains, so a change to either capability's act ## `@Remote` marks capability methods -`@Remote` carries four disciplines that a reviewer checks separately: it marks the concrete method that performs a capability; client-facing types come from the existing definitions through the generator instead of a hand-written copy; the payload is JSON and the remote method performs no output-side decode; and existing method and field names are additive. Each discipline removes a different failure — a wrapper hides which operation is exposed, a second hand-written type set drifts from the generated one, an output-side decode adds a second result path beside the codec the Gateway already runs, and a rename breaks every caller in the same change. +`@Remote` carries five disciplines that a reviewer checks separately: it marks the concrete method that performs a capability; client-facing types come from the existing definitions through the generator instead of a hand-written copy; the payload is JSON and the remote method performs no output-side decode; existing method and field names are additive; and a method names every resource it touches and reads its caller only from the invocation. Each discipline removes a different failure — a wrapper hides which operation is exposed, a second hand-written type set drifts from the generated one, an output-side decode adds a second result path beside the codec the Gateway already runs, a rename breaks every caller in the same change, and an unnamed resource, an unfiltered aggregate, or an argument-supplied caller lets a non-owner reach what no binding grants it. - **Capability methods.** The annotation goes on the concrete method that performs the operation (`list`, `prompt`, `interruptByParent`), never on a wrapper that forwards to the real implementation. Judged by the decorated method's body: a body that only delegates means the annotation is on the wrong method. - **Types from inference.** Client-facing types are aliased from what the generator produces — `type SessionRemote = TypertClientRemote['session']` — instead of being written a second time. Judged by a hand-written declaration whose fields repeat a generated Client type; [typert.md](../subsystems/typert.md) owns the generated maps and their declaration merges. - **JSON on the wire, no output-side decode.** The **wire payload** of a Remote method is JSON, with binary content expressed field-level. The Host signature keeps what the Gateway already supports — lookup arguments resolved to Host objects, a trailing `AbortSignal`, a stream declared with `@Remote({ mode: 'stream' })` — and `@Remote` performs no decode on the way out; the generated codec and the Gateway validate the result. Judged by a decode, a stream reassembly, or a whole non-JSON value in the payload of a method carrying the decorator; [programming model](../api-gateway.md#programming-model) owns what may cross a Remote method. - **Existing names are additive.** A migration does not rename a remote method or field that a Client already calls; a new semantic gets a new entry, and a rename is its own change with its own review. Judged by a diff that renames or removes a name already exposed through `ctx.remote.`. -- **Not covered.** A method without the decorator stays an ordinary service method with in-process callers; the four disciplines govern what becomes a Remote method, not what a service may implement. +- **Caller identity and access.** Every Remote method is under access control: the `@Remote({ resources })` / `@RemoteScope(key, { resources })` readers and automatic resolution must name every resource the method touches, because access control never checks a resource they omit, and a method returning several resources filters each item through `this.ctx.remoteAccess.allows(resource)`. The caller is read only from `this.ctx.invocation.peer.principal`, never from arguments; a Remote method that builds a user-message source records it through `principalOf(this.ctx.invocation)` and passes the built source down instead of passing the principal as a function argument. Every call path names its Peer explicitly. Judged by a method whose resolved resources omit one it touches, an unfiltered aggregate, a principal read from wire data or threaded through helper parameters, or a user-message source built in a Remote method without `principalOf`; the [Peer principals Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns the design. +- **Not covered.** A method without the decorator stays an ordinary service method with in-process callers; the five disciplines govern what becomes a Remote method, not what a service may implement. - **Exception.** A rename or a new decode path states its benefit and the impact on existing callers first and needs the domain owner's confirmation; the author never self-approves. The generated map is the only source for Client-facing types, the decorator sits on the concrete method, and the payload stays JSON: diff --git a/docs/defensive-patterns/remote-and-api.zh.md b/docs/defensive-patterns/remote-and-api.zh.md index 1a9c291d9c..c621109913 100644 --- a/docs/defensive-patterns/remote-and-api.zh.md +++ b/docs/defensive-patterns/remote-and-api.zh.md @@ -44,13 +44,14 @@ class ApiProxy { ## `@Remote` 标在能力方法上 -`@Remote` 有四项纪律,审查者分别核对:标注执行能力的具体方法;面向 Client 的类型经生成器来自既有定义,而不是手写第二份;载荷只承载 JSON,远程方法不做输出侧 decode;既有的方法与字段名只增不改。每项纪律消除一类不同的缺陷——壳方法藏起真正暴露的是哪个操作,手写的第二份类型会与生成结果漂移,输出侧 decode 在 Gateway 已有的 codec 之外再造一条结果路径,改名则让所有调用方在同一次变更里一起断掉。 +`@Remote` 有五项纪律,审查者分别核对:标注执行能力的具体方法;面向 Client 的类型经生成器来自既有定义,而不是手写第二份;载荷只承载 JSON,远程方法不做输出侧 decode;既有的方法与字段名只增不改;方法指明其访问的全部资源,且只从 invocation 读取调用方。每项纪律消除一类不同的缺陷——壳方法藏起真正暴露的是哪个操作,手写的第二份类型会与生成结果漂移,输出侧 decode 在 Gateway 已有的 codec 之外再造一条结果路径,改名让所有调用方在同一次变更里一起断掉,遗漏资源、未过滤的聚合结果或按参数判定调用方则让非 owner 访问没有 binding 授予它的内容。 - **标能力方法。** 标注落在执行该操作的具体方法上(`list`、`prompt`、`interruptByParent`),绝不落在转发给真正实现的壳方法上。判定看被标注方法的函数体:函数体只做转发,标注就标错了方法。 - **类型靠推断。** 面向 Client 的类型从生成结果取别名——`type SessionRemote = TypertClientRemote['session']`——而不是重写一份。判定看是否出现字段与生成的 Client 类型重复的手写声明;生成映射及其声明合并的正家是 [typert.zh.md](../subsystems/typert.zh.md)。 - **线上载荷只承载 JSON、不做输出侧 decode。** Remote 方法的**线上载荷**是 JSON,二进制在字段级表达。Host 签名保留 Gateway 已支持的形式——经解析变成 Host 对象的 lookup 参数、末尾的 `AbortSignal`、以 `@Remote({ mode: 'stream' })` 声明的流式返回;`@Remote` 不做输出侧 decode,结果由生成的 codec 与 Gateway 校验。判定看带该标注的方法上是否出现 decode、流重组或整体非 JSON 值;可跨越 Remote 方法的内容正家是 [编程模型](../api-gateway.zh.md#programming-model)。 - **存量名只增不改。** 迁移不改 Client 已在调用的远程方法名或字段名;新语义新增条目,改名是独立的一刀、单独评审。判定看 diff 是否改名或删除了已经经 `ctx.remote.` 暴露的名字。 -- **不覆盖。** 不带该标注的方法仍是有进程内调用者的普通 service 方法;这四项纪律约束的是什么成为 Remote 方法,而不是 service 可以实现什么。 +- **调用方身份与访问。** 每个 Remote 方法都受访问控制:`@Remote({ resources })` / `@RemoteScope(key, { resources })` 的读取函数与自动解析必须指明方法访问的全部资源,因为访问控制从不检查它们遗漏的资源;返回多个资源的方法对每一项经 `this.ctx.remoteAccess.allows(resource)` 过滤。调用方只从 `this.ctx.invocation.peer.principal` 读取,绝不从参数读取;在 Remote 方法内构造用户消息 source 时经 `principalOf(this.ctx.invocation)` 记录调用方,并向下传递已构造的 source,而不是把 principal 当函数参数传递。每条调用路径都显式给出 Peer。判定看方法解析出的资源是否遗漏其访问的资源、聚合结果是否未过滤、principal 是否从 wire 数据读取或经辅助函数参数逐层传递、Remote 方法内构造的用户消息 source 是否未经 `principalOf`;设计的正家是 [Peer 身份属性 Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md)。 +- **不覆盖。** 不带该标注的方法仍是有进程内调用者的普通 service 方法;这五项纪律约束的是什么成为 Remote 方法,而不是 service 可以实现什么。 - **例外。** 确需改名或新增 decode 路径,须先说明收益与对既有调用方的影响,并取得对应领域专家的确认;作者不得自行批准。 生成的映射是面向 Client 类型的唯一来源,标注落在具体方法上,载荷保持 JSON: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a54a65bdb7..d727ee4ec8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,17 +22,17 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:624`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:631`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:629`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:636`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:615`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:622`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | -| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:67`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:68`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:383`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:389`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:395`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | @@ -80,7 +80,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index faebbe62ff..fccfeea698 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,17 +24,17 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:624`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:631`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:629`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:609`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:636`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:615`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:622`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | | `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) | -| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:67`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | +| `connection/request` | `waterfall` | [`packages/client/connection/src/index.ts:68`](../packages/client/connection/src/index.ts) | `connection` (`waterfall`) | - | | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:383`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:389`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | | `cordis/inspect-query` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:395`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `remotes` | @@ -82,7 +82,7 @@ | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 264bc333b0..b6df4b5e7b 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -111,6 +111,7 @@ flowchart TD pkg_api_account_controller["api-account-controller"] pkg_api_gateway["api-gateway"] pkg_api_job_controller["api-job-controller"] + pkg_api_remote_access["api-remote-access"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] @@ -484,7 +485,6 @@ flowchart TD pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session - pkg_api_gateway --> pkg_client_connection pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths @@ -1461,7 +1461,9 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`scope`](../packages/core/scope) | `core` | — | +| [`api-gateway`](../packages/api/gateway) | `api` | — | | [`api-job-controller`](../packages/api/job-controller) | `api` | — | +| [`api-remote-access`](../packages/api/remote-access) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1588,7 +1590,6 @@ flowchart TD | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | | [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 9306d32276..4c4e5e23d9 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -113,6 +113,7 @@ flowchart TD pkg_api_account_controller["api-account-controller"] pkg_api_gateway["api-gateway"] pkg_api_job_controller["api-job-controller"] + pkg_api_remote_access["api-remote-access"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] @@ -486,7 +487,6 @@ flowchart TD pkg_spill --> pkg_brand pkg_spill --> pkg_llm pkg_spill --> pkg_session - pkg_api_gateway --> pkg_client_connection pkg_api_terminal_controller --> pkg_subprocess pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths @@ -1463,7 +1463,9 @@ flowchart TD | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | | [`scope`](../packages/core/scope) | `core` | — | +| [`api-gateway`](../packages/api/gateway) | `api` | — | | [`api-job-controller`](../packages/api/job-controller) | `api` | — | +| [`api-remote-access`](../packages/api/remote-access) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1590,7 +1592,6 @@ flowchart TD | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection) | | [`api-terminal-controller`](../packages/api/terminal-controller) | `api` | [`subprocess`](../packages/subprocess/subprocess) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | | [`hmr`](../packages/boot/hmr) | `boot` | [`app-boot`](../packages/boot/app-boot), [`cmdline`](../packages/boot/cmdline) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ab62a37959..4f88b2f226 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -9,11 +9,11 @@ en: 1387c4505dde2faf zh: 48570fa1c13d6910 /session-persistence-event-catalog/persistence-type-fingerprints: - en: f6421cacbfe510db - zh: 6c24e981ac836d0c + en: a696c43a89d5fbe8 + zh: 6855eb0239b8dcb3 /session-persistence-event-catalog/event-envelope: - en: 7ebb1a2e0dd1f916 - zh: 8946f415dda5c1b8 + en: 689f781d95689e20 + zh: 602f6a7f0835d698 /session-persistence-event-catalog/events: en: f4776ba3d0fd53ce zh: e86769f50a69055a @@ -45,11 +45,11 @@ en: 44426b6cd0dc01cf zh: 44426b6cd0dc01cf /session-persistence-event-catalog/events/assistant/assistant-attempt-log-only: - en: 6baf474f8c4a905e - zh: b92f1c041441ba98 + en: 5b002a8dcefbfc71 + zh: e44f53ed4bf1f929 /session-persistence-event-catalog/events/assistant/assistant-message-surface: - en: 70721b64d98f892e - zh: ad5091727753c698 + en: a0f8289f97125e4f + zh: 1ff54770086eb016 /session-persistence-event-catalog/events/command: en: 2cecb5b0ee955732 zh: 2cecb5b0ee955732 @@ -84,8 +84,8 @@ en: 3344204b1858edb1 zh: 3344204b1858edb1 /session-persistence-event-catalog/events/developer/developer-message-surface: - en: d1ef5d25c01a725e - zh: eea5a11e0c4516ee + en: cd036bde54c62690 + zh: 5a21179b33551ed5 /session-persistence-event-catalog/events/feedback: en: f50dbac6867fa64d zh: f50dbac6867fa64d @@ -141,8 +141,8 @@ en: 9edf4ef31e60e880 zh: 9edf4ef31e60e880 /session-persistence-event-catalog/events/model/model-selection-log-only: - en: ea963e9ec025c667 - zh: f6eea77d22442cd8 + en: 0d9e20c2357ae7ac + zh: 23401d59e97d6011 /session-persistence-event-catalog/events/permission: en: 19917bc5438e60af zh: 19917bc5438e60af @@ -159,11 +159,11 @@ en: a8df82440ebdf5f8 zh: a8df82440ebdf5f8 /session-persistence-event-catalog/events/request/request-context-log-only: - en: e1a46fbd7273e666 - zh: e3390b27a540e353 + en: 799784e7cd019889 + zh: db6c59d673b4acff /session-persistence-event-catalog/events/request/request-header-log-only: - en: 897d315d9a0489d2 - zh: 98726636aa3c4ae1 + en: fb095eddad22b5f5 + zh: 49f1a3ac0a9ec57c /session-persistence-event-catalog/events/sandbox: en: ec841ab8439335b0 zh: ec841ab8439335b0 @@ -180,8 +180,8 @@ en: a1ac8f2660a4f61e zh: a1ac8f2660a4f61e /session-persistence-event-catalog/events/session/session-end-seed-log-only: - en: 53b95ea8a0fd3bd5 - zh: c5a0d2f068637efa + en: 70c3c0d6614f2d30 + zh: bb4dd52f5bcf351c /session-persistence-event-catalog/events/session/session-title-log-only: en: 6c6a27578dff5828 zh: 9a5add2bc0df2d0a @@ -198,11 +198,11 @@ en: bd0dcbbc352b771a zh: bd0dcbbc352b771a /session-persistence-event-catalog/events/step/step-end-log-only: - en: e24b5dd8b2b806aa - zh: a02bc5c3d7b07168 + en: 2de61d81867d485c + zh: 21025a396ef7c88f /session-persistence-event-catalog/events/step/step-start-log-only: - en: 71b35276d634c512 - zh: b1b7f063cffac1de + en: cb8f05bf3b59adef + zh: 686b33b689c4a841 /session-persistence-event-catalog/events/subagent: en: 9630752e97ddbfbc zh: 9630752e97ddbfbc @@ -219,8 +219,8 @@ en: 8cac41d2fb575975 zh: 8cac41d2fb575975 /session-persistence-event-catalog/events/system/system-message-surface: - en: 59abc66d6a56e861 - zh: cc2d67c879079151 + en: 92b1f61ac638da71 + zh: 2d60ba9d38abda05 /session-persistence-event-catalog/events/team: en: a640800a38b3c874 zh: a640800a38b3c874 @@ -246,8 +246,8 @@ en: 28a24430609494b2 zh: 28a24430609494b2 /session-persistence-event-catalog/events/tool/tool-call-log-only: - en: 96cc0c05d3a2d01b - zh: f6f86d5bd29db6a5 + en: 03f36237cbf9e267 + zh: 83fe2e69bbbfd3c1 /session-persistence-event-catalog/events/tool/tool-ptc-dispatch-log-only: en: 3d5d72b99e702a8c zh: e8c3b82cd5361c4a @@ -255,8 +255,8 @@ en: de38cf70d81ad7c3 zh: dd0a2a8be099271d /session-persistence-event-catalog/events/tool/tool-result-surface: - en: 2857bd718f6e7264 - zh: 61aac43cff6451b6 + en: 283e3cf1fdd57f05 + zh: 0bc3a15ae4f47aaf /session-persistence-event-catalog/events/tool-workflow: en: 1eac7deac6af15f3 zh: 1eac7deac6af15f3 @@ -276,17 +276,17 @@ en: d7e8c999a0851a0c zh: d7e8c999a0851a0c /session-persistence-event-catalog/events/turn/turn-end-log-only: - en: db0b56bc4d2959d1 - zh: 4587a42273da90d0 + en: ce5ef6916c95ea16 + zh: 9652c8d40b5dfb6d /session-persistence-event-catalog/events/turn/turn-start-log-only: - en: 09ef649bb3c92015 - zh: 69244b81c8c1f614 + en: 999ffb7ad4e600aa + zh: a8720bf8578431ae /session-persistence-event-catalog/events/user: en: d864a88c03e8971c zh: d864a88c03e8971c /session-persistence-event-catalog/events/user/user-message-surface: - en: 6d302c03787ebb1f - zh: d53f597a8b651309 + en: 55128b5458eae10f + zh: 4198ea89093484f6 /session-persistence-event-catalog/events/web: en: d278748103cf1640 zh: d278748103cf1640 @@ -621,8 +621,11 @@ en: afd1613dd3d4da80 zh: afd1613dd3d4da80 /session-persistence-event-catalog/resolved-persistence-types/other: - en: 588a8f46adda6707 - zh: 588a8f46adda6707 + en: d4ae27f96b0df108 + zh: d4ae27f96b0df108 +/session-persistence-event-catalog/resolved-persistence-types/owner: + en: 5ae91798cfeb7895 + zh: 5ae91798cfeb7895 /session-persistence-event-catalog/resolved-persistence-types/parent: en: 83af3e4fc9243790 zh: 83af3e4fc9243790 @@ -975,8 +978,8 @@ en: e393deed01de3665 zh: 0fa98ad93c22ce79 /session-persistence-event-catalog/resolved-persistence-types/commandsource: - en: 86c7e2b4ae099661 - zh: 2ffe1bd3f25b96fd + en: 53feeeb70547e7df + zh: 99d9e901d9b4afe5 /session-persistence-event-catalog/resolved-persistence-types/compactioncheckpointsource: en: 87f2aaf2b23a5249 zh: 3d6c5ed6603bee31 @@ -999,14 +1002,14 @@ en: 586743dfeab78c55 zh: 26ccef4a02dd7a58 /session-persistence-event-catalog/resolved-persistence-types/deepseeksearchllmrequest: - en: 8bb149f81f7c2544 - zh: 7e9c410f530b7c82 + en: db71ecccc1e5409e + zh: 7b55744e022dc102 /session-persistence-event-catalog/resolved-persistence-types/developermessage: - en: da88473da1299be0 - zh: fe756703e34fcc42 + en: 997b5db27df00446 + zh: 07e5197ddd7bd59b /session-persistence-event-catalog/resolved-persistence-types/epochheader: - en: d31020fa1fda6fa4 - zh: 77984c4bf09443fb + en: 3786becd1ca9c697 + zh: ea8e6fd4f6590567 /session-persistence-event-catalog/resolved-persistence-types/everyscheduledispatchchange: en: 54c487394ce0db1d zh: 48d7c9affbe841cb @@ -1113,11 +1116,11 @@ en: 6dbe099c62f7c226 zh: 6fd788f63c038c91 /session-persistence-event-catalog/resolved-persistence-types/llmretrystartedeventdata: - en: cc63fb8b8fad7d63 - zh: 2026e3da98cad54e + en: c2d9edd8d9f492b7 + zh: f59c687ac691f7ce /session-persistence-event-catalog/resolved-persistence-types/message: - en: e478c640a9c17a4f - zh: b4c6a4930a3789a1 + en: 594707e4894819a8 + zh: 268c4bfd5c4c28f2 /session-persistence-event-catalog/resolved-persistence-types/messagefeedbackdelete: en: c582953270d4ca44 zh: 966ccfe2e7660a2a @@ -1128,20 +1131,20 @@ en: f58575a1fc96df42 zh: 9f0c6ed9033e5c9d /session-persistence-event-catalog/resolved-persistence-types/messagefeedbackrating: - en: 46f4cbb41e7c053a - zh: 511e10d1be567bc5 + en: 674296d54c1914d0 + zh: 3d71ab319514be25 /session-persistence-event-catalog/resolved-persistence-types/messagesource: - en: c4ed18665fbc1e15 - zh: 6db2b051fc329408 + en: 3fab179fe8d58c31 + zh: 5328c9161ff908a9 /session-persistence-event-catalog/resolved-persistence-types/message~2: - en: 2c79aaf7cec7d9d6 - zh: 1403dd3cf9ed4a82 + en: 8bd12543fcdcda5f + zh: d4c6b17083948856 /session-persistence-event-catalog/resolved-persistence-types/modelmessagesource: en: 3b677f021d7dd4d3 zh: 2a875742b22879a2 /session-persistence-event-catalog/resolved-persistence-types/modelselection: - en: 771e9ab3e64076bc - zh: 30d5095a93549d55 + en: 6d66ffc1b393f2f2 + zh: ef6cb30b71f287e7 /session-persistence-event-catalog/resolved-persistence-types/oneshotscheduledispatchchange: en: a3300babe57b6b67 zh: 4aaf06a149b399c3 @@ -1149,14 +1152,17 @@ en: 9ba0cfd5ee9d6a38 zh: 1414fd7468c42651 /session-persistence-event-catalog/resolved-persistence-types/optionalsessionseq: - en: 7baa520fa984480e - zh: 6f809a83a720416d + en: 6d4afd6c237b50fd + zh: ec07f774c853f27b /session-persistence-event-catalog/resolved-persistence-types/presentedfile: en: 3e36e2f29ff1b506 zh: 3dcc1a1dced59d9f /session-persistence-event-catalog/resolved-persistence-types/presentedfile~2: - en: 807fde1cf42ddef3 - zh: 2ea131667b990d1e + en: bb11552744e477b8 + zh: afb881f3ebf97e68 +/session-persistence-event-catalog/resolved-persistence-types/principal: + en: d1e45e03f161dc7c + zh: 4f80af59ca4967c5 /session-persistence-event-catalog/resolved-persistence-types/ptcdispatcheventdata: en: 81e66c5a07e17dd6 zh: 0ecf3cfceba14d2b @@ -1170,11 +1176,11 @@ en: 3460f0c910a247fd zh: 3990587ab01a1907 /session-persistence-event-catalog/resolved-persistence-types/requestcontext: - en: cc8bb7075d546941 - zh: 77209abf30744be0 + en: b1ade5ed2c42b1ad + zh: f1a6126bec25657b /session-persistence-event-catalog/resolved-persistence-types/requestheaderreason: - en: 93c4403f94ecdc14 - zh: 2b2eb0c400881d71 + en: 0db3aa2b122a4454 + zh: 15b6cfd8896935b8 /session-persistence-event-catalog/resolved-persistence-types/sandboxmode: en: c9fa5343ea99733b zh: d6de41351c57a73f @@ -1188,17 +1194,17 @@ en: 35c0b15c285d045e zh: 8ccce6ea60dd5742 /session-persistence-event-catalog/resolved-persistence-types/sessionheader: - en: 0f60d736cd9d6c94 - zh: 205b8833a4efc5e5 + en: 0536dab0d2eed6ad + zh: 54396338c9917818 /session-persistence-event-catalog/resolved-persistence-types/sessionreferencesource: en: 9a54c4478ad3b620 zh: 02c4c1fd7e252770 /session-persistence-event-catalog/resolved-persistence-types/sessiontitleeventdata: - en: 4ff17eae0b30056d - zh: 6642afbaa87da3ab + en: bf7325029c9338d6 + zh: e5f98271511e5318 /session-persistence-event-catalog/resolved-persistence-types/sessiontitlellmrequesteventdata: - en: d7491d51e36cd1b6 - zh: 2fa7d1c33bace7fc + en: 89dcb9a9b957c126 + zh: 132aeb7dfd435290 /session-persistence-event-catalog/resolved-persistence-types/sessiontitlemodelidentity: en: 9531468264499498 zh: 9da23c0ad2776563 @@ -1227,8 +1233,8 @@ en: d34acab659e74957 zh: 25cabc6c9fb44a7e /session-persistence-event-catalog/resolved-persistence-types/surfaceop: - en: 4f3349f21317ba18 - zh: 508c26670085953a + en: 95f8ef2fb8a7c154 + zh: 04cdad161fb88cc3 /session-persistence-event-catalog/resolved-persistence-types/systemmessage: en: 94876079d6696df9 zh: d25c7ad9f5054eb7 @@ -1302,17 +1308,17 @@ en: 54090e169899c573 zh: 530ea8d0d0f8cf68 /session-persistence-event-catalog/resolved-persistence-types/turnendcancelcause: - en: 544292eddeff5faa - zh: 4e36e575a5030fb2 + en: 4dc89c072fe5d0d1 + zh: 4ed2c9e9e8b4f7e2 /session-persistence-event-catalog/resolved-persistence-types/turnendreason: - en: cdf0bbf6399a3677 - zh: f4422bf67b216bae + en: eec92deb07c000fa + zh: 666682b6f3efcfa3 /session-persistence-event-catalog/resolved-persistence-types/usermessage: - en: b1c19f6d8f6f31fc - zh: 3843640f47897934 + en: bd3aedd570ded4d2 + zh: f020d0816d3a6c1a /session-persistence-event-catalog/resolved-persistence-types/usermessage~2: - en: 2ee253b3743ee6a2 - zh: 5ee8b08850d93d8e + en: b99d66ffe277f0bc + zh: eacc9c1f62ae66c1 /session-persistence-event-catalog/resolved-persistence-types/workflowagentoutcome: en: d0a3f9aab18c3de5 zh: 76ac69a835ca4e29 @@ -1332,14 +1338,14 @@ en: bccefcfce00a211f zh: bccefcfce00a211f /session-persistence-event-catalog/resolved-persistence-types/number: - en: 5e7d82a493a1d4d8 - zh: d2d785891dd766fc + en: 0223d7ffb67ac272 + zh: c2a8221caab6d350 /session-persistence-event-catalog/resolved-persistence-types/number~2: en: 2786fef560851bb4 zh: 5d787475356b2606 /session-persistence-event-catalog/resolved-persistence-types/string: - en: 5fa180e9ba84fb2f - zh: ba6249510e691374 + en: 009c7ecb8c7d3bb5 + zh: 7f56d672f376b22f /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1401,8 +1407,8 @@ en: a117573a5d58cb3c zh: d5ad59e17a3a815d /session-persistence-event-catalog/resolved-persistence-types/arguments-callid-name-step: - en: bbb8322870e4695e - zh: dc90a029bf156604 + en: 99e67b046fa9cf40 + zh: 88060ee577d02d20 /session-persistence-event-catalog/resolved-persistence-types/callid-files-turn: en: 5f4d3f66ed86a14d zh: 7c729360fa1279e6 @@ -1434,8 +1440,8 @@ en: a1f188c9058cf621 zh: eb43c0f045fb9afb /session-persistence-event-catalog/resolved-persistence-types/code-name-reason: - en: 5b7790906008fcba - zh: 3d1a6bdb82d82fde + en: 404a76066a59f3c6 + zh: ec9d510bdbac9786 /session-persistence-event-catalog/resolved-persistence-types/commandid-kind-sourceeventseq-text: en: 87b03e1c73d2a326 zh: d7b9f06863cad891 @@ -1476,14 +1482,14 @@ en: 4b7a8793663d3fef zh: 249459c825d330c3 /session-persistence-event-catalog/resolved-persistence-types/endseq-op-startseq: - en: ba27e6f100b73e81 - zh: 82a12774bf5611f8 + en: 318404925fed44c8 + zh: 6cc8b0fcd8d11540 /session-persistence-event-catalog/resolved-persistence-types/error-kind-error: - en: b24398b25f46a94f - zh: 40640447d21ba296 + en: 72572b3d4f2f0c01 + zh: 4c4234325c1713fc /session-persistence-event-catalog/resolved-persistence-types/error-message-meta-step: - en: 423077da59c080a5 - zh: ea8a92a786603279 + en: 8cc3e4d05fa25012 + zh: 2c2d1c1936dd99ce /session-persistence-event-catalog/resolved-persistence-types/failure-kind-aborted: en: 349cd90f2598fd12 zh: 1eda02d520ad5d76 @@ -1491,11 +1497,11 @@ en: 51af619ed8532a43 zh: a5ad878c63d0e046 /session-persistence-event-catalog/resolved-persistence-types/header-reason-startsseries: - en: 562fcce1a6c17ade - zh: 15828c9bf4dcbe6e + en: fe0f6163c5202da6 + zh: 98feea5f03b725e1 /session-persistence-event-catalog/resolved-persistence-types/headerseq-message-step-turn: - en: 884dda8acdf28a83 - zh: 53ef06336629743c + en: 05903e55f510fd79 + zh: 90a17b10f682abcb /session-persistence-event-catalog/resolved-persistence-types/height-width: en: a5df50710083ac25 zh: aad3ccf6f6de70e4 @@ -1512,29 +1518,29 @@ en: 41f73c58a77dfa28 zh: af77f4aee3cb4a40 /session-persistence-event-catalog/resolved-persistence-types/inherited: - en: f46de4c8d413b567 - zh: af0283688492ac43 + en: 8a9199b1784c70f5 + zh: 76c98bc74686fda0 /session-persistence-event-catalog/resolved-persistence-types/inserted-outcome-removedcount-start: - en: 62e1cb96abf41fd5 - zh: c24c03766f6790d8 + en: e77d5d524dd8b4ea + zh: 4dac28dda78a510f /session-persistence-event-catalog/resolved-persistence-types/interrupted-message-step-stream: - en: f460572284c92755 - zh: bae7561ce887b040 + en: ec10cd8e9c202a81 + zh: e08f859bcb13f0d4 /session-persistence-event-catalog/resolved-persistence-types/kind-aborted-reason: - en: c7755cbd343d67ee - zh: e00a7477cd664cb5 + en: 48424cbed3fbc269 + zh: 2ce88c04ad002104 /session-persistence-event-catalog/resolved-persistence-types/kind-blocked: - en: 46465e6fa6292ec3 - zh: b8ed143600bf51d2 + en: 2fad818b19823c48 + zh: a6b33dee21c3fdc9 /session-persistence-event-catalog/resolved-persistence-types/kind-completed: - en: 2f3ffb879c568b64 - zh: ffc7d0f2b64494fc + en: e8f76b61a39ebeb0 + zh: e61a304be00b4490 /session-persistence-event-catalog/resolved-persistence-types/kind-cordis-host-runner: en: fb7b8207a6ef3b81 zh: 9d6f112181d90701 /session-persistence-event-catalog/resolved-persistence-types/kind-disposed: - en: 70d5bdb48ced0ea9 - zh: fc950fee2c166ff0 + en: e3c9527803bc6de3 + zh: 375c82ce2ac5282b /session-persistence-event-catalog/resolved-persistence-types/kind-dsh-session-title-llm: en: 428529e5dedc761d zh: 3ca0b54c9c521ea0 @@ -1560,11 +1566,11 @@ en: 78e34e6d0d70339e zh: 072127e40cfb9686 /session-persistence-event-catalog/resolved-persistence-types/kind-forked: - en: 4b299abd1cf27c78 - zh: dce75fa13b690720 + en: dc750041126fe853 + zh: 736505cd1e57db62 /session-persistence-event-catalog/resolved-persistence-types/kind-hook: - en: 965c99cedd28afdf - zh: 15d8f9991883bd5c + en: 78278a869370695f + zh: 6cdc47f1876f3b23 /session-persistence-event-catalog/resolved-persistence-types/kind-hooks-claude-code: en: 5eb383a67f11ac4d zh: 1943747cb4995dbc @@ -1608,14 +1614,14 @@ en: a8a338be3b91008f zh: c7632c42c869c2f6 /session-persistence-event-catalog/resolved-persistence-types/kind-interrupted: - en: 9e87f168b322c617 - zh: bf81e0aa69f46c08 + en: b2dea1e08facaa40 + zh: 71c9b8d4e3d1d2b8 /session-persistence-event-catalog/resolved-persistence-types/kind-legacy: - en: 7c000bffbf0c567e - zh: b3210306a125820b + en: c6f68741f8631d4f + zh: 090b58f2b86c02d0 /session-persistence-event-catalog/resolved-persistence-types/kind-max-tokens: - en: 20433ac79d2cf7a7 - zh: 8907d87c5179fca8 + en: 4746f91df74cc1dd + zh: d0427e9191359299 /session-persistence-event-catalog/resolved-persistence-types/kind-model-selection: en: a5165486dca91116 zh: b7051ca8167806ef @@ -1638,8 +1644,8 @@ en: 509f9a91aedb2da1 zh: db1071678bfb98d1 /session-persistence-event-catalog/resolved-persistence-types/kind-parent: - en: 96dec53b9a43815a - zh: 4163af6bc636ecc9 + en: 7a3ac2238405913d + zh: b249a4d6d0508d40 /session-persistence-event-catalog/resolved-persistence-types/kind-plan-mode: en: 9758304800b717df zh: dccf14f3a5d0d861 @@ -1821,11 +1827,11 @@ en: 08be4349548406a9 zh: 8e7cf9966198ef78 /session-persistence-event-catalog/resolved-persistence-types/kind-tool-registry: - en: 79db83dd2c430faf - zh: 173f568241f5ce86 + en: 809a23c25ded8497 + zh: e9cd23e281f8d31f /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: d23abbfb20eb4fc0 - zh: 65207e79db74e2fe + en: 9b6318ab4d7eba12 + zh: f475439ef40b2681 /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 @@ -1863,8 +1869,8 @@ en: 82b0989dad6bbfc0 zh: 0789b63f1d59b2ed /session-persistence-event-catalog/resolved-persistence-types/message-step-turn: - en: 7cc30865737643f2 - zh: 512c6c81ed10a9b6 + en: dcaa676f106cfdfd + zh: e3941fea97c943fb /session-persistence-event-catalog/resolved-persistence-types/message-teamid-version: en: f778769acb264dd5 zh: eba9b81d04e9f57b @@ -1881,8 +1887,8 @@ en: f60ecbd156c6f063 zh: c9149679bbacff21 /session-persistence-event-catalog/resolved-persistence-types/reason-turn: - en: 168c2ac7db7371b4 - zh: f851223c95582eb9 + en: a4ba4d0ee1396381 + zh: b6f595f8a696d512 /session-persistence-event-catalog/resolved-persistence-types/role-user: en: 04b34b8ffe7e7764 zh: a792d5371516fd9d @@ -1893,11 +1899,11 @@ en: 7a2ab7b62244f43b zh: 1084e3f8876feebc /session-persistence-event-catalog/resolved-persistence-types/step-stream-turn: - en: 9d436c515695f35d - zh: 247a7c0bb24337d3 + en: 84004f9b9c6bf24c + zh: ce1abe27b0bc43e2 /session-persistence-event-catalog/resolved-persistence-types/step-turn: - en: 6c9cd916657e052e - zh: ed60789608cee181 + en: 989fad01cdd866ba + zh: 4f08dafda68f0c77 /session-persistence-event-catalog/resolved-persistence-types/targets: en: 5360af614fe753e3 zh: 96427c489cc71de6 @@ -1908,14 +1914,14 @@ en: 06e6c1837e2b5d50 zh: 37cebfe3e41294ec /session-persistence-event-catalog/resolved-persistence-types/turn: - en: a79b2ab16b9ed215 - zh: 764cd1400ee8db63 + en: c58b89cec274f956 + zh: 056a29ed1ada9690 /session-persistence-event-catalog/resolved-persistence-types/type-agent-preset-selected: - en: 52112a8633ba49e4 - zh: 95318f8c8b2d19ee + en: c8c731ee23bed85a + zh: f4ffc80f7828701d /session-persistence-event-catalog/resolved-persistence-types/type-agent-inbox-spliced: - en: 35219c983f8168c9 - zh: 5bdf2bc555cc5e1a + en: 62dfe9447efaceb1 + zh: 076454de19b5d7d7 /session-persistence-event-catalog/resolved-persistence-types/type-approval-asked: en: 9c77f18b0dad22f0 zh: 8894cf2878fe90db @@ -1959,11 +1965,11 @@ en: ba73fb10b7e2ffc9 zh: 8d5c97e63e5f16da /session-persistence-event-catalog/resolved-persistence-types/type-deliverables-presented: - en: d69ad6b38df667d3 - zh: 72fb317a507a0b3b + en: a4d9eab2928e9b30 + zh: b36702c866518273 /session-persistence-event-catalog/resolved-persistence-types/type-developer-message: - en: 1d16681802c679ce - zh: 01c03831212dd260 + en: ed8c78af3a89ba8f + zh: 4f859687aa13c1a3 /session-persistence-event-catalog/resolved-persistence-types/type-feedback-message-delete: en: 204561e2311db97e zh: 77264396bd5e25c4 @@ -2034,11 +2040,11 @@ en: acf7e21e4d21ebff zh: fb83a853634d204a /session-persistence-event-catalog/resolved-persistence-types/type-session-title: - en: 203f5661ca1850b1 - zh: b3d9ec1019188df4 + en: b99fbacda0db1abd + zh: 9e75ce6b1d174d48 /session-persistence-event-catalog/resolved-persistence-types/type-session-title-llm-request: - en: 246920f06c3a2175 - zh: 56deee3b86499174 + en: 7ba30b98d8e3f653 + zh: ca68b01ab4b6ba3d /session-persistence-event-catalog/resolved-persistence-types/type-step-end: en: 7fc233c4a569dae2 zh: bc2b3fed4a397c00 @@ -2115,11 +2121,11 @@ en: 1b045de96172be4b zh: 8a15c6f35b576b37 /session-persistence-event-catalog/resolved-persistence-types/type-usage: - en: a5579e3f96903381 - zh: deac8bf4949cf17a + en: 1eabcbd128f86602 + zh: c6a2067a05f4e692 /session-persistence-event-catalog/resolved-persistence-types/type-user-message: - en: fb49c375aec5952c - zh: 19b80920c1068b86 + en: afab869c0592b46c + zh: f15874a1e728ab0d /session-persistence-event-catalog/resolved-persistence-types/type-web-deepseek-search-llm-request: en: f109997e295af37d zh: b2dcfd599f73b61c diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 32bdc47a0a..67f829b9d8 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -23,7 +23,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) | | `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) | | `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) | -| `event:agent/inbox/spliced` | event | `9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0) | +| `event:agent/inbox/spliced` | event | `0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034) | | `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) | | `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) | | `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) | @@ -36,7 +36,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) | | `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) | | `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) | -| `event:developer/message` | event | `ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06` | [`{ type: "developer/message" }`](#persistence-type-sha256-ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06) | +| `event:developer/message` | event | `97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c` | [`{ type: "developer/message" }`](#persistence-type-sha256-97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c) | | `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) | | `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) | | `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) | @@ -58,7 +58,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | -| `event:session/title-llm-request` | event | `b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6) | +| `event:session/title-llm-request` | event | `79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | | `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | @@ -80,7 +80,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) | | `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) | | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) | -| `event:user/message` | event | `2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade` | [`{ type: "user/message" }`](#persistence-type-sha256-2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade) | +| `event:user/message` | event | `2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8` | [`{ type: "user/message" }`](#persistence-type-sha256-2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8) | | `event:web/deepseek-search-llm-request` | event | `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331) | | `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) | | `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) | @@ -160,7 +160,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:431`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:439`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:462`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:493`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:432`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:440`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:463`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:494`](../packages/core/session/src/types.ts) ## Events @@ -290,7 +290,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:40`](../packages/inter 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] } ``` -Source: [`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) @@ -320,7 +320,7 @@ Source: [`packages/core/session/src/types.ts:355`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) ### `command/*` @@ -508,7 +508,7 @@ Source: [`packages/deliverables/tool-present/src/types.ts:15`](../packages/deliv } ``` -Source: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) ### `feedback/*` @@ -700,7 +700,7 @@ Source: [`packages/llm/llm-retry/src/types.ts:11`](../packages/llm/llm-retry/src 'model/selection': ModelSelection ``` -Source: [`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts) +Source: [`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -753,7 +753,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:402`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:403`](../packages/core/session/src/types.ts) @@ -772,7 +772,7 @@ Source: [`packages/core/session/src/types.ts:402`](../packages/core/session/src/ } ``` -Source: [`packages/core/session/src/types.ts:390`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -849,7 +849,7 @@ Source: [`packages/schedule/schedule/src/types.ts:365`](../packages/schedule/sch 'session/end-seed': { inherited?: true } ``` -Source: [`packages/core/session/src/types.ts:427`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:428`](../packages/core/session/src/types.ts) @@ -911,7 +911,7 @@ Source: [`packages/session/session-log-deepseek/src/types.ts:81`](../packages/se 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) @@ -922,7 +922,7 @@ Source: [`packages/core/session/src/types.ts:301`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -998,7 +998,7 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:17`](../p 'system/message': { turn: number; step: number; message: SystemMessage } ``` -Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) ### `team/*` @@ -1091,7 +1091,7 @@ Source: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/s Types: [ToolCallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) @@ -1172,7 +1172,7 @@ Source: [`packages/core/tools/src/types.ts:45`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1252,7 +1252,7 @@ Source: [`packages/workflow/tool-workflow/src/types.ts:47`](../packages/workflow Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) @@ -1268,7 +1268,7 @@ Source: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) ### `user/*` @@ -1287,7 +1287,7 @@ Source: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:309`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) ### `web/*` @@ -2185,6 +2185,14 @@ SHA-256: `9d80129e13edfb6feb03ed327e6085c6ef60b97ad074244b2f5fd4667424a6a4` `"other"` + + +### `"owner"` + +SHA-256: `95a3ffccbf3c8afcf9a2f3b355f724da8e07e98dc47ee741166dde7f9ea058bd` + +`"owner"` + ### `"parent"` @@ -3206,7 +3214,7 @@ Array of [`AssistantStreamRecord`](#persistence-type-sha256-33341aac09312300de5a SHA-256: `ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05` -Sources: [`packages/core/session/src/types.ts:190`](../packages/core/session/src/types.ts) · [`packages/interaction/commands/src/types.ts:75`](../packages/interaction/commands/src/types.ts) · [`packages/interaction/commands/src/types.ts:79`](../packages/interaction/commands/src/types.ts) · [`packages/llm/llm/src/message.ts:111`](../packages/llm/llm/src/message.ts) · [`packages/session/session-title/src/types.ts:35`](../packages/session/session-title/src/types.ts) +Sources: [`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts) · [`packages/interaction/commands/src/types.ts:75`](../packages/interaction/commands/src/types.ts) · [`packages/interaction/commands/src/types.ts:79`](../packages/interaction/commands/src/types.ts) · [`packages/llm/llm/src/message.ts:111`](../packages/llm/llm/src/message.ts) · [`packages/session/session-title/src/types.ts:35`](../packages/session/session-title/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -3349,7 +3357,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web | `body` | required | [`{ max_tokens, messages, model, tools }`](#persistence-type-sha256-930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731) | | `endpoint` | required | `string` | - + @@ -3357,7 +3365,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:55`](../packages/web ### `DeveloperMessage` -SHA-256: `af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08` +SHA-256: `0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7` Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts) @@ -3366,7 +3374,7 @@ Sources: [`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message | `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | required | `string` | | `role` | required | `"developer"` | -| `source` | required | [`MessageSource`](#persistence-type-sha256-1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154) | +| `source` | required | [`MessageSource`](#persistence-type-sha256-5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8) | Source compatibility: `source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`. @@ -3382,7 +3390,7 @@ Attribution-only additions: `tmux-context`, `user-question-reply`, `working-dire SHA-256: `9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7` -Sources: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4065,7 +4073,7 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr | `step` | required | `number` | | `turn` | required | `number` | - + @@ -4073,17 +4081,17 @@ Sources: [`packages/llm/llm-retry/src/types.ts:43`](../packages/llm/llm-retry/sr ### `Message` -SHA-256: `d901c4f5e856591c3217af983caa15bba568d580700f0958bab56b88004eced9` +SHA-256: `040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b` Sources: [`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts) One of: - [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731) -- [`DeveloperMessage`](#persistence-type-sha256-af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08) -- [`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97) -- [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) +- [`DeveloperMessage`](#persistence-type-sha256-0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7) +- [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) - [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) +- [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) @@ -4158,7 +4166,7 @@ One of: - `"negative"` - `"positive"` - + @@ -4166,7 +4174,7 @@ One of: ### `MessageSource` -SHA-256: `1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154` +SHA-256: `5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8` Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts) @@ -4271,7 +4279,7 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message | `tool-jobs` | `"snapshot"` | `sections`: [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | [`{ kind: "tool-jobs", form: "snapshot" }`](#persistence-type-sha256-c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49) | | `tool-jobs` | not declared | none | [`{ kind: "tool-jobs" }`](#persistence-type-sha256-24ce31aa528272e59f21cd26da9eca08968bcc97529756a749bfdab71746d9c8) | | `tool-registry` | not declared | none | [`{ kind: "tool-registry" }`](#persistence-type-sha256-8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543) | -| `user` | not declared | `rpcId`: `string` | [`{ kind: "user" }`](#persistence-type-sha256-6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7) | +| `user` | not declared | `rpcId`: `string` | [`{ kind: "user" }`](#persistence-type-sha256-6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def) | | `user` | not declared | none | [`CommandSource`](#persistence-type-sha256-ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05) | | `user-approval` | `"catalog"` | none | [`{ kind: "user-approval", form: "catalog" }`](#persistence-type-sha256-825ca23416b1ea9d81442afead5cf9229a6fa05842f042135cc3aa9710a6a2d8) | | `user-approval` | `"instructions"` | none | [`{ kind: "user-approval", form: "instructions" }`](#persistence-type-sha256-aa2bf9e82b9fdeb94a42af20031fffa30e4e23f01f4ac39757427d4c64c407f0) | @@ -4284,13 +4292,13 @@ Sources: [`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message | `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) | | `working-directory` | not declared | none | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) | - + ### `Message[]` -SHA-256: `4be8fd28fefa5534675ea9aabd0a24efc2838b989bbc74da20a1efbd0daede8c` +SHA-256: `a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f` -Array of [`Message`](#persistence-type-sha256-d901c4f5e856591c3217af983caa15bba568d580700f0958bab56b88004eced9). +Array of [`Message`](#persistence-type-sha256-040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b). @@ -4321,7 +4329,7 @@ Sources: [`packages/llm/llm/src/message.ts:24`](../packages/llm/llm/src/message. SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -Sources: [`packages/api/session-controller/src/types.ts:100`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:101`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4376,7 +4384,7 @@ Sources: [`packages/subagent/subagent/src/descriptor.ts:61`](../packages/subagen SHA-256: `3bd652ebfa8726b3ce3937a4f1bd2759e02f86b3a08a20f9e41a8513656fbc5f` -Sources: [`packages/core/session/src/types.ts:65`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:66`](../packages/core/session/src/types.ts) One of: @@ -4408,6 +4416,22 @@ SHA-256: `1d9cb3caa96100b18b1911fa3ff8c751ef6992971a03c5f4d9aefc1a0004a345` Array of [`PresentedFile`](#persistence-type-sha256-b8fc636a2121df9d8423c242e03c9d23b7ab7c6fdce00e45746932c60b730f97). + + + + + + +### `Principal` + +SHA-256: `deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5` + +Sources: [`packages/typert/protocol/src/types.ts:457`](../packages/typert/protocol/src/types.ts) · [`packages/typert/protocol/src/types.ts:461`](../packages/typert/protocol/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `kind` | required | `"owner"` | + @@ -4496,7 +4520,7 @@ Sources: [`packages/llm/llm/src/types.ts:446`](../packages/llm/llm/src/types.ts) SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081` -Sources: [`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4515,7 +4539,7 @@ Sources: [`packages/core/session/src/types.ts:254`](../packages/core/session/src SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6` -Sources: [`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) One of: @@ -4607,7 +4631,7 @@ Sources: [`packages/schedule/schedule/src/types.ts:230`](../packages/schedule/sc SHA-256: `1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445` -Sources: [`packages/core/session/src/types.ts:94`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:95`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4658,7 +4682,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ | `source` | required | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) | | `title` | required | `string` | - + @@ -4666,7 +4690,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/ ### `SessionTitleLlmRequestEventData` -SHA-256: `045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19` +SHA-256: `00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f` Sources: [`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts) @@ -4674,7 +4698,7 @@ Sources: [`packages/session/session-title-llm/src/index.ts:37`](../packages/sess |---|---|---| | `maxTokens` | required | `number` | | `messageSeqs` | required | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | -| `messages` | required | [`Message[]`](#persistence-type-sha256-4be8fd28fefa5534675ea9aabd0a24efc2838b989bbc74da20a1efbd0daede8c) | +| `messages` | required | [`Message[]`](#persistence-type-sha256-a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f) | | `reasoningEffort` | optional | `string` | | `route` | required | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) | | `system` | required | `string` | @@ -4853,7 +4877,7 @@ Sources: [`packages/subagent/subagent/src/continuation-messages.ts:29`](../packa SHA-256: `335e242de1fcc17b6ca920fc420d71bec2d76e53e37955c00948b65ab77f05c5` -Sources: [`packages/core/session/src/types.ts:462`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:463`](../packages/core/session/src/types.ts) One of: @@ -5298,7 +5322,7 @@ Sources: [`packages/workflow/tool-workflow/src/types.ts:14`](../packages/workflo SHA-256: `1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0` -Sources: [`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) | kind | Form property | Other required fields | Full definition | |---|---|---|---| @@ -5318,7 +5342,7 @@ Sources: [`packages/core/session/src/types.ts:196`](../packages/core/session/src SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` -Sources: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) | kind | Form property | Other required fields | Full definition | |---|---|---|---| @@ -5330,7 +5354,7 @@ Sources: [`packages/core/session/src/types.ts:232`](../packages/core/session/src | `interrupted` | not declared | none | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | not declared | none | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - + @@ -5338,7 +5362,7 @@ Sources: [`packages/core/session/src/types.ts:232`](../packages/core/session/src ### `UserMessage` -SHA-256: `89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97` +SHA-256: `2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711` Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts) @@ -5347,19 +5371,19 @@ Sources: [`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message | `content` | required | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | required | `string` | | `role` | required | `"user"` | -| `source` | required | [`MessageSource`](#persistence-type-sha256-1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154) | +| `source` | required | [`MessageSource`](#persistence-type-sha256-5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8) | Source compatibility: `source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`. Attribution-only additions: `tmux-context`, `user-question-reply`, `working-directory`. - + ### `UserMessage[]` -SHA-256: `2fef90def36aa758ca096c5f6ad0e00dbb2101943e19ca54b05618801974894a` +SHA-256: `454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020` -Array of [`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97). +Array of [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711). @@ -5439,7 +5463,7 @@ SHA-256: `938f7c7357bc403ea3b281a9dd016939df7a0880fc8db12612585be574d0d615` SHA-256: `0657d3ca5ac4e7bf79b00bcf860b1c7159d01a9da137497221ec2f1776e365f7` -Sources: [`packages/core/session/src/types.ts:32`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:33`](../packages/core/session/src/types.ts) `number` @@ -5551,7 +5575,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:409`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5788,7 +5812,7 @@ Sources: [`packages/interaction/commands/src/types.ts:105`](../packages/interact SHA-256: `e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1` -Sources: [`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5947,7 +5971,7 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735` -Sources: [`packages/core/session/src/types.ts:383`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:27`](../packages/core/tools/src/types.ts) +Sources: [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:27`](../packages/core/tools/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6167,7 +6191,7 @@ Sources: [`packages/compaction/compaction/src/types.ts:38`](../packages/compacti SHA-256: `bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825` -Sources: [`packages/core/session/src/types.ts:464`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:465`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6181,7 +6205,7 @@ Sources: [`packages/core/session/src/types.ts:464`](../packages/core/session/src SHA-256: `79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985` -Sources: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6194,7 +6218,7 @@ Sources: [`packages/core/session/src/types.ts:212`](../packages/core/session/src SHA-256: `5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777` -Sources: [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6236,7 +6260,7 @@ Sources: [`packages/llm/llm/src/types.ts:161`](../packages/llm/llm/src/types.ts) SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` -Sources: [`packages/core/session/src/types.ts:390`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6244,18 +6268,18 @@ Sources: [`packages/core/session/src/types.ts:390`](../packages/core/session/src | `reason` | required | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) | | `startsSeries` | optional | `true` | - + ### `{ headerSeq?, message, step, turn }` -SHA-256: `5b55817a403569fbb2d379496df2e3f3f71107f6c577a218028ce187c60b61e6` +SHA-256: `f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb` -Sources: [`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| | `headerSeq` | optional | `number` | -| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08) | +| `message` | required | [`DeveloperMessage`](#persistence-type-sha256-0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7) | | `step` | required | `number` | | `turn` | required | `number` | @@ -6333,23 +6357,23 @@ SHA-256: `e3e77b26f0148755a505f5b8ea843a3be117827d4703bb5754d9dc8ddbef521f` SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` -Sources: [`packages/core/session/src/types.ts:427`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:428`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| | `inherited` | optional | `true` | - + ### `{ inserted, outcome?, removedCount?, start, … }` -SHA-256: `83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428` +SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42` Sources: [`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts) | Property | Presence | Type | |---|---|---| -| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-2fef90def36aa758ca096c5f6ad0e00dbb2101943e19ca54b05618801974894a) | +| `inserted` | required | [`UserMessage[]`](#persistence-type-sha256-454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020) | | `outcome` | optional | `"canceled"` | | `removedCount` | optional | `number` | | `start` | required | `number` | @@ -6361,7 +6385,7 @@ Sources: [`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/type SHA-256: `1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d` -Sources: [`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6378,7 +6402,7 @@ Sources: [`packages/core/session/src/types.ts:341`](../packages/core/session/src SHA-256: `0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3` -Sources: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:205`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6391,7 +6415,7 @@ Sources: [`packages/core/session/src/types.ts:204`](../packages/core/session/src SHA-256: `023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4` -Sources: [`packages/core/session/src/types.ts:206`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:207`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6403,7 +6427,7 @@ Sources: [`packages/core/session/src/types.ts:206`](../packages/core/session/src SHA-256: `b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0` -Sources: [`packages/core/session/src/types.ts:202`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:203`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6427,7 +6451,7 @@ Sources: [`packages/extensions/cordis-host-runner/src/index.ts:14`](../packages/ SHA-256: `4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db` -Sources: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:194`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6529,7 +6553,7 @@ Sources: [`packages/session/session-title/src/types.ts:29`](../packages/session/ SHA-256: `ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7` -Sources: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:229`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6541,7 +6565,7 @@ Sources: [`packages/core/session/src/types.ts:228`](../packages/core/session/src SHA-256: `6c5a37d42ebdb12db4410699259335f1e149e3a0260425684c00b153afc8990d` -Sources: [`packages/core/session/src/types.ts:192`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6710,7 +6734,7 @@ SHA-256: `88841202942c7683b3b7e5ea6a796047ec326b6701ac23e58cc236d85613b9fa` SHA-256: `56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23` -Sources: [`packages/core/session/src/types.ts:221`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6722,7 +6746,7 @@ Sources: [`packages/core/session/src/types.ts:221`](../packages/core/session/src SHA-256: `eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c` -Sources: [`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6734,7 +6758,7 @@ Sources: [`packages/core/session/src/types.ts:196`](../packages/core/session/src SHA-256: `ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7` -Sources: [`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) +Sources: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6824,7 +6848,7 @@ SHA-256: `52c6113cb0e53f345324bf84f01928bea5d5df57ee80bc0dd65407b8bc5a3734` SHA-256: `6c03546143cdf6ae88f1745ac98c5139efa1588d1415249bbc5f3104d32ab800` -Sources: [`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:192`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7516,18 +7540,19 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde |---|---|---| | `kind` | required | `"tool-registry"` | - + ### `{ kind: "user" }` -SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` +SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -Sources: [`packages/api/session-controller/src/types.ts:413`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:418`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| | `clientTimeZone` | optional | `string` | | `kind` | required | `"user"` | +| `principal` | optional | [`Principal`](#persistence-type-sha256-deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5) | | `rpcId` | required | `string` | @@ -7687,7 +7712,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:230`](../packages/exper SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` -Sources: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7768,7 +7793,7 @@ Sources: [`packages/interaction/permission-presets/src/index.ts:59`](../packages SHA-256: `e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654` -Sources: [`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7822,7 +7847,7 @@ Sources: [`packages/compaction/compaction/src/types.ts:82`](../packages/compacti SHA-256: `54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0` -Sources: [`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7836,7 +7861,7 @@ Sources: [`packages/core/session/src/types.ts:355`](../packages/core/session/src SHA-256: `ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545` -Sources: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7887,7 +7912,7 @@ Sources: [`packages/todo/tool-todo/src/types.ts:31`](../packages/todo/tool-todo/ SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d` -Sources: [`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) +Sources: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7909,17 +7934,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | `time` | required | `number` | | `type` | required | `"agent-preset/selected"` | - + ### `{ type: "agent/inbox/spliced" }` -SHA-256: `9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0` +SHA-256: `0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034` | Property | Presence | Type | |---|---|---| -| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428) | +| `data` | required | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -8160,17 +8185,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | `time` | required | `number` | | `type` | required | `"deliverables/presented"` | - + ### `{ type: "developer/message" }` -SHA-256: `ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06` +SHA-256: `97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c` | Property | Presence | Type | |---|---|---| -| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-5b55817a403569fbb2d379496df2e3f3f71107f6c577a218028ce187c60b61e6) | +| `data` | required | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | @@ -8558,17 +8583,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | `time` | required | `number` | | `type` | required | `"session/title"` | - + ### `{ type: "session/title-llm-request" }` -SHA-256: `b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6` +SHA-256: `79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056` | Property | Presence | Type | |---|---|---| -| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19) | +| `data` | required | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | @@ -8991,17 +9016,17 @@ Sources: [`packages/llm/llm/src/types.ts:474`](../packages/llm/llm/src/types.ts) | `type` | required | `"usage"` | | `usage` | required | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) | - + ### `{ type: "user/message" }` -SHA-256: `2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade` +SHA-256: `2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8` | Property | Presence | Type | |---|---|---| -| `data` | required | [`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97) | +| `data` | required | [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `sourceEventSeqs` | optional | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 991e8743f7..c1eb47569f 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -25,7 +25,7 @@ | `JsonlHeaderLine` | header | `18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f` | [`HeaderLine`](#persistence-type-sha256-18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f) | | `SessionEventEnvelope` | envelope | `5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc` | [`union (3 variants)`](#persistence-type-sha256-5776e5553ff2dfe3f5bc202dbb1e7c9f93e35a531aebb7764c23b2b6153b2ccc) | | `event:agent-preset/selected` | event | `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | [`{ type: "agent-preset/selected" }`](#persistence-type-sha256-a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b) | -| `event:agent/inbox/spliced` | event | `9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0) | +| `event:agent/inbox/spliced` | event | `0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034` | [`{ type: "agent/inbox/spliced" }`](#persistence-type-sha256-0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034) | | `event:approval/asked` | event | `3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1` | [`{ type: "approval/asked" }`](#persistence-type-sha256-3bfeb47b58606f4661904bc723da612782214c463d01e6d61cd6d6193d7374e1) | | `event:approval/decided` | event | `bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3` | [`{ type: "approval/decided" }`](#persistence-type-sha256-bb1ab3d08f49a9f3b265f844cd78d5c49813062a7b34b54904b426f85d0ff6e3) | | `event:approval/policy` | event | `26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78` | [`{ type: "approval/policy" }`](#persistence-type-sha256-26718e15e7e395bce9642dba5bbe09b3b1a4ce2213d20d566cd9207d7fc5fb78) | @@ -38,7 +38,7 @@ | `event:compaction/start` | event | `db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965` | [`{ type: "compaction/start" }`](#persistence-type-sha256-db874d463b0fdec77e9da1c4568f37cb70bd6596781eb93800db44fb8a116965) | | `event:compaction/summary` | event | `e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602` | [`{ type: "compaction/summary" }`](#persistence-type-sha256-e2f9a41e0989f54ed8cee80f8db2bcf9d60a5c810dc9d45b83fa050b9dce7602) | | `event:deliverables/presented` | event | `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | [`{ type: "deliverables/presented" }`](#persistence-type-sha256-13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176) | -| `event:developer/message` | event | `ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06` | [`{ type: "developer/message" }`](#persistence-type-sha256-ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06) | +| `event:developer/message` | event | `97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c` | [`{ type: "developer/message" }`](#persistence-type-sha256-97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c) | | `event:feedback/message-delete` | event | `3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796` | [`{ type: "feedback/message-delete" }`](#persistence-type-sha256-3ee93b06f3a125850337602bcdf155d2538c43a5c944ec55b1b3c365152d6796) | | `event:feedback/message-put` | event | `b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf` | [`{ type: "feedback/message-put" }`](#persistence-type-sha256-b5086d249e8502e9ead1d39156bb8d559bde7951cac0f14ce150345b4e42a2bf) | | `event:feedback/record` | event | `b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8` | [`{ type: "feedback/record" }`](#persistence-type-sha256-b54940ff095c17e874c5be03815f4c2145a256cf3a1d34dae4ab2f7769dfffe8) | @@ -60,7 +60,7 @@ | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | -| `event:session/title-llm-request` | event | `b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6) | +| `event:session/title-llm-request` | event | `79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056` | [`{ type: "session/title-llm-request" }`](#persistence-type-sha256-79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056) | | `event:step/end` | event | `e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05` | [`{ type: "step/end" }`](#persistence-type-sha256-e0a787e6ec76c7c94fecbc501b489164ab0293db05bc947914077ad01e674f05) | | `event:step/start` | event | `4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986` | [`{ type: "step/start" }`](#persistence-type-sha256-4513e088d43e6c68425be30451b9f961cc264fe7318ca62681c41d4d78615986) | | `event:subagent/catalog` | event | `53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac` | [`{ type: "subagent/catalog" }`](#persistence-type-sha256-53750b9abf61efb7992b0aadc3e247ec927449c63266df28230fd1139f5590ac) | @@ -82,7 +82,7 @@ | `event:tool/result` | event | `7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397` | [`{ type: "tool/result" }`](#persistence-type-sha256-7c9f44e90a0058f4cc532ae20dad0c10afa6eba22e70a6c79fc79490bad64397) | | `event:turn/end` | event | `0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce` | [`{ type: "turn/end" }`](#persistence-type-sha256-0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce) | | `event:turn/start` | event | `aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8` | [`{ type: "turn/start" }`](#persistence-type-sha256-aa0957eca50aeb28bcd2e6930b95809926edacb550c8c340ba526ba6b861b3d8) | -| `event:user/message` | event | `2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade` | [`{ type: "user/message" }`](#persistence-type-sha256-2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade) | +| `event:user/message` | event | `2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8` | [`{ type: "user/message" }`](#persistence-type-sha256-2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8) | | `event:web/deepseek-search-llm-request` | event | `cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331` | [`{ type: "web/deepseek-search-llm-request" }`](#persistence-type-sha256-cf6e3aaf1e2de6480aa0157730a41b9a492108a55304100b0f7e112711dd4331) | | `event:working-directory/change` | event | `124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3` | [`{ type: "working-directory/change" }`](#persistence-type-sha256-124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3) | | `event:workspace/changes` | event | `e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72` | [`{ type: "workspace/changes" }`](#persistence-type-sha256-e308ccf867a5398e316e0af8cb6ce238a8d33a63b9b384c8250a686786285f72) | @@ -162,7 +162,7 @@ export type SessionEvent = { }[T] ``` -来源:[`packages/core/session/src/types.ts:431`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:439`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:462`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:493`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:432`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:440`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:463`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:494`](../packages/core/session/src/types.ts) ## 事件 @@ -292,7 +292,7 @@ export type SessionEvent = { 'assistant/attempt': { turn: number; step: number; stream: AssistantStreamRecord[] } ``` -来源:[`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) @@ -322,7 +322,7 @@ export type SessionEvent = { 类型:[TokenUsage](subsystems/llm-streaming.zh.md) -来源:[`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) ### `command/*` @@ -510,7 +510,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) ### `feedback/*` @@ -702,7 +702,7 @@ export type SessionEvent = { 'model/selection': ModelSelection ``` -来源:[`packages/api/session-controller/src/types.ts:40`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:41`](../packages/api/session-controller/src/types.ts) ### `permission/*` @@ -755,7 +755,7 @@ export type SessionEvent = { 'request/context': RequestContext ``` -来源:[`packages/core/session/src/types.ts:402`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:403`](../packages/core/session/src/types.ts) @@ -774,7 +774,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:390`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -851,7 +851,7 @@ export type SessionEvent = { 'session/end-seed': { inherited?: true } ``` -来源:[`packages/core/session/src/types.ts:427`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:428`](../packages/core/session/src/types.ts) @@ -913,7 +913,7 @@ export type SessionEvent = { 'step/end': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) @@ -924,7 +924,7 @@ export type SessionEvent = { 'step/start': { turn: number; step: number } ``` -来源:[`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -1000,7 +1000,7 @@ export type SessionEvent = { 'system/message': { turn: number; step: number; message: SystemMessage } ``` -来源:[`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) ### `team/*` @@ -1093,7 +1093,7 @@ export type SessionEvent = { 类型:[ToolCallId](subsystems/core.zh.md) -来源:[`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) @@ -1174,7 +1174,7 @@ export type SessionEvent = { } ``` -来源:[`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) ### `tool-workflow/*` @@ -1254,7 +1254,7 @@ export type SessionEvent = { 类型:[TurnEndReason](subsystems/session.zh.md) -来源:[`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) @@ -1270,7 +1270,7 @@ export type SessionEvent = { 'turn/start': { turn: number } ``` -来源:[`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) ### `user/*` @@ -1289,7 +1289,7 @@ export type SessionEvent = { 'user/message': UserMessage ``` -来源:[`packages/core/session/src/types.ts:309`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:310`](../packages/core/session/src/types.ts) ### `web/*` @@ -2187,6 +2187,14 @@ SHA-256: `9d80129e13edfb6feb03ed327e6085c6ef60b97ad074244b2f5fd4667424a6a4` `"other"` + + +### `"owner"` + +SHA-256: `95a3ffccbf3c8afcf9a2f3b355f724da8e07e98dc47ee741166dde7f9ea058bd` + +`"owner"` + ### `"parent"` @@ -3208,7 +3216,7 @@ SHA-256: `84bbfdce7d2eab5b0c53e72ff7406db856c8bfd621b965b72fd86598ba493bf7` SHA-256: `ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05` -来源:[`packages/core/session/src/types.ts:190`](../packages/core/session/src/types.ts) · [`packages/interaction/commands/src/types.ts:75`](../packages/interaction/commands/src/types.ts) · [`packages/interaction/commands/src/types.ts:79`](../packages/interaction/commands/src/types.ts) · [`packages/llm/llm/src/message.ts:111`](../packages/llm/llm/src/message.ts) · [`packages/session/session-title/src/types.ts:35`](../packages/session/session-title/src/types.ts) +来源:[`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts) · [`packages/interaction/commands/src/types.ts:75`](../packages/interaction/commands/src/types.ts) · [`packages/interaction/commands/src/types.ts:79`](../packages/interaction/commands/src/types.ts) · [`packages/llm/llm/src/message.ts:111`](../packages/llm/llm/src/message.ts) · [`packages/session/session-title/src/types.ts:35`](../packages/session/session-title/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -3351,7 +3359,7 @@ SHA-256: `2517ba143a271508d3ca35126d5aca8f7f1facf5aaddce4adf011832042fa2b9` | `body` | 必需 | [`{ max_tokens, messages, model, tools }`](#persistence-type-sha256-930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731) | | `endpoint` | 必需 | `string` | - + @@ -3359,7 +3367,7 @@ SHA-256: `2517ba143a271508d3ca35126d5aca8f7f1facf5aaddce4adf011832042fa2b9` ### `DeveloperMessage` -SHA-256: `af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08` +SHA-256: `0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7` 来源:[`packages/llm/llm/src/message.ts:157`](../packages/llm/llm/src/message.ts) @@ -3368,7 +3376,7 @@ SHA-256: `af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08` | `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | 必需 | `string` | | `role` | 必需 | `"developer"` | -| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154) | +| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8) | 消息源兼容策略:`source` — `session-source-attribution` v1; `session.developer-message.source`; `kind`; `preserve`. @@ -3384,7 +3392,7 @@ SHA-256: `af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08` SHA-256: `9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7` -来源:[`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:241`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4067,7 +4075,7 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20` | `step` | 必需 | `number` | | `turn` | 必需 | `number` | - + @@ -4075,17 +4083,17 @@ SHA-256: `6f72ece8f317cd080c4f20a93f1bf709965f5b81ac4669528f5a023191cfcf20` ### `Message` -SHA-256: `d901c4f5e856591c3217af983caa15bba568d580700f0958bab56b88004eced9` +SHA-256: `040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b` 来源:[`packages/llm/llm/src/message.ts:196`](../packages/llm/llm/src/message.ts) 以下类型之一: - [`ToolResultMessage`](#persistence-type-sha256-4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731) -- [`DeveloperMessage`](#persistence-type-sha256-af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08) -- [`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97) -- [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) +- [`DeveloperMessage`](#persistence-type-sha256-0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7) +- [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) - [`AssistantMessage`](#persistence-type-sha256-fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be) +- [`SystemMessage`](#persistence-type-sha256-09f3f8dc417f278257202714132f98ba7b52957d7b558c4a04456603890eb363) @@ -4160,7 +4168,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2` - `"negative"` - `"positive"` - + @@ -4168,7 +4176,7 @@ SHA-256: `390eed1028aba02774f14408647acec10ec5516e97d4f15ecde2b9989d5a69f2` ### `MessageSource` -SHA-256: `1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154` +SHA-256: `5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8` 来源:[`packages/llm/llm/src/message.ts:136`](../packages/llm/llm/src/message.ts) @@ -4273,7 +4281,7 @@ SHA-256: `1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154` | `tool-jobs` | `"snapshot"` | `sections`: [`ContextSnapshotSection[]`](#persistence-type-sha256-c748fb42c80a120038a8c587af80ec39924803c401364db11c8974d5bf6d0262) | [`{ kind: "tool-jobs", form: "snapshot" }`](#persistence-type-sha256-c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49) | | `tool-jobs` | 未声明 | 无 | [`{ kind: "tool-jobs" }`](#persistence-type-sha256-24ce31aa528272e59f21cd26da9eca08968bcc97529756a749bfdab71746d9c8) | | `tool-registry` | 未声明 | 无 | [`{ kind: "tool-registry" }`](#persistence-type-sha256-8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543) | -| `user` | 未声明 | `rpcId`: `string` | [`{ kind: "user" }`](#persistence-type-sha256-6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7) | +| `user` | 未声明 | `rpcId`: `string` | [`{ kind: "user" }`](#persistence-type-sha256-6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def) | | `user` | 未声明 | 无 | [`CommandSource`](#persistence-type-sha256-ada310bf0bdb8fed51f3b56ea63f6ea6b18bbd587f04fccb63a14ab0b2a24e05) | | `user-approval` | `"catalog"` | 无 | [`{ kind: "user-approval", form: "catalog" }`](#persistence-type-sha256-825ca23416b1ea9d81442afead5cf9229a6fa05842f042135cc3aa9710a6a2d8) | | `user-approval` | `"instructions"` | 无 | [`{ kind: "user-approval", form: "instructions" }`](#persistence-type-sha256-aa2bf9e82b9fdeb94a42af20031fffa30e4e23f01f4ac39757427d4c64c407f0) | @@ -4286,13 +4294,13 @@ SHA-256: `1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154` | `webhook` | `"notice"` | `deliveryId`: `string`; `provider`: `string`; `ruleId`: `string`; `source`: `string`; `summary`: `string` | [`{ kind: "webhook", form: "notice" }`](#persistence-type-sha256-9edc162949abf93bd5ff049191b345454ba104f583937ce5580fb50f067d2b94) | | `working-directory` | 未声明 | 无 | [`{ kind: "working-directory" }`](#persistence-type-sha256-2235d957bf4b7c6cf959231d480f0ba1f3a6d72d82981493fb6c7c963021d3d5) | - + ### `Message[]` -SHA-256: `4be8fd28fefa5534675ea9aabd0a24efc2838b989bbc74da20a1efbd0daede8c` +SHA-256: `a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f` -[`Message`](#persistence-type-sha256-d901c4f5e856591c3217af983caa15bba568d580700f0958bab56b88004eced9) 的数组。 +[`Message`](#persistence-type-sha256-040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b) 的数组。 @@ -4323,7 +4331,7 @@ SHA-256: `719c51f67074f97034a6c94297fe1f4b5d3c8d392c57586dab732aa5398c7a91` SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` -来源:[`packages/api/session-controller/src/types.ts:100`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:101`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4378,7 +4386,7 @@ SHA-256: `5f758f3de5f00c18968a7c6d347083aac5c9ae13f6a79c78f6611b23c4973564` SHA-256: `3bd652ebfa8726b3ce3937a4f1bd2759e02f86b3a08a20f9e41a8513656fbc5f` -来源:[`packages/core/session/src/types.ts:65`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:66`](../packages/core/session/src/types.ts) 以下类型之一: @@ -4410,6 +4418,22 @@ SHA-256: `1d9cb3caa96100b18b1911fa3ff8c751ef6992971a03c5f4d9aefc1a0004a345` [`PresentedFile`](#persistence-type-sha256-b8fc636a2121df9d8423c242e03c9d23b7ab7c6fdce00e45746932c60b730f97) 的数组。 + + + + + + +### `Principal` + +SHA-256: `deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5` + +来源:[`packages/typert/protocol/src/types.ts:457`](../packages/typert/protocol/src/types.ts) · [`packages/typert/protocol/src/types.ts:461`](../packages/typert/protocol/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `kind` | 必需 | `"owner"` | + @@ -4498,7 +4522,7 @@ SHA-256: `0acd94fe8794574c9ae72c7855ee16d29c546096d4af760d16d1225385764555` SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081` -来源:[`packages/core/session/src/types.ts:254`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4517,7 +4541,7 @@ SHA-256: `b158f3ff6923714807c0d73882f0b05dc87f8fa85c9e90814852d48bbc866081` SHA-256: `530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6` -来源:[`packages/core/session/src/types.ts:273`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) 以下类型之一: @@ -4609,7 +4633,7 @@ SHA-256: `18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0` SHA-256: `1a3440e3577382704d42a6263aa463504eb74c566734a55e9503a63efcd02445` -来源:[`packages/core/session/src/types.ts:94`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:95`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4660,7 +4684,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` | `source` | 必需 | [`SessionTitleSource`](#persistence-type-sha256-30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f) | | `title` | 必需 | `string` | - + @@ -4668,7 +4692,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4` ### `SessionTitleLlmRequestEventData` -SHA-256: `045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19` +SHA-256: `00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f` 来源:[`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts) @@ -4676,7 +4700,7 @@ SHA-256: `045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19` |---|---|---| | `maxTokens` | 必需 | `number` | | `messageSeqs` | 必需 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | -| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-4be8fd28fefa5534675ea9aabd0a24efc2838b989bbc74da20a1efbd0daede8c) | +| `messages` | 必需 | [`Message[]`](#persistence-type-sha256-a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f) | | `reasoningEffort` | 可选 | `string` | | `route` | 必需 | [`SessionTitleModelIdentity`](#persistence-type-sha256-07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235) | | `system` | 必需 | `string` | @@ -4855,7 +4879,7 @@ SHA-256: `4e97c3d85c0fc817ee58873c38c25a2474abaaa0af4839311b50b68db3b8cf1a` SHA-256: `335e242de1fcc17b6ca920fc420d71bec2d76e53e37955c00948b65ab77f05c5` -来源:[`packages/core/session/src/types.ts:462`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:463`](../packages/core/session/src/types.ts) 以下类型之一: @@ -5300,7 +5324,7 @@ SHA-256: `a6da9ad5d42ae83a6833a8288b076688e8e0801633ea36cbdd8a30a6d9f3160c` SHA-256: `1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0` -来源:[`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) | kind | form 属性 | 其他必需字段 | 完整定义 | |---|---|---|---| @@ -5320,7 +5344,7 @@ SHA-256: `1f8c126cfab3df812cd115cfc23e3256f0fa2383400f4ce91ecad313577e59b0` SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` -来源:[`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:233`](../packages/core/session/src/types.ts) | kind | form 属性 | 其他必需字段 | 完整定义 | |---|---|---|---| @@ -5332,7 +5356,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` | `interrupted` | 未声明 | 无 | [`{ kind: "interrupted" }`](#persistence-type-sha256-56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23) | | `max-tokens` | 未声明 | 无 | [`{ kind: "max-tokens" }`](#persistence-type-sha256-ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7) | - + @@ -5340,7 +5364,7 @@ SHA-256: `f1687f982fa3d41ef29e159feed154ecd13af4dd209acaee16038f223f2e9402` ### `UserMessage` -SHA-256: `89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97` +SHA-256: `2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711` 来源:[`packages/llm/llm/src/message.ts:162`](../packages/llm/llm/src/message.ts) @@ -5349,19 +5373,19 @@ SHA-256: `89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97` | `content` | 必需 | [`ContentBlock[]`](#persistence-type-sha256-b52f215a6cd691058a3413c12f161fa3b563a2f4895adb3df6f744ec4b0a6ef1) | | `id` | 必需 | `string` | | `role` | 必需 | `"user"` | -| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154) | +| `source` | 必需 | [`MessageSource`](#persistence-type-sha256-5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8) | 消息源兼容策略:`source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`. 仅表示归属的新增 kind:`tmux-context`, `user-question-reply`, `working-directory`. - + ### `UserMessage[]` -SHA-256: `2fef90def36aa758ca096c5f6ad0e00dbb2101943e19ca54b05618801974894a` +SHA-256: `454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020` -[`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97) 的数组。 +[`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) 的数组。 @@ -5441,7 +5465,7 @@ SHA-256: `938f7c7357bc403ea3b281a9dd016939df7a0880fc8db12612585be574d0d615` SHA-256: `0657d3ca5ac4e7bf79b00bcf860b1c7159d01a9da137497221ec2f1776e365f7` -来源:[`packages/core/session/src/types.ts:32`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:33`](../packages/core/session/src/types.ts) `number` @@ -5553,7 +5577,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:409`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -5790,7 +5814,7 @@ SHA-256: `a853902cfab417b8f08aa51ad6855fea46f34ed4588a74e47403035111d0fb86` SHA-256: `e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1` -来源:[`packages/core/session/src/types.ts:361`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:362`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5949,7 +5973,7 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735` -来源:[`packages/core/session/src/types.ts:383`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:27`](../packages/core/tools/src/types.ts) +来源:[`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:27`](../packages/core/tools/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6169,7 +6193,7 @@ SHA-256: `9e41386b3a0c9572b0d63078492ebb3997da7d3a830d44e0259418bd02f4bcb2` SHA-256: `bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825` -来源:[`packages/core/session/src/types.ts:464`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:465`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6183,7 +6207,7 @@ SHA-256: `bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825` SHA-256: `79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985` -来源:[`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6196,7 +6220,7 @@ SHA-256: `79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985` SHA-256: `5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777` -来源:[`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:376`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6238,7 +6262,7 @@ SHA-256: `693f4952fb5565da96c06bace8924b098ac2fbc915fde0ff8019701183abc057` SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` -来源:[`packages/core/session/src/types.ts:390`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:391`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6246,18 +6270,18 @@ SHA-256: `ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585` | `reason` | 必需 | [`RequestHeaderReason`](#persistence-type-sha256-530da6c0267813bb4511af72883cc518834e624e3b3220a7062955fd57a788a6) | | `startsSeries` | 可选 | `true` | - + ### `{ headerSeq?, message, step, turn }` -SHA-256: `5b55817a403569fbb2d379496df2e3f3f71107f6c577a218028ce187c60b61e6` +SHA-256: `f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb` -来源:[`packages/core/session/src/types.ts:311`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `headerSeq` | 可选 | `number` | -| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08) | +| `message` | 必需 | [`DeveloperMessage`](#persistence-type-sha256-0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7) | | `step` | 必需 | `number` | | `turn` | 必需 | `number` | @@ -6335,23 +6359,23 @@ SHA-256: `e3e77b26f0148755a505f5b8ea843a3be117827d4703bb5754d9dc8ddbef521f` SHA-256: `17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3` -来源:[`packages/core/session/src/types.ts:427`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:428`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `inherited` | 可选 | `true` | - + ### `{ inserted, outcome?, removedCount?, start, … }` -SHA-256: `83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428` +SHA-256: `fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42` 来源:[`packages/core/agent/src/types.ts:96`](../packages/core/agent/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| -| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-2fef90def36aa758ca096c5f6ad0e00dbb2101943e19ca54b05618801974894a) | +| `inserted` | 必需 | [`UserMessage[]`](#persistence-type-sha256-454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020) | | `outcome` | 可选 | `"canceled"` | | `removedCount` | 可选 | `number` | | `start` | 必需 | `number` | @@ -6363,7 +6387,7 @@ SHA-256: `83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428` SHA-256: `1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d` -来源:[`packages/core/session/src/types.ts:341`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:342`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6380,7 +6404,7 @@ SHA-256: `1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d` SHA-256: `0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3` -来源:[`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:205`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6393,7 +6417,7 @@ SHA-256: `0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3` SHA-256: `023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4` -来源:[`packages/core/session/src/types.ts:206`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:207`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6405,7 +6429,7 @@ SHA-256: `023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4` SHA-256: `b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0` -来源:[`packages/core/session/src/types.ts:202`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:203`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6429,7 +6453,7 @@ SHA-256: `02b2c23f2d94b028c9588a8faace3fce1217405db346da3389368b495457bf79` SHA-256: `4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db` -来源:[`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:194`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6531,7 +6555,7 @@ SHA-256: `1b5649238876e28811e80f10ec67a00c197ad3babd4ecc61a22b09064b88c597` SHA-256: `ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7` -来源:[`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:229`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6543,7 +6567,7 @@ SHA-256: `ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7` SHA-256: `6c5a37d42ebdb12db4410699259335f1e149e3a0260425684c00b153afc8990d` -来源:[`packages/core/session/src/types.ts:192`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6712,7 +6736,7 @@ SHA-256: `88841202942c7683b3b7e5ea6a796047ec326b6701ac23e58cc236d85613b9fa` SHA-256: `56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23` -来源:[`packages/core/session/src/types.ts:221`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6724,7 +6748,7 @@ SHA-256: `56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23` SHA-256: `eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c` -来源:[`packages/core/session/src/types.ts:196`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6736,7 +6760,7 @@ SHA-256: `eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c` SHA-256: `ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7` -来源:[`packages/core/session/src/types.ts:214`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) +来源:[`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts) · [`packages/llm/llm/src/types.ts:159`](../packages/llm/llm/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6826,7 +6850,7 @@ SHA-256: `52c6113cb0e53f345324bf84f01928bea5d5df57ee80bc0dd65407b8bc5a3734` SHA-256: `6c03546143cdf6ae88f1745ac98c5139efa1588d1415249bbc5f3104d32ab800` -来源:[`packages/core/session/src/types.ts:191`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:192`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7518,18 +7542,19 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` |---|---|---| | `kind` | 必需 | `"tool-registry"` | - + ### `{ kind: "user" }` -SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` +SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -来源:[`packages/api/session-controller/src/types.ts:413`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:418`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `clientTimeZone` | 可选 | `string` | | `kind` | 必需 | `"user"` | +| `principal` | 可选 | [`Principal`](#persistence-type-sha256-deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5) | | `rpcId` | 必需 | `string` | @@ -7689,7 +7714,7 @@ SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1` SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5` -来源:[`packages/core/session/src/types.ts:330`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7770,7 +7795,7 @@ SHA-256: `8a9c990773f798eac10ca8878e6bcc531ee080bc589cac956730e462cc6a09ad` SHA-256: `e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654` -来源:[`packages/core/session/src/types.ts:297`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:298`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7824,7 +7849,7 @@ SHA-256: `5dd766ccd1702a1142ad8b4bd44f2242f8e8a6752669ad3de0a5d224329b1438` SHA-256: `54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0` -来源:[`packages/core/session/src/types.ts:355`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:356`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7838,7 +7863,7 @@ SHA-256: `54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0` SHA-256: `ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545` -来源:[`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:301`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:300`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:302`](../packages/core/session/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7889,7 +7914,7 @@ SHA-256: `79ddc907312f97cf9553d4841e8b3fc937097bd8d97900a790efad3c05255e99` SHA-256: `f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d` -来源:[`packages/core/session/src/types.ts:288`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) +来源:[`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) · [`packages/deliverables/workspace-changes/src/types.ts:106`](../packages/deliverables/workspace-changes/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7911,17 +7936,17 @@ SHA-256: `a10c17474eaf2ddab7095a099e0fe3d046fc18e56c3e344fc8894c05ff9ef97b` | `time` | 必需 | `number` | | `type` | 必需 | `"agent-preset/selected"` | - + ### `{ type: "agent/inbox/spliced" }` -SHA-256: `9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0` +SHA-256: `0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428) | +| `data` | 必需 | [`{ inserted, outcome?, removedCount?, start, … }`](#persistence-type-sha256-fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -8162,17 +8187,17 @@ SHA-256: `13d3d180f977bf78081d487ffa0ecb75857349bcab29a5a3fb48189fca2a6176` | `time` | 必需 | `number` | | `type` | 必需 | `"deliverables/presented"` | - + ### `{ type: "developer/message" }` -SHA-256: `ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06` +SHA-256: `97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-5b55817a403569fbb2d379496df2e3f3f71107f6c577a218028ce187c60b61e6) | +| `data` | 必需 | [`{ headerSeq?, message, step, turn }`](#persistence-type-sha256-f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | @@ -8560,17 +8585,17 @@ SHA-256: `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | `time` | 必需 | `number` | | `type` | 必需 | `"session/title"` | - + ### `{ type: "session/title-llm-request" }` -SHA-256: `b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6` +SHA-256: `79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19) | +| `data` | 必需 | [`SessionTitleLlmRequestEventData`](#persistence-type-sha256-00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | @@ -8993,17 +9018,17 @@ SHA-256: `f1a96afe7503ac1817de769cd0ba13d9b80111c7034c50106b3344ecc14cc515` | `type` | 必需 | `"usage"` | | `usage` | 必需 | [`TokenUsage`](#persistence-type-sha256-fec5442b9ee4122e10514d81d5a43648003fe8bfb7ee2ba50c0cdbdd383ed394) | - + ### `{ type: "user/message" }` -SHA-256: `2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade` +SHA-256: `2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97) | +| `data` | 必需 | [`UserMessage`](#persistence-type-sha256-2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `sourceEventSeqs` | 可选 | [`number[]`](#persistence-type-sha256-5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78) | diff --git a/docs/persistence-changes/2026-10-13-user-rpc-principal.i18n.yaml b/docs/persistence-changes/2026-10-13-user-rpc-principal.i18n.yaml new file mode 100644 index 0000000000..13e1d9e422 --- /dev/null +++ b/docs/persistence-changes/2026-10-13-user-rpc-principal.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-10-13-user-rpc-principal.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 docs/persistence-changes/2026-10-13-user-rpc-principal.md +/: + en: 3d5a424ae7d7e46b + zh: c22a8e4a62d8cc1a +/2026-10-13-user-rpc-principal: + en: c8b8c9fe1f1fb295 + zh: 6775ad8687023eb9 +/2026-10-13-user-rpc-principal/summary: + en: 9fd17b8ef1f569d2 + zh: d1e9679296ce47a0 +/2026-10-13-user-rpc-principal/table-of-contents: + en: 6cf314a069ceb451 + zh: 9adff678e9ecab3b +/2026-10-13-user-rpc-principal/declaration: + en: 6907136f28e48c90 + zh: 02f8298485f3d8e7 +/2026-10-13-user-rpc-principal/compatibility: + en: ae44b935893f7eaa + zh: 686893a2a812570d +/2026-10-13-user-rpc-principal/verification: + en: 53e8380b9c0c63a9 + zh: 3adac787e267570d +/2026-10-13-user-rpc-principal/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-10-13-user-rpc-principal.md b/docs/persistence-changes/2026-10-13-user-rpc-principal.md new file mode 100644 index 0000000000..cccf5c6a32 --- /dev/null +++ b/docs/persistence-changes/2026-10-13-user-rpc-principal.md @@ -0,0 +1,60 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-10-13-user-rpc-principal + +English | [中文](2026-10-13-user-rpc-principal.zh.md) + +## Summary + +Adds an optional principal to the user-rpc message source for prompts submitted by a caller other than the owner. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-13-user-rpc-principal +baseline: false +changes: + - root: "event:agent/inbox/spliced" + previous: "2026-10-05-working-directory-attribution" + after: "0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034" + decision: same-version + - root: "event:developer/message" + previous: "2026-10-05-working-directory-attribution" + after: "97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c" + decision: same-version + - root: "event:session/title-llm-request" + previous: "2026-10-07-title-reasoning-effort" + after: "79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056" + decision: same-version + - root: "event:user/message" + previous: "2026-10-05-working-directory-attribution" + after: "2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8" + decision: same-version +``` + + +## Compatibility + +Owner prompts omit the field, so their sources and every existing Session log are unchanged; an absent principal means the owner. A reader that predates the field ignores it and treats the message as an ordinary user message, because the kind stays 'user'. A current reader of an older log finds no principal and treats every message as the owner's. The field is not part of any model request: provider adapters read only the model-visible message content. SESSION_FORMAT_VERSION does not change. + + +## Verification + +pnpm exec vitest run packages/api/session-controller/tests/remote-access.host.spec.ts packages/subagent/subagent/tests/control.spec.ts packages/api/session-controller/tests/session-cold.host.spec.ts: non-owner prompts persist source.principal in agent/inbox/spliced, owner and existing time-zone sources stay byte-identical; all tests passed. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-10-13-user-rpc-principal.schema.json b/docs/persistence-changes/2026-10-13-user-rpc-principal.schema.json new file mode 100644 index 0000000000..82d7ab993e --- /dev/null +++ b/docs/persistence-changes/2026-10-13-user-rpc-principal.schema.json @@ -0,0 +1,11559 @@ +{ + "formatVersion": 2, + "roots": [ + { + "key": "event:agent/inbox/spliced", + "kind": "event", + "event": "agent/inbox/spliced", + "surface": false, + "digest": "0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 27, + "optional": true + }, + { + "name": "seq", + "type": 20, + "optional": false + }, + { + "name": "time", + "type": 20, + "optional": false + }, + { + "name": "type", + "type": 206, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "inserted", + "type": 2, + "optional": false + }, + { + "name": "outcome", + "type": 202, + "optional": true + }, + { + "name": "removedCount", + "type": 20, + "optional": true + }, + { + "name": "start", + "type": 20, + "optional": false + }, + { + "name": "target", + "type": 203, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 3 + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 4, + "optional": false + }, + { + "name": "id", + "type": 7, + "optional": false + }, + { + "name": "role", + "type": 32, + "optional": false + }, + { + "name": "source", + "type": 33, + "optional": false, + "compatibility": { + "version": 1, + "policy": "session-source-attribution", + "binding": "session.user-message.source", + "discriminator": "kind", + "unknownKinds": "preserve", + "attributionKinds": [ + "tmux-context", + "user-question-reply", + "working-directory" + ] + } + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 5 + }, + { + "kind": "union", + "types": [ + 6, + 9, + 11, + 14, + 16, + 18, + 29 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "reasoning" + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "text" + }, + { + "kind": "object", + "properties": [ + { + "name": "tool", + "type": 12, + "optional": true + }, + { + "name": "toolName", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 13, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "never" + }, + { + "kind": "literal", + "value": "tool-addition" + }, + { + "kind": "object", + "properties": [ + { + "name": "toolName", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-removal" + }, + { + "kind": "object", + "properties": [ + { + "name": "arguments", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 7, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 17, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-call" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 19, + "optional": false + }, + { + "name": "offloaded", + "type": 27, + "optional": true + }, + { + "name": "type", + "type": 28, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 7, + "optional": false + }, + { + "name": "bytes", + "type": 20, + "optional": false + }, + { + "name": "height", + "type": 20, + "optional": false + }, + { + "name": "mediaType", + "type": 21, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": true + }, + { + "name": "originalDimensions", + "type": 26, + "optional": true + }, + { + "name": "width", + "type": 20, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "union", + "types": [ + 22, + 23, + 24, + 25 + ] + }, + { + "kind": "literal", + "value": "image/gif" + }, + { + "kind": "literal", + "value": "image/jpeg" + }, + { + "kind": "literal", + "value": "image/png" + }, + { + "kind": "literal", + "value": "image/webp" + }, + { + "kind": "object", + "properties": [ + { + "name": "height", + "type": 20, + "optional": false + }, + { + "name": "width", + "type": 20, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "image" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 30, + "optional": false + }, + { + "name": "type", + "type": 31, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 7, + "optional": false + }, + { + "name": "bytes", + "type": 20, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "file" + }, + { + "kind": "literal", + "value": "user" + }, + { + "kind": "union", + "types": [ + 34, + 39, + 41, + 43, + 45, + 48, + 51, + 53, + 55, + 57, + 59, + 61, + 63, + 65, + 67, + 69, + 70, + 71, + 72, + 73, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 116, + 117, + 118, + 119, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 128, + 130, + 132, + 135, + 136, + 137, + 138, + 139, + 141, + 142, + 143, + 144, + 146, + 147, + 148, + 149, + 150, + 152, + 153, + 154, + 156, + 158, + 159, + 167, + 170, + 172, + 175, + 177, + 179, + 183, + 185, + 193, + 194, + 195, + 196, + 197, + 198, + 199, + 200, + 201 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "snapshot" + }, + { + "kind": "literal", + "value": "tool-goal" + }, + { + "kind": "array", + "element": 38 + }, + { + "kind": "object", + "properties": [ + { + "name": "name", + "type": 7, + "optional": false + }, + { + "name": "text", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "dsh-session-title-llm" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 47, + "optional": false + }, + { + "name": "senderSessionId", + "type": 7, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "notice" + }, + { + "kind": "literal", + "value": "subagent-settled" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + }, + { + "name": "senderSessionId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "relay" + }, + { + "kind": "literal", + "value": "agent-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-claude-code" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-codex" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model-selection" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "plan-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "repeat-tool-reminder" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "runtime-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "schedule" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "time-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tmux-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "recall" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "goalId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 129, + "optional": false + }, + { + "name": "revision", + "type": 20, + "optional": false + }, + { + "name": "round", + "type": 20, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 131, + "optional": false + }, + { + "name": "messageId", + "type": 7, + "optional": false + }, + { + "name": "senderId", + "type": 7, + "optional": false + }, + { + "name": "senderName", + "type": 7, + "optional": false + }, + { + "name": "teamId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "team-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 133, + "optional": false + }, + { + "name": "model", + "type": 7, + "optional": false + }, + { + "name": "provider", + "type": 7, + "optional": false + }, + { + "name": "replayState", + "type": 134, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model" + }, + { + "kind": "opaque", + "reason": "unknown" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 58, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 140, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "ptc-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 60, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 62, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 145, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 151, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-registry" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 32, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 155, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "working-directory" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 157, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "cordis-host-runner" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "baseline", + "type": 27, + "optional": true + }, + { + "name": "baselineIdentity", + "type": 7, + "optional": true + }, + { + "name": "changes", + "type": 160, + "optional": false + }, + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 166, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 161 + }, + { + "kind": "object", + "properties": [ + { + "name": "action", + "type": 162, + "optional": false + }, + { + "name": "digest", + "type": 7, + "optional": true + }, + { + "name": "path", + "type": 7, + "optional": false + }, + { + "name": "scope", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 163, + 164, + 165 + ] + }, + { + "kind": "literal", + "value": "remove" + }, + { + "kind": "literal", + "value": "replace" + }, + { + "kind": "literal", + "value": "set" + }, + { + "kind": "literal", + "value": "agent-instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 168, + "optional": false + }, + { + "name": "outcome", + "type": 169, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-question-reply" + }, + { + "kind": "literal", + "value": "answered" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 171, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool" + }, + { + "kind": "object", + "properties": [ + { + "name": "clientTimeZone", + "type": 7, + "optional": true + }, + { + "name": "kind", + "type": 32, + "optional": false + }, + { + "name": "principal", + "type": 173, + "optional": true + }, + { + "name": "rpcId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 174, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, + { + "kind": "object", + "properties": [ + { + "name": "compactionId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 176, + "optional": false + }, + { + "name": "sourceCommandId", + "type": 7, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "compact-checkpoint" + }, + { + "kind": "object", + "properties": [ + { + "name": "deliveryId", + "type": 7, + "optional": false + }, + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 178, + "optional": false + }, + { + "name": "provider", + "type": 7, + "optional": false + }, + { + "name": "ruleId", + "type": 7, + "optional": false + }, + { + "name": "source", + "type": 7, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "webhook" + }, + { + "kind": "object", + "properties": [ + { + "name": "entries", + "type": 180, + "optional": false + }, + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 182, + "optional": false + }, + { + "name": "update", + "type": 27, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 181 + }, + { + "kind": "object", + "properties": [ + { + "name": "description", + "type": 7, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 184, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-invocation" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 186, + "optional": false + }, + { + "name": "references", + "type": 187, + "optional": false + }, + { + "name": "version", + "type": 192, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "session-reference" + }, + { + "kind": "array", + "element": 188 + }, + { + "kind": "object", + "properties": [ + { + "name": "capturedFormatVersion", + "type": 20, + "optional": true + }, + { + "name": "capturedThroughSeq", + "type": 189, + "optional": false + }, + { + "name": "compacted", + "type": 191, + "optional": false + }, + { + "name": "inputIndex", + "type": 20, + "optional": false + }, + { + "name": "label", + "type": 7, + "optional": false + }, + { + "name": "omittedBytes", + "type": 20, + "optional": false + }, + { + "name": "omittedMessages", + "type": 20, + "optional": false + }, + { + "name": "originalMessages", + "type": 20, + "optional": false + }, + { + "name": "retainedMessages", + "type": 20, + "optional": false + }, + { + "name": "sessionId", + "type": 7, + "optional": false + }, + { + "name": "truncated", + "type": 191, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 20, + 190 + ] + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 56, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 58, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 60, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 62, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 66, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 68, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "canceled" + }, + { + "kind": "union", + "types": [ + 204, + 205 + ] + }, + { + "kind": "literal", + "value": "next-step" + }, + { + "kind": "literal", + "value": "next-turn" + }, + { + "kind": "literal", + "value": "agent/inbox/spliced" + } + ] + } + }, + { + "key": "event:developer/message", + "kind": "event", + "event": "developer/message", + "surface": true, + "digest": "97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 26, + "optional": true + }, + { + "name": "seq", + "type": 2, + "optional": false + }, + { + "name": "sourceEventSeqs", + "type": 202, + "optional": true + }, + { + "name": "surfaceOp", + "type": 203, + "optional": false + }, + { + "name": "time", + "type": 2, + "optional": false + }, + { + "name": "type", + "type": 206, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "headerSeq", + "type": 2, + "optional": true + }, + { + "name": "message", + "type": 3, + "optional": false + }, + { + "name": "step", + "type": 2, + "optional": false + }, + { + "name": "turn", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 4, + "optional": false + }, + { + "name": "id", + "type": 7, + "optional": false + }, + { + "name": "role", + "type": 31, + "optional": false + }, + { + "name": "source", + "type": 32, + "optional": false, + "compatibility": { + "version": 1, + "policy": "session-source-attribution", + "binding": "session.developer-message.source", + "discriminator": "kind", + "unknownKinds": "preserve", + "attributionKinds": [ + "tmux-context", + "user-question-reply", + "working-directory" + ] + } + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 5 + }, + { + "kind": "union", + "types": [ + 6, + 9, + 11, + 14, + 16, + 18, + 28 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "reasoning" + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "text" + }, + { + "kind": "object", + "properties": [ + { + "name": "tool", + "type": 12, + "optional": true + }, + { + "name": "toolName", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 13, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "never" + }, + { + "kind": "literal", + "value": "tool-addition" + }, + { + "kind": "object", + "properties": [ + { + "name": "toolName", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-removal" + }, + { + "kind": "object", + "properties": [ + { + "name": "arguments", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 7, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + }, + { + "name": "type", + "type": 17, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-call" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 19, + "optional": false + }, + { + "name": "offloaded", + "type": 26, + "optional": true + }, + { + "name": "type", + "type": 27, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 7, + "optional": false + }, + { + "name": "bytes", + "type": 2, + "optional": false + }, + { + "name": "height", + "type": 2, + "optional": false + }, + { + "name": "mediaType", + "type": 20, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": true + }, + { + "name": "originalDimensions", + "type": 25, + "optional": true + }, + { + "name": "width", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 21, + 22, + 23, + 24 + ] + }, + { + "kind": "literal", + "value": "image/gif" + }, + { + "kind": "literal", + "value": "image/jpeg" + }, + { + "kind": "literal", + "value": "image/png" + }, + { + "kind": "literal", + "value": "image/webp" + }, + { + "kind": "object", + "properties": [ + { + "name": "height", + "type": 2, + "optional": false + }, + { + "name": "width", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "image" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 29, + "optional": false + }, + { + "name": "type", + "type": 30, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 7, + "optional": false + }, + { + "name": "bytes", + "type": 2, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "file" + }, + { + "kind": "literal", + "value": "developer" + }, + { + "kind": "union", + "types": [ + 33, + 38, + 40, + 42, + 44, + 47, + 50, + 52, + 54, + 56, + 58, + 60, + 62, + 64, + 66, + 68, + 69, + 70, + 71, + 72, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 115, + 116, + 117, + 118, + 119, + 120, + 121, + 122, + 123, + 124, + 125, + 126, + 127, + 129, + 131, + 134, + 135, + 136, + 137, + 138, + 139, + 141, + 142, + 143, + 144, + 146, + 147, + 148, + 149, + 150, + 152, + 154, + 155, + 157, + 159, + 167, + 170, + 172, + 175, + 177, + 179, + 183, + 185, + 193, + 194, + 195, + 196, + 197, + 198, + 199, + 200, + 201 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "snapshot" + }, + { + "kind": "literal", + "value": "tmux-context" + }, + { + "kind": "array", + "element": 37 + }, + { + "kind": "object", + "properties": [ + { + "name": "name", + "type": 7, + "optional": false + }, + { + "name": "text", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + }, + { + "name": "senderSessionId", + "type": 7, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "notice" + }, + { + "kind": "literal", + "value": "subagent-settled" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 49, + "optional": false + }, + { + "name": "senderSessionId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "relay" + }, + { + "kind": "literal", + "value": "agent-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "dsh-session-title-llm" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-claude-code" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-codex" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model-selection" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "plan-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "repeat-tool-reminder" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "runtime-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "schedule" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "time-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "recall" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "goalId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 128, + "optional": false + }, + { + "name": "revision", + "type": 2, + "optional": false + }, + { + "name": "round", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 130, + "optional": false + }, + { + "name": "messageId", + "type": 7, + "optional": false + }, + { + "name": "senderId", + "type": 7, + "optional": false + }, + { + "name": "senderName", + "type": 7, + "optional": false + }, + { + "name": "teamId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "team-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 132, + "optional": false + }, + { + "name": "model", + "type": 7, + "optional": false + }, + { + "name": "provider", + "type": 7, + "optional": false + }, + { + "name": "replayState", + "type": 133, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model" + }, + { + "kind": "opaque", + "reason": "unknown" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 51, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 53, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 140, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "ptc-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 63, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 65, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 145, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 67, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 151, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-registry" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 153, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 156, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "working-directory" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 158, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "cordis-host-runner" + }, + { + "kind": "object", + "properties": [ + { + "name": "baseline", + "type": 26, + "optional": true + }, + { + "name": "baselineIdentity", + "type": 7, + "optional": true + }, + { + "name": "changes", + "type": 160, + "optional": false + }, + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 166, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 161 + }, + { + "kind": "object", + "properties": [ + { + "name": "action", + "type": 162, + "optional": false + }, + { + "name": "digest", + "type": 7, + "optional": true + }, + { + "name": "path", + "type": 7, + "optional": false + }, + { + "name": "scope", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 163, + 164, + 165 + ] + }, + { + "kind": "literal", + "value": "remove" + }, + { + "kind": "literal", + "value": "replace" + }, + { + "kind": "literal", + "value": "set" + }, + { + "kind": "literal", + "value": "agent-instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 168, + "optional": false + }, + { + "name": "outcome", + "type": 169, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-question-reply" + }, + { + "kind": "literal", + "value": "answered" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 171, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool" + }, + { + "kind": "object", + "properties": [ + { + "name": "clientTimeZone", + "type": 7, + "optional": true + }, + { + "name": "kind", + "type": 153, + "optional": false + }, + { + "name": "principal", + "type": 173, + "optional": true + }, + { + "name": "rpcId", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 174, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, + { + "kind": "object", + "properties": [ + { + "name": "compactionId", + "type": 7, + "optional": false + }, + { + "name": "kind", + "type": 176, + "optional": false + }, + { + "name": "sourceCommandId", + "type": 7, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "compact-checkpoint" + }, + { + "kind": "object", + "properties": [ + { + "name": "deliveryId", + "type": 7, + "optional": false + }, + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 178, + "optional": false + }, + { + "name": "provider", + "type": 7, + "optional": false + }, + { + "name": "ruleId", + "type": 7, + "optional": false + }, + { + "name": "source", + "type": 7, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "webhook" + }, + { + "kind": "object", + "properties": [ + { + "name": "entries", + "type": 180, + "optional": false + }, + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 182, + "optional": false + }, + { + "name": "update", + "type": 26, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 181 + }, + { + "kind": "object", + "properties": [ + { + "name": "description", + "type": 7, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 184, + "optional": false + }, + { + "name": "name", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-invocation" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 186, + "optional": false + }, + { + "name": "references", + "type": 187, + "optional": false + }, + { + "name": "version", + "type": 192, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "session-reference" + }, + { + "kind": "array", + "element": 188 + }, + { + "kind": "object", + "properties": [ + { + "name": "capturedFormatVersion", + "type": 2, + "optional": true + }, + { + "name": "capturedThroughSeq", + "type": 189, + "optional": false + }, + { + "name": "compacted", + "type": 191, + "optional": false + }, + { + "name": "inputIndex", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 7, + "optional": false + }, + { + "name": "omittedBytes", + "type": 2, + "optional": false + }, + { + "name": "omittedMessages", + "type": 2, + "optional": false + }, + { + "name": "originalMessages", + "type": 2, + "optional": false + }, + { + "name": "retainedMessages", + "type": 2, + "optional": false + }, + { + "name": "sessionId", + "type": 7, + "optional": false + }, + { + "name": "truncated", + "type": 191, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 190 + ] + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 53, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 55, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 63, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 65, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 67, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 2 + }, + { + "kind": "union", + "types": [ + 204, + 205 + ] + }, + { + "kind": "literal", + "value": "append" + }, + { + "kind": "object", + "properties": [ + { + "name": "endSeq", + "type": 2, + "optional": false + }, + { + "name": "op", + "type": 164, + "optional": false + }, + { + "name": "startSeq", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "developer/message" + } + ] + } + }, + { + "key": "event:session/title-llm-request", + "kind": "event", + "event": "session/title-llm-request", + "surface": false, + "digest": "79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 29, + "optional": true + }, + { + "name": "seq", + "type": 2, + "optional": false + }, + { + "name": "time", + "type": 2, + "optional": false + }, + { + "name": "type", + "type": 212, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "maxTokens", + "type": 2, + "optional": false + }, + { + "name": "messageSeqs", + "type": 3, + "optional": false + }, + { + "name": "messages", + "type": 4, + "optional": false + }, + { + "name": "reasoningEffort", + "type": 10, + "optional": true + }, + { + "name": "route", + "type": 211, + "optional": false + }, + { + "name": "system", + "type": 10, + "optional": false + }, + { + "name": "titleProvider", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "array", + "element": 2 + }, + { + "kind": "array", + "element": 5 + }, + { + "kind": "union", + "types": [ + 6, + 37, + 206, + 207, + 209 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "isError", + "type": 34, + "optional": true + }, + { + "name": "role", + "type": 35, + "optional": false + }, + { + "name": "source", + "type": 36, + "optional": false + }, + { + "name": "toolCallId", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 8 + }, + { + "kind": "union", + "types": [ + 9, + 12, + 14, + 17, + 19, + 21, + 31 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 11, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "reasoning" + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 13, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "text" + }, + { + "kind": "object", + "properties": [ + { + "name": "tool", + "type": 15, + "optional": true + }, + { + "name": "toolName", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 16, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "never" + }, + { + "kind": "literal", + "value": "tool-addition" + }, + { + "kind": "object", + "properties": [ + { + "name": "toolName", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 18, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-removal" + }, + { + "kind": "object", + "properties": [ + { + "name": "arguments", + "type": 10, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "name", + "type": 10, + "optional": false + }, + { + "name": "type", + "type": 20, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-call" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 22, + "optional": false + }, + { + "name": "offloaded", + "type": 29, + "optional": true + }, + { + "name": "type", + "type": 30, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 10, + "optional": false + }, + { + "name": "bytes", + "type": 2, + "optional": false + }, + { + "name": "height", + "type": 2, + "optional": false + }, + { + "name": "mediaType", + "type": 23, + "optional": false + }, + { + "name": "name", + "type": 10, + "optional": true + }, + { + "name": "originalDimensions", + "type": 28, + "optional": true + }, + { + "name": "width", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 24, + 25, + 26, + 27 + ] + }, + { + "kind": "literal", + "value": "image/gif" + }, + { + "kind": "literal", + "value": "image/jpeg" + }, + { + "kind": "literal", + "value": "image/png" + }, + { + "kind": "literal", + "value": "image/webp" + }, + { + "kind": "object", + "properties": [ + { + "name": "height", + "type": 2, + "optional": false + }, + { + "name": "width", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "image" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 32, + "optional": false + }, + { + "name": "type", + "type": 33, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 10, + "optional": false + }, + { + "name": "bytes", + "type": 2, + "optional": false + }, + { + "name": "name", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "file" + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "literal", + "value": "tool" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 10, + "optional": false + }, + { + "name": "kind", + "type": 35, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "role", + "type": 38, + "optional": false + }, + { + "name": "source", + "type": 39, + "optional": false, + "compatibility": { + "version": 1, + "policy": "session-source-attribution", + "binding": "session.developer-message.source", + "discriminator": "kind", + "unknownKinds": "preserve", + "attributionKinds": [ + "tmux-context", + "user-question-reply", + "working-directory" + ] + } + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "developer" + }, + { + "kind": "union", + "types": [ + 36, + 40, + 45, + 47, + 49, + 51, + 53, + 55, + 57, + 58, + 60, + 62, + 63, + 65, + 67, + 69, + 71, + 73, + 75, + 77, + 78, + 79, + 80, + 81, + 84, + 87, + 89, + 98, + 101, + 104, + 106, + 108, + 110, + 115, + 117, + 119, + 127, + 128, + 129, + 130, + 131, + 132, + 133, + 134, + 135, + 136, + 137, + 138, + 139, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 149, + 150, + 151, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 159, + 160, + 161, + 162, + 163, + 164, + 165, + 166, + 167, + 168, + 169, + 170, + 171, + 172, + 173, + 174, + 175, + 176, + 177, + 178, + 179, + 180, + 181, + 182, + 183, + 184, + 185, + 186, + 187, + 188, + 189, + 190, + 191, + 192, + 193, + 194, + 195, + 196, + 198, + 200, + 203, + 204, + 205 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "snapshot" + }, + { + "kind": "literal", + "value": "time-context" + }, + { + "kind": "array", + "element": 44 + }, + { + "kind": "object", + "properties": [ + { + "name": "name", + "type": 10, + "optional": false + }, + { + "name": "text", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "plan-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "ptc-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "repeat-tool-reminder" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "runtime-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "schedule" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tmux-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 66, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-registry" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 70, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 72, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "working-directory" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 74, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "cordis-host-runner" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "dsh-session-title-llm" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 83, + "optional": false + }, + { + "name": "senderSessionId", + "type": 10, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "notice" + }, + { + "kind": "literal", + "value": "subagent-settled" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 86, + "optional": false + }, + { + "name": "senderSessionId", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "relay" + }, + { + "kind": "literal", + "value": "agent-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-claude-code" + }, + { + "kind": "object", + "properties": [ + { + "name": "baseline", + "type": 29, + "optional": true + }, + { + "name": "baselineIdentity", + "type": 10, + "optional": true + }, + { + "name": "changes", + "type": 90, + "optional": false + }, + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 97, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 91 + }, + { + "kind": "object", + "properties": [ + { + "name": "action", + "type": 92, + "optional": false + }, + { + "name": "digest", + "type": 10, + "optional": true + }, + { + "name": "path", + "type": 10, + "optional": false + }, + { + "name": "scope", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 93, + 94, + 95 + ] + }, + { + "kind": "literal", + "value": "remove" + }, + { + "kind": "literal", + "value": "replace" + }, + { + "kind": "literal", + "value": "set" + }, + { + "kind": "literal", + "value": "instructions" + }, + { + "kind": "literal", + "value": "agent-instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 10, + "optional": false + }, + { + "name": "kind", + "type": 99, + "optional": false + }, + { + "name": "outcome", + "type": 100, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-question-reply" + }, + { + "kind": "literal", + "value": "answered" + }, + { + "kind": "object", + "properties": [ + { + "name": "clientTimeZone", + "type": 10, + "optional": true + }, + { + "name": "kind", + "type": 68, + "optional": false + }, + { + "name": "principal", + "type": 102, + "optional": true + }, + { + "name": "rpcId", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 103, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, + { + "kind": "object", + "properties": [ + { + "name": "compactionId", + "type": 10, + "optional": false + }, + { + "name": "kind", + "type": 105, + "optional": false + }, + { + "name": "sourceCommandId", + "type": 10, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "compact-checkpoint" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-codex" + }, + { + "kind": "object", + "properties": [ + { + "name": "deliveryId", + "type": 10, + "optional": false + }, + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 109, + "optional": false + }, + { + "name": "provider", + "type": 10, + "optional": false + }, + { + "name": "ruleId", + "type": 10, + "optional": false + }, + { + "name": "source", + "type": 10, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "webhook" + }, + { + "kind": "object", + "properties": [ + { + "name": "entries", + "type": 111, + "optional": false + }, + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 114, + "optional": false + }, + { + "name": "update", + "type": 29, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 112 + }, + { + "kind": "object", + "properties": [ + { + "name": "description", + "type": 10, + "optional": false + }, + { + "name": "name", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "catalog" + }, + { + "kind": "literal", + "value": "skill-catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model-selection" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 118, + "optional": false + }, + { + "name": "name", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-invocation" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 121, + "optional": false + }, + { + "name": "references", + "type": 122, + "optional": false + }, + { + "name": "version", + "type": 126, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "recall" + }, + { + "kind": "literal", + "value": "session-reference" + }, + { + "kind": "array", + "element": 123 + }, + { + "kind": "object", + "properties": [ + { + "name": "capturedFormatVersion", + "type": 2, + "optional": true + }, + { + "name": "capturedThroughSeq", + "type": 124, + "optional": false + }, + { + "name": "compacted", + "type": 34, + "optional": false + }, + { + "name": "inputIndex", + "type": 2, + "optional": false + }, + { + "name": "label", + "type": 10, + "optional": false + }, + { + "name": "omittedBytes", + "type": 2, + "optional": false + }, + { + "name": "omittedMessages", + "type": 2, + "optional": false + }, + { + "name": "originalMessages", + "type": 2, + "optional": false + }, + { + "name": "retainedMessages", + "type": 2, + "optional": false + }, + { + "name": "sessionId", + "type": 10, + "optional": false + }, + { + "name": "truncated", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 125 + ] + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 107, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 116, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 64, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 70, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "goalId", + "type": 10, + "optional": false + }, + { + "name": "kind", + "type": 197, + "optional": false + }, + { + "name": "revision", + "type": 2, + "optional": false + }, + { + "name": "round", + "type": 2, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 199, + "optional": false + }, + { + "name": "messageId", + "type": 10, + "optional": false + }, + { + "name": "senderId", + "type": 10, + "optional": false + }, + { + "name": "senderName", + "type": 10, + "optional": false + }, + { + "name": "teamId", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "team-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 201, + "optional": false + }, + { + "name": "model", + "type": 10, + "optional": false + }, + { + "name": "provider", + "type": 10, + "optional": false + }, + { + "name": "replayState", + "type": 202, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model" + }, + { + "kind": "opaque", + "reason": "unknown" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 107, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 116, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "role", + "type": 68, + "optional": false + }, + { + "name": "source", + "type": 39, + "optional": false, + "compatibility": { + "version": 1, + "policy": "session-source-attribution", + "binding": "session.user-message.source", + "discriminator": "kind", + "unknownKinds": "preserve", + "attributionKinds": [ + "tmux-context", + "user-question-reply", + "working-directory" + ] + } + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "role", + "type": 208, + "optional": false + }, + { + "name": "source", + "type": 200, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "assistant" + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 7, + "optional": false + }, + { + "name": "id", + "type": 10, + "optional": false + }, + { + "name": "role", + "type": 210, + "optional": false + }, + { + "name": "source", + "type": 55, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system" + }, + { + "kind": "object", + "properties": [ + { + "name": "model", + "type": 10, + "optional": false + }, + { + "name": "provider", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "session/title-llm-request" + } + ] + } + }, + { + "key": "event:user/message", + "kind": "event", + "event": "user/message", + "surface": true, + "digest": "2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 25, + "optional": true + }, + { + "name": "seq", + "type": 18, + "optional": false + }, + { + "name": "sourceEventSeqs", + "type": 200, + "optional": true + }, + { + "name": "surfaceOp", + "type": 201, + "optional": false + }, + { + "name": "time", + "type": 18, + "optional": false + }, + { + "name": "type", + "type": 204, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "content", + "type": 2, + "optional": false + }, + { + "name": "id", + "type": 5, + "optional": false + }, + { + "name": "role", + "type": 30, + "optional": false + }, + { + "name": "source", + "type": 31, + "optional": false, + "compatibility": { + "version": 1, + "policy": "session-source-attribution", + "binding": "session.user-message.source", + "discriminator": "kind", + "unknownKinds": "preserve", + "attributionKinds": [ + "tmux-context", + "user-question-reply", + "working-directory" + ] + } + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 3 + }, + { + "kind": "union", + "types": [ + 4, + 7, + 9, + 12, + 14, + 16, + 27 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 6, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "reasoning" + }, + { + "kind": "object", + "properties": [ + { + "name": "text", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 8, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "text" + }, + { + "kind": "object", + "properties": [ + { + "name": "tool", + "type": 10, + "optional": true + }, + { + "name": "toolName", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 11, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "never" + }, + { + "kind": "literal", + "value": "tool-addition" + }, + { + "kind": "object", + "properties": [ + { + "name": "toolName", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 13, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-removal" + }, + { + "kind": "object", + "properties": [ + { + "name": "arguments", + "type": 5, + "optional": false + }, + { + "name": "id", + "type": 5, + "optional": false + }, + { + "name": "name", + "type": 5, + "optional": false + }, + { + "name": "type", + "type": 15, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-call" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 17, + "optional": false + }, + { + "name": "offloaded", + "type": 25, + "optional": true + }, + { + "name": "type", + "type": 26, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 5, + "optional": false + }, + { + "name": "bytes", + "type": 18, + "optional": false + }, + { + "name": "height", + "type": 18, + "optional": false + }, + { + "name": "mediaType", + "type": 19, + "optional": false + }, + { + "name": "name", + "type": 5, + "optional": true + }, + { + "name": "originalDimensions", + "type": 24, + "optional": true + }, + { + "name": "width", + "type": 18, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "union", + "types": [ + 20, + 21, + 22, + 23 + ] + }, + { + "kind": "literal", + "value": "image/gif" + }, + { + "kind": "literal", + "value": "image/jpeg" + }, + { + "kind": "literal", + "value": "image/png" + }, + { + "kind": "literal", + "value": "image/webp" + }, + { + "kind": "object", + "properties": [ + { + "name": "height", + "type": 18, + "optional": false + }, + { + "name": "width", + "type": 18, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "image" + }, + { + "kind": "object", + "properties": [ + { + "name": "attachment", + "type": 28, + "optional": false + }, + { + "name": "type", + "type": 29, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "attachmentId", + "type": 5, + "optional": false + }, + { + "name": "bytes", + "type": 18, + "optional": false + }, + { + "name": "name", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "file" + }, + { + "kind": "literal", + "value": "user" + }, + { + "kind": "union", + "types": [ + 32, + 35, + 37, + 39, + 41, + 43, + 45, + 47, + 49, + 51, + 53, + 55, + 58, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 115, + 117, + 120, + 121, + 122, + 123, + 124, + 126, + 127, + 128, + 129, + 131, + 132, + 133, + 134, + 135, + 137, + 138, + 139, + 141, + 143, + 144, + 152, + 155, + 157, + 160, + 162, + 164, + 168, + 170, + 178, + 182, + 183, + 184, + 185, + 186, + 187, + 188, + 189, + 190, + 191, + 192, + 193, + 194, + 196, + 198, + 199 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "notice" + }, + { + "kind": "literal", + "value": "model-selection" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "plan-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "repeat-tool-reminder" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "runtime-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "schedule" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "time-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tmux-context" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "dsh-session-title-llm" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "instructions" + }, + { + "kind": "literal", + "value": "hooks-claude-code" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "hooks-codex" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "recall" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "relay" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "goalId", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 114, + "optional": false + }, + { + "name": "revision", + "type": 18, + "optional": false + }, + { + "name": "round", + "type": 18, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 116, + "optional": false + }, + { + "name": "messageId", + "type": 5, + "optional": false + }, + { + "name": "senderId", + "type": 5, + "optional": false + }, + { + "name": "senderName", + "type": 5, + "optional": false + }, + { + "name": "teamId", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "team-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 118, + "optional": false + }, + { + "name": "model", + "type": 5, + "optional": false + }, + { + "name": "provider", + "type": 5, + "optional": false + }, + { + "name": "replayState", + "type": 119, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model" + }, + { + "kind": "opaque", + "reason": "unknown" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 57, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 59, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 34, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 125, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "ptc-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 38, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 130, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 46, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 48, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 136, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-registry" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 30, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 52, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 140, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "working-directory" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 142, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "cordis-host-runner" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "baseline", + "type": 25, + "optional": true + }, + { + "name": "baselineIdentity", + "type": 5, + "optional": true + }, + { + "name": "changes", + "type": 145, + "optional": false + }, + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 151, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 146 + }, + { + "kind": "object", + "properties": [ + { + "name": "action", + "type": 147, + "optional": false + }, + { + "name": "digest", + "type": 5, + "optional": true + }, + { + "name": "path", + "type": 5, + "optional": false + }, + { + "name": "scope", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 148, + 149, + 150 + ] + }, + { + "kind": "literal", + "value": "remove" + }, + { + "kind": "literal", + "value": "replace" + }, + { + "kind": "literal", + "value": "set" + }, + { + "kind": "literal", + "value": "agent-instructions" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 153, + "optional": false + }, + { + "name": "outcome", + "type": 154, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-question-reply" + }, + { + "kind": "literal", + "value": "answered" + }, + { + "kind": "object", + "properties": [ + { + "name": "callId", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 156, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool" + }, + { + "kind": "object", + "properties": [ + { + "name": "clientTimeZone", + "type": 5, + "optional": true + }, + { + "name": "kind", + "type": 30, + "optional": false + }, + { + "name": "principal", + "type": 158, + "optional": true + }, + { + "name": "rpcId", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 159, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, + { + "kind": "object", + "properties": [ + { + "name": "compactionId", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 161, + "optional": false + }, + { + "name": "sourceCommandId", + "type": 5, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "compact-checkpoint" + }, + { + "kind": "object", + "properties": [ + { + "name": "deliveryId", + "type": 5, + "optional": false + }, + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 163, + "optional": false + }, + { + "name": "provider", + "type": 5, + "optional": false + }, + { + "name": "ruleId", + "type": 5, + "optional": false + }, + { + "name": "source", + "type": 5, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "webhook" + }, + { + "kind": "object", + "properties": [ + { + "name": "entries", + "type": 165, + "optional": false + }, + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 167, + "optional": false + }, + { + "name": "update", + "type": 25, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 166 + }, + { + "kind": "object", + "properties": [ + { + "name": "description", + "type": 5, + "optional": false + }, + { + "name": "name", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-catalog" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 56, + "optional": false + }, + { + "name": "kind", + "type": 169, + "optional": false + }, + { + "name": "name", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "skill-invocation" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 171, + "optional": false + }, + { + "name": "references", + "type": 172, + "optional": false + }, + { + "name": "version", + "type": 177, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "session-reference" + }, + { + "kind": "array", + "element": 173 + }, + { + "kind": "object", + "properties": [ + { + "name": "capturedFormatVersion", + "type": 18, + "optional": true + }, + { + "name": "capturedThroughSeq", + "type": 174, + "optional": false + }, + { + "name": "compacted", + "type": 176, + "optional": false + }, + { + "name": "inputIndex", + "type": 18, + "optional": false + }, + { + "name": "label", + "type": 5, + "optional": false + }, + { + "name": "omittedBytes", + "type": 18, + "optional": false + }, + { + "name": "omittedMessages", + "type": 18, + "optional": false + }, + { + "name": "originalMessages", + "type": 18, + "optional": false + }, + { + "name": "retainedMessages", + "type": 18, + "optional": false + }, + { + "name": "sessionId", + "type": 5, + "optional": false + }, + { + "name": "truncated", + "type": 176, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 18, + 175 + ] + }, + { + "kind": "primitive", + "type": "null" + }, + { + "kind": "primitive", + "type": "boolean" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "snapshot" + }, + { + "kind": "array", + "element": 181 + }, + { + "kind": "object", + "properties": [ + { + "name": "name", + "type": 5, + "optional": false + }, + { + "name": "text", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 34, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 36, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 46, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 48, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 50, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "sections", + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 195, + "optional": false + }, + { + "name": "senderSessionId", + "type": 5, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "subagent-settled" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 197, + "optional": false + }, + { + "name": "senderSessionId", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "agent-message" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 57, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 59, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "array", + "element": 18 + }, + { + "kind": "union", + "types": [ + 202, + 203 + ] + }, + { + "kind": "literal", + "value": "append" + }, + { + "kind": "object", + "properties": [ + { + "name": "endSeq", + "type": 18, + "optional": false + }, + { + "name": "op", + "type": 149, + "optional": false + }, + { + "name": "startSeq", + "type": 18, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user/message" + } + ] + } + } + ], + "types": [] +} diff --git a/docs/persistence-changes/2026-10-13-user-rpc-principal.zh.md b/docs/persistence-changes/2026-10-13-user-rpc-principal.zh.md new file mode 100644 index 0000000000..5dca79c486 --- /dev/null +++ b/docs/persistence-changes/2026-10-13-user-rpc-principal.zh.md @@ -0,0 +1,60 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-10-13-user-rpc-principal + +[English](2026-10-13-user-rpc-principal.md) | 中文 + +## 概述 + +为 owner 以外的调用方提交的 prompt,在 user-rpc 消息来源上增加可选的 principal。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-10-13-user-rpc-principal +baseline: false +changes: + - root: "event:agent/inbox/spliced" + previous: "2026-10-05-working-directory-attribution" + after: "0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034" + decision: same-version + - root: "event:developer/message" + previous: "2026-10-05-working-directory-attribution" + after: "97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c" + decision: same-version + - root: "event:session/title-llm-request" + previous: "2026-10-07-title-reasoning-effort" + after: "79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056" + decision: same-version + - root: "event:user/message" + previous: "2026-10-05-working-directory-attribution" + after: "2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8" + decision: same-version +``` + + +## 兼容性 + +owner 的 prompt 不写该字段,因此其来源与所有既有 Session 日志都不变;缺少 principal 即为 owner。早于该字段的读取方会忽略它,并因 kind 仍为 'user' 而把消息当作普通用户消息。当前读取方读取旧日志时找不到 principal,把每条消息都视为 owner 的消息。该字段不进入任何模型请求:provider 适配器只读取模型可见的消息内容。SESSION_FORMAT_VERSION 不变。 + + +## 验证 + +pnpm exec vitest run packages/api/session-controller/tests/remote-access.host.spec.ts packages/subagent/subagent/tests/control.spec.ts packages/api/session-controller/tests/session-cold.host.spec.ts:非 owner 的 prompt 在 agent/inbox/spliced 中持久化 source.principal,owner 与既有时区来源保持逐字节不变;全部测试通过。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-changes/historical-formats/README.i18n.yaml b/docs/persistence-changes/historical-formats/README.i18n.yaml index 1835c6fe96..e24f17e991 100644 --- a/docs/persistence-changes/historical-formats/README.i18n.yaml +++ b/docs/persistence-changes/historical-formats/README.i18n.yaml @@ -15,8 +15,8 @@ en: 14c57f30e676f6e3 zh: b1a0ccf26fab3727 /session-persistence-formats/format-references: - en: 73b4caa341293dda - zh: 305a83174f20c790 + en: 43a4a6600fa37a1a + zh: 72174fab8355c344 /session-persistence-formats/scope-and-evidence: en: b220062139ddac1e zh: 7c4c0efe05dafa9c diff --git a/docs/persistence-changes/historical-formats/README.md b/docs/persistence-changes/historical-formats/README.md index 1f0274c047..2bc0daa565 100644 --- a/docs/persistence-changes/historical-formats/README.md +++ b/docs/persistence-changes/historical-formats/README.md @@ -32,7 +32,7 @@ The index is generated from validated snapshots and the current writer constant. | 1 | PR #3349 | [V1](v1.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 65 / 610 | +| 4 | Current checkout | [Current catalog](../../persistence-catalog.md) | [JSON](../../persistence-schema.json) | 65 / 612 | diff --git a/docs/persistence-changes/historical-formats/README.zh.md b/docs/persistence-changes/historical-formats/README.zh.md index 1dd81855c5..15b08f318d 100644 --- a/docs/persistence-changes/historical-formats/README.zh.md +++ b/docs/persistence-changes/historical-formats/README.zh.md @@ -32,7 +32,7 @@ description: "查找从 V0 到工作区写入器版本的每个 Session 格式 | 1 | PR #3349 | [V1](v1.zh.md) | [JSON](v1.schema.json) | 54 / 415 | | 2 | `dsh-v0.1.3-alpha.2` | [V2](v2.zh.md) | [JSON](v2.schema.json) | 56 / 435 | | 3 | PR #4320 | [V3](v3.zh.md) | [JSON](v3.schema.json) | 60 / 467 | -| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 65 / 610 | +| 4 | 当前工作树 | [当前目录](../../persistence-catalog.zh.md) | [JSON](../../persistence-schema.json) | 65 / 612 | diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 97bc1ffe41..04344d9b87 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -406,7 +406,7 @@ "kind": "event", "event": "agent/inbox/spliced", "surface": false, - "digest": "9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0", + "digest": "0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034", "schema": { "root": 0, "nodes": [ @@ -435,7 +435,7 @@ }, { "name": "type", - "type": 204, + "type": 206, "optional": false } ], @@ -451,7 +451,7 @@ }, { "name": "outcome", - "type": 200, + "type": 202, "optional": true }, { @@ -466,7 +466,7 @@ }, { "name": "target", - "type": 201, + "type": 203, "optional": false } ], @@ -818,10 +818,10 @@ "types": [ 34, 39, - 42, + 41, + 43, 45, - 47, - 49, + 48, 51, 53, 55, @@ -833,9 +833,9 @@ 67, 69, 70, + 71, 72, 73, - 74, 75, 76, 77, @@ -846,9 +846,9 @@ 82, 83, 84, + 85, 86, 87, - 88, 89, 90, 91, @@ -872,9 +872,9 @@ 109, 110, 111, + 112, 113, 114, - 115, 116, 117, 118, @@ -885,48 +885,48 @@ 123, 124, 125, + 126, 127, - 129, + 128, + 130, 132, - 133, - 134, 135, 136, + 137, 138, 139, - 140, 141, + 142, 143, 144, - 145, 146, 147, + 148, 149, 150, - 151, + 152, 153, - 155, + 154, 156, - 164, + 158, + 159, 167, - 169, 170, 172, - 174, - 178, - 180, - 188, - 189, - 190, - 191, - 192, + 175, + 177, + 179, + 183, + 185, 193, 194, 195, 196, 197, 198, - 199 + 199, + 200, + 201 ] }, { @@ -956,7 +956,7 @@ }, { "kind": "literal", - "value": "dsh-session-title-llm" + "value": "tool-goal" }, { "kind": "array", @@ -983,12 +983,87 @@ "properties": [ { "name": "form", - "type": 40, + "type": 35, "optional": false }, { "name": "kind", - "type": 41, + "type": 40, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 35, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "sections", + "type": 37, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "dsh-session-title-llm" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 47, "optional": false }, { @@ -1017,12 +1092,12 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { "name": "kind", - "type": 44, + "type": 50, "optional": false }, { @@ -1046,12 +1121,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 46, + "type": 52, "optional": false }, { @@ -1071,12 +1146,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 48, + "type": 54, "optional": false }, { @@ -1096,12 +1171,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 50, + "type": 56, "optional": false }, { @@ -1121,12 +1196,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 52, + "type": 58, "optional": false }, { @@ -1146,12 +1221,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 54, + "type": 60, "optional": false }, { @@ -1171,12 +1246,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 56, + "type": 62, "optional": false }, { @@ -1196,12 +1271,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 58, + "type": 64, "optional": false }, { @@ -1221,12 +1296,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 60, + "type": 66, "optional": false }, { @@ -1246,12 +1321,12 @@ "properties": [ { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 62, + "type": 68, "optional": false }, { @@ -1271,82 +1346,7 @@ "properties": [ { "name": "form", - "type": 40, - "optional": false - }, - { - "name": "kind", - "type": 64, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-goal" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 40, - "optional": false - }, - { - "name": "kind", - "type": 66, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-jobs" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 40, - "optional": false - }, - { - "name": "kind", - "type": 68, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "user-approval" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 40, + "type": 46, "optional": false }, { @@ -1367,13 +1367,76 @@ "properties": [ { "name": "form", - "type": 71, + "type": 46, "optional": false }, { "name": "kind", + "type": 40, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", "type": 46, "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 46, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 52, + "optional": false } ], "indices": [] @@ -1387,55 +1450,7 @@ "properties": [ { "name": "form", - "type": 71, - "optional": false - }, - { - "name": "kind", - "type": 48, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 71, - "optional": false - }, - { - "name": "kind", - "type": 50, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 71, - "optional": false - }, - { - "name": "kind", - "type": 52, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1451,7 +1466,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1467,7 +1482,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1483,7 +1498,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1499,7 +1514,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1515,7 +1530,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1531,7 +1546,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1547,7 +1562,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1563,7 +1578,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { @@ -1579,12 +1594,60 @@ "properties": [ { "name": "form", - "type": 85, + "type": 74, "optional": false }, { "name": "kind", - "type": 46, + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 74, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 88, + "optional": false + }, + { + "name": "kind", + "type": 52, "optional": false } ], @@ -1599,55 +1662,7 @@ "properties": [ { "name": "form", - "type": 85, - "optional": false - }, - { - "name": "kind", - "type": 48, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 85, - "optional": false - }, - { - "name": "kind", - "type": 50, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 85, - "optional": false - }, - { - "name": "kind", - "type": 52, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1663,7 +1678,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1679,7 +1694,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1695,7 +1710,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1711,7 +1726,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1727,7 +1742,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1743,7 +1758,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1759,7 +1774,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1775,7 +1790,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { @@ -1791,12 +1806,12 @@ "properties": [ { "name": "form", - "type": 43, + "type": 88, "optional": false }, { "name": "kind", - "type": 46, + "type": 40, "optional": false } ], @@ -1807,12 +1822,12 @@ "properties": [ { "name": "form", - "type": 43, + "type": 88, "optional": false }, { "name": "kind", - "type": 48, + "type": 42, "optional": false } ], @@ -1823,12 +1838,12 @@ "properties": [ { "name": "form", - "type": 43, + "type": 88, "optional": false }, { "name": "kind", - "type": 50, + "type": 44, "optional": false } ], @@ -1839,7 +1854,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1855,7 +1870,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1871,7 +1886,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1887,7 +1902,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1903,7 +1918,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1919,7 +1934,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1935,7 +1950,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1951,7 +1966,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1967,7 +1982,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1983,7 +1998,7 @@ "properties": [ { "name": "form", - "type": 43, + "type": 49, "optional": false }, { @@ -1999,12 +2014,60 @@ "properties": [ { "name": "form", - "type": 112, + "type": 49, "optional": false }, { "name": "kind", - "type": 46, + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 49, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 52, "optional": false } ], @@ -2019,55 +2082,7 @@ "properties": [ { "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 48, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 50, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 52, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2083,7 +2098,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2099,7 +2114,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2115,7 +2130,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2131,7 +2146,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2147,7 +2162,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2163,7 +2178,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2179,7 +2194,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2195,7 +2210,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 115, "optional": false }, { @@ -2206,6 +2221,54 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 115, + "optional": false + }, + { + "name": "kind", + "type": 44, + "optional": false + } + ], + "indices": [] + }, { "kind": "object", "properties": [ @@ -2216,7 +2279,7 @@ }, { "name": "kind", - "type": 126, + "type": 129, "optional": false }, { @@ -2241,7 +2304,7 @@ "properties": [ { "name": "kind", - "type": 128, + "type": 131, "optional": false }, { @@ -2276,7 +2339,7 @@ "properties": [ { "name": "kind", - "type": 130, + "type": 133, "optional": false }, { @@ -2291,7 +2354,7 @@ }, { "name": "replayState", - "type": 131, + "type": 134, "optional": true } ], @@ -2305,39 +2368,6 @@ "kind": "opaque", "reason": "unknown" }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 46, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 48, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 50, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -2349,21 +2379,6 @@ ], "indices": [] }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 137, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "ptc-mode" - }, { "kind": "object", "properties": [ @@ -2402,7 +2417,7 @@ "properties": [ { "name": "kind", - "type": 142, + "type": 140, "optional": false } ], @@ -2410,7 +2425,7 @@ }, { "kind": "literal", - "value": "system-prompt" + "value": "ptc-mode" }, { "kind": "object", @@ -2445,6 +2460,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 145, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, { "kind": "object", "properties": [ @@ -2461,7 +2491,40 @@ "properties": [ { "name": "kind", - "type": 148, + "type": 68, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 40, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 151, "optional": false } ], @@ -2487,7 +2550,7 @@ "properties": [ { "name": "kind", - "type": 68, + "type": 42, "optional": false } ], @@ -2498,7 +2561,7 @@ "properties": [ { "name": "kind", - "type": 152, + "type": 155, "optional": false } ], @@ -2513,7 +2576,7 @@ "properties": [ { "name": "kind", - "type": 154, + "type": 157, "optional": false } ], @@ -2528,7 +2591,7 @@ "properties": [ { "name": "kind", - "type": 36, + "type": 44, "optional": false } ], @@ -2549,17 +2612,17 @@ }, { "name": "changes", - "type": 157, + "type": 160, "optional": false }, { "name": "form", - "type": 71, + "type": 74, "optional": false }, { "name": "kind", - "type": 163, + "type": 166, "optional": false } ], @@ -2567,14 +2630,14 @@ }, { "kind": "array", - "element": 158 + "element": 161 }, { "kind": "object", "properties": [ { "name": "action", - "type": 159, + "type": 162, "optional": false }, { @@ -2598,9 +2661,9 @@ { "kind": "union", "types": [ - 160, - 161, - 162 + 163, + 164, + 165 ] }, { @@ -2629,12 +2692,12 @@ }, { "name": "kind", - "type": 165, + "type": 168, "optional": false }, { "name": "outcome", - "type": 166, + "type": 169, "optional": false } ], @@ -2658,7 +2721,7 @@ }, { "name": "kind", - "type": 168, + "type": 171, "optional": false } ], @@ -2681,6 +2744,11 @@ "type": 32, "optional": false }, + { + "name": "principal", + "type": 173, + "optional": true + }, { "name": "rpcId", "type": 7, @@ -2689,6 +2757,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 174, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, { "kind": "object", "properties": [ @@ -2699,7 +2782,7 @@ }, { "name": "kind", - "type": 171, + "type": 176, "optional": false }, { @@ -2724,12 +2807,12 @@ }, { "name": "form", - "type": 40, + "type": 46, "optional": false }, { "name": "kind", - "type": 173, + "type": 178, "optional": false }, { @@ -2764,17 +2847,17 @@ "properties": [ { "name": "entries", - "type": 175, + "type": 180, "optional": false }, { "name": "form", - "type": 112, + "type": 115, "optional": false }, { "name": "kind", - "type": 177, + "type": 182, "optional": false }, { @@ -2787,7 +2870,7 @@ }, { "kind": "array", - "element": 176 + "element": 181 }, { "kind": "object", @@ -2814,12 +2897,12 @@ "properties": [ { "name": "form", - "type": 71, + "type": 74, "optional": false }, { "name": "kind", - "type": 179, + "type": 184, "optional": false }, { @@ -2839,22 +2922,22 @@ "properties": [ { "name": "form", - "type": 85, + "type": 88, "optional": false }, { "name": "kind", - "type": 181, + "type": 186, "optional": false }, { "name": "references", - "type": 182, + "type": 187, "optional": false }, { "name": "version", - "type": 187, + "type": 192, "optional": false } ], @@ -2866,7 +2949,7 @@ }, { "kind": "array", - "element": 183 + "element": 188 }, { "kind": "object", @@ -2878,12 +2961,12 @@ }, { "name": "capturedThroughSeq", - "type": 184, + "type": 189, "optional": false }, { "name": "compacted", - "type": 186, + "type": 191, "optional": false }, { @@ -2923,7 +3006,7 @@ }, { "name": "truncated", - "type": 186, + "type": 191, "optional": false } ], @@ -2933,7 +3016,7 @@ "kind": "union", "types": [ 20, - 185 + 190 ] }, { @@ -2948,69 +3031,6 @@ "kind": "literal", "value": 1 }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 35, - "optional": false - }, - { - "name": "kind", - "type": 46, - "optional": false - }, - { - "name": "sections", - "type": 37, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 35, - "optional": false - }, - { - "name": "kind", - "type": 48, - "optional": false - }, - { - "name": "sections", - "type": 37, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 35, - "optional": false - }, - { - "name": "kind", - "type": 50, - "optional": false - }, - { - "name": "sections", - "type": 37, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -3207,8 +3227,8 @@ { "kind": "union", "types": [ - 202, - 203 + 204, + 205 ] }, { @@ -6571,7 +6591,7 @@ "kind": "event", "event": "developer/message", "surface": true, - "digest": "ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06", + "digest": "97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c", "schema": { "root": 0, "nodes": [ @@ -6595,12 +6615,12 @@ }, { "name": "sourceEventSeqs", - "type": 200, + "type": 202, "optional": true }, { "name": "surfaceOp", - "type": 201, + "type": 203, "optional": false }, { @@ -6610,7 +6630,7 @@ }, { "name": "type", - "type": 204, + "type": 206, "optional": false } ], @@ -6984,10 +7004,10 @@ "types": [ 33, 38, - 41, + 40, + 42, 44, - 46, - 48, + 47, 50, 52, 54, @@ -6999,9 +7019,9 @@ 66, 68, 69, + 70, 71, 72, - 73, 74, 75, 76, @@ -7012,9 +7032,9 @@ 81, 82, 83, + 84, 85, 86, - 87, 88, 89, 90, @@ -7038,9 +7058,9 @@ 108, 109, 110, + 111, 112, 113, - 114, 115, 116, 117, @@ -7051,48 +7071,48 @@ 122, 123, 124, + 125, 126, - 128, + 127, + 129, 131, - 132, - 133, 134, 135, 136, + 137, 138, 139, - 140, 141, + 142, 143, 144, - 145, 146, 147, + 148, 149, - 151, + 150, 152, 154, - 156, - 164, + 155, + 157, + 159, 167, - 169, 170, 172, - 174, - 178, - 180, - 188, - 189, - 190, - 191, - 192, + 175, + 177, + 179, + 183, + 185, 193, 194, 195, 196, 197, 198, - 199 + 199, + 200, + 201 ] }, { @@ -7122,7 +7142,7 @@ }, { "kind": "literal", - "value": "user-approval" + "value": "tmux-context" }, { "kind": "array", @@ -7149,12 +7169,87 @@ "properties": [ { "name": "form", - "type": 39, + "type": 34, "optional": false }, { "name": "kind", - "type": 40, + "type": 39, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-goal" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-jobs" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 34, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + }, + { + "name": "sections", + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 46, "optional": false }, { @@ -7183,12 +7278,12 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { "name": "kind", - "type": 43, + "type": 49, "optional": false }, { @@ -7212,12 +7307,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 45, + "type": 51, "optional": false }, { @@ -7237,12 +7332,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 47, + "type": 53, "optional": false }, { @@ -7262,12 +7357,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 49, + "type": 55, "optional": false }, { @@ -7287,12 +7382,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 51, + "type": 57, "optional": false }, { @@ -7312,12 +7407,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 53, + "type": 59, "optional": false }, { @@ -7337,12 +7432,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 55, + "type": 61, "optional": false }, { @@ -7362,12 +7457,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 57, + "type": 63, "optional": false }, { @@ -7387,12 +7482,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 59, + "type": 65, "optional": false }, { @@ -7412,12 +7507,12 @@ "properties": [ { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 61, + "type": 67, "optional": false }, { @@ -7437,82 +7532,7 @@ "properties": [ { "name": "form", - "type": 39, - "optional": false - }, - { - "name": "kind", - "type": 63, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tmux-context" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 39, - "optional": false - }, - { - "name": "kind", - "type": 65, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-goal" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 39, - "optional": false - }, - { - "name": "kind", - "type": 67, - "optional": false - }, - { - "name": "summary", - "type": 7, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-jobs" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 39, + "type": 45, "optional": false }, { @@ -7533,13 +7553,76 @@ "properties": [ { "name": "form", - "type": 70, + "type": 45, "optional": false }, { "name": "kind", + "type": 39, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", "type": 45, "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 45, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + }, + { + "name": "summary", + "type": 7, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 51, + "optional": false } ], "indices": [] @@ -7553,55 +7636,7 @@ "properties": [ { "name": "form", - "type": 70, - "optional": false - }, - { - "name": "kind", - "type": 47, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 70, - "optional": false - }, - { - "name": "kind", - "type": 49, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 70, - "optional": false - }, - { - "name": "kind", - "type": 51, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7617,7 +7652,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7633,7 +7668,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7649,7 +7684,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7665,7 +7700,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7681,7 +7716,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7697,7 +7732,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7713,7 +7748,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7729,7 +7764,7 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { @@ -7745,12 +7780,60 @@ "properties": [ { "name": "form", - "type": 84, + "type": 73, "optional": false }, { "name": "kind", - "type": 45, + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 73, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 87, + "optional": false + }, + { + "name": "kind", + "type": 51, "optional": false } ], @@ -7765,55 +7848,7 @@ "properties": [ { "name": "form", - "type": 84, - "optional": false - }, - { - "name": "kind", - "type": 47, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 84, - "optional": false - }, - { - "name": "kind", - "type": 49, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 84, - "optional": false - }, - { - "name": "kind", - "type": 51, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7829,7 +7864,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7845,7 +7880,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7861,7 +7896,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7877,7 +7912,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7893,7 +7928,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7909,7 +7944,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7925,7 +7960,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7941,7 +7976,7 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { @@ -7957,12 +7992,12 @@ "properties": [ { "name": "form", - "type": 42, + "type": 87, "optional": false }, { "name": "kind", - "type": 45, + "type": 39, "optional": false } ], @@ -7973,12 +8008,12 @@ "properties": [ { "name": "form", - "type": 42, + "type": 87, "optional": false }, { "name": "kind", - "type": 47, + "type": 41, "optional": false } ], @@ -7989,12 +8024,12 @@ "properties": [ { "name": "form", - "type": 42, + "type": 87, "optional": false }, { "name": "kind", - "type": 49, + "type": 43, "optional": false } ], @@ -8005,7 +8040,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8021,7 +8056,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8037,7 +8072,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8053,7 +8088,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8069,7 +8104,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8085,7 +8120,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8101,7 +8136,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8117,7 +8152,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8133,7 +8168,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8149,7 +8184,7 @@ "properties": [ { "name": "form", - "type": 42, + "type": 48, "optional": false }, { @@ -8165,12 +8200,60 @@ "properties": [ { "name": "form", - "type": 111, + "type": 48, "optional": false }, { "name": "kind", - "type": 45, + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 48, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 51, "optional": false } ], @@ -8185,55 +8268,7 @@ "properties": [ { "name": "form", - "type": 111, - "optional": false - }, - { - "name": "kind", - "type": 47, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 111, - "optional": false - }, - { - "name": "kind", - "type": 49, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 111, - "optional": false - }, - { - "name": "kind", - "type": 51, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8249,7 +8284,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8265,7 +8300,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8281,7 +8316,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8297,7 +8332,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8313,7 +8348,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8329,7 +8364,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8345,7 +8380,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8361,7 +8396,7 @@ "properties": [ { "name": "form", - "type": 111, + "type": 114, "optional": false }, { @@ -8372,6 +8407,54 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 114, + "optional": false + }, + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, { "kind": "object", "properties": [ @@ -8382,7 +8465,7 @@ }, { "name": "kind", - "type": 125, + "type": 128, "optional": false }, { @@ -8407,7 +8490,7 @@ "properties": [ { "name": "kind", - "type": 127, + "type": 130, "optional": false }, { @@ -8442,7 +8525,7 @@ "properties": [ { "name": "kind", - "type": 129, + "type": 132, "optional": false }, { @@ -8457,7 +8540,7 @@ }, { "name": "replayState", - "type": 130, + "type": 133, "optional": true } ], @@ -8471,39 +8554,6 @@ "kind": "opaque", "reason": "unknown" }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 45, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 47, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 49, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -8526,21 +8576,6 @@ ], "indices": [] }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 137, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "ptc-mode" - }, { "kind": "object", "properties": [ @@ -8579,7 +8614,7 @@ "properties": [ { "name": "kind", - "type": 142, + "type": 140, "optional": false } ], @@ -8587,7 +8622,7 @@ }, { "kind": "literal", - "value": "system-prompt" + "value": "ptc-mode" }, { "kind": "object", @@ -8622,6 +8657,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 145, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" + }, { "kind": "object", "properties": [ @@ -8633,36 +8683,6 @@ ], "indices": [] }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 148, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-registry" - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 150, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "user" - }, { "kind": "object", "properties": [ @@ -8674,6 +8694,43 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 39, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 41, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 151, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "tool-registry" + }, { "kind": "object", "properties": [ @@ -8685,6 +8742,32 @@ ], "indices": [] }, + { + "kind": "literal", + "value": "user" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 156, + "optional": false + } + ], + "indices": [] + }, { "kind": "literal", "value": "working-directory" @@ -8694,7 +8777,7 @@ "properties": [ { "name": "kind", - "type": 155, + "type": 158, "optional": false } ], @@ -8719,17 +8802,17 @@ }, { "name": "changes", - "type": 157, + "type": 160, "optional": false }, { "name": "form", - "type": 70, + "type": 73, "optional": false }, { "name": "kind", - "type": 163, + "type": 166, "optional": false } ], @@ -8737,14 +8820,14 @@ }, { "kind": "array", - "element": 158 + "element": 161 }, { "kind": "object", "properties": [ { "name": "action", - "type": 159, + "type": 162, "optional": false }, { @@ -8768,9 +8851,9 @@ { "kind": "union", "types": [ - 160, - 161, - 162 + 163, + 164, + 165 ] }, { @@ -8799,12 +8882,12 @@ }, { "name": "kind", - "type": 165, + "type": 168, "optional": false }, { "name": "outcome", - "type": 166, + "type": 169, "optional": false } ], @@ -8828,7 +8911,7 @@ }, { "name": "kind", - "type": 168, + "type": 171, "optional": false } ], @@ -8848,9 +8931,14 @@ }, { "name": "kind", - "type": 150, + "type": 153, "optional": false }, + { + "name": "principal", + "type": 173, + "optional": true + }, { "name": "rpcId", "type": 7, @@ -8859,6 +8947,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 174, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, { "kind": "object", "properties": [ @@ -8869,7 +8972,7 @@ }, { "name": "kind", - "type": 171, + "type": 176, "optional": false }, { @@ -8894,12 +8997,12 @@ }, { "name": "form", - "type": 39, + "type": 45, "optional": false }, { "name": "kind", - "type": 173, + "type": 178, "optional": false }, { @@ -8934,17 +9037,17 @@ "properties": [ { "name": "entries", - "type": 175, + "type": 180, "optional": false }, { "name": "form", - "type": 111, + "type": 114, "optional": false }, { "name": "kind", - "type": 177, + "type": 182, "optional": false }, { @@ -8957,7 +9060,7 @@ }, { "kind": "array", - "element": 176 + "element": 181 }, { "kind": "object", @@ -8984,12 +9087,12 @@ "properties": [ { "name": "form", - "type": 70, + "type": 73, "optional": false }, { "name": "kind", - "type": 179, + "type": 184, "optional": false }, { @@ -9009,22 +9112,22 @@ "properties": [ { "name": "form", - "type": 84, + "type": 87, "optional": false }, { "name": "kind", - "type": 181, + "type": 186, "optional": false }, { "name": "references", - "type": 182, + "type": 187, "optional": false }, { "name": "version", - "type": 187, + "type": 192, "optional": false } ], @@ -9036,7 +9139,7 @@ }, { "kind": "array", - "element": 183 + "element": 188 }, { "kind": "object", @@ -9048,12 +9151,12 @@ }, { "name": "capturedThroughSeq", - "type": 184, + "type": 189, "optional": false }, { "name": "compacted", - "type": 186, + "type": 191, "optional": false }, { @@ -9093,7 +9196,7 @@ }, { "name": "truncated", - "type": 186, + "type": 191, "optional": false } ], @@ -9103,7 +9206,7 @@ "kind": "union", "types": [ 2, - 185 + 190 ] }, { @@ -9118,69 +9221,6 @@ "kind": "literal", "value": 1 }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 34, - "optional": false - }, - { - "name": "kind", - "type": 45, - "optional": false - }, - { - "name": "sections", - "type": 36, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 34, - "optional": false - }, - { - "name": "kind", - "type": 47, - "optional": false - }, - { - "name": "sections", - "type": 36, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 34, - "optional": false - }, - { - "name": "kind", - "type": 49, - "optional": false - }, - { - "name": "sections", - "type": 36, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -9377,8 +9417,8 @@ { "kind": "union", "types": [ - 202, - 203 + 204, + 205 ] }, { @@ -9395,7 +9435,7 @@ }, { "name": "op", - "type": 161, + "type": 164, "optional": false }, { @@ -12181,7 +12221,7 @@ "kind": "event", "event": "session/title-llm-request", "surface": false, - "digest": "b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6", + "digest": "79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056", "schema": { "root": 0, "nodes": [ @@ -12210,7 +12250,7 @@ }, { "name": "type", - "type": 210, + "type": 212, "optional": false } ], @@ -12241,7 +12281,7 @@ }, { "name": "route", - "type": 209, + "type": 211, "optional": false }, { @@ -12274,9 +12314,9 @@ "types": [ 6, 37, - 204, - 205, - 207 + 206, + 207, + 209 ] }, { @@ -12683,7 +12723,7 @@ 51, 53, 55, - 56, + 57, 58, 60, 62, @@ -12692,28 +12732,25 @@ 67, 69, 71, - 72, 73, - 76, + 75, + 77, + 78, 79, + 80, 81, - 83, - 92, - 95, - 97, + 84, + 87, + 89, 98, - 100, - 102, + 101, 104, - 109, - 111, + 106, + 108, + 110, + 115, + 117, 119, - 120, - 121, - 123, - 124, - 125, - 126, 127, 128, 129, @@ -12777,15 +12814,18 @@ 187, 188, 189, + 190, 191, 192, + 193, 194, - 197, + 195, + 196, 198, - 199, 200, - 201, - 203 + 203, + 204, + 205 ] }, { @@ -12815,7 +12855,7 @@ }, { "kind": "literal", - "value": "tool-goal" + "value": "time-context" }, { "kind": "array", @@ -12850,7 +12890,7 @@ }, { "kind": "literal", - "value": "runtime-context" + "value": "plan-mode" }, { "kind": "object", @@ -12865,7 +12905,7 @@ }, { "kind": "literal", - "value": "schedule" + "value": "ptc-mode" }, { "kind": "object", @@ -12880,7 +12920,7 @@ }, { "kind": "literal", - "value": "system-prompt" + "value": "repeat-tool-reminder" }, { "kind": "object", @@ -12895,7 +12935,7 @@ }, { "kind": "literal", - "value": "time-context" + "value": "runtime-context" }, { "kind": "object", @@ -12910,7 +12950,22 @@ }, { "kind": "literal", - "value": "tmux-context" + "value": "schedule" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 56, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "system-prompt" }, { "kind": "object", @@ -12923,21 +12978,6 @@ ], "indices": [] }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 57, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "tool-jobs" - }, { "kind": "object", "properties": [ @@ -12951,7 +12991,7 @@ }, { "kind": "literal", - "value": "tool-registry" + "value": "tmux-context" }, { "kind": "object", @@ -12966,7 +13006,7 @@ }, { "kind": "literal", - "value": "user" + "value": "tool-goal" }, { "kind": "object", @@ -12978,7 +13018,7 @@ }, { "name": "kind", - "type": 57, + "type": 59, "optional": false }, { @@ -13002,7 +13042,7 @@ }, { "kind": "literal", - "value": "user-approval" + "value": "tool-jobs" }, { "kind": "object", @@ -13017,7 +13057,7 @@ }, { "kind": "literal", - "value": "working-directory" + "value": "tool-registry" }, { "kind": "object", @@ -13032,7 +13072,7 @@ }, { "kind": "literal", - "value": "cordis-host-runner" + "value": "user" }, { "kind": "object", @@ -13045,10 +13085,76 @@ ], "indices": [] }, + { + "kind": "literal", + "value": "user-approval" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 72, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "working-directory" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 74, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "cordis-host-runner" + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, { "kind": "literal", "value": "dsh-session-title-llm" }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, { "kind": "object", "properties": [ @@ -13096,12 +13202,33 @@ "properties": [ { "name": "form", - "type": 74, + "type": 41, "optional": false }, { "name": "kind", - "type": 75, + "type": 76, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 83, "optional": false }, { @@ -13130,12 +13257,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 78, + "type": 86, "optional": false }, { @@ -13159,12 +13286,12 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { "name": "kind", - "type": 80, + "type": 88, "optional": false }, { @@ -13179,31 +13306,6 @@ "kind": "literal", "value": "hooks-claude-code" }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 74, - "optional": false - }, - { - "name": "kind", - "type": 82, - "optional": false - }, - { - "name": "summary", - "type": 10, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "hooks-codex" - }, { "kind": "object", "properties": [ @@ -13219,17 +13321,17 @@ }, { "name": "changes", - "type": 84, - "optional": false - }, - { - "name": "form", "type": 90, "optional": false }, + { + "name": "form", + "type": 96, + "optional": false + }, { "name": "kind", - "type": 91, + "type": 97, "optional": false } ], @@ -13237,14 +13339,14 @@ }, { "kind": "array", - "element": 85 + "element": 91 }, { "kind": "object", "properties": [ { "name": "action", - "type": 86, + "type": 92, "optional": false }, { @@ -13268,9 +13370,9 @@ { "kind": "union", "types": [ - 87, - 88, - 89 + 93, + 94, + 95 ] }, { @@ -13303,12 +13405,12 @@ }, { "name": "kind", - "type": 93, + "type": 99, "optional": false }, { "name": "outcome", - "type": 94, + "type": 100, "optional": false } ], @@ -13322,31 +13424,6 @@ "kind": "literal", "value": "answered" }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 74, - "optional": false - }, - { - "name": "kind", - "type": 96, - "optional": false - }, - { - "name": "summary", - "type": 10, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "model-selection" - }, { "kind": "object", "properties": [ @@ -13357,9 +13434,14 @@ }, { "name": "kind", - "type": 61, + "type": 68, "optional": false }, + { + "name": "principal", + "type": 102, + "optional": true + }, { "name": "rpcId", "type": 10, @@ -13368,6 +13450,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 103, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, { "kind": "object", "properties": [ @@ -13378,7 +13475,7 @@ }, { "name": "kind", - "type": 99, + "type": 105, "optional": false }, { @@ -13398,12 +13495,12 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { "name": "kind", - "type": 101, + "type": 107, "optional": false }, { @@ -13416,7 +13513,7 @@ }, { "kind": "literal", - "value": "plan-mode" + "value": "hooks-codex" }, { "kind": "object", @@ -13428,12 +13525,12 @@ }, { "name": "form", - "type": 74, + "type": 82, "optional": false }, { "name": "kind", - "type": 103, + "type": 109, "optional": false }, { @@ -13468,17 +13565,17 @@ "properties": [ { "name": "entries", - "type": 105, + "type": 111, "optional": false }, { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 108, + "type": 114, "optional": false }, { @@ -13491,7 +13588,7 @@ }, { "kind": "array", - "element": 106 + "element": 112 }, { "kind": "object", @@ -13522,12 +13619,37 @@ "properties": [ { "name": "form", - "type": 90, + "type": 82, "optional": false }, { "name": "kind", - "type": 110, + "type": 116, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "model-selection" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 118, "optional": false }, { @@ -13547,22 +13669,22 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { "name": "kind", - "type": 113, + "type": 121, "optional": false }, { "name": "references", - "type": 114, + "type": 122, "optional": false }, { "name": "version", - "type": 118, + "type": 126, "optional": false } ], @@ -13578,7 +13700,7 @@ }, { "kind": "array", - "element": 115 + "element": 123 }, { "kind": "object", @@ -13590,7 +13712,7 @@ }, { "name": "capturedThroughSeq", - "type": 116, + "type": 124, "optional": false }, { @@ -13645,7 +13767,7 @@ "kind": "union", "types": [ 2, - 117 + 125 ] }, { @@ -13666,7 +13788,7 @@ }, { "name": "kind", - "type": 80, + "type": 88, "optional": false }, { @@ -13682,76 +13804,9 @@ "properties": [ { "name": "form", - "type": 41, - "optional": false - }, - { - "name": "kind", "type": 82, "optional": false }, - { - "name": "sections", - "type": 43, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 74, - "optional": false - }, - { - "name": "kind", - "type": 122, - "optional": false - }, - { - "name": "summary", - "type": 10, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "repeat-tool-reminder" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 41, - "optional": false - }, - { - "name": "kind", - "type": 96, - "optional": false - }, - { - "name": "sections", - "type": 43, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 74, - "optional": false - }, { "name": "kind", "type": 46, @@ -13770,12 +13825,12 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { "name": "kind", - "type": 48, + "type": 50, "optional": false }, { @@ -13791,7 +13846,7 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { @@ -13812,7 +13867,7 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { @@ -13833,7 +13888,7 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { @@ -13854,12 +13909,12 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false }, { @@ -13875,7 +13930,28 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 82, "optional": false }, { @@ -13896,7 +13972,7 @@ "properties": [ { "name": "form", - "type": 74, + "type": 82, "optional": false }, { @@ -13917,29 +13993,18 @@ "properties": [ { "name": "form", - "type": 90, - "optional": false - }, - { - "name": "kind", - "type": 80, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 90, - "optional": false - }, - { - "name": "kind", "type": 82, "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + }, + { + "name": "summary", + "type": 10, + "optional": false } ], "indices": [] @@ -13949,13 +14014,13 @@ "properties": [ { "name": "form", - "type": 90, - "optional": false - }, - { - "name": "kind", "type": 96, "optional": false + }, + { + "name": "kind", + "type": 88, + "optional": false } ], "indices": [] @@ -13965,12 +14030,12 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { "name": "kind", - "type": 101, + "type": 107, "optional": false } ], @@ -13981,12 +14046,12 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { "name": "kind", - "type": 122, + "type": 116, "optional": false } ], @@ -13997,7 +14062,7 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { @@ -14013,12 +14078,12 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { "name": "kind", - "type": 48, + "type": 50, "optional": false } ], @@ -14029,7 +14094,7 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { @@ -14050,7 +14115,7 @@ }, { "name": "kind", - "type": 101, + "type": 107, "optional": false }, { @@ -14066,7 +14131,7 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { @@ -14082,7 +14147,7 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { @@ -14098,12 +14163,12 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false } ], @@ -14114,7 +14179,23 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 96, "optional": false }, { @@ -14130,7 +14211,7 @@ "properties": [ { "name": "form", - "type": 90, + "type": 96, "optional": false }, { @@ -14146,45 +14227,13 @@ "properties": [ { "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 80, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 82, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", "type": 96, "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false } ], "indices": [] @@ -14194,12 +14243,28 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { "name": "kind", - "type": 101, + "type": 88, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 107, "optional": false } ], @@ -14215,7 +14280,7 @@ }, { "name": "kind", - "type": 122, + "type": 116, "optional": false }, { @@ -14231,12 +14296,12 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { "name": "kind", - "type": 122, + "type": 116, "optional": false } ], @@ -14247,7 +14312,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { @@ -14263,12 +14328,12 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { "name": "kind", - "type": 48, + "type": 50, "optional": false } ], @@ -14279,7 +14344,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { @@ -14295,7 +14360,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { @@ -14311,7 +14376,7 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { @@ -14327,12 +14392,12 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false } ], @@ -14343,7 +14408,23 @@ "properties": [ { "name": "form", - "type": 112, + "type": 120, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 120, "optional": false }, { @@ -14354,22 +14435,6 @@ ], "indices": [] }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 112, - "optional": false - }, - { - "name": "kind", - "type": 70, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -14396,12 +14461,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 120, "optional": false }, { "name": "kind", - "type": 80, + "type": 70, "optional": false } ], @@ -14412,12 +14477,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 120, "optional": false }, { "name": "kind", - "type": 82, + "type": 76, "optional": false } ], @@ -14428,12 +14493,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 96, + "type": 88, "optional": false } ], @@ -14444,12 +14509,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 101, + "type": 107, "optional": false } ], @@ -14460,12 +14525,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 122, + "type": 116, "optional": false } ], @@ -14476,7 +14541,7 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { @@ -14492,12 +14557,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 48, + "type": 50, "optional": false } ], @@ -14508,7 +14573,7 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { @@ -14524,7 +14589,7 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { @@ -14545,7 +14610,7 @@ }, { "name": "kind", - "type": 48, + "type": 50, "optional": false }, { @@ -14561,7 +14626,7 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { @@ -14577,12 +14642,12 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false } ], @@ -14593,7 +14658,23 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 85, "optional": false }, { @@ -14609,7 +14690,7 @@ "properties": [ { "name": "form", - "type": 77, + "type": 85, "optional": false }, { @@ -14625,12 +14706,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 85, "optional": false }, { "name": "kind", - "type": 80, + "type": 76, "optional": false } ], @@ -14641,12 +14722,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 82, + "type": 88, "optional": false } ], @@ -14657,12 +14738,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 96, + "type": 107, "optional": false } ], @@ -14673,12 +14754,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 101, + "type": 116, "optional": false } ], @@ -14689,23 +14770,7 @@ "properties": [ { "name": "form", - "type": 107, - "optional": false - }, - { - "name": "kind", - "type": 122, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 107, + "type": 113, "optional": false }, { @@ -14742,12 +14807,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 48, + "type": 50, "optional": false } ], @@ -14758,7 +14823,7 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { @@ -14774,7 +14839,7 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { @@ -14790,7 +14855,7 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { @@ -14806,12 +14871,12 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false } ], @@ -14822,7 +14887,23 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 61, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, "optional": false }, { @@ -14838,7 +14919,7 @@ "properties": [ { "name": "form", - "type": 107, + "type": 113, "optional": false }, { @@ -14849,6 +14930,43 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 113, + "optional": false + }, + { + "name": "kind", + "type": 76, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 41, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "sections", + "type": 43, + "optional": false + } + ], + "indices": [] + }, { "kind": "object", "properties": [ @@ -14859,7 +14977,7 @@ }, { "name": "kind", - "type": 190, + "type": 197, "optional": false }, { @@ -14882,30 +15000,9 @@ { "kind": "object", "properties": [ - { - "name": "form", - "type": 41, - "optional": false - }, { "name": "kind", - "type": 54, - "optional": false - }, - { - "name": "sections", - "type": 43, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 193, + "type": 199, "optional": false }, { @@ -14940,7 +15037,7 @@ "properties": [ { "name": "kind", - "type": 195, + "type": 201, "optional": false }, { @@ -14955,7 +15052,7 @@ }, { "name": "replayState", - "type": 196, + "type": 202, "optional": true } ], @@ -14974,7 +15071,7 @@ "properties": [ { "name": "kind", - "type": 80, + "type": 88, "optional": false } ], @@ -14985,7 +15082,7 @@ "properties": [ { "name": "kind", - "type": 82, + "type": 107, "optional": false } ], @@ -14996,44 +15093,7 @@ "properties": [ { "name": "kind", - "type": 96, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 101, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 202, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "ptc-mode" - }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 122, + "type": 116, "optional": false } ], @@ -15054,7 +15114,7 @@ }, { "name": "role", - "type": 61, + "type": 68, "optional": false }, { @@ -15092,12 +15152,12 @@ }, { "name": "role", - "type": 206, + "type": 208, "optional": false }, { "name": "source", - "type": 194, + "type": 200, "optional": false } ], @@ -15122,12 +15182,12 @@ }, { "name": "role", - "type": 208, + "type": 210, "optional": false }, { "name": "source", - "type": 49, + "type": 55, "optional": false } ], @@ -19148,7 +19208,7 @@ "kind": "event", "event": "user/message", "surface": true, - "digest": "2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade", + "digest": "2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8", "schema": { "root": 0, "nodes": [ @@ -19172,12 +19232,12 @@ }, { "name": "sourceEventSeqs", - "type": 198, + "type": 200, "optional": true }, { "name": "surfaceOp", - "type": 199, + "type": 201, "optional": false }, { @@ -19187,7 +19247,7 @@ }, { "name": "type", - "type": 202, + "type": 204, "optional": false } ], @@ -19544,7 +19604,7 @@ 49, 51, 53, - 56, + 55, 58, 60, 61, @@ -19557,7 +19617,7 @@ 68, 69, 70, - 72, + 71, 73, 74, 75, @@ -19570,7 +19630,7 @@ 82, 83, 84, - 86, + 85, 87, 88, 89, @@ -19583,7 +19643,7 @@ 96, 97, 98, - 100, + 99, 101, 102, 103, @@ -19596,40 +19656,38 @@ 110, 111, 112, - 114, - 116, - 119, + 113, + 115, + 117, 120, 121, 122, 123, - 125, + 124, 126, 127, 128, - 130, + 129, 131, 132, 133, 134, - 136, + 135, 137, 138, - 140, - 142, + 139, + 141, 143, - 151, - 154, - 156, + 144, + 152, + 155, 157, - 159, - 161, - 165, - 167, - 175, - 179, - 180, - 181, + 160, + 162, + 164, + 168, + 170, + 178, 182, 183, 184, @@ -19640,10 +19698,12 @@ 189, 190, 191, + 192, 193, - 195, + 194, 196, - 197 + 198, + 199 ] }, { @@ -19673,7 +19733,7 @@ }, { "kind": "literal", - "value": "plan-mode" + "value": "model-selection" }, { "kind": "object", @@ -19696,6 +19756,31 @@ ], "indices": [] }, + { + "kind": "literal", + "value": "plan-mode" + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 33, + "optional": false + }, + { + "name": "kind", + "type": 38, + "optional": false + }, + { + "name": "summary", + "type": 5, + "optional": false + } + ], + "indices": [] + }, { "kind": "literal", "value": "repeat-tool-reminder" @@ -19710,7 +19795,7 @@ }, { "name": "kind", - "type": 38, + "type": 40, "optional": false }, { @@ -19735,7 +19820,7 @@ }, { "name": "kind", - "type": 40, + "type": 42, "optional": false }, { @@ -19760,7 +19845,7 @@ }, { "name": "kind", - "type": 42, + "type": 44, "optional": false }, { @@ -19785,7 +19870,7 @@ }, { "name": "kind", - "type": 44, + "type": 46, "optional": false }, { @@ -19810,7 +19895,7 @@ }, { "name": "kind", - "type": 46, + "type": 48, "optional": false }, { @@ -19835,7 +19920,7 @@ }, { "name": "kind", - "type": 48, + "type": 50, "optional": false }, { @@ -19860,7 +19945,7 @@ }, { "name": "kind", - "type": 50, + "type": 52, "optional": false }, { @@ -19885,7 +19970,7 @@ }, { "name": "kind", - "type": 52, + "type": 54, "optional": false }, { @@ -19905,12 +19990,12 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { "name": "kind", - "type": 55, + "type": 57, "optional": false } ], @@ -19929,12 +20014,12 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { "name": "kind", - "type": 57, + "type": 59, "optional": false } ], @@ -19949,27 +20034,7 @@ "properties": [ { "name": "form", - "type": 54, - "optional": false - }, - { - "name": "kind", - "type": 59, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "model-selection" - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -19985,7 +20050,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20001,7 +20066,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20017,7 +20082,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20033,7 +20098,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20049,7 +20114,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20065,7 +20130,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20081,7 +20146,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20097,7 +20162,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20113,7 +20178,7 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { @@ -20129,12 +20194,28 @@ "properties": [ { "name": "form", - "type": 71, + "type": 56, "optional": false }, { "name": "kind", - "type": 55, + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 72, + "optional": false + }, + { + "name": "kind", + "type": 57, "optional": false } ], @@ -20149,23 +20230,7 @@ "properties": [ { "name": "form", - "type": 71, - "optional": false - }, - { - "name": "kind", - "type": 57, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20181,7 +20246,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20197,7 +20262,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20213,7 +20278,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20229,7 +20294,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20245,7 +20310,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20261,7 +20326,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20277,7 +20342,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20293,7 +20358,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20309,7 +20374,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20325,7 +20390,7 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { @@ -20341,12 +20406,28 @@ "properties": [ { "name": "form", - "type": 85, + "type": 72, "optional": false }, { "name": "kind", - "type": 55, + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 86, + "optional": false + }, + { + "name": "kind", + "type": 57, "optional": false } ], @@ -20361,23 +20442,7 @@ "properties": [ { "name": "form", - "type": 85, - "optional": false - }, - { - "name": "kind", - "type": 57, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20393,7 +20458,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20409,7 +20474,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20425,7 +20490,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20441,7 +20506,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20457,7 +20522,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20473,7 +20538,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20489,7 +20554,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20505,7 +20570,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20521,7 +20586,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20537,7 +20602,7 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { @@ -20553,12 +20618,28 @@ "properties": [ { "name": "form", - "type": 99, + "type": 86, "optional": false }, { "name": "kind", - "type": 55, + "type": 54, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 57, "optional": false } ], @@ -20573,23 +20654,7 @@ "properties": [ { "name": "form", - "type": 99, - "optional": false - }, - { - "name": "kind", - "type": 57, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20605,7 +20670,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20621,7 +20686,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20637,7 +20702,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20653,7 +20718,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20669,7 +20734,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20685,7 +20750,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20701,7 +20766,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20717,7 +20782,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20733,7 +20798,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20749,7 +20814,7 @@ "properties": [ { "name": "form", - "type": 99, + "type": 100, "optional": false }, { @@ -20760,6 +20825,22 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 100, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + } + ], + "indices": [] + }, { "kind": "object", "properties": [ @@ -20770,7 +20851,7 @@ }, { "name": "kind", - "type": 113, + "type": 114, "optional": false }, { @@ -20795,7 +20876,7 @@ "properties": [ { "name": "kind", - "type": 115, + "type": 116, "optional": false }, { @@ -20830,7 +20911,7 @@ "properties": [ { "name": "kind", - "type": 117, + "type": 118, "optional": false }, { @@ -20845,7 +20926,7 @@ }, { "name": "replayState", - "type": 118, + "type": 119, "optional": true } ], @@ -20859,17 +20940,6 @@ "kind": "opaque", "reason": "unknown" }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 55, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -20908,7 +20978,18 @@ "properties": [ { "name": "kind", - "type": 124, + "type": 36, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 125, "optional": false } ], @@ -20918,17 +20999,6 @@ "kind": "literal", "value": "ptc-mode" }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 36, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -20956,7 +21026,18 @@ "properties": [ { "name": "kind", - "type": 129, + "type": 42, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 130, "optional": false } ], @@ -20966,17 +21047,6 @@ "kind": "literal", "value": "system-prompt" }, - { - "kind": "object", - "properties": [ - { - "name": "kind", - "type": 42, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -21015,7 +21085,18 @@ "properties": [ { "name": "kind", - "type": 135, + "type": 50, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 136, "optional": false } ], @@ -21041,7 +21122,7 @@ "properties": [ { "name": "kind", - "type": 50, + "type": 52, "optional": false } ], @@ -21052,7 +21133,7 @@ "properties": [ { "name": "kind", - "type": 139, + "type": 140, "optional": false } ], @@ -21067,7 +21148,7 @@ "properties": [ { "name": "kind", - "type": 141, + "type": 142, "optional": false } ], @@ -21082,7 +21163,7 @@ "properties": [ { "name": "kind", - "type": 52, + "type": 54, "optional": false } ], @@ -21103,17 +21184,17 @@ }, { "name": "changes", - "type": 144, + "type": 145, "optional": false }, { "name": "form", - "type": 54, + "type": 56, "optional": false }, { "name": "kind", - "type": 150, + "type": 151, "optional": false } ], @@ -21121,14 +21202,14 @@ }, { "kind": "array", - "element": 145 + "element": 146 }, { "kind": "object", "properties": [ { "name": "action", - "type": 146, + "type": 147, "optional": false }, { @@ -21152,9 +21233,9 @@ { "kind": "union", "types": [ - 147, 148, - 149 + 149, + 150 ] }, { @@ -21183,12 +21264,12 @@ }, { "name": "kind", - "type": 152, + "type": 153, "optional": false }, { "name": "outcome", - "type": 153, + "type": 154, "optional": false } ], @@ -21212,7 +21293,7 @@ }, { "name": "kind", - "type": 155, + "type": 156, "optional": false } ], @@ -21235,6 +21316,11 @@ "type": 30, "optional": false }, + { + "name": "principal", + "type": 158, + "optional": true + }, { "name": "rpcId", "type": 5, @@ -21243,6 +21329,21 @@ ], "indices": [] }, + { + "kind": "object", + "properties": [ + { + "name": "kind", + "type": 159, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "owner" + }, { "kind": "object", "properties": [ @@ -21253,7 +21354,7 @@ }, { "name": "kind", - "type": 158, + "type": 161, "optional": false }, { @@ -21283,7 +21384,7 @@ }, { "name": "kind", - "type": 160, + "type": 163, "optional": false }, { @@ -21318,17 +21419,17 @@ "properties": [ { "name": "entries", - "type": 162, + "type": 165, "optional": false }, { "name": "form", - "type": 99, + "type": 100, "optional": false }, { "name": "kind", - "type": 164, + "type": 167, "optional": false }, { @@ -21341,7 +21442,7 @@ }, { "kind": "array", - "element": 163 + "element": 166 }, { "kind": "object", @@ -21368,12 +21469,12 @@ "properties": [ { "name": "form", - "type": 54, + "type": 56, "optional": false }, { "name": "kind", - "type": 166, + "type": 169, "optional": false }, { @@ -21393,22 +21494,22 @@ "properties": [ { "name": "form", - "type": 71, + "type": 72, "optional": false }, { "name": "kind", - "type": 168, + "type": 171, "optional": false }, { "name": "references", - "type": 169, + "type": 172, "optional": false }, { "name": "version", - "type": 174, + "type": 177, "optional": false } ], @@ -21420,7 +21521,7 @@ }, { "kind": "array", - "element": 170 + "element": 173 }, { "kind": "object", @@ -21432,12 +21533,12 @@ }, { "name": "capturedThroughSeq", - "type": 171, + "type": 174, "optional": false }, { "name": "compacted", - "type": 173, + "type": 176, "optional": false }, { @@ -21477,7 +21578,7 @@ }, { "name": "truncated", - "type": 173, + "type": 176, "optional": false } ], @@ -21487,7 +21588,7 @@ "kind": "union", "types": [ 18, - 172 + 175 ] }, { @@ -21507,17 +21608,17 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { "name": "kind", - "type": 55, + "type": 57, "optional": false }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21529,7 +21630,7 @@ }, { "kind": "array", - "element": 178 + "element": 181 }, { "kind": "object", @@ -21552,28 +21653,7 @@ "properties": [ { "name": "form", - "type": 176, - "optional": false - }, - { - "name": "kind", - "type": 57, - "optional": false - }, - { - "name": "sections", - "type": 177, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21583,7 +21663,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21594,7 +21674,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21604,7 +21684,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21615,7 +21695,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21625,7 +21705,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21636,7 +21716,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21646,7 +21726,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21657,7 +21737,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21667,7 +21747,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21678,7 +21758,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21688,7 +21768,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21699,7 +21779,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21709,7 +21789,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21720,7 +21800,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21730,7 +21810,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21741,7 +21821,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21751,7 +21831,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21762,7 +21842,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21772,7 +21852,7 @@ }, { "name": "sections", - "type": 177, + "type": 180, "optional": false } ], @@ -21783,7 +21863,7 @@ "properties": [ { "name": "form", - "type": 176, + "type": 179, "optional": false }, { @@ -21793,7 +21873,28 @@ }, { "name": "sections", - "type": 177, + "type": 180, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "form", + "type": 179, + "optional": false + }, + { + "name": "kind", + "type": 54, + "optional": false + }, + { + "name": "sections", + "type": 180, "optional": false } ], @@ -21809,7 +21910,7 @@ }, { "name": "kind", - "type": 192, + "type": 195, "optional": false }, { @@ -21834,12 +21935,12 @@ "properties": [ { "name": "form", - "type": 85, + "type": 86, "optional": false }, { "name": "kind", - "type": 194, + "type": 197, "optional": false }, { @@ -21854,27 +21955,6 @@ "kind": "literal", "value": "agent-message" }, - { - "kind": "object", - "properties": [ - { - "name": "form", - "type": 33, - "optional": false - }, - { - "name": "kind", - "type": 55, - "optional": false - }, - { - "name": "summary", - "type": 5, - "optional": false - } - ], - "indices": [] - }, { "kind": "object", "properties": [ @@ -21924,8 +22004,8 @@ { "kind": "union", "types": [ - 200, - 201 + 202, + 203 ] }, { @@ -21942,7 +22022,7 @@ }, { "name": "op", - "type": 148, + "type": 149, "optional": false }, { @@ -22303,6 +22383,15 @@ } ], "types": [ + { + "digest": "00c054d3018c198d92f459563331edd1385e58345c677bb499791df2da0ade9f", + "names": [ + "packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData" + ], + "sources": [ + "packages/session/session-title-llm/src/index.ts:37" + ] + }, { "digest": "0116b1f89c05221d56b0ba35c83d302bf4857bc7277d247e9bbaf74b27d2c66e", "names": [], @@ -22322,7 +22411,7 @@ "digest": "023a355b2be0d688fb6e4df8388dadb942761d46cfe96d0f499aae9631a159d4", "names": [], "sources": [ - "packages/core/session/src/types.ts:206" + "packages/core/session/src/types.ts:207" ] }, { @@ -22338,12 +22427,12 @@ "sources": [] }, { - "digest": "045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19", + "digest": "040cd65423bd64264d225504f1c572d239eeb1793d0c2856ad8e42bb6e1e148b", "names": [ - "packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData" + "packages/llm/llm/src/message.ts#Message" ], "sources": [ - "packages/session/session-title-llm/src/index.ts:37" + "packages/llm/llm/src/message.ts:196" ] }, { @@ -22366,9 +22455,14 @@ "packages/core/session/src/types.ts#SessionSeq" ], "sources": [ - "packages/core/session/src/types.ts:32" + "packages/core/session/src/types.ts:33" ] }, + { + "digest": "0755b7d1128319ba2e389619cbbf2c961ad648882b4e91da7f3e9582cc8a2034", + "names": [], + "sources": [] + }, { "digest": "07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235", "names": [ @@ -22403,7 +22497,7 @@ "digest": "0a30c3be336cdd4f8f34e66543a3d46d611c43986edd9b3ce6cbb9b721d3b5d3", "names": [], "sources": [ - "packages/core/session/src/types.ts:204" + "packages/core/session/src/types.ts:205" ] }, { @@ -22499,6 +22593,15 @@ "packages/attachment/attachment/src/types.ts:28" ] }, + { + "digest": "0f6265188dae7ba6faa26830a180bb4deb0760561f97ee0c9d399ccf6c9cf1f7", + "names": [ + "packages/llm/llm/src/message.ts#DeveloperMessage" + ], + "sources": [ + "packages/llm/llm/src/message.ts:157" + ] + }, { "digest": "0f8512903d94f57a4748fa1a2092e64342856796684e6b8343db685b192745ce", "names": [], @@ -22618,7 +22721,7 @@ "digest": "17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3", "names": [], "sources": [ - "packages/core/session/src/types.ts:427" + "packages/core/session/src/types.ts:428" ] }, { @@ -22675,7 +22778,7 @@ "packages/core/session/src/types.ts#SessionHeader" ], "sources": [ - "packages/core/session/src/types.ts:94" + "packages/core/session/src/types.ts:95" ] }, { @@ -22709,7 +22812,7 @@ "digest": "1ccb56061942f5ccb0d30c9e4f2883b9d115ff906560029de637b61d3cde896d", "names": [], "sources": [ - "packages/core/session/src/types.ts:341" + "packages/core/session/src/types.ts:342" ] }, { @@ -22788,16 +22891,7 @@ "packages/core/session/src/types.ts#TurnEndCancelCause" ], "sources": [ - "packages/core/session/src/types.ts:196" - ] - }, - { - "digest": "1fa156c85450ca31063d0e0d2853028e6dac9687f2d3191fa811648210118154", - "names": [ - "packages/llm/llm/src/message.ts#MessageSource" - ], - "sources": [ - "packages/llm/llm/src/message.ts:136" + "packages/core/session/src/types.ts:197" ] }, { @@ -22952,7 +23046,7 @@ ] }, { - "digest": "2ace3b8059b82b6d9837fed518ca97a99eb37c4482df7aad0e71c85489428ade", + "digest": "2ae8594a998c9862a597129e782277bcc518266712a703b5c3161bdf552d13b8", "names": [], "sources": [] }, @@ -22971,6 +23065,15 @@ "names": [], "sources": [] }, + { + "digest": "2cf075b013d363ad743b5a4982900678d108499c721bc11255137402003db711", + "names": [ + "packages/llm/llm/src/message.ts#UserMessage" + ], + "sources": [ + "packages/llm/llm/src/message.ts:162" + ] + }, { "digest": "2d11ca7b0d4493e244b74eba093227866b33249841e59a1e9bf56afed38604c3", "names": [], @@ -23029,11 +23132,6 @@ "packages/core/tools/src/types.ts:12" ] }, - { - "digest": "2fef90def36aa758ca096c5f6ad0e00dbb2101943e19ca54b05618801974894a", - "names": [], - "sources": [] - }, { "digest": "30d5a537b296685770b1241c4b4bbde19d5a31ff81aa23df73fab5e90b8fc65f", "names": [ @@ -23086,7 +23184,7 @@ "packages/core/session/src/types.ts#SurfaceOp" ], "sources": [ - "packages/core/session/src/types.ts:462" + "packages/core/session/src/types.ts:463" ] }, { @@ -23187,7 +23285,7 @@ "packages/core/session/src/types.ts#OptionalSessionSeq" ], "sources": [ - "packages/core/session/src/types.ts:65" + "packages/core/session/src/types.ts:66" ] }, { @@ -23310,6 +23408,11 @@ "names": [], "sources": [] }, + { + "digest": "454a0fa879e38076bdcdbd87ef561cd67d7e43e58a287cf7dbaa7b6cf7847020", + "names": [], + "sources": [] + }, { "digest": "45f2a07ea94573123b6ae14db787a40062dec3834f36d8be92c523d40ba28448", "names": [], @@ -23411,14 +23514,9 @@ "digest": "4bb451330ff699f36b45539ceb0eb2606ead72084e2c5bb637c36cc80e2252db", "names": [], "sources": [ - "packages/core/session/src/types.ts:193" + "packages/core/session/src/types.ts:194" ] }, - { - "digest": "4be8fd28fefa5534675ea9aabd0a24efc2838b989bbc74da20a1efbd0daede8c", - "names": [], - "sources": [] - }, { "digest": "4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b", "names": [ @@ -23448,10 +23546,10 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:408", + "packages/api/session-controller/src/types.ts:409", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", - "packages/core/session/src/types.ts:20", + "packages/core/session/src/types.ts:21", "packages/experimental/agent-team/src/types.ts:21", "packages/experimental/agent-team/src/types.ts:33", "packages/experimental/agent-team/src/types.ts:9", @@ -23570,7 +23668,7 @@ "digest": "5302e763151277614b97e77f00e3b326694c6c1247b916a20503c344dc970777", "names": [], "sources": [ - "packages/core/session/src/types.ts:375" + "packages/core/session/src/types.ts:376" ] }, { @@ -23579,7 +23677,7 @@ "packages/core/session/src/types.ts#RequestHeaderReason" ], "sources": [ - "packages/core/session/src/types.ts:273" + "packages/core/session/src/types.ts:274" ] }, { @@ -23596,7 +23694,7 @@ "digest": "54f9276a1617d7c12594722c89cfe864bcbe4517e9a6f9c98bab14f199a135e0", "names": [], "sources": [ - "packages/core/session/src/types.ts:355" + "packages/core/session/src/types.ts:356" ] }, { @@ -23613,7 +23711,7 @@ "digest": "56b7c047694875a843ba020dfea3c8380d54ae4ff88ab349e72004dc21e07c23", "names": [], "sources": [ - "packages/core/session/src/types.ts:221" + "packages/core/session/src/types.ts:222" ] }, { @@ -23675,13 +23773,6 @@ "names": [], "sources": [] }, - { - "digest": "5b55817a403569fbb2d379496df2e3f3f71107f6c577a218028ce187c60b61e6", - "names": [], - "sources": [ - "packages/core/session/src/types.ts:311" - ] - }, { "digest": "5b6fb1f226ff56402db08a6aae82af76100b14056271875f69ec526a1ae05d51", "names": [], @@ -23694,6 +23785,15 @@ "names": [], "sources": [] }, + { + "digest": "5bc2d7f02503fc6b3e1bb7e0b7fa22c0b9e4b9841fa3175b2ad8e607ae3525a8", + "names": [ + "packages/llm/llm/src/message.ts#MessageSource" + ], + "sources": [ + "packages/llm/llm/src/message.ts:136" + ] + }, { "digest": "5bc66269cfaf614c0ecda8ecb469f23101a7609793fc2b693ac23c4aecdb575e", "names": [], @@ -23904,13 +24004,6 @@ "names": [], "sources": [] }, - { - "digest": "6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7", - "names": [], - "sources": [ - "packages/api/session-controller/src/types.ts:413" - ] - }, { "digest": "6ab45ac76839722df912f3ba50f1c6b41f2d614bde11f1aabfae04117ccdc80d", "names": [], @@ -23934,14 +24027,14 @@ "digest": "6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5", "names": [], "sources": [ - "packages/core/session/src/types.ts:330" + "packages/core/session/src/types.ts:331" ] }, { "digest": "6c03546143cdf6ae88f1745ac98c5139efa1588d1415249bbc5f3104d32ab800", "names": [], "sources": [ - "packages/core/session/src/types.ts:191" + "packages/core/session/src/types.ts:192" ] }, { @@ -23953,7 +24046,7 @@ "digest": "6c5a37d42ebdb12db4410699259335f1e149e3a0260425684c00b153afc8990d", "names": [], "sources": [ - "packages/core/session/src/types.ts:192" + "packages/core/session/src/types.ts:193" ] }, { @@ -23971,6 +24064,13 @@ "names": [], "sources": [] }, + { + "digest": "6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def", + "names": [], + "sources": [ + "packages/api/session-controller/src/types.ts:418" + ] + }, { "digest": "6e52dd55a1d48954ed5464ec46fe34d9eb65f709baabc9561e7a279910b0c406", "names": [], @@ -24105,9 +24205,14 @@ "digest": "79650cfa10aa3c52440328d85e8a25f0ffadc8d7efd784e3c0819d8bb74a1985", "names": [], "sources": [ - "packages/core/session/src/types.ts:212" + "packages/core/session/src/types.ts:213" ] }, + { + "digest": "79a4c87f8bfb4d6d05cd9952b99210d6a7db3b478c4a01d94b4c50602308c056", + "names": [], + "sources": [] + }, { "digest": "79ddc907312f97cf9553d4841e8b3fc937097bd8d97900a790efad3c05255e99", "names": [], @@ -24269,13 +24374,6 @@ "names": [], "sources": [] }, - { - "digest": "83a665476a9ee9af05cb1fdded12dd647d1b97b491200d7f12ef1af7cdac4428", - "names": [], - "sources": [ - "packages/core/agent/src/types.ts:96" - ] - }, { "digest": "83d6916021eede8f2d83a1872716caa1efdec39a3681890996c55147187fdd12", "names": [], @@ -24378,15 +24476,6 @@ "packages/llm/llm/src/types.ts:472" ] }, - { - "digest": "89b2edff547be1ecfcd78ef18aeeb3cf507fcbf7d6093ec04fb111ef4c8e8a97", - "names": [ - "packages/llm/llm/src/message.ts#UserMessage" - ], - "sources": [ - "packages/llm/llm/src/message.ts:162" - ] - }, { "digest": "8a046b52331959aa3394680d6acbe09b8a5447c6b61375f263445bfcef19b7f1", "names": [], @@ -24545,6 +24634,11 @@ "packages/llm/llm/src/types.ts:470" ] }, + { + "digest": "95a3ffccbf3c8afcf9a2f3b355f724da8e07e98dc47ee741166dde7f9ea058bd", + "names": [], + "sources": [] + }, { "digest": "95c0e88fcf0bafb2087f912ad9e2369de6ae6530cc0c4c2772c96205b57f7f60", "names": [], @@ -24569,6 +24663,11 @@ "packages/core/agent/src/types.ts:39" ] }, + { + "digest": "97e0162f983c27456ec71269fb4976b1851588b8c1aeac5d071d43e11f95e03c", + "names": [], + "sources": [] + }, { "digest": "9890113e97dd3541787bfe6704f2b223d3a890b5325ca0acae2b0398909b413e", "names": [], @@ -24608,11 +24707,6 @@ "packages/web/web-search-deepseek/src/provider.ts:64" ] }, - { - "digest": "9a36727716d0e73f681f3b725e7f28f89417c68174c8af0ff2b1ef52b08326a0", - "names": [], - "sources": [] - }, { "digest": "9a410ddf56792b2b7cd4a224a2e54dc4dee30a8e8e6b1c2b9a1d19992b217adc", "names": [ @@ -24628,7 +24722,7 @@ "packages/core/session/src/types.ts#EpochHeader" ], "sources": [ - "packages/core/session/src/types.ts:240" + "packages/core/session/src/types.ts:241" ] }, { @@ -24797,7 +24891,7 @@ "digest": "a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735", "names": [], "sources": [ - "packages/core/session/src/types.ts:383", + "packages/core/session/src/types.ts:384", "packages/core/tools/src/types.ts:27" ] }, @@ -24827,6 +24921,11 @@ "packages/schedule/schedule/src/types.ts:173" ] }, + { + "digest": "a927d444c928ac0efd9dd8b2e7d7d04d711c264cb71578d81e0e2fd143ff669f", + "names": [], + "sources": [] + }, { "digest": "a99ec0993c62c058e50af9f68922202a3b54f5e259bc2abf1915a73381d2316d", "names": [], @@ -24879,7 +24978,7 @@ "digest": "ab7ec5f9eb2c6dfacca5da4c37cf7ba85d04172053ee6b1ebf1709ffcd99c1a7", "names": [], "sources": [ - "packages/core/session/src/types.ts:214", + "packages/core/session/src/types.ts:215", "packages/llm/llm/src/types.ts:159" ] }, @@ -24892,7 +24991,7 @@ "digest": "ad0c38a0adb82a94c925456df41586d9b5f9202fca18593eae2213d4445b4585", "names": [], "sources": [ - "packages/core/session/src/types.ts:390" + "packages/core/session/src/types.ts:391" ] }, { @@ -24916,7 +25015,7 @@ "packages/interaction/commands/src/types.ts#CommandSource" ], "sources": [ - "packages/core/session/src/types.ts:190", + "packages/core/session/src/types.ts:191", "packages/interaction/commands/src/types.ts:75", "packages/interaction/commands/src/types.ts:79", "packages/llm/llm/src/message.ts:111", @@ -24948,15 +25047,6 @@ "names": [], "sources": [] }, - { - "digest": "af99c95d46bed802870f1ebefe0ea11f92a03cb37b8e0cbe3f94b7aaaf509b08", - "names": [ - "packages/llm/llm/src/message.ts#DeveloperMessage" - ], - "sources": [ - "packages/llm/llm/src/message.ts:157" - ] - }, { "digest": "aff60ef947c18b012644852bdd0d5bfe72065e061dd563787c76c646b4a10f6b", "names": [], @@ -24987,7 +25077,7 @@ "packages/core/session/src/types.ts#RequestContext" ], "sources": [ - "packages/core/session/src/types.ts:254" + "packages/core/session/src/types.ts:255" ] }, { @@ -25031,11 +25121,6 @@ "packages/experimental/agent-team/src/types.ts:72" ] }, - { - "digest": "b33545b3244861bcbf73b407d24b53402710f5acacab7ae939537e5504a68dc6", - "names": [], - "sources": [] - }, { "digest": "b3681c026a0b98e98b103f057dfd217fbe904129c090efd668253ec9aea7fece", "names": [], @@ -25103,7 +25188,7 @@ "digest": "b6ef9c6210be8350cd465f485cf520031fffa6bf3809c61a1e713e834c5982d0", "names": [], "sources": [ - "packages/core/session/src/types.ts:202" + "packages/core/session/src/types.ts:203" ] }, { @@ -25181,11 +25266,6 @@ "names": [], "sources": [] }, - { - "digest": "ba5b10ea77d68f4a29439a9bd2a14f6a560acf55731749d57237757cc72fbc06", - "names": [], - "sources": [] - }, { "digest": "ba77b58995e36f14bb13886b0493d0a1a63d5db10b1a6cd9f6ac65a72cf832ec", "names": [], @@ -25225,7 +25305,7 @@ "digest": "bcf0caf62d964b2fcf5404bd5c909cfa9a21c3f3c96e6b7e36d9e33565223825", "names": [], "sources": [ - "packages/core/session/src/types.ts:464" + "packages/core/session/src/types.ts:465" ] }, { @@ -25464,15 +25544,6 @@ "names": [], "sources": [] }, - { - "digest": "d901c4f5e856591c3217af983caa15bba568d580700f0958bab56b88004eced9", - "names": [ - "packages/llm/llm/src/message.ts#Message" - ], - "sources": [ - "packages/llm/llm/src/message.ts:196" - ] - }, { "digest": "d9196086e41e4dd5c2ce87f5c25529c8112a1731d258cf0728747d41a9ee3241", "names": [], @@ -25561,6 +25632,16 @@ "names": [], "sources": [] }, + { + "digest": "deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5", + "names": [ + "packages/typert/protocol/src/types.ts#Principal" + ], + "sources": [ + "packages/typert/protocol/src/types.ts:457", + "packages/typert/protocol/src/types.ts:461" + ] + }, { "digest": "df5d9709b7db2a72510dc52052a23f0c38f39730af1504bab8bccabc14fae528", "names": [], @@ -25643,14 +25724,14 @@ "digest": "e481c46af3813e0010558e93f578b2986acd658404cec2d1679e3ac2a6c48654", "names": [], "sources": [ - "packages/core/session/src/types.ts:297" + "packages/core/session/src/types.ts:298" ] }, { "digest": "e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1", "names": [], "sources": [ - "packages/core/session/src/types.ts:361" + "packages/core/session/src/types.ts:362" ] }, { @@ -25725,7 +25806,7 @@ "digest": "eaa71be0fbf0f702df8ea6c11d0aa2f9782263d45a10d0fb14eda662f4bfe04c", "names": [], "sources": [ - "packages/core/session/src/types.ts:196" + "packages/core/session/src/types.ts:197" ] }, { @@ -25742,7 +25823,7 @@ "digest": "ec8b5ff7c77ca6893ac7ba5a0c5b2aefeb52f12c64450b7359660c2a7e8a53d7", "names": [], "sources": [ - "packages/core/session/src/types.ts:228" + "packages/core/session/src/types.ts:229" ] }, { @@ -25778,8 +25859,8 @@ "digest": "ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545", "names": [], "sources": [ - "packages/core/session/src/types.ts:299", - "packages/core/session/src/types.ts:301" + "packages/core/session/src/types.ts:300", + "packages/core/session/src/types.ts:302" ] }, { @@ -25803,7 +25884,7 @@ "packages/api/session-controller/src/types.ts#ModelSelection" ], "sources": [ - "packages/api/session-controller/src/types.ts:100" + "packages/api/session-controller/src/types.ts:101" ] }, { @@ -25812,7 +25893,7 @@ "packages/core/session/src/types.ts#TurnEndReason" ], "sources": [ - "packages/core/session/src/types.ts:232" + "packages/core/session/src/types.ts:233" ] }, { @@ -25869,6 +25950,13 @@ "names": [], "sources": [] }, + { + "digest": "f4ed4717b4c259e933b550a8dcd90dc93628cda8a3c7affcaa53b9c13c1d53eb", + "names": [], + "sources": [ + "packages/core/session/src/types.ts:312" + ] + }, { "digest": "f5520c1d261ca86ea03735799c311a0a9ede8babb588cf0a65422576d2ee0ad1", "names": [], @@ -25941,7 +26029,7 @@ "digest": "f90eb4ab2d3897bc20c521a038e3119d581f791ec974b1a0004f63a8ab9e232d", "names": [], "sources": [ - "packages/core/session/src/types.ts:288", + "packages/core/session/src/types.ts:289", "packages/deliverables/workspace-changes/src/types.ts:106" ] }, @@ -25955,6 +26043,13 @@ "names": [], "sources": [] }, + { + "digest": "fa106c5b33b657e8ef16e8cbf39f3b7d44e5cbcdf4f9303d9db6410f28951e42", + "names": [], + "sources": [ + "packages/core/agent/src/types.ts:96" + ] + }, { "digest": "fa87537f4a9c1e25602990a8bf686b383e9e00352ec8946e16ae24ba7c1625be", "names": [ diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index bb40e7f93f..4933734d57 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -848,12 +848,14 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** - * Search visible Session content without resuming an Agent. + * Search visible Session content without resuming an Agent. A non-owner + * receives only the hits whose Session `allows()` admits; `hasMore` + * describes the bounded result before that filtering. * @param request - literal message-content query. * @param signal - cancellation for list and search reads. * @returns authorized bounded Session search results. */ -@Remote('search') search(request: SessionSearchRequest, signal: AbortSignal): Promise +@Remote('search') async search(request: SessionSearchRequest, signal: AbortSignal): Promise /** * Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable. @@ -933,7 +935,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - caller cancellation before prompt admission begins. * @returns acknowledgement that the Agent accepted the prompt. */ -@Remote('prompt') prompt(request: SessionPromptRequest, signal: AbortSignal): Promise +@Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise /** * Read one image proven reachable from the addressed Session log. @@ -962,7 +964,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation for persistence reads. * @returns one chronological page. */ -@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise +@Remote({ name: 'page', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise /** * Follow one Session log from its opening or resume cursor. @@ -971,7 +973,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @returns a complete opening snapshot followed by gap-free durable event * frames and optional cursorless assistant-stream frames. */ -@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable /** * Read exact projections, returning cached hints only when migration is required. @@ -986,7 +988,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation owned by the Remote stream carrier. * @returns one complete baseline followed by live replacement frames. */ -@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) async *control(signal: AbortSignal): AsyncIterable ``` Types: [SessionId](core.md) · [SessionInspection](persistence.md) · [SessionSearchRequest](session-query.md) diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 298b704bde..a3e5446b61 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -852,12 +852,14 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise /** - * Search visible Session content without resuming an Agent. + * Search visible Session content without resuming an Agent. A non-owner + * receives only the hits whose Session `allows()` admits; `hasMore` + * describes the bounded result before that filtering. * @param request - literal message-content query. * @param signal - cancellation for list and search reads. * @returns authorized bounded Session search results. */ -@Remote('search') search(request: SessionSearchRequest, signal: AbortSignal): Promise +@Remote('search') async search(request: SessionSearchRequest, signal: AbortSignal): Promise /** * Create one ordinary Session for a new conversation, reusing the named blank Session while it is reusable. @@ -937,7 +939,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - caller cancellation before prompt admission begins. * @returns acknowledgement that the Agent accepted the prompt. */ -@Remote('prompt') prompt(request: SessionPromptRequest, signal: AbortSignal): Promise +@Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise /** * Read one image proven reachable from the addressed Session log. @@ -966,7 +968,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation for persistence reads. * @returns one chronological page. */ -@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise +@Remote({ name: 'page', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise /** * Follow one Session log from its opening or resume cursor. @@ -975,7 +977,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @returns a complete opening snapshot followed by gap-free durable event * frames and optional cursorless assistant-stream frames. */ -@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable /** * Read exact projections, returning cached hints only when migration is required. @@ -990,7 +992,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * @param signal - cancellation owned by the Remote stream carrier. * @returns one complete baseline followed by live replacement frames. */ -@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) async *control(signal: AbortSignal): AsyncIterable ``` Types: [SessionId](core.zh.md) · [SessionInspection](persistence.zh.md) · [SessionSearchRequest](session-query.zh.md) diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 1dd04a5ab9..5da22cf2d1 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -15,8 +15,8 @@ en: 9cb70abb2e39b958 zh: cd4b6f1705d90e17 /web-client-slots/framework-provided-hooks: - en: 2fcfc55fbac80f72 - zh: ea3016f394c56b07 + en: c9f8d836214623ae + zh: 76dec55006396535 /web-client-slots/developer-provided-injection: en: 3481dfa6cd3097da zh: acbd6f06a7da1752 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 30f8acedb4..12a8d89b8f 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -84,11 +84,12 @@ The shipped adapters add these standard props. They are available according to t | Availability | Props | Owner | |---|---|---| -| every scope | `useSessions`, `useSessionStatus`, `useSessionRetainInfo` | `ui-session` | -| every scope | `useWorkspaces` | `ui-workspace` | +| every scope | `useSessions`, `useSessionStatus`, `useSessionRetainInfo`, `useSessionAccess` | `ui-session` | +| every scope | `useWorkspaces`, `useWorkspaceAccess` | `ui-workspace` | +| every scope | `useRemoteAccess` (the `global` resource) | `api-remote-access` | | every scope | `usePanelInfo` | `ui-layout` | -| `session` | `sessionId`, `useSession`, `useProjection` | `ui-session` | -| `session-maybe` | optional `sessionId`, `useSession`, `useProjection` results | `ui-session` | +| `session` | `sessionId`, `useSession`, `useProjection`, `useAccess` (the bound Session's answer) | `ui-session` | +| `session-maybe` | optional `sessionId`, `useSession`, `useProjection`, `useAccess` results | `ui-session` | | `session` | `useConversation`, `useInput`, `inputActions` | `ui-conversation` | | `session-maybe` | optional `useConversation`, `useInput`, `inputActions` results | `ui-conversation` | | `session` | `useChat` | `ui-chat` | diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index a660f720e4..77f3717f36 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -86,11 +86,12 @@ Slot 声明固定两个相互独立的维度。 | 可用范围 | Props | Owner | |---|---|---| -| 所有 scope | `useSessions`、`useSessionStatus`、`useSessionRetainInfo` | `ui-session` | -| 所有 scope | `useWorkspaces` | `ui-workspace` | +| 所有 scope | `useSessions`、`useSessionStatus`、`useSessionRetainInfo`、`useSessionAccess` | `ui-session` | +| 所有 scope | `useWorkspaces`、`useWorkspaceAccess` | `ui-workspace` | +| 所有 scope | `useRemoteAccess`(`global` 资源) | `api-remote-access` | | 所有作用域 | `usePanelInfo` | `ui-layout` | -| `session` | `sessionId`、`useSession`、`useProjection` | `ui-session` | -| `session-maybe` | 结果可选的 `sessionId`、`useSession`、`useProjection` | `ui-session` | +| `session` | `sessionId`、`useSession`、`useProjection`、`useAccess`(所绑定 Session 的答案) | `ui-session` | +| `session-maybe` | 结果可选的 `sessionId`、`useSession`、`useProjection`、`useAccess` | `ui-session` | | `session` | `useConversation`、`useInput`、`inputActions` | `ui-conversation` | | `session-maybe` | 结果可选的 `useConversation`、`useInput`、`inputActions` | `ui-conversation` | | `session` | `useChat` | `ui-chat` | diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index 0030b50111..d6917635cf 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.i18n.yaml @@ -11,12 +11,15 @@ /typert-remote-calls/invocation-descriptors: en: 21e88d68154c2c92 zh: 8c9e05c65ef0f34d +/typert-remote-calls/remote-access-resources: + en: fa95f70437c07395 + zh: d4870b678b569a89 /typert-remote-calls/typert-registry: - en: 29f578ba733cfd5a - zh: c486ff4ac2d1e3b4 + en: 5827ef50011811c9 + zh: 21a9e7b368ba211a /typert-remote-calls/host-gateway: - en: 90fab88fe51d17bb - zh: 2d4c3849cf2dc7d3 + en: 58b923de12e48f18 + zh: d3c2372d8becf65e /typert-remote-calls/consumer-remote: en: 324386c600a5fdf5 zh: 7963501eb663e228 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index e9cabd714b..fb5bca48fd 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -200,13 +200,35 @@ interface RemoteStreamHandle extends AsyncIterable { ```ts type-equiv /** - * One Peer's session on this Host. Connection owns it: `ctx` is the Cordis + * 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. + */ +interface PrincipalMap { + /** The local user who owns this Host. */ + owner: { readonly kind: 'owner' } +} +``` + +```ts type-equiv +/** One caller identity; the authorization layer reads only this. */ +type Principal = PrincipalMap[keyof PrincipalMap] +``` + +```ts type-equiv +/** + * One Peer's session on this Host. Its creator owns it: `ctx` is the Cordis * scope that owns connection-lifetime registrations and is disposed with the - * Peer. Who the Peer is and what it may do are not recorded here. + * Peer, and `principal` is the caller identity fixed when the Peer was created. */ 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 /** * Tear down every registration made through `ctx`. * @returns settles once the scope has quiesced; racing calls share one completion. @@ -239,7 +261,7 @@ interface RemoteInvocation { } /** Cordis service key of the receiving Service. */ readonly service: string - /** Peer the call speaks for; an in-process carrier speaks for the operator. */ + /** Peer the call speaks for; every carrier names it explicitly. */ readonly peer: PeerScope /** Carrier cancellation: Client cancel, socket close, or an uplink failure. */ readonly signal: AbortSignal @@ -260,9 +282,81 @@ interface RemoteInvocation { } ``` +## Remote access resources + +Every Remote method is under the access control of [`dsh-api-remote-access`](../../packages/api/remote-access/README.md): a non-owner call is allowed only when the policy for its principal kind binds, on every resource the call touches, a role that lists the endpoint action. The package that owns an identity type merge-declares its resource kind in `TypertResourceMap`; `global`, the built-in identity-less resource, is never a kind there. The Gateway reads a kind's identities from the `resources` readers of `@Remote({ resources })` or `@RemoteScope(key, { resources })`, else from a lookup parameter or scoped Context that declares the kind as its `resource`, else from the default field registered in `typert.resources`; a call that names no resource touches `global`. The [Peer principals Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns the decision. + +```ts type-equiv +/** + * Merge-extensible resource kinds whose identities a Remote call or forwarded + * event may carry. The package that owns an identity type declares its kind; + * the value is that kind's identity type, a string type such as a branded id. + * `global` names the built-in identity-less resource and is never a kind here. + */ +interface TypertResourceMap {} +``` + +```ts type-equiv +/** One resource kind: the built-in `global` kind or a declared kind. */ +type RemoteAccessResourceKind = 'global' | TypertResourceKind +``` + +```ts type-equiv +/** + * One resource a call or forwarded event touches: `global`, or one identity + * of a declared kind. Identities are wire values, read before any lookup + * resolves or validates them. + */ +type RemoteAccessResource = + | { readonly kind: 'global' } + | { + [Kind in TypertResourceKind]: { readonly kind: Kind; readonly id: TypertResourceId } + }[TypertResourceKind] +``` + +```ts type-equiv +/** + * Explicit endpoint and resource choices for one Remote Scope method. + * @template WireArgs - the decorated method's positional parameters as the + * wire carries them; the default `never` admits readers of any tuple. + */ +interface RemoteScopeOptions { + /** Exported endpoint method; omission retains the implementation member name. */ + readonly name?: string + /** + * Per-kind readers of the resources one call touches. Each receives the + * method's arguments as its own parameters, JSON arguments as their Typert + * codecs validated them, a lookup parameter as its wire identity and a + * cancellation parameter as the call's signal, so it performs plain reads + * only, and returns one identity, several, or `undefined` for none. A + * throw, an empty list, or an identity other than a non-empty string + * refuses the call. For a kind without a reader, the Gateway reads the + * identity of a scoped Context whose Host adapter declares that `resource` + * and of lookup parameters declaring it, else the field registered in + * `typert.resources`: a top-level argument or, failing that, the first + * object argument. A call that carries no identity touches `global`. The + * readers and these defaults together must name every resource the method + * accesses: access control never checks a resource they omit. + */ + readonly resources?: TypertResourceReaders +} +``` + +```ts type-equiv +/** + * Explicit endpoint, stream, and resource choices for one direct Remote method. + * @template WireArgs - the decorated method's positional parameters as the + * wire carries them; the default `never` admits readers of any tuple. + */ +interface RemoteMethodOptions extends RemoteScopeOptions { + /** `stream`: deliver each Iterable item over the shared logical-stream carrier. */ + readonly mode?: 'stream' +} +``` + ## Typert registry -`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, and scoped Context providers. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers. +`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, scoped Context providers, and the default fields of resource kinds. A lookup provider owns the stable wire declaration and default resolver; Host composition can configure an effect-scoped synchronous or asynchronous resolver for the same key, and unloading that configuration restores the default policy. Registrations are Cordis-owned effects and return awaitable disposers. ```ts type-equiv /** Minimal Typert runtime consumed through dependency inversion. */ @@ -271,6 +365,7 @@ interface TypertRegistryContract { readonly remotes: TypertRemoteRegistry readonly lookups: TypertLookupRegistry readonly contexts: TypertContextRegistry + readonly resources: TypertResourceRegistry } ``` @@ -283,7 +378,7 @@ interface TypertRemoteNamespaceMap {} ## Host Gateway -Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields and the carrier's cancellation signal separately; infrastructure and boundary failures ride `TypertGatewayError`, whose `gateway/*` codes are ordinary `RemoteError` codes, so the RPC adapter passes every structurally identified `RemoteError` through with its code and details intact and folds only unrecognized exceptions into `gateway/internal`. +Connection decodes its carrier envelope before calling `ctx.typertGateway`. The request carries exact named wire fields, the carrier's cancellation signal, and the Peer the call speaks for separately; every carrier and in-process caller names that Peer, and the Gateway refuses a disposed one with `gateway/peer-disposed`. For a non-owner call the Gateway resolves its resources and asks `ctx.remoteAccess.decide()` after argument validation and before any lookup or Context resolution; infrastructure and boundary failures ride `TypertGatewayError`, whose `gateway/*` codes are ordinary `RemoteError` codes, so the RPC adapter passes every structurally identified `RemoteError` through with its code and details intact and folds only unrecognized exceptions into `gateway/internal`. ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -301,8 +396,8 @@ interface InvokeRemoteRequest { readonly uplink?: AsyncIterable /** Carrier-provided frame capacity; absent for carriers without an encoded-frame inbox limit. */ readonly uplinkWindow?: RemoteUplinkWindow | undefined - /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ - readonly peer?: PeerScope + /** Peer the call speaks for; every carrier and in-process caller names it explicitly. */ + readonly peer: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -324,6 +419,7 @@ type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/peer-disposed' | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' @@ -338,8 +434,8 @@ interface TypertGateway { /** Carrier adapter shared by WebSocket and in-process transports. */ readonly wireStream: TypertGatewayWireStream /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean /** @@ -404,6 +500,88 @@ interface TypertClientRemote extends TypertRemoteNamespaceMap { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.remoteAccess` — `RemoteAccessService` + +Registry of access policies, the Gateway's access decisions, list filtering, and the per-Peer access snapshot. Every composition that mounts the Gateway mounts this service too. + +```ts cordis-catalog +/** + * Register the policy of one non-owner principal kind in the caller's + * fiber; open snapshots are recomputed on registration and removal. + * @param kind - the principal kind the policy serves; never `owner`. + * @param policy - business-owned bindings and refusal reasons for principals of `kind`. + * @returns disposer removing this policy. + * @throws Error when `kind` already has a policy. + */ +registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void + +/** + * Report that the bindings or condition answers for `principal` may have + * changed. Business packages call this on every grant, revocation, mode + * change, and expiry; open snapshots re-push, and the next failure of each + * policy is logged again. + * @param principal - affected principal; omitted for every principal. + */ +invalidate(principal?: Principal): void + +/** + * Gateway-only: decide one request whose resources the Gateway resolved. + * The owner is allowed, and so is `remoteAccess/follow`: every Peer reads + * its own snapshot. Otherwise the action is allowed only when, on every + * listed resource, some binding of the principal's policy names that + * resource, has a role listing the action, and has no condition or a + * condition that allows. `bindings()` is called once per decision. + * @param request - the caller or recipient, the action, the resources it + * touches, and a call's arguments. + * @returns `allow`; or `deny` with `no-allow` when the principal kind has + * no policy, the first refusing condition's reason on the first resource + * no binding allows, else `explain()`'s reason, else `no-allow`; + * `policy-failed` when `bindings()`, a condition, or `explain()` throws, + * logged once per policy until the next `invalidate()`. The Gateway + * throws `remote-access/denied` for a denied call and drops a denied event. + */ +decide(request: RemoteAccessRequest): RemoteAccessDecision + +/** + * Filter items inside a Remote method. The invocation is read from the + * Context that accessed this service, so call it as + * `this.ctx.remoteAccess.allows(resource)` from the Remote method. Each + * check reads the policy's bindings again, so a long-running stream sees + * binding changes from the next check on. + * @param resource - resource of one result item. + * @returns true for the owner and outside a Remote call; otherwise whether + * the invocation's endpoint action is allowed on `resource` without call + * arguments, false when the policy is absent or fails. + */ +allows(resource: RemoteAccessResource): boolean + +/** + * The baseline a page opens with before its snapshot stream connects. A + * business package rendering a non-owner page assigns this value to + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, + * replacing the owner baseline the webServer index carries. + * @param principal - the page's caller identity. + * @returns the owner baseline for the owner; otherwise the principal's current grants. + */ +snapshotFor(principal: Principal): RemoteAccessBaseline + +/** + * Stream the calling Peer's access snapshot: one baseline, then `upsert` + * and `remove` frames after each `invalidate()` and each policy + * registration or removal that changes a grant. The owner receives only + * `{ owner: true }`. The generation ends when `signal` aborts or this + * service unloads, so a reloaded service starts every reader from a fresh + * baseline. + * @param signal - generation cancellation. + * @returns the access frames of one generation. + */ +@Remote({ mode: 'stream' }) async *follow(signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/remote-access/src/index.ts`](../../packages/api/remote-access/src/index.ts) + ### `ctx.typert` — `TypertRegistry` @@ -476,8 +654,8 @@ Resolve strict generated definitions or conservative SRC markers against current ```ts cordis-catalog /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index 287e81a5fa..11b3fd8dbb 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -200,13 +200,35 @@ interface RemoteStreamHandle extends AsyncIterable { ```ts type-equiv /** - * One Peer's session on this Host. Connection owns it: `ctx` is the Cordis + * 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. + */ +interface PrincipalMap { + /** The local user who owns this Host. */ + owner: { readonly kind: 'owner' } +} +``` + +```ts type-equiv +/** One caller identity; the authorization layer reads only this. */ +type Principal = PrincipalMap[keyof PrincipalMap] +``` + +```ts type-equiv +/** + * One Peer's session on this Host. Its creator owns it: `ctx` is the Cordis * scope that owns connection-lifetime registrations and is disposed with the - * Peer. Who the Peer is and what it may do are not recorded here. + * Peer, and `principal` is the caller identity fixed when the Peer was created. */ 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 /** * Tear down every registration made through `ctx`. * @returns settles once the scope has quiesced; racing calls share one completion. @@ -239,7 +261,7 @@ interface RemoteInvocation { } /** Cordis service key of the receiving Service. */ readonly service: string - /** Peer the call speaks for; an in-process carrier speaks for the operator. */ + /** Peer the call speaks for; every carrier names it explicitly. */ readonly peer: PeerScope /** Carrier cancellation: Client cancel, socket close, or an uplink failure. */ readonly signal: AbortSignal @@ -260,9 +282,81 @@ interface RemoteInvocation { } ``` +## Remote 访问资源 + +每个 Remote 方法都受 [`dsh-api-remote-access`](../../packages/api/remote-access/README.zh.md) 的访问控制:只有当该 principal 种类的 policy 在调用访问的每个资源上都绑定了列出该端点 action 的 role 时,非 owner 调用才被放行。拥有某个身份类型的包把其资源种类声明合并进 `TypertResourceMap`;内置的无身份资源 `global` 从不是其中的种类。Gateway 从 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` 的 `resources` 读取函数读取某个种类的身份,其次是把该种类声明为 `resource` 的 lookup 参数或作用域 Context,再次是在 `typert.resources` 中登记的默认字段;不指向任何资源的调用访问 `global`。决策由 [Peer 身份属性 Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 负责。 + +```ts type-equiv +/** + * Merge-extensible resource kinds whose identities a Remote call or forwarded + * event may carry. The package that owns an identity type declares its kind; + * the value is that kind's identity type, a string type such as a branded id. + * `global` names the built-in identity-less resource and is never a kind here. + */ +interface TypertResourceMap {} +``` + +```ts type-equiv +/** One resource kind: the built-in `global` kind or a declared kind. */ +type RemoteAccessResourceKind = 'global' | TypertResourceKind +``` + +```ts type-equiv +/** + * One resource a call or forwarded event touches: `global`, or one identity + * of a declared kind. Identities are wire values, read before any lookup + * resolves or validates them. + */ +type RemoteAccessResource = + | { readonly kind: 'global' } + | { + [Kind in TypertResourceKind]: { readonly kind: Kind; readonly id: TypertResourceId } + }[TypertResourceKind] +``` + +```ts type-equiv +/** + * Explicit endpoint and resource choices for one Remote Scope method. + * @template WireArgs - the decorated method's positional parameters as the + * wire carries them; the default `never` admits readers of any tuple. + */ +interface RemoteScopeOptions { + /** Exported endpoint method; omission retains the implementation member name. */ + readonly name?: string + /** + * Per-kind readers of the resources one call touches. Each receives the + * method's arguments as its own parameters, JSON arguments as their Typert + * codecs validated them, a lookup parameter as its wire identity and a + * cancellation parameter as the call's signal, so it performs plain reads + * only, and returns one identity, several, or `undefined` for none. A + * throw, an empty list, or an identity other than a non-empty string + * refuses the call. For a kind without a reader, the Gateway reads the + * identity of a scoped Context whose Host adapter declares that `resource` + * and of lookup parameters declaring it, else the field registered in + * `typert.resources`: a top-level argument or, failing that, the first + * object argument. A call that carries no identity touches `global`. The + * readers and these defaults together must name every resource the method + * accesses: access control never checks a resource they omit. + */ + readonly resources?: TypertResourceReaders +} +``` + +```ts type-equiv +/** + * Explicit endpoint, stream, and resource choices for one direct Remote method. + * @template WireArgs - the decorated method's positional parameters as the + * wire carries them; the default `never` admits readers of any tuple. + */ +interface RemoteMethodOptions extends RemoteScopeOptions { + /** `stream`: deliver each Iterable item over the shared logical-stream carrier. */ + readonly mode?: 'stream' +} +``` + ## Typert 注册表 -`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方与作用域上下文提供方。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 +`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方、作用域上下文提供方与资源种类的默认字段。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。 ```ts type-equiv /** Minimal Typert runtime consumed through dependency inversion. */ @@ -271,6 +365,7 @@ interface TypertRegistryContract { readonly remotes: TypertRemoteRegistry readonly lookups: TypertLookupRegistry readonly contexts: TypertContextRegistry + readonly resources: TypertResourceRegistry } ``` @@ -283,7 +378,7 @@ interface TypertRemoteNamespaceMap {} ## Host Gateway -Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段与 carrier 的取消 signal 分开携带;基础设施与边界失败由 `TypertGatewayError` 承载,其 `gateway/*` 码就是普通的 `RemoteError` 码,因此 RPC 适配器会把每个经结构识别的 `RemoteError` 连同其 code 与 details 原样放行,只把无法识别的异常归并为 `gateway/internal`。 +Connection 会先解码 carrier envelope,再调用 `ctx.typertGateway`。请求将精确的具名 wire 字段、carrier 的取消 signal 与本次调用代表的 Peer 分开携带;每个载体与进程内调用方都显式给出该 Peer,Gateway 以 `gateway/peer-disposed` 拒绝已 dispose 的 Peer。对非 owner 调用,Gateway 在参数校验之后、任何 lookup 或 Context 解析之前解析其资源并询问 `ctx.remoteAccess.decide()`;基础设施与边界失败由 `TypertGatewayError` 承载,其 `gateway/*` 码就是普通的 `RemoteError` 码,因此 RPC 适配器会把每个经结构识别的 `RemoteError` 连同其 code 与 details 原样放行,只把无法识别的异常归并为 `gateway/internal`。 ```ts type-equiv /** One Remote method request after a carrier has decoded its envelope. */ @@ -301,8 +396,8 @@ interface InvokeRemoteRequest { readonly uplink?: AsyncIterable /** Carrier-provided frame capacity; absent for carriers without an encoded-frame inbox limit. */ readonly uplinkWindow?: RemoteUplinkWindow | undefined - /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ - readonly peer?: PeerScope + /** Peer the call speaks for; every carrier and in-process caller names it explicitly. */ + readonly peer: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -324,6 +419,7 @@ type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/peer-disposed' | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' @@ -338,8 +434,8 @@ interface TypertGateway { /** Carrier adapter shared by WebSocket and in-process transports. */ readonly wireStream: TypertGatewayWireStream /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean /** @@ -404,6 +500,88 @@ interface TypertClientRemote extends TypertRemoteNamespaceMap { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.remoteAccess` — `RemoteAccessService` + +Registry of access policies, the Gateway's access decisions, list filtering, and the per-Peer access snapshot. Every composition that mounts the Gateway mounts this service too. + +```ts cordis-catalog +/** + * Register the policy of one non-owner principal kind in the caller's + * fiber; open snapshots are recomputed on registration and removal. + * @param kind - the principal kind the policy serves; never `owner`. + * @param policy - business-owned bindings and refusal reasons for principals of `kind`. + * @returns disposer removing this policy. + * @throws Error when `kind` already has a policy. + */ +registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void + +/** + * Report that the bindings or condition answers for `principal` may have + * changed. Business packages call this on every grant, revocation, mode + * change, and expiry; open snapshots re-push, and the next failure of each + * policy is logged again. + * @param principal - affected principal; omitted for every principal. + */ +invalidate(principal?: Principal): void + +/** + * Gateway-only: decide one request whose resources the Gateway resolved. + * The owner is allowed, and so is `remoteAccess/follow`: every Peer reads + * its own snapshot. Otherwise the action is allowed only when, on every + * listed resource, some binding of the principal's policy names that + * resource, has a role listing the action, and has no condition or a + * condition that allows. `bindings()` is called once per decision. + * @param request - the caller or recipient, the action, the resources it + * touches, and a call's arguments. + * @returns `allow`; or `deny` with `no-allow` when the principal kind has + * no policy, the first refusing condition's reason on the first resource + * no binding allows, else `explain()`'s reason, else `no-allow`; + * `policy-failed` when `bindings()`, a condition, or `explain()` throws, + * logged once per policy until the next `invalidate()`. The Gateway + * throws `remote-access/denied` for a denied call and drops a denied event. + */ +decide(request: RemoteAccessRequest): RemoteAccessDecision + +/** + * Filter items inside a Remote method. The invocation is read from the + * Context that accessed this service, so call it as + * `this.ctx.remoteAccess.allows(resource)` from the Remote method. Each + * check reads the policy's bindings again, so a long-running stream sees + * binding changes from the next check on. + * @param resource - resource of one result item. + * @returns true for the owner and outside a Remote call; otherwise whether + * the invocation's endpoint action is allowed on `resource` without call + * arguments, false when the policy is absent or fails. + */ +allows(resource: RemoteAccessResource): boolean + +/** + * The baseline a page opens with before its snapshot stream connects. A + * business package rendering a non-owner page assigns this value to + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, + * replacing the owner baseline the webServer index carries. + * @param principal - the page's caller identity. + * @returns the owner baseline for the owner; otherwise the principal's current grants. + */ +snapshotFor(principal: Principal): RemoteAccessBaseline + +/** + * Stream the calling Peer's access snapshot: one baseline, then `upsert` + * and `remove` frames after each `invalidate()` and each policy + * registration or removal that changes a grant. The owner receives only + * `{ owner: true }`. The generation ends when `signal` aborts or this + * service unloads, so a reloaded service starts every reader from a fresh + * baseline. + * @param signal - generation cancellation. + * @returns the access frames of one generation. + */ +@Remote({ mode: 'stream' }) async *follow(signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/remote-access/src/index.ts`](../../packages/api/remote-access/src/index.ts) + ### `ctx.typert` — `TypertRegistry` @@ -476,8 +654,8 @@ Resolve strict generated definitions or conservative SRC markers against current ```ts cordis-catalog /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index 29bb05c709..593dcc3494 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.i18n.yaml @@ -24,8 +24,8 @@ en: c063711f8247c240 zh: 5e54758bbaf1670c /web-client-architecture/conversation-and-presentation: - en: 71362f4ac9490bcb - zh: 025507ca615f5e2a + en: 9a22492f0d4359c6 + zh: 55d449e383e88f62 /web-client-architecture/data-paths: en: a254278432416473 zh: 567c2d3c6a20b304 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index 3f0d7ecf99..bb68cd64a1 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -57,7 +57,7 @@ This pairing is not a second source of business truth. Host controllers decide d Web and desktop share the [Coding Tools preference](../../packages/client/ui-settings/README.md#use-this-package). It controls diagnostic Views, [available preset choices](../../packages/client/ui-agent-preset/README.md), changed-file cards, and the builtin HTML preview policy without changing Session records. -`ui-session` installs the Session scope adapter and publishes `useSessions`, `useSessionStatus`, `useSessionRetainInfo`, `useSession`, `sessionId`, and `useProjection`. `SessionProvider` inherits an outer binding or binds an explicit `SessionReference`, so concurrent subtrees can target different Sessions. Domain adapters add further standard sources without putting React hooks on the model objects. +`ui-session` installs the Session scope adapter and publishes `useSessions`, `useSessionStatus`, `useSessionRetainInfo`, `useSessionAccess`, `useSession`, `sessionId`, `useProjection`, and the Session-scope `useAccess` over the [remote-access](../../packages/api/remote-access/README.md) Client service. `SessionProvider` inherits an outer binding or binds an explicit `SessionReference`, so concurrent subtrees can target different Sessions. Domain adapters add further standard sources without putting React hooks on the model objects. `ui-conversation` binds once to each `SessionBinding.eventSource`. Its event registry correlates durable Session events and Client-only `assistant/live-chunk` updates into stable business Contexts, and its view registry materializes target snapshots. Chat Assistant, Trajectory Assistant, and Turn Tail interpret both live chunks and the compact streams embedded in durable settlements, so reconnect and paged history reproduce the same Assistant state without durable token rows. `ui-chat` and `ui-trajectory` register separate Definitions and builders: they may interpret the same event family, but they do not import or share each other's final display model. The shell selects a registered view and passes its snapshot through standard hooks and Slots. [Conversation](conversation.md) defines Context identity, replay, Location data, target builders, and keyed renderers. diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index 5230eef917..a3d62f738f 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -57,7 +57,7 @@ Connection 拥有请求 URL 解析、request correlation、`/api` carrier、trus Web 和桌面端共享[代码工作工具偏好](../../packages/client/ui-settings/README.zh.md#use-this-package)。它控制诊断 View、[可用的预设选项](../../packages/client/ui-agent-preset/README.zh.md)、改动文件卡片和内置 HTML 预览策略,不改变 Session 记录。 -`ui-session` 安装 Session scope adapter,并提供 `useSessions`、`useSessionStatus`、`useSessionRetainInfo`、`useSession`、`sessionId` 和 `useProjection`。`SessionProvider` 可以继承外围 binding,也可以绑定显式 `SessionReference`,因此并存子树可以指向不同 Session。领域 adapter 可以继续添加标准 source,但不会把 React hook 放进 model object。 +`ui-session` 安装 Session scope adapter,并在 [remote-access](../../packages/api/remote-access/README.zh.md) Client 服务之上提供 `useSessions`、`useSessionStatus`、`useSessionRetainInfo`、`useSessionAccess`、`useSession`、`sessionId`、`useProjection` 和会话作用域的 `useAccess`。`SessionProvider` 可以继承外围 binding,也可以绑定显式 `SessionReference`,因此并存子树可以指向不同 Session。领域 adapter 可以继续添加标准 source,但不会把 React hook 放进 model object。 `ui-conversation` 对每个 `SessionBinding.eventSource` 只绑定一次。它的 event registry 把持久 Session event 与 Client-only `assistant/live-chunk` update 关联成稳定的业务 Context,view registry 则 materialize target snapshot。Chat Assistant、Trajectory Assistant 与 Turn Tail 同时解释 live chunk 和持久 settlement 中嵌入的紧凑 stream,因此重连与分页历史无需持久 token 行即可复现相同 Assistant 状态。`ui-chat` 与 `ui-trajectory` 分别注册自己的 Definition 和 builder:它们可以解释同一 event family,但不会导入或共享彼此的最终 display model。Shell 选择一个已注册 view,再通过标准 hook 与 Slot 交付其 snapshot。[Conversation](conversation.zh.md)定义 Context identity、replay、Location data、target builder 与 keyed renderer。 diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index 4fcb42f3cc..df76650336 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -116,13 +116,23 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection /** - * Admit one request: it passes {@link requestRejection} and speaks for the - * operator, or it is refused with that status. + * Admit one network request: it passes {@link requestRejection} and speaks + * for {@link browserPeer}, or it is refused with that status. * @param request - request headers from the HTTP or upgrade request. - * @returns the operator Peer, or the rejection status. + * @returns the admitted Peer, or the rejection status. */ admit(request: ConnectionTrustRequest): PeerAdmission +/** + * Create an independent Peer. In-process only: no route, header, query, or + * cookie reaches it. Disposed with the Context that read `ctx.connection`, + * or earlier by its own `dispose()`. + * @param options - caller identity the Peer speaks for. + * @param options.principal - principal fixed for the Peer's lifetime. + * @returns a new Peer distinct from every other Peer. + */ +createPeer(options: { readonly principal: Principal }): PeerScope + /** * Authenticate one frontend index request, owning a token redirect or 401. * The cookie this mints is `Secure` when the mounted Web carrier serves TLS, @@ -141,6 +151,8 @@ authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexRespons authenticatedUrl(baseUrl: string): string ``` +Types: [Principal](typert.md) + Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connection/src/rpc.ts) diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index 67d67b15de..ed53d0ec22 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -116,13 +116,23 @@ createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection /** - * Admit one request: it passes {@link requestRejection} and speaks for the - * operator, or it is refused with that status. + * Admit one network request: it passes {@link requestRejection} and speaks + * for {@link browserPeer}, or it is refused with that status. * @param request - request headers from the HTTP or upgrade request. - * @returns the operator Peer, or the rejection status. + * @returns the admitted Peer, or the rejection status. */ admit(request: ConnectionTrustRequest): PeerAdmission +/** + * Create an independent Peer. In-process only: no route, header, query, or + * cookie reaches it. Disposed with the Context that read `ctx.connection`, + * or earlier by its own `dispose()`. + * @param options - caller identity the Peer speaks for. + * @param options.principal - principal fixed for the Peer's lifetime. + * @returns a new Peer distinct from every other Peer. + */ +createPeer(options: { readonly principal: Principal }): PeerScope + /** * Authenticate one frontend index request, owning a token redirect or 401. * The cookie this mints is `Secure` when the mounted Web carrier serves TLS, @@ -141,6 +151,8 @@ authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexRespons authenticatedUrl(baseUrl: string): string ``` +Types: [Principal](typert.zh.md) + Source: [`packages/client/connection/src/rpc.ts`](../../packages/client/connection/src/rpc.ts) diff --git a/docs/subsystems/workspace.md b/docs/subsystems/workspace.md index 0736c86fe7..b363ba184d 100644 --- a/docs/subsystems/workspace.md +++ b/docs/subsystems/workspace.md @@ -387,11 +387,17 @@ Host service backing the generated `ctx.remote.workspace` namespace. @Remote('unpinSession') unpinSession(request: WorkspaceUnpinSessionRequest): Promise /** - * Stream a complete Workspace baseline followed by ordered increments. + * Stream a complete Workspace baseline followed by ordered increments. A + * non-owner receives only the Workspaces and Sessions `allows()` admits. + * When a delivered Workspace is no longer admitted at its next change or + * the next order change, the stream sends a `replace` frame with the + * complete filtered state instead of a `remove`, because a Client never + * resurrects a removed Workspace; a `remove` frame reports a deletion and + * reaches only a Client that received that Workspace. * @param signal - generation cancellation. * @returns baseline followed by ordered Workspace increments. */ -@Remote({ mode: 'stream' }) follow(signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) async *follow(signal: AbortSignal): AsyncIterable ``` Source: [`packages/api/workspace-controller/src/index.ts`](../../packages/api/workspace-controller/src/index.ts) diff --git a/docs/subsystems/workspace.zh.md b/docs/subsystems/workspace.zh.md index 1a366370e5..c04a05c97d 100644 --- a/docs/subsystems/workspace.zh.md +++ b/docs/subsystems/workspace.zh.md @@ -387,11 +387,17 @@ Host service backing the generated `ctx.remote.workspace` namespace. @Remote('unpinSession') unpinSession(request: WorkspaceUnpinSessionRequest): Promise /** - * Stream a complete Workspace baseline followed by ordered increments. + * Stream a complete Workspace baseline followed by ordered increments. A + * non-owner receives only the Workspaces and Sessions `allows()` admits. + * When a delivered Workspace is no longer admitted at its next change or + * the next order change, the stream sends a `replace` frame with the + * complete filtered state instead of a `remove`, because a Client never + * resurrects a removed Workspace; a `remove` frame reports a deletion and + * reaches only a Client that received that Workspace. * @param signal - generation cancellation. * @returns baseline followed by ordered Workspace increments. */ -@Remote({ mode: 'stream' }) follow(signal: AbortSignal): AsyncIterable +@Remote({ mode: 'stream' }) async *follow(signal: AbortSignal): AsyncIterable ``` Source: [`packages/api/workspace-controller/src/index.ts`](../../packages/api/workspace-controller/src/index.ts) diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index 7c3e86cc1c..bbe9d4ab52 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /api-remote-api-layers/packages: - en: 9bf51a5550fd4853 - zh: 4d13dd77d86f7f97 + en: 37388d921f2f47cf + zh: 24eb674c14dc2c6d /api-remote-api-layers/related-documentation: en: 507116ae09161df1 zh: f5e538ac5fdaf454 diff --git a/packages/api/README.md b/packages/api/README.md index 691a56856d..9f490b8134 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -28,6 +28,7 @@ The packages below provide the Remote layer; the package READMEs own the exhaust |---|---|---| | [`remotes/`](remotes/README.md) | Chooses which Host capabilities and events the Client can consume. | — | | [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams with their Client uplinks, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` | +| [`remote-access/`](remote-access/README.md) | Decides which Remote methods non-owner Peers may call, filters list results, and streams each Peer's access snapshot. | `ctx.remoteAccess` / `ctx.remote.remoteAccess` | | [`job-controller/`](job-controller/README.md) | Streams one background job's observation record to the Client. | `ctx.jobController` / `ctx.remote.job` | | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 9303fbaae7..3d7ae60113 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -28,6 +28,7 @@ kind: "package-group" |---|---|---| | [`remotes/`](remotes/README.zh.md) | 决定 Client 可以消费哪些 Host 能力与事件。 | — | | [`gateway/`](gateway/README.zh.md) | 承载类型化一元调用、带 Client 上行的多路复用流,以及转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | +| [`remote-access/`](remote-access/README.zh.md) | 决定非 owner Peer 可以调用哪些 Remote 方法,过滤列表结果,并为每个 Peer 推送其访问快照。 | `ctx.remoteAccess` / `ctx.remote.remoteAccess` | | [`job-controller/`](job-controller/README.zh.md) | 把一个后台任务的观测 record 流式送到 Client。 | `ctx.jobController` / `ctx.remote.job` | | [`session-controller/`](session-controller/README.zh.md) | 拥有会话命令、历史记录流、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 3c5a28123f..4b8cb2e1f4 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -15,11 +15,11 @@ en: d9859219cdb0f367 zh: 2a3cfa8cb786e327 /deepseek-ai-dsh-api-gateway/host-service-typertgatewayservice-ctx-key-typertgateway: - en: 2aabd26516a45771 - zh: 5907f472a4d536df + en: 6019f9b47d9ab474 + zh: b72d9c378bdafffb /deepseek-ai-dsh-api-gateway/client-service-clientremote-ctx-key-remote: en: c106f0ca3e547f84 - zh: 833e30d7151c41c7 + zh: 8eadd3bffde9e49d /deepseek-ai-dsh-api-gateway/model-experience: en: 6cb309d3378f035d zh: 998042a02ee32850 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index c212ec01ff..e31fa33e80 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -30,19 +30,23 @@ Strict mode reads generated invocation descriptors from `ctx.typert.local`. Look The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` is a `RemoteError` subclass whose `gateway/*` codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, codecs, and the uplink: `gateway/uplink-overflow` for an uplink whose buffered frames exceed `streamInboxBytes`, `gateway/protocol` for an uplink item after `end`, and the codec code `gateway/input-invalid` for a rejected uplink item. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own `RemoteError`, and the code it chose reaches the caller unchanged. +Every call names the Peer it speaks for: `InvokeRemoteRequest.peer` and the `peer` argument of `wireStream.open()` are required, and the Gateway never supplies a default Peer. Dispatch from a disposed Peer fails with `gateway/peer-disposed`, for unary calls, streams, `$events`, and `$events/result` alike, and wire paths check it before they read the payload. The Host service injects `remoteAccess` and does not start without [`dsh-api-remote-access`](../remote-access/README.md). After the endpoint and argument-field checks, every call validates its JSON arguments once against their Typert-compiled types, failing with `gateway/input-invalid`, and dispatch reuses the validated values. For a call whose Peer principal is not the owner, after that validation and before lookup and Context resolution, the Gateway resolves the resources the call touches and asks `ctx.remoteAccess.decide({ principal, action, resources, args })` with the endpoint as the action and the validated arguments as `args`, or refuses with reason `resource-invalid` without asking when the resources are invalid; a refused call throws `remote-access/denied` with details `{ action, reason }`, so a refused call fails before it activates any Session. An owner call is dispatched without resource resolution or a decision. + +Resources are read from the arguments validated against their Typert-compiled types, which drop object fields those types do not declare, while lookup and Context identities keep their wire values. For each resource kind, a reader in the method's `@Remote({ resources })` or `@RemoteScope(key, { resources })` options wins; it is called with the method's positional arguments, where lookup positions hold wire identities and the cancellation position holds the call's signal, and is read from the class metadata of the root Service instance. Otherwise every lookup parameter whose provider declares that kind through `resource` contributes its wire identity, an omitted optional one contributing none, and a Context method whose Host Context adapter declares that kind contributes its Context identity, read from the arguments before the Context is resolved. Otherwise the kind's default field registered in `typert.resources` is read from a top-level argument of that name or, failing that, from the first object argument. A reader that throws or returns an empty list, a default field holding an array, or an identity, including a Context identity, that is not a non-empty string makes the resolution `invalid`; a call that names no resource touches the `global` resource alone, and a call that names resources touches only those. A forwarded event carries every resource its entry declares, so there a reader returning `undefined` also makes the resolution `invalid`, while an entry without readers touches `global`. + A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A Pong, any received frame, or a downlink write that waited in the full local send buffer and then completed counts as peer activity; a socket with no activity across two Ping intervals is terminated, so a congested link that delays the Pong behind data stays connected. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. When the launcher supplies `ctx.appReady`, Gateway registers the WebSocket upgrade route only after successful application startup. Earlier connection attempts remain carrier failures handled by Connection's retry policy, so a restarting Host cannot accept streams while its controllers are still initializing. Unloading cancels a pending readiness subscription and closes any registered carrier. Embedded Hosts without `appReady` register immediately and own their startup ordering; in-process invocation is unchanged. -Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream, a top-level `undefined` item as an `item` frame without `value`, and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through unchecked. When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. `item`, `end`, and `cancel` frames for a stream id the Host has already finished are dropped; only a duplicate `open` closes the socket. Gateway-owned streams such as `$events` return their uplink as soon as they open, so their `item` frames are dropped instead of buffered. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for the operator Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the operator directly. +Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream, a top-level `undefined` item as an `item` frame without `value`, and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through unchecked. When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. `item`, `end`, and `cancel` frames for a stream id the Host has already finished are dropped; only a duplicate `open` closes the socket. Gateway-owned streams such as `$events` return their uplink as soon as they open, so their `item` frames are dropped instead of buffered. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for an explicitly named Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the Peer its caller passes. A stream method's `signal` also aborts when that Peer is disposed, so the stream ends with `gateway/cancelled`, and a `$events` stream ends with its Peer; a unary call already running is not cancelled. Stream cancellation and stopping keep read-local state, not a history of delivered items, on the Host downlink, Host uplink, and Client uplink pump. Completing the downlink also wakes pending uplink reads. -Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. +Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. A `$events` stream whose Peer principal is not the owner never receives a scoped waterfall it could answer; it receives a broadcast emit frame, whole and untrimmed, only when `ctx.remoteAccess.decide({ principal, action: 'event:', resources })` allows it on the resources the frame's readers name, or on `global` for a frame without readers; the resources are resolved once per frame, a frame whose resources are invalid reaches no non-owner stream without a decision, and a denied frame is dropped for that stream alone. An `$events/result` reply from such a Peer fails with `gateway/invocation-unavailable`. -`hasLiveClient()` checks the existing `$events` records for an uncancelled stream. A cancelled stream does not count even while its iterator is still awaiting cleanup; a bare WebSocket does not count. This synchronous observation does not guarantee delivery or Client provider readiness. +`hasLiveClient()` checks the existing `$events` records for an uncancelled stream whose Peer principal is the owner, the only kind that can answer a forwarded waterfall. A cancelled stream does not count even while its iterator is still awaiting cleanup; a bare WebSocket does not count. This synchronous observation does not guarantee delivery or Client provider readiness. ## Client service: `ClientRemote` (ctx key: `remote`) diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 1c3a9a2048..4160546db6 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -30,19 +30,23 @@ kind: "package-reference" Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 是 `RemoteError` 的子类,其 `gateway/*` 码命名了分发、绑定、提供方、查找、Context、参数、编解码器以及上行各自负责的故障:缓冲帧超过 `streamInboxBytes` 的上行报 `gateway/uplink-overflow`,`end` 之后的上行项报 `gateway/protocol`,被 codec 拒绝的上行项报编解码码 `gateway/input-invalid`。因策略而拒绝的解析器——冷恢复失败或 ownership fence——抛出自己的 `RemoteError`,它选定的码原样到达调用方。 +每次调用都指明它所代表的 Peer:`InvokeRemoteRequest.peer` 与 `wireStream.open()` 的 `peer` 参数都是必填项,Gateway 从不提供默认 Peer。来自已释放 Peer 的分发以 `gateway/peer-disposed` 失败,一元调用、流、`$events` 与 `$events/result` 一律如此,wire 路径在读取 payload 之前检查。Host 服务注入 `remoteAccess`,没有 [`dsh-api-remote-access`](../remote-access/README.zh.md) 就不启动。每次调用在 endpoint 与参数字段检查之后按 Typert 编译类型校验一次 JSON 参数,失败为 `gateway/input-invalid`,分派复用校验后的值。对 Peer 的 principal 不是 owner 的调用,Gateway 在该校验之后、lookup 与 Context 解析之前解析调用访问的资源,并以端点为 action、校验后的参数为 `args` 询问 `ctx.remoteAccess.decide({ principal, action, resources, args })`,资源非法时不询问而以原因 `resource-invalid` 拒绝;被拒的调用抛出 details 为 `{ action, reason }` 的 `remote-access/denied`,因此被拒绝的调用在激活任何 Session 之前就失败。owner 的调用不解析资源、不询问判定即分派。 + +资源从经 Typert 编译类型校验后的参数中读取,校验会移除类型未声明的对象字段,lookup 与 Context 身份保持 wire 值。对每个资源种类,方法 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` options 中的读取函数优先;它以方法的位置参数调用(lookup 位为 wire 身份,取消参数位为调用的 signal),从根 Service 实例的类元数据读取。否则 provider 经 `resource` 声明了该种类的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Host Context adapter 声明了该种类的 Context 方法贡献其 Context 身份,在解析 Context 之前直接从参数读取。再否则从同名顶层参数读取该种类在 `typert.resources` 中登记的默认字段,没有同名参数时读第一个对象参数的同名字段。读取函数抛错或返回空列表、默认字段读到数组、或某个身份(含 Context 身份)不是非空字符串时,解析结果为 `invalid`;不指明任何资源的调用只访问 `global` 资源,指明了资源的调用只访问这些资源。转发事件携带其条目声明的每个资源,因此在事件路径上读取函数返回 `undefined` 同样使解析结果为 `invalid`,而没有读取函数的条目访问 `global`。 + 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。Pong、收到的任意帧,或曾在已满的本机发送缓冲区中等待后完成的下行写入,都视为对端活动;连续两个 Ping 间隔没有活动的 socket 会被终止,因此 Pong 被数据阻塞在拥塞链路上时连接仍会保持。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 启动器提供 `ctx.appReady` 时,Gateway 仅在应用成功启动后注册 WebSocket 升级路由。此前的连接尝试仍属于载体故障,由 Connection 的重试策略处理,因此重启中的 Host 不会在控制器仍在初始化时接受流。卸载会取消尚未触发的就绪订阅,并关闭已注册的载体。不提供 `appReady` 的嵌入式 Host 会立即注册,并自行负责启动顺序;进程内调用不变。 -每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送(顶层 `undefined` 项是不带 `value` 的 `item` 帧),以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,不经校验原样透传。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。发给 Host 已结束的流 id 的 `item`、`end` 与 `cancel` 帧被丢弃;只有重复的 `open` 会关闭 socket。`$events` 这类 Gateway 自有的流在打开时立即 return 其 uplink,因此它们的 `item` 帧被丢弃而不缓冲。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表操作者 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流直接代表操作者。 +每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送(顶层 `undefined` 项是不带 `value` 的 `item` 帧),以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,不经校验原样透传。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。发给 Host 已结束的流 id 的 `item`、`end` 与 `cancel` 帧被丢弃;只有重复的 `open` 会关闭 socket。`$events` 这类 Gateway 自有的流在打开时立即 return 其 uplink,因此它们的 `item` 帧被丢弃而不缓冲。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表一个显式指明的 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流代表其调用方传入的 Peer。该 Peer 释放时,流方法的 `signal` 也随之中止,流以 `gateway/cancelled` 结束,`$events` 流也随其 Peer 结束;已在运行的一元调用不会被取消。 Host 下行、Host 上行和 Client 上行泵的取消与停止状态归属单次读取,不保留已交付项的历史。下行结束也会唤醒尚未完成的上行读取。 -Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。 +Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。Peer 的 principal 不是 owner 的 `$events` 流从不收到它可以作答的 scoped waterfall(瀑布式事件);只有当 `ctx.remoteAccess.decide({ principal, action: 'event:', resources })` 在该帧读取函数指明的资源上(帧没有读取函数时为 `global`)允许时,才整条、不裁剪地收到广播 emit 帧;每帧只解析一次资源,资源非法的帧不经判定即不送达任何非 owner 流,被拒的帧只对该流丢弃。这类 Peer 的 `$events/result` 回复以 `gateway/invocation-unavailable` 失败。 -`hasLiveClient()` 检查已有 `$events` 记录中是否有未取消的流。已取消的流即使尚未完成 iterator 清理也不计入,单独的 WebSocket 也不计入。这一同步观察不保证后续投递成功或 Client Provider 已就绪。 +`hasLiveClient()` 检查已有 `$events` 记录中是否有 Peer principal 为 owner 的未取消流,只有这类流能回答转发的 waterfall。已取消的流即使尚未完成 iterator 清理也不计入,单独的 WebSocket 也不计入。这一同步观察不保证后续投递成功或 Client Provider 已就绪。 ## Client 服务:`ClientRemote`(ctx key:`remote`) @@ -59,7 +63,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source `ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。一切终态失败离开本面时都是 `RemoteError`,包括重试耗尽和在 opening value 之前就结束的代次,因此流消费方与一元调用方用同一种方式判别。`RemoteStreamCarrierError` 命名的是可重试的物理丢失,它只作为 `carrierFailed` 回调参数到达领域,绝不作为终态结果。`RemoteSnapshotStream` 在此之上规定每代由一个初始快照和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。领域还可以携带无 cursor 的通知:通知绝不推进或修复持久 cursor,在缺口修复期间收到的通知只会在 replacement page 提交后发布。若更新代次取代该修复,旧代次 held notification 会与其 page 一同丢弃。对任一种流执行 dispose(资源释放)时,系统会取消该流的请求,并在活动 iterator 完全停止后完成资源释放。 -`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属调用方 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,无论当前是否存在 `$on` listener。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote 流故障、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 按持续且间隔封顶的带抖动指数退避重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall(瀑布式事件)允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 +`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属调用方 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,无论当前是否存在 `$on` listener。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote 流故障、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 按持续且间隔封顶的带抖动指数退避重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 Client waterfall 的 Context 解析保持同步。解析器可以返回借用的 Context 或 `TypertOwnedValue`;Gateway 仅在处理器使用和回复结算均结束后释放 owned value。Context 解析失败保留既有的记录错误并委托语义,处理器失败产生拒绝回复。取消会抑制迟到回复,但不会释放处理器仍在使用的 Context。每个 handler 都必须响应 `request.signal` 并在取消后结束;插件销毁与 Connection generation 替换会等待未结束的 handler 结算。Session Context 的获取本身不执行历史 I/O。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index d4469cf8cd..af26594447 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -64,10 +64,10 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-client-connection": "workspace:*" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-cmdline": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", diff --git a/packages/api/gateway/src/access.ts b/packages/api/gateway/src/access.ts new file mode 100644 index 0000000000..d85ebb3e65 --- /dev/null +++ b/packages/api/gateway/src/access.ts @@ -0,0 +1,172 @@ +/** + * Resource resolution for non-owner access decisions: the resources one call + * or one forwarded event touches, read from Typert-validated arguments before + * any lookup or Context resolves them. + * @module @deepseek-ai/dsh-api-gateway/src/access + */ + +import type { + InvocationDescriptor, + RemoteAccessResource, + TypertRegistryContract, + TypertResourceReader, +} from '@deepseek-ai/dsh-typert-protocol' + +const GLOBAL: readonly RemoteAccessResource[] = Object.freeze([Object.freeze({ kind: 'global' })]) + +/** Resource ids one source produced for a kind, or `invalid` for a malformed value. */ +type ResourceIds = readonly unknown[] | 'invalid' + +/** + * Resolve the resources one call touches. For each resource kind, a + * `resources` reader of the method wins; otherwise every lookup parameter + * whose provider declares the kind contributes its wire resource id, and so + * does the Context identity of a Context method whose Host adapter declares + * the kind; otherwise the kind's default field registered in + * `typert.resources` is read from a top-level argument or, failing that, + * from the first object argument. + * @param typert - registry holding default fields, lookup providers, and Host Context adapters. + * @param readers - the method's `resources` readers, or `undefined` when it declares none. + * @param descriptor - receiver, parameter order, lookups, and cancellation slot of the method. + * @param args - named arguments matched to the descriptor's field set: JSON + * arguments as their Typert codecs validated them, lookup and Context + * identities as wire values. + * @param signal - value placed in the cancellation slot of the positional arguments. + * @returns the touched resources, the global resource alone when none; + * `undefined` when the resources are invalid: a reader throws or returns + * an empty list, a default field holds an array, or a resource id is not a + * non-empty string. + */ +export function callResources( + typert: Pick, + readers: Readonly> | undefined, + descriptor: Pick, + args: Readonly>, + signal: AbortSignal, +): readonly RemoteAccessResource[] | undefined { + // Each later source replaces an earlier one for its kind and keeps the kind's position. + const sources = new Map ResourceIds>() + for (const { kind, field } of typert.resources.entries()) { + sources.set(kind, () => { + // A default field names exactly one resource id; only a reader may return several. + const id = defaultFieldId(descriptor, args, field) + return Array.isArray(id) ? 'invalid' : id === undefined ? [] : [id] + }) + } + for (const [kind, ids] of lookupResources(typert, descriptor, args)) { + sources.set(kind, () => ids) + } + let positional: readonly unknown[] | undefined + for (const [kind, reader] of Object.entries(readers ?? {})) { + sources.set(kind, () => { + positional ??= [ + ...descriptor.parameters.map(parameter => args[parameter.wire]), + ...descriptor.cancellation === undefined ? [] : [signal], + ] + return read(reader, positional, []) + }) + } + const resources: RemoteAccessResource[] = [] + for (const [kind, ids] of sources) { + if (!collect(resources, kind, ids())) return undefined + } + return resources.length === 0 ? GLOBAL : resources +} + +/** + * Resolve the resources one forwarded event notification touches. An event + * carries every resource it declares, so a reader returning `undefined` + * makes the notification invalid instead of a global one. + * @param readers - the event entry's declared readers, or `undefined` when it declares none. + * @param args - the event's validated JSON arguments, passed to each reader as its positional arguments. + * @returns the global resource alone without readers, including an empty + * reader set; otherwise the touched resources; `undefined` when the + * resources are invalid: a resource id is missing or malformed. + */ +export function eventResources( + readers: Readonly> | undefined, + args: readonly unknown[], +): readonly RemoteAccessResource[] | undefined { + const declared = Object.entries(readers ?? {}) + if (declared.length === 0) return GLOBAL + const resources: RemoteAccessResource[] = [] + for (const [kind, reader] of declared) { + if (!collect(resources, kind, read(reader, args, 'invalid'))) return undefined + } + return resources +} + +/** + * The resource ids lookup parameters and the Context identity name, per + * resource kind: the Context identity first, then lookup parameters in order. + * An optional lookup parameter the call omits names no resource id; the + * Context identity is required. + */ +function lookupResources( + typert: Pick, + descriptor: Pick, + args: Readonly>, +): ReadonlyMap { + const ids = new Map() + const add = (kind: string, id: unknown): void => { + const list = ids.get(kind) + if (list === undefined) ids.set(kind, [id]) + else list.push(id) + } + const receiver = descriptor.invocation + if (receiver.kind === 'context') { + const kind = typert.contexts.getHost(receiver.context)?.resource + // oxlint-disable-next-line typescript/no-unnecessary-condition -- domain packages merge resource kinds into TypertResourceMap. + if (kind !== undefined) add(kind, args[receiver.wire]) + } + for (const parameter of descriptor.parameters) { + if (parameter.lookup === undefined) continue + const kind = typert.lookups.get(parameter.lookup)?.resource + const id = args[parameter.wire] + // oxlint-disable-next-line typescript/no-unnecessary-condition -- domain packages merge resource kinds into TypertResourceMap. + if (kind !== undefined && id !== undefined) add(kind, id) + } + return ids +} + +/** Run one reader with spread positional arguments: `undefined` yields `absent`, a list names several and must not be empty. */ +function read(reader: TypertResourceReader, args: readonly unknown[], absent: ResourceIds): ResourceIds { + let value: unknown + try { + value = reader(...args) + } catch { + // Any reader error means it cannot name its resources, so they are invalid. + return 'invalid' + } + if (value === undefined) return absent + if (!Array.isArray(value)) return [value] + return value.length === 0 ? 'invalid' : value +} + +/** Append one kind's resource ids; false when any is malformed. */ +function collect(resources: RemoteAccessResource[], kind: string, ids: ResourceIds): boolean { + if (ids === 'invalid') return false + for (const id of ids) { + if (typeof id !== 'string' || id.length === 0) return false + // Kinds come from registrations, lookups, Context adapters, and reader keys, all typed by the merged resource map. + resources.push({ kind, id } as RemoteAccessResource) + } + return true +} + +/** The default field: a top-level argument, else that field of the first object argument. */ +function defaultFieldId( + descriptor: Pick, + args: Readonly>, + field: string, +): unknown { + if (descriptor.parameters.some(parameter => parameter.wire === field)) return args[field] + for (const parameter of descriptor.parameters) { + const value = args[parameter.wire] + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue + if (!Object.hasOwn(value, field)) return undefined + const id: unknown = Reflect.get(value, field) + return id + } + return undefined +} diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 877dd83194..294fe78698 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -7,11 +7,12 @@ import { randomUUID } from 'node:crypto' import { Context, Service, symbols } from '@deepseek-ai/cordis' -import { - OperatorPeer, - type ConnectionRpcAttachment, - type ConnectionRpcHandler, +import type { + ConnectionRpcAttachment, + ConnectionRpcHandler, } from '@deepseek-ai/dsh-client-connection' +import type { RemoteAccessDecision } from '@deepseek-ai/dsh-api-remote-access' +import { callResources, eventResources } from './access.ts' import { Deque } from '@deepseek-ai/dsh-deque' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -26,7 +27,10 @@ import { type InvocationDescriptor, type InvocationParameterDescriptor, type PeerScope, + type Principal, + type RemoteAccessResource, type RemoteInvocation, + type RemoteMethodMarker, type TypertCodec, type TypertGatewayBinding, } from '@deepseek-ai/dsh-typert-protocol' @@ -121,6 +125,10 @@ interface RemoteEventClient { readonly queue: RemoteEventQueue readonly signal: AbortSignal readonly deliveries: Map + /** The stream's Peer principal: only the owner answers scoped waterfalls and receives every broadcast. */ + readonly principal: Principal + /** Only owner streams receive scoped waterfalls they could answer; other Peers receive admitted broadcasts only. */ + readonly answers: boolean } interface PendingRemoteEvent { @@ -198,7 +206,7 @@ export class TypertGatewayError extends RemoteError { * @typert service typertGateway */ export class TypertGatewayService extends Service implements TypertGateway { - static inject = ['typert'] + static inject = ['typert', 'remoteAccess'] static Config: z = z.object({ websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS) .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS), @@ -213,7 +221,6 @@ export class TypertGatewayService extends Service implements TypertGateway { } private srcClaims: ReadonlySet | undefined - private inProcessOperator: PeerScope | undefined private remoteEvents: RegisteredRemoteEventSource | undefined private readonly remoteEventClients = new Map() private readonly pendingRemoteEvents = new Map() @@ -279,12 +286,12 @@ export class TypertGatewayService extends Service implements TypertGateway { } /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean { for (const client of this.remoteEventClients.values()) { - if (!client.signal.aborted) return true + if (client.answers && !client.signal.aborted) return true } return false } @@ -332,6 +339,18 @@ export class TypertGatewayService extends Service implements TypertGateway { return this.srcClaims.has(endpoint) } + /** + * The `resources` readers of a method, read from the root Service's class + * metadata so a Context method's receiver is not resolved before access is + * decided; `undefined` when the Service is absent or the method declares none. + */ + private resourceReaders(descriptor: InvocationDescriptor): RemoteMethodMarker['resources'] { + const receiver: unknown = this.ctx.get(descriptor.service) + if (!isObject(receiver)) return undefined + const member = descriptor.implementation ?? descriptor.method + return remoteMethods(originalOf(receiver)).find(marker => marker.method === member)?.resources + } + private collectSrcClaims(): ReadonlySet { const claims = new Set() for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { @@ -430,6 +449,11 @@ export class TypertGatewayService extends Service implements TypertGateway { ): Promise { if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) { try { + assertPeerLive(peer, endpoint) + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (peer.principal.kind !== 'owner') { + throw new TypertGatewayError('gateway/invocation-unavailable', endpoint, 'only owner Peers answer forwarded Remote events') + } const result = parseRemoteEventResultPayload(payload) const client = this.remoteEventClients.get(result.clientId) if (client === undefined) { @@ -448,7 +472,7 @@ export class TypertGatewayService extends Service implements TypertGateway { endpoint: string, payload: unknown, uplink: AsyncIterable, - peer: PeerScope | undefined, + peer: PeerScope, signal: AbortSignal, control: AbortController, uplinkWindow?: RemoteInvocation['uplinkWindow'], @@ -456,27 +480,16 @@ export class TypertGatewayService extends Service implements TypertGateway { if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) { // A Gateway-owned stream reads no uplink: releasing it now keeps its items out of the bounded inbox. releaseUplink(uplink) - return this.openRemoteEvents(payload, signal) + assertPeerLive(peer, endpoint) + return this.openRemoteEvents(payload, signal, peer) } return this.openStream({ ...remoteRequest(endpoint, payload, signal, peer), uplink, uplinkWindow }, control) } - /** - * The Peer an in-process carrier speaks for when it names none: the - * operator's Peer when Connection is mounted, otherwise an operator scope the - * Gateway owns for its own lifetime. - * @returns the operator Peer. - */ - private operatorPeer(): PeerScope { - const connection = this.ctx.get('connection') - if (connection !== undefined) return connection.operator - this.inProcessOperator ??= new OperatorPeer(this.ctx) - return this.inProcessOperator - } - private async *openRemoteEvents( payload: unknown, signal: AbortSignal, + peer: PeerScope, ): AsyncGenerator< RemoteEventEmitFrame | RemoteEventInvocationFrame | RemoteEventCancellationFrame | RemoteEventReadyFrame @@ -502,7 +515,7 @@ export class TypertGatewayService extends Service implements TypertGateway { 'forwarded Remote event source is unavailable', ) } - const lifetime = AbortSignal.any([signal, registration.lifetime.signal]) + const lifetime = AbortSignal.any([signal, registration.lifetime.signal, peer.signal]) let clientId = randomUUID() as RemoteEventClientId while (this.remoteEventClients.has(clientId)) clientId = randomUUID() as RemoteEventClientId const client: RemoteEventClient = { @@ -510,9 +523,14 @@ export class TypertGatewayService extends Service implements TypertGateway { queue: new RemoteEventQueue(), signal: lifetime, deliveries: new Map(), + principal: peer.principal, + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + answers: peer.principal.kind === 'owner', } this.remoteEventClients.set(clientId, client) - for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client) + if (client.answers) { + for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client) + } try { yield { ...REMOTE_EVENT_STREAM_READY, clientId, host: registration.host } yield* client.queue.iterate(lifetime) @@ -545,7 +563,21 @@ export class TypertGatewayService extends Service implements TypertGateway { event: frame.event, args: frame.args, } - for (const client of this.remoteEventClients.values()) client.queue.push(wire) + let resolved = false + let resources: readonly RemoteAccessResource[] | undefined + for (const client of this.remoteEventClients.values()) { + if (!client.answers) { + // A non-owner receives the whole notification or nothing: its resources decide, payloads are never trimmed, + // and a notification with invalid resources reaches no non-owner. + if (!resolved) { + resources = eventResources(frame.resources, frame.args) + resolved = true + } + if (resources === undefined) continue + if (this.ctx.remoteAccess.decide({ principal: client.principal, action: `event:${frame.event}`, resources }).kind === 'deny') continue + } + client.queue.push(wire) + } } private startRemoteEvent(source: TypertRemoteEventInvocation): void { @@ -601,7 +633,11 @@ export class TypertGatewayService extends Service implements TypertGateway { this.pendingRemoteEvents.set(id, pending) for (const signal of signals) signal.addEventListener('abort', abort, { once: true }) if ([...signals].some(signal => signal.aborted)) abort() - else for (const client of this.remoteEventClients.values()) this.deliverRemoteEvent(pending, client) + else { + for (const client of this.remoteEventClients.values()) { + if (client.answers) this.deliverRemoteEvent(pending, client) + } + } } catch (error) { source.reject(error) } @@ -703,9 +739,36 @@ export class TypertGatewayService extends Service implements TypertGateway { control: AbortController, ): Promise { const endpoint = endpointOf(request.namespace, request.method) + assertPeerLive(request.peer, endpoint) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) assertExactArguments(request.args, descriptor, endpoint) - const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint) + const validated = validateArguments(descriptor, request.args, endpoint) + // Access precedes lookup resolution, so a refused call activates nothing. + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (request.peer.principal.kind !== 'owner') { + const resources = callResources( + this.ctx.typert, + this.resourceReaders(descriptor), + descriptor, + validated, + request.signal ?? NEVER_ABORTED_SIGNAL, + ) + const decision: RemoteAccessDecision = resources === undefined + ? { kind: 'deny', reason: 'resource-invalid' } + : this.ctx.remoteAccess.decide({ + principal: request.peer.principal, + action: `${descriptor.namespace}/${descriptor.method}`, + resources, + args: validated, + }) + if (decision.kind === 'deny') { + throw new RemoteError('remote-access/denied', `remote access: ${endpoint}: access denied (${decision.reason})`, { + action: endpoint, + reason: decision.reason, + }) + } + } + const receiverContext = await this.resolveReceiverContext(descriptor, validated, endpoint) const receiver: unknown = receiverContext.get(descriptor.service) if (!isObject(receiver)) { throw new TypertGatewayError( @@ -716,12 +779,12 @@ export class TypertGatewayService extends Service implements TypertGateway { } validateBinding(receiver, descriptor.service, descriptor.namespace, endpoint) const args = await Promise.all(descriptor.parameters.map(parameter => - this.resolveParameter(parameter, request.args, endpoint))) - const signal = methodSignal(request, control) + this.resolveParameter(parameter, validated, endpoint))) + const signal = methodSignal(request, control, descriptor.mode === 'stream') const invocation = new GatewayInvocation( { namespace: request.namespace, method: request.method, args: request.args }, descriptor.service, - request.peer ?? this.operatorPeer(), + request.peer, signal, { source: request.uplink ?? EMPTY_ASYNC_ITERABLE, @@ -945,13 +1008,10 @@ export class TypertGatewayService extends Service implements TypertGateway { args: Readonly>, endpoint: string, ): Promise { - // An absent field reached assertExactArguments' allowance, so this parameter - // takes undefined; a present-but-undefined field is not JSON-safe input and - // still fails decode. Lookup ids are never omissible, so absence here only - // ever belongs to a json parameter. + // Lookup ids are never omissible, so an absent field belongs to a json parameter that takes undefined. if (!Object.hasOwn(args, parameter.wire)) return undefined + if (parameter.source === 'json') return args[parameter.wire] const value = decode(parameter.codec, args[parameter.wire], endpoint, parameter.wire) - if (parameter.source === 'json') return value const key = parameter.lookup /* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */ if (key === undefined) { @@ -1088,12 +1148,18 @@ class RemoteEventQueue { this.waiter?.() } + /** + * Yield buffered and later frames until the queue ends or `signal` aborts. + * An abort discards the frames still buffered, so a revoked Peer receives none of them. + * @param signal - Client generation lifetime. + * @returns the queued wire frames in push order. + */ async *iterate(signal: AbortSignal): AsyncGenerator { const abort = (): void => { this.end() } signal.addEventListener('abort', abort, { once: true }) try { while (true) { - while (this.frames.size > 0) yield this.frames.popFront() as RemoteEventWireFrame + while (this.frames.size > 0 && !signal.aborted) yield this.frames.popFront() as RemoteEventWireFrame if (this.closed || signal.aborted) return await new Promise((resolve) => { this.waiter = resolve }) this.waiter = undefined @@ -1131,8 +1197,10 @@ function remoteRequest( endpoint: string, payload: unknown, signal: AbortSignal, - peer?: PeerScope, + peer: PeerScope, ): InvokeRemoteRequest { + // A disposed Peer is refused whatever it sent. + assertPeerLive(peer, endpoint) const segments = endpoint.split('/') if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`) @@ -1146,20 +1214,34 @@ function remoteRequest( || !isPlainObject(payload.args)) { throw new Error('Remote payload must contain exactly one plain-object args field') } - return { namespace, method, args: payload.args, signal, ...(peer === undefined ? {} : { peer }) } + return { namespace, method, args: payload.args, signal, peer } } /** * The signal a method observes. A carrier that supplies an uplink fails the * stream through `control` when an item is rejected, so that invocation joins - * `control` with the carrier signal; every other invocation keeps the carrier - * signal's identity. + * `control` with the carrier signal; a stream also ends with its Peer. Every + * other invocation keeps the carrier signal's identity. */ -function methodSignal(request: InvokeRemoteRequest, control: AbortController): AbortSignal { +function methodSignal(request: InvokeRemoteRequest, control: AbortController, stream: boolean): AbortSignal { const carrier = request.signal - if (request.uplink === undefined) return carrier ?? NEVER_ABORTED_SIGNAL - if (carrier === undefined || carrier === control.signal) return control.signal - return AbortSignal.any([carrier, control.signal]) + if (request.uplink === undefined && !stream) return carrier ?? NEVER_ABORTED_SIGNAL + const joined = carrier === undefined || carrier === control.signal ? [control.signal] : [carrier, control.signal] + // `AbortSignal.any` leaves each source only a weak reference to the joined signal, + // so per-stream joins on a Host-lifetime Peer signal accumulate nothing. + return AbortSignal.any(stream ? [...joined, request.peer.signal] : joined) +} + +/** + * Refuse a call whose Peer has been disposed. + * @param peer - Peer the call speaks for. + * @param endpoint - endpoint named in the failure. + * @throws {@link TypertGatewayError} `gateway/peer-disposed` once the Peer is disposed. + */ +function assertPeerLive(peer: PeerScope, endpoint: string): void { + if (peer.signal.aborted) { + throw new TypertGatewayError('gateway/peer-disposed', endpoint, 'the calling Peer has been disposed') + } } function isIterable(value: unknown): value is Iterable | AsyncIterable { @@ -1508,6 +1590,28 @@ function assertExactArguments( throw new TypertGatewayError('gateway/arguments-invalid', endpoint, `args fields do not match the descriptor: ${clauses.join('; ')}`) } +/** + * Decode every present JSON argument once through its parameter codec; a + * Typert codec drops object fields its compiled type does not declare. Lookup + * and Context identities keep their wire values; dispatch decodes and resolves + * them only after the access check. An absent field stays absent; a present + * `undefined` is not JSON-safe and fails decoding. + */ +function validateArguments( + descriptor: InvocationDescriptor, + args: Readonly>, + endpoint: string, +): Readonly> { + const entries: [string, unknown][] = [] + if (descriptor.invocation.kind === 'context') entries.push([descriptor.invocation.wire, args[descriptor.invocation.wire]]) + for (const parameter of descriptor.parameters) { + if (!Object.hasOwn(args, parameter.wire)) continue + const value = args[parameter.wire] + entries.push([parameter.wire, parameter.source === 'json' ? decode(parameter.codec, value, endpoint, parameter.wire) : value]) + } + return Object.fromEntries(entries) +} + function decode( codec: TypertCodec, value: unknown, diff --git a/packages/api/gateway/src/remote-error-codes.ts b/packages/api/gateway/src/remote-error-codes.ts index 5e560557cc..e61432b8c5 100644 --- a/packages/api/gateway/src/remote-error-codes.ts +++ b/packages/api/gateway/src/remote-error-codes.ts @@ -27,6 +27,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'gateway/lookup-not-found': TypertGatewayFaultDetails 'gateway/lookup-unavailable': TypertGatewayFaultDetails 'gateway/method-unavailable': TypertGatewayFaultDetails + 'gateway/peer-disposed': TypertGatewayFaultDetails 'gateway/protocol': TypertGatewayFaultDetails 'gateway/provider-mismatch': TypertGatewayFaultDetails 'gateway/result-invalid': TypertGatewayFaultDetails diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index fd7c6a4a96..d83f120ab3 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -4,7 +4,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import type { PeerScope, RemoteUplinkWindow } from '@deepseek-ai/dsh-typert-protocol' +import type { PeerScope, RemoteUplinkWindow, TypertResourceReader } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteEventHostInfo } from './stream-protocol.ts' /** One Remote method request after a carrier has decoded its envelope. */ @@ -22,8 +22,8 @@ export interface InvokeRemoteRequest { readonly uplink?: AsyncIterable /** Carrier-provided frame capacity; absent for carriers without an encoded-frame inbox limit. */ readonly uplinkWindow?: RemoteUplinkWindow | undefined - /** Peer the call speaks for; absent means an in-process carrier, answered as the operator. */ - readonly peer?: PeerScope + /** Peer the call speaks for; every carrier and in-process caller names it explicitly. */ + readonly peer: PeerScope /** Carrier or direct-caller cancellation injected only into cancellation-aware methods. */ readonly signal?: AbortSignal } @@ -34,6 +34,12 @@ export interface TypertRemoteEventFrame { readonly event: string /** Original event argument list after the owner validates it for JSON transport. */ readonly args: readonly unknown[] + /** + * Readers of the resources the arguments carry, keyed by resource kind and + * called with the arguments as positional parameters. Absent or empty for + * a notification that touches the global resource. + */ + readonly resources?: Readonly> } /** Live Host values used to project one scoped Remote Event. */ @@ -90,7 +96,7 @@ export interface TypertGatewayWireStream { * @param payload - decoded carrier payload. * @param uplink - Client-to-Host items of the logical stream; a Gateway-owned endpoint returns its iterator * as soon as it opens, so the carrier drops those items instead of buffering them. - * @param peer - Peer the stream speaks for; `undefined` means the operator's in-process carrier. + * @param peer - Peer the stream speaks for; required, never defaulted. * @param signal - logical-stream cancellation. * @returns validated stream values. */ @@ -98,7 +104,7 @@ export interface TypertGatewayWireStream { endpoint: string, payload: unknown, uplink: AsyncIterable, - peer: PeerScope | undefined, + peer: PeerScope, signal: AbortSignal, ) => Promise> @@ -129,6 +135,7 @@ export type TypertGatewayErrorCode = | 'gateway/lookup-not-found' | 'gateway/lookup-unavailable' | 'gateway/method-unavailable' + | 'gateway/peer-disposed' | 'gateway/protocol' | 'gateway/provider-mismatch' | 'gateway/result-invalid' @@ -142,8 +149,8 @@ export interface TypertGateway { readonly wireStream: TypertGatewayWireStream /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean diff --git a/packages/api/gateway/tests/echo-stream.host.spec.ts b/packages/api/gateway/tests/echo-stream.host.spec.ts index d56b529002..24a24f6918 100644 --- a/packages/api/gateway/tests/echo-stream.host.spec.ts +++ b/packages/api/gateway/tests/echo-stream.host.spec.ts @@ -7,7 +7,9 @@ import WebServer from '@deepseek-ai/dsh-host-webserver' import { Remote, TypertRemoteService, type RemoteStream } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import { browserCookie, provideBrowserCredentials } from './browser-credentials.ts' +import { ownerPeer } from './peer.ts' /** The smallest Remote stream that reads its uplink: every carrier has to serve it alike. */ class EchoService extends TypertRemoteService { @@ -36,6 +38,7 @@ describe('the echo stream on each carrier', () => { it('echoes uplink items through the in-process carrier', async () => { const ctx = await setup(false) const replies = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'echo', method: 'echo', args: { prefix: '> ' }, uplink: items(['a', 'b']), }) await expect(collect(replies)).resolves.toEqual(['> a', '> b']) @@ -73,6 +76,7 @@ async function setup(transport: boolean): Promise { provideBrowserCredentials(ctx) } await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGatewayService, {}) if (transport) await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) await ctx.plugin(EchoService) diff --git a/packages/api/gateway/tests/gateway-access.host.spec.ts b/packages/api/gateway/tests/gateway-access.host.spec.ts new file mode 100644 index 0000000000..42cbb1bbff --- /dev/null +++ b/packages/api/gateway/tests/gateway-access.host.spec.ts @@ -0,0 +1,915 @@ +import { describe, expect, it, vi, afterEach } from 'vitest' +import { Context, symbols } from '@deepseek-ai/cordis' +import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' +import RemoteAccessService, { + type RemoteAccessAction, + type RemoteAccessBinding, + type RemoteAccessCondition, + type RemoteAccessResource, + type RemoteAccessResourceKind, + type RemoteAccessRole, +} from '@deepseek-ai/dsh-api-remote-access' +import { + Remote, + RemoteScope, + TypertRemoteService, + type InvocationDescriptor, + type InvocationParameterDescriptor, + type PeerScope, + type Principal, + type PrincipalMap, + type TypertContext, + type TypertLookup, + type TypertResource, +} from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { z } from 'zod' +import TypertGatewayService, { type TypertRemoteEventDispatch } from '@deepseek-ai/dsh-api-gateway' +import { callResources, eventResources } from '../src/access.ts' +import { provideBrowserCredentials } from './browser-credentials.ts' +import { ownerPeer, peerFor } from './peer.ts' + +interface AccessAgent { + readonly id: string +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } + interface TypertLookupMap { + gatewayAccessAgent: TypertLookup + } + interface TypertContextMap { + gatewayAccessScope: TypertContext + } + interface TypertResourceMap { + /** Test-only resource kinds; the Gateway itself names no kind. */ + 'test-shelf': TypertResource + 'test-desk': TypertResource + } +} + +type Guest = PrincipalMap['test-guest'] + +const OWNER: Principal = { kind: 'owner' } +const GUEST: Guest = { kind: 'test-guest', guest: 'gateway' } +const GLOBAL = { kind: 'global' } as const +const shelf = (id: string) => ({ kind: 'test-shelf', id }) as const +const desk = (id: string) => ({ kind: 'test-desk', id }) as const + +const FIXTURE_ACTIONS: readonly RemoteAccessAction[] = [ + 'accessFixture/open', 'accessFixture/read', 'accessFixture/byAgent', 'accessFixture/byLookup', + 'accessFixture/byLookupAndOption', 'accessFixture/byList', 'accessFixture/scoped', 'accessFixture/scopedOnDesk', + 'accessFixture/scopedByReader', 'accessFixture/annotate', 'absentAccess/read', 'event:fixture/notice', 'event:fixture/shelf', +] + +function role(resource: K): RemoteAccessRole { + return { name: `test:${resource}`, resource, actions: FIXTURE_ACTIONS } +} + +/** One binding granting every fixture action on `resource`. */ +function on(resource: RemoteAccessResource): RemoteAccessBinding { + switch (resource.kind) { + case 'global': + return { role: role('global'), resource } + case 'test-shelf': + return { role: role('test-shelf'), resource } + case 'test-desk': + return { role: role('test-desk'), resource } + default: + throw new Error('no fixture role for this resource kind') + } +} + +class AccessFixture extends TypertRemoteService { + readonly principals: Principal[] = [] + readonly requests: unknown[] = [] + release: PromiseWithResolvers = Promise.withResolvers() + + constructor(ctx: Context) { + super(ctx, 'accessFixture') + } + + @Remote + open(): string { + return this.seen() + } + + @Remote + read(request: { readonly shelfId?: unknown; readonly principal?: unknown }): string { + void request + return this.seen() + } + + @Remote({ resources: { 'test-shelf': agentId => agentId } }) + byAgent(agentId: string): string { + void agentId + return this.seen() + } + + @Remote + byLookup(agent: AccessAgent): string { + return `${agent.id}:${this.seen()}` + } + + @Remote({ resources: { 'test-desk': (agentId, request) => [agentId, request.deskId] } }) + byLookupAndOption(agent: AccessAgent, request: { readonly deskId: string; readonly shelfId: string }): string { + void request + return `${agent.id}:${this.seen()}` + } + + @Remote({ resources: { 'test-shelf': shelfIds => shelfIds } }) + byList(shelfIds: readonly string[]): string { + void shelfIds + return this.seen() + } + + @Remote + annotate(request: { readonly title: string }): string { + this.requests.push(request) + return this.seen() + } + + @RemoteScope('gatewayAccessScope') + scoped(): string { + return this.seen() + } + + @RemoteScope('gatewayAccessScope', { resources: { 'test-desk': request => request.deskId } }) + scopedOnDesk(request: { readonly deskId: string }): string { + void request + return this.seen() + } + + @RemoteScope('gatewayAccessScope', { resources: { 'test-shelf': request => request.shelfId } }) + scopedByReader(request: { readonly shelfId: string }): string { + void request + return this.seen() + } + + @Remote + async slow(): Promise { + await this.release.promise + return this.seen() + } + + @Remote({ mode: 'stream' }) + async *watch(signal: AbortSignal): AsyncIterable { + yield this.seen() + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + + private seen(): string { + const principal = this.ctx.invocation?.peer.principal + if (principal !== undefined) this.principals.push(principal) + return principal?.kind ?? 'none' + } +} + +const roots: Context[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +async function setup(options: { readonly connection?: boolean } = {}): Promise<{ + readonly ctx: Context + readonly fixture: AccessFixture + /** Replace the guest's bindings: every fixture action on each listed resource. */ + readonly hold: (...resources: RemoteAccessResource[]) => void + readonly bindings: ReturnType readonly RemoteAccessBinding[]>> +}> { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) + await ctx.plugin(TypertGatewayService) + if (options.connection === true) { + provideBrowserCredentials(ctx) + await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) + } + ctx.typert.resources.register('test-shelf', { field: 'shelfId' }) + await ctx.plugin(AccessFixture) + let held: readonly RemoteAccessBinding[] = [] + const bindings = vi.fn((guest: Guest): readonly RemoteAccessBinding[] => { void guest; return held }) + ctx.remoteAccess.registerPolicy('test-guest', { bindings }) + const receiver = ctx.get('accessFixture') as AccessFixture & { [symbols.original]?: AccessFixture } + return { + ctx, + fixture: receiver[symbols.original] ?? receiver, + hold: (...resources) => { held = resources.map(on) }, + bindings, + } +} + +/** Record every request the service decides from now on; the returned function lists their actions and resources in order. */ +function recordDecisions(): () => { readonly action: string; readonly resources: unknown }[] { + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + return () => decide.mock.calls.map(([request]) => ({ action: request.action, resources: request.resources })) +} + +function call(ctx: Context, peer: PeerScope, method: string, args: Record = {}): Promise { + return ctx.typertGateway.invoke({ peer, namespace: 'accessFixture', method, args }) +} + +function registerScope(ctx: Context, resource: 'test-shelf' | undefined, resolve = vi.fn((id: string) => { void id; return ctx })): typeof resolve { + ctx.typert.contexts.registerHost('gatewayAccessScope', { + wire: 'scopeId', + wireTypeSymbol: '@fixture/access#ScopeId', + ...resource === undefined ? {} : { resource }, + resolve, + }) + return resolve +} + +function strictCodec(name: string, create: () => z.ZodType): InvocationParameterDescriptor['codec'] { + return { mode: 'strict', typeSymbol: `@fixture/access#${name}`, create } +} + +/** Give each listed fixture method a strict descriptor with these parameters. */ +function registerStrict(ctx: Context, methods: Readonly>): void { + ctx.typert.register({ + package: '@fixture/gateway-access-strict', + face: 'host', + schemas: [], + model: { services: [], events: [], objects: [] }, + invocations: Object.entries(methods).map(([method, parameters]) => ({ + id: `@fixture/gateway-access-strict#accessFixture/${method}`, + service: 'accessFixture', + namespace: 'accessFixture', + method, + invocation: { kind: 'direct' }, + parameters, + result: { mode: 'strict', typeSymbol: '@fixture/access#Result', create: () => z.string() }, + })), + }) +} + +describe('Gateway access checks', () => { + it('dispatches the owner without resolving resources or reading the policy', async () => { + const { ctx, bindings } = await setup() + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + await expect(call(ctx, ownerPeer(ctx), 'open')).resolves.toBe('owner') + await expect(call(ctx, ownerPeer(ctx), 'read', { request: { shelfId: 7 } })).resolves.toBe('owner') + expect(decide).not.toHaveBeenCalled() + expect(bindings).not.toHaveBeenCalled() + }) + + it('starts only in a composition that mounts remoteAccess', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + void ctx.plugin(TypertGatewayService) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(ctx.get('typertGateway')).toBeUndefined() + await ctx.plugin(RemoteAccessService) + await vi.waitFor(() => { expect(ctx.get('typertGateway')).toBeDefined() }) + }) + + it('refuses a non-owner call no binding allows before the method runs', async () => { + const { ctx, fixture, hold } = await setup() + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'open')).rejects.toMatchObject({ + code: 'remote-access/denied', + message: 'remote access: accessFixture/open: access denied (no-allow)', + details: { action: 'accessFixture/open', reason: 'no-allow' }, + }) + expect(fixture.principals).toEqual([]) + hold(GLOBAL) + await expect(call(ctx, guest, 'open')).resolves.toBe('test-guest') + expect(fixture.principals).toEqual([GUEST]) + }) + + it('hands remoteAccess the endpoint action, the resolved resources, and the call arguments', async () => { + const { ctx, hold } = await setup() + hold(GLOBAL, shelf('s1'), shelf('s2')) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const guest = peerFor(ctx, GUEST) + await call(ctx, guest, 'read', { request: { shelfId: 's1' } }) + await call(ctx, guest, 'byAgent', { agentId: 's2' }) + await call(ctx, guest, 'read', { request: {} }) + expect(decide.mock.calls.map(([request]) => request)).toEqual([ + { principal: GUEST, action: 'accessFixture/read', resources: [shelf('s1')], args: { request: { shelfId: 's1' } } }, + { principal: GUEST, action: 'accessFixture/byAgent', resources: [shelf('s2')], args: { agentId: 's2' } }, + { principal: GUEST, action: 'accessFixture/read', resources: [GLOBAL], args: { request: {} } }, + ]) + }) + + it('reads a lookup parameter\'s resource and lets a reader replace it for its kind', async () => { + const { ctx, hold } = await setup() + hold(shelf('s9'), desk('a1'), desk('a2'), desk('d9')) + const decisions = recordDecisions() + ctx.typert.lookups.register('gatewayAccessAgent', { + parameter: 'agent', + wire: 'agentId', + resource: 'test-desk', + hostTypeSymbol: '@fixture/access#Agent', + wireTypeSymbol: '@fixture/access#AgentId', + resolve: (id: string) => ({ id }), + }) + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'byLookup', { agentId: 'a1' })).resolves.toBe('a1:test-guest') + await expect(call(ctx, guest, 'byLookupAndOption', { agentId: 'a2', request: { deskId: 'd9', shelfId: 's9' } })) + .resolves.toBe('a2:test-guest') + expect(decisions().map(({ resources }) => resources)).toEqual([ + [desk('a1')], + // The reader owns `test-desk`; the registered `shelfId` field still names `test-shelf`. + [shelf('s9'), desk('a2'), desk('d9')], + ]) + }) + + it('refuses an empty reader list and a malformed default field with resource-invalid without asking remoteAccess', async () => { + const { ctx, hold, bindings } = await setup() + hold(shelf('s1'), shelf('s2')) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'byList', { shelfIds: [] })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'accessFixture/byList', reason: 'resource-invalid' }, + }) + await expect(call(ctx, guest, 'read', { request: { shelfId: 7 } })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { reason: 'resource-invalid' }, + }) + expect(decide).not.toHaveBeenCalled() + expect(bindings).not.toHaveBeenCalled() + // Every listed resource must be allowed. + await expect(call(ctx, guest, 'byList', { shelfIds: ['s1', 's2'] })).resolves.toBe('test-guest') + await expect(call(ctx, guest, 'byList', { shelfIds: ['s1', 's3'] })).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + }) + + it('resolves a strict method whose Service is absent from its arguments alone', async () => { + const { ctx } = await setup() + const decisions = recordDecisions() + ctx.typert.register({ + package: '@fixture/gateway-access', + face: 'host', + schemas: [], + model: { services: [], events: [], objects: [] }, + invocations: [{ + id: '@fixture/gateway-access#absentAccess/read', + service: 'absentAccess', + namespace: 'absentAccess', + method: 'read', + invocation: { kind: 'direct' }, + parameters: [], + result: { mode: 'strict', typeSymbol: '@fixture/gateway-access#Result', create: () => z.string() }, + }], + }) + await expect(ctx.typertGateway.invoke({ peer: peerFor(ctx, GUEST), namespace: 'absentAccess', method: 'read', args: {} })) + .rejects.toMatchObject({ code: 'remote-access/denied', details: { action: 'absentAccess/read', reason: 'no-allow' } }) + expect(decisions()).toEqual([{ action: 'absentAccess/read', resources: [GLOBAL] }]) + }) + + it('checks access before resolving lookups, so a refused call activates nothing', async () => { + const { ctx, hold } = await setup() + const resolve = vi.fn((id: string) => ({ id })) + ctx.typert.lookups.register('gatewayAccessAgent', { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/access#Agent', + wireTypeSymbol: '@fixture/access#AgentId', + resolve, + }) + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'byLookup', { agentId: 'a1' })).rejects.toMatchObject({ code: 'remote-access/denied' }) + expect(resolve).not.toHaveBeenCalled() + // A lookup without a resource kind contributes nothing, so the call touches the global resource. + hold(GLOBAL) + await expect(call(ctx, guest, 'byLookup', { agentId: 'a1' })).resolves.toBe('a1:test-guest') + expect(resolve).toHaveBeenCalledOnce() + }) + + it('keeps the principal out of reach of wire fields', async () => { + const { ctx, fixture, hold } = await setup({ connection: true }) + hold(GLOBAL) + const guest = ctx.connection.createPeer({ principal: GUEST }) + await expect(call(ctx, guest, 'read', { request: {}, principal: OWNER })).rejects.toMatchObject({ + code: 'gateway/arguments-invalid', + }) + const shared = ctx.connection.createSharedFetchHandler('/api') + const response = await shared.fetch(new Request('http://host/api/accessFixture/read', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-1', + method: 'accessFixture/read', + principal: OWNER, + payload: { args: { request: { principal: OWNER } } }, + }), + }), guest) + expect(await response.json()).toMatchObject({ result: { ok: true, value: 'test-guest' } }) + const payloadSibling = await shared.fetch(new Request('http://host/api/accessFixture/read', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-2', + method: 'accessFixture/read', + payload: { args: { request: {} }, principal: OWNER }, + }), + }), guest) + expect(await payloadSibling.json()).toMatchObject({ result: { ok: false } }) + expect(fixture.principals).toEqual([GUEST]) + }) +}) + +describe('Gateway access checks on Typert-validated arguments', () => { + const titleRequest = (create: () => z.ZodType = () => z.object({ title: z.string() })): InvocationParameterDescriptor => + ({ name: 'request', wire: 'request', source: 'json', codec: strictCodec('TitleRequest', create) }) + + it('resolves resources from the decoded arguments, so an undeclared nested field names no resource', async () => { + const { ctx, fixture, hold } = await setup() + const create = vi.fn(() => z.object({ title: z.string() })) + registerStrict(ctx, { + annotate: [titleRequest(create)], + read: [{ name: 'request', wire: 'request', source: 'json', codec: strictCodec('ShelfRequest', () => z.object({ shelfId: z.string() })) }], + }) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const guest = peerFor(ctx, GUEST) + hold(shelf('s1')) + await expect(call(ctx, guest, 'annotate', { request: { title: 't', shelfId: 's1' } })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'accessFixture/annotate', reason: 'no-allow' }, + }) + await expect(call(ctx, guest, 'read', { request: { shelfId: 's1', principal: OWNER } })).resolves.toBe('test-guest') + hold(GLOBAL) + await expect(call(ctx, guest, 'annotate', { request: { title: 't', shelfId: 's1' } })).resolves.toBe('test-guest') + expect(decide.mock.calls.map(([request]) => request)).toEqual([ + { principal: GUEST, action: 'accessFixture/annotate', resources: [GLOBAL], args: { request: { title: 't' } } }, + { principal: GUEST, action: 'accessFixture/read', resources: [shelf('s1')], args: { request: { shelfId: 's1' } } }, + { principal: GUEST, action: 'accessFixture/annotate', resources: [GLOBAL], args: { request: { title: 't' } } }, + ]) + // Dispatch receives the value the access check read, decoded once per call. + expect(fixture.requests).toEqual([{ title: 't' }]) + expect(create).toHaveBeenCalledTimes(2) + }) + + it('hands a condition the decoded arguments, so it cannot read an undeclared field', async () => { + const { ctx, bindings } = await setup() + registerStrict(ctx, { annotate: [titleRequest()] }) + const seen: unknown[] = [] + const condition: RemoteAccessCondition<'global'> = ({ args }) => { + seen.push(args) + return { kind: 'allow' } + } + bindings.mockImplementation(() => [{ role: role('global'), resource: GLOBAL, condition }]) + await expect(call(ctx, peerFor(ctx, GUEST), 'annotate', { request: { title: 't', readOnly: false } })).resolves.toBe('test-guest') + expect(seen).toEqual([{ request: { title: 't' } }]) + }) + + it('passes readers the decoded arguments and lookup wire identities, resolving no lookup for a refused call', async () => { + const { ctx, hold } = await setup() + const resolve = vi.fn((id: string) => ({ id })) + ctx.typert.lookups.register('gatewayAccessAgent', { + parameter: 'agent', + wire: 'agentId', + resource: 'test-desk', + hostTypeSymbol: '@fixture/access#Agent', + wireTypeSymbol: '@fixture/access#AgentId', + resolve, + }) + registerStrict(ctx, { + byLookupAndOption: [ + { name: 'agent', wire: 'agentId', source: 'lookup', lookup: 'gatewayAccessAgent', codec: strictCodec('AgentId', () => z.string()) }, + { name: 'request', wire: 'request', source: 'json', codec: strictCodec('DeskRequest', () => z.object({ deskId: z.string() })) }, + ], + }) + const decisions = recordDecisions() + const guest = peerFor(ctx, GUEST) + const args = { agentId: 'a2', request: { deskId: 'd9', shelfId: 's9' } } + hold(shelf('s9'), desk('a2')) + await expect(call(ctx, guest, 'byLookupAndOption', args)).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + expect(resolve).not.toHaveBeenCalled() + hold(desk('a2'), desk('d9')) + await expect(call(ctx, guest, 'byLookupAndOption', args)).resolves.toBe('a2:test-guest') + expect(resolve).toHaveBeenCalledExactlyOnceWith('a2') + // The undeclared `shelfId` names no `test-shelf` resource. + expect(decisions().map(({ resources }) => resources)).toEqual([[desk('a2'), desk('d9')], [desk('a2'), desk('d9')]]) + }) + + it('refuses arguments their codec rejects with gateway/input-invalid before the access check', async () => { + const { ctx, fixture, bindings } = await setup() + registerStrict(ctx, { annotate: [titleRequest()] }) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + for (const peer of [peerFor(ctx, GUEST), ownerPeer(ctx)]) { + await expect(call(ctx, peer, 'annotate', { request: { title: 7, shelfId: 's1' } })).rejects.toMatchObject({ + code: 'gateway/input-invalid', details: { endpoint: 'accessFixture/annotate', field: 'request' }, + }) + } + expect(decide).not.toHaveBeenCalled() + expect(bindings).not.toHaveBeenCalled() + expect(fixture.requests).toEqual([]) + }) +}) + +describe('Gateway access checks for Context methods', () => { + it('reads the Context identity as the resource its adapter declares, before resolving the Context', async () => { + const { ctx, fixture, hold } = await setup() + const resolve = registerScope(ctx, 'test-shelf') + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'scoped', { scopeId: 's1' })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'accessFixture/scoped', reason: 'no-allow' }, + }) + expect(resolve).not.toHaveBeenCalled() + hold(shelf('s1')) + await expect(call(ctx, guest, 'scoped', { scopeId: 's1' })).resolves.toBe('test-guest') + expect(resolve).toHaveBeenCalledWith('s1') + expect(fixture.principals).toEqual([GUEST]) + }) + + it('adds the resources a reader declares to the Context resource, and lets a reader replace it for its kind', async () => { + const { ctx, hold } = await setup() + registerScope(ctx, 'test-shelf') + const decisions = recordDecisions() + const guest = peerFor(ctx, GUEST) + hold(shelf('s1')) + await expect(call(ctx, guest, 'scopedOnDesk', { scopeId: 's1', request: { deskId: 'd1' } })).rejects.toMatchObject({ + details: { reason: 'no-allow' }, + }) + hold(shelf('s1'), desk('d1')) + await expect(call(ctx, guest, 'scopedOnDesk', { scopeId: 's1', request: { deskId: 'd1' } })).resolves.toBe('test-guest') + await expect(call(ctx, guest, 'scopedByReader', { scopeId: 's9', request: { shelfId: 's1' } })).resolves.toBe('test-guest') + expect(decisions().map(({ resources }) => resources)).toEqual([ + [shelf('s1'), desk('d1')], + [shelf('s1'), desk('d1')], + [shelf('s1')], + ]) + }) + + it('refuses a malformed Context identity with resource-invalid without asking remoteAccess or resolving the Context', async () => { + const { ctx, hold, bindings } = await setup() + const resolve = registerScope(ctx, 'test-shelf') + hold(GLOBAL, shelf('s1')) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const guest = peerFor(ctx, GUEST) + for (const scopeId of [7, '']) { + await expect(call(ctx, guest, 'scoped', { scopeId })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'accessFixture/scoped', reason: 'resource-invalid' }, + }) + } + expect(resolve).not.toHaveBeenCalled() + expect(decide).not.toHaveBeenCalled() + expect(bindings).not.toHaveBeenCalled() + }) + + it('resolves a Context method whose adapter declares no resource by the ordinary sources', async () => { + const { ctx, hold } = await setup() + registerScope(ctx, undefined) + const decisions = recordDecisions() + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'scoped', { scopeId: 's1' })).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + hold(GLOBAL, desk('d1')) + await expect(call(ctx, guest, 'scoped', { scopeId: 's1' })).resolves.toBe('test-guest') + await expect(call(ctx, guest, 'scopedOnDesk', { scopeId: 's1', request: { deskId: 'd1' } })).resolves.toBe('test-guest') + expect(decisions().map(({ resources }) => resources)).toEqual([ + [GLOBAL], + [GLOBAL], + [desk('d1')], + ]) + }) +}) + +describe('Gateway Peer lifetime', () => { + it('refuses calls and streams from a disposed Peer without affecting other Peers', async () => { + const { ctx } = await setup({ connection: true }) + const peer = peerFor(ctx, OWNER) + await peer.dispose() + await expect(call(ctx, peer, 'open')).rejects.toMatchObject({ code: 'gateway/peer-disposed' }) + await expect(ctx.typertGateway.stream({ peer, namespace: 'accessFixture', method: 'watch', args: {} })) + .rejects.toMatchObject({ code: 'gateway/peer-disposed' }) + await expect(ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), peer, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/peer-disposed' }) + const result = await ctx.connection.createSharedFetchHandler('/api').fetch(new Request('http://host/api/$events/result', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-disposed', + method: '$events/result', + payload: { args: { clientId: 'any', eventId: 'any', outcome: { kind: 'next' } } }, + }), + }), peer) + expect(await result.json()).toMatchObject({ result: { ok: false, error: { code: 'gateway/peer-disposed' } } }) + await expect(call(ctx, ownerPeer(ctx), 'open')).resolves.toBe('owner') + }) + + it('refuses a disposed Peer before reading its wire payload', async () => { + const { ctx } = await setup({ connection: true }) + const peer = peerFor(ctx, OWNER) + await peer.dispose() + for (const payload of [{}, { args: 1 }, { args: {}, extra: true }]) { + await expect(ctx.typertGateway.wireStream.open('accessFixture/watch', payload, (async function* () {})(), peer, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/peer-disposed' }) + } + const response = await ctx.connection.createSharedFetchHandler('/api').fetch(new Request('http://host/api/accessFixture/open', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId: 'rpc-malformed', method: 'accessFixture/open', payload: { args: 1 } }), + }), peer) + expect(await response.json()).toMatchObject({ result: { ok: false, error: { code: 'gateway/peer-disposed' } } }) + }) + + it('ends a disposed Peer\'s streams and lets its running unary calls finish', async () => { + const { ctx, fixture } = await setup() + const peer = peerFor(ctx, OWNER) + const other = peerFor(ctx, OWNER) + const stream = (await ctx.typertGateway.stream({ peer, namespace: 'accessFixture', method: 'watch', args: {} }))[Symbol.asyncIterator]() + const survivorAbort = new AbortController() + const survivor = (await ctx.typertGateway.stream({ + peer: other, namespace: 'accessFixture', method: 'watch', args: {}, signal: survivorAbort.signal, + }))[Symbol.asyncIterator]() + await expect(stream.next()).resolves.toEqual({ done: false, value: 'owner' }) + await expect(survivor.next()).resolves.toEqual({ done: false, value: 'owner' }) + const running = call(ctx, peer, 'slow') + const pending = stream.next() + await peer.dispose() + // The stream's method signal joins the Peer's, so disposal cancels it like a carrier cancel. + await expect(pending).rejects.toMatchObject({ code: 'gateway/cancelled' }) + fixture.release.resolve() + await expect(running).resolves.toBe('owner') + const next = survivor.next() + let settled = false + void next.then(() => { settled = true }, () => { settled = true }) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(settled).toBe(false) + survivorAbort.abort() + await expect(next).rejects.toMatchObject({ code: 'gateway/cancelled' }) + }) +}) + +/** Install a pushable event source; `push` yields one dispatch to every open `$events` stream. */ +function eventSource(ctx: Context): (dispatch: TypertRemoteEventDispatch) => void { + const queue: TypertRemoteEventDispatch[] = [] + let wake: (() => void) | undefined + ctx.effect(() => ctx.typertGateway.registerRemoteEvents(async function* (signal) { + while (!signal.aborted) { + while (queue.length > 0) yield queue.shift() as TypertRemoteEventDispatch + await new Promise((resolve) => { + wake = resolve + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + }, { home: '/fixture' })) + return (dispatch) => { + queue.push(dispatch) + wake?.() + } +} + +describe('Gateway forwarded events for non-owner Peers', () => { + it('delivers only allowed broadcasts and no answerable waterfall to a non-owner, and refuses its results', async () => { + const { ctx, hold } = await setup({ connection: true }) + hold(shelf('s1')) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const push = eventSource(ctx) + const guest = ctx.connection.createPeer({ principal: GUEST }) + const abort = new AbortController() + const guestEvents = (await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), guest, abort.signal))[Symbol.asyncIterator]() + const guestReady = await guestEvents.next() + // A non-owner stream receives the same Host facts but cannot answer, so it does not count as a live Client. + expect(guestReady.value).toMatchObject({ type: 'ready', host: { home: '/fixture' } }) + expect(ctx.typertGateway.hasLiveClient()).toBe(false) + const ownerEvents = (await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), ctx.connection.browserPeer, abort.signal))[Symbol.asyncIterator]() + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'ready', host: { home: '/fixture' } } }) + expect(ctx.typertGateway.hasLiveClient()).toBe(true) + + const agentCtx = ctx.extend({}) + const subject = {} + const settled = vi.fn() + push({ + event: 'fixture/ask', + request: { question: 'q', agent: subject }, + context: { value: agentCtx, subject, agentId: 'agent-1' }, + resolve: settled, + reject: settled, + }) + const resources = { 'test-shelf': (shelfId: unknown) => shelfId } + push({ event: 'fixture/notice', args: ['hello'] }) + push({ event: 'fixture/shelf', args: ['s2'], resources }) + push({ event: 'fixture/shelf', args: ['s1'], resources }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'waterfall', event: 'fixture/ask' } }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/notice' } }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf', args: ['s2'] } }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf', args: ['s1'] } }) + // The global notice and the denied shelf never reach the non-owner; the allowed one arrives whole. + await expect(guestEvents.next()).resolves.toEqual({ done: false, value: { type: 'emit', event: 'fixture/shelf', args: ['s1'] } }) + expect(decide.mock.calls.map(([request]) => request)).toEqual([ + { principal: GUEST, action: 'event:fixture/notice', resources: [GLOBAL] }, + { principal: GUEST, action: 'event:fixture/shelf', resources: [shelf('s2')] }, + { principal: GUEST, action: 'event:fixture/shelf', resources: [shelf('s1')] }, + ]) + + const clientId: unknown = Reflect.get(guestReady.value as object, 'clientId') + const shared = ctx.connection.createSharedFetchHandler('/api') + const refused = await shared.fetch(new Request('http://host/api/$events/result', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-result', + method: '$events/result', + payload: { args: { clientId, eventId: 'any', outcome: { kind: 'next' } } }, + }), + }), guest) + expect(await refused.json()).toMatchObject({ + result: { ok: false, error: { code: 'gateway/invocation-unavailable', details: { endpoint: '$events/result' } } }, + }) + abort.abort() + await guestEvents.return?.() + await ownerEvents.return?.() + }) + + it('delivers a global event that a global role lists, and drops one whose resource is missing without asking remoteAccess', async () => { + const { ctx, hold } = await setup({ connection: true }) + hold(GLOBAL, shelf('s1')) + const decisions = recordDecisions() + const push = eventSource(ctx) + const abort = new AbortController() + const open = async (): Promise> => (await ctx.typertGateway.wireStream.open( + '$events', { args: {} }, (async function* () {})(), ctx.connection.createPeer({ principal: GUEST }), abort.signal, + ))[Symbol.asyncIterator]() + const streams = [await open(), await open()] + for (const events of streams) await events.next() + const missing = vi.fn(() => undefined) + push({ event: 'fixture/shelf', args: [null], resources: { 'test-shelf': missing } }) + push({ event: 'fixture/notice', args: ['hello'], resources: {} }) + for (const events of streams) { + await expect(events.next()).resolves.toEqual({ done: false, value: { type: 'emit', event: 'fixture/notice', args: ['hello'] } }) + } + // A frame's resources are resolved once for all non-owner streams. + expect(missing).toHaveBeenCalledOnce() + const notice = { action: 'event:fixture/notice', resources: [GLOBAL] } + expect(decisions()).toEqual([notice, notice]) + abort.abort() + for (const events of streams) await events.return?.() + }) + + it('discards the events buffered for a revoked non-owner Peer and keeps other streams', async () => { + const { ctx, hold } = await setup({ connection: true }) + hold(shelf('s1')) + const push = eventSource(ctx) + const abort = new AbortController() + const open = async (peer: PeerScope): Promise> => + (await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), peer, abort.signal))[Symbol.asyncIterator]() + const revoked = ctx.connection.createPeer({ principal: GUEST }) + const lagging = await open(revoked) + const other = await open(ctx.connection.createPeer({ principal: GUEST })) + const owner = await open(ctx.connection.browserPeer) + for (const events of [lagging, other, owner]) await events.next() + const resources = { 'test-shelf': (shelfId: unknown) => shelfId } + push({ event: 'fixture/shelf', args: ['s1'], resources }) + push({ event: 'fixture/shelf', args: ['s1'], resources }) + const frame = { done: false, value: { type: 'emit', event: 'fixture/shelf', args: ['s1'] } } + for (const events of [other, owner]) { + await expect(events.next()).resolves.toMatchObject(frame) + await expect(events.next()).resolves.toMatchObject(frame) + } + // Both frames were queued for the lagging stream before its Peer was revoked. + await revoked.dispose() + await expect(lagging.next()).resolves.toEqual({ done: true, value: undefined }) + push({ event: 'fixture/shelf', args: ['s1'], resources }) + for (const events of [other, owner]) await expect(events.next()).resolves.toMatchObject(frame) + abort.abort() + for (const events of [other, owner]) await events.return?.() + }) + + it('contains a throwing policy to the non-owner deliveries it decides', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) + await ctx.plugin(TypertGatewayService) + provideBrowserCredentials(ctx) + await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + ctx.remoteAccess.registerPolicy('test-guest', { bindings: () => { throw new Error('binding store unavailable') } }) + const push = eventSource(ctx) + const abort = new AbortController() + const open = async (peer: PeerScope): Promise> => + (await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), peer, abort.signal))[Symbol.asyncIterator]() + const guestEvents = await open(ctx.connection.createPeer({ principal: GUEST })) + const ownerEvents = await open(ctx.connection.browserPeer) + await guestEvents.next() + await ownerEvents.next() + push({ event: 'fixture/shelf', args: ['s1'], resources: { 'test-shelf': (shelfId: unknown) => shelfId } }) + push({ event: 'fixture/notice', args: ['after'] }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf' } }) + // The shared source survives the failure: later events still reach the owner. + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/notice' } }) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to list bindings for event:fixture/shelf; its further failures are not logged until access state changes') + const guestNext = guestEvents.next() + abort.abort() + await expect(guestNext).resolves.toEqual({ done: true, value: undefined }) + await ownerEvents.return?.() + }) +}) + +describe('resource resolution', () => { + const NEVER = new AbortController().signal + const ROOM = { kind: 'test-shelf', id: 'r1' } as const + const DESK = { kind: 'test-desk', id: 'd1' } as const + const DIRECT: InvocationDescriptor['invocation'] = { kind: 'direct' } + + /** A direct method's descriptor fields carrying only what resource resolution reads. */ + function method(...wires: string[]): Pick { + return { invocation: DIRECT, parameters: wires.map(wire => ({ name: wire, wire, source: 'json' }) as InvocationParameterDescriptor) } + } + + async function registry(): Promise { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + ctx.typert.resources.register('test-shelf', { field: 'roomId' }) + ctx.typert.resources.register('test-desk', { field: 'deskId' }) + return ctx.typert + } + + it('reads a default field from a top-level argument, else from the first object argument, else touches global', async () => { + const typert = await registry() + expect(callResources(typert, undefined, method('roomId'), { roomId: 'r1' }, NEVER)) + .toEqual([ROOM]) + expect(callResources(typert, undefined, method('count', 'tags', 'none', 'request'), { + count: 1, tags: ['r9'], none: null, request: { roomId: 'r1', deskId: 'd1' }, + }, NEVER)).toEqual([ROOM, DESK]) + // Only the first object argument is read; a later one naming the field does not count. + expect(callResources(typert, undefined, method('first', 'second'), { first: {}, second: { roomId: 'r1' } }, NEVER)) + .toEqual([GLOBAL]) + expect(callResources(typert, undefined, method(), {}, NEVER)).toEqual([GLOBAL]) + }) + + it('lets a reader replace the defaults for its kind, called with the positional wire arguments', async () => { + const typert = await registry() + const readers = { 'test-shelf': (ids: unknown) => ids } + expect(callResources(typert, readers, method('ids'), { ids: ['r1', 'r2'] }, NEVER)) + .toEqual([ROOM, { kind: 'test-shelf', id: 'r2' }]) + expect(callResources(typert, readers, method('ids', 'roomId'), { ids: undefined, roomId: 'r9' }, NEVER)) + .toEqual([GLOBAL]) + const signal = new AbortController().signal + const positional = vi.fn((...args: readonly unknown[]) => (args.at(-1) === signal ? args[1] : 'wrong')) + expect(callResources(typert, { 'test-desk': positional }, { + ...method('first', 'desk'), cancellation: { parameter: 'signal' }, + }, { first: 'f', desk: 'd1' }, signal)).toEqual([DESK]) + expect(positional).toHaveBeenCalledWith('f', 'd1', signal) + }) + + it('reads lookup parameters and a Context identity of a declared kind, an omitted optional lookup as none', async () => { + const typert = await registry() + typert.lookups.register('gatewayAccessAgent', { + parameter: 'agent', + wire: 'agentId', + resource: 'test-desk', + hostTypeSymbol: '@fixture/access#Agent', + wireTypeSymbol: '@fixture/access#AgentId', + resolve: (id: string) => ({ id }), + }) + typert.contexts.registerHost('gatewayAccessScope', { + wire: 'scopeId', wireTypeSymbol: '@fixture/access#ScopeId', resource: 'test-desk', resolve: () => undefined, + }) + const agent = { name: 'agent', wire: 'agentId', source: 'lookup', lookup: 'gatewayAccessAgent' } as InvocationParameterDescriptor + const unknownLookup = { name: 'other', wire: 'otherId', source: 'lookup', lookup: 'absentLookup' } as InvocationParameterDescriptor + expect(callResources(typert, undefined, { invocation: DIRECT, parameters: [agent, unknownLookup] }, { agentId: undefined, otherId: 'o1' }, NEVER)) + .toEqual([GLOBAL]) + expect(callResources(typert, undefined, { invocation: DIRECT, parameters: [agent] }, { agentId: 'd1' }, NEVER)) + .toEqual([DESK]) + const scoped = (context: string): InvocationDescriptor['invocation'] => ({ kind: 'context', context, wire: 'scopeId', codec: { mode: 'src-json' } }) + expect(callResources(typert, undefined, { invocation: scoped('gatewayAccessScope'), parameters: [agent] }, { scopeId: 'd0', agentId: 'd1' }, NEVER)) + .toEqual([{ kind: 'test-desk', id: 'd0' }, DESK]) + // A required Context identity is never omitted, so a missing one is malformed. + expect(callResources(typert, undefined, { invocation: scoped('gatewayAccessScope'), parameters: [] }, {}, NEVER)) + .toBeUndefined() + expect(callResources(typert, undefined, { invocation: scoped('absentScope'), parameters: [] }, { scopeId: 'd0' }, NEVER)) + .toEqual([GLOBAL]) + }) + + it('reports invalid resources when a reader throws or returns an empty list, or a resource id is not a non-empty string', async () => { + const typert = await registry() + const one = method('value') + const throwing = { 'test-shelf': () => { throw new TypeError('malformed call') } } + expect(callResources(typert, throwing, one, { value: 1 }, NEVER)).toBeUndefined() + for (const value of ['', 7, ['r1', ''], [null], []]) { + expect(callResources(typert, { 'test-shelf': (read: unknown) => read }, one, { value }, NEVER)).toBeUndefined() + } + // A default field names exactly one identity, so an array is malformed. + for (const roomId of [7, ['r1'], '']) { + expect(callResources(typert, undefined, method('roomId'), { roomId }, NEVER)).toBeUndefined() + } + }) + + it('reads a forwarded event through the readers its entry declares, and the global resource without them', () => { + expect(eventResources(undefined, ['r1'])).toEqual([GLOBAL]) + expect(eventResources({}, ['r1'])).toEqual([GLOBAL]) + expect(eventResources({ 'test-shelf': (id: unknown) => id }, ['r1'])).toEqual([ROOM]) + expect(eventResources({ 'test-desk': (_first: unknown, second: unknown) => second }, ['r1', 'd1'])) + .toEqual([DESK]) + // An event carries every resource it declares, so a missing one is invalid rather than a global event. + expect(eventResources({ 'test-shelf': () => undefined }, ['r1'])).toBeUndefined() + expect(eventResources({ 'test-shelf': () => [] }, ['r1'])).toBeUndefined() + expect(eventResources({ 'test-shelf': () => 7 }, ['r1'])).toBeUndefined() + }) +}) diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 2efcb950e0..72a46e21f6 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -21,6 +21,7 @@ import { RemoteError, } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { @@ -41,6 +42,7 @@ import type { RemoteEventClientId, RemoteEventInvocationFrame, } from '../src/stream-protocol.ts' +import { ownerPeer, peerFor } from './peer.ts' vi.mock('node:crypto', async (importOriginal) => { const actual = await importOriginal() @@ -368,13 +370,13 @@ describe('Typert Remote streams', () => { const unregister = ctx.effect(() => ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)) const abort = new AbortController() const empty = (async function* () {})() - const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, undefined, abort.signal) + const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, ownerPeer(ctx), abort.signal) const first = events[Symbol.asyncIterator]() await first.next() expect(ctx.typertGateway.hasLiveClient()).toBe(true) abort.abort() expect(ctx.typertGateway.hasLiveClient()).toBe(false) - const replacement = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, undefined, new AbortController().signal) + const replacement = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, ownerPeer(ctx), new AbortController().signal) const second = replacement[Symbol.asyncIterator]() try { await second.next() @@ -432,7 +434,7 @@ describe('Typert Remote streams', () => { 'feed/sync', { args: { label: 'wire' } }, toAsync([]), - undefined, + ownerPeer(ctx), new AbortController().signal, ) await expect(collect(source)).resolves.toEqual(['wire:one', 'wire:two']) @@ -441,7 +443,7 @@ describe('Typert Remote streams', () => { 'feed/echo', { args: { prefix: 'wire:' } }, toAsync(['a', 'b']), - undefined, + ownerPeer(ctx), new AbortController().signal, ) await expect(collect(echoed)).resolves.toEqual(['wire:a', 'wire:b']) @@ -450,13 +452,16 @@ describe('Typert Remote streams', () => { it('delivers uplink items through the in-process carrier and ends on half-close', async () => { const { ctx, service } = await setup(false) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '> ' }, uplink: toAsync(['a', 'b']), }))).resolves.toEqual(['> a', '> b']) expect(service.returns).toBe(1) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: 'x' }, }))).resolves.toEqual([]) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: 'x' }, })).rejects.toMatchObject({ code: 'gateway/signature-invalid', @@ -474,6 +479,7 @@ describe('Typert Remote streams', () => { }), } await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'ignore', args: { prefix: 'i' }, uplink: unread, }))).resolves.toEqual(['i:one', 'i:two']) await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) @@ -482,6 +488,7 @@ describe('Typert Remote streams', () => { it('fails a stream whose uplink item fails its codec, even when the method swallows the failure', async () => { const { ctx, service } = await setup(false) const rejected = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync(['ok', 1]), }) const iterator = rejected[Symbol.asyncIterator]() @@ -496,6 +503,7 @@ describe('Typert Remote streams', () => { expect(service.returns).toBe(1) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'swallow', args: {}, uplink: toAsync([1]), }))).rejects.toMatchObject({ code: 'gateway/input-invalid' }) }) @@ -504,6 +512,7 @@ describe('Typert Remote streams', () => { const { ctx, service } = await setup(false) const abort = new AbortController() const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'peek', args: {}, uplink: neverYielding(), signal: abort.signal, }) const iterator = source[Symbol.asyncIterator]() @@ -520,6 +529,7 @@ describe('Typert Remote streams', () => { const { ctx, service } = await setup(false) const abort = new AbortController() const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'hold', args: {}, uplink: toAsync(['unread']), signal: abort.signal, }) const iterator = source[Symbol.asyncIterator]() @@ -536,7 +546,9 @@ describe('Typert Remote streams', () => { it('exposes the call as ctx.invocation, delivers codec-less uplink items as unknown, and hands the uplink out once', async () => { const { ctx, service } = await setup(false) + const caller = peerFor(ctx, { kind: 'owner' }) await expect(collect(await ctx.typertGateway.stream({ + peer: caller, namespace: 'feed', method: 'context', args: { label: 'ctx' }, uplink: toAsync([1, { nested: true }]), }))).resolves.toEqual([ 'feed/context:feed:{"label":"ctx"}:ctx', @@ -547,18 +559,22 @@ describe('Typert Remote streams', () => { expect(service.peers.at(-1)?.id).toMatch(/^[0-9a-f-]{36}$/) expect(service.invocationOutsideCall()).toBeUndefined() await expect(collect(await ctx.typertGateway.stream({ + peer: caller, namespace: 'feed', method: 'context', args: { label: 'again' }, }))).resolves.toHaveLength(2) + expect(service.peers.at(-1)).toBe(caller) expect(service.peers.at(-1)).toBe(service.peers.at(-2)) await expect(service.peers.at(-1)?.dispose()).resolves.toBeUndefined() // Items sent to a method that never takes its uplink wait in the carrier and are dropped when the downlink ends. await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'sync', args: { label: 'closed' }, uplink: toAsync(['unread']), }))).resolves.toEqual(['closed:one', 'closed:two']) // Without a codec an item still has to be a JSON-safe value. await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'context', args: { label: 'bad' }, uplink: toAsync([1n]), }))).rejects.toMatchObject({ code: 'gateway/input-invalid', @@ -579,6 +595,7 @@ describe('Typert Remote streams', () => { }), } const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'hold', args: {}, uplink: stuck, signal: abort.signal, }) const iterator = source[Symbol.asyncIterator]() @@ -592,6 +609,7 @@ describe('Typert Remote streams', () => { it('closes the uplink before returning a method blocked on it', async () => { const { ctx } = await setup(false) const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'drain', args: {}, uplink: neverYielding(), }) const iterator = source[Symbol.asyncIterator]() @@ -608,6 +626,7 @@ describe('Typert Remote streams', () => { let index = 0 const returned = vi.fn(async (): Promise> => ({ done: true, value: undefined })) const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'hold', args: {}, uplink: { [Symbol.asyncIterator]: () => ({ next: () => reads[index++]!.promise, return: returned }) }, }) @@ -639,9 +658,9 @@ describe('Typert Remote streams', () => { const { ctx, service } = await setup(false) service.source = { [Symbol.iterator]: () => ({ next: () => new ReadResult() }) } const source = await ctx.typertGateway.stream(direction === 'downlink' - ? { namespace: 'feed', method: 'items', args: {} } + ? { peer: ownerPeer(ctx), namespace: 'feed', method: 'items', args: {} } : { - namespace: 'feed', method: 'echo', args: { prefix: '' }, + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: { [Symbol.asyncIterator]: () => ({ next: async () => new ReadResult() }) }, }) const iterator = source[Symbol.asyncIterator]() @@ -664,6 +683,7 @@ describe('Typert Remote streams', () => { const next = vi.fn((): IteratorResult => ({ done: false, value: 'item' })) service.source = { [Symbol.iterator]: () => ({ next }) } const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'items', args: {}, signal: abort.signal, }) const iterator = source[Symbol.asyncIterator]() @@ -687,9 +707,9 @@ describe('Typert Remote streams', () => { } service.source = { [Symbol.iterator]: () => ({ next }) } const source = await ctx.typertGateway.stream(direction === 'downlink' - ? { namespace: 'feed', method: 'items', args: {}, signal: abort.signal } + ? { peer: ownerPeer(ctx), namespace: 'feed', method: 'items', args: {}, signal: abort.signal } : { - namespace: 'feed', method: 'hold', args: {}, signal: abort.signal, + peer: ownerPeer(ctx), namespace: 'feed', method: 'hold', args: {}, signal: abort.signal, uplink: { [Symbol.asyncIterator]: () => ({ next }) }, }) const iterator = source[Symbol.asyncIterator]() @@ -706,6 +726,7 @@ describe('Typert Remote streams', () => { it('reports done on every read after the uplink ended', async () => { const { ctx } = await setup(false) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'rereads', args: {}, uplink: toAsync(['a', 'b']), }))).resolves.toEqual(['a', 'b', 'done']) }) @@ -713,6 +734,7 @@ describe('Typert Remote streams', () => { it('accepts a top-level undefined uplink item where no codec applies and rejects it where one does', async () => { const { ctx } = await setup(false) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'context', args: { label: 'absent' }, uplink: toAsync([undefined, 'x']), }))).resolves.toEqual([ 'feed/context:feed:{"label":"absent"}:absent', @@ -721,6 +743,7 @@ describe('Typert Remote streams', () => { 'typert gateway: feed/context: invocation.uplink() is available once per call', ]) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync([undefined]), }))).rejects.toMatchObject({ code: 'gateway/input-invalid', details: { endpoint: 'feed/echo', field: 'uplink' } }) }) @@ -732,6 +755,7 @@ describe('Typert Remote streams', () => { [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}), return: returned }), } await expect(ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'unary', args: { label: 'a' }, uplink: unread, })).rejects.toMatchObject({ code: 'gateway/signature-invalid' }) await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) @@ -740,6 +764,7 @@ describe('Typert Remote streams', () => { it('lets a unary method read uplink items while it runs', async () => { const { ctx } = await setup(false) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'unaryUplink', args: {}, uplink: toAsync(['a', 'b']), })).resolves.toEqual(['a', 'b']) }) @@ -747,6 +772,7 @@ describe('Typert Remote streams', () => { it('derives SRC descriptors whose uplink arrives without a codec', async () => { const { ctx } = await setup(false) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'srcEcho', args: { prefix: 's:' }, uplink: toAsync(['a', 2]), }))).resolves.toEqual(['s:a', 's:2']) }) @@ -756,6 +782,7 @@ describe('Typert Remote streams', () => { const window = { maxBytes: 512, frameOverheadBytes: 40 } for (const uplinkWindow of [undefined, window]) { await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync(['a']), uplinkWindow, }))).resolves.toEqual(['a']) } @@ -803,7 +830,7 @@ describe('Typert Remote streams', () => { ]) }) expect(service.returns).toBe(1) - expect(service.peers.at(-1)).toBe(ctx.connection.operator) + expect(service.peers.at(-1)).toBe(ctx.connection.browserPeer) sendOpen(socket, 'rejected', 'feed/echo', { prefix: '' }) socket.send(JSON.stringify({ type: 'item', streamId: 'rejected', value: 1 })) @@ -860,6 +887,7 @@ describe('Typert Remote streams', () => { const { ctx, service } = await setup(false) const abort = new AbortController() const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'follow', args: { label: 'a' }, @@ -870,35 +898,44 @@ describe('Typert Remote streams', () => { const pending = iterator.next() abort.abort(new Error('fixture cancellation')) await expect(pending).rejects.toThrow('Remote invocation "feed/follow" was aborted') - expect(service.signals).toEqual([abort.signal]) + // A stream's method signal also ends with its Peer, so it follows the caller's signal rather than being it. + expect(service.signals).toHaveLength(1) + expect(service.signals[0]?.reason).toBe(abort.signal.reason) expect(service.returns).toBe(1) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'sync', args: { label: 'b' }, }))).resolves.toEqual(['b:one', 'b:two']) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'invalid', args: {}, }))).resolves.toEqual([42]) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'nonJson', args: {}, }))).resolves.toEqual([1n]) await expect(ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'missing', args: {}, })).rejects.toMatchObject({ code: 'gateway/result-invalid' }) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'src', args: { label: 'c' }, }))).resolves.toEqual(['c:src']) const abortedBeforeOpen = new AbortController() abortedBeforeOpen.abort(new Error('cancelled before open')) await expect(ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'abortBeforeOpen', args: {}, signal: abortedBeforeOpen.signal, })).rejects.toThrow('Remote invocation "feed/abortBeforeOpen" was aborted') const abortedBeforeIteration = new AbortController() abortedBeforeIteration.abort(new Error('cancelled before iteration')) const preCancelled = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'sync', args: { label: 'ignored' }, signal: abortedBeforeIteration.signal, }) await expect(collect(preCancelled)).rejects.toThrow('Remote invocation "feed/sync" was aborted') @@ -907,9 +944,11 @@ describe('Typert Remote streams', () => { it('keeps unary and stream invocation modes distinct', async () => { const { ctx } = await setup(false) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'sync', args: { label: 'a' }, })).rejects.toMatchObject({ code: 'gateway/signature-invalid' } satisfies Partial) await expect(ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'unary', args: { label: 'a' }, })).rejects.toMatchObject({ code: 'gateway/signature-invalid' } satisfies Partial) }) @@ -1004,7 +1043,7 @@ describe('Typert Remote streams', () => { const idle: AsyncIterable = { [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}), return: returned }), } - const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, idle, undefined, new AbortController().signal) + const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, idle, ownerPeer(ctx), new AbortController().signal) await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) await events[Symbol.asyncIterator]().return?.() @@ -1259,7 +1298,7 @@ describe('Typert Remote streams', () => { it('retries a colliding Remote event Client id before opening the second generation', async () => { const { ctx } = await setup(true) - // Connection minted the operator Peer id above; only the event Client ids are counted below. + // Connection minted the browser Peer id above; only the event Client ids are counted below. randomUuid.mockClear() const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) @@ -1593,6 +1632,7 @@ async function setup( provideBrowserCredentials(ctx) } await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGatewayService, gatewayConfig) if (transport) { await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index e0fcbec649..8f3f6fddc4 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -7,7 +7,6 @@ import { apply as applyConnection, inject as connectionInject } from '@deepseek- import type { ConnectionRpcHandler, HostConnectionHandle, - PeerId, PeerScope, } from '@deepseek-ai/dsh-client-connection' import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver' @@ -23,7 +22,9 @@ import { } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-api-gateway' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import { provideBrowserCredentials } from './browser-credentials.ts' +import { ownerPeer } from './peer.ts' interface FixtureAgent { readonly id: string @@ -116,12 +117,12 @@ class FakeConnectionService extends Service { channel: string | undefined matches: ((endpoint: string) => boolean) | undefined handler: FakeRpcHandler | undefined - /** The operator Peer every call this fake dispatches speaks for. */ - readonly operator: PeerScope + /** The browser Peer every call this fake dispatches speaks for. */ + readonly browserPeer: PeerScope constructor(ctx: Context) { super(ctx, 'connection') - this.operator = { id: 'fake-operator' as PeerId, ctx, dispose: () => Promise.resolve() } + this.browserPeer = ownerPeer(ctx) } get rpc() { @@ -135,7 +136,7 @@ class FakeConnectionService extends Service { owner.effect(() => { this.channel = channel this.matches = matches - this.handler = (endpoint, payload, signal) => handler(endpoint, payload, signal, this.operator) + this.handler = (endpoint, payload, signal) => handler(endpoint, payload, signal, this.browserPeer) return () => { this.channel = undefined this.matches = undefined @@ -407,6 +408,7 @@ describe('TypertGatewayService', () => { const abort = new AbortController() await expect(caller.typertGateway.invoke({ + peer: ownerPeer(caller), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: ' ship ' } }, @@ -416,6 +418,7 @@ describe('TypertGatewayService', () => { expect(service.lastSignal).toBe(abort.signal) await expect(caller.typertGateway.invoke({ + peer: ownerPeer(caller), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'again' } }, @@ -431,6 +434,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [renameDescriptor()]) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -446,6 +450,7 @@ describe('TypertGatewayService', () => { const abort = new AbortController() await expect(caller.typertGateway.invoke({ + peer: ownerPeer(caller), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -464,6 +469,7 @@ describe('TypertGatewayService', () => { registerAgentLookup(ctx, { id: 'agent-1' }) await expect(ctx.extend({ fixtureScope: 'unbound' }).typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -477,6 +483,7 @@ describe('TypertGatewayService', () => { await dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -490,6 +497,7 @@ describe('TypertGatewayService', () => { ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -503,15 +511,19 @@ describe('TypertGatewayService', () => { await ctx.plugin(InheritedMethodService) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'exported', method: 'execute', args: { value: 'ship' }, })).resolves.toBe('ship') await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'empty', method: 'ping', args: {}, })).resolves.toBe('pong') await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'inherited', method: 'run', args: { value: 'land' }, })).resolves.toBe('land') await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'other', method: 'absent', args: {}, }), 'gateway/invocation-unavailable') }) @@ -521,6 +533,7 @@ describe('TypertGatewayService', () => { await colliding.plugin(CollidingWireService) registerAgentLookup(colliding, { id: 'agent-1' }) await expectCode(colliding.typertGateway.invoke({ + peer: ownerPeer(colliding), namespace: 'colliding-wire', method: 'run', args: { agentId: 'agent-1' }, @@ -528,6 +541,7 @@ describe('TypertGatewayService', () => { const missing = await setup() await expectCode(missing.ctx.typertGateway.invoke({ + peer: ownerPeer(missing.ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -537,6 +551,7 @@ describe('TypertGatewayService', () => { await contextCollision.plugin(ContextWireService) contextCollision.typert.contexts.registerHost('gatewayFixture', contextProvider(contextCollision.extend())) await expectCode(contextCollision.typertGateway.invoke({ + peer: ownerPeer(contextCollision), namespace: 'context-wire', method: 'run', args: { agentId: 'agent-1' }, @@ -551,6 +566,7 @@ describe('TypertGatewayService', () => { await disposeLookup() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -559,6 +575,7 @@ describe('TypertGatewayService', () => { registerAgentLookup(ctx, agent) await serviceFiber.dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -573,6 +590,7 @@ describe('TypertGatewayService', () => { await dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -583,6 +601,7 @@ describe('TypertGatewayService', () => { resolve: () => { throw new Error('provider failed') }, }) const error = await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -600,6 +619,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [renameDescriptor()]) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -615,6 +635,7 @@ describe('TypertGatewayService', () => { wire: 'differentAgentId', }) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -626,6 +647,7 @@ describe('TypertGatewayService', () => { resolve: () => undefined, }) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -640,6 +662,7 @@ describe('TypertGatewayService', () => { resolve: async () => { throw new Error('lookup failed') }, }) const failure = await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -652,6 +675,7 @@ describe('TypertGatewayService', () => { resolve: () => Promise.resolve(undefined), }) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -663,6 +687,7 @@ describe('TypertGatewayService', () => { resolve: async id => ({ id }), }) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -675,6 +700,7 @@ describe('TypertGatewayService', () => { await dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, @@ -684,12 +710,14 @@ describe('TypertGatewayService', () => { it('seeds the no-downgrade guard from definitions present before Gateway startup', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) const dispose = registerStrict(ctx, [passthroughDescriptor()]) await ctx.plugin(TypertGatewayService) await ctx.plugin(GoalService) await dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, @@ -699,6 +727,7 @@ describe('TypertGatewayService', () => { it('retains the no-downgrade guard across Gateway Service reloads', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) const gatewayFiber = ctx.plugin(TypertGatewayService) await gatewayFiber await ctx.plugin(GoalService) @@ -709,6 +738,7 @@ describe('TypertGatewayService', () => { await ctx.plugin(TypertGatewayService) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, @@ -721,6 +751,7 @@ describe('TypertGatewayService', () => { await ctx.plugin(SecondSharedService) const error = await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'shared', method: 'run', args: { value: 'ship' }, @@ -739,6 +770,7 @@ describe('TypertGatewayService', () => { const ctx = await setupGateway() await ctx.plugin(testCase.plugin) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: testCase.namespace, method: 'run', args: testCase.args, @@ -753,6 +785,7 @@ describe('TypertGatewayService', () => { ctx.typert.lookups.register('gatewayFixtureAlias', provider) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -764,16 +797,19 @@ describe('TypertGatewayService', () => { registerAgentLookup(ctx, { id: 'agent-1' }) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { request: { title: 'ship' } }, }), 'gateway/arguments-invalid') await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true }, }), 'gateway/arguments-invalid') await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: [] as unknown as Record, @@ -786,6 +822,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [strictOnlyDescriptor()]) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'strictOnly', args: { request: { title: 1 } }, @@ -793,6 +830,7 @@ describe('TypertGatewayService', () => { service.nextResult = { title: 1 } await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'strictOnly', args: { request: { title: 'ship' } }, @@ -805,6 +843,7 @@ describe('TypertGatewayService', () => { service.nextResult = 1n await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'strictOnly', args: { request: { title: 'ship' } }, @@ -824,6 +863,7 @@ describe('TypertGatewayService', () => { ])('rejects non-JSON SRC input %#', async (value) => { const { ctx } = await setup() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value }, @@ -836,6 +876,7 @@ describe('TypertGatewayService', () => { // cannot see which are optional, so an absent field is admitted; the case // above keeps an explicitly undefined field rejected. await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: {}, @@ -848,6 +889,7 @@ describe('TypertGatewayService', () => { const cyclic: { self?: unknown } = {} cyclic.self = cyclic await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: cyclic }, @@ -856,6 +898,7 @@ describe('TypertGatewayService', () => { const result = new Date(0) service.nextResult = result await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: null }, @@ -865,6 +908,7 @@ describe('TypertGatewayService', () => { it('accepts dense JSON and rejects decorated arrays and object properties', async () => { const { ctx } = await setup() await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: [1, { nested: true }] }, @@ -882,6 +926,7 @@ describe('TypertGatewayService', () => { Object.defineProperty(accessor, 'value', { get: () => true, enumerable: true }) for (const value of [sparseWithExtra, symbolArray, symbolObject, hidden, accessor]) { await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value }, }), 'gateway/input-invalid') } @@ -896,6 +941,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [createDescriptor()]) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -906,6 +952,7 @@ describe('TypertGatewayService', () => { const ctx = await setupGateway() await ctx.plugin(WrongBindingService) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'wrong-binding', method: 'run', args: { value: 'ship' }, @@ -914,6 +961,7 @@ describe('TypertGatewayService', () => { await ctx.plugin(GoalService) registerStrict(ctx, [{ ...passthroughDescriptor(), method: 'missing' }]) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'missing', args: { value: 'ship' }, @@ -931,6 +979,7 @@ describe('TypertGatewayService', () => { method: 'run', }]) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'no-binding', method: 'run', args: { value: 'ship' }, }), 'gateway/binding-invalid') @@ -954,6 +1003,7 @@ describe('TypertGatewayService', () => { }], }) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'plain', method: 'run', args: { value: 'land' }, })).resolves.toBe('land') }) @@ -968,6 +1018,7 @@ describe('TypertGatewayService', () => { }) try { await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'missing-method', method: 'run', args: { value: 'ship' }, }), 'gateway/method-unavailable') } finally { @@ -981,6 +1032,7 @@ describe('TypertGatewayService', () => { service.businessError = failure await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'fail', args: { request: { reason: 'fixture' } }, @@ -990,6 +1042,7 @@ describe('TypertGatewayService', () => { it('reports an absent endpoint without retaining receiver state', async () => { const { ctx } = await setup() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'absent', args: {}, @@ -999,6 +1052,7 @@ describe('TypertGatewayService', () => { it('mounts a shared /api interceptor through an optional Connection and returns existing RPC results', async () => { const ctx = new Context().extend({ fixtureScope: 'rpc-caller' }) await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(FakeConnectionService) const gatewayFiber = ctx.plugin(TypertGatewayService) await gatewayFiber @@ -1031,11 +1085,12 @@ describe('TypertGatewayService', () => { abort.abort(new Error('client disconnected')) expect(service.lastSignal?.aborted).toBe(true) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'direct' } }, })).resolves.toMatchObject({ title: 'direct' }) - expect(service.lastPeer).toBe(connection.operator) + expect(service.lastPeer).toBe(connection.browserPeer) const invalid = await handler('goals/create', { invalid: true }, signal) expect(invalid).toMatchObject({ ok: false, @@ -1207,6 +1262,7 @@ describe('TypertGatewayService', () => { it('claims and validates in-process Remote event results for the active Client generation', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(FakeConnectionService) await ctx.plugin(TypertGatewayService) const connection = rawConnection(ctx) @@ -1229,7 +1285,9 @@ describe('TypertGatewayService', () => { }) })(), { home: '/home/fixture' }) const carrier = new AbortController() - const events = rawGatewayEventHarness(ctx).openRemoteEvents({ args: {} }, carrier.signal) + const events = (await ctx.typertGateway.wireStream.open( + '$events', { args: {} }, (async function* () {})(), ownerPeer(ctx), carrier.signal, + ))[Symbol.asyncIterator]() const opening = await events.next() expect(opening).toMatchObject({ done: false, @@ -1252,7 +1310,7 @@ describe('TypertGatewayService', () => { value: undefined, }) - await events.return(undefined) + await events.return?.(undefined) await unregister() await ctx.fiber.dispose() }) @@ -1260,6 +1318,7 @@ describe('TypertGatewayService', () => { it('preserves a lookup policy rejection through the Connection RPC result', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(FakeConnectionService) await ctx.plugin(TypertGatewayService) await ctx.plugin(GoalService) @@ -1284,6 +1343,7 @@ describe('TypertGatewayService', () => { it('caches SRC ownership until the Cordis Service set changes', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(FakeConnectionService) await ctx.plugin(TypertGatewayService) const observedFiber = ctx.plugin(ObservedClaimService) @@ -1319,6 +1379,7 @@ describe('TypertGatewayService', () => { const connectionFiber = ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) await connectionFiber await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) const gatewayFiber = ctx.plugin(TypertGatewayService) await gatewayFiber const goalFiber = ctx.plugin(GoalService) @@ -1428,6 +1489,7 @@ async function setup(): Promise<{ async function setupGateway(): Promise { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGatewayService) return ctx } @@ -1444,17 +1506,6 @@ function rawConnection(ctx: Context): FakeConnectionService { return receiver[symbols.original] ?? receiver } -interface GatewayEventHarness { - openRemoteEvents(payload: unknown, signal: AbortSignal): AsyncGenerator -} - -function rawGatewayEventHarness(ctx: Context): GatewayEventHarness { - const receiver = ctx.get('typertGateway') as unknown as GatewayEventHarness & { - [symbols.original]?: GatewayEventHarness - } - return receiver[symbols.original] ?? receiver -} - function registerStrict(ctx: Context, descriptors: readonly InvocationDescriptor[]): () => Promise { return ctx.typert.register({ package: '@fixture/gateway', diff --git a/packages/api/gateway/tests/peer.ts b/packages/api/gateway/tests/peer.ts new file mode 100644 index 0000000000..2571e791d5 --- /dev/null +++ b/packages/api/gateway/tests/peer.ts @@ -0,0 +1,30 @@ +/** Owner Peers for Gateway specs: every in-process call names its Peer explicitly. */ +import type { Context } from '@deepseek-ai/cordis' +import { Peer } from '@deepseek-ai/dsh-client-connection' +import type { PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol' + +const owners = new WeakMap() + +/** + * The owner Peer of one test Context tree, created on first use. + * @param ctx - any Context of the tree; the Peer hangs under its root. + * @returns the tree's shared owner Peer. + */ +export function ownerPeer(ctx: Context): PeerScope { + let peer = owners.get(ctx.root) + if (peer === undefined) { + peer = new Peer(ctx.root, { principal: { kind: 'owner' } }) + owners.set(ctx.root, peer) + } + return peer +} + +/** + * A fresh Peer of one principal under the test Context tree. + * @param ctx - any Context of the tree; the Peer hangs under its root. + * @param principal - identity the Peer speaks for. + * @returns an independent Peer. + */ +export function peerFor(ctx: Context, principal: Principal): PeerScope { + return new Peer(ctx.root, { principal }) +} diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index 965cbb7989..87c0784d5d 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -575,6 +575,8 @@ async function fixturePeer(): Promise { return { id: 'fixture-peer' as PeerId, ctx: fiber.ctx, + principal: { kind: 'owner' }, + signal: new AbortController().signal, dispose: async () => { await fiber.dispose() }, } } diff --git a/packages/api/gateway/tests/strict-source.host.spec.ts b/packages/api/gateway/tests/strict-source.host.spec.ts index 2bd9c35a17..fcf4988c12 100644 --- a/packages/api/gateway/tests/strict-source.host.spec.ts +++ b/packages/api/gateway/tests/strict-source.host.spec.ts @@ -3,7 +3,9 @@ import { afterEach, expect, it } from 'vitest' import { z } from 'zod' import { bindTypertRemote, Remote } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import TypertGateway from '../src/index.ts' +import { ownerPeer } from './peer.ts' const contexts: Context[] = [] afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) }) @@ -39,6 +41,7 @@ async function setup() { const ctx = new Context() contexts.push(ctx) await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGateway) const service = new ItemsService(ctx) return { ctx, service } @@ -47,11 +50,11 @@ async function setup() { it('validates source dispatch with the ordinary local Registry before invoking the Service', async () => { const { ctx, service } = await setup() ctx.typert.register(contribution) - await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } } })) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } }, peer: ownerPeer(ctx) })) .resolves.toBe('ready') for (const args of [{}, { request: { title: 'ready', extra: true } }, { request: { title: 1 } }]) { const code: unknown = expect.stringMatching(/^gateway\/(arguments|input)-invalid$/) - await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args })) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args, peer: ownerPeer(ctx) })) .rejects.toMatchObject({ code }) } expect(service.calls).toBe(1) @@ -59,7 +62,7 @@ it('validates source dispatch with the ordinary local Registry before invoking t it('allows limited source fallback without recovering erased object constraints', async () => { const { ctx, service } = await setup() - await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready', extra: true } } })) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready', extra: true } }, peer: ownerPeer(ctx) })) .resolves.toBe('ready') expect(service.calls).toBe(1) }) @@ -71,7 +74,7 @@ it('refuses source fallback after the strict definition owner unloads', async () expect(ctx.typert.local.get('items/read')).toBe(contribution.invocations[0]) await owner.dispose() expect(ctx.typert.local.get('items/read')).toBeUndefined() - await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } } })) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } }, peer: ownerPeer(ctx) })) .rejects.toMatchObject({ code: 'gateway/definition-unavailable' }) expect(service.calls).toBe(0) }) diff --git a/packages/api/gateway/tsconfig.host.json b/packages/api/gateway/tsconfig.host.json index 5cb68a7183..7694cea7cb 100644 --- a/packages/api/gateway/tsconfig.host.json +++ b/packages/api/gateway/tsconfig.host.json @@ -6,6 +6,7 @@ "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" }, "files": [ + "src/access.ts", "src/index.ts", "src/remote-error-codes.ts", "src/stream-protocol.ts", @@ -28,6 +29,9 @@ { "path": "../../client/connection/tsconfig.host.json" }, + { + "path": "../remote-access/tsconfig.host.json" + }, { "path": "../../host/webserver" }, diff --git a/packages/api/remote-access/README.i18n.yaml b/packages/api/remote-access/README.i18n.yaml new file mode 100644 index 0000000000..df8ad59235 --- /dev/null +++ b/packages/api/remote-access/README.i18n.yaml @@ -0,0 +1,34 @@ +# 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 packages/api/remote-access/README.md +/: + en: bbd88553f12d4f3d + zh: c5fa4ac3f9746170 +/deepseek-ai-dsh-api-remote-access: + en: 15713a51f827c9c1 + zh: a1349a5f83efc82c +/deepseek-ai-dsh-api-remote-access/summary: + en: ee81bdee09f42c9b + zh: 41c6864e59c24519 +/deepseek-ai-dsh-api-remote-access/table-of-contents: + en: a18ecc232e3c3933 + zh: b8e43580403f6d77 +/deepseek-ai-dsh-api-remote-access/host-service-remoteaccessservice-ctx-key-remoteaccess: + en: 9175835ea5c5bff1 + zh: 0e33b266fdac3d13 +/deepseek-ai-dsh-api-remote-access/client-face: + en: 1db01235b6af84fa + zh: c0ced511cc1841a0 +/deepseek-ai-dsh-api-remote-access/model-experience: + en: 8b07250cecacd3aa + zh: d28ab93980bdcdad +/deepseek-ai-dsh-api-remote-access/model-experience/kv-cache-effect: + en: 60835eca1cee4e0e + zh: 3b5579ceccd5b5e7 +/deepseek-ai-dsh-api-remote-access/known-limitations-and-deferred-work: + en: 66b483bf5144ac46 + zh: 84bde5e0b1af270b +/deepseek-ai-dsh-api-remote-access/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: e88aeb617ffb74ea diff --git a/packages/api/remote-access/README.md b/packages/api/remote-access/README.md new file mode 100644 index 0000000000..8d9df9e542 --- /dev/null +++ b/packages/api/remote-access/README.md @@ -0,0 +1,97 @@ +--- +description: "Method-level Remote access control for non-owner Peers: role-based policy decisions, list filtering, and the per-Peer access snapshot." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-remote-access + +English | [中文](README.zh.md) + +## Summary + +Decides which Remote methods a non-owner Peer may call and which forwarded events it may receive. Business packages define roles, each an action set over one resource kind, and register one policy per non-owner principal kind that binds roles to resources. The [API Gateway](../gateway/README.md) resolves the resources a call or event touches and asks this package before it resolves lookups or decodes arguments. List methods filter items through `allows()`, and each Peer follows its own access snapshot, so the Client can hide entries the Host would refuse. The [Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns the design. + +## Table of Contents + +- [Host service: `RemoteAccessService` (ctx key: `remoteAccess`)](#host-service) +- [Client face](#client-face) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Host service: `RemoteAccessService` (ctx key: `remoteAccess`) + +Every composition that mounts [API Gateway](../gateway/README.md) mounts this service: the Gateway injects `remoteAccess` and does not start without it. [`bundle/base`](../../bundle/base/cordis.patch.yml) loads it beside `typert-gateway`. The owner (`{ kind: 'owner' }`) is never checked; every method below concerns non-owner principals. + +| Member | Contract | +|---|---| +| `registerPolicy(kind, policy)` | Registers the `RemoteAccessPolicy` of one non-owner principal kind; its `bindings()` and `explain()` receive principals typed by that kind. A second policy for the same kind throws. The registration is a Cordis effect of the caller's fiber; registration and removal recompute open snapshots. | +| `invalidate(principal?)` | Reports that bindings or condition answers for one principal, or for every principal, may have changed. A business package calls it on every grant, revocation, mode change, and expiry. | +| `decide(request)` | Gateway-only. `request` is `{ principal, action, resources, args? }`: the caller or recipient, the endpoint `ns/method` or `event:`, the valid resources the Gateway resolved, and a call's arguments validated against their Typert-compiled types, with lookup parameters as wire identities. Returns `{ kind: 'allow' }` or `{ kind: 'deny', reason }`; the Gateway throws `remote-access/denied` with details `{ action, reason }` for a denied call and drops a denied event without building an error. The owner and `remoteAccess/follow` are always allowed. | +| `allows(resource)` | Reads the invocation of the Context that accessed the service, so a list method calls `this.ctx.remoteAccess.allows(resource)` for each item. Decides the call's endpoint action on that resource without arguments, reading the bindings again on every check. True for the owner and outside a Remote call. | +| `snapshotFor(principal)` | The baseline a page opens with: `{ type: 'baseline', owner: true }` for the owner, otherwise the principal's current grants. | +| `follow(signal)` | Remote stream `remoteAccess/follow`: one `baseline`, then `upsert` and `remove` frames. The owner receives only `{ type: 'baseline', owner: true }`. The stream ends when `signal` aborts or the service unloads, so a reloaded service starts every reader from a fresh baseline. | + +A role `{ name, resource, actions }` is a plain value naming an action set over one resource kind, `global` or a kind declared in `TypertResourceMap`. A binding `{ role, resource, condition? }` grants the role on one resource of the role's kind. A policy answers `bindings(principal)` and an optional `explain(request)` synchronously from state it holds in memory. A request is allowed only when, on every resolved resource, some binding names that resource, its role lists the action, and it has no condition or its condition allows. Resources are independent: a grant on `global` implies nothing on any other resource, and no resource implies another. A condition receives `{ action, resource, args? }` and runs only for a binding whose resource and role match; `args` is absent for events, snapshots, and list filtering. `bindings()` runs once per `decide()`, once per `allows()` check, and once per snapshot computation; it is never cached across decisions, so time-dependent state applies to the next call. + +The Gateway resolves resources from `@Remote` and `@RemoteScope` `resources` readers, lookup and Host Context adapter `resource` declarations, and the default fields in `typert.resources`, and the [API Gateway](../gateway/README.md) owns that order; this package reads no argument field. A call that names no resource touches the `global` resource alone, so only a `global` role can allow it; putting an action in a `global` role lets the principal call it on any resource the call does not name. The Gateway refuses a call whose resources are invalid with `resource-invalid` without asking `decide()`. + +When `bindings()`, a condition, or `explain()` throws, the decision is refused with `policy-failed`; in a snapshot, a throwing `bindings()` leaves the principal no grant and a throwing condition excludes its action. A faulty rule therefore never breaks the shared `$events` source, the snapshot stream, or page rendering. The service logger reports the first failure of each policy after each `invalidate()` and each policy registration or removal; later failures of that policy stay silent until then. + +A forwarded event is decided as the action `event:` on the resources its forwarding entry's readers name, or on `global` when the entry declares none, and reaches a non-owner whole or not at all; its payload is never trimmed. An event carries every resource it declares, so a reader that returns `undefined` makes the event's resources invalid, and the Gateway delivers it to no non-owner without asking `decide()`. An event names at most one resource, so collection changes travel on follow streams (`session/follow`, `session/control`, `workspace/follow`), whose methods decide each item inline through `allows()`. + +The service adds a `global` row named `REMOTE_ACCESS_SNAPSHOT_KEY` (`__DSH_REMOTE_ACCESS__`) holding 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 a liveshare viewer page rendered on the owner Host, places an assignment of that global to `ctx.remoteAccess.snapshotFor(principal)` after every index injection row, for example a `global` row appended to the end of `ctx.webServer.collectIndexInjections()`. The later assignment replaces the owner baseline, and the Client entry reads the global when it mounts, so the page opens with the viewer's grants instead of the owner's. + +| `details.reason` of `remote-access/denied` | Cause | +|---|---| +| `resource-invalid` | Given by the Gateway before `decide()`: a resource reader threw or returned an empty list, or a resource id, including a Context identity, is not a non-empty string. | +| `no-allow` | The principal kind has no policy, or no binding allows the action on some resource and neither a condition nor `explain()` names a reason. | +| `policy-failed` | `bindings()`, a condition, or `explain()` threw; the failure is logged as described above. | +| condition or `explain()` reason | On the first resource no binding allows, the first refusing condition of a matching binding names the reason; without one, `explain(request)` does. | + +Snapshot grants list, per resource the principal's bindings name, the sorted endpoint actions of those bindings' roles that `decide()` allows there without call arguments, so the snapshot agrees with `decide()` for the same principal, action, and resource; a resource with no such action gets no grant, and the stream pushes `remove` once its allowed set becomes empty. `event:` actions never enter the snapshot. Snapshots are recomputed only after `invalidate()` and policy registration or removal. + + +## Client face + +The Client entry reads the baseline the index injected under `REMOTE_ACCESS_SNAPSHOT_KEY` synchronously, mounts the generated `remoteAccess` contribution itself through `mountAccess(ctx, contribution, initial)`, follows the snapshot stream, and provides: + +- the Client service `ctx.remoteAccess.source(kind, id, action)`, an identity-stable boolean source per `(kind, id, action)` for one resource of a declared kind; +- the standard Hook `useRemoteAccess(action)`, which answers on the `global` resource. + +Domain UI packages publish their resource Hooks over `source()`: [ui-session](../../client/ui-session/README.md) provides `useSessionAccess(sessionId, action)` and the Session-scope `useAccess(action)` on Session resources, a different name from the `global` `useRemoteAccess`, and [ui-workspace](../../client/ui-workspace/README.md) provides `useWorkspaceAccess(workspaceId, action)` on Workspace resources. A Hook's `action` is a `RemoteEndpoint`, an endpoint some Client Remote namespace declares. A non-owner answer is whether that resource's grant lists the action and never falls back to the `global` answer, so a resource without a grant, or whose grant lists no action, answers false. Every answer is true for the owner; a non-owner page without an injected baseline, and a non-owner page whose stream fails terminally, answers false; the terminal failure is reported with its error, for a non-owner with the advice to reload the page and for the owner with the note that its answers are unaffected; a reconnect keeps the last snapshot. A malformed injected baseline fails the mount. Hooks never issue requests, and the Host still checks every call. [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md) replaces this row with allow-all access in Client specs. + + +## Model Experience + +None, as this package decides Remote access for Client callers and registers nothing model-facing. + +#### KV Cache effect + +No direct effect. + +## Known Limitations and Deferred Work + + + +- Snapshot and list-filtering decisions carry no call arguments, so a condition that refines its answer from `args` can refuse a call that the snapshot shows as allowed. +- Resource readers and automatic resolution must cover every resource a method touches; a resource the call does not name is not checked, so a principal granted on the named resources reaches it. +- Default resource resolution matches wire field names; adding a field with a registered kind's name to a method adds that kind's resource to the call. +- Exact Connection Fetch routes do not pass through the Gateway; the shared Fetch handler refuses them to every non-owner Peer with 403 until each route declares its own access rule. +- Interaction participation (answering forwarded waterfalls) is owner-only: non-owner `$events` streams receive only the broadcast frames whose `event:` action the policy allows on the event's resources. +- `api-session/added` carries the whole `SessionSummary`, so a non-owner allowed to receive it also learns its `parentSessionId`. +- A granted Session's cwd and tool paths already contain the owner's absolute paths, so the `$events` ready frame gives a non-owner the full `host`, including `home`; the Gateway does not hide `home` separately. +- `workspace/follow` notices a Workspace whose visibility a policy revoked only when that Workspace or the Workspace order next changes, and then replaces the non-owner's baseline; an access change alone sends no frame. + + +### Dev Note + +

+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/api/remote-access/README.zh.md b/packages/api/remote-access/README.zh.md new file mode 100644 index 0000000000..8d593f7132 --- /dev/null +++ b/packages/api/remote-access/README.zh.md @@ -0,0 +1,97 @@ +--- +description: "非 owner Peer 的 Remote 方法级访问控制:基于角色的 policy 判定、列表过滤与每个 Peer 的访问快照。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-remote-access + +[English](README.md) | 中文 + +## 概述 + +决定非 owner Peer 可以调用哪些 Remote 方法、可以收到哪些转发事件。业务包定义 role(限定一种资源种类的 action 集合),并为每种非 owner principal kind 注册一个 policy,把 role 绑定到资源上。[API Gateway](../gateway/README.zh.md) 解析一次调用或一个事件访问的资源,并在解析 lookup、解码参数之前询问本包。列表方法经 `allows()` 逐项过滤;每个 Peer 订阅自己的访问快照,Client 据此隐藏 Host 会拒绝的入口。设计由 [Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 负责。 + +## 目录 + +- [Host 服务:`RemoteAccessService`(ctx key:`remoteAccess`)](#host-service) +- [Client 面](#client-face) +- [模型体验](#model-experience) +- [已知限制与暂缓事项](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## Host 服务:`RemoteAccessService`(ctx key:`remoteAccess`) + +挂载 [API Gateway](../gateway/README.zh.md) 的组合都要挂载本服务:Gateway 注入 `remoteAccess`,没有它就不启动。[`bundle/base`](../../bundle/base/cordis.patch.yml) 紧随 `typert-gateway` 装配它。owner(`{ kind: 'owner' }`)从不受检查;下列成员都只针对非 owner principal。 + +| 成员 | 契约 | +|---|---| +| `registerPolicy(kind, policy)` | 为一种非 owner principal kind 注册 `RemoteAccessPolicy`;其 `bindings()` 与 `explain()` 收到的 principal 按该 kind 定型。同一 kind 再注册一个 policy 即抛错。登记是调用方 fiber 的 Cordis effect;注册与撤回都会重新计算已打开的快照。 | +| `invalidate(principal?)` | 报告某个 principal(省略时为所有 principal)的 binding 或 condition 结论可能已变化。业务包在每次授权、撤权、模式切换与过期时调用。 | +| `decide(request)` | 仅供 Gateway 使用。`request` 为 `{ principal, action, resources, args? }`:调用方或接收方、端点 `ns/method` 或 `event:`、Gateway 解析出的合法资源,以及调用中按 Typert 编译类型校验后的参数(lookup 参数为 wire 身份)。返回 `{ kind: 'allow' }` 或 `{ kind: 'deny', reason }`;调用被拒时由 Gateway 抛出 details 为 `{ action, reason }` 的 `remote-access/denied`,事件被拒时由 Gateway 直接丢弃、不构造错误。owner 与 `remoteAccess/follow` 恒放行。 | +| `allows(resource)` | 读访问本服务的 Context 上的 invocation,因此列表方法对每一项调用 `this.ctx.remoteAccess.allows(resource)`。不带参数判定该调用的端点 action 在该资源上是否被允许,每次判定都重新读取 binding。owner 与 Remote 调用之外恒为 true。 | +| `snapshotFor(principal)` | 页面打开时的 baseline:owner 为 `{ type: 'baseline', owner: true }`,否则为该 principal 当前的 grant。 | +| `follow(signal)` | Remote stream `remoteAccess/follow`:一个 `baseline`,之后是 `upsert` 与 `remove` 帧。owner 只收到 `{ type: 'baseline', owner: true }`。`signal` abort 或服务卸载时流结束,因此重载后的服务从新的 baseline 回答每个读者。 | + +role `{ name, resource, actions }` 是普通值,表示限定一种资源种类(`global` 或在 `TypertResourceMap` 中声明的种类)的 action 集合。binding `{ role, resource, condition? }` 在该 role 种类的一个资源上授予该 role。policy 根据预先载入内存的状态同步回答 `bindings(principal)` 与可选的 `explain(request)`。只有在每个已解析资源上都有某个 binding 指向该资源、其 role 列出该 action、且没有 condition 或 condition 放行时,请求才被放行。资源之间相互独立:`global` 上的授权不蕴含任何其他资源上的授权,任何资源也不蕴含另一个资源。condition 收到 `{ action, resource, args? }`,只对资源与 role 都匹配的 binding 调用;事件、快照与列表过滤时没有 `args`。`bindings()` 在每次 `decide()`、每次 `allows()` 判定与每次快照计算中各调用一次,不跨判定缓存,因此依赖时间的状态对下一次调用即时生效。 + +Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lookup 与 Host Context adapter 的 `resource` 声明、`typert.resources` 中的默认字段解析资源,解析顺序由 [API Gateway](../gateway/README.zh.md) 负责;本包不读取任何参数字段。不指明任何资源的调用只访问 `global` 资源,因此只有 `global` 级 role 能放行;把某个 action 放进 `global` 级 role,等于允许该 principal 在调用未指明的任何资源上调用它。资源非法的调用由 Gateway 以 `resource-invalid` 拒绝,不询问 `decide()`。 + +`bindings()`、condition 或 `explain()` 抛错时,本次判定以 `policy-failed` 拒绝;计算快照时,`bindings()` 抛错即该 principal 没有 grant,condition 抛错即该 action 不放行。有问题的规则因此不会破坏共享的 `$events` 事件源、快照流或页面渲染。每次 `invalidate()` 以及每次 policy 注册或撤回之后,服务日志对每个 policy 只报告第一次失败;此后该 policy 的失败不再记录,直到下一次上述变化。 + +转发事件按其转发条目的读取函数指明的资源(条目未声明时为 `global`)判定 action `event:`,对非 owner 整条送达或整条不送,从不裁剪 payload。事件携带其声明的每个资源,因此读取函数返回 `undefined` 时该事件的资源非法,Gateway 不询问 `decide()`,不送达任何非 owner。一个事件至多指明一个资源,因此集合类变化走 follow 类流(`session/follow`、`session/control`、`workspace/follow`),由这些方法内联经 `allows()` 逐项判定。 + +本服务经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入名为 `REMOTE_ACCESS_SNAPSHOT_KEY`(`__DSH_REMOTE_ACCESS__`)的 `global` 行,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如在 owner Host 上渲染 liveshare 读者页)在所有 index 注入行之后放置一条把该全局赋值为 `ctx.remoteAccess.snapshotFor(principal)` 的语句,例如追加到 `ctx.webServer.collectIndexInjections()` 末尾的一条 `global` 行。后写的赋值覆盖 owner baseline,Client 入口在挂载时读取该全局,因此页面以读者的 grant 而非 owner 的快照打开。 + +| `remote-access/denied` 的 `details.reason` | 原因 | +|---|---| +| `resource-invalid` | 由 Gateway 在 `decide()` 之前给出:资源读取函数抛错或返回空列表,或某个资源 id(含 Context 身份)不是非空字符串。 | +| `no-allow` | 该 principal kind 没有 policy,或在某个资源上没有 binding 放行该 action,且 condition 与 `explain()` 都没有给出原因。 | +| `policy-failed` | `bindings()`、condition 或 `explain()` 抛错;该失败按上文规则记录日志。 | +| condition 或 `explain()` 的原因 | 在第一个没有 binding 放行的资源上,取匹配 binding 中第一个拒绝的 condition 的原因;没有时取 `explain(request)` 的返回值。 | + +快照 grant 按该 principal 的 binding 所指的每个资源,列出这些 binding 的 role 中、经 `decide()` 在该资源上不带调用参数放行的端点 action(已排序),因此在同一 principal、action 与资源上快照与 `decide()` 一致;没有此类 action 的资源不出 grant,放行集合变空时推送 `remove`。`event:` action 不进入快照。快照只在 `invalidate()` 与 policy 注册或撤回之后重算。 + + +## Client 面 + +Client 入口同步读取 index 注入在 `REMOTE_ACCESS_SNAPSHOT_KEY` 下的 baseline,经 `mountAccess(ctx, contribution, initial)` 自行挂载生成的 `remoteAccess` contribution,订阅快照流,并提供: + +- Client 服务 `ctx.remoteAccess.source(kind, id, action)`,为已声明种类的某个资源上的每个 `(kind, id, action)` 提供一个身份稳定的布尔 source; +- 标准 Hook `useRemoteAccess(action)`,回答 `global` 资源上的访问。 + +领域 UI 包在 `source()` 之上发布各自资源的 Hook:[ui-session](../../client/ui-session/README.zh.md) 提供 Session 资源上的 `useSessionAccess(sessionId, action)` 与会话作用域的 `useAccess(action)`(与 `global` 资源上的 `useRemoteAccess` 不同名),[ui-workspace](../../client/ui-workspace/README.zh.md) 提供 Workspace 资源上的 `useWorkspaceAccess(workspaceId, action)`。Hook 的 `action` 类型为 `RemoteEndpoint`,即某个 Client Remote namespace 声明的端点。非 owner 的答案是该资源的 grant 是否列出该 action,不回退 `global` 答案,因此没有 grant 或 grant 未列出任何 action 的资源回答 false。owner 的所有答案为 true;没有注入 baseline 的非 owner 页面、快照流终止失败的非 owner 页面,所有答案为 false;终止失败会连同错误一并报告,非 owner 附“刷新页面重连”的提示,owner 附“答案不受影响”的说明;重连期间保留上次快照。注入的 baseline 格式错误时挂载失败。Hook 从不发请求,Host 仍检查每次调用。[`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 在 Client spec 中以全允许的访问替换本行。 + + +## 模型体验 + +无:本包为 Client 调用方判别 Remote 访问,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无直接影响。 + +## 已知限制与暂缓事项 + + + +- 快照与列表过滤的判定不带调用参数,因此按 `args` 细化结论的 condition 可能拒绝快照显示为允许的调用。 +- 资源读取函数与自动解析必须覆盖方法访问的全部资源;调用未指明的资源不受检查,持有已指明资源授权的 principal 可以访问它。 +- 默认资源解析按 wire 字段名匹配;给方法新增与已登记种类同名的字段,会让调用多访问该种类的资源。 +- exact Connection Fetch route 不经过 Gateway;在各路由声明自己的访问规则之前,共享 Fetch handler 对所有非 owner Peer 返回 403。 +- 交互参与(回答转发的 waterfall)仅限 owner:非 owner 的 `$events` 流只收到 policy 在事件资源上放行其 `event:` action 的广播帧。 +- `api-session/added` 携带完整的 `SessionSummary`,因此被允许接收它的非 owner 也会得知其 `parentSessionId`。 +- 被授权 Session 的 cwd 与工具路径本就包含 owner 的绝对路径,因此 `$events` ready 帧向非 owner 提供完整的 `host`(含 `home`);Gateway 不单独隐藏 `home`。 +- policy 撤销某个 Workspace 的可见性后,`workspace/follow` 只在该 Workspace 或 Workspace 顺序下一次变化时察觉并重推非 owner 的 baseline;仅访问变化不会发送帧。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/api/remote-access/package.json b/packages/api/remote-access/package.json new file mode 100644 index 0000000000..74d0b741db --- /dev/null +++ b/packages/api/remote-access/package.json @@ -0,0 +1,83 @@ +{ + "name": "@deepseek-ai/dsh-api-remote-access", + "description": "Method-level Remote access control for non-owner Peers and the per-Peer access snapshot", + "version": "0.2.1-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/remote-access" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-ui-renderer" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "zod": "4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-host-webserver": "workspace:*", + "@deepseek-ai/dsh-typert-registry": "workspace:*" + } +} diff --git a/packages/api/remote-access/src/client/index.ts b/packages/api/remote-access/src/client/index.ts new file mode 100644 index 0000000000..396f56a16a --- /dev/null +++ b/packages/api/remote-access/src/client/index.ts @@ -0,0 +1,33 @@ +/** + * Client face: starts from the access snapshot the index injected, follows + * the calling Peer's snapshot stream, provides the `remoteAccess` Client + * service whose `source(kind, id, action)` answers for any declared-kind + * resource, and publishes `useRemoteAccess(action)` on the global resource. + * Domain UI adapters publish their own resource Hooks over `source()`. Every + * answer is a boolean: true for the owner, false without a snapshot or after + * a non-owner stream fails; the Host enforces every call regardless. + * @module @deepseek-ai/dsh-api-remote-access/client + */ + +import type { Context } from '@deepseek-ai/cordis' +import remoteAccessRemote from '@deepseek-ai/dsh-api-remote-access/remote' +import { REMOTE_ACCESS_SNAPSHOT_KEY } from '../snapshot.ts' +import { mountAccess } from './mount.ts' +import { parseInjectedBaseline } from './state.ts' + +export type { + RemoteAccessAction, RemoteAccessEndpoint, RemoteAccessFrame, RemoteAccessGrant, RemoteAccessResource, TypertResourceKind, +} from '../types.ts' +export type { ClientRemoteAccess, RemoteEndpoint } from './mount.ts' + +/** Required Client services: the Remote mount face and the Slot registry. */ +export const inject = ['remote', 'slots'] + +/** + * Mount the generated `remoteAccess` namespace and publish the access Hooks. + * @param ctx - Client root Context. + * @returns disposer that withdraws the namespace after the stream stops. + */ +export function apply(ctx: Context): Promise<() => Promise> { + return mountAccess(ctx, remoteAccessRemote, parseInjectedBaseline(Reflect.get(globalThis, REMOTE_ACCESS_SNAPSHOT_KEY))) +} diff --git a/packages/api/remote-access/src/client/mount.ts b/packages/api/remote-access/src/client/mount.ts new file mode 100644 index 0000000000..257aa396c2 --- /dev/null +++ b/packages/api/remote-access/src/client/mount.ts @@ -0,0 +1,132 @@ +/** + * Client access mount: follows the calling Peer's access snapshot, provides + * the `remoteAccess` Client service, and publishes the global-resource `useRemoteAccess` Hook. + * The entry module supplies the generated `remoteAccess` contribution, so + * specs mount a fake one. + * @module @deepseek-ai/dsh-api-remote-access/src/client/mount + */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import { + RemoteSnapshotStream, + RemoteStreamCarrierError, + type ClientRemote, +} from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-remote-access/remote' +import type { HostObservable, RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: the SlotRegistry (`ctx.slots`) merge. +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { TypertRemoteContribution, TypertRemoteMap, TypertResourceKind } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteAccessFrame } from '../types.ts' +import { ClientAccessState, type Baseline } from './state.ts' + +/** Every endpoint the Client Remote namespaces declare. */ +export type RemoteEndpoint = Extract + +/** Client access answers over the calling Peer's snapshot; it never issues a request. */ +export interface ClientRemoteAccess { + /** + * Whether the current Peer may call `action` on one resource. + * True for the owner; false without a baseline and after a non-owner + * stream fails; otherwise whether the resource's grant lists the action. + * @param kind - resource kind declared in `TypertResourceMap`. + * @param id - resource identity. + * @param action - endpoint action the caller would invoke. + * @returns identity-stable boolean source for this `(kind, id, action)`. + */ + source(kind: TypertResourceKind, id: string, action: string): HostObservable +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Client access answers for the calling Peer. */ + remoteAccess: ClientRemoteAccess + } +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface GlobalStandardProps { + /** Whether the current Peer may call `action` on the global resource. */ + useRemoteAccess: (action: RemoteEndpoint) => boolean + } +} + +type IncrementFrame = Exclude + +/** The `remoteAccess` Client service over one snapshot owner. */ +class ClientRemoteAccessService extends Service implements ClientRemoteAccess { + /** + * @param ctx - Client Context the service is provided on. + * @param state - snapshot owner answering every source. + */ + constructor(ctx: Context, private readonly state: ClientAccessState) { + super(ctx, 'remoteAccess') + } + + source(kind: TypertResourceKind, id: string, action: string): HostObservable { + return this.state.resource(kind, id, action) + } +} + +/** + * Mount the `remoteAccess` namespace, follow the access snapshot, provide the + * `remoteAccess` Client service, and publish the global-resource `useRemoteAccess` Hook. + * Answers start from the page's injected baseline, or false without one, + * and follow the stream's frames from then on. + * @param ctx - Client root Context injecting `remote` and `slots`. + * @param contribution - generated `remoteAccess` Remote contribution. + * @param initial - the page's injected opening baseline, read before the stream connects. + * @returns disposer that withdraws the namespace after the stream stops. + */ +export async function mountAccess( + ctx: Context, + contribution: TypertRemoteContribution, + initial?: Baseline, +): Promise<() => Promise> { + const state = new ClientAccessState(initial) + new ClientRemoteAccessService(ctx, state) + ctx.slots.provideRoot({ + keyedHooks: { remoteAccess: action => state.global(action) }, + } satisfies RootStandardSourceContribution) + const unmount = await ctx.remote.$mount(contribution) + const follow = ctx.inject(['remote.remoteAccess'], (remoteCtx) => { + const control = createAccessStream(remoteCtx.remote, state, remoteCtx.logger) + control.start() + remoteCtx.effect(() => async () => { await control.dispose() }, 'remote-access.client.follow') + }) + return async () => { + await follow.dispose() + await unmount() + } +} + +/** + * Create the reconnecting access snapshot stream. A carrier loss keeps the + * last snapshot while the stream reconnects; a terminal failure keeps the + * owner's answers and turns a non-owner's answers false. + */ +function createAccessStream( + remote: ClientRemote, + state: ClientAccessState, + logger: Context['logger'], +): RemoteSnapshotStream { + const stream = remote.$stream({ + name: 'Remote access stream', + open: signal => remote.remoteAccess.follow(signal), + ended: accepted => accepted + ? new RemoteStreamCarrierError('Remote access stream ended without a terminal result') + : new Error('Remote access stream ended before its opening snapshot'), + }) + return new RemoteSnapshotStream(stream, { + name: 'Remote access stream', + isSnapshot: (frame): frame is Baseline => frame.type === 'baseline', + replace: (frame) => { state.replace(frame) }, + update: (frame) => { state.update(frame) }, + failed: (error) => { + // Only a non-owner's answers turn false after a terminal failure, so only its report names the recovery. + if (state.owner) logger.error('access snapshot stream failed; owner access answers are unaffected', error) + else logger.error('access snapshot stream failed; reload the page to reconnect', error) + state.unavailable() + }, + }) +} diff --git a/packages/api/remote-access/src/client/state.ts b/packages/api/remote-access/src/client/state.ts new file mode 100644 index 0000000000..b0ee02b400 --- /dev/null +++ b/packages/api/remote-access/src/client/state.ts @@ -0,0 +1,208 @@ +/** + * React-free Client access state: the latest `remoteAccess/follow` snapshot + * and identity-stable boolean sources per resource and action. + * @module @deepseek-ai/dsh-api-remote-access/src/client/state + */ + +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' +import type { RemoteAccessFrame, RemoteAccessGrant, RemoteAccessResource } from '../types.ts' + +/** Actions allowed per resource identity, for one declared resource kind. */ +type KindGrants = ReadonlyMap> + +type AccessSnapshot = + /** No baseline, or a non-owner snapshot stream failed: every answer is false. */ + | { readonly kind: 'closed' } + | { readonly kind: 'owner' } + | { + readonly kind: 'granted' + readonly global: ReadonlySet + readonly resources: ReadonlyMap + } + +type GrantedSnapshot = Extract + +const CLOSED: AccessSnapshot = Object.freeze({ kind: 'closed' }) +const OWNER_SNAPSHOT: AccessSnapshot = Object.freeze({ kind: 'owner' }) +const GLOBAL_KEY = '\u0000global' + +/** The opening frame of a snapshot generation. */ +export type Baseline = Extract + +/** + * Validate the opening baseline an index injection row assigned to the page. + * @param value - the injected global; `undefined` when the page carries none. + * @returns the baseline, or `undefined` when none was injected. + * @throws TypeError when the injected value is not a baseline frame. + */ +export function parseInjectedBaseline(value: unknown): Baseline | undefined { + if (value === undefined) return undefined + if (!isRecord(value) || value['type'] !== 'baseline') throw new TypeError('remote access: the injected access snapshot is not a baseline frame') + if (value['owner'] === true) return { type: 'baseline', owner: true } + const grants = value['grants'] + if (value['owner'] !== false || !Array.isArray(grants) || !grants.every(isGrant)) { + throw new TypeError('remote access: the injected access snapshot carries malformed grants') + } + return { type: 'baseline', owner: false, grants } +} + +function isGrant(value: unknown): value is RemoteAccessGrant { + if (!isRecord(value) || !isResource(value['resource'])) return false + const actions = value['actions'] + return Array.isArray(actions) && actions.every(action => typeof action === 'string') +} + +function isResource(value: unknown): value is RemoteAccessResource { + if (!isRecord(value) || typeof value['kind'] !== 'string') return false + return value['kind'] === 'global' || typeof value['id'] === 'string' +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Snapshot owner for one Client generation. Sources are cached per + * `(kind, id, action)` so Hook bindings keep their identity; each source + * computes its boolean from the current snapshot. + */ +export class ClientAccessState { + private snapshot: AccessSnapshot = CLOSED + + /** @param initial - the page's injected opening baseline; without one every answer is false until a baseline arrives. */ + constructor(initial?: Baseline) { + if (initial !== undefined) this.replace(initial) + } + private readonly listeners = new Set<() => void>() + private readonly sources = new Map>() + + /** + * Answer for one action on the global resource. + * @param action - endpoint action the caller may invoke. + * @returns identity-stable boolean source. + */ + global(action: string): HostObservable { + return this.cached(GLOBAL_KEY, '', action) + } + + /** + * Answer for one action on one resource of a declared kind. + * @param kind - declared resource kind. + * @param id - resource identity. + * @param action - endpoint action the caller may invoke. + * @returns identity-stable boolean source. + */ + resource(kind: string, id: string, action: string): HostObservable { + return this.cached(kind, id, action) + } + + /** + * Replace the snapshot from one generation's opening frame. + * @param frame - owner or non-owner baseline. + */ + replace(frame: Baseline): void { + if (frame.owner) { + this.publish(OWNER_SNAPSHOT) + return + } + let global: ReadonlySet = new Set() + const resources = new Map>>() + // The baseline is unpublished, so its maps are filled in place. + for (const { resource, actions } of frame.grants) { + if (resource.kind === 'global') { + global = new Set(actions) + continue + } + let grants = resources.get(resource.kind) + if (grants === undefined) { + grants = new Map() + resources.set(resource.kind, grants) + } + grants.set(resource.id, new Set(actions)) + } + this.publish({ kind: 'granted', global, resources }) + } + + /** + * Apply one increment of the current generation. + * @param frame - `upsert` or `remove`. + */ + update(frame: Exclude): void { + const current = this.snapshot + if (current.kind !== 'granted') return + switch (frame.type) { + case 'upsert': + this.publish(withGrant(current, frame.grant)) + return + case 'remove': + this.publish(withGrant(current, { resource: frame.resource, actions: [] }, true)) + return + /* v8 ignore next -- the generated Remote codec validates this closed union. */ + default: + return assertNever(frame) + } + } + + /** Whether the current snapshot is the owner's, whose answers never depend on the stream. */ + get owner(): boolean { + return this.snapshot.kind === 'owner' + } + + /** The stream failed terminally: the owner keeps every answer; a non-owner's answers become false. */ + unavailable(): void { + if (this.snapshot.kind !== 'owner') this.publish(CLOSED) + } + + private cached(kind: string, id: string, action: string): HostObservable { + const key = `${kind}\u0000${id}\u0000${action}` + let source = this.sources.get(key) + if (source === undefined) { + source = { + getSnapshot: () => answer(this.snapshot, kind, id, action), + subscribe: (listener) => { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + }, + } + this.sources.set(key, source) + } + return source + } + + private publish(next: AccessSnapshot): void { + this.snapshot = next + for (const listener of [...this.listeners]) listener() + } +} + +function answer(snapshot: AccessSnapshot, kind: string, id: string, action: string): boolean { + switch (snapshot.kind) { + case 'closed': + return false + case 'owner': + return true + case 'granted': { + const allowed = kind === GLOBAL_KEY ? snapshot.global : snapshot.resources.get(kind)?.get(id) + return allowed?.has(action) ?? false + } + /* v8 ignore next -- closed union. */ + default: + return assertNever(snapshot) + } +} + +function withGrant(snapshot: GrantedSnapshot, grant: RemoteAccessGrant, remove = false): GrantedSnapshot { + const resource = grant.resource + if (resource.kind === 'global') return { ...snapshot, global: new Set(grant.actions) } + const resources = new Map(snapshot.resources) + const grants = new Map(resources.get(resource.kind)) + if (remove) grants.delete(resource.id) + else grants.set(resource.id, new Set(grant.actions)) + resources.set(resource.kind, grants) + return { ...snapshot, resources } +} + +/* v8 ignore next 3 -- closed-union backstop. */ +function assertNever(value: never): never { + throw new Error(`unreachable access frame: ${JSON.stringify(value)}`) +} diff --git a/packages/api/remote-access/src/index.ts b/packages/api/remote-access/src/index.ts new file mode 100644 index 0000000000..5b3cf41e0f --- /dev/null +++ b/packages/api/remote-access/src/index.ts @@ -0,0 +1,424 @@ +/** + * Host method-level access control. The Gateway asks `decide()` before it + * dispatches a non-owner call or forwards an event to a non-owner Peer; list + * methods filter items through `allows()`; every Peer reads its own access + * snapshot, first from the index and then from `follow()`. Business packages + * register one policy per non-owner principal kind. + * @module @deepseek-ai/dsh-api-remote-access + */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-host-webserver' +import { + Remote, + TypertRemoteService, + principalOf, + type Principal, + type PrincipalMap, + type RemoteAccessResource, +} from '@deepseek-ai/dsh-typert-protocol' +import { REMOTE_ACCESS_SNAPSHOT_KEY } from './snapshot.ts' +import type { + RemoteAccessAction, + RemoteAccessDecision, + RemoteAccessFrame, + RemoteAccessGrant, + RemoteAccessPolicy, + RemoteAccessRequest, +} from './types.ts' + +export type * from './types.ts' +export { REMOTE_ACCESS_SNAPSHOT_KEY } from './snapshot.ts' + +/** The opening frame of a snapshot generation. */ +export type RemoteAccessBaseline = Extract + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host method-level access control for non-owner Peers. */ + remoteAccess: RemoteAccessService + } +} + +/** The snapshot endpoint every Peer may read for itself. */ +const FOLLOW_ACTION: RemoteAccessAction = 'remoteAccess/follow' +const ALLOW: RemoteAccessDecision = Object.freeze({ kind: 'allow' }) +const NO_ALLOW: RemoteAccessDecision = Object.freeze({ kind: 'deny', reason: 'no-allow' }) +const POLICY_FAILED: RemoteAccessDecision = Object.freeze({ kind: 'deny', reason: 'policy-failed' }) +const OWNER: Principal = Object.freeze({ kind: 'owner' }) +const OWNER_BASELINE: RemoteAccessBaseline = Object.freeze({ type: 'baseline', owner: true }) + +/** + * A binding seen without its resource kind. Method syntax keeps the condition + * of every kind assignable; each condition is called only with a resource + * equal to its own binding's resource. + */ +interface ErasedBinding { + readonly role: { readonly actions: readonly RemoteAccessAction[] } + readonly resource: RemoteAccessResource + condition?(request: { + readonly action: RemoteAccessAction + readonly resource: RemoteAccessResource + readonly args?: Readonly> + }): RemoteAccessDecision +} + +/** One registered policy with the principal kind it serves. */ +interface PolicyEntry { + readonly kind: string + readonly policy: RemoteAccessPolicy +} + +/** A condition's refusal. */ +type Refusal = Extract + +/** The answer for one action on one resource, before any `explain()`. */ +type Evaluation = + | { readonly kind: 'allow' } + /** No binding allows; `refusal` is the first refusing condition's decision, if any condition ran. */ + | { readonly kind: 'refuse'; readonly refusal: Refusal | undefined } + | { readonly kind: 'failed' } + +const ALLOWED: Evaluation = Object.freeze({ kind: 'allow' }) +const FAILED: Evaluation = Object.freeze({ kind: 'failed' }) + +/** One open non-owner snapshot generation; changes coalesce until its reader wakes. */ +class AccessFollower { + private dirty = false + private wake: (() => void) | undefined + + /** @param principal - the generation's caller identity; never the owner. */ + constructor(readonly principal: Principal) {} + + /** Mark the generation stale and wake its reader. */ + touch(): void { + this.dirty = true + this.wake?.() + } + + /** + * Wait until the generation is stale or cancelled. + * @param signal - generation cancellation. + * @returns true when stale; false once cancelled. + */ + async changed(signal: AbortSignal): Promise { + const abort = (): void => { this.wake?.() } + signal.addEventListener('abort', abort, { once: true }) + try { + while (!this.dirty && !signal.aborted) { + await new Promise((resolve) => { this.wake = resolve }) + this.wake = undefined + } + } finally { + signal.removeEventListener('abort', abort) + } + this.dirty = false + return !signal.aborted + } +} + +/** + * Registry of access policies, the Gateway's access decisions, list + * filtering, and the per-Peer access snapshot. Every composition that mounts + * the Gateway mounts this service too. + */ +export class RemoteAccessService extends TypertRemoteService { + private readonly policies = new Map() + /** Policies whose failure was logged since the last `invalidate()`; their later failures stay silent. */ + private readonly reported = new Set() + private readonly followers = new Set() + /** Aborted when this instance unloads; every open snapshot generation ends with it. */ + private readonly lifetime = new AbortController() + + /** @param ctx - owning Host plugin Context. */ + constructor(ctx: Context) { + super(ctx, 'remoteAccess') + ctx.effect(() => () => { this.lifetime.abort() }, 'remoteAccess: snapshot generations') + // Every index the webServer renders belongs to the owner; snapshotFor documents non-owner pages. + ctx.inject(['webServer'], (webCtx) => { + webCtx.on('webserver/index-inject', (table) => { + table.push({ kind: 'global', name: REMOTE_ACCESS_SNAPSHOT_KEY, value: OWNER_BASELINE }) + }) + }) + } + + /** + * Register the policy of one non-owner principal kind in the caller's + * fiber; open snapshots are recomputed on registration and removal. + * @param kind - the principal kind the policy serves; never `owner`. + * @param policy - business-owned bindings and refusal reasons for principals of `kind`. + * @returns disposer removing this policy. + * @throws Error when `kind` already has a policy. + */ + registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void { + const policies = this.policies + if (policies.has(kind)) throw new Error(`remote access: principal kind ${JSON.stringify(kind)} already has a policy`) + const entry: PolicyEntry = { kind, policy } + const dispose = this.ctx.effect(() => { + policies.set(kind, entry) + this.invalidate() + return () => { + policies.delete(kind) + this.invalidate() + } + }, `remoteAccess.registerPolicy(${JSON.stringify(kind)})`) + return () => { void dispose() } + } + + /** + * Report that the bindings or condition answers for `principal` may have + * changed. Business packages call this on every grant, revocation, mode + * change, and expiry; open snapshots re-push, and the next failure of each + * policy is logged again. + * @param principal - affected principal; omitted for every principal. + */ + invalidate(principal?: Principal): void { + this.reported.clear() + const key = principal === undefined ? undefined : canonicalJson(principal) + for (const follower of this.followers) { + if (key === undefined || canonicalJson(follower.principal) === key) follower.touch() + } + } + + /** + * Gateway-only: decide one request whose resources the Gateway resolved. + * The owner is allowed, and so is `remoteAccess/follow`: every Peer reads + * its own snapshot. Otherwise the action is allowed only when, on every + * listed resource, some binding of the principal's policy names that + * resource, has a role listing the action, and has no condition or a + * condition that allows. `bindings()` is called once per decision. + * @param request - the caller or recipient, the action, the resources it + * touches, and a call's arguments. + * @returns `allow`; or `deny` with `no-allow` when the principal kind has + * no policy, the first refusing condition's reason on the first resource + * no binding allows, else `explain()`'s reason, else `no-allow`; + * `policy-failed` when `bindings()`, a condition, or `explain()` throws, + * logged once per policy until the next `invalidate()`. The Gateway + * throws `remote-access/denied` for a denied call and drops a denied event. + */ + decide(request: RemoteAccessRequest): RemoteAccessDecision { + const { principal, action } = request + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (principal.kind === 'owner' || action === FOLLOW_ACTION) return ALLOW + const entry = this.policies.get(principal.kind) + if (entry === undefined) return NO_ALLOW + const bindings = this.bindingsOf(entry, principal, action) + if (bindings === undefined) return POLICY_FAILED + for (const resource of request.resources) { + const evaluation = this.evaluate(entry, bindings, action, resource, request.args) + switch (evaluation.kind) { + case 'allow': + continue + case 'failed': + return POLICY_FAILED + case 'refuse': + return evaluation.refusal ?? this.explain(entry, request) + /* v8 ignore next -- closed union. */ + default: + return assertNever(evaluation) + } + } + return ALLOW + } + + /** + * Filter items inside a Remote method. The invocation is read from the + * Context that accessed this service, so call it as + * `this.ctx.remoteAccess.allows(resource)` from the Remote method. Each + * check reads the policy's bindings again, so a long-running stream sees + * binding changes from the next check on. + * @param resource - resource of one result item. + * @returns true for the owner and outside a Remote call; otherwise whether + * the invocation's endpoint action is allowed on `resource` without call + * arguments, false when the policy is absent or fails. + */ + allows(resource: RemoteAccessResource): boolean { + const invocation = this.ctx.invocation + const principal = principalOf(invocation) + if (invocation === undefined || principal === undefined) return true + const action: RemoteAccessAction = `${invocation.request.namespace}/${invocation.request.method}` + const entry = this.policies.get(principal.kind) + if (entry === undefined) return false + const bindings = this.bindingsOf(entry, principal, action) + return bindings !== undefined && this.evaluate(entry, bindings, action, resource, undefined).kind === 'allow' + } + + /** + * The baseline a page opens with before its snapshot stream connects. A + * business package rendering a non-owner page assigns this value to + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, + * replacing the owner baseline the webServer index carries. + * @param principal - the page's caller identity. + * @returns the owner baseline for the owner; otherwise the principal's current grants. + */ + snapshotFor(principal: Principal): RemoteAccessBaseline { + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (principal.kind === 'owner') return OWNER_BASELINE + return { type: 'baseline', owner: false, grants: this.grantsFor(principal) } + } + + /** + * Stream the calling Peer's access snapshot: one baseline, then `upsert` + * and `remove` frames after each `invalidate()` and each policy + * registration or removal that changes a grant. The owner receives only + * `{ owner: true }`. The generation ends when `signal` aborts or this + * service unloads, so a reloaded service starts every reader from a fresh + * baseline. + * @param signal - generation cancellation. + * @returns the access frames of one generation. + */ + @Remote({ mode: 'stream' }) + async *follow(signal: AbortSignal): AsyncIterable { + signal.throwIfAborted() + const generation = AbortSignal.any([signal, this.lifetime.signal]) + const principal = principalOf(this.ctx.invocation) + if (principal === undefined) { + yield this.snapshotFor(OWNER) + await abortion(generation) + return + } + const follower = new AccessFollower(principal) + this.followers.add(follower) + try { + let sent = this.grantsFor(principal) + yield { type: 'baseline', owner: false, grants: sent } + while (await follower.changed(generation)) { + const next = this.grantsFor(principal) + const previous = sent + sent = next + for (const grant of next) { + const before = previous.find(other => sameResource(other.resource, grant.resource)) + if (before === undefined || !sameActions(before.actions, grant.actions)) yield { type: 'upsert', grant } + } + for (const grant of previous) { + if (!next.some(other => sameResource(other.resource, grant.resource))) yield { type: 'remove', resource: grant.resource } + } + } + } finally { + this.followers.delete(follower) + } + } + + /** One `bindings()` call; `undefined` after a logged failure. */ + private bindingsOf(entry: PolicyEntry, principal: Principal, purpose: string): readonly ErasedBinding[] | undefined { + try { + return entry.policy.bindings(principal) + } catch (error) { + // A policy failure is contained to this question, so one faulty rule cannot break a shared event stream. + this.report(entry, `list bindings for ${purpose}`, error) + return undefined + } + } + + /** Find a binding on `resource` whose role lists `action` and whose condition, if any, allows. */ + private evaluate( + entry: PolicyEntry, + bindings: readonly ErasedBinding[], + action: RemoteAccessAction, + resource: RemoteAccessResource, + args: Readonly> | undefined, + ): Evaluation { + let refusal: Refusal | undefined + for (const binding of bindings) { + if (!sameResource(binding.resource, resource) || !binding.role.actions.includes(action)) continue + if (binding.condition === undefined) return ALLOWED + let decision: RemoteAccessDecision + try { + decision = binding.condition(args === undefined ? { action, resource } : { action, resource, args }) + } catch (error) { + this.report(entry, `evaluate a condition for ${action}`, error) + return FAILED + } + if (decision.kind === 'allow') return ALLOWED + refusal ??= decision + } + return { kind: 'refuse', refusal } + } + + /** The refusal reason `explain()` names, else `no-allow`; `policy-failed` when it throws. */ + private explain(entry: PolicyEntry, request: RemoteAccessRequest): RemoteAccessDecision { + let reason: string | undefined + try { + reason = entry.policy.explain?.(request) + } catch (error) { + this.report(entry, `explain a refusal of ${request.action}`, error) + return POLICY_FAILED + } + return reason === undefined ? NO_ALLOW : { kind: 'deny', reason } + } + + /** + * One grant per bound resource, in first-binding order: the endpoint + * actions of the resource's bindings that `decide()` would allow there + * without call arguments. A resource with no such action gets no grant; + * event actions stay out of the snapshot. + */ + private grantsFor(principal: Principal): readonly RemoteAccessGrant[] { + const entry = this.policies.get(principal.kind) + if (entry === undefined) return [] + const bindings = this.bindingsOf(entry, principal, 'a snapshot') + if (bindings === undefined) return [] + const grants: RemoteAccessGrant[] = [] + const seen: RemoteAccessResource[] = [] + for (const { resource } of bindings) { + if (seen.some(other => sameResource(other, resource))) continue + seen.push(resource) + const candidates = new Set() + for (const binding of bindings) { + if (!sameResource(binding.resource, resource)) continue + for (const action of binding.role.actions) { + if (!action.startsWith('event:')) candidates.add(action) + } + } + const actions = [...candidates].filter(action => this.evaluate(entry, bindings, action, resource, undefined).kind === 'allow').sort() + if (actions.length > 0) grants.push(Object.freeze({ resource, actions: Object.freeze(actions) })) + } + return grants + } + + /** Log a policy failure once per policy until the next `invalidate()`; snapshots and lists ask many times per change. */ + private report(entry: PolicyEntry, action: string, error: unknown): void { + if (this.reported.has(entry)) return + this.reported.add(entry) + this.ctx.logger.warn( + `remote access: the ${JSON.stringify(entry.kind)} policy failed to ${action}; its further failures are not logged until access state changes`) + this.ctx.logger.warn(error) + } +} + +/* v8 ignore next 3 -- closed-union backstop. */ +function assertNever(value: never): never { + throw new Error(`remote access: unreachable value ${JSON.stringify(value)}`) +} + +/** Key-order-independent JSON text identifying one principal. */ +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` + if (typeof value === 'object' && value !== null) { + // Own keys are unique, so no two entries compare equal. + const entries = Object.entries(value).sort(([left], [right]) => (left < right ? -1 : 1)) + return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}` + } + return JSON.stringify(value) +} + +/** Resources are equal when their kinds and ids are; `global` carries no id. */ +function sameResource( + left: { readonly kind: string; readonly id?: unknown }, + right: { readonly kind: string; readonly id?: unknown }, +): boolean { + return left.kind === right.kind && left.id === right.id +} + +function sameActions(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((action, index) => action === right[index]) +} + +function abortion(signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} + +export default RemoteAccessService diff --git a/packages/api/remote-access/src/snapshot.ts b/packages/api/remote-access/src/snapshot.ts new file mode 100644 index 0000000000..ad3a490205 --- /dev/null +++ b/packages/api/remote-access/src/snapshot.ts @@ -0,0 +1,8 @@ +/** + * The index global carrying a page's first access snapshot, shared by the + * Host row that writes it and the Client mount that reads it. + * @module @deepseek-ai/dsh-api-remote-access/src/snapshot + */ + +/** `globalThis` property an index injection row assigns to the page's opening baseline. */ +export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' diff --git a/packages/api/remote-access/src/types.ts b/packages/api/remote-access/src/types.ts new file mode 100644 index 0000000000..c598ee60a2 --- /dev/null +++ b/packages/api/remote-access/src/types.ts @@ -0,0 +1,142 @@ +/** + * Method-level access control contracts: actions, roles, bindings, policies, + * decision requests, and the access snapshot stream. + * @module @deepseek-ai/dsh-api-remote-access/types + */ + +import type { + Principal, + RemoteAccessResource, + RemoteAccessResourceKind, + RemoteAccessResourceOf, +} from '@deepseek-ai/dsh-typert-protocol' + +export type { + RemoteAccessResource, + RemoteAccessResourceKind, + RemoteAccessResourceOf, + TypertResourceKind, + TypertResourceMap, +} from '@deepseek-ai/dsh-typert-protocol' + +/** Canonical `/` Remote endpoint; its segments never contain `:`. */ +export type RemoteAccessEndpoint = `${string}/${string}` + +/** A Remote endpoint, or `event:` for one forwarded event. */ +export type RemoteAccessAction = RemoteAccessEndpoint | `event:${string}` + +/** A named action set over one resource kind; a plain value the business package owns. */ +export interface RemoteAccessRole { + /** Name for diagnostics; decisions read only `resource` and `actions`. */ + readonly name: string + /** The resource kind every binding of this role names. */ + readonly resource: K + /** Actions the role allows on its binding's resource. */ + 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. Called synchronously, + * only for a binding whose resource matches and whose role lists the action. + * @param request - the action, the binding's resource, and the named + * arguments of a concrete call, validated against their Typert-compiled + * types with lookup parameters as wire identities; `args` is absent for + * events, snapshots, and list filtering, where the condition answers for + * the action alone. + * @returns allow, or deny with the reason a refused call reports. + */ +export type RemoteAccessCondition = (request: { + readonly action: RemoteAccessAction + readonly resource: RemoteAccessResourceOf + readonly args?: Readonly> +}) => 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 + readonly resource: RemoteAccessResourceOf + /** Without a condition, the binding allows every action its role lists. */ + readonly condition?: RemoteAccessCondition + } +}[RemoteAccessResourceKind] + +/** One access question: may `principal` take `action` on `resources`. */ +export interface RemoteAccessRequest

{ + readonly principal: P + readonly action: RemoteAccessAction + /** + * Every resource the action touches, the global resource alone when it + * names none; allowed 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> +} + +/** + * Business rules for one non-owner principal kind. Every member answers + * synchronously from state the business package holds in memory. + */ +export interface RemoteAccessPolicy

{ + /** + * List the principal's bindings. Called once per `decide()`, once per + * `allows()`, and once per snapshot computation; never cached. + * @param principal - non-owner caller of this policy's kind. + * @returns every binding the principal holds now. + */ + bindings(principal: P): readonly RemoteAccessBinding[] + /** + * Name the reason for a request no binding allows; called only after the + * decision is already a deny, so it never changes the decision. + * @param request - the refused request. + * @returns the reason a refused call reports, or `undefined` for `no-allow`. + */ + explain?(request: RemoteAccessRequest

): string | undefined +} + +/** Details of a refused non-owner call. */ +export interface RemoteAccessDeniedDetails { + /** Canonical `/` endpoint of the refused call. */ + readonly action: string + /** + * A condition's or `explain()`'s reason, `no-allow`, or `policy-failed`; + * the Gateway refuses a call whose resources are invalid with + * `resource-invalid` before `decide()` is asked. + */ + readonly reason: string +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + 'remote-access/denied': RemoteAccessDeniedDetails + } +} + +/** The endpoint actions one principal may call on one resource. */ +export interface RemoteAccessGrant { + readonly resource: RemoteAccessResource + /** + * Endpoint actions allowed on this resource without call arguments, sorted; + * plain strings because Typert generates no codec for template literal types. + */ + readonly actions: readonly string[] +} + +/** + * One frame of `remoteAccess/follow`. A generation opens with one baseline; + * an owner baseline is the only frame of its generation. + */ +export 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 } diff --git a/packages/api/remote-access/tests/client-access.client.spec.ts b/packages/api/remote-access/tests/client-access.client.spec.ts new file mode 100644 index 0000000000..b73944ec58 --- /dev/null +++ b/packages/api/remote-access/tests/client-access.client.spec.ts @@ -0,0 +1,291 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { HostObservable, RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots' +import type { TypertRemoteContribution, TypertResource } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteAccessFrame, RemoteAccessGrant } from '../src/types.ts' +import { ClientAccessState, parseInjectedBaseline, type Baseline } from '../src/client/state.ts' +import { mountAccess } from '../src/client/mount.ts' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertResourceMap { + /** Test-only resource kinds; the Client face itself names no kind. */ + 'test-shelf': TypertResource + 'test-desk': TypertResource + } +} + +const GRANTED: Extract = { + type: 'baseline', + owner: false, + grants: [ + { resource: { kind: 'global' }, actions: ['session/list'] }, + { resource: { kind: 'test-shelf', id: 's1' }, actions: ['session/fork', 'session/prompt'] }, + { resource: { kind: 'test-desk', id: 'd1' }, actions: ['workspace/follow'] }, + ], +} + +describe('ClientAccessState', () => { + it('answers false without a baseline and after a non-owner stream fails, and true for the owner', () => { + const state = new ClientAccessState() + expect(state.global('session/create').getSnapshot()).toBe(false) + expect(state.resource('test-shelf', 's9', 'session/fork').getSnapshot()).toBe(false) + state.replace({ type: 'baseline', owner: true }) + expect(state.resource('test-desk', 'd9', 'workspace/follow').getSnapshot()).toBe(true) + state.unavailable() + expect(state.resource('test-desk', 'd9', 'workspace/follow').getSnapshot()).toBe(true) + state.replace(GRANTED) + expect(state.global('session/list').getSnapshot()).toBe(true) + state.unavailable() + expect(state.global('session/list').getSnapshot()).toBe(false) + }) + + it('starts from the injected baseline before any stream frame', () => { + expect(new ClientAccessState({ type: 'baseline', owner: true }).global('session/create').getSnapshot()).toBe(true) + const granted = new ClientAccessState(GRANTED) + expect(granted.global('session/list').getSnapshot()).toBe(true) + expect(granted.resource('test-shelf', 's1', 'session/fork').getSnapshot()).toBe(true) + expect(granted.global('session/create').getSnapshot()).toBe(false) + }) + + it('validates the injected baseline and refuses a malformed one', () => { + expect(parseInjectedBaseline(undefined)).toBeUndefined() + expect(parseInjectedBaseline({ type: 'baseline', owner: true, extra: 1 })).toEqual({ type: 'baseline', owner: true }) + expect(parseInjectedBaseline(JSON.parse(JSON.stringify(GRANTED)))).toEqual(GRANTED) + for (const malformed of [ + null, 'baseline', [], { type: 'upsert' }, + { type: 'baseline' }, { type: 'baseline', owner: false }, { type: 'baseline', owner: false, grants: [7] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 'test-shelf' }, actions: [] }] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 7 }, actions: [] }] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 'global' }, actions: 'session/list' }] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 'global' }, actions: [7] }] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 'global' } }] }, + { type: 'baseline', owner: false, grants: [{ scope: { kind: 'page' }, endpoints: ['session/list'] }] }, + ]) { + expect(() => parseInjectedBaseline(malformed)).toThrow(TypeError) + } + }) + + it('answers each resource from its own grant without falling back to the global resource', () => { + const state = new ClientAccessState() + state.replace(GRANTED) + expect(state.global('session/list').getSnapshot()).toBe(true) + expect(state.global('session/fork').getSnapshot()).toBe(false) + expect(state.resource('test-shelf', 's1', 'session/fork').getSnapshot()).toBe(true) + expect(state.resource('test-shelf', 's1', 'session/list').getSnapshot()).toBe(false) + expect(state.resource('test-shelf', 's2', 'session/fork').getSnapshot()).toBe(false) + expect(state.resource('test-desk', 'd1', 'workspace/follow').getSnapshot()).toBe(true) + expect(state.resource('test-desk', 'd2', 'workspace/follow').getSnapshot()).toBe(false) + }) + + it('applies upserts and removals and notifies subscribers until they unsubscribe', () => { + const state = new ClientAccessState() + state.replace(GRANTED) + const fork = state.resource('test-shelf', 's1', 'session/fork') + const listener = vi.fn() + const unsubscribe = fork.subscribe(listener) + state.update({ type: 'upsert', grant: { resource: { kind: 'test-shelf', id: 's1' }, actions: ['session/prompt'] } }) + expect(fork.getSnapshot()).toBe(false) + expect(listener).toHaveBeenCalledOnce() + state.update({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['session/create'] } }) + expect(state.global('session/create').getSnapshot()).toBe(true) + state.update({ type: 'upsert', grant: { resource: { kind: 'test-desk', id: 'd2' }, actions: ['workspace/follow'] } }) + expect(state.resource('test-desk', 'd2', 'workspace/follow').getSnapshot()).toBe(true) + state.update({ type: 'remove', resource: { kind: 'test-desk', id: 'd2' } }) + expect(state.resource('test-desk', 'd2', 'workspace/follow').getSnapshot()).toBe(false) + state.update({ type: 'remove', resource: { kind: 'test-shelf', id: 's1' } }) + expect(state.resource('test-shelf', 's1', 'session/prompt').getSnapshot()).toBe(false) + // Removing a kind that never had a grant leaves the other kinds intact. + state.update({ type: 'remove', resource: { kind: 'test-shelf', id: 'absent' } }) + unsubscribe() + state.update({ type: 'remove', resource: { kind: 'global' } }) + expect(listener).toHaveBeenCalledTimes(6) + expect(state.global('session/create').getSnapshot()).toBe(false) + }) + + it('applies a baseline of many grants of one kind in one pass and notifies once', () => { + const state = new ClientAccessState() + const first = state.resource('test-shelf', 's0', 'session/fork') + const listener = vi.fn() + first.subscribe(listener) + const grants: RemoteAccessGrant[] = Array.from({ length: 20_000 }, (_, index) => ({ + resource: { kind: 'test-shelf', id: `s${index}` }, + actions: ['session/fork'], + })) + // Copying the kind's grants once per grant takes seconds at this size and exceeds the spec timeout. + state.replace({ type: 'baseline', owner: false, grants }) + expect(listener).toHaveBeenCalledOnce() + expect(first.getSnapshot()).toBe(true) + expect(state.resource('test-shelf', 's19999', 'session/fork').getSnapshot()).toBe(true) + expect(state.resource('test-shelf', 's20000', 'session/fork').getSnapshot()).toBe(false) + }) + + it('ignores increments outside a non-owner generation and keeps source identity stable', () => { + const state = new ClientAccessState() + state.update({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['session/list'] } }) + expect(state.global('session/list').getSnapshot()).toBe(false) + state.replace({ type: 'baseline', owner: true }) + state.update({ type: 'remove', resource: { kind: 'global' } }) + expect(state.global('session/list').getSnapshot()).toBe(true) + expect(state.global('session/list')).toBe(state.global('session/list')) + expect(state.resource('test-shelf', 's1', 'session/fork')).toBe(state.resource('test-shelf', 's1', 'session/fork')) + expect(state.resource('test-shelf', 's1', 'session/fork')).not.toBe(state.resource('test-desk', 's1', 'session/fork')) + }) +}) + +interface FakeStreamOptions { + readonly name: string + readonly open: (signal: AbortSignal) => unknown + readonly ended: (accepted: boolean) => Error +} + +/** One controllable generation of `remoteAccess/follow`, standing in for the Gateway-owned carrier. */ +class FakeAccessStream { + private readonly frames: RemoteAccessFrame[] = [] + private wake: (() => void) | undefined + private done = false + private failure: Error | undefined + + constructor(readonly options: FakeStreamOptions) {} + + push(frame: RemoteAccessFrame): void { + this.frames.push(frame) + this.wake?.() + } + + fail(error: Error): void { + this.failure = error + this.wake?.() + } + + restart(): void {} + + async dispose(): Promise { + this.done = true + this.wake?.() + } + + async *[Symbol.asyncIterator](): AsyncIterator<{ + readonly generation: number + readonly value: RemoteAccessFrame + readonly accept: () => void + }> { + while (!this.done) { + if (this.failure !== undefined) throw this.failure + const frame = this.frames.shift() + if (frame !== undefined) { + yield { generation: 1, value: frame, accept: () => {} } + continue + } + await new Promise((resolve) => { this.wake = resolve }) + } + } +} + +function value(source: HostObservable | undefined): unknown { + if (source === undefined) throw new Error('missing source') + return source.getSnapshot() +} + +async function mounted(initial?: Baseline): Promise<{ + readonly ctx: Context + readonly stream: () => FakeAccessStream + readonly global: (action: string) => HostObservable | undefined + readonly follow: ReturnType + readonly mount: ReturnType + readonly unmount: ReturnType + readonly contribution: TypertRemoteContribution + readonly dispose: () => Promise +}> { + const ctx = new Context() + let stream: FakeAccessStream | undefined + const follow = vi.fn() + const unmount = vi.fn(async () => {}) + const remote = { + $mount: vi.fn(async () => unmount), + $stream: (options: FakeStreamOptions) => { + stream = new FakeAccessStream(options) + return stream + }, + remoteAccess: { follow }, + } + let root: RootStandardSourceContribution | undefined + ctx.provide('remote', remote as never) + ctx.provide('slots', { + provideRoot: (contribution: RootStandardSourceContribution) => { + root = contribution + return () => {} + }, + } as never) + const contribution: TypertRemoteContribution = { package: '@deepseek-ai/dsh-api-remote-access', descriptors: [] } + let dispose: (() => Promise) | undefined + await ctx.plugin({ + inject: ['remote', 'slots'], + apply: async (pluginCtx: Context) => { dispose = await mountAccess(pluginCtx, contribution, initial) }, + }) + // The generated namespace arrives after the mount, as `$mount` provides it. + ctx.provide('remote.remoteAccess', remote.remoteAccess as never) + await vi.waitFor(() => { expect(stream).toBeDefined() }) + return { + ctx, + stream: () => stream as FakeAccessStream, + global: action => root?.keyedHooks?.['remoteAccess']?.(action), + follow, + mount: remote.$mount, + unmount, + contribution, + dispose: async () => { await dispose?.() }, + } +} + +describe('remote-access Client mount', () => { + it('provides the global-resource Hook and the remoteAccess service over the followed snapshot', async () => { + const access = await mounted() + expect(access.mount).toHaveBeenCalledWith(access.contribution) + const globalFork = access.global('session/fork') + const shelfFork = access.ctx.remoteAccess.source('test-shelf', 's1', 'session/fork') + const deskFollow = access.ctx.remoteAccess.source('test-desk', 'd1', 'workspace/follow') + expect(access.ctx.remoteAccess.source('test-shelf', 's1', 'session/fork')).toBe(shelfFork) + expect([value(globalFork), value(shelfFork), value(deskFollow)]).toEqual([false, false, false]) + + access.stream().push(GRANTED) + await vi.waitFor(() => { expect(value(shelfFork)).toBe(true) }) + expect(value(globalFork)).toBe(false) + expect(value(deskFollow)).toBe(true) + access.stream().push({ type: 'remove', resource: { kind: 'test-shelf', id: 's1' } }) + await vi.waitFor(() => { expect(value(shelfFork)).toBe(false) }) + // Reading an answer never issues a request; only the stream factory reaches the namespace. + expect(access.follow).not.toHaveBeenCalled() + access.stream().options.open(new AbortController().signal) + expect(access.follow).toHaveBeenCalledOnce() + expect(access.stream().options.ended(true).message).toMatch(/without a terminal result/) + expect(access.stream().options.ended(false).message).toMatch(/before its opening snapshot/) + + await access.dispose() + expect(access.unmount).toHaveBeenCalledOnce() + }) + + it('answers from the injected baseline at once and false once a non-owner stream fails terminally', async () => { + const access = await mounted(GRANTED) + const report = vi.spyOn(access.ctx.logger, 'error').mockImplementation(() => undefined) + const globalList = access.global('session/list') + expect(value(globalList)).toBe(true) + const failure = new Error('carrier closed for good') + access.stream().fail(failure) + await vi.waitFor(() => { expect(value(globalList)).toBe(false) }) + expect(report).toHaveBeenCalledWith('access snapshot stream failed; reload the page to reconnect', failure) + await access.dispose() + }) + + it('keeps the owner\'s answers when its stream fails terminally', async () => { + const access = await mounted({ type: 'baseline', owner: true }) + const report = vi.spyOn(access.ctx.logger, 'error').mockImplementation(() => undefined) + const globalFork = access.global('session/fork') + expect(value(globalFork)).toBe(true) + const failure = new Error('carrier closed for good') + access.stream().fail(failure) + await vi.waitFor(() => { expect(report).toHaveBeenCalledOnce() }) + expect(report).toHaveBeenCalledWith('access snapshot stream failed; owner access answers are unaffected', failure) + expect(value(globalFork)).toBe(true) + await access.dispose() + }) +}) diff --git a/packages/api/remote-access/tests/remote-access.host.spec.ts b/packages/api/remote-access/tests/remote-access.host.spec.ts new file mode 100644 index 0000000000..f29d4d1744 --- /dev/null +++ b/packages/api/remote-access/tests/remote-access.host.spec.ts @@ -0,0 +1,610 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import { Peer } from '@deepseek-ai/dsh-client-connection' +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import { + Remote, + TypertRemoteService, + type PeerScope, + type Principal, + type PrincipalMap, + type RemoteInvocation, + type TypertResource, +} from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import RemoteAccessService, { + REMOTE_ACCESS_SNAPSHOT_KEY, + type RemoteAccessBinding, + type RemoteAccessCondition, + type RemoteAccessFrame, + type RemoteAccessGrant, + type RemoteAccessPolicy, + type RemoteAccessRequest, + type RemoteAccessResource, + type RemoteAccessResourceOf, + type RemoteAccessRole, +} from '../src/index.ts' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + /** Test-only non-owner caller whose identity carries a list. */ + 'test-crew': { readonly kind: 'test-crew'; readonly members: readonly string[] } + } + interface TypertResourceMap { + /** Test-only resource kinds; the service itself names no kind. */ + 'test-shelf': TypertResource + 'test-desk': TypertResource + } +} + +type Guest = PrincipalMap['test-guest'] + +const OWNER: Principal = { kind: 'owner' } +const GUEST: Guest = { kind: 'test-guest', guest: 'a' } +const OTHER: Guest = { kind: 'test-guest', guest: 'b' } +const CREW: Principal = { kind: 'test-crew', members: ['a', 'b'] } +const GLOBAL: RemoteAccessResourceOf<'global'> = { kind: 'global' } +const ROOM: RemoteAccessResourceOf<'test-shelf'> = { kind: 'test-shelf', id: 'r1' } +const ROOM2: RemoteAccessResourceOf<'test-shelf'> = { kind: 'test-shelf', id: 'r2' } +const DESK: RemoteAccessResourceOf<'test-desk'> = { kind: 'test-desk', id: 'd1' } + +const READER: RemoteAccessRole<'global'> = { + name: 'test:reader', + resource: 'global', + actions: ['notes/read', 'notes/rooms', 'notes/write', 'event:notes/changed'], +} +const SHELF_READER: RemoteAccessRole<'test-shelf'> = { + name: 'test:shelf-reader', + resource: 'test-shelf', + actions: ['notes/rooms', 'notes/read', 'event:notes/changed'], +} +const SHELF_WRITER: RemoteAccessRole<'test-shelf'> = { name: 'test:shelf-writer', resource: 'test-shelf', actions: ['notes/write'] } +const DESK_WRITER: RemoteAccessRole<'test-desk'> = { name: 'test:desk-writer', resource: 'test-desk', actions: ['notes/write'] } + +class Notes extends TypertRemoteService { + static inject = ['remoteAccess'] + + constructor(ctx: Context) { super(ctx, 'notes') } + + @Remote + read(): string { return 'read' } + + /** Resolves to the shelf its request names through the registered default field. */ + @Remote + write(request: { readonly shelfId: string; readonly text: string }): string { return request.text } + + @Remote + hidden(): string { return 'hidden' } + + /** A list method: a global call, then one `allows()` check per item. */ + @Remote + rooms(): string[] { + return ['r1', 'r2'].filter(id => this.ctx.remoteAccess.allows({ kind: 'test-shelf', id })) + } +} + +/** A policy whose bindings live in memory per guest, the way a business package preloads them. */ +class GuestPolicy implements RemoteAccessPolicy { + readonly held = new Map() + readonly bindings = vi.fn((guest: Guest): readonly RemoteAccessBinding[] => this.held.get(guest.guest) ?? []) + + hold(guest: Guest, ...bindings: RemoteAccessBinding[]): void { + this.held.set(guest.guest, bindings) + } +} + +const roots: Context[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +async function setup(): Promise<{ readonly ctx: Context; readonly access: RemoteAccessService }> { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) + await ctx.plugin(TypertGatewayService) + ctx.typert.resources.register('test-shelf', { field: 'shelfId' }) + await ctx.plugin(Notes) + return { ctx, access: ctx.remoteAccess } +} + +function call(ctx: Context, principal: Principal, method: string, args: Record = {}): Promise { + return ctx.typertGateway.invoke({ peer: new Peer(ctx, { principal }), namespace: 'notes', method, args }) +} + +/** A request by `principal` for `action` on `resources`. */ +function ask(principal: Principal, action: RemoteAccessRequest['action'], ...resources: RemoteAccessResource[]): RemoteAccessRequest { + return { principal, action, resources } +} + +const NEVER = new AbortController().signal + +/** The invocation a Gateway binds while dispatching `remoteAccess/follow` for one principal. */ +function invocationOf(ctx: Context, principal: Principal, method = 'follow', namespace = 'remoteAccess'): RemoteInvocation { + return { + request: { namespace, method, args: {} }, + service: namespace, + peer: new Peer(ctx, { principal }), + signal: NEVER, + uplink: (): AsyncIterable => ({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }), + } +} + +async function follow(ctx: Context, principal: Principal): Promise<{ + readonly frames: RemoteAccessFrame[] + readonly next: () => Promise + readonly stop: () => Promise +}> { + const abort = new AbortController() + const peer: PeerScope = new Peer(ctx, { principal }) + const stream = await ctx.typertGateway.stream({ peer, namespace: 'remoteAccess', method: 'follow', args: {}, signal: abort.signal }) + const iterator = stream[Symbol.asyncIterator]() + const frames: RemoteAccessFrame[] = [] + return { + frames, + next: async () => { + const result = await iterator.next() + if (result.done === true) throw new Error('access stream ended') + const frame = result.value as RemoteAccessFrame + frames.push(frame) + return frame + }, + stop: async () => { + abort.abort() + await iterator.return?.() + }, + } +} + +interface SpiedListenerMethod { + readonly mock: { readonly calls: readonly unknown[][]; readonly contexts: readonly unknown[] } +} + +/** `abort` registrations a spied `EventTarget` method received on AbortSignals. */ +function abortListenerCalls(spy: SpiedListenerMethod): number { + return spy.mock.calls.filter((args, index) => args[0] === 'abort' && spy.mock.contexts[index] instanceof AbortSignal).length +} + +describe('RemoteAccessService registrations', () => { + it('removes a policy with its disposer and with the plugin fiber that registered it', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + const dispose = access.registerPolicy('test-guest', policy) + await expect(call(ctx, GUEST, 'read')).resolves.toBe('read') + dispose() + await expect(call(ctx, GUEST, 'read')).rejects.toMatchObject({ code: 'remote-access/denied', details: { reason: 'no-allow' } }) + const fiber = ctx.plugin({ + inject: ['remoteAccess'], + apply: (pluginCtx: Context) => { pluginCtx.remoteAccess.registerPolicy('test-guest', policy) }, + }) + await fiber + await expect(call(ctx, GUEST, 'read')).resolves.toBe('read') + await fiber.dispose() + await expect(call(ctx, GUEST, 'read')).rejects.toMatchObject({ code: 'remote-access/denied', details: { reason: 'no-allow' } }) + }) + + it('accepts one policy per principal kind', async () => { + const { access } = await setup() + access.registerPolicy('test-guest', new GuestPolicy()) + expect(() => access.registerPolicy('test-guest', new GuestPolicy())) + .toThrow('remote access: principal kind "test-guest" already has a policy') + access.registerPolicy('test-crew', { bindings: () => [{ role: READER, resource: GLOBAL }] }) + expect(access.decide(ask(CREW, 'notes/read', GLOBAL))).toEqual({ kind: 'allow' }) + }) +}) + +describe('RemoteAccessService.decide', () => { + it('allows the owner without any policy and refuses a kind without a policy', async () => { + const { ctx, access } = await setup() + expect(access.decide(ask(OWNER, 'notes/hidden', ROOM))).toEqual({ kind: 'allow' }) + await expect(call(ctx, OWNER, 'hidden')).resolves.toBe('hidden') + await expect(call(ctx, GUEST, 'read')).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'notes/read', reason: 'no-allow' }, + }) + }) + + it('allows only actions a bound role lists on the resource the binding names', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + policy.hold(GUEST, { role: READER, resource: GLOBAL }, { role: SHELF_WRITER, resource: ROOM }) + await expect(call(ctx, GUEST, 'read')).resolves.toBe('read') + await expect(call(ctx, GUEST, 'hidden')).rejects.toMatchObject({ details: { action: 'notes/hidden', reason: 'no-allow' } }) + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'hi' } })).resolves.toBe('hi') + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r2', text: 'hi' } })).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + await expect(call(ctx, OTHER, 'read')).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + // Resources are independent: a global grant implies nothing on a shelf, and a shelf grant nothing on global. + expect(access.decide(ask(GUEST, 'notes/read', ROOM))).toEqual({ kind: 'deny', reason: 'no-allow' }) + expect(access.decide(ask(GUEST, 'notes/write', GLOBAL))).toEqual({ kind: 'allow' }) + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }) + expect(access.decide(ask(GUEST, 'notes/write', GLOBAL))).toEqual({ kind: 'deny', reason: 'no-allow' }) + }) + + it('requires the action on every resource of the target', async () => { + const { access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const both = ask(GUEST, 'notes/write', ROOM, DESK) + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }) + expect(access.decide(both)).toEqual({ kind: 'deny', reason: 'no-allow' }) + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }, { role: DESK_WRITER, resource: DESK }) + expect(access.decide(both)).toEqual({ kind: 'allow' }) + expect(policy.bindings).toHaveBeenCalledTimes(2) + }) + + it('asks a matching binding\'s condition, which can refuse with its own reason', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const paused = vi.fn>(() => ({ kind: 'deny', reason: 'shelf-paused' })) + const locked = vi.fn>(() => ({ kind: 'deny', reason: 'shelf-locked' })) + const short = vi.fn>(({ args }) => { + const request: unknown = args?.['request'] + return typeof request === 'object' && request !== null && Reflect.get(request, 'text') === 'ok' + ? { kind: 'allow' } + : { kind: 'deny', reason: 'text-refused' } + }) + const unrelated = vi.fn>(() => ({ kind: 'allow' })) + policy.hold(GUEST, + { role: SHELF_READER, resource: ROOM, condition: unrelated }, + { role: SHELF_WRITER, resource: ROOM2, condition: unrelated }, + { role: SHELF_WRITER, resource: ROOM, condition: paused }, + { role: SHELF_WRITER, resource: ROOM, condition: locked }) + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'x' } })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'notes/write', reason: 'shelf-paused' }, + }) + expect(paused).toHaveBeenCalledWith({ action: 'notes/write', resource: ROOM, args: { request: { shelfId: 'r1', text: 'x' } } }) + expect(locked).toHaveBeenCalledOnce() + // A condition runs only for a binding whose resource matches and whose role lists the action. + expect(unrelated).not.toHaveBeenCalled() + + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM, condition: short }) + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'x' } })).rejects.toMatchObject({ details: { reason: 'text-refused' } }) + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'ok' } })).resolves.toBe('ok') + + // Any binding without a condition, or with an allowing one, allows. + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM, condition: paused }, { role: SHELF_WRITER, resource: ROOM }) + expect(access.decide(ask(GUEST, 'notes/write', ROOM))).toEqual({ kind: 'allow' }) + expect(paused).toHaveBeenLastCalledWith({ action: 'notes/write', resource: ROOM }) + }) + + it('names the reason explain() gives once no binding allows, without changing the decision', async () => { + const { access } = await setup() + const policy = new GuestPolicy() + const explain = vi.fn((request: { readonly action: string }) => (request.action === 'notes/write' ? 'share-not-writable' : undefined)) + access.registerPolicy('test-guest', { bindings: policy.bindings, explain }) + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + expect(access.decide(ask(GUEST, 'notes/read', GLOBAL))).toEqual({ kind: 'allow' }) + expect(explain).not.toHaveBeenCalled() + const write = { ...ask(GUEST, 'notes/write', ROOM, DESK), args: { request: {} } } + expect(access.decide(write)).toEqual({ kind: 'deny', reason: 'share-not-writable' }) + expect(explain).toHaveBeenCalledWith({ principal: GUEST, action: 'notes/write', resources: [ROOM, DESK], args: { request: {} } }) + expect(access.decide(ask(GUEST, 'notes/hidden', GLOBAL))).toEqual({ kind: 'deny', reason: 'no-allow' }) + expect(explain).toHaveBeenLastCalledWith({ principal: GUEST, action: 'notes/hidden', resources: [GLOBAL] }) + // A refusing condition's reason comes first. + const paused: RemoteAccessCondition<'test-shelf'> = () => ({ kind: 'deny', reason: 'shelf-paused' }) + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM, condition: paused }) + expect(access.decide(ask(GUEST, 'notes/write', ROOM))).toEqual({ kind: 'deny', reason: 'shelf-paused' }) + expect(explain).toHaveBeenCalledTimes(2) + }) + + it('refuses with policy-failed when bindings(), a condition, or explain() throws, logging each policy once per change', async () => { + const { ctx, access } = await setup() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const failures = (): unknown[] => warn.mock.calls.flatMap(([value]) => (value instanceof Error ? [value.message] : [])) + let mode: 'bindings' | 'condition' | 'explain' = 'bindings' + access.registerPolicy('test-guest', { + bindings: () => { + if (mode === 'bindings') throw new Error('binding store unavailable') + return [{ role: SHELF_WRITER, resource: ROOM, condition: () => { throw new Error('condition failed') } }] + }, + explain: () => { throw new Error('explain failed') }, + }) + access.registerPolicy('test-crew', { bindings: () => { throw new Error('crew store unavailable') } }) + for (let round = 0; round < 3; round++) { + expect(access.decide(ask(GUEST, 'notes/write', ROOM))).toEqual({ kind: 'deny', reason: 'policy-failed' }) + expect(access.decide(ask(CREW, 'notes/write', ROOM))).toEqual({ kind: 'deny', reason: 'policy-failed' }) + } + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'x' } })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'notes/write', reason: 'policy-failed' }, + }) + expect(failures()).toEqual(['binding store unavailable', 'crew store unavailable']) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to list bindings for notes/write; its further failures are not logged until access state changes') + + mode = 'condition' + access.invalidate(OTHER) + expect(access.decide(ask(GUEST, 'notes/write', ROOM))).toEqual({ kind: 'deny', reason: 'policy-failed' }) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to evaluate a condition for notes/write; its further failures are not logged until access state changes') + + mode = 'explain' + access.invalidate() + expect(access.decide(ask(GUEST, 'notes/read', ROOM))).toEqual({ kind: 'deny', reason: 'policy-failed' }) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to explain a refusal of notes/read; its further failures are not logged until access state changes') + expect(failures()).toEqual(['binding store unavailable', 'crew store unavailable', 'condition failed', 'explain failed']) + }) + + it('always lets a Peer open its own access snapshot', async () => { + const { ctx, access } = await setup() + access.registerPolicy('test-guest', { bindings: () => [] }) + const guest = await follow(ctx, GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + await guest.stop() + }) +}) + +describe('RemoteAccessService.allows', () => { + it('filters list items for non-owners on one resource without arguments', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const reader = vi.fn>(() => ({ kind: 'allow' })) + policy.hold(GUEST, { role: READER, resource: GLOBAL }, { role: SHELF_READER, resource: ROOM, condition: reader }) + await expect(call(ctx, GUEST, 'rooms')).resolves.toEqual(['r1']) + expect(reader).toHaveBeenCalledWith({ action: 'notes/rooms', resource: ROOM }) + // One call for the Gateway's decision and one per item check. + expect(policy.bindings).toHaveBeenCalledTimes(3) + policy.bindings.mockClear() + await expect(call(ctx, OWNER, 'rooms')).resolves.toEqual(['r1', 'r2']) + expect(access.allows(ROOM)).toBe(true) + expect(policy.bindings).not.toHaveBeenCalled() + }) + + it('reads the bindings on every check, so a running call sees binding changes from its next check on', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + policy.hold(GUEST, { role: SHELF_READER, resource: ROOM }) + // One long-running call, such as a follow stream that filters every frame. + const stream = ctx.extend({ invocation: invocationOf(ctx, GUEST, 'rooms', 'notes') }).remoteAccess + expect(stream.allows(ROOM)).toBe(true) + expect(stream.allows(ROOM2)).toBe(false) + policy.hold(GUEST, { role: SHELF_READER, resource: ROOM2 }) + expect(stream.allows(ROOM)).toBe(false) + expect(stream.allows(ROOM2)).toBe(true) + }) + + it('allows nothing when the principal kind has no policy or the policy fails', async () => { + const { ctx, access } = await setup() + vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const crewCall = ctx.extend({ invocation: invocationOf(ctx, CREW, 'rooms', 'notes') }) + expect(crewCall.remoteAccess.allows(ROOM)).toBe(false) + const bindings = vi.fn((): readonly RemoteAccessBinding[] => { throw new Error('binding store unavailable') }) + access.registerPolicy('test-guest', { bindings }) + const guestCall = ctx.extend({ invocation: invocationOf(ctx, GUEST, 'rooms', 'notes') }) + expect(guestCall.remoteAccess.allows(ROOM)).toBe(false) + expect(guestCall.remoteAccess.allows(ROOM2)).toBe(false) + expect(bindings).toHaveBeenCalledTimes(2) + }) +}) + +describe('first snapshots', () => { + it('gives the owner the owner baseline and a non-owner its current grants', async () => { + const { access } = await setup() + const policy = new GuestPolicy() + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + access.registerPolicy('test-guest', policy) + expect(access.snapshotFor(OWNER)).toEqual({ type: 'baseline', owner: true }) + expect(access.snapshotFor(GUEST)).toEqual({ + type: 'baseline', owner: false, grants: [{ resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }], + }) + expect(access.snapshotFor(CREW)).toEqual({ type: 'baseline', owner: false, grants: [] }) + expect(REMOTE_ACCESS_SNAPSHOT_KEY).toBe('__DSH_REMOTE_ACCESS__') + }) + + it('injects the owner baseline into every index the webServer renders', async () => { + const { ctx } = await setup() + const render = (): IndexInjection[] => { + const table: IndexInjection[] = [] + ctx.emit('webserver/index-inject', table) + return table + } + expect(render()).toEqual([]) + ctx.provide('webServer', {} as Context['webServer']) + await vi.waitFor(() => { + expect(render()).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: true } }]) + }) + }) + + it('lists per bound resource the endpoint actions decide() allows there without arguments, and no grant where none is allowed', async () => { + const { ctx, access } = await setup() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const writeOnly: RemoteAccessCondition<'test-shelf'> = ({ action }) => + action === 'notes/write' ? { kind: 'allow' } : { kind: 'deny', reason: 'read-paused' } + policy.hold(GUEST, + { role: SHELF_READER, resource: ROOM, condition: writeOnly }, + { role: SHELF_WRITER, resource: ROOM }, + { role: DESK_WRITER, resource: DESK, condition: () => { throw new Error('condition failed') } }, + { role: READER, resource: GLOBAL }) + const grants: RemoteAccessGrant[] = [ + { resource: ROOM, actions: ['notes/write'] }, + { resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }, + ] + expect(access.snapshotFor(GUEST)).toEqual({ type: 'baseline', owner: false, grants }) + for (const resource of [ROOM, GLOBAL]) { + for (const action of ['notes/read', 'notes/rooms', 'notes/write'] as const) { + const listed = grants.find(grant => grant.resource === resource)?.actions.includes(action) === true + expect(access.decide(ask(GUEST, action, resource)).kind).toBe(listed ? 'allow' : 'deny') + } + } + // Event actions are decided per notification and never enter the snapshot. + expect(access.decide(ask(GUEST, 'event:notes/changed', GLOBAL))).toEqual({ kind: 'allow' }) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'condition failed' })) + }) + + it('gives a principal whose bindings() throws no grant', async () => { + const { ctx, access } = await setup() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + access.registerPolicy('test-guest', { bindings: () => { throw new Error('binding store unavailable') } }) + expect(access.snapshotFor(GUEST)).toEqual({ type: 'baseline', owner: false, grants: [] }) + const guest = await follow(ctx, GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to list bindings for a snapshot; its further failures are not logged until access state changes') + await guest.stop() + }) +}) + +describe('remoteAccess/follow', () => { + it('sends the owner one owner baseline and nothing else', async () => { + const { ctx } = await setup() + const owner = await follow(ctx, OWNER) + await expect(owner.next()).resolves.toEqual({ type: 'baseline', owner: true }) + await owner.stop() + expect(owner.frames).toHaveLength(1) + + // Outside a Remote call the caller is trusted in-process code, answered as the owner. + for (const abortWhileWaiting of [false, true]) { + const abort = new AbortController() + const direct = ctx.remoteAccess.follow(abort.signal)[Symbol.asyncIterator]() + await expect(direct.next()).resolves.toEqual({ done: false, value: { type: 'baseline', owner: true } }) + if (!abortWhileWaiting) abort.abort() + const end = direct.next() + if (abortWhileWaiting) abort.abort() + await expect(end).resolves.toEqual({ done: true, value: undefined }) + } + }) + + it('pushes upserts and removals after invalidation and policy changes', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const guest = await follow(ctx, GUEST) + const other = await follow(ctx, OTHER) + const crew = await follow(ctx, CREW) + await expect(guest.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + await other.next() + await expect(crew.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'upsert', grant: { resource: ROOM, actions: ['notes/write'] } }) + + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }, { role: SHELF_READER, resource: ROOM }) + access.invalidate() + await expect(guest.next()).resolves.toEqual({ + type: 'upsert', grant: { resource: ROOM, actions: ['notes/read', 'notes/rooms', 'notes/write'] }, + }) + + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ + type: 'upsert', grant: { resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }, + }) + await expect(guest.next()).resolves.toEqual({ type: 'remove', resource: ROOM }) + + // An unchanged grant is not pushed again. + policy.hold(GUEST, { role: READER, resource: GLOBAL }, { role: SHELF_WRITER, resource: ROOM2 }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'upsert', grant: { resource: ROOM2, actions: ['notes/write'] } }) + policy.hold(GUEST, + { role: { ...READER, actions: ['notes/hidden', 'notes/read', 'notes/rooms'] }, resource: GLOBAL }, + { role: SHELF_WRITER, resource: ROOM2 }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ + type: 'upsert', grant: { resource: GLOBAL, actions: ['notes/hidden', 'notes/read', 'notes/rooms'] }, + }) + // A resource on which no action stays allowed loses its grant. + const paused: RemoteAccessCondition<'test-shelf'> = () => ({ kind: 'deny', reason: 'shelf-paused' }) + policy.hold(GUEST, + { role: { ...READER, actions: ['notes/hidden', 'notes/read', 'notes/rooms'] }, resource: GLOBAL }, + { role: SHELF_WRITER, resource: ROOM2, condition: paused }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'remove', resource: ROOM2 }) + + const disposeCrew = access.registerPolicy('test-crew', { bindings: () => [{ role: SHELF_WRITER, resource: ROOM2 }] }) + await expect(crew.next()).resolves.toEqual({ type: 'upsert', grant: { resource: ROOM2, actions: ['notes/write'] } }) + access.invalidate({ kind: 'test-crew', members: ['a', 'b'] }) + disposeCrew() + await expect(crew.next()).resolves.toEqual({ type: 'remove', resource: ROOM2 }) + + await guest.stop() + await crew.stop() + // Every recomputation for the other principal produced the same grants, so it received no increment. + expect(other.frames).toHaveLength(1) + await other.stop() + }) + + it('keeps no abort listener from a wait that a change ended', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const abort = new AbortController() + const stream = ctx.extend({ invocation: invocationOf(ctx, GUEST) }).remoteAccess.follow(abort.signal)[Symbol.asyncIterator]() + await stream.next() + const add = vi.spyOn(EventTarget.prototype, 'addEventListener') + const remove = vi.spyOn(EventTarget.prototype, 'removeEventListener') + try { + for (let round = 0; round < 30; round++) { + // The reader is already waiting when the change arrives. + const pending = stream.next() + policy.hold(GUEST, { role: round % 2 === 0 ? READER : { ...READER, actions: ['notes/read'] }, resource: GLOBAL }) + access.invalidate(GUEST) + await expect(pending).resolves.toMatchObject({ done: false, value: { type: 'upsert' } }) + } + expect(abortListenerCalls(add)).toBeGreaterThan(0) + expect(abortListenerCalls(add) - abortListenerCalls(remove)).toBe(0) + } finally { + add.mockRestore() + remove.mockRestore() + } + abort.abort() + await stream.return?.() + }) + + it('ends every open generation when the service unloads, so a reloaded service starts from a fresh baseline', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + const service = ctx.plugin(RemoteAccessService) + await service + await ctx.plugin(TypertGatewayService) + await ctx.plugin(Notes) + const policy = new GuestPolicy() + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + ctx.remoteAccess.registerPolicy('test-guest', policy) + const open = async (principal: Principal): Promise> => + (await ctx.typertGateway.stream({ + peer: new Peer(ctx, { principal }), namespace: 'remoteAccess', method: 'follow', args: {}, + }))[Symbol.asyncIterator]() + const guest = await open(GUEST) + const owner = await open(OWNER) + await expect(guest.next()).resolves.toMatchObject({ + value: { type: 'baseline', grants: [{ resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }] }, + }) + await expect(owner.next()).resolves.toMatchObject({ value: { type: 'baseline', owner: true } }) + const guestEnd = guest.next() + await service.dispose() + await expect(guestEnd).resolves.toEqual({ done: true, value: undefined }) + await expect(owner.next()).resolves.toEqual({ done: true, value: undefined }) + + await ctx.plugin(RemoteAccessService) + await vi.waitFor(() => { expect(ctx.get('typertGateway')).toBeDefined() }) + const reopened = await follow(ctx, GUEST) + await expect(reopened.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + await reopened.stop() + }) + + it('stops a non-owner generation that is cancelled while waiting', async () => { + const { ctx, access } = await setup() + access.registerPolicy('test-guest', new GuestPolicy()) + const abort = new AbortController() + const stream = ctx.extend({ invocation: invocationOf(ctx, GUEST) }).remoteAccess.follow(abort.signal)[Symbol.asyncIterator]() + await expect(stream.next()).resolves.toMatchObject({ value: { type: 'baseline', owner: false } }) + const pending = stream.next() + abort.abort() + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + // A cancelled generation no longer receives invalidations. + access.invalidate() + }) +}) diff --git a/packages/api/remote-access/tsconfig.client.json b/packages/api/remote-access/tsconfig.client.json new file mode 100644 index 0000000000..0ddeaeac30 --- /dev/null +++ b/packages/api/remote-access/tsconfig.client.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/client/mount.ts", + "src/client/state.ts", + "src/snapshot.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../gateway/tsconfig.client.json" }, + { "path": "../../client/ui-renderer" }, + { "path": "../../client/ui-slots" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/remote-access/tsconfig.host.json b/packages/api/remote-access/tsconfig.host.json new file mode 100644 index 0000000000..0d9e8a79c8 --- /dev/null +++ b/packages/api/remote-access/tsconfig.host.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/snapshot.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../host/webserver" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/remote-access/tsconfig.json b/packages/api/remote-access/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/api/remote-access/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/api/remote-access/tsdown.config.ts b/packages/api/remote-access/tsdown.config.ts new file mode 100644 index 0000000000..0e42a1163e --- /dev/null +++ b/packages/api/remote-access/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + import.meta.dirname, + ['lib/types/index.js'], + { hostPhase: true }, +) diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index c8686dd4e2..3339260812 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -18,8 +18,8 @@ en: a80d44e0683768a5 zh: 379833ac180e5418 /deepseek-ai-dsh-api-remotes/forwarded-host-events: - en: d6d82bf0e55ae5e9 - zh: c3a0c80f8b115039 + en: 31bb9a2f50094b84 + zh: 226c055739d5def7 /deepseek-ai-dsh-api-remotes/build-boundary: en: 7a9934a3ba5b185f zh: 40457fd5707f57f0 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index f479777030..bde4e4c93a 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -44,6 +44,8 @@ This package owns no physical transport or Host service discovery. It projects t The listener signature is not restated here. Each allowlisted event's Cordis `Events` declaration lives in its owner package's client-safe `./types` export, and both faces of this package pull those declarations in. The Host face additionally asserts every entry against `TypertForwardableEventEntry`: an `emit` entry must be a declared one-way event, while a `waterfall` entry must be a declared Agent-scoped waterfall whose final parameter is its same-result `next()` callback. +An `emit` entry may declare `resources`, readers keyed by `TypertResourceMap` kind and typed by the event's own parameters, such as `resources: { session: summary => summary.sessionId }` on `api-session/added`. The Host entry attaches them to each forwarded frame unchanged, and the Gateway forwards a notification to a non-owner Peer only when access control allows the event on the resources they name; a notification without `resources` touches the built-in `global` resource. `api-session/activity`, `api-session/added`, `api-session/error`, `api-session/removed`, and `api-session/status` declare their Session. A notification names one resource identity or none; collection changes travel on follow streams instead. + The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. Each scoped waterfall request must carry its routed Agent directly as `request.agent`; the Host rejects a missing or mismatched identity before forwarding. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams. The payload-free `schedule/changed` event invalidates Schedule task queries; Clients re-read after a notification and reconnect. The payload-free `permission-presets/catalog-changed` event invalidates the process catalog. A Client subscribes before its first `permissionPresets.catalog()` read and re-reads the complete snapshot after a notification; the event carries no catalog state and changes no Session sequence. diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index b130e28b76..680e41e045 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -44,6 +44,8 @@ Client 组合挂载 Office 转换、Commands、凭据、settings、Goal、Schedu 监听器签名不在此处重写。名单内每条事件的 Cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 导出,本包两个 face 都把那些声明纳入编译面。Host face 还会把每个条目断言给 `TypertForwardableEventEntry`:`emit` 条目必须是已声明的单向事件,`waterfall` 条目则必须是已声明的 agent-scoped waterfall,且其最后一个参数是返回相同结果类型的 `next()` 回调。 +`emit` 条目可以声明 `resources`:以 `TypertResourceMap` 种类为键、类型由该事件自身参数决定的读取函数,例如 `api-session/added` 上的 `resources: { session: summary => summary.sessionId }`。Host entry 把它们原样附在每个转发帧上;只有访问控制在它们给出的资源上允许该事件时,Gateway 才把通知转发给非 owner Peer,未声明 `resources` 的通知触及内置的 `global` 资源。`api-session/activity`、`api-session/added`、`api-session/error`、`api-session/removed` 与 `api-session/status` 声明其 Session。一个通知只携带一个资源身份或不携带;集合类变化改走 follow 类流。 + Host entry 为每条 Client 流独立注册一组 allowlist listener 和一个队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。每个作用域 waterfall 请求都必须以 `request.agent` 直接携带路由所用的 agent;Host 会在转发前拒绝缺失或不匹配的身份。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` 逻辑流前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动流。 无 payload 的 `schedule/changed` 事件使 Schedule 任务查询失效;Client 在收到通知及重连后重新读取。无 payload 的 `permission-presets/catalog-changed` 事件使进程目录失效。Client 在首次读取 `permissionPresets.catalog()` 前先订阅,并在通知后重新读取完整快照;该事件不携带目录状态,也不改变 Session 序号。 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 1ac78ce12f..c4db08b517 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -81,6 +81,7 @@ "@deepseek-ai/dsh-permission-presets": "workspace:*", "@deepseek-ai/dsh-schedule": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-reference": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 781ffbb95a..d2fa2989f1 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -48,10 +48,12 @@ export function apply(ctx: Context): void { function remoteEventSource(ctx: Context): TypertRemoteEventSource { return (signal) => { const queue = new RemoteEventQueue() - const disposers = API_REMOTE_FORWARDED_EVENTS.map(({ event, mode }) => { + const disposers = API_REMOTE_FORWARDED_EVENTS.map((entry) => { + const { event, mode } = entry if (mode === 'emit') { + const resources = 'resources' in entry ? entry.resources : undefined return ctx.on(event as never, ((...args: unknown[]) => { - queue.push({ event, args: assertJsonArgs(event, args) }) + queue.push({ event, args: assertJsonArgs(event, args), ...resources === undefined ? {} : { resources } }) }) as never) } return ctx.on(event as never, (function ( diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index f57a5c6481..c59f72030c 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -7,6 +7,7 @@ */ import type {} from '@deepseek-ai/dsh-api-session-controller/remote-events' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-deepseek-account/types' import type {} from '@deepseek-ai/dsh-permission-presets/types' import type {} from '@deepseek-ai/dsh-plugin-manager/types' @@ -16,15 +17,19 @@ import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protoc /** * Host events this application forwards without renaming. The explicit mode is * both the Host dispatch strategy and the legal key set of `ctx.remote.$on`. + * An `emit` entry declares readers of the resources its payload carries, or + * none to touch the built-in `global` resource; it reaches a non-owner Peer + * only when access control allows it. Each notification names one resource + * identity or none, and collection changes travel on follow streams instead. */ export const API_REMOTE_FORWARDED_EVENTS = [ { event: 'agent-preset/selected', mode: 'emit' }, { event: 'approval/request', mode: 'waterfall' }, - { event: 'api-session/activity', mode: 'emit' }, - { event: 'api-session/added', mode: 'emit' }, - { event: 'api-session/error', mode: 'emit' }, - { event: 'api-session/removed', mode: 'emit' }, - { event: 'api-session/status', mode: 'emit' }, + { event: 'api-session/activity', mode: 'emit', resources: { session: (sessionId): SessionId => sessionId } }, + { event: 'api-session/added', mode: 'emit', resources: { session: (summary): SessionId => summary.sessionId } }, + { event: 'api-session/error', mode: 'emit', resources: { session: (sessionId): SessionId => sessionId } }, + { event: 'api-session/removed', mode: 'emit', resources: { session: (sessionId): SessionId => sessionId } }, + { event: 'api-session/status', mode: 'emit', resources: { session: (sessionId): SessionId => sessionId } }, { event: 'commands/change', mode: 'emit' }, { event: 'deepseek-account/session-expired', mode: 'emit' }, { event: 'deepseek-account/model-sign-in-required', mode: 'emit' }, diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index 991c1ac46e..5b8200c657 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -24,6 +24,7 @@ const requiredArtifacts = [ 'packages/goal/goal/lib/typert.host.js', 'packages/api/gateway/lib/client.js', 'packages/api/gateway/lib/index.js', + 'packages/api/remote-access/lib/index.js', 'packages/typert/registry/lib/client.js', 'packages/typert/registry/lib/index.js', 'packages/session/session-projection/lib/index.js', @@ -39,6 +40,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { connectionHost: 'packages/client/connection/lib/index.js', goal: 'packages/goal/goal/lib/index.js', goalTypert: 'packages/goal/goal/lib/typert.host.js', + remoteAccess: 'packages/api/remote-access/lib/index.js', registryClient: 'packages/typert/registry/lib/client.js', registryHost: 'packages/typert/registry/lib/index.js', remotesClient: 'packages/api/remotes/lib/client.js', @@ -55,6 +57,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { const { default: AgentRegistry } = await import(urls.agent) const connectionHost = await import(urls.connectionHost) const { default: TypertRemoteService } = await import(urls.apiGatewayHost) + const { default: RemoteAccessService } = await import(urls.remoteAccess) const { default: GoalService } = await import(urls.goal) const { default: SessionProjectionRegistry } = await import(urls.sessionProjections) const { TYPERT } = await import(urls.goalTypert) @@ -84,6 +87,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply }) await host.plugin(TypertRegistry) await host.plugin(AgentRegistry) + await host.plugin(RemoteAccessService) await host.plugin(TypertRemoteService) await host.plugin(SessionProjectionRegistry) await host.plugin(GoalService) diff --git a/packages/api/remotes/tests/remote-events.host.spec.ts b/packages/api/remotes/tests/remote-events.host.spec.ts index 80841acf6d..977257ee44 100644 --- a/packages/api/remotes/tests/remote-events.host.spec.ts +++ b/packages/api/remotes/tests/remote-events.host.spec.ts @@ -152,6 +152,33 @@ describe('Remote event Host source', () => { await ctx.fiber.dispose() }) + it('attaches each declared resource reader to its forwarded notification', async () => { + const { ctx, gateway, fiber } = await setup() + const abort = new AbortController() + const stream = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() + const cases: readonly (readonly [string, readonly unknown[], string])[] = [ + ['api-session/activity', ['s-activity', 1], 's-activity'], + ['api-session/added', [{ sessionId: 's-added' }], 's-added'], + ['api-session/error', ['s-error', 'boom'], 's-error'], + ['api-session/removed', ['s-removed'], 's-removed'], + ['api-session/status', ['s-status', true], 's-status'], + ] + for (const [event, args, sessionId] of cases) { + emitRaw(ctx, event, args) + const next = await stream.next() + if (next.done === true || 'context' in next.value) throw new Error(`${event} was not forwarded as a notification`) + const frame = next.value + expect(frame).toMatchObject({ event, args }) + expect(frame.resources?.['session']?.(...frame.args)).toBe(sessionId) + } + // An event that declares no resources carries no readers, so access control sees the global resource. + emitRaw(ctx, 'commands/change', []) + await expect(stream.next()).resolves.toEqual({ done: false, value: { event: 'commands/change', args: [] } }) + abort.abort(new Error('Client disconnected')) + await fiber.dispose() + await ctx.fiber.dispose() + }) + it('rejects a non-JSON argument without poisoning the stream', async () => { const { ctx, gateway } = await setup() const abort = new AbortController() diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 279c07025f..729bbd9e6b 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -33,6 +33,9 @@ { "path": "../../context/session-reference" }, + { + "path": "../../core/session" + }, { "path": "../../extensions/cordis-host-runner" }, diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 3d1a5b274d..46f670802e 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: d8c0e2b2e45fa75d - zh: 025b50be4204580c + en: f9e4042fae88f801 + zh: f379f78966d57c2e /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index ed01827a70..02dff176ba 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -33,6 +33,8 @@ The Client journal validates current Session event envelopes before publishing f Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. +Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers the `session` resource kind with the default field `sessionId` through `typert.resources`. `session/page` and `session/follow` declare a `session` reader in `@Remote({ resources })` that reads `request.address.sessionId` for an ordinary Session address and `request.address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. + List rows carry `formatStatus` when available, so Clients can identify migration-required Sessions without requesting their projections. Host list generation yields between complete rows only when its configured work slice is exhausted; cheap remaining work returns without a forced yield. Request cancellation is checked per row and after each yield and rejects without partial results. Each row is synchronous; the list does not promise one cross-Session snapshot. A Session already queued as cold remains a cached row if it attaches during a yield: Client in-flight mutation replay corrects its availability, and sequenced projections supersede cached hints. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index cc5a4ce4b2..b01a99cc1f 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -33,6 +33,8 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证当 每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 +每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `sessionId` 登记 `session` 资源种类。`session/page` 与 `session/follow` 在 `@Remote({ resources })` 中声明 `session` 读取函数:对普通 Session 地址读 `request.address.sessionId`,对 subagent 地址读 `request.address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 + 列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。 Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件循环;剩余工作较少时直接返回,不强制让出。每行及每次让出之后检查请求取消,取消时拒绝返回部分结果。每行同步计算;列表不承诺跨 Session 的统一快照。已经进入冷态队列的 Session 若在让出期间接入,仍返回缓存行:Client 对进行中请求的变更重放会纠正其可用性,带序号的投影会覆盖缓存提示。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 436b151bbb..1bcdd19004 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -122,6 +122,7 @@ } }, "devDependencies": { + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-default-model": "workspace:*", diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 5bb0a7039d..8dd38573c8 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -20,7 +20,6 @@ import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' -import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' import { assertNever } from '@deepseek-ai/dsh-util-values' import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type { Workspace } from '@deepseek-ai/dsh-workspace' @@ -65,7 +64,12 @@ type PromptContentCandidate = | SessionPromptRequest['content'][number] | Extract['content'][number] -function hasPromptContent(content: readonly PromptContentCandidate[]): boolean { +/** + * Whether prompt content carries non-whitespace text or an attachment. + * @param content - submitted or edited prompt parts. + * @returns false for content the Agent must not receive. + */ +export function hasPromptContent(content: readonly PromptContentCandidate[]): boolean { return content.some(part => part.type !== 'text' || part.text.trim().length > 0) } @@ -306,35 +310,15 @@ export class SessionCommandController { } /** - * Reject empty content, then admit one prompt after Agent and attachment validation. - * @param request - Session identity, prompt content, source metadata, and delivery mode. + * Admit one prompt after Agent and attachment validation; the Remote method + * has already validated the content and built the durable message source. + * @param request - Session identity, prompt content, and delivery mode. + * @param source - the `user-rpc` source recorded on the accepted message. * @returns acknowledgement that the Agent accepted the prompt. */ - async prompt(request: SessionPromptRequest): Promise { - if (!hasPromptContent(request.content)) { - throw new RemoteError( - 'gateway/bad-request', - 'prompt content must include non-whitespace text or an attachment', - {}, - ) - } - const clientTimeZone = request.clientTimeZone === undefined - ? undefined - : canonicalClientTimeZone(request.clientTimeZone) - if (request.clientTimeZone !== undefined && clientTimeZone === undefined) { - throw new RemoteError( - 'session/invalid-time-zone', - 'clientTimeZone must be UTC or a valid IANA Area/Location name', - { value: request.clientTimeZone }, - ) - } + async prompt(request: SessionPromptRequest, source: MessageSource): Promise { const agent = await this.resolveAgent(request.sessionId) if (hasPromptRequest(agent, request.requestId)) return { accepted: true } - const source: MessageSource = { - kind: 'user', - rpcId: request.requestId, - ...(clientTimeZone === undefined ? {} : { clientTimeZone }), - } const hasImage = request.content.some(part => part.type === 'image') const admit = async (): Promise => { try { diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b28be14073..2362bce21c 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -6,20 +6,23 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-fs' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { errorChain, ReasoningEffortId, type MessageSource } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-client-file-upload' import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-session-projection-cache' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' -import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService, principalOf } from '@deepseek-ai/dsh-typert-protocol' +import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import type {} from '@deepseek-ai/dsh-api-remote-access' import { ApiSessionAgentController, inspectApiSession, type ApiSessionAgentResult, } from './agent.ts' -import { SessionCommandController } from './commands.ts' +import { SessionCommandController, hasPromptContent } from './commands.ts' import { SessionControlController } from './control.ts' import { SessionHistoryController } from './history.ts' import { SessionFileReferences } from './file-references.ts' @@ -32,6 +35,7 @@ import { ArchivedSessionGate } from './archived-session-gate.ts' import type { ModelCatalog, SessionWorkspacePathApplication, + SessionAddress, SessionAttachmentRequest, SessionAttachmentValue, SessionCancelRequest, @@ -76,6 +80,16 @@ declare module '@deepseek-ai/cordis' { } } +/** + * Read the Session resource a history address names: the Session itself, or + * the child Session whose transcript a subagent address reads. + * @param request - history request carrying the durable address. + * @returns the Session whose access the read requires. + */ +function addressedSession(request: { readonly address: SessionAddress }): SessionId { + return request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId +} + /** Session Controller deployment policy. */ export interface Config { /** Override platform desktop-opener detection. */ @@ -110,6 +124,7 @@ export class SessionController extends TypertRemoteService { 'fileUploads', 'fs', 'llm', + 'remoteAccess', 'sessions', 'sessionProjections', 'sessionQuery', @@ -141,6 +156,7 @@ export class SessionController extends TypertRemoteService { */ constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) { super(ctx, 'sessionController', { namespace: 'session' }) + ctx.typert.resources.register('session', { field: 'sessionId' }) const resolved = SessionController.Config(config) installModelSelectionProjection(ctx) this.agents = new ApiSessionAgentController(ctx) @@ -258,18 +274,22 @@ export class SessionController extends TypertRemoteService { */ @Remote('list') async list(_request: SessionListRequest, signal: AbortSignal): Promise { - return { items: await this.listState.list(signal) } + const items = await this.listState.list(signal) + return { items: items.filter(item => this.ctx.remoteAccess.allows({ kind: 'session', id: item.sessionId })) } } /** - * Search visible Session content without resuming an Agent. + * Search visible Session content without resuming an Agent. A non-owner + * receives only the hits whose Session `allows()` admits; `hasMore` + * describes the bounded result before that filtering. * @param request - literal message-content query. * @param signal - cancellation for list and search reads. * @returns authorized bounded Session search results. */ @Remote('search') - search(request: SessionSearchRequest, signal: AbortSignal): Promise { - return this.listState.search(request.query, signal) + async search(request: SessionSearchRequest, signal: AbortSignal): Promise { + const result = await this.listState.search(request.query, signal) + return { ...result, items: result.items.filter(item => this.ctx.remoteAccess.allows({ kind: 'session', id: item.sessionId })) } } /** @@ -439,9 +459,30 @@ export class SessionController extends TypertRemoteService { * @returns acknowledgement that the Agent accepted the prompt. */ @Remote('prompt') - prompt(request: SessionPromptRequest, signal: AbortSignal): Promise { + async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise { signal.throwIfAborted() - return this.commands.prompt(request) + if (!hasPromptContent(request.content)) { + throw new RemoteError('gateway/bad-request', 'prompt content must include non-whitespace text or an attachment', {}) + } + const clientTimeZone = request.clientTimeZone === undefined + ? undefined + : canonicalClientTimeZone(request.clientTimeZone) + if (request.clientTimeZone !== undefined && clientTimeZone === undefined) { + throw new RemoteError( + 'session/invalid-time-zone', + 'clientTimeZone must be UTC or a valid IANA Area/Location name', + { value: request.clientTimeZone }, + ) + } + // The caller is recorded only when it is not the owner, so owner messages keep their exact source. + const principal = principalOf(this.ctx.invocation) + const source: MessageSource = { + kind: 'user', + rpcId: request.requestId, + ...(clientTimeZone === undefined ? {} : { clientTimeZone }), + ...(principal === undefined ? {} : { principal }), + } + return this.commands.prompt(request, source) } /** @@ -480,7 +521,7 @@ export class SessionController extends TypertRemoteService { * @param signal - cancellation for persistence reads. * @returns one chronological page. */ - @Remote('page') + @Remote({ name: 'page', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise { return this.history.page(request, signal) } @@ -492,7 +533,7 @@ export class SessionController extends TypertRemoteService { * @returns a complete opening snapshot followed by gap-free durable event * frames and optional cursorless assistant-stream frames. */ - @Remote({ mode: 'stream' }) + @Remote({ mode: 'stream', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { return this.history.follow(request, signal) } @@ -555,8 +596,23 @@ export class SessionController extends TypertRemoteService { * @returns one complete baseline followed by live replacement frames. */ @Remote({ mode: 'stream' }) - control(signal: AbortSignal): AsyncIterable { - return this.controlState.control(signal) + async *control(signal: AbortSignal): AsyncIterable { + for await (const frame of this.controlState.control(signal)) { + switch (frame.type) { + case 'baseline': { + const projections = Object.fromEntries(Object.entries(frame.value.projections) + .filter(([sessionId]) => this.ctx.remoteAccess.allows({ kind: 'session', id: sessionId as SessionId }))) + yield { type: 'baseline', value: { ...frame.value, projections } } + break + } + case 'projection': + if (this.ctx.remoteAccess.allows({ kind: 'session', id: frame.sessionId })) yield frame + break + /* v8 ignore next -- closed control-frame union. */ + default: + assertNever(frame) + } + } } diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 03878bfae7..c0b2b9a573 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -9,6 +9,7 @@ import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand' import type { TextBlock } from '@deepseek-ai/dsh-llm' import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session/types' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import type { Principal } from '@deepseek-ai/dsh-typert-protocol' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' @@ -409,8 +410,12 @@ export type SessionRequestId = Branded<'session-request-id'> declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { - /** Browser prompt correlation and optional Host-validated time zone. */ - 'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string } + /** + * Browser prompt correlation, optional Host-validated time zone, and the + * caller that submitted the prompt. `principal` is written only for a + * caller other than the owner, so an absent `principal` means the owner. + */ + 'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string; principal?: Principal } } } diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 53d1137628..4c7bc2ed5a 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -7,7 +7,7 @@ import type { FileAttachmentRef, ImageAttachmentRef, SaveFileAttachment, SaveFileStreamAttachment, } from '@deepseek-ai/dsh-attachment' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { UserMessage } from '@deepseek-ai/dsh-llm' +import type { MessageSource, UserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import CommandRuntime from '@deepseek-ai/dsh-commands' import { createScope } from '@deepseek-ai/dsh-scope' @@ -113,6 +113,9 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ } } +/** The owner source `SessionController.prompt()` builds for {@link promptRequest}. */ +const USER_SOURCE: MessageSource = { kind: 'user', rpcId: 'req-1' as SessionRequestId } + function promptRequest(content: Parameters[0]['content']) { return { requestId: 'req-1' as SessionRequestId, @@ -163,7 +166,7 @@ describe('Session file uploads', () => { { type: 'file', receiptId: receipt.receiptId }, { type: 'image', mediaType: 'image/png', data: 'AAAA' }, { type: 'text', text: 'read it' }, - ])) + ]), USER_SOURCE) expect(followup).toHaveBeenCalledTimes(1) const message = followup.mock.calls[0]?.[0] as UserMessage expect(message.content).toEqual([ @@ -190,7 +193,7 @@ describe('Session file uploads', () => { name: 'huge.bin', })) expect(receipt.file).toMatchObject({ name: 'huge.bin', bytes: 5 }) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([ { type: 'file', attachment: receipt.file }, ]) @@ -217,7 +220,7 @@ describe('Session file uploads', () => { await expect(controller.prompt(promptRequest([ { type: 'image', mediaType: 'image/png', data: 'AAAA' }, { type: 'file', receiptId: 'missing-receipt' as FileUploadReceiptId }, - ]))).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } }) + ]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } }) expect(followup).not.toHaveBeenCalled() expect(saveImages).not.toHaveBeenCalled() }) @@ -302,7 +305,7 @@ describe('Session file uploads', () => { const { controller, uploads, agent } = await uploadHarness() uploads.retirePrompt(agent, 'not-staged') const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) uploads.retirePrompt(agent, 'other-request') expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) @@ -317,7 +320,7 @@ describe('Session file uploads', () => { { data: 'AAAA' }, new AbortController().signal, ) - await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }])) + await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]), USER_SOURCE) first.ctx.emit('session/event', first.agent.session, { type: 'user/message', data: createUserMessage({ @@ -345,9 +348,9 @@ describe('Session file uploads', () => { it('deduplicates a retried rpcId already present in the Agent inbox', async () => { const { controller, agent, followup } = await uploadHarness() const request = promptRequest([{ type: 'text', text: 'once' }]) - await controller.prompt(request) + await controller.prompt(request, USER_SOURCE) agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage) - await expect(controller.prompt(request)).resolves.toEqual({ accepted: true }) + await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true }) expect(followup).toHaveBeenCalledOnce() }) @@ -364,7 +367,7 @@ describe('Session file uploads', () => { source: { kind: 'user', rpcId: request.requestId }, }), { surfaceOp: 'append' }) - await expect(controller.prompt(request)).resolves.toEqual({ accepted: true }) + await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true }) expect(followup).not.toHaveBeenCalled() }) @@ -374,7 +377,7 @@ describe('Session file uploads', () => { saveImages.mockReturnValueOnce(admitted.promise) const prompting = controller.prompt(promptRequest([ { type: 'image', mediaType: 'image/png', data: 'AAAA' }, - ])) + ]), USER_SOURCE) await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) await disposeAgent() admitted.resolve([{ @@ -386,7 +389,7 @@ describe('Session file uploads', () => { it('rejects when a previously bound receipt retires during image admission', async () => { const { controller, uploads, agent, saveImages } = await uploadHarness() const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) const admitted = Promise.withResolvers() saveImages.mockReturnValueOnce(admitted.promise) const prompting = controller.prompt({ @@ -395,7 +398,7 @@ describe('Session file uploads', () => { { type: 'image', mediaType: 'image/png', data: 'AAAA' }, ]), requestId: 'req-2' as SessionRequestId, - }) + }, USER_SOURCE) await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) uploads.retirePrompt(agent, 'req-1') admitted.resolve([{ @@ -409,12 +412,12 @@ describe('Session file uploads', () => { it('keeps the prior receipt binding when a later prompt attempt fails', async () => { const { controller, uploads, agent, followup } = await uploadHarness() const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) followup.mockImplementationOnce(() => { throw new Error('busy') }) await expect(controller.prompt({ ...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), requestId: 'req-2' as SessionRequestId, - })).rejects.toMatchObject({ code: 'session/agent-busy' }) + }, USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' }) uploads.retirePrompt(agent, 'req-1') expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined() }) @@ -425,7 +428,7 @@ describe('Session file uploads', () => { followup.mockImplementationOnce(() => { throw new Error('busy') }) await expect(controller.prompt(promptRequest([ { type: 'file', receiptId: receipt.receiptId }, - ]))).rejects.toMatchObject({ code: 'session/agent-busy' }) + ]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' }) uploads.retirePrompt(agent, 'req-1') expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) }) @@ -433,7 +436,7 @@ describe('Session file uploads', () => { it('retires a prompt-bound receipt when its queued occurrence is removed', async () => { const { controller, uploads, agent, followup } = await uploadHarness() const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }])) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) const queued = followup.mock.calls[0]?.[0] as UserMessage agent.inbox.append('next-turn', queued) expect(await controller.updateQueue({ @@ -454,7 +457,7 @@ describe('Session file uploads', () => { await controller.prompt(promptRequest([ { type: 'file', receiptId: first.receiptId }, { type: 'file', receiptId: second.receiptId }, - ])) + ]), USER_SOURCE) const message = followup.mock.calls[0]?.[0] as UserMessage expect(message.content).toEqual([ diff --git a/packages/api/session-controller/tests/list-scheduling.host.spec.ts b/packages/api/session-controller/tests/list-scheduling.host.spec.ts index eefea76713..b3e412ec9f 100644 --- a/packages/api/session-controller/tests/list-scheduling.host.spec.ts +++ b/packages/api/session-controller/tests/list-scheduling.host.spec.ts @@ -8,6 +8,7 @@ import type { SessionRecord } from '@deepseek-ai/dsh-session-query' import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { scheduler } from 'node:timers/promises' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '../src/index.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiSessionList } from '../src/list.ts' @@ -181,10 +182,15 @@ describe('Session-list work slices', () => { it('routes an overridden budget through the public Session Controller', async () => { const h = await harness([{ id: 'first', phase: 'cold' }, { id: 'middle', phase: 'cold' }, { id: 'last', phase: 'cold' }], 4) const dispose = (): void => {} - h.ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose } } as never) + h.ctx.provide('typert', { + lookups: { configure: () => dispose }, + contexts: { configureHost: () => dispose }, + resources: { register: () => dispose }, + } as never) h.ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) h.ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'test', model: 'test' }) } as never) h.ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) + new RemoteAccessService(h.ctx) const controller = new SessionController(h.ctx, { nativeOpen: false, listWorkSliceMs: 4 }, { canOpenPath: () => false }) const yieldWork = vi.spyOn(scheduler, 'yield') const result = await controller.list({}, new AbortController().signal) diff --git a/packages/api/session-controller/tests/remote-access.host.spec.ts b/packages/api/session-controller/tests/remote-access.host.spec.ts new file mode 100644 index 0000000000..bd39bc13f1 --- /dev/null +++ b/packages/api/session-controller/tests/remote-access.host.spec.ts @@ -0,0 +1,222 @@ +/** + * Session Controller under non-owner callers: the Session resource kind it + * registers, the Session readers of `session/page` and `session/follow`, + * `session/list`, `session/search`, and `session/control` called through a + * `global` role and filtered per Session through `remoteAccess.allows()`, the caller recorded + * on prompt sources, and `session/projections` admitted per Session through + * the Gateway. + */ + +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' +import TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import type { RemoteAccessBinding, RemoteAccessRole } from '@deepseek-ai/dsh-api-remote-access' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { remoteMethods, type PeerId, type PeerScope, type Principal, type RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type SessionController from '../src/index.ts' +import { ApiSessionList } from '../src/list.ts' +import type { SessionControlFrame, SessionRequestId, SessionSearchValue } from '../src/types.ts' +import { createSessionTestController } from './test-remote.ts' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } +} + +const OWNER: Principal = { kind: 'owner' } +const GUEST: Principal = { kind: 'test-guest', guest: 'session-controller' } +const SHARED = SessionId('shared-session') +const PRIVATE = SessionId('private-session') + +/** Calls the Session list and control methods; each item still needs a Session role. */ +const LISTER: RemoteAccessRole<'global'> = { name: 'test:session-lister', resource: 'global', actions: ['session/list', 'session/control'] } +/** Reads one Session: its list row, its control projections, and its stored projections. */ +const READER: RemoteAccessRole<'session'> = { + name: 'test:session-reader', resource: 'session', actions: ['session/list', 'session/control', 'session/projections'], +} +const LIST_CALLS: RemoteAccessBinding = { role: LISTER, resource: { kind: 'global' } } +const READS_SHARED: RemoteAccessBinding = { role: READER, resource: { kind: 'session', id: SHARED } } +const SEARCH_CALLS: RemoteAccessBinding = { + role: { name: 'test:session-searcher', resource: 'global', actions: ['session/search'] }, + resource: { kind: 'global' }, +} +const SEARCHES_SHARED: RemoteAccessBinding = { + role: { name: 'test:session-search-reader', resource: 'session', actions: ['session/search'] }, + resource: { kind: 'session', id: SHARED }, +} + +const owned = new Set() +afterEach(async () => { + await Promise.all([...owned].map(ctx => ctx.fiber.dispose())) + owned.clear() +}) + +/** + * Register the guest policy. + * @returns the bindings the policy answers with; edits apply from the next Remote call. + */ +function guestPolicy(ctx: Context, ...bindings: RemoteAccessBinding[]): RemoteAccessBinding[] { + const held = [...bindings] + ctx.remoteAccess.registerPolicy('test-guest', { bindings: () => held }) + return held +} + +/** Invoke `session/` as the guest through the Gateway. */ +function guestCall(ctx: Context, method: string, args: Readonly>): Promise { + const peer: PeerScope = { id: 'session-access-peer' as PeerId, ctx, principal: GUEST, signal: new AbortController().signal, dispose: async () => {} } + return ctx.typertGateway.invoke({ peer, namespace: 'session', method, args }) +} + +async function harness(): Promise<{ + readonly ctx: Context + readonly controller: SessionController + readonly shared: Agent + readonly hidden: Agent + /** The Context a Gateway dispatch of `session/` binds for `principal`. */ + readonly as: (principal: Principal, method: string) => Context +}> { + const ctx = new Context() + owned.add(ctx) + await ctx.plugin(TypertRegistry) + await mountAgentLoopTestDependencies(ctx) + const loop = await mountAgentLoopTestHarness(ctx) + const shared = await loop.create(SHARED) + const hidden = await loop.create(PRIVATE) + const controller = createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const as = (principal: Principal, method: string): Context => { + const invocation: RemoteInvocation = { + request: { namespace: 'session', method, args: {} }, + service: 'sessionController', + peer: { id: 'session-access-peer' as PeerId, ctx, principal, signal: new AbortController().signal, dispose: async () => {} }, + signal: new AbortController().signal, + uplink: (): AsyncIterable => ({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }), + } + return ctx.extend({ invocation }) + } + return { ctx, controller, shared, hidden, as } +} + +function message(text: string) { + return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }) +} + +describe('Session Controller access', () => { + it('owns the Session resource kind and its default sessionId field', async () => { + const { ctx } = await harness() + expect(() => ctx.typert.resources.register('session', { field: 'sessionId' })).toThrow(/already registered/) + }) + + it('reads the Session resource of page and follow requests from the addressed Session', async () => { + const { controller } = await harness() + for (const method of ['page', 'follow']) { + const reader = remoteMethods(controller).find(marker => marker.method === method)?.resources?.['session'] + expect(reader?.({ address: { kind: 'session', sessionId: SHARED } })).toBe(SHARED) + // A subagent address reads the child transcript; whether the parent's access covers it is the policy's rule. + expect(reader?.({ address: { kind: 'subagent', parentSessionId: SHARED, childSessionId: PRIVATE } })).toBe(PRIVATE) + } + }) + + it('lets a global role call session/list and lists only the Sessions a Session role admits', async () => { + const { ctx, as } = await harness() + await ctx.plugin(TypertGatewayService) + const held = guestPolicy(ctx, LIST_CALLS, READS_SHARED) + const rows = async (): Promise => { + const value = await guestCall(ctx, 'list', { _request: {} }) as { readonly items: readonly { readonly sessionId: SessionId }[] } + return value.items.map(item => item.sessionId) + } + await expect(rows()).resolves.toEqual([SHARED]) + // The global role admits the call, not any row. + held.splice(0, held.length, LIST_CALLS) + await expect(rows()).resolves.toEqual([]) + // A Session role alone does not admit the call: the global resource does not inherit from it. + held.splice(0, held.length, READS_SHARED) + await expect(rows()).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'session/list', reason: 'no-allow' }, + }) + const owner = await as(OWNER, 'list').sessionController.list({}, new AbortController().signal) + expect(owner.items.map(item => item.sessionId).sort()).toEqual([PRIVATE, SHARED].sort()) + }) + + it('returns a non-owner only the session/search hits whose Session a Session role admits', async () => { + const { ctx, as } = await harness() + await ctx.plugin(TypertGatewayService) + guestPolicy(ctx, SEARCH_CALLS, SEARCHES_SHARED) + const hits: SessionSearchValue = { + items: [{ sessionId: PRIVATE, snippet: 'private hit' }, { sessionId: SHARED, snippet: 'shared hit' }], + hasMore: true, + } + vi.spyOn(ApiSessionList.prototype, 'search').mockResolvedValue(hits) + await expect(guestCall(ctx, 'search', { request: { query: 'hit' } })).resolves.toEqual({ + items: [{ sessionId: SHARED, snippet: 'shared hit' }], + hasMore: true, + }) + await expect(as(OWNER, 'search').sessionController.search({ query: 'hit' }, new AbortController().signal)).resolves.toEqual(hits) + }) + + it('filters session/control baselines and projection frames for a non-owner', async () => { + const { ctx, shared, hidden, as } = await harness() + guestPolicy(ctx, LIST_CALLS, READS_SHARED) + const abort = new AbortController() + const guest = as(GUEST, 'control').sessionController.control(abort.signal)[Symbol.asyncIterator]() + const owner = as(OWNER, 'control').sessionController.control(abort.signal)[Symbol.asyncIterator]() + const baselineSessions = async (iterator: AsyncIterator): Promise => { + const next = await iterator.next() + if (next.done === true || next.value.type !== 'baseline') throw new Error('control stream opened without a baseline') + return Object.keys(next.value.value.projections).sort() + } + expect(await baselineSessions(guest)).toEqual([SHARED]) + expect(await baselineSessions(owner)).toEqual([PRIVATE, SHARED].sort()) + + hidden.inbox.append('next-turn', message('private work')) + shared.inbox.append('next-turn', message('shared work')) + const projected = async (iterator: AsyncIterator): Promise => { + for (;;) { + const next = await iterator.next() + if (next.done === true) throw new Error('control stream ended') + if (next.value.type === 'projection' && next.value.key === 'inbox') return next.value.sessionId + } + } + // The owner sees the private Session's frame first; the guest's first Inbox frame is the shared one. + expect(await projected(owner)).toBe(PRIVATE) + expect(await projected(guest)).toBe(SHARED) + abort.abort() + await Promise.all([guest.return?.(), owner.return?.()]) + }) + + it('records a non-owner caller on the prompt source and leaves owner sources unchanged', async () => { + const { shared, as } = await harness() + const signal = new AbortController().signal + const prompt = (principal: Principal, requestId: string) => as(principal, 'prompt').sessionController.prompt({ + sessionId: SHARED, + requestId: requestId as SessionRequestId, + mode: 'queue', + content: [{ type: 'text', text: `from ${principal.kind}` }], + }, signal) + await expect(prompt(GUEST, 'guest-1')).resolves.toEqual({ accepted: true }) + await expect(prompt(OWNER, 'owner-1')).resolves.toEqual({ accepted: true }) + // The durable Inbox splice records each accepted message with its source. + const logged = shared.session.snapshotEvents() + .flatMap(event => event.type === 'agent/inbox/spliced' ? event.data.inserted.map(item => item.source) : []) + expect(logged).toEqual([ + { kind: 'user', rpcId: 'guest-1', principal: GUEST }, + { kind: 'user', rpcId: 'owner-1' }, + ]) + }) + + it('lets a non-owner read projections only in a Session its Session role names', async () => { + const { ctx } = await harness() + await ctx.plugin(TypertGatewayService) + guestPolicy(ctx, READS_SHARED) + const projections = (sessionId: SessionId): Promise => guestCall(ctx, 'projections', { request: { sessionId } }) + await expect(projections(SHARED)).resolves.toMatchObject({ kind: 'sequenced' }) + await expect(projections(PRIVATE)).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'session/projections', reason: 'no-allow' }, + }) + }) +}) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 71db06b22b..586afcde7b 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -31,6 +31,7 @@ import { remoteErrorOf, type RemoteResult, } from '@deepseek-ai/dsh-typert-protocol' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '../src/index.ts' import type { ModelCatalog, @@ -243,6 +244,7 @@ function installControllers( ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose }, + resources: { register: () => dispose }, } as never) } if (ctx.get('agentDefaultModel') === undefined) { @@ -289,6 +291,7 @@ function installControllers( } as never) } installSessionReadTestServices(ctx) + if (ctx.get('remoteAccess') === undefined) new RemoteAccessService(ctx) const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd) let controller: SessionController try { diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index 6376b437ca..8f90ca7a6a 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -104,6 +104,9 @@ { "path": "../../typert/protocol" }, + { + "path": "../remote-access/tsconfig.host.json" + }, { "path": "../../typert/registry" }, diff --git a/packages/api/terminal-controller/tests/controller.spec.ts b/packages/api/terminal-controller/tests/controller.spec.ts index aa907a95b8..dd86c0be82 100644 --- a/packages/api/terminal-controller/tests/controller.spec.ts +++ b/packages/api/terminal-controller/tests/controller.spec.ts @@ -737,7 +737,7 @@ function attachController( ) { const invocation: RemoteInvocation = { request: { namespace: 'terminal', method: 'attach', args: {} }, service: 'terminalController', signal, - peer: { id: 'test-peer' as PeerId, ctx: agent.ctx, dispose: async () => {} }, + peer: { id: 'test-peer' as PeerId, ctx: agent.ctx, principal: { kind: 'owner' }, signal, dispose: async () => {} }, uplink: () => input as AsyncIterable, uplinkWindow, } diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml index d804399b83..e3cb08a719 100644 --- a/packages/api/workspace-controller/README.i18n.yaml +++ b/packages/api/workspace-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 4e53164159b5ce41 zh: 89582dc0294d0776 /workspace-controller/use-this-package: - en: aa0d93826ab685fe - zh: 144e48d49020f5a4 + en: 8276c5edb7d0db7d + zh: cda0c91f8c3776e6 /workspace-controller/use-this-package/first-use-workspace: en: 61032e060e7213f0 zh: 437eb982a8738539 diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md index b24053384f..27c26c7d9b 100644 --- a/packages/api/workspace-controller/README.md +++ b/packages/api/workspace-controller/README.md @@ -24,6 +24,8 @@ English | [中文](README.zh.md) The Host controller serializes mutations whose correctness depends on current registry state and throws `RemoteError` with a stable error code for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, `archived`, and `pinned` increments. Archive and pin sets are Session id arrays, with the most recently pinned id first in the pin array. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected. `archiveSession` without `stopActivity` refuses a Session with running work as `workspace/session-active`, whose details list that work by family (`turn`, `subagent`, `job`, `schedule`) with item ids and labels; with `stopActivity: true` the registry's providers stop the work first and the response arrives once the archive set is durable, while the stops settle in the background. +The controller injects `remoteAccess` and registers the `workspace` resource kind with the default field `workspaceId` through `typert.resources`. `workspace/follow` reads no resource, so its call touches the `global` resource: a Peer whose principal is not the owner may follow it only when its principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) binds a `global` role listing the action. For that caller, the baseline, `upsert` frames, and `order` ids keep only the Workspaces `ctx.remoteAccess.allows()` admits as `workspace` resources, and only the Session ids it admits as `session` resources, within each Workspace and in the archived and pinned sets, so `workspace` and `session` roles list the same action. The stream tracks the Workspaces it delivered. When one is no longer admitted at its next change or in a new order, the stream sends a `replace` increment carrying the complete filtered state instead of a `remove`, because a removed id is never resurrected on the Client; `replace` is an ordinary increment that the Client applies like an opening baseline, not the start of a new generation, so it neither reports an error nor reconnects; once access returns, the Workspace reappears through an ordinary `upsert`. A `remove` frame reports a deletion and reaches only a caller that received that Workspace. Owner frames are unfiltered. + The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived and pinned Session identities, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. Pin snapshots change only when their identities or order change. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner. `WorkspaceController.archiveSession(sessionId, { stopActivity })` throws `WorkspaceArchiveError` with the Host's `rpcError`, so a surface can tell the running-work refusal from a missing Session or a carrier fault and offer to stop the work. diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md index dc240950cc..4772d0c9b1 100644 --- a/packages/api/workspace-controller/README.zh.md +++ b/packages/api/workspace-controller/README.zh.md @@ -24,6 +24,8 @@ kind: "package-reference" Host 控制器会串行执行正确性取决于当前注册表状态的变更,并为预期失败抛出带有稳定错误码的 `RemoteError`。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order`、`archived` 和 `pinned` 增量。归档与置顶集合都是会话 id 数组,置顶数组把最近置顶的 id 放在前面。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。不带 `stopActivity` 的 `archiveSession` 会以 `workspace/session-active` 拒绝仍有工作在跑的会话,其 details 按族(`turn`、`subagent`、`job`、`schedule`)列出这些工作及各项的 id 与名称;带 `stopActivity: true` 时注册表的提供方先停止这些工作,归档集合持久化后即返回响应,停止在后台收敛。 +控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `workspaceId` 登记 `workspace` 资源种类。`workspace/follow` 不读取资源,其调用访问 `global` 资源:principal 不是 owner 的 Peer 只有在其 principal kind 于 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 绑定了列出该 action 的 `global` role 时才能订阅它。对这类调用方,baseline、`upsert` 帧与 `order` 中的 id 只保留 `ctx.remoteAccess.allows()` 作为 `workspace` 资源允许的 Workspace,以及它作为 `session` 资源允许的会话 id(各 Workspace 内的会话以及归档与置顶集合),因此 `workspace` role 与 `session` role 也列出同一 action。流记录已下发的 Workspace。某个 Workspace 在下一次变化或新顺序中已不被允许时,流发送一个携带完整过滤后状态的 `replace` 增量帧而不是 `remove`,因为 Client 不会复活已移除的 id;`replace` 是普通增量帧,Client 像开场 baseline 一样整体替换状态,但它不开启新的一代,因此既不报告错误也不重连;访问恢复后,该 Workspace 经普通 `upsert` 重新出现。`remove` 帧表示删除,只发给收到过该 Workspace 的调用方。owner 的帧不过滤。 + Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、归档与置顶会话身份、一元变更回显,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。置顶快照仅在会话身份或顺序变化时更新。该包公开与框架无关的快照和订阅,把导航策略与 React 钩子留给 UI owner。`WorkspaceController.archiveSession(sessionId, { stopActivity })` 抛出携带 Host `rpcError` 的 `WorkspaceArchiveError`,界面因此能区分"仍有工作在跑"的拒绝与会话缺失或载体故障,并提议停止这些工作。 diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index ac9d45a3e1..a6105a5c1c 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -89,6 +89,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", diff --git a/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts index c3182102a7..3bbf4670a0 100644 --- a/packages/api/workspace-controller/src/client/index.ts +++ b/packages/api/workspace-controller/src/client/index.ts @@ -98,6 +98,9 @@ export function createWorkspaceStateStream( function acceptIncrement(accept: WorkspaceFollowSink, frame: WorkspaceFollowIncrement): void { switch (frame.type) { + case 'replace': + accept.replaceBaseline(frame.value) + return case 'upsert': accept.upsertView(frame.workspace) return diff --git a/packages/api/workspace-controller/src/feed.ts b/packages/api/workspace-controller/src/feed.ts index 709f17fa5d..be1cbdb21c 100644 --- a/packages/api/workspace-controller/src/feed.ts +++ b/packages/api/workspace-controller/src/feed.ts @@ -15,6 +15,9 @@ import type { WorkspaceView, } from './types.ts' +/** The frames the feed produces: every follow frame except `replace`, which only access filtering sends. */ +type WorkspaceFeedFrame = Exclude + /** * Project one authoritative Workspace entity into its Remote value. * @param workspace - authoritative registry entity. @@ -82,7 +85,7 @@ export class WorkspaceFeed { * @param signal - generation cancellation. * @returns baseline followed by ordered Workspace increments. */ - async *follow(signal: AbortSignal): AsyncIterable { + async *follow(signal: AbortSignal): AsyncIterable { signal.throwIfAborted() const follower = new WorkspaceFollower() this.followers.add(follower) @@ -138,7 +141,7 @@ export class WorkspaceFeed { }) } - private publish(frame: Exclude): void { + private publish(frame: Exclude): void { for (const follower of this.followers) follower.push(frame) } } @@ -148,11 +151,11 @@ function sameStrings(left: readonly string[], right: readonly string[]): boolean } class WorkspaceFollower { - private readonly frames = new Deque() + private readonly frames = new Deque() private waiting: (() => void) | undefined private closed = false - push(frame: WorkspaceFollowFrame): void { + push(frame: WorkspaceFeedFrame): void { /* v8 ignore next -- closed followers are removed before later publication can reach them. */ if (this.closed) return this.frames.pushBack(frame) @@ -165,7 +168,7 @@ class WorkspaceFollower { this.waiting?.() } - async *read(signal: AbortSignal): AsyncIterable { + async *read(signal: AbortSignal): AsyncIterable { while (!this.closed && !signal.aborted) { const frame = this.frames.popFront() if (frame !== undefined) { diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts index 0b23517921..f275eb3d12 100644 --- a/packages/api/workspace-controller/src/index.ts +++ b/packages/api/workspace-controller/src/index.ts @@ -3,6 +3,8 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-api-remote-access' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import { WorkspaceCommands } from './commands.ts' import { DirectoryPickerController } from './directory-picker.ts' import { WorkspaceFeed, workspaceView } from './feed.ts' @@ -10,11 +12,13 @@ import { defaultWorkspaceDirectory, validateDocumentsDirectory } from './default import type { WorkspaceArchiveSessionRequest, WorkspaceArchiveValue, + WorkspaceBaseline, WorkspaceCreateRequest, WorkspaceCreateValue, WorkspaceDeleteRequest, WorkspaceDeleteValue, WorkspaceFollowFrame, + WorkspaceId, WorkspaceInsertBeforeRequest, WorkspaceInsertSessionBeforeRequest, WorkspaceOrderValue, @@ -49,7 +53,7 @@ declare module '@deepseek-ai/cordis' { /** Host service backing the generated `ctx.remote.workspace` namespace. */ export class WorkspaceController extends TypertRemoteService { - static inject = ['typert', 'workspaceRegistry'] + static inject = ['remoteAccess', 'typert', 'workspaceRegistry'] static Config: z = z.object({ documentsDirectory: z.string(), @@ -66,6 +70,7 @@ export class WorkspaceController extends TypertRemoteService { */ constructor(ctx: Context, config: Config = {}) { super(ctx, 'workspaceController', { namespace: 'workspace' }) + ctx.typert.resources.register('workspace', { field: 'workspaceId' }) this.config = WorkspaceController.Config(config) if (this.config.documentsDirectory !== undefined) validateDocumentsDirectory(this.config.documentsDirectory) this.commands = new WorkspaceCommands(ctx) @@ -187,14 +192,77 @@ export class WorkspaceController extends TypertRemoteService { } /** - * Stream a complete Workspace baseline followed by ordered increments. + * Stream a complete Workspace baseline followed by ordered increments. A + * non-owner receives only the Workspaces and Sessions `allows()` admits. + * When a delivered Workspace is no longer admitted at its next change or + * the next order change, the stream sends a `replace` frame with the + * complete filtered state instead of a `remove`, because a Client never + * resurrects a removed Workspace; a `remove` frame reports a deletion and + * reaches only a Client that received that Workspace. * @param signal - generation cancellation. * @returns baseline followed by ordered Workspace increments. */ @Remote({ mode: 'stream' }) - follow(signal: AbortSignal): AsyncIterable { - return this.feed.follow(signal) + async *follow(signal: AbortSignal): AsyncIterable { + const access = this.ctx.remoteAccess + const visible = (workspaceId: WorkspaceId): boolean => access.allows({ kind: 'workspace', id: workspaceId }) + const sessions = (sessionIds: readonly SessionId[]): SessionId[] => + sessionIds.filter(sessionId => access.allows({ kind: 'session', id: sessionId })) + /** Workspaces this generation has delivered and not yet removed. */ + const delivered = new Set() + /** The admitted part of `value`; it becomes the delivered set. */ + const filtered = (value: WorkspaceBaseline): WorkspaceBaseline => { + const items = value.items.filter(item => visible(item.workspaceId)) + delivered.clear() + for (const item of items) delivered.add(item.workspaceId) + return { + items: items.map(item => ({ ...item, sessionIds: sessions(item.sessionIds) })), + archivedSessionIds: sessions(value.archivedSessionIds), + pinnedSessionIds: sessions(value.pinnedSessionIds), + } + } + for await (const frame of this.feed.follow(signal)) { + switch (frame.type) { + case 'baseline': + yield { type: 'baseline', value: filtered(frame.value) } + break + case 'upsert': { + const workspaceId = frame.workspace.workspaceId + if (visible(workspaceId)) { + delivered.add(workspaceId) + yield { type: 'upsert', workspace: { ...frame.workspace, sessionIds: sessions(frame.workspace.sessionIds) } } + } else if (delivered.has(workspaceId)) { + yield { type: 'replace', value: filtered(this.feed.baseline()) } + } + break + } + case 'remove': + if (delivered.delete(frame.workspaceId)) yield frame + break + case 'order': + if (frame.workspaceIds.some(workspaceId => delivered.has(workspaceId) && !visible(workspaceId))) { + yield { type: 'replace', value: filtered(this.feed.baseline()) } + } else { + yield { type: 'order', workspaceIds: frame.workspaceIds.filter(visible) } + } + break + case 'archived': + yield { type: 'archived', archivedSessionIds: sessions(frame.archivedSessionIds) } + break + case 'pinned': + yield { type: 'pinned', pinnedSessionIds: sessions(frame.pinnedSessionIds) } + break + /* v8 ignore next -- closed follow-frame union. */ + default: + assertNever(frame) + } + } } } +/* v8 ignore next 3 -- closed-union backstop. */ +function assertNever(value: never): never { + throw new Error(`unreachable Workspace follow frame: ${JSON.stringify(value)}`) +} + export default WorkspaceController diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts index d5b98746be..cdf9adf073 100644 --- a/packages/api/workspace-controller/src/types.ts +++ b/packages/api/workspace-controller/src/types.ts @@ -157,8 +157,13 @@ export interface WorkspaceBaseline { readonly pinnedSessionIds: readonly SessionId[] } -/** One ordered Workspace change after a generation's baseline. */ +/** + * One ordered Workspace change after a generation's baseline. `replace` + * carries the complete state that supersedes everything delivered before it + * in the generation; it is a change, not a new generation's opening frame. + */ export type WorkspaceFollowIncrement = + | { readonly type: 'replace'; readonly value: WorkspaceBaseline } | { readonly type: 'upsert'; readonly workspace: WorkspaceView } | { readonly type: 'remove'; readonly workspaceId: WorkspaceId } | { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] } diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index b74ddfb394..b1d6ca2a9b 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -183,6 +183,38 @@ describe('Workspace state stream', () => { expect(streamStates(mock)).toEqual(['cancelled']) }) + it('applies a replace increment in place, so a Workspace hidden and shown again never errors or reconnects', async ({ mock, start }) => { + const { remote } = await gatewayClient(mock, start) + mock.stream(FOLLOW, openStream([baseline('one', 'two')])) + const model = new ClientWorkspaceModel(remote.workspace) + const carrierFailed = vi.fn(() => { model.handleCarrierFailure() }) + const failed = vi.fn((error: unknown) => { model.handleStreamFailure(error) }) + const stream = createWorkspaceStateStream(remote, { accept: model, carrierFailed, failed }) + const states = new Set() + model.subscribe(() => { states.add(model.getSnapshot().state) }) + const ids = (): string[] => model.getSnapshot().items.map(item => item.workspaceId).sort() + const replace = (...workspaceIds: string[]): WorkspaceFollowFrame => ({ type: 'replace', value: baseline(...workspaceIds).value }) + + stream.start() + await vi.waitFor(() => { expect(ids()).toEqual(['one', 'two']) }) + // Access to `two` is revoked, restored through a replace, revoked again, and restored through an upsert. + mock.streams.push(FOLLOW, replace('one')) + await vi.waitFor(() => { expect(ids()).toEqual(['one']) }) + mock.streams.push(FOLLOW, replace('one', 'two')) + await vi.waitFor(() => { expect(ids()).toEqual(['one', 'two']) }) + mock.streams.push(FOLLOW, replace('one')) + await vi.waitFor(() => { expect(ids()).toEqual(['one']) }) + mock.streams.push(FOLLOW, { type: 'upsert', workspace: workspace('two') }) + await vi.waitFor(() => { expect(ids()).toEqual(['one', 'two']) }) + + expect(states).not.toContain('error') + expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', error: null }) + expect(carrierFailed).not.toHaveBeenCalled() + expect(failed).not.toHaveBeenCalled() + expect(streamStates(mock)).toEqual(['open']) + await stream.dispose() + }) + it('retains the old state across carrier loss and applies the replacement baseline', async ({ mock, start }) => { const { remote } = await gatewayClient(mock, start) const carrier = new RemoteStreamCarrierError('socket lost') diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts index 35158490f2..f284f35155 100644 --- a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -6,9 +6,10 @@ import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import Storage from '@deepseek-ai/dsh-storage' import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' -import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, type PeerId, type Principal, type RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' import WorkspaceRegistry from '@deepseek-ai/dsh-workspace' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import RemoteAccessService, { type RemoteAccessCondition, type RemoteAccessRole } from '@deepseek-ai/dsh-api-remote-access' import WorkspaceController from '../src/index.ts' import { DEFAULT_WORKSPACE_DIRECTORY } from '../src/default-workspace.ts' import { WorkspaceFeed } from '../src/feed.ts' @@ -67,11 +68,39 @@ async function harness(options: { systemDocuments?: boolean } = {}) { ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose }, + resources: { register: vi.fn(() => dispose) }, } as never) + new RemoteAccessService(ctx) const controller = new WorkspaceController(ctx, options.systemDocuments === true ? {} : { documentsDirectory: root }) return { controller, ctx, root, storageDomain } } +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } +} + +/** Opens the Workspace follow stream; each Workspace and Session in it still needs its own role. */ +const FOLLOWER: RemoteAccessRole<'global'> = { name: 'test:workspace-follower', resource: 'global', actions: ['workspace/follow'] } +/** Shows one Workspace in the follow stream. */ +const WORKSPACE_VIEWER: RemoteAccessRole<'workspace'> = { name: 'test:workspace-viewer', resource: 'workspace', actions: ['workspace/follow'] } +/** Shows one Session id in the follow stream. */ +const SESSION_VIEWER: RemoteAccessRole<'session'> = { name: 'test:session-viewer', resource: 'session', actions: ['workspace/follow'] } + +/** The Context a Gateway dispatch of `workspace/follow` binds for `principal`. */ +function followerContext(ctx: Context, principal: Principal): Context { + const invocation: RemoteInvocation = { + request: { namespace: 'workspace', method: 'follow', args: {} }, + service: 'workspaceController', + peer: { id: 'workspace-access-peer' as PeerId, ctx, principal, signal: new AbortController().signal, dispose: async () => {} }, + signal: new AbortController().signal, + uplink: (): AsyncIterable => ({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }), + } + return ctx.extend({ invocation }) +} + function stageDir(root: string, name: string): string { const path = join(root, name) mkdirSync(path, { recursive: true }) @@ -416,6 +445,110 @@ describe('WorkspaceController follow', () => { await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) }) + it('follows only the Workspaces and Sessions a non-owner may follow', async () => { + const { controller, ctx, root } = await harness() + // oxlint-disable-next-line typescript/unbound-method -- the assertion inspects the spy and never calls it. + expect(ctx.typert.resources.register).toHaveBeenCalledWith('workspace', { field: 'workspaceId' }) + const shown = await controller.create({ path: stageDir(root, 'shown') }) + const hiddenWorkspace = await controller.create({ path: stageDir(root, 'hidden') }) + const shownId = shown.workspace.workspaceId + const hiddenId = hiddenWorkspace.workspace.workspaceId + const shared = ctx.sessions.create(SessionId('shared'), { meta: { cwd: shown.workspace.path } }) + const secret = ctx.sessions.create(SessionId('secret'), { meta: { cwd: shown.workspace.path } }) + const workspace = ctx.workspaceRegistry.get(shownId) + if (workspace === undefined) throw new Error('fixture Workspace disappeared') + await workspace.attachSession(shared.id) + await workspace.attachSession(secret.id) + await controller.pinSession({ sessionId: shared.id }) + await controller.pinSession({ sessionId: secret.id }) + ctx.remoteAccess.registerPolicy('test-guest', { + bindings: () => [ + { role: FOLLOWER, resource: { kind: 'global' } }, + { role: WORKSPACE_VIEWER, resource: { kind: 'workspace', id: shownId } }, + { role: SESSION_VIEWER, resource: { kind: 'session', id: shared.id } }, + ], + }) + const guest: Principal = { kind: 'test-guest', guest: 'workspace-controller' } + const abort = new AbortController() + const iterator = followerContext(ctx, guest).workspaceController.follow(abort.signal)[Symbol.asyncIterator]() + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'baseline', + value: { + items: [expect.objectContaining({ workspaceId: shownId, sessionIds: [shared.id] })], + archivedSessionIds: [], + pinnedSessionIds: [shared.id], + }, + }) + + await controller.rename({ workspaceId: hiddenId, title: 'still hidden' }) + await controller.rename({ workspaceId: shownId, title: 'renamed' }) + await expect(nextFrame(iterator)).resolves.toMatchObject({ + type: 'upsert', workspace: { workspaceId: shownId, title: 'renamed', sessionIds: [shared.id] }, + }) + await controller.unpinSession({ sessionId: secret.id }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'pinned', pinnedSessionIds: [shared.id] }) + await controller.unpinSession({ sessionId: shared.id }) + await controller.archiveSession({ sessionId: secret.id }) + await controller.archiveSession({ sessionId: shared.id }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'pinned', pinnedSessionIds: [] }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'archived', archivedSessionIds: [] }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'archived', archivedSessionIds: [shared.id] }) + await controller.delete({ workspaceId: hiddenId }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'order', workspaceIds: [shownId] }) + await controller.delete({ workspaceId: shownId }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'order', workspaceIds: [] }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'remove', workspaceId: shownId }) + + abort.abort() + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('sends a non-owner a replace increment when a delivered Workspace loses visibility, so it can reappear', async () => { + const { controller, ctx, root } = await harness() + const first = (await controller.create({ path: stageDir(root, 'first') })).workspace.workspaceId + const second = (await controller.create({ path: stageDir(root, 'second') })).workspace.workspaceId + // One Remote call reads its bindings once, so visibility changes during the stream come from the condition. + const allowedWorkspaces = new Set([first, second]) + const stillShared: RemoteAccessCondition<'workspace'> = ({ resource }) => + allowedWorkspaces.has(resource.id) ? { kind: 'allow' } : { kind: 'deny', reason: 'test:unshared' } + ctx.remoteAccess.registerPolicy('test-guest', { + bindings: () => [ + { role: FOLLOWER, resource: { kind: 'global' } }, + ...[first, second].map(id => ({ role: WORKSPACE_VIEWER, resource: { kind: 'workspace', id } as const, condition: stillShared })), + ], + }) + const abort = new AbortController() + const iterator = followerContext(ctx, { kind: 'test-guest', guest: 'withdrawn' }).workspaceController.follow(abort.signal)[Symbol.asyncIterator]() + const ids = (frame: WorkspaceFollowFrame): string[] => + frame.type === 'baseline' || frame.type === 'replace' ? frame.value.items.map(item => item.workspaceId).sort() : [] + const opening = await nextFrame(iterator) + expect(opening.type).toBe('baseline') + expect(ids(opening)).toEqual([first, second].sort()) + + // Revoked: the next change replaces the state instead of sending a `remove` the Client would never undo. + allowedWorkspaces.delete(first) + await controller.rename({ workspaceId: first, title: 'revoked' }) + const withoutFirst = await nextFrame(iterator) + expect(withoutFirst.type).toBe('replace') + expect(ids(withoutFirst)).toEqual([second]) + allowedWorkspaces.delete(second) + const third = (await controller.create({ path: stageDir(root, 'third') })).workspace.workspaceId + const empty = await nextFrame(iterator) + expect(empty.type).toBe('replace') + expect(ids(empty)).toEqual([]) + // A deletion reaches only a Client that received the Workspace. + await controller.delete({ workspaceId: third }) + await expect(nextFrame(iterator)).resolves.toEqual({ type: 'order', workspaceIds: [] }) + + // Restored: the Workspace reappears through an ordinary upsert. + allowedWorkspaces.add(first) + await controller.rename({ workspaceId: first, title: 'restored' }) + await expect(nextFrame(iterator)).resolves.toMatchObject({ type: 'upsert', workspace: { workspaceId: first, title: 'restored' } }) + + abort.abort() + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + }) + it('ignores unrelated domain writes and closes active followers on disposal', async () => { const { controller, ctx, root } = await harness() const abort = new AbortController() diff --git a/packages/api/workspace-controller/tsconfig.host.json b/packages/api/workspace-controller/tsconfig.host.json index ff1ca31080..e9d5104e98 100644 --- a/packages/api/workspace-controller/tsconfig.host.json +++ b/packages/api/workspace-controller/tsconfig.host.json @@ -21,6 +21,7 @@ { "path": "../../host/directory-picker" }, { "path": "../../storage/storage-domain" }, { "path": "../../typert/protocol" }, + { "path": "../remote-access/tsconfig.host.json" }, { "path": "../../util/deque" }, { "path": "../../util/native-command" }, { "path": "../../workspace/workspace" } diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index f77a965ad0..5aebc36735 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -64,6 +64,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-resources": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", diff --git a/packages/api/workspace-files/src/index.ts b/packages/api/workspace-files/src/index.ts index 1c1c652f1e..d6ecbc68c1 100644 --- a/packages/api/workspace-files/src/index.ts +++ b/packages/api/workspace-files/src/index.ts @@ -203,6 +203,7 @@ export class WorkspaceFiles extends TypertRemoteService { scope.typert.lookups.register('workspaceFileScope', { parameter: 'workspaceFileScope', wire: 'workspaceFileScopeId', + resource: 'session', hostTypeSymbol: '@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', resolve: async (sessionId) => { diff --git a/packages/api/workspace-files/tests/binary-rpc.spec.ts b/packages/api/workspace-files/tests/binary-rpc.spec.ts index 35058d58a1..dc42bfbe02 100644 --- a/packages/api/workspace-files/tests/binary-rpc.spec.ts +++ b/packages/api/workspace-files/tests/binary-rpc.spec.ts @@ -5,6 +5,7 @@ import { beforeEach, afterEach, expect, it, vi } from 'vitest' import SessionStore from '@deepseek-ai/dsh-session' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import TypertGateway from '@deepseek-ai/dsh-api-gateway' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import { HostConnectionService } from '@deepseek-ai/dsh-client-connection/src/rpc-host.ts' import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' import { createWebConnectionRpc } from '@deepseek-ai/dsh-client-connection/src/client/rpc.ts' @@ -19,12 +20,14 @@ beforeEach(async () => { const ctx = harness.ctx await ctx.plugin(SessionStore) await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGateway) await ctx.plugin((scope) => { new HostConnectionService(scope, [], {} as BrowserAuth) }) await ctx.plugin(WorkspaceFiles, { maxBytes: 8, maxFileBytes: 4, maxLines: 100, maxEntries: 100 }) ctx.sessions.create(harness.scope.sessionId, { meta: { cwd: harness.workspace, origin: 'subagent' } }) - const handler = (ctx.get('connection') as HostConnectionService).createSharedFetchHandler('/api') - rpc = createWebConnectionRpc(async (path, init) => handler.fetch(new Request(new URL(path, 'http://host'), init))) + const connection = ctx.get('connection') as HostConnectionService + const handler = connection.createSharedFetchHandler('/api') + rpc = createWebConnectionRpc(async (path, init) => handler.fetch(new Request(new URL(path, 'http://host'), init), connection.browserPeer)) }) afterEach(async () => { diff --git a/packages/api/workspace-files/tests/scope.spec.ts b/packages/api/workspace-files/tests/scope.spec.ts index 2d9cd097b2..d954063bfd 100644 --- a/packages/api/workspace-files/tests/scope.spec.ts +++ b/packages/api/workspace-files/tests/scope.spec.ts @@ -52,6 +52,8 @@ describe('Workspace Files Session scope lookup', () => { expect(lookup).toMatchObject({ parameter: 'workspaceFileScope', wire: 'workspaceFileScopeId', + // A method taking a scope reads the scope's Session as its access-control resource. + resource: 'session', hostTypeSymbol: '@deepseek-ai/dsh-api-workspace-files#WorkspaceFileScope', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', }) diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index ebfb72d6e2..6734d2872b 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -71,7 +71,7 @@ const protectedModules = new Set([ '@deepseek-ai/dsh-host-webserver', '@deepseek-ai/dsh-client-modules', '@deepseek-ai/dsh-client-ui-settings-plugin-inventory', '@deepseek-ai/dsh-client-ui-plugin-manager', '@deepseek-ai/dsh-host-plugin-inventory', '@deepseek-ai/dsh-typert-registry', - '@deepseek-ai/dsh-api-remotes', + '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-api-remote-access', '@deepseek-ai/cordis-plugin-timer', '@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-host-frontend-static', '@deepseek-ai/dsh-tools', '@deepseek-ai/dsh-hmr', diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 0600956068..844d376b26 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -735,6 +735,7 @@ it.each([ '@deepseek-ai/dsh-host-plugin-inventory', '@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-api-remotes', + '@deepseek-ai/dsh-api-remote-access', ])('protects the management dependency %s and its containing bundle', async (name) => { const { ctx, manager, bundle, profile, dir } = await fixture('startup') bundle('extra', [{ id: 'dependency', name, disabled: true }]) diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 21e24f3865..ee2f8f280b 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -52,6 +52,11 @@ - id: typert-gateway name: '@deepseek-ai/dsh-api-gateway' + # Method-level access control for non-owner Peers; the Gateway requires it, + # and its Client half publishes the standard access Hooks. + - id: remote-access + name: '@deepseek-ai/dsh-api-remote-access' + - id: session-title name: '@deepseek-ai/dsh-session-title' config: diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index fa80fcfb55..8ec635fde1 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-goal": "workspace:*", "@deepseek-ai/dsh-goal-round-driver": "workspace:*", "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-llm-deepseek-account": "workspace:*", diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 9efedba955..851c94ecbc 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -18,8 +18,8 @@ en: 13b54969ff1d00a4 zh: 4d2ffff0445472ed /deepseek-ai-dsh-client-connection/browser-authentication-and-request-trust: - en: 44956be3cc11908a - zh: d518f765d58f3558 + en: dbd48251ac03d968 + zh: 6323c09d75e56ecc /deepseek-ai-dsh-client-connection/connection-generation: en: 116f94e44c0d87e7 zh: 59ef48984154d858 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index d6cb58d42c..a5b4d4cc85 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -44,7 +44,7 @@ HTTPS cookie names and signed audiences include the `https:` scheme, so HTTP coo Before authentication, every request passes `src/api-request-trust.ts`. Its `Host` must be loopback, equal the listener's bind IP literal on any port, or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries. Host and configured entries use the listener's scheme for WHATWG default-port normalization, not a forwarded header or `Origin`. An attached HTTP(S) `Origin` must name the same authority, with Host parsed under that Origin's scheme to support HTTPS proxies over HTTP upstreams; `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. `allowsRemoteAuthorities` reports whether `trustedHosts` permits a non-loopback authority; accepting the bind IP does not change that list. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` is unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md). -Every admitted request speaks for one Peer, the operator. `ctx.connection.operator` is that `PeerScope`: its `ctx` is a Cordis scope that owns connection-lifetime registrations and is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or the operator; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. `OperatorPeer` is exported so a composition without Connection, such as the Gateway's in-process carrier, owns an operator scope with the same contract. +Every request speaks for one Peer, a `PeerScope` whose `principal` names the caller and whose `signal` aborts when its scope ends. `ctx.connection.browserPeer` is the Peer every authenticated browser connection shares; its principal is `{ kind: 'owner' }` and its scope is disposed with the Connection. `ctx.connection.admit(request)` runs the trust and authentication checks and answers with the rejection status or `browserPeer`; the `/api` route and the Gateway's WebSocket upgrade admit through it, and every RPC handler receives the Peer of its call. `ctx.connection.createPeer({ principal })` creates an independent in-process Peer, disposed with the Context that read `ctx.connection` or earlier by its own `dispose()`; no route, header, query, or cookie can create one or set its principal. The exported `Peer` class implements both, and a composition without Connection constructs it directly as `new Peer(owner, { principal })`. `fetch(request, peer)` and the HTTP bridge take the Peer explicitly; an exact route answers 403 to a Peer whose principal is not the owner. Method-level access for other principals belongs to [`dsh-api-remote-access`](../../api/remote-access/README.md). Authenticated shared HTTP requests pass through the `connection/request` waterfall before body transfer. A listener may refuse new requests or await `next()` through response completion; removing its owning fiber removes admission behavior. Desktop uses this hook to lock new API work during an approved installation without canceling already-admitted work. Client disconnection aborts the handler signal; the bridge stops socket writes and drains any remaining response chunks. WebSocket stream ownership remains with API Gateway. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 6ec954f84d..cb23e015bd 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -44,7 +44,7 @@ HTTPS cookie 名称与签名 audience 包含 `https:` scheme,因此即使 host 认证之前,每个请求都经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback、等于监听器的绑定 IP 字面量(端口不限),或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口。Host 与配置条目按监听器的 scheme 做 WHATWG 默认端口归一化,不采信转发 header 或 `Origin`。附带的 HTTP(S) `Origin` 必须指向同一 authority;此时按 Origin 的 scheme 解析 Host,以支持 HTTPS 代理转发到 HTTP 上游;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。`allowsRemoteAuthorities` 报告 `trustedHosts` 是否允许非 loopback authority;接受绑定 IP 不会改变该列表。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。 -每个被接纳的请求都代表同一个 Peer——操作者。`ctx.connection.operator` 就是这个 `PeerScope`:其 `ctx` 是拥有连接期注册的 Cordis scope,随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或操作者作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。`OperatorPeer` 对外导出,供没有 Connection 的组合(例如 Gateway 的进程内载体)以同一约定拥有一个操作者 scope。 +每个请求都代表一个 Peer:一个 `PeerScope`,其 `principal` 指明调用方,其 `signal` 在 scope 结束时中止。`ctx.connection.browserPeer` 是所有通过认证的浏览器连接共享的 Peer;其 principal 为 `{ kind: 'owner' }`,scope 随 Connection 一起释放。`ctx.connection.admit(request)` 执行信任与认证检查,以拒绝状态或 `browserPeer` 作答;`/api` 路由与 Gateway 的 WebSocket 升级都经它接纳,每个 RPC 处理器都收到本次调用的 Peer。`ctx.connection.createPeer({ principal })` 创建一个独立的进程内 Peer,随读取 `ctx.connection` 的 Context 释放,或更早经其自身的 `dispose()` 释放;任何路由、header、query 或 cookie 都无法创建它或设置其 principal。导出的 `Peer` 类实现这两者,没有 Connection 的组合直接以 `new Peer(owner, { principal })` 构造。`fetch(request, peer)` 与 HTTP bridge 显式接收 Peer;对 principal 不是 owner 的 Peer,exact route 返回 403。其他 principal 的方法级访问归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md)。 通过认证的共享 HTTP 请求在传输请求体之前经过 `connection/request` waterfall。监听器可以拒绝新请求,或等待 `next()` 直到响应完成;释放所属 fiber 会移除准入行为。Desktop 使用此扩展点,在已批准的安装期间锁住新的 API 工作,而不取消已接纳的工作。客户端断开会中止处理函数的信号;桥接器停止写入 socket,并排空剩余响应块。WebSocket 流仍由 API Gateway 负责。 diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index e9d80a64f4..910cea58c5 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -96,7 +96,7 @@ export interface ClientTransportHooks { /** * The transport owner declares the page owns the Host outright: the Host * runs inside a worker this page spawned, so no other party can reach it and - * the loopback stand-in for "the operator's own machine" is vacuous. + * the loopback stand-in for "the owner's own machine" is vacuous. * `ctx.connection.isLoopback` then reports the privileged surface reachable * regardless of the page authority. Only a shell that assembles its own * transport can set this; served pages never carry the global at all. diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index 713f53dcea..2343b1c38b 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -5,6 +5,7 @@ import type { IncomingMessage } from 'node:http' import { Readable } from 'node:stream' +import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' import type { ConnectionFetchHandler } from './rpc.ts' /** Default carrier cap for all HTTP RPC bodies: sized for the default @@ -30,12 +31,14 @@ interface BridgeServerResponse { * @param req - incoming node:http request. * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. + * @param peer - Peer the carrier admitted the request as. * @param maxRequestBodyBytes - maximum bytes buffered for a buffered route. */ export async function bridge( req: IncomingMessage, res: BridgeServerResponse, apiHandler: ConnectionFetchHandler, + peer: PeerScope, maxRequestBodyBytes = DEFAULT_MAX_REQUEST_BODY_BYTES, ): Promise { const abort = new AbortController() @@ -91,7 +94,7 @@ export async function bridge( duplex: 'half', } as RequestInit & { duplex: 'half' }) } - const response = await apiHandler.fetch(request) + const response = await apiHandler.fetch(request, peer) const requestUnread = bodyMode === 'streaming' && !req.readableEnded const responseHeaders = Object.fromEntries(response.headers.entries()) res.writeHead(response.status, requestUnread ? { ...responseHeaders, connection: 'close' } : responseHeaders) diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 34e15f90a6..ce58da1606 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -36,9 +36,10 @@ export type { RpcMessage, ServerResponse, } from './rpc.ts' -export type { PeerId, PeerScope, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' +export type { PeerId, PeerScope, Principal, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' export { RpcId, transportError } from './rpc.ts' -export { OperatorPeer } from './operator-peer.ts' +export { Peer } from './peer.ts' +export type { PeerOptions } from './peer.ts' export { clientRequestSchema, rpcErrorSchema, @@ -150,7 +151,7 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise bridge(req, res, fetchHandler, maxRequestBodyBytes)) + await webCtx.waterfall('connection/request', req, res, () => bridge(req, res, fetchHandler, admission.peer, maxRequestBodyBytes)) }, } webCtx.effect(() => webCtx.webServer.register(route), 'client-connection: /api route') diff --git a/packages/client/connection/src/operator-peer.ts b/packages/client/connection/src/operator-peer.ts deleted file mode 100644 index 83325f78e7..0000000000 --- a/packages/client/connection/src/operator-peer.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * The operator Peer: the one party this Host answers to. Connection owns it - * for its own lifetime, admits every request as it, and hands it to each - * Remote call as `invocation.peer`. - * @module @deepseek-ai/dsh-client-connection/src/operator-peer - */ - -import { randomUUID } from 'node:crypto' -import type { Context } from '@deepseek-ai/cordis' -import { createScope, type Scope } from '@deepseek-ai/dsh-scope' -import type { PeerId, PeerScope } from '@deepseek-ai/dsh-typert-protocol' - -/** - * The operator's scope. The instance is its own scope key, so `scopeOf(peer.ctx)` - * returns it and events dispatched with `scopeTarget(subject, peer)` reach - * listeners registered through `peer.ctx` and nobody else. - */ -export class OperatorPeer implements PeerScope { - readonly id: PeerId = randomUUID() as PeerId - readonly ctx: Context - private readonly scope: Scope - - /** @param owner - Connection plugin context the scope fiber hangs under. */ - constructor(owner: Context) { - this.scope = createScope(owner, this) - this.ctx = this.scope.ctx - } - - /** Tear down every connection-lifetime registration; racing calls share one completion. */ - dispose(): Promise { - return this.scope.dispose() - } -} diff --git a/packages/client/connection/src/peer.ts b/packages/client/connection/src/peer.ts new file mode 100644 index 0000000000..991ac521ba --- /dev/null +++ b/packages/client/connection/src/peer.ts @@ -0,0 +1,49 @@ +/** + * The Peer: one party admitted to this Host. Its creator fixes the principal + * it speaks for and owns its lifetime; Gateway hands it to each Remote call as + * `invocation.peer`. + * @module @deepseek-ai/dsh-client-connection/src/peer + */ + +import { randomUUID } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import { createScope, type Scope } from '@deepseek-ai/dsh-scope' +import type { PeerId, PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol' + +/** Construction options for one {@link Peer}. */ +export interface PeerOptions { + /** Caller identity every call through this Peer speaks for. */ + readonly principal: Principal +} + +/** + * One Peer's scope. The instance is its own scope key, so `scopeOf(peer.ctx)` + * returns it and events dispatched with `scopeTarget(subject, peer)` reach + * listeners registered through `peer.ctx` and nobody else. + */ +export class Peer implements PeerScope { + readonly id: PeerId = randomUUID() as PeerId + readonly ctx: Context + readonly principal: Principal + readonly signal: AbortSignal + private readonly scope: Scope + + /** + * @param owner - Context the scope fiber hangs under; disposing it disposes the Peer. + * @param options - the principal fixed for this Peer's lifetime. + */ + constructor(owner: Context, options: PeerOptions) { + this.principal = options.principal + this.scope = createScope(owner, this) + this.ctx = this.scope.ctx + const lifetime = new AbortController() + this.signal = lifetime.signal + // Runs however the scope ends: dispose() or the owner Context unloading. + this.ctx.effect(() => () => { lifetime.abort(new Error(`Peer ${this.id} was disposed`)) }, 'client-connection: Peer lifetime') + } + + /** Tear down every connection-lifetime registration; racing calls share one completion. */ + dispose(): Promise { + return this.scope.dispose() + } +} diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 44862c770a..33562d790f 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -2,7 +2,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' -import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' +import type { PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol' import { RpcId, type ClientRequest, @@ -13,7 +13,7 @@ import { bridge } from './http-bridge.ts' import { isRemoteAuthority, isTrustedApiRequest } from './api-request-trust.ts' import { API_PATH } from './api-path.ts' import type { BrowserAuth } from './browser-auth.ts' -import { OperatorPeer } from './operator-peer.ts' +import { Peer } from './peer.ts' import type { PeerAdmission, ConnectionIndexRequest, @@ -61,8 +61,8 @@ declare module '@deepseek-ai/cordis' { /** Host Connection service whose channel registrations belong to the caller fiber. */ export class HostConnectionService extends Service implements HostConnectionHandle { - /** The operator Peer every admitted request speaks for. */ - readonly operator: PeerScope + /** The Peer every authenticated browser connection shares. */ + readonly browserPeer: PeerScope private readonly interceptors = new Map() private readonly fetchRoutes = new Map() @@ -80,8 +80,8 @@ export class HostConnectionService extends Service implements HostConnectionHand private readonly browserAuth: BrowserAuth, ) { super(ctx, 'connection') - this.operator = new OperatorPeer(ctx) - ctx.effect(() => () => this.operator.dispose(), 'client-connection: operator Peer') + this.browserPeer = new Peer(ctx, { principal: { kind: 'owner' } }) + ctx.effect(() => () => this.browserPeer.dispose(), 'client-connection: browser Peer') this.allowsRemoteAuthorities = trustedHosts.some(isRemoteAuthority) } @@ -110,10 +110,15 @@ export class HostConnectionService extends Service implements HostConnectionHand return this.browserAuth.isAuthenticated(request, carrier?.protocol === 'https:') ? undefined : 401 } - /** A request that passes the fence and authentication speaks for the operator. */ + /** A request that passes the fence and authentication speaks for the shared browser Peer. */ admit(request: ConnectionTrustRequest): PeerAdmission { const rejection = this.requestRejection(request) - return rejection === undefined ? { peer: this.operator } : { rejection } + return rejection === undefined ? { peer: this.browserPeer } : { rejection } + } + + /** Create one in-process Peer owned by the Context that read this service. */ + createPeer(options: { readonly principal: Principal }): PeerScope { + return new Peer(this.ctx, options) } /** @@ -141,16 +146,21 @@ export class HostConnectionService extends Service implements HostConnectionHand const route = this.fetchRoutes.get(url.pathname) return route?.methods.has(method) === true ? route.requestBody : 'buffered' }, - fetch: (request) => { + fetch: (request, peer) => { const pathname = new URL(request.url).pathname const route = this.fetchRoutes.get(pathname) - if (route?.methods.has(request.method) === true) return route.fetch(request) + if (route?.methods.has(request.method) === true) { + // Exact routes bypass Gateway access checks, so only the owner reaches them. + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (peer.principal.kind !== 'owner') return Promise.resolve(new Response('forbidden', { status: 403 })) + return route.fetch(request) + } const endpoint = endpointFromPath(channel, pathname) const interceptor = this.interceptors.get(channel) if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) { return Promise.resolve(new Response('not found', { status: 404 })) } - return interceptor.fetchHandler.fetch(request) + return interceptor.fetchHandler.fetch(request, peer) }, } } @@ -180,7 +190,7 @@ export class HostConnectionService extends Service implements HostConnectionHand handler: ConnectionRpcHandler, ): () => Promise { assertChannel(channel) - const fetchHandler = rpcFetchHandler(channel, handler, this.operator) + const fetchHandler = rpcFetchHandler(channel, handler) const route: WebRoute = { kind: 'prefix', path: channel, @@ -191,7 +201,7 @@ export class HostConnectionService extends Service implements HostConnectionHand res.end(admission.rejection === 401 ? 'unauthorized' : 'forbidden') return } - await bridge(req, res, fetchHandler) + await bridge(req, res, fetchHandler, admission.peer) }, } return owner.effect( @@ -211,7 +221,7 @@ export class HostConnectionService extends Service implements HostConnectionHand } const interceptor: ConnectionRpcInterceptor = { matches, - fetchHandler: rpcFetchHandler(channel, handler, this.operator), + fetchHandler: rpcFetchHandler(channel, handler), } return owner.effect(() => { if (this.interceptors.has(channel)) { @@ -228,11 +238,10 @@ export class HostConnectionService extends Service implements HostConnectionHand function rpcFetchHandler( channel: string, handler: ConnectionRpcHandler, - peer: PeerScope, ): ConnectionFetchHandler { return { requestBodyMode: () => 'buffered', - async fetch(request: Request): Promise { + async fetch(request: Request, peer: PeerScope): Promise { const endpoint = endpointFromPath(channel, new URL(request.url).pathname) if (request.method !== 'POST' || endpoint === undefined) { return new Response('not found', { status: 404 }) diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index cf30df6204..2d1cfda387 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -1,7 +1,7 @@ /** Generic unary RPC contracts shared by the Host and Client Connection halves. */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' +import type { PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol' /** Correlation id minted by a caller and echoed by the Connection response. */ export type RpcId = Branded<'rpc-id'> @@ -115,14 +115,14 @@ export interface ConnectionIndexResponse { end(body?: string): unknown } -/** Outcome of admitting one request: the operator Peer it speaks for, or the status refusing it. */ +/** Outcome of admitting one request: the Peer it speaks for, or the status refusing it. */ export type PeerAdmission = | { readonly peer: PeerScope } | { readonly rejection: 401 | 403 } /** * Handler invoked after Connection has decoded the transport envelope. - * `peer` is the Peer the request was admitted as: the operator. + * `peer` is the Peer the carrier admitted or the in-process caller supplied. */ export type ConnectionRpcHandler = ( endpoint: string, @@ -148,7 +148,11 @@ export interface ConnectionFetchRoute { readonly methods: readonly ConnectionFetchMethod[] /** Buffered requests obey the configured JSON cap; streaming requests arrive with backpressure and no aggregate cap. */ readonly requestBody: ConnectionRequestBodyMode - /** Handle one request after the physical carrier has applied its trust and authentication policy. */ + /** + * Handle one request after the physical carrier has applied its trust and + * authentication policy. Only owner Peers reach an exact route; the shared + * handler answers 403 for every other principal. + */ readonly fetch: (request: Request) => Promise } @@ -195,8 +199,11 @@ export interface HostConnectionHandle { readonly rpc: HostConnectionRpc /** Exact Fetch routes for streaming or browser-native responses. */ readonly fetch: HostConnectionFetch - /** The operator Peer every admitted request speaks for; its scope lives as long as Connection. */ - readonly operator: PeerScope + /** + * The Peer every authenticated browser connection shares; its principal is + * the owner and its scope lives as long as Connection. + */ + readonly browserPeer: PeerScope /** * Whether this deployment accepts authorities beyond loopback: true only @@ -220,13 +227,23 @@ export interface HostConnectionHandle { requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection /** - * Admit one request: it passes {@link requestRejection} and speaks for the - * operator, or it is refused with that status. + * Admit one network request: it passes {@link requestRejection} and speaks + * for {@link browserPeer}, or it is refused with that status. * @param request - request headers from the HTTP or upgrade request. - * @returns the operator Peer, or the rejection status. + * @returns the admitted Peer, or the rejection status. */ admit(request: ConnectionTrustRequest): PeerAdmission + /** + * Create an independent Peer. In-process only: no route, header, query, or + * cookie reaches it. Disposed with the Context that read `ctx.connection`, + * or earlier by its own `dispose()`. + * @param options - caller identity the Peer speaks for. + * @param options.principal - principal fixed for the Peer's lifetime. + * @returns a new Peer distinct from every other Peer. + */ + createPeer(options: { readonly principal: Principal }): PeerScope + /** * Authenticate one frontend index request, owning a token redirect or 401. * The cookie this mints is `Secure` when the mounted Web carrier serves TLS, @@ -257,9 +274,10 @@ export interface ConnectionFetchHandler { /** * Dispatch one already-authenticated request. * @param request - Fetch request below the shared channel. - * @returns the registered response or a 404 response. + * @param peer - Peer to dispatch as; required, never defaulted. + * @returns the registered response, a 403 response for an exact route and a non-owner Peer, or a 404 response. */ - fetch(request: Request): Promise + fetch(request: Request, peer: PeerScope): Promise } /** Client caller for logical RPC channels carried by the current transport. */ diff --git a/packages/client/connection/tests/binary-rpc.host.spec.ts b/packages/client/connection/tests/binary-rpc.host.spec.ts index 05ec270545..4431199ec6 100644 --- a/packages/client/connection/tests/binary-rpc.host.spec.ts +++ b/packages/client/connection/tests/binary-rpc.host.spec.ts @@ -35,7 +35,7 @@ describe('Connection binary RPC', () => { attachments: [{ path: ['data'], bytes: data }], })) const handler = connection.createSharedFetchHandler('/api') - ctx.webServer.register({ kind: 'prefix', path: '/api', handler: (req, res) => bridge(req, res, handler) }) + ctx.webServer.register({ kind: 'prefix', path: '/api', handler: (req, res) => bridge(req, res, handler, connection.browserPeer) }) let transferred = 0 let encoding: string | undefined const rpc = createWebConnectionRpc((path, init) => new Promise((resolve, reject) => { @@ -111,7 +111,7 @@ describe('Connection binary RPC', () => { const shared = connection.createSharedFetchHandler('/api') const mediaTypes: (string | null)[] = [] const rpc = createWebConnectionRpc(async (url, init) => { - const response = await shared.fetch(new Request(new URL(url, 'http://host'), init)) + const response = await shared.fetch(new Request(new URL(url, 'http://host'), init), connection.browserPeer) mediaTypes.push(response.headers.get('content-type')) return response }) @@ -144,7 +144,7 @@ describe('Connection binary RPC', () => { attachments: [{ path: ['content'], bytes: content }], })) const handler = connection.createSharedFetchHandler('/api') - const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init))) + const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init), connection.browserPeer)) expect(await rpc.call('/api', 'fixture/read', {})).toEqual({ ok: true, value: { content: new Uint8Array([0, 128, 255]), metadata: { count: 1 } }, }) @@ -211,7 +211,7 @@ describe('Connection binary RPC', () => { const connection = ctx.get('connection') as HostConnectionService connection.rpc.intercept('/api', () => true, async () => next) const handler = connection.createSharedFetchHandler('/api') - const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init))) + const rpc = createWebConnectionRpc((url, init) => handler.fetch(new Request(new URL(url, 'http://host'), init), connection.browserPeer)) const expected = { files: [{ name: 'a.png', content: data }, { name: 'b.png', content: new Uint8Array([2, 3]) }, { name: 'empty' }], 'dots./[]': [new Uint8Array(), null, [data]], diff --git a/packages/client/connection/tests/fetch-routes.host.spec.ts b/packages/client/connection/tests/fetch-routes.host.spec.ts index ddb9b3b264..a6b6d159c6 100644 --- a/packages/client/connection/tests/fetch-routes.host.spec.ts +++ b/packages/client/connection/tests/fetch-routes.host.spec.ts @@ -1,4 +1,5 @@ import { Context } from '@deepseek-ai/cordis' +import type { PeerScope, Principal } from '@deepseek-ai/dsh-typert-protocol' import { describe, expect, it, vi } from 'vitest' import type { BrowserAuth } from '../src/browser-auth.ts' import { HostConnectionService } from '../src/rpc-host.ts' @@ -18,6 +19,15 @@ async function mounted(): Promise<{ } } +const GUEST: Principal = { kind: 'test-guest', guest: 'connection' } + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } +} + describe('Connection exact Fetch routes', () => { it('dispatches owned methods and returns 404 for unclaimed requests', async () => { const { connection, dispose: disposeFiber } = await mounted() @@ -33,7 +43,7 @@ describe('Connection exact Fetch routes', () => { const response = await shared.fetch(new Request( 'http://host/api/session.export?sessionId=session-1', - )) + ), connection.browserPeer) expect(shared.requestBodyMode({ method: 'POST', url: new URL('http://host/api/session.export'), })).toBe('streaming') @@ -43,12 +53,12 @@ describe('Connection exact Fetch routes', () => { expect(response.status).toBe(200) expect(await response.json()).toEqual({ query: 'session-1' }) expect(route).toHaveBeenCalledOnce() - const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' })) + const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' }), connection.browserPeer) expect(post.status).toBe(200) expect(route).toHaveBeenCalledTimes(2) await dispose() - const withdrawn = await shared.fetch(new Request('http://host/api/session.export')) + const withdrawn = await shared.fetch(new Request('http://host/api/session.export'), connection.browserPeer) expect(withdrawn.status).toBe(404) await disposeFiber() }) @@ -80,4 +90,76 @@ describe('Connection exact Fetch routes', () => { })).not.toThrow() await disposeFiber() }) + + it('refuses exact routes to non-owner Peers while the RPC interceptor still receives them', async () => { + const { connection, dispose: disposeFiber } = await mounted() + const route = vi.fn(async () => new Response('route')) + const disposeRoute = connection.fetch.register({ + path: '/api/session.export', methods: ['GET'], requestBody: 'buffered', fetch: route, + }) + const peers: PeerScope[] = [] + const disposeInterceptor = connection.rpc.intercept('/api', () => true, async (_endpoint, _payload, _signal, peer) => { + peers.push(peer) + return { ok: true, value: null } + }) + const guest = connection.createPeer({ principal: GUEST }) + const shared = connection.createSharedFetchHandler('/api') + + const refused = await shared.fetch(new Request('http://host/api/session.export'), guest) + expect(refused.status).toBe(403) + expect(route).not.toHaveBeenCalled() + expect((await shared.fetch(new Request('http://host/api/session.export'), connection.browserPeer)).status).toBe(200) + + const rpc = await shared.fetch(new Request('http://host/api/goals/get', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId: 'rpc-1', method: 'goals/get', payload: { args: {} } }), + }), guest) + expect(rpc.status).toBe(200) + expect(peers).toEqual([guest]) + await disposeInterceptor() + await disposeRoute() + await disposeFiber() + }) +}) + +describe('Connection in-process Peers', () => { + it('creates independent Peers that keep their principal and leave with the reading Context', async () => { + const { connection, dispose } = await mounted() + const first = connection.createPeer({ principal: GUEST }) + const second = connection.createPeer({ principal: GUEST }) + const owner = connection.createPeer({ principal: { kind: 'owner' } }) + expect(new Set([first.id, second.id, owner.id, connection.browserPeer.id]).size).toBe(4) + expect(first.principal).toBe(GUEST) + expect(owner.principal).toEqual({ kind: 'owner' }) + expect(first.ctx).not.toBe(second.ctx) + + const released = vi.fn() + first.ctx.effect(() => released, 'first Peer registration') + await first.dispose() + expect(released).toHaveBeenCalledOnce() + expect(() => first.ctx.effect(() => () => {})).toThrow() + // Disposing one Peer leaves the others usable. + expect(() => second.ctx.effect(() => () => {})()).not.toThrow() + + await dispose() + }) + + it('disposes a created Peer with the plugin that read the Connection', async () => { + const ctx = new Context() + const provider = ctx.plugin((pluginCtx) => { new HostConnectionService(pluginCtx, [], {} as BrowserAuth) }) + await provider.await() + let created: PeerScope | undefined + const consumer = ctx.plugin({ + inject: ['connection'], + apply: (consumerCtx: Context) => { created = consumerCtx.connection.createPeer({ principal: GUEST }) }, + }) + await consumer.await() + const lingering = vi.fn() + created!.ctx.effect(() => lingering, 'consumer Peer registration') + await consumer.dispose() + expect(lingering).toHaveBeenCalledOnce() + expect(() => created!.ctx.effect(() => () => {})).toThrow() + await provider.dispose() + }) }) diff --git a/packages/client/connection/tests/http-bridge.host.spec.ts b/packages/client/connection/tests/http-bridge.host.spec.ts index e9fa9df364..e612a0608d 100644 --- a/packages/client/connection/tests/http-bridge.host.spec.ts +++ b/packages/client/connection/tests/http-bridge.host.spec.ts @@ -1,8 +1,12 @@ import { EventEmitter } from 'node:events' import { Readable } from 'node:stream' import type { IncomingMessage, ServerResponse } from 'node:http' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { bridge } from '../src/http-bridge.ts' +import { Peer } from '../src/peer.ts' + +const PEER = new Peer(new Context(), { principal: { kind: 'owner' } }) describe('HTTP bridge abort', () => { it('destroys a declared-oversize request instead of draining it', async () => { @@ -26,7 +30,7 @@ describe('HTTP bridge abort', () => { await bridge(request, response, { requestBodyMode: () => 'buffered', fetch: () => { throw new Error('a rejected request must never reach the handler') }, - }, 1000) + }, PEER, 1000) // The socket must not stay parked draining a body the client can trickle // at will after the rejection — same discipline as the chunked overrun. expect(status).toBe(413) @@ -68,7 +72,7 @@ describe('HTTP bridge abort', () => { } return Response.json({ aborted: fetchRequest.signal.aborted }) }, - }, Number.MAX_SAFE_INTEGER) + }, PEER, Number.MAX_SAFE_INTEGER) await started response.emit('close') await pending @@ -102,7 +106,7 @@ describe('HTTP bridge abort', () => { const pending = bridge(request, response, { requestBodyMode: () => 'buffered', fetch: async () => new Response(body), - }).then(() => { settled = true }) + }, PEER).then(() => { settled = true }) try { await expect.poll(() => settled).toBe(true) expect(writes).toBe(1) @@ -134,7 +138,7 @@ describe('HTTP bridge abort', () => { const pending = bridge(request, response, { requestBodyMode: () => 'buffered', fetch: async () => new Response(body), - }) + }, PEER) await started.promise response.emit('close') controller.enqueue(Uint8Array.of(1)) @@ -172,7 +176,7 @@ describe('HTTP bridge abort', () => { for await (const chunk of input.body) received.push(chunk) return new Response('stored') }, - }, 1) + }, PEER, 1) await started expect(received).toEqual([]) @@ -206,7 +210,7 @@ describe('HTTP bridge abort', () => { await bridge(request, response, { requestBodyMode: () => 'streaming', fetch: () => Promise.resolve(new Response(null, { status: 415 })), - }, 1) + }, PEER, 1) expect(status).toBe(415) expect(headers).toMatchObject({ connection: 'close' }) expect(destroyed).toEqual([true]) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 9a16fe0c43..ce37c41383 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -555,7 +555,7 @@ describe('connection node half', () => { await fiber.dispose() }) - it('admits every trusted, authenticated request as the operator Peer and hands each call that Peer', async () => { + it('admits every trusted, authenticated request as the owner browser Peer and hands each call that Peer', async () => { const { connection, routes, dispose } = await mounted({ trustedHosts: ['harness.example'] }) const peers: PeerScope[] = [] const remove = connection.rpc.intercept( @@ -569,7 +569,7 @@ describe('connection node half', () => { expect(connection.admit(fakeRequest({ host: 'other.example' }))).toEqual({ rejection: 403 }) expect(connection.admit(fakeRequest({ host: '127.0.0.1:3080' }))).toEqual({ rejection: 401 }) const cookie = browserCookie(connection, '127.0.0.1:3080') - expect(connection.admit(fakeRequest({ host: '127.0.0.1:3080', cookie }))).toEqual({ peer: connection.operator }) + expect(connection.admit(fakeRequest({ host: '127.0.0.1:3080', cookie }))).toEqual({ peer: connection.browserPeer }) const route = routes.find(candidate => candidate.path === API_PATH)! const request: ClientRequest = { @@ -581,17 +581,18 @@ describe('connection node half', () => { const answered = fakeResponse() await route.handler(fakePost({ host: '127.0.0.1:3080', cookie }, '/api/goals/create', request), answered.response) expect(JSON.parse(String(answered.state.body))).toMatchObject({ result: { ok: true, value: null } }) - // A shell-owned carrier dispatches the shared handler without the bridge and speaks for the operator too. + // A shell-owned carrier dispatches the shared handler without the bridge and speaks for the browser Peer too. const direct = await connection.createSharedFetchHandler('/api').fetch(new Request('http://127.0.0.1:3080/api/goals/create', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(request), - })) + }), connection.browserPeer) expect(direct.status).toBe(200) - expect(peers).toEqual([connection.operator, connection.operator]) + expect(peers).toEqual([connection.browserPeer, connection.browserPeer]) + expect(connection.browserPeer.principal).toEqual({ kind: 'owner' }) // Racing disposals share one completion, and the scope goes with the Connection. - await Promise.all([connection.operator.dispose(), connection.operator.dispose()]) + await Promise.all([connection.browserPeer.dispose(), connection.browserPeer.dispose()]) await remove() await dispose() }) diff --git a/packages/client/connection/tsconfig.host.json b/packages/client/connection/tsconfig.host.json index 66d9844f17..0a60bb86ea 100644 --- a/packages/client/connection/tsconfig.host.json +++ b/packages/client/connection/tsconfig.host.json @@ -14,7 +14,7 @@ "src/http-bridge.ts", "src/index.ts", "src/loopback-hostname.ts", - "src/operator-peer.ts", + "src/peer.ts", "src/recovery-config.ts", "src/rpc-host.ts", "src/rpc-schema.ts", diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index 776542c0f2..ecf23dd6e9 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -5,7 +5,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { LanguageRow } from '../src/client/LanguageRow.tsx' import type { LanguageRowComponentProps } from '../src/client/LanguageRow.tsx' import { createLanguageRowStore } from '../src/client/settings-store.ts' @@ -44,6 +44,7 @@ function mount(active = 'en') { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useStore: bindSnapshotSelector(store), actions: store.actions, t: (key: string) => key === 'language.title' ? 'Language' : key, diff --git a/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx b/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx index d1ecedaa66..578234c898 100644 --- a/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx @@ -2,7 +2,7 @@ import { useState, type ComponentProps } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { Menu, MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' import { CreatePluginMenuItem } from '../src/client/CreatePluginMenuItem.tsx' @@ -29,6 +29,7 @@ function view(roster: Partial = {}, inMenu = false) { useAgentPresets: bindSnapshotSelector(agentPresets), usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, + ...allowAllAccess, load, startCreatorDraft, onDismiss, } function TestMenu() { diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index 6505bceb7f..78a4f599e3 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { Modal } from '@deepseek-ai/dsh-client-ui-primitives' import { AgentPresetSection, type AgentPresetSectionProps } from '../src/client/AgentPresetSection.tsx' @@ -23,6 +23,7 @@ function view(partial: Partial = {}, startCreatorDraft? ...(startCreatorDraft === undefined ? {} : { startCreatorDraft }), usePanelInfo: unusedHook, useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, + ...allowAllAccess, useAgentPresetSection: bindSnapshotSelector(store), useDeveloperTools: bindSnapshotSelector(developerTools), t: key => translations.get(key) ?? key } diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx index a3d58a8cfe..c29b831ce8 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx @@ -26,9 +26,10 @@ function lastContent(snapshot: ChatSnapshot, turn: number, skipWarning: boolean) /** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */ export const TurnTailNodeView = memo(function TurnTailNodeView({ - node, openFile, forkAt, renderSlot, t, useChat, usePerformanceUsage, + node, openFile, forkAt, renderSlot, t, useAccess, useChat, usePerformanceUsage, }: TurnTailNodeViewProps) { const detailed = usePerformanceUsage(mode => mode) === 'detailed' + const forkAllowed = useAccess('session/fork') const data = node.data const hasLaterChatNode = useChat(snapshot => (lastContent(snapshot, data.turn, true)?.anchorSeq ?? -1) > (data.closing?.finalNode.seq ?? data.seq)) @@ -66,7 +67,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ clock="end" // The branch action owns boundary resolution: it sends the real // turn/end seq it already has, and the Host cuts exactly there. - onBranch={() => { forkAt(data.seq) }} + onBranch={forkAllowed ? () => { forkAt(data.seq) } : undefined} branchUnavailable={data.branchUnavailable || hasLaterChatNode} className={css.actions} extraActions={assistantActions} diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 9bce65619b..a85e28798d 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -17,6 +17,8 @@ import type { import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { MarkdownFileMentions, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +// Type-only: the Session-scope access Hook (`useAccess`) the Chat entries read. +import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type { createChatStore } from '../stores.ts' import type { ChatPresentationPolicy } from '../presentation-policy.ts' import type { FlowMotionRows } from '../chat/flow-motion.ts' diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index e334bbc039..e97d0e56dc 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -27,7 +27,7 @@ import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controlle import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { KeyedSnapshotSelectorHook, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' @@ -479,6 +479,7 @@ function makeHarness( createSnapshotStore(new Map()), ), useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useProjection: (key: string) => { const inbox = useTestSession(snapshot => snapshot.testInbox) return key === 'inbox' ? inbox : outlineValue @@ -3376,6 +3377,17 @@ describe('ChatView', () => { expect(h.forkAt).not.toHaveBeenCalled() }) + it('omits the Turn Tail branch action when the Session access snapshot excludes session/fork', () => { + const h = makeHarness({ nodes: [user(1, 'question'), assistant(2, 'answer')], turnEnds: new Map([[1, 3]]) }) + const useAccess = vi.fn((endpoint: string) => endpoint !== 'session/fork') + // Chat Node renderers read the harness's standard props object, so the override goes there. + Object.assign(h.props, { useAccess }) + const view = render() + expect(view.getAllByRole('button', { name: '复制' }).length).toBeGreaterThan(0) + expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull() + expect(useAccess).toHaveBeenCalledWith('session/fork') + }) + it('renders assistant Markdown across history, streaming, final, and interrupted states while user text stays literal', () => { const markdown = '# Rendered\n\n- **one**\n- `two`' const h = makeHarness({ nodes: [user(1, markdown), assistant(2, markdown)] }) diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index d195d284e2..b8f3b5abd0 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -6,7 +6,7 @@ import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controlle import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx' import { PerformanceUsageRow } from '../src/client/settings/PerformanceUsageRow.tsx' import type { LinkOpening, PerformanceUsageMode, TranscriptViewMode } from '../src/chat-settings.ts' @@ -47,6 +47,7 @@ function mount(mode: TranscriptViewMode = 'standard', dictionary: typeof en | ty useWorkspaces: emptyWorkspaces(), useSessionRetainInfo: () => undefined, useResource, + ...allowAllAccess, useTranscriptView: bindSnapshotSelector(source), useCollapseTiming: bindSnapshotSelector(collapseTiming), setTranscriptView, diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index 3d1b833896..f4b80c891c 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' @@ -46,6 +46,7 @@ function mount() { useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useBusyEnter: bindSnapshotSelector(policy.busyEnter), setBusyEnter, t: makeTranslate(en), diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 17b9b87282..b18486372d 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -16,7 +16,7 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react' import { createPortal } from 'react-dom' import { $getRoot, $isTextNode } from 'lexical' import { - bindSnapshotSelector, conversationSnapshot as conversationFixture, makeTranslate, RemoteError, + allowAllAccess, bindSnapshotSelector, conversationSnapshot as conversationFixture, makeTranslate, RemoteError, sessionSnapshot as sessionFixture, } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -191,6 +191,7 @@ function bench(over?: BenchOptions) { useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, })), + ...allowAllAccess, useProjection: ((key: string, selector?: (v: unknown) => unknown) => (selector ?? (v => v))(key === 'plan' ? over?.plan diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index e7c997cb61..f1aba8e4e7 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -13,7 +13,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { - bindSnapshotSelector, conversationSnapshot, sessionSnapshot, + allowAllAccess, bindSnapshotSelector, conversationSnapshot, sessionSnapshot, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -66,6 +66,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), + ...allowAllAccess, useProjection: (() => undefined), useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useInput: bindSnapshotSelector(shell.state), diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index ca03589a52..bb089654dd 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -23,7 +23,7 @@ import type { CommandClaim, PickOutcome, SubmitAttachment, SubmitOutcome, } from '../src/client/contract/input.ts' import { - bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, SlotTestRuntime, + allowAllAccess, bindSnapshotSelector, conversationSnapshot, makeTranslate, sessionSnapshot, SlotTestRuntime, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -165,6 +165,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, })), + ...allowAllAccess, useProjection: (() => undefined), useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useInput: bindSnapshotSelector(shell.state), diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index be31f70fb4..ec93676ff5 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -17,7 +17,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { - bindSnapshotSelector, conversationSnapshot, makeTranslate, + allowAllAccess, bindSnapshotSelector, conversationSnapshot, makeTranslate, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' @@ -117,6 +117,7 @@ function kitFor(snapshot: SessionSnapshot, injected: Partial createSnapshotStore(new Map()), ), useWorkspaces: (() => { throw new Error('unused') }) as never, + ...allowAllAccess, useProjection: (() => undefined) as never, useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot())), useChat: (() => { throw new Error('unused') }) as QueueDockProps['useChat'], diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 7f0225ff33..00a784ac96 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -8,7 +8,7 @@ import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-ses import type { WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { - bindSnapshotSelector, makeTranslate, RemoteError, sessionSnapshot as sessionFixture, + allowAllAccess, bindSnapshotSelector, makeTranslate, RemoteError, sessionSnapshot as sessionFixture, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' @@ -223,6 +223,7 @@ function mount( useSessionStatus={useSessionStatus} useSessionRetainInfo={() => undefined} useWorkspaces={props.useWorkspaces} + {...allowAllAccess} useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} @@ -252,6 +253,7 @@ function mount( useSessionStatus={useSessionStatus} useSessionRetainInfo={() => undefined} useWorkspaces={props.useWorkspaces} + {...allowAllAccess} useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} @@ -279,6 +281,7 @@ function mount( useSessionStatus={useSessionStatus} useSessionRetainInfo={() => undefined} useWorkspaces={props.useWorkspaces} + {...allowAllAccess} useProjection={(() => undefined)} useInput={useInput} inputActions={inputActions} @@ -337,6 +340,7 @@ function mount( useSessionStatus, useSessionRetainInfo: () => undefined, useWorkspaces: bindSnapshotSelector(workspaces), + ...allowAllAccess, useProjection: (() => undefined), useComposerBlock: select => select(options.composerBlock), useInput, @@ -367,6 +371,7 @@ function mount( useSessionRetainInfo: () => undefined, useResource, useWorkspaces: bindSnapshotSelector(workspaces), + ...allowAllAccess, useProjection: (() => undefined), useInput, inputActions, diff --git a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts index 6fda6fed4a..9921990174 100644 --- a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts @@ -69,10 +69,13 @@ async function fixture() { apply: registerPresentOpen, }) const handler = connection.createSharedFetchHandler('/api') - const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' })) - const read = (query = '?sessionId=owner&seq=9') => handler.fetch(new Request(`http://localhost${CHANGED_FILES_PATH}${query}`)) - const compare = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_DIFF_PATH}${query}`)) - return { handler, applications, root, cwd, ctx, data, readEvent, open, read, compare, comparison, diff, opener, outside, summary } + const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' }), connection.browserPeer) + const read = (query = '?sessionId=owner&seq=9') => handler.fetch(new Request(`http://localhost${CHANGED_FILES_PATH}${query}`), connection.browserPeer) + const compare = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_DIFF_PATH}${query}`), connection.browserPeer) + return { + browserPeer: connection.browserPeer, handler, applications, root, cwd, ctx, data, readEvent, open, read, compare, comparison, diff, + opener, outside, summary, + } } describe('change summary route', () => { @@ -203,17 +206,17 @@ describe('changed files native open route', () => { it('queries handlers only after file authorization and forwards explicit application choices', async () => { - const { handler, applications, opener, open, ctx } = await fixture() + const { browserPeer, handler, applications, opener, open, ctx } = await fixture() const url = 'http://localhost/api/changes.open?sessionId=owner&seq=9&index=0' - const response = await handler.fetch(new Request(url)) + const response = await handler.fetch(new Request(url), browserPeer) expect(response.status).toBe(200) expect(await response.json()).toEqual([{ id: 'player', name: 'Player', default: true, icon: null }]) expect(opener).not.toHaveBeenCalled() expect((await open('?sessionId=owner&seq=9&index=0&application=player')).status).toBe(204) expect(opener.mock.lastCall?.[0]).toMatchObject({ application: 'player' }) - expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')))).status).toBe(404) + expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')), browserPeer)).status).toBe(404) vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) - expect((await handler.fetch(new Request(url))).status).toBe(422) + expect((await handler.fetch(new Request(url), browserPeer)).status).toBe(422) expect(applications).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts index b313ee0aa8..6d6057d204 100644 --- a/packages/client/ui-deliverables/tests/present-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -54,17 +54,19 @@ async function fixture() { const handler = connection.createSharedFetchHandler('/api') const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request( `http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null }, - )) - return { applications, root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent } + ), connection.browserPeer) + return { + browserPeer: connection.browserPeer, applications, root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent, + } } describe('Presented workspace file native open route', () => { it('opens the source itself with current bytes and leaves it intact at disposal', async () => { - const { cwd, open, file, fiber, opener, handler, ctx } = await fixture() + const { browserPeer, cwd, open, file, fiber, opener, handler, ctx } = await fixture() const source = await realpath(join(cwd, file.path)) expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe('api/present.open?sessionId=owner&seq=7&index=0') - expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(400) - expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'))).status).toBe(404) + expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`), browserPeer)).status).toBe(400) + expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'), browserPeer)).status).toBe(404) for (const contents of ['current source', 'edited source']) { await writeFile(source, contents) const response = await open() @@ -189,8 +191,8 @@ describe('Presented workspace file native open route', () => { it('reports the serving desktop and reveals only an authorized declared source', async () => { - const { cwd, file, open, opener, handler } = await fixture() - const info = await handler.fetch(new Request('http://localhost/api/present.host')) + const { browserPeer, cwd, file, open, opener, handler } = await fixture() + const info = await handler.fetch(new Request('http://localhost/api/present.host'), browserPeer) expect(await info.json()).toEqual({ name: 'desktop', available: true, fileManager: 'finder' }) expect((await open('?sessionId=owner&seq=7&index=0&action=reveal')).status).toBe(204) expect(opener).toHaveBeenCalledWith({ path: await realpath(join(cwd, file.path)), action: 'reveal' }, expect.any(AbortSignal)) @@ -201,9 +203,9 @@ it('reports the serving desktop and reveals only an authorized declared source', }) it('refuses native actions when the configured Host desktop is unavailable', async () => { - const { ctx, open, opener, handler } = await fixture() + const { browserPeer, ctx, open, opener, handler } = await fixture() vi.spyOn(ctx.sessionController, 'workspaceDesktop').mockReturnValue({ name: 'desktop', available: false, fileManager: 'finder' }) - expect(await (await handler.fetch(new Request('http://localhost/api/present.host'))).json()).toMatchObject({ available: false }) + expect(await (await handler.fetch(new Request('http://localhost/api/present.host'), browserPeer)).json()).toMatchObject({ available: false }) for (const action of ['open', 'reveal']) { expect((await open(`?sessionId=owner&seq=7&index=0&action=${action}`)).status).toBe(409) } @@ -237,16 +239,16 @@ it('uses the deployment workspace root when the viewed Session has no cwd', asyn it('queries handlers only after file authorization and forwards explicit application choices', async () => { - const { handler, applications, opener, open, ctx } = await fixture() + const { browserPeer, handler, applications, opener, open, ctx } = await fixture() const url = 'http://localhost/api/present.open?sessionId=owner&seq=7&index=0' - const response = await handler.fetch(new Request(url)) + const response = await handler.fetch(new Request(url), browserPeer) expect(response.status).toBe(200) expect(await response.json()).toEqual([{ id: 'player', name: 'Player', default: true, icon: null }]) expect(opener).not.toHaveBeenCalled() expect((await open('?sessionId=owner&seq=7&index=0&application=player')).status).toBe(204) expect(opener.mock.lastCall?.[0]).toMatchObject({ application: 'player' }) - expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')))).status).toBe(404) + expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')), browserPeer)).status).toBe(404) vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) - expect((await handler.fetch(new Request(url))).status).toBe(422) + expect((await handler.fetch(new Request(url), browserPeer)).status).toBe(422) expect(applications).toHaveBeenCalledOnce() }) diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx index f82cec372a..1dc7cc85b8 100644 --- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom /** Frame interactions with a real store and explicitly driven browser measurements. */ import type { GlobalStandardProps, RenderOpts } from '@deepseek-ai/dsh-client-ui-slots' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' import { AppFrame } from '../src/client/AppFrame.tsx' @@ -99,6 +99,7 @@ function mountFrame(windowWidth = frameWidth) { useSessionRetainInfo={() => undefined} useResource={useResource} useWorkspaces={sel => sel(workspaceState)} + {...allowAllAccess} t={key => key === 'brand.localBuild' ? 'DSH Local Build' : key} /> ) diff --git a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx index aec73b8415..160bd49d80 100644 --- a/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx +++ b/packages/client/ui-permission-presets/tests/permission-presets-row.client.spec.tsx @@ -3,7 +3,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { PermissionRow, type PermissionRowProps } from '../src/client/PermissionRow.tsx' import { zh } from '../src/client/locales.ts' @@ -63,6 +63,7 @@ const runtime = { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: (() => { throw new Error('unused') }) as never, + ...allowAllAccess, } function mount(controller: PermissionPresetSettingsController) { diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index 8dbe936512..dbce0d098f 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -8,7 +8,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ILayout, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import * as settings from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' @@ -147,7 +147,7 @@ describe('ui-plugin-manager browser plugin', () => { const unread = () => { throw new Error('The sidebar icon must not read application state') } const glyph = render() + useWorkspaces={unread} useResource={unread} {...allowAllAccess} />) expect(glyph.container.querySelector('svg')?.getAttribute('width')).toBe('18') expect(icon.options).toMatchObject({ id: PANEL_ID, order: 0 }) expect(icon.locale).toBe(NS) diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index fc2751b4bb..a0055f2a56 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from import { Context } from '@deepseek-ai/cordis' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { PluginEntryId, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client' -import { bindSnapshotSelector, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import type { ConfigForm, ConfigFormSnapshot, SettingsMirrorSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives' @@ -139,6 +139,7 @@ function renderTab( useSessionStatus: unusedStandardHook, useSessionRetainInfo: unusedStandardHook, useResource: unusedStandardHook, + ...allowAllAccess, } const navigation = createNavigationStore().create() const props: PluginManagerPageProps = { diff --git a/packages/client/ui-renderer/src/client/bindings.tsx b/packages/client/ui-renderer/src/client/bindings.tsx index 0e0c0c9527..5ecc314320 100644 --- a/packages/client/ui-renderer/src/client/bindings.tsx +++ b/packages/client/ui-renderer/src/client/bindings.tsx @@ -101,13 +101,43 @@ function useAbsentSnapshot( return undefined } -/** Erased open-key selector Hook synthesized from one keyed source family. */ +/** + * Erased open-key selector Hook synthesized from one keyed source family. + * A string second argument names a member: the Hook reads the source the + * resolver returns for `(key, member)` without a selector. + */ export type KeyedSnapshotHook = ( keyOrSelector: string | ((value: unknown) => unknown), - selectorOrEqual?: ((value: unknown) => unknown) | ((left: unknown, right: unknown) => boolean), + memberOrSelectorOrEqual?: string | ((value: unknown) => unknown) | ((left: unknown, right: unknown) => boolean), equal?: (left: unknown, right: unknown) => boolean, ) => unknown +interface KeyedHookCall { + readonly key: string | undefined + readonly member: string | undefined + readonly selector: ((value: unknown) => unknown) | undefined + readonly equal: ((left: unknown, right: unknown) => boolean) | undefined +} + +/** Read the `(key, member)`, `(key, selector?, equal?)`, or `(selector, equal?)` call forms. */ +function keyedHookCall( + defaultKey: string | undefined, + keyOrSelector: Parameters[0], + second: Parameters[1], + equal: Parameters[2], +): KeyedHookCall { + if (typeof keyOrSelector !== 'string') { + return { + key: defaultKey, + member: undefined, + selector: keyOrSelector, + equal: second as ((left: unknown, right: unknown) => boolean) | undefined, + } + } + if (typeof second === 'string') return { key: keyOrSelector, member: second, selector: undefined, equal: undefined } + return { key: keyOrSelector, member: undefined, selector: second as ((value: unknown) => unknown) | undefined, equal } +} + /** * Bind an open-key source family. * @param source - keyed resolver, or absence for an optional scope. @@ -126,13 +156,13 @@ export function keyedObservableHook( const cacheKey = defaultKey ?? NO_DEFAULT_KEY let hook = hooks.get(cacheKey) if (hook === undefined) { - hook = (keyOrSelector, selectorOrEqual, equal) => { - const keyed = typeof keyOrSelector === 'string' - const key = keyed ? keyOrSelector : defaultKey - const selector = (keyed ? selectorOrEqual : keyOrSelector) as ((value: unknown) => unknown) | undefined - const comparison = (keyed ? equal : selectorOrEqual) as ((left: unknown, right: unknown) => boolean) | undefined - const useValue = observableHook(key === undefined ? absentSource : source(key) ?? absentSource) - return useValue(selector ?? identity, comparison) + hook = (keyOrSelector, second, equal) => { + const call = keyedHookCall(defaultKey, keyOrSelector, second, equal) + const resolved = call.key === undefined + ? undefined + : call.member === undefined ? source(call.key) : source(call.key, call.member) + const useValue = observableHook(resolved ?? absentSource) + return useValue(call.selector ?? identity, call.equal) } hooks.set(cacheKey, hook) } @@ -142,11 +172,9 @@ export function keyedObservableHook( const NO_DEFAULT_KEY = Symbol('no default key') const keyedHookCache = new WeakMap>() const identity = (value: unknown): unknown => value -const absentKeyedHook: KeyedSnapshotHook = (keyOrSelector, selectorOrEqual, equal) => { - const keyed = typeof keyOrSelector === 'string' - const selector = (keyed ? selectorOrEqual : keyOrSelector) as ((value: unknown) => unknown) | undefined - const comparison = (keyed ? equal : selectorOrEqual) as ((left: unknown, right: unknown) => boolean) | undefined - return observableHook(absentSource)(selector ?? identity, comparison) +const absentKeyedHook: KeyedSnapshotHook = (keyOrSelector, second, equal) => { + const call = keyedHookCall(undefined, keyOrSelector, second, equal) + return observableHook(absentSource)(call.selector ?? identity, call.equal) } /** Subscribe the tree to the atomically assembled root standard-source roster. */ diff --git a/packages/client/ui-renderer/tests/keyed-hook-member.client.spec.tsx b/packages/client/ui-renderer/tests/keyed-hook-member.client.spec.tsx new file mode 100644 index 0000000000..fdfaecf3d8 --- /dev/null +++ b/packages/client/ui-renderer/tests/keyed-hook-member.client.spec.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import type { HostObservable, KeyedStandardSource } from '@deepseek-ai/dsh-client-ui-slots' +import { keyedObservableHook } from '../src/client/bindings.tsx' + +function observable(initial: T): HostObservable & { set(next: T): void } { + let value = initial + const listeners = new Set<() => void>() + return { + getSnapshot: () => value, + subscribe: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + set: (next) => { + value = next + for (const listener of [...listeners]) listener() + }, + } +} + +describe('keyed standard Hooks', () => { + it('reads the resolver\'s (key, member) source when the second argument is a string', () => { + const fork = observable(true) + const whole = observable({ title: 'one' }) + const resolver = vi.fn((key, member) => + member === undefined ? (key === 's1' ? whole : undefined) : (key === 's1' && member === 'session/fork' ? fork : undefined)) + const useAccess = keyedObservableHook(resolver) + + const member = renderHook(() => useAccess('s1', 'session/fork')) + expect(member.result.current).toBe(true) + act(() => { fork.set(false) }) + expect(member.result.current).toBe(false) + expect(resolver).toHaveBeenCalledWith('s1', 'session/fork') + + expect(renderHook(() => useAccess('s1', 'session/missing')).result.current).toBeUndefined() + expect(renderHook(() => useAccess('s1')).result.current).toEqual({ title: 'one' }) + expect(resolver.mock.calls.some(call => call.length === 1 && call[0] === 's1')).toBe(true) + }) + + it('keeps the selector, equality, and default-key call forms', () => { + const whole = observable({ title: 'one', count: 1 }) + const useValue = keyedObservableHook(key => (key === 'k' ? whole : undefined), 'k') + expect(renderHook(() => useValue('k', (value: unknown) => (value as { title: string }).title)).result.current).toBe('one') + expect(renderHook(() => useValue((value: unknown) => (value as { count: number }).count)).result.current).toBe(1) + const equal = vi.fn(() => true) + renderHook(() => useValue('k', (value: unknown) => value, equal)) + act(() => { whole.set({ title: 'two', count: 2 }) }) + expect(equal).toHaveBeenCalled() + const noDefault = keyedObservableHook(key => (key === 'k' ? whole : undefined)) + expect(renderHook(() => noDefault((value: unknown) => value)).result.current).toBeUndefined() + }) + + it('reads undefined for every call form while the source family is absent', () => { + const absent = keyedObservableHook(undefined) + expect(renderHook(() => absent('s1', 'session/fork')).result.current).toBeUndefined() + expect(renderHook(() => absent('s1', (value: unknown) => value ?? 'fallback')).result.current).toBe('fallback') + expect(renderHook(() => absent((value: unknown) => value ?? 'bare')).result.current).toBe('bare') + }) +}) diff --git a/packages/client/ui-schedule/tests/task-detail.client.spec.tsx b/packages/client/ui-schedule/tests/task-detail.client.spec.tsx index bb6a084780..ff64d41b88 100644 --- a/packages/client/ui-schedule/tests/task-detail.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-detail.client.spec.tsx @@ -11,7 +11,7 @@ */ import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' @@ -118,6 +118,7 @@ function mount( useSessionStatus: select => select(new Map()), useSessionRetainInfo: () => undefined, useResource: () => { throw new Error('The task manager does not load document resources') }, + ...allowAllAccess, onDelete: vi.fn(async () => 'deleted'), onRetry: vi.fn(async () => {}), onNewTask: vi.fn(), diff --git a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx index 52158d0ed2..7f29237374 100644 --- a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { act, cleanup, fireEvent, isInaccessible, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { bindSnapshotSelector, makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' @@ -174,6 +174,7 @@ function mount( useSessionStatus: select => select(new Map()), useSessionRetainInfo: () => undefined, useResource: () => { throw new Error('The task manager does not load document resources') }, + ...allowAllAccess, onDelete: vi.fn(async () => 'deleted'), onRetry: vi.fn(async () => {}), onNewTask: vi.fn(), @@ -1598,6 +1599,7 @@ describe('Task manager details', () => { useResource={unread} useSessionStatus={unread} useSessionRetainInfo={unread} + {...allowAllAccess} />) } diff --git a/packages/client/ui-session/README.i18n.yaml b/packages/client/ui-session/README.i18n.yaml index 02db9d6ecb..447f8f489b 100644 --- a/packages/client/ui-session/README.i18n.yaml +++ b/packages/client/ui-session/README.i18n.yaml @@ -12,8 +12,11 @@ en: 75428e0474a073a4 zh: 18f1db4b4c5302e5 /deepseek-ai-dsh-client-ui-session/table-of-contents: - en: b4683d2a53256fda - zh: 4e65ff63dc8c48fe + en: 12e1ffc25a2d6695 + zh: 321156dea1356e55 +/deepseek-ai-dsh-client-ui-session/access-hooks: + en: 619ba442c3e40fb0 + zh: af0206a774ac9bc1 /deepseek-ai-dsh-client-ui-session/model-experience: en: cb2d4451e8bf8520 zh: 6d3adbb930d90c59 diff --git a/packages/client/ui-session/README.md b/packages/client/ui-session/README.md index d10b62ffbc..79ac9f0e73 100644 --- a/packages/client/ui-session/README.md +++ b/packages/client/ui-session/README.md @@ -14,12 +14,18 @@ Running status comes from Host list baselines or status events. Subagent catalog ## Table of Contents +- [Access Hooks](#access-hooks) - [Model Experience](#model-experience) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) - [Dev Note](#dev-note) ----- + +## Access Hooks + +The package injects the Client `remoteAccess` service and publishes Session access Hooks over its `source()`: the root `useSessionAccess(sessionId, action)` answers on one Session resource, and the Session-scope `useAccess(action)` answers on the bound Session's resource; the `global` resource's answer is a different Hook, `useRemoteAccess(action)`, so neither shadows the other. The SessionMaybe-scope `useAccess(action)` returns `undefined` while no Session is bound, which callers treat as not allowed; a decision on the `global` resource reads `useRemoteAccess` instead. Snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). + ## Model Experience diff --git a/packages/client/ui-session/README.zh.md b/packages/client/ui-session/README.zh.md index 95944f55f3..157cc79a7f 100644 --- a/packages/client/ui-session/README.zh.md +++ b/packages/client/ui-session/README.zh.md @@ -14,12 +14,18 @@ kind: "package-reference" ## 目录 +- [访问 Hook](#access-hooks) - [模型体验](#model-experience) - [已知限制与暂缓事项](#known-limitations-and-deferred-work) - [开发备注](#dev-note) ----- + +## 访问 Hook + +本包注入 Client `remoteAccess` 服务,并在其 `source()` 之上发布会话访问 Hook:根作用域的 `useSessionAccess(sessionId, action)` 回答某一个 Session 资源上的访问,Session 作用域的 `useAccess(action)` 回答所绑定 Session 资源上的访问;`global` 资源上的答案是另一个 Hook `useRemoteAccess(action)`,两者互不遮蔽。SessionMaybe 作用域的 `useAccess(action)` 在未绑定 Session 时返回 `undefined`,调用方将其视为不允许;需要 `global` 资源上的判断时读 `useRemoteAccess`。快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 + ## 模型体验 diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index db6caa564f..1518437c49 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -29,6 +29,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-remote-access", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-ui-renderer" @@ -46,6 +47,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts index 5017904d86..29aeebce04 100644 --- a/packages/client/ui-session/src/client/index.ts +++ b/packages/client/ui-session/src/client/index.ts @@ -12,6 +12,7 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type { RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values' import { standardHookPropName } from '@deepseek-ai/dsh-client-ui-slots' @@ -158,6 +159,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { useSessions: UseSessions useSessionStatus: UseSessionStatus useSessionRetainInfo: UseSessionRetainInfo + /** Whether the current Peer may call `action` on one Session resource. */ + useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean } interface SessionStandardProps { @@ -167,6 +170,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { sessionId: SessionId /** Host-computed projection values addressed by projection key. */ useProjection: UseProjection + /** Whether the current Peer may call `action` on this Session resource. */ + useAccess: (action: RemoteEndpoint) => boolean } interface SessionMaybeStandardProps { @@ -176,6 +181,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { sessionId: SessionId | undefined /** Host-computed projection values; every key is absent without a Session. */ useProjection: UseProjection + /** The bound Session's access answer; `undefined` without a Session, which callers treat as not allowed. */ + useAccess: (action: RemoteEndpoint) => boolean | undefined } } @@ -660,8 +667,8 @@ function claimStandardProp(kind: StandardMemberKind, name: string, finalProps: S finalProps.add(propName) } -/** Required Controller and renderer services. */ -export const inject = ['sessions', 'slots', 'remote'] +/** Required Controller, renderer, Remote, and access services. */ +export const inject = ['sessions', 'slots', 'remote', 'remoteAccess'] /** * Install the Session root source and scoped adapter. @@ -676,9 +683,18 @@ export function apply(ctx: Context): void { }, keyedHooks: { sessionRetainInfo: key => ctx.sessions.retainInfo(key as SessionId), + sessionAccess: (sessionId, action) => action === undefined + ? undefined + : ctx.remoteAccess.source('session', sessionId, action), }, } satisfies RootStandardSourceContribution) ctx.slots.installScope('session', service.adapter) + service.provide({ + keyedHooks: ['access'], + resolve: binding => ({ + keyedHooks: { access: action => ctx.remoteAccess.source('session', binding.sessionId, action) }, + }), + }) } function sameSessionStatus(left: SessionStatusSnapshot, right: SessionStatusSnapshot): boolean { diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index a91f0e2b9c..ae105e7b8e 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -715,25 +715,49 @@ describe('ui-session apply', () => { const slots = { provideRoot: vi.fn(), installScope: vi.fn(), + bindStoreScope: vi.fn(), + } + const accessSources = new Map>() + const remoteAccess = { + source: vi.fn((kind: string, id: string, action: string): HostObservable => { + const key = `${kind}:${id}:${action}` + let source = accessSources.get(key) + if (source === undefined) { + source = createSnapshotStore(false) + accessSources.set(key, source) + } + return source + }), } ctx.provide('sessions', bench.sessions) ctx.provide('slots', slots as never) + ctx.provide('remoteAccess', remoteAccess) apply(ctx) expect(ctx.uiSession).toBeInstanceOf(UiSession) const retainInfoMatcher: unknown = expect.any(Function) + const sessionAccessMatcher: unknown = expect.any(Function) expect(slots.provideRoot).toHaveBeenCalledWith({ hooks: { sessions: bench.sessions.list, sessionStatus: ctx.uiSession.sessionStatus, }, - keyedHooks: { sessionRetainInfo: retainInfoMatcher }, + keyedHooks: { sessionRetainInfo: retainInfoMatcher, sessionAccess: sessionAccessMatcher }, }) expect(slots.installScope).toHaveBeenCalledWith('session', ctx.uiSession.adapter) const root = slots.provideRoot.mock.calls[0]![0] as RootStandardSourceContribution expect(root.keyedHooks?.sessionRetainInfo?.(sessionId('s1'))) .toBe(bench.retainInfo(sessionId('s1'))) + // `useSessionAccess(sessionId, action)` reads the Session resource's answer; a call without an action names no source. + expect(root.keyedHooks?.sessionAccess?.(sessionId('s1'), 'session/fork')) + .toBe(remoteAccess.source('session', sessionId('s1'), 'session/fork')) + expect(root.keyedHooks?.sessionAccess?.(sessionId('s1'))).toBeUndefined() + + // The Session scope's `useAccess(action)` reads the bound Session's answer from the same source. + const binding = bench.binding(sessionId('s2')) + const scoped = ctx.uiSession.bindingSource(bench.reference(binding)).getSnapshot() + expect(scoped.keyedHooks.access?.('session/fork')).toBe(remoteAccess.source('session', sessionId('s2'), 'session/fork')) }) it('keeps the Host loader half inert', () => { diff --git a/packages/client/ui-session/tsconfig.json b/packages/client/ui-session/tsconfig.json index de3f6472ee..e38563a19b 100644 --- a/packages/client/ui-session/tsconfig.json +++ b/packages/client/ui-session/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../api/remotes/tsconfig.client.json" }, + { + "path": "../../api/remote-access/tsconfig.client.json" + }, { "path": "../../api/session-controller/tsconfig.client.json" }, diff --git a/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx b/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx index 9d5915fcb5..007aa59644 100644 --- a/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx +++ b/packages/client/ui-settings-account/tests/onboarding.client.spec.tsx @@ -6,7 +6,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' import { AccountOnboarding } from '../src/client/AccountOnboarding.tsx' import type { AccountSnapshot } from '../src/client/AccountSection.tsx' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en } from '../src/client/locales.ts' afterEach(cleanup) @@ -22,6 +22,7 @@ it('claims onboarding while loading, offers API key fallback, and completes afte const globals: GlobalStandardProps = { usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, + ...allowAllAccess, } const props = { ...globals, diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index ac2e7f000b..ab8a19976d 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx' import { GeneralSection } from '../src/client/GeneralSection.tsx' import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx' @@ -41,6 +41,7 @@ const useSessionStatus: TriggerContentProps['useSessionStatus'] = selector => se const kit = { useSessions: unusedHook, useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: unusedHook, + ...allowAllAccess, } describe('Desktop collapsed update badge', () => { diff --git a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx index dcf874bcf7..2f46ad2057 100644 --- a/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/settings-root.client.spec.tsx @@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { useEffect, useState } from 'react' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createSettingsShellStore } from '../src/client/shell-store.ts' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import { SessionId } from '@deepseek-ai/dsh-session/types' import type { SettingsRootComponentProps } from '../src/client/shell-contract.ts' @@ -104,6 +104,7 @@ function mount({ useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: unusedHook, + ...allowAllAccess, wide, reconnect, openDesktopUpdate: () => {}, diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index f04b8853b4..a60aff9f8c 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import type { JsonValue } from '@deepseek-ai/dsh-util-values' -import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' @@ -152,6 +152,7 @@ function harness(options: { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: unusedHook, + ...allowAllAccess, controller, useModels: bindSnapshotSelector(controller.store), operations, diff --git a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx index 09cb3bb49d..107266f082 100644 --- a/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/welcome-notice.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, RemoteError } from '@deepseek-ai/dsh-client-test-runtime' import { Context } from '@deepseek-ai/cordis' import { SettingsSchemaService } from '@deepseek-ai/dsh-client-ui-settings/src/client/schema.ts' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' @@ -95,6 +95,7 @@ function mount( useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: unusedHook, + ...allowAllAccess, controller, useWelcome: bindSnapshotSelector(controller.store), t: key => zh[key], diff --git a/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx b/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx index 9061f68145..a379b7010f 100644 --- a/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/expand-button.client.spec.tsx @@ -10,6 +10,7 @@ import { useSyncExternalStore } from 'react' import type { GlobalStandardProps, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { allowAllAccess } from '@deepseek-ai/dsh-client-test-runtime' import { ExpandButton } from '../src/client/shell/ExpandButton.tsx' import type { ExpandButtonProps } from '../src/client/shell/ExpandButton.tsx' import { createSidebarRightStore } from '../src/client/stores.ts' @@ -21,6 +22,7 @@ const standard: GlobalStandardProps & SessionStandardProps = { useInput: unused, useChat: unused, useTrajectory: unused, usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, + ...allowAllAccess, inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused, addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused }, } diff --git a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx index a4015b079e..445b4dae68 100644 --- a/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/guide-body.client.spec.tsx @@ -19,7 +19,7 @@ import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' import { GuideBody } from '../src/client/tabs/guide/GuideBody.tsx' import type { GuideBodyProps } from '../src/client/tabs/guide/GuideBody.tsx' import type { SidebarRightGuideBox } from '../src/client/tab-registry.ts' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import css from '../src/client/tabs/guide/GuideBody.module.css' afterEach(cleanup) @@ -31,6 +31,7 @@ const standard: GlobalStandardProps & SessionStandardProps = { useInput: unused, useChat: unused, useTrajectory: unused, usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, + ...allowAllAccess, inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused, addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused }, } diff --git a/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx index ec7bcd46ca..390fecc4da 100644 --- a/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/cleanup.client.spec.tsx @@ -2,7 +2,7 @@ /** Cleanup errors remain reachable after the terminal tab has disappeared. */ import { afterEach, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, within } from '@testing-library/react' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { TerminalCloseFailure } from '@deepseek-ai/dsh-api-terminal-controller/client' import type { WebTerminalId } from '@deepseek-ai/dsh-api-terminal-controller/types' import { TerminalCleanup } from '../src/client/TerminalCleanup.tsx' @@ -15,6 +15,7 @@ const unusedHook = (): never => { throw new Error('Terminal cleanup does not con const standard: Omit = { useSessions: unusedHook, useSessionStatus: unusedHook, useSessionRetainInfo: unusedHook, usePanelInfo: unusedHook, useWorkspaces: unusedHook, useResource: unusedHook, + ...allowAllAccess, } it('stays absent for ordinary closes and offers a retry only after cleanup fails', () => { diff --git a/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx b/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx index a50f7ca835..bdcd5fb2b1 100644 --- a/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx +++ b/packages/client/ui-sidebar-terminal/tests/terminal-guide.client.spec.tsx @@ -2,7 +2,7 @@ /** Guide shell discovery, direct launch and cancellation stay within the entry lifetime. */ import { act, cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { TerminalLaunchShells } from '@deepseek-ai/dsh-api-terminal-controller/client' import type { GlobalStandardProps, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -19,6 +19,7 @@ const standard: GlobalStandardProps & SessionStandardProps = { useInput: unused, useChat: unused, useTrajectory: unused, usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, + ...allowAllAccess, inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused, addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused }, } diff --git a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx index 1522597dae..200cfe6f74 100644 --- a/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/pointer-scrollbars.client.spec.tsx @@ -8,6 +8,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { allowAllAccess } from '@deepseek-ai/dsh-client-test-runtime' import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src/client/contract/slots.ts' import { SidebarRoot } from '../src/client/SidebarRoot.tsx' import { en } from '../src/client/locales.ts' @@ -43,6 +44,7 @@ function mountColumn(): { column: HTMLElement; quiet: () => boolean } { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, owner: SidebarSectionOwnerProps) =>

) as SidebarRootComponentProps['renderSlot']} diff --git a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx index f2407246f8..c0aee40acf 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx @@ -4,7 +4,7 @@ import type { ShortcutCatalogEntry, ShortcutCommandId } from '@deepseek-ai/dsh-c import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { ReactNode } from 'react' import type { SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarSectionOwnerProps, @@ -54,6 +54,7 @@ function mountShell({ collapsed = false, width = 300, shortcuts = [] }: { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector(shortcuts)} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={startSession} toggleSidebar={toggleSidebar} t={t} renderSlot={(( key: string, @@ -115,6 +116,7 @@ describe('SidebarRoot shell', () => { render( select([])} useShortcuts={select => select([shortcut])} />) const button = screen.getByRole('button', { name: 'New session' }) expect(button.getAttribute('aria-keyshortcuts')).toBe('Control+N') @@ -143,6 +145,7 @@ describe('SidebarRoot shell', () => { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} @@ -163,6 +166,7 @@ describe('SidebarRoot shell', () => { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} @@ -178,6 +182,7 @@ describe('SidebarRoot shell', () => { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((_key: string, _owner: unknown, options?: { fallback?: ReactNode }) => options?.fallback ?? null) as SidebarRootComponentProps['renderSlot']} @@ -225,6 +230,7 @@ describe('SidebarRoot shell', () => { useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook} usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useShortcuts={selector => selector([])} useResource={useResource} useWorkspaces={neverHook} + {...allowAllAccess} startSession={vi.fn()} toggleSidebar={vi.fn()} t={t} renderSlot={((key: string) => key === 'sidebar.toggle.badge' ? diff --git a/packages/client/ui-slots/README.i18n.yaml b/packages/client/ui-slots/README.i18n.yaml index af812bc333..453ea30bac 100644 --- a/packages/client/ui-slots/README.i18n.yaml +++ b/packages/client/ui-slots/README.i18n.yaml @@ -21,8 +21,8 @@ en: 71cbe7aa26f3ff80 zh: 0e35265d4d95cc36 /deepseek-ai-dsh-client-ui-slots/use-this-package/the-five-framework-props-shares: - en: 45e2933a5e084956 - zh: 938deedaf489f892 + en: b69ffb6ca503e781 + zh: fe5887cbfc7fb588 /deepseek-ai-dsh-client-ui-slots/use-this-package/store-seats: en: 0d1c2948112841ec zh: fc5a998cea485024 diff --git a/packages/client/ui-slots/README.md b/packages/client/ui-slots/README.md index fc4bfaf6ec..0698714bff 100644 --- a/packages/client/ui-slots/README.md +++ b/packages/client/ui-slots/README.md @@ -37,6 +37,8 @@ Factory `children` remain ordinary global Slots and must match `SlotMap`, while Every registered component receives props composed from five framework shares: the runtime share (`owner` from the parent's render call site, plus the session standard kit and global seat), the child-render share (`renderSlot` statically narrowed to declared children), the Factory-render share (`renderFactorySlot`), the store share (the declared handle's selector hook and draft-stripped actions), and the business share (inferred from `inject`). Components reference the derived props aliases; they never re-type a share locally. +An open-key standard Hook, called as `useName(key, selector?, equal?)`, also accepts `useName(key, member)`: a string second argument reads the `KeyedStandardSource` resolver's `(key, member)` source without a selector. The resolver returns the same source object for the same arguments while that source is live. + ### Store seats A register call may declare a store seat with `store: defineStore(...)`: `init` infers the state schema and `actions` is the complete draft-transform write set. Components read through the selector hook and write through the baked callbacks; the engine implementation of `defineStore` lives in the runtime package and satisfies the `DefineStore` contract exported here. diff --git a/packages/client/ui-slots/README.zh.md b/packages/client/ui-slots/README.zh.md index b08b278328..365db5b428 100644 --- a/packages/client/ui-slots/README.zh.md +++ b/packages/client/ui-slots/README.zh.md @@ -37,6 +37,8 @@ Factory `children` 仍是普通全局 Slots 且必须与 `SlotMap` 匹配,而 每个已注册组件都会收到由五个框架 share 组合而成的 props:运行时 share(父级 render 调用点的 `owner`,加上会话标准工具包与全局席位)、child render share(静态缩窄到已声明 children 的 `renderSlot`)、Factory render share(`renderFactorySlot`)、store share(已声明 handle 的 selector 钩子与移除 draft 的 actions),以及业务 share(从 `inject` 推导)。组件引用推导出的 props 别名;它们绝不在本地重新定义任何 share 的类型。 +以 `useName(key, selector?, equal?)` 调用的开放键标准钩子也接受 `useName(key, member)`:第二个参数为字符串时,钩子不经 selector,直接读取 `KeyedStandardSource` 解析器为 `(key, member)` 返回的 source。在该 source 存活期间,解析器对相同参数返回同一个 source 对象。 + ### Store 席位 register 调用可以用 `store: defineStore(...)` 声明 store 席位:`init` 推断状态 schema,`actions` 是完整的 draft-transform 写入集合。组件经 selector 钩子读取、经烘焙回调写入;`defineStore` 的引擎实现位于运行时包,并满足这里导出的 `DefineStore` 约定。 diff --git a/packages/client/ui-slots/src/renderer.ts b/packages/client/ui-slots/src/renderer.ts index 2ea326cea4..e6ed27a9ef 100644 --- a/packages/client/ui-slots/src/renderer.ts +++ b/packages/client/ui-slots/src/renderer.ts @@ -61,8 +61,12 @@ export interface StoreInstanceLike { readonly actions: Record void> } -/** Resolve one member of an open-key standard hook family. */ -export type KeyedStandardSource = (key: string) => HostObservable | undefined +/** + * Resolve one member of an open-key standard hook family. `member` is present + * only when the Hook is called as `useName(key, member)`; the resolver returns + * the same source object for the same arguments while that source is live. + */ +export type KeyedStandardSource = (key: string, member?: string) => HostObservable | undefined /** * Framework-neutral inputs from which the renderer materializes standard @@ -74,7 +78,11 @@ export interface StandardSourceBinding { readonly key: string | undefined /** Fixed-name sources; each `name` becomes a `useName` selector Hook. */ readonly hooks: Readonly | undefined>> - /** Open-key source families; each `name` becomes a `useName(key, selector)` Hook. */ + /** + * Open-key source families; each `name` becomes a `useName(key, selector?, equal?)` + * Hook, which also accepts `useName(key, member)` to read the resolver's + * `(key, member)` source without a selector. + */ readonly keyedHooks: Readonly> /** Stable plain values copied into standard props. */ readonly props: Readonly> diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index dac2d9e053..ed5b4043fa 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' -import { makeTranslate, RemoteError, sessionSnapshot } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, makeTranslate, RemoteError, sessionSnapshot } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState, SessionSummary, SessionSnapshot, } from '@deepseek-ai/dsh-api-session-controller/client' @@ -94,6 +94,7 @@ function props( useSessionRetainInfo: unused, useWorkspaces: unused, useResource: unused, + ...allowAllAccess, useProjection: unused, useConversation: unused, useInput: unused, diff --git a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx index 75fcdcd0d2..d7d2afa0d2 100644 --- a/packages/client/ui-theme/tests/appearance-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.client.spec.tsx @@ -5,7 +5,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { AppearanceRow } from '../src/client/AppearanceRow.tsx' import type { AppearanceRowComponentProps } from '../src/client/AppearanceRow.tsx' import { createAppearanceRowStore } from '../src/client/settings-store.ts' @@ -50,6 +50,7 @@ function mount(preference: ThemePreference = 'system') { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useStore: bindSnapshotSelector(store), actions: store.actions, t: (key: string) => COPY[key] ?? key, diff --git a/packages/client/ui-theme/tests/font-family-row.client.spec.tsx b/packages/client/ui-theme/tests/font-family-row.client.spec.tsx index 1fc4d77a32..a2ca325a7e 100644 --- a/packages/client/ui-theme/tests/font-family-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-family-row.client.spec.tsx @@ -8,7 +8,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { FontFamilyRow } from '../src/client/FontFamilyRow.tsx' import type { FontFamilyRowComponentProps } from '../src/client/FontFamilyRow.tsx' import { createFontRowStore } from '../src/client/settings-store.ts' @@ -52,6 +52,7 @@ function mount(kind: FontRole = 'code', code = '') { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useStore: bindSnapshotSelector(store), actions: store.actions, t: (key: string) => COPY[key] ?? key, diff --git a/packages/client/ui-theme/tests/font-settings-group.client.spec.tsx b/packages/client/ui-theme/tests/font-settings-group.client.spec.tsx index f9c823be8d..947c099e20 100644 --- a/packages/client/ui-theme/tests/font-settings-group.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-settings-group.client.spec.tsx @@ -8,7 +8,7 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { FontSettingsGroup } from '../src/client/FontSettingsGroup.tsx' import type { FontSettingsGroupProps } from '../src/client/FontSettingsGroup.tsx' import { createFontRowStore } from '../src/client/settings-store.ts' @@ -55,6 +55,7 @@ function mount() { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useStore: bindSnapshotSelector(store), actions: store.actions, t: (key: string) => COPY[key] ?? key, diff --git a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx index 0081edfea6..35b67f6205 100644 --- a/packages/client/ui-theme/tests/font-size-row.client.spec.tsx +++ b/packages/client/ui-theme/tests/font-size-row.client.spec.tsx @@ -7,7 +7,7 @@ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' -import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { FontSizeRow } from '../src/client/FontSizeRow.tsx' import type { FontSizeRowComponentProps } from '../src/client/FontSizeRow.tsx' import { createFontRowStore } from '../src/client/settings-store.ts' @@ -55,6 +55,7 @@ function mount(fontSize = 14, role: FontRole = 'text') { useSessionStatus, usePanelInfo, useSessionRetainInfo: () => undefined, useResource, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useStore: bindSnapshotSelector(store), actions: store.actions, t: (key: string) => COPY[key] ?? key, diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 2f5dd4a45e..1c6e39f0a1 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -11,7 +11,7 @@ import type { GlobalStandardProps, PropsRenderSlots } from '@deepseek-ai/dsh-cli import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { createElement, type ComponentProps, type FC, type ReactNode } from 'react' -import { bindSnapshotSelector, SlotTestRuntime, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, SlotTestRuntime, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { EMPTY_CONVERSATION_SNAPSHOT, UiConversation, @@ -235,6 +235,7 @@ function standaloneProps( ), useSessionRetainInfo: () => undefined, useWorkspaces: emptyWorkspaces(), + ...allowAllAccess, useConversation: bindSnapshotSelector(createSnapshotStore(conversationSnapshot(trajectory))), useInput: bindSnapshotSelector(input), inputActions, @@ -365,6 +366,7 @@ function mount(fixture: Awaited>) { useSessionStatus, useSessionRetainInfo: () => undefined, useWorkspaces, + ...allowAllAccess, useProjection, useInput, inputActions, diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 798a2bedba..2ed8e0c27a 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -2,6 +2,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { allowAllAccess } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import { @@ -123,6 +124,7 @@ const kit: Omit = { useSessionStatus: selector => selector(attentionState), useSessionRetainInfo: () => undefined, useWorkspaces: selector => selector(workspaceState), + ...allowAllAccess, useConversation: selector => selector(conversationState), useChat: selector => selector(chatState), useTrajectory: selector => selector(trajectoryState), diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index a583a6d296..8ece0da777 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -3,6 +3,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { useSyncExternalStore } from 'react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { allowAllAccess } from '@deepseek-ai/dsh-client-test-runtime' import { ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AskUserQuestionAnswerItem } from '@deepseek-ai/dsh-user-questions/types' @@ -122,6 +123,7 @@ const kitBase: Omit selector(attentionState), useSessionRetainInfo: () => undefined, useWorkspaces: selector => selector(workspaceState), + ...allowAllAccess, useConversation: selector => selector(conversationState), useChat: selector => selector(chatState), useTrajectory: selector => selector(trajectoryState), diff --git a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx index 3fa00ccc54..c91c543c99 100644 --- a/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx +++ b/packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx @@ -20,7 +20,7 @@ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import { SessionSeq } from '@deepseek-ai/dsh-session' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { - chatSnapshot as emptyChatSnapshot, conversationSnapshot, makeTranslate, sessionSnapshot, + allowAllAccess, chatSnapshot as emptyChatSnapshot, conversationSnapshot, makeTranslate, sessionSnapshot, stubConfigForm, TestSessions, workspaceSnapshot, } from '@deepseek-ai/dsh-client-test-runtime' import { @@ -360,6 +360,7 @@ function panelProps(data: WorkflowRunChatData, sessions = listState(), openSessi submit: () => {}, }, useWorkspaces: selector => selector(panelWorkspace), + ...allowAllAccess, useTurnData: () => undefined, useDisclosure: () => { throw new Error('unused') }, useGroupAction: () => { throw new Error('unused') }, diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index eaa7774534..60a03011cf 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -33,8 +33,8 @@ en: 9fb88260380bf093 zh: 3bace2b96ecd4a82 /deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers: - en: 68423607516bb58e - zh: 550e5c55cc6a25db + en: 8d82539e4e4e890d + zh: 7b97a3927de90ea8 /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation: en: 721d5a49fe231191 zh: 3ef1dd720219e00e @@ -42,8 +42,8 @@ en: 868904dbfb5713f8 zh: 225906bfef7398bf /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/session-row-actions: - en: 24d196702ca9a5a0 - zh: 351b4896a0217ac1 + en: 5bbba6e462faf3dc + zh: 4734263208ee07c9 /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/session-row-actions/packaged-client-plugin: en: 36934671b80b11ae zh: 8e0aea6162e35e77 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index f694548654..2654447b77 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -81,6 +81,8 @@ Once both Workspace and Session startup baselines are ready, an empty installati The first-use directory name and its stored title are fixed, so neither follows the reader's language: `workspaces.initializeDefault` carries no names, and one installation keeps one on-disk path and one stored title across language switches. A Workspace still carrying that automatic title is labeled with the localized default name wherever this package shows it — sidebar rows and their hover card, search result meta, the hero picker menu, and the rename and delete dialogs — through the controller's `workspaceDisplayTitle`. The rename dialog is seeded with the label on screen while the stored title decides whether confirming saves, so confirming the untouched prefill on such a Workspace pins that name and the row stops following the language; the duplicate-title check excludes the target by Workspace identity rather than by title. +The package injects the Client `remoteAccess` service and provides the root standard Hook `useWorkspaceAccess(workspaceId, action)`, which answers whether the current Peer may call `action` on one Workspace resource; its snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). + ## Understand the implementation @@ -101,7 +103,7 @@ Each registration declares a **directory-flow child hole** (`single` kind: `conv The Session row's "..." menu and its hover buttons are two `list` slots declared by the WorkspaceBrowser entry: `sidebar.workspaces.session.menu.item` and `sidebar.workspaces.session.row.action`. Every menu row and every hover button is an entry, this package's own actions included: `apply` registers `pin` (menu 100, button 200), `rename` (200), `fork` (300), `archive` (menu 400, button 100), and `session-id` (450) the way a client plugin registers its actions, so a plugin action lands wherever its `order` says, and reusing a shipped id at another `priority` shadows that action. -An entry receives only the row identity (`sessionId`, `displayTitle`) and owns everything else. It reads the Host state it cares about through its own injected hooks (the pin and archive sets, as Sets derived once per Workspace snapshot), decides its own visibility (pin renders nothing on an archived row, because the Host keeps the two sets exclusive), carries its whole behavior in its registration's `inject` face (a pin also fronts the Session in its saved orders; an archive raises the notice), and raises its own surfaces: the rename dialog, the stop-and-archive dialog, and the row-action notice are this package's `shell.overlay` entries, fed by the requests the actions inject. A menu entry renders one `role="menuitem"` button (this package's rows use `MenuItemButton` from ui-primitives, which adds the host styling and `separatorBefore` for a group start; the hairline comes and goes with the row) and dismisses the menu through the slot-level `useMenuOpenState` hook, the menu's own open state bound from the row's render occurrence; a hover button entry renders one icon button, and the strip keeps its clicks from opening the row. The browser passes no action callbacks to its rows; its remaining verbs are the search results' restore button and the title double-click, which raises the same rename request. +An entry receives only the row identity (`sessionId`, `displayTitle`) and owns everything else. It reads the Host state it cares about through its own injected hooks (the pin and archive sets, as Sets derived once per Workspace snapshot), decides its own visibility (pin renders nothing on an archived row, because the Host keeps the two sets exclusive, and fork renders nothing when `useSessionAccess(sessionId, 'session/fork')` is false), carries its whole behavior in its registration's `inject` face (a pin also fronts the Session in its saved orders; an archive raises the notice), and raises its own surfaces: the rename dialog, the stop-and-archive dialog, and the row-action notice are this package's `shell.overlay` entries, fed by the requests the actions inject. A menu entry renders one `role="menuitem"` button (this package's rows use `MenuItemButton` from ui-primitives, which adds the host styling and `separatorBefore` for a group start; the hairline comes and goes with the row) and dismisses the menu through the slot-level `useMenuOpenState` hook, the menu's own open state bound from the row's render occurrence; a hover button entry renders one icon button, and the strip keeps its clicks from opening the row. The browser passes no action callbacks to its rows; its remaining verbs are the search results' restore button and the title double-click, which raises the same rename request. The existing notice overlay dispatches `workspace.toast` by notice kind. Keyed registrations inject localized display mappings and their own actions; only archive notices subscribe to the viewing filter. They render the package-local `workspace.toast.content` Factory, which reuses the Toast primitive once for all presentations. The existing notice state, display sequence, latest-notice replacement, and dismissal remain shared; locale or filter changes do not restart the same notice's timer. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 920562c4e7..c655995453 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -81,6 +81,8 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works 首次使用的目录名与其存储标题都是固定的,都不跟随读者语言:`workspaces.initializeDefault` 不携带任何名称,同一安装环境在切换语言后仍只有一个磁盘路径和一个存储标题。仍保留该自动标题的工作区,在本包展示它的所有位置——侧栏行及其悬浮卡片、搜索结果元信息、hero 选择器菜单、重命名与删除对话框——都通过控制器的 `workspaceDisplayTitle` 显示为本地化的默认名称。重命名对话框以屏幕上的标签作为初值,而判断确认是否需要保存用的是存储标题,因此对这类工作区直接确认未改动的初值即可固定该名称,该行不再跟随语言;重名检查按工作区身份而非标题排除目标自身。 +本包注入 Client `remoteAccess` 服务,并提供根作用域标准 Hook `useWorkspaceAccess(workspaceId, action)`,回答当前 Peer 能否在某个 Workspace 资源上调用 `action`;其快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 + ## 理解实现 @@ -101,7 +103,7 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works Session 行的 "..." 菜单和行尾悬停按钮是 WorkspaceBrowser 注册项声明的两个 `list` slot:`sidebar.workspaces.session.menu.item` 与 `sidebar.workspaces.session.row.action`。每一个菜单行、每一个悬停按钮都是条目,本包自己的 action 也不例外:`apply` 以客户端插件注册自己 action 的同一方式注册 `pin`(菜单 100、按钮 200)、`rename`(200)、`fork`(300)、`archive`(菜单 400、按钮 100)和 `session-id`(450),因此插件 action 落在其 `order` 所指的位置,以另一个 `priority` 复用内置 id 则遮蔽该 action。 -条目只接收行身份(`sessionId`、`displayTitle`),其余一切自己负责:用自己注入的 hook 读自己关心的 Host 状态(置顶与归档集合,以每次 Workspace 快照只派生一次的 Set 形式),自己决定是否显示(Host 规定归档与置顶互斥,所以 pin 在归档行上不渲染),整套行为放在注册项自己的 `inject` face 里(置顶成功后顺带把会话推到保存顺序最前,归档成功后发提示),浮层也自己带——重命名对话框、停止并归档对话框和行 action 的提示是本包注册在 `shell.overlay` 的条目,由 action 注入的请求驱动。菜单条目渲染一个 `role="menuitem"` 的按钮(本包自己的行用 ui-primitives 的 `MenuItemButton`,它带宿主样式,开启新分组的行加 `separatorBefore`,分隔线随行一起出现和消失),并通过 slot 级 `useMenuOpenState` hook(菜单自身的打开状态,从该行的渲染出现处绑定)关闭菜单;悬停按钮条目渲染一个图标按钮,按钮条会拦住点击、不让它打开该行。browser 不再向行传任何 action 回调,它剩下的动作只有搜索结果里的恢复按钮和标题双击,后者发出的是同一个重命名请求。 +条目只接收行身份(`sessionId`、`displayTitle`),其余一切自己负责:用自己注入的 hook 读自己关心的 Host 状态(置顶与归档集合,以每次 Workspace 快照只派生一次的 Set 形式),自己决定是否显示(Host 规定归档与置顶互斥,所以 pin 在归档行上不渲染;`useSessionAccess(sessionId, 'session/fork')` 为 false 时 fork 不渲染),整套行为放在注册项自己的 `inject` face 里(置顶成功后顺带把会话推到保存顺序最前,归档成功后发提示),浮层也自己带——重命名对话框、停止并归档对话框和行 action 的提示是本包注册在 `shell.overlay` 的条目,由 action 注入的请求驱动。菜单条目渲染一个 `role="menuitem"` 的按钮(本包自己的行用 ui-primitives 的 `MenuItemButton`,它带宿主样式,开启新分组的行加 `separatorBefore`,分隔线随行一起出现和消失),并通过 slot 级 `useMenuOpenState` hook(菜单自身的打开状态,从该行的渲染出现处绑定)关闭菜单;悬停按钮条目渲染一个图标按钮,按钮条会拦住点击、不让它打开该行。browser 不再向行传任何 action 回调,它剩下的动作只有搜索结果里的恢复按钮和标题双击,后者发出的是同一个重命名请求。 现有通知浮层按通知 kind 分派 `workspace.toast`。各 keyed 注册注入本地化展示映射与自己的操作,只有归档通知订阅查看筛选状态。它们渲染包内的 `workspace.toast.content` Factory,所有展示共用一处 Toast primitive。现有通知状态、显示序号、最新提示替换及关闭逻辑仍共享;语言或筛选变化不会重新启动同一条提示的计时。 diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index c2d44e8939..e88301dcdf 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -29,6 +29,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-remote-access", "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-api-workspace-controller", @@ -53,6 +54,7 @@ "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 04c4e5da2a..cc9da072c9 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -20,8 +20,9 @@ import type { Context } from '@deepseek-ai/cordis' import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { ISessions, SessionForkError } from '@deepseek-ai/dsh-api-session-controller/client' import type { - IWorkspaces, SessionActivity, WorkspaceArchiveError, WorkspaceSnapshot, + IWorkspaces, SessionActivity, WorkspaceArchiveError, WorkspaceId, WorkspaceSnapshot, } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives' import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' @@ -71,6 +72,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { interface GlobalStandardProps { /** Selector hook over the pure Workspace Controller snapshot. */ useWorkspaces: SnapshotSelectorHook + /** Whether the current Peer may call `action` on one Workspace resource. */ + useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean } interface LocaleNamespaceMap { @@ -97,7 +100,7 @@ const NS = 'workspace' * declaration through `slots.inject()` instead of assuming order. */ export const inject = [ - 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', 'layout', 'shortcuts', + 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', 'layout', 'shortcuts', 'remoteAccess', ] /** @@ -121,7 +124,14 @@ export function apply(ctx: Context): void { const uiWorkspace = new UiWorkspaceService( ctx, ctx.remote.directoryPicker, workspaces, sessions, viewInstance.actions, notify, ) - ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } }) + ctx.slots.provideRoot({ + hooks: { workspaces: workspaces.list }, + keyedHooks: { + workspaceAccess: (workspaceId, action) => action === undefined + ? undefined + : ctx.remoteAccess.source('workspace', workspaceId, action), + }, + }) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries') const shortcutControls = createWorkspaceShortcutControls() diff --git a/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx b/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx index 6a2d338b13..a71dc67638 100644 --- a/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx +++ b/packages/client/ui-workspace/src/client/session-actions/ForkSession.tsx @@ -4,15 +4,18 @@ import type { ForkSessionInjected, SessionMenuItemProps } from '../contract/slot /** * Menu row (order 300): fork at the Session's last completed turn; the child - * arrives through the Host list beside its source. - * @param props - owner share, menu open state, and the fork share. - * @returns the row. + * arrives through the Host list beside its source. Absent when the access + * snapshot excludes `session/fork` for this Session. + * @param props - owner share, menu open state, access Hook, and the fork share. + * @returns the row, or nothing when the caller may not fork this Session. */ export function ForkSessionMenuItem({ - sessionId, useMenuOpenState, useShortcuts, forkSession, t, + sessionId, useMenuOpenState, useShortcuts, useSessionAccess, forkSession, t, }: SessionMenuItemProps) { const [, setMenuOpen] = useMenuOpenState() const shortcut = useShortcuts(rows => rows.find(row => row.id === 'session.fork')) + const forkAllowed = useSessionAccess(sessionId, 'session/fork') + if (!forkAllowed) return null return ( = createSnapshotStore(true) + async function bench() { const ctx = new Context() const commands = new Map() @@ -66,6 +68,8 @@ async function bench() { }) ctx.provide('uiConversation', {}) await ctx.plugin(SlotRegistry).await() + const accessSource = vi.fn((_kind: string, _id: string, _action: string): HostObservable => ALLOWED) + ctx.provide('remoteAccess', { source: accessSource }) const create = vi.fn(async (input: { name: string } | { path: string }) => ({ workspaceId: 'ws-new' as never, path: 'name' in input ? `/projects/${input.name}` : input.path, @@ -158,7 +162,7 @@ async function bench() { locale.setLocale('zh') ctx.provide('locale', locale) return { - ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, commands, + ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, commands, accessSource, retain, using, selectPanel, search, renameSession, binding, fork, pickDirectory, pinSession, unpinSession, workspacesSubscribe, initializeDefault, history, scope, sessionOf, setWorkspaces: (snapshot: WorkspaceSnapshot): void => { workspaceSnapshot = snapshot }, @@ -215,10 +219,22 @@ describe('ui-workspace apply', () => { it('declares the services it drives', () => { expect(inject).toEqual([ - 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', 'layout', 'shortcuts', + 'slots', 'sessions', 'workspaces', 'locale', 'remote', 'remote.directoryPicker', 'layout', 'shortcuts', 'remoteAccess', ]) }) + it('publishes the Workspace access Hook over the remoteAccess service', async () => { + const b = await bench() + onTestFinished(() => b.ctx.fiber.dispose()) + const provideRoot = vi.spyOn(b.slots, 'provideRoot') + await b.ctx.plugin({ inject: [...inject], apply }).await() + const keyed = provideRoot.mock.calls.map(([contribution]) => contribution.keyedHooks?.['workspaceAccess']).find(Boolean) + // `useWorkspaceAccess(workspaceId, action)` reads the Workspace resource's answer; a call without an action names no source. + expect(keyed?.('w1', 'workspace/follow')).toBe(ALLOWED) + expect(b.accessSource).toHaveBeenCalledWith('workspace', 'w1', 'workspace/follow') + expect(keyed?.('w1')).toBeUndefined() + }) + it('reports a default Workspace creation failure through the shared notice overlay', async () => { const b = await bench() onTestFinished(() => b.ctx.fiber.dispose()) diff --git a/packages/client/ui-workspace/tests/session-actions.client.spec.tsx b/packages/client/ui-workspace/tests/session-actions.client.spec.tsx index 35e40222e2..1b7533e4dc 100644 --- a/packages/client/ui-workspace/tests/session-actions.client.spec.tsx +++ b/packages/client/ui-workspace/tests/session-actions.client.spec.tsx @@ -15,7 +15,7 @@ import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/c import type { SessionId } from '@deepseek-ai/dsh-session/types' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { GlobalStandardProps, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { bindSnapshotSelector, makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { registerToasts } from '../src/client/toast/register.ts' import { ArchiveToastEntry } from '../src/client/toast/Toast.tsx' @@ -84,6 +84,7 @@ const standard: GlobalStandardProps = { usePanelInfo, useResource, useWorkspaces: hook(workspaces), + ...allowAllAccess, } type MenuRowProps = PropsRuntime<'sidebar.workspaces.session.menu.item'> & PropsLocale<'workspace'> @@ -237,6 +238,16 @@ describe('fork and rename rows', () => { expect(callOrder(setMenuOpen)).toBeLessThan(callOrder(forkSession)) }) + it('omits the fork row when the access snapshot excludes session/fork for this Session', () => { + const { state } = openMenu() + const useSessionAccess = vi.fn(() => false) + const { container } = render( + , + ) + expect(container.innerHTML).toBe('') + expect(useSessionAccess).toHaveBeenCalledWith(sid('one'), 'session/fork') + }) + it('rename closes the menu, then asks for the dialog with the row title', () => { const { state, setMenuOpen } = openMenu() const requestSessionRename = vi.fn() diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index 544ddd5140..4b62826cbc 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, createEvent, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceId, WorkspaceSnapshot, WorkspaceView, @@ -122,6 +122,7 @@ function mount(overrides: Partial = {}) { useSessionRetainInfo: () => undefined, usePanelInfo, useResource, useWorkspaces: hook(workspaceState([])), + ...allowAllAccess, useStore: bindSnapshotSelector(store), actions: store.actions, startSession: vi.fn(), diff --git a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx index 4bb54db303..df9149403c 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.client.spec.tsx @@ -7,7 +7,7 @@ import type { WorkspaceId, WorkspaceSnapshot, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' import type {} from '@deepseek-ai/dsh-client-locale/client' -import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client' import type { DirectoryFlowOwnerProps, WorkspacePickerProps } from '../src/client/contract/slots.ts' @@ -102,6 +102,7 @@ function mount( useSessionRetainInfo={() => undefined} usePanelInfo={usePanelInfo} useResource={useResource} useWorkspaces={hook(workspaceState(nextItems))} + {...allowAllAccess} onPick={onPick} onClose={onClose} createWorkspace={createWorkspace} @@ -224,6 +225,7 @@ describe('WorkspacePicker', () => { useSessionStatus={hook(noPendingInteraction)} useSessionRetainInfo={() => undefined} usePanelInfo={usePanelInfo} useResource={useResource} + {...allowAllAccess} onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t} />, @@ -242,6 +244,7 @@ describe('WorkspacePicker', () => { useSessionStatus={hook(noPendingInteraction)} useSessionRetainInfo={() => undefined} usePanelInfo={usePanelInfo} useResource={useResource} + {...allowAllAccess} onPick={vi.fn()} onClose={vi.fn()} createWorkspace={vi.fn()} useDirectoryFlow={occupancySource().useDirectoryFlow} renderSlot={renderSlot} t={t} />, diff --git a/packages/client/ui-workspace/tsconfig.json b/packages/client/ui-workspace/tsconfig.json index 0bf315d56f..dea11b7bd6 100644 --- a/packages/client/ui-workspace/tsconfig.json +++ b/packages/client/ui-workspace/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../api/workspace-controller/tsconfig.client.json" }, + { + "path": "../../api/remote-access/tsconfig.client.json" + }, { "path": "../locale/tsconfig.client.json" }, diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 54dd8a805d..dd8d09e6b7 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -258,6 +258,7 @@ export class AgentRegistry extends Service { typeCtx.typert.lookups.register('agent', { parameter: 'agent', wire: 'agentId', + resource: 'session', hostTypeSymbol: '@deepseek-ai/dsh-agent#Agent', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', resolve: sessionId => this.get(sessionId), @@ -265,6 +266,7 @@ export class AgentRegistry extends Service { typeCtx.typert.contexts.registerHost('agent', { wire: 'agentId', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', + resource: 'session', resolve: sessionId => this.get(sessionId)?.ctx, }) }) diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index c31acf5c66..21d90151cb 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -51,11 +51,13 @@ describe('AgentRegistry', () => { expect(lookup).toMatchObject({ parameter: 'agent', wire: 'agentId', + resource: 'session', hostTypeSymbol: '@deepseek-ai/dsh-agent#Agent', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', }) expect(lookup?.resolve(agent.id)).toBe(agent) const context = ctx.typert.contexts.getHost('agent') + expect(context).toMatchObject({ wire: 'agentId', resource: 'session' }) expect(context?.resolve(agent.id)).toBe(agent.ctx) await disposeAgent() diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index e97585e463..e1ecb920bd 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -1079,6 +1079,7 @@ export class SessionStore extends Service { typeCtx.typert.lookups.register('session', { parameter: 'session', wire: 'sessionId', + resource: 'session', hostTypeSymbol: '@deepseek-ai/dsh-session#Session', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', resolve: sessionId => this.get(sessionId), diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index fbc9a9cccb..e0931f2240 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -14,6 +14,7 @@ import type { ToolSchema, UserMessage, } from '@deepseek-ai/dsh-llm' +import type { TypertResource } from '@deepseek-ai/dsh-typert-protocol' import type { JsonValue } from '@deepseek-ai/dsh-util-values' /** Identifies one session in the store (and its persistence artifacts). */ @@ -549,6 +550,11 @@ export interface PluginRecord { } declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertResourceMap { + /** One Session; the Session Controller registers its default `sessionId` wire field. */ + session: TypertResource + } + interface RemoteErrorDetailsMap { /** The named Session does not exist; produced by every layer that resolves a SessionId. */ 'session/not-found': { readonly sessionId: SessionId } diff --git a/packages/core/session/tests/typert.spec.ts b/packages/core/session/tests/typert.spec.ts index 8a6e663ba5..9058f905fb 100644 --- a/packages/core/session/tests/typert.spec.ts +++ b/packages/core/session/tests/typert.spec.ts @@ -15,6 +15,7 @@ describe('Session Typert provider', () => { expect(lookup).toMatchObject({ parameter: 'session', wire: 'sessionId', + resource: 'session', hostTypeSymbol: '@deepseek-ai/dsh-session#Session', wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId', }) diff --git a/packages/experimental/client-ui-cot-translation/package.json b/packages/experimental/client-ui-cot-translation/package.json index 22125d92bd..d302906ff2 100644 --- a/packages/experimental/client-ui-cot-translation/package.json +++ b/packages/experimental/client-ui-cot-translation/package.json @@ -86,6 +86,7 @@ "react-dom": "^18.2.0", "@testing-library/react": "^16.3.3", "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", diff --git a/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts b/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts index 715e149dd8..87b2c75895 100644 --- a/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts +++ b/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '@deepseek-ai/dsh-api-session-controller' import Translator from '@deepseek-ai/dsh-experimental-translator' import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' @@ -66,6 +67,7 @@ async function fixture(origin?: 'subagent') { saveSelection: async () => {}, } as never) ctx.provide('fileUploads', { registerAgentResolver: () => () => {} } as never) + await ctx.plugin(RemoteAccessService) const sessions = new SessionController(ctx, { nativeOpen: false }) await ctx.plugin(Translator, Translator.Config({ bingEndpoint: `http://127.0.0.1:${address.port}/bing` })) const ui = ctx.plugin(CotTranslationController, {}) diff --git a/packages/experimental/inspector/tests/loader-composition.host.spec.ts b/packages/experimental/inspector/tests/loader-composition.host.spec.ts index 462a5b146a..f4972ba680 100644 --- a/packages/experimental/inspector/tests/loader-composition.host.spec.ts +++ b/packages/experimental/inspector/tests/loader-composition.host.spec.ts @@ -83,12 +83,13 @@ describe('experimental Inspector through a real Loader composition', () => { .find(entry => entry.options.name === '@deepseek-ai/dsh-experimental-inspector') expect(inspectorEntry?.disabled).toBe(false) expect(open).toHaveBeenCalledTimes(flag ? 1 : 0) - const api = (context.connection as HostConnectionService).createSharedFetchHandler('/api') + const connection = context.connection as HostConnectionService + const api = connection.createSharedFetchHandler('/api') expect(inspectorEntry?.fiber).toBeDefined() await vi.waitFor(async () => { expect((await context!.inspector.cordis.getTree()).host?.source.kind).toBe('host') }) - const bootstrap = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`)) + const bootstrap = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`), connection.browserPeer) expect(await bootstrap.json()).toHaveProperty('endpoint', expect.stringContaining('/ingest')) await inspectorEntry!.fiber!.dispose() diff --git a/packages/experimental/inspector/tests/plugin.host.spec.ts b/packages/experimental/inspector/tests/plugin.host.spec.ts index 0fe950bfed..79ba34dd6a 100644 --- a/packages/experimental/inspector/tests/plugin.host.spec.ts +++ b/packages/experimental/inspector/tests/plugin.host.spec.ts @@ -79,7 +79,7 @@ describe('experimental Inspector Host plugin', () => { const value = bootstrap?.kind === 'global' ? bootstrap.value : undefined const api = connection.createSharedFetchHandler('/api') - const fetched = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`)) + const fetched = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`), connection.browserPeer) expect(fetched.headers.get('cache-control')).toBe('no-store') expect(await fetched.json()).toEqual(value) expect(open).not.toHaveBeenCalled() @@ -115,7 +115,7 @@ describe('experimental Inspector Host plugin', () => { const afterDispose: IndexInjection[] = [] context.emit('webserver/index-inject', afterDispose) expect(afterDispose).toEqual([]) - expect((await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`))).status).toBe(404) + expect((await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`), connection.browserPeer)).status).toBe(404) }) it('closes the started Worker when a later plugin registration fails', async () => { @@ -155,7 +155,7 @@ describe('experimental Inspector Host plugin', () => { app: { name: 'chrome' }, wait: false, }) const api = connection.createSharedFetchHandler('/api') - const response = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`)) + const response = await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`), connection.browserPeer) expect(response.status).toBe(200) expect(await response.json()).toHaveProperty('endpoint', expect.stringContaining('/ingest')) }) diff --git a/packages/experimental/webworker-runtime/src/worker-host.ts b/packages/experimental/webworker-runtime/src/worker-host.ts index caca4d7bfc..39769f1871 100644 --- a/packages/experimental/webworker-runtime/src/worker-host.ts +++ b/packages/experimental/webworker-runtime/src/worker-host.ts @@ -270,12 +270,13 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { const usage = loader.usage() console.info(`webworker host: tree active (modules=${String(usage.modules)}, data overlays=${String(overlays.length)}, direct lane=connection.createSharedFetchHandler, als causality=${options.alsCausality === undefined ? 'inert' : 'snapshot/restore'}, image lowering=${LOWERING_VERSION})`) + // The worker tree serves only the page that spawned it, so every call speaks for the browser Peer. + const peer = connection.browserPeer tunnel.serve({ - directFetch: (request: Request) => handler.fetch(request), + directFetch: (request: Request) => handler.fetch(request, peer), bootPayload: () => readBootPayload(ctx), - // The worker tree serves its own page: every stream speaks for the operator. openStream: (endpoint, payload, uplink, signal) => - typertGateway.wireStream.open(endpoint, payload, uplink, undefined, signal), + typertGateway.wireStream.open(endpoint, payload, uplink, peer, signal), streamFailure: typertGateway.wireStream.failure, }) started.resolve() diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 6a1c9afeec..c4887547ab 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -661,7 +661,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'KeyedStandardSource', - declaration: 'export type KeyedStandardSource = (key: string) => HostObservable | undefined;', + declaration: 'export type KeyedStandardSource = (key: string, member?: string) => HostObservable | undefined;', }, { name: 'KeyPropsOf', diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 7ec9c04ce8..13795cd4df 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -91,13 +91,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -105,6 +107,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -151,13 +154,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'MessageId', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -165,6 +170,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -176,7 +182,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:439', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:441', }, { key: 'conversation.chat.commandview', @@ -201,13 +207,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'CompactionSummaryNode', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -215,6 +223,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', @@ -224,7 +233,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:421', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:423', }, { key: 'conversation.chat.flow', @@ -242,13 +251,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'RenderEntry', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -256,6 +267,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -267,7 +279,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.flow\', () => ctx.slots.register(\n { name: \'conversation.chat.flow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:388', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:390', }, { key: 'conversation.chat.message.images', @@ -285,13 +297,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'MessageImageSource', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -299,6 +313,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -310,7 +325,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.message.images\', () => ctx.slots.register(\n { name: \'conversation.chat.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:413', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:415', }, { key: 'conversation.chat.node', @@ -337,13 +352,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'TurnTailOwnerProps', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -351,6 +368,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, question-reply, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, turn-trigger, unknown, user, workflow-run', @@ -380,7 +398,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:400', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:402', }, { key: 'conversation.chat.reasoning.body', @@ -394,13 +412,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -408,6 +428,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -420,7 +441,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.reasoning.body\', () => ctx.slots.register(\n { name: \'conversation.chat.reasoning.body\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:427', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:429', }, { key: 'conversation.chat.turnTail', @@ -455,13 +476,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'TurnLocation', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -469,6 +492,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -482,7 +506,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:433', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:435', }, { key: 'conversation.chat.user.images', @@ -500,13 +524,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'MessageImageSource', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -514,6 +540,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -525,7 +552,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.user.images\', () => ctx.slots.register(\n { name: \'conversation.chat.user.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:415', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:417', }, { key: 'conversation.composer', @@ -550,13 +577,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'SessionSnapshot', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -564,6 +593,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -593,19 +623,22 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'Workspace', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useConversation: MaybeSnapshotSelectorHook', 'useInput: MaybeSnapshotSelectorHook', 'inputActions: InputActions | undefined', 'useSession: MaybeSnapshotSelectorHook', 'sessionId: SessionId | undefined', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean | undefined', ], keyDomain: '', hookContext: '', @@ -647,13 +680,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -661,6 +696,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -685,19 +721,22 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useConversation: MaybeSnapshotSelectorHook', 'useInput: MaybeSnapshotSelectorHook', 'inputActions: InputActions | undefined', 'useSession: MaybeSnapshotSelectorHook', 'sessionId: SessionId | undefined', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean | undefined', ], keyDomain: '', hookContext: '', @@ -722,13 +761,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -751,19 +792,22 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useConversation: MaybeSnapshotSelectorHook', 'useInput: MaybeSnapshotSelectorHook', 'inputActions: InputActions | undefined', 'useSession: MaybeSnapshotSelectorHook', 'sessionId: SessionId | undefined', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean | undefined', ], keyDomain: '', hookContext: '', @@ -788,13 +832,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -819,13 +865,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'Workspace', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -850,13 +898,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -884,13 +934,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'InputControlOwnerProps', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -898,6 +950,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -927,19 +980,22 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'DraftFileUploads', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useConversation: MaybeSnapshotSelectorHook', 'useInput: MaybeSnapshotSelectorHook', 'inputActions: InputActions | undefined', 'useSession: MaybeSnapshotSelectorHook', 'sessionId: SessionId | undefined', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean | undefined', ], keyDomain: '', hookContext: '', @@ -986,13 +1042,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'SessionSnapshot', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1000,6 +1058,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1045,13 +1104,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1059,6 +1120,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1082,13 +1144,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1096,6 +1160,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1138,13 +1203,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1152,6 +1219,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1179,13 +1247,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1193,6 +1263,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1218,13 +1289,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1232,6 +1305,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1274,13 +1348,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1288,6 +1364,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1330,13 +1407,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1344,6 +1423,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1367,13 +1447,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1381,6 +1463,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1404,13 +1487,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1418,6 +1503,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1462,13 +1548,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1476,6 +1564,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1504,13 +1593,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1518,6 +1609,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1545,13 +1637,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'SessionId', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1559,6 +1653,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1603,13 +1698,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1617,6 +1714,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1648,13 +1746,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'MessageImageSource', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1662,6 +1762,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1694,13 +1795,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1708,6 +1811,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'fixed by the owner\'s key table { [Id in UserTextId]: { node: UserTextNode } }, already taken: command, file, folder, secret, session, skill, text', @@ -1760,13 +1864,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'ConversationViewRequest', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1774,6 +1880,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1820,13 +1927,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1834,6 +1943,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1880,13 +1990,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'SlotEntryDef', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -1894,6 +2006,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -1924,13 +2037,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: conversation', hookContext: '', @@ -1955,19 +2070,22 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useConversation: MaybeSnapshotSelectorHook', 'useInput: MaybeSnapshotSelectorHook', 'inputActions: InputActions | undefined', 'useSession: MaybeSnapshotSelectorHook', 'sessionId: SessionId | undefined', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean | undefined', ], keyDomain: '', hookContext: '', @@ -2011,13 +2129,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2049,13 +2169,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-experimental-voice-input-bundle', hookContext: '', @@ -2089,13 +2211,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'ConfigPageForm', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: @deepseek-ai/dsh-experimental-cot-translation-bundle, @deepseek-ai/dsh-experimental-voice-input-bundle', hookContext: '', @@ -2142,13 +2266,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'PluginsSubject', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2192,13 +2318,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'PluginsSubject', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2242,13 +2370,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'PluginsSubject', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2292,13 +2422,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'ConfigPageForm', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2335,13 +2467,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'ConfigPageForm', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', hookContext: '', @@ -2364,13 +2498,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2398,13 +2534,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'Branded', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -2412,6 +2550,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -2437,13 +2576,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2487,13 +2628,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2518,13 +2661,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2568,13 +2713,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2610,13 +2757,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2641,13 +2790,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2691,13 +2842,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2729,13 +2882,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'ProviderDirectoryEntry', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', hookContext: '', @@ -2756,13 +2911,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2806,13 +2963,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2857,13 +3016,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2907,13 +3068,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2942,13 +3105,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -2971,13 +3136,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3000,13 +3167,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3048,13 +3217,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3097,13 +3268,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'QuotaNoticeCode', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3114,7 +3287,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.quota-notice\', () => ctx.slots.register(\n { name: \'shell.quota-notice\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:447', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:449', }, { key: 'sidebar', @@ -3128,13 +3301,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3159,13 +3334,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3190,13 +3367,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3219,13 +3398,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3233,6 +3414,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -3277,13 +3459,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3327,13 +3511,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -3364,13 +3550,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3378,6 +3566,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', @@ -3417,13 +3606,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3431,6 +3622,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', @@ -3471,13 +3663,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'DocumentContent', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3485,6 +3679,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', @@ -3526,13 +3721,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'DocumentTextPage', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3540,6 +3737,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', @@ -3582,13 +3780,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3596,6 +3796,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -3630,13 +3831,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'DocumentContent', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3644,6 +3847,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', @@ -3686,13 +3890,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3700,6 +3906,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -3742,13 +3949,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3756,6 +3965,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -3786,13 +3996,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3800,6 +4012,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -3830,13 +4043,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3844,6 +4059,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', @@ -3890,13 +4106,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'TabRecord', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -3904,6 +4122,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -3948,13 +4167,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'SessionId', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -4000,13 +4221,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'SessionId', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -4031,13 +4254,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -4060,13 +4285,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -4091,13 +4318,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -4122,13 +4351,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], ownerPropsReferences: [], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -4175,13 +4406,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'SessionId', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: 'MenuOpenState', @@ -4231,13 +4464,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'SessionId', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: '', hookContext: '', @@ -4266,13 +4501,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'MessageImageSource', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -4280,6 +4517,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: '', @@ -4321,13 +4559,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'UseDisclosure', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -4335,6 +4575,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: ask_user_question, bash, cordis_define, cordis_inspect_list, cordis_inspect_query, cordis_inspect_self, cordis_run, cordis_stop, cordis_undefine, create_goal, edit, get_goal, glob, grep, interrupt_agent, job_kill, job_list, job_output, list_agents, list_subagent_models, lsp, present, ralph, read, read_image, schedule_create, schedule_delete, schedule_list, schedule_update, send_message, session_event_read, session_event_search, session_event_trace, session_search, session_trace, skill, spawn_teammate, subagent, team_task_create, team_task_get, team_task_list, team_task_update, terminal_close, terminal_list, terminal_open, terminal_read, terminal_signal, todo_write, update_goal, wait_agent, web_fetch, web_search, workflow, write', @@ -4424,13 +4665,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'CordisDynamicPluginRunId', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', 'useChat: UseChat', 'useConversation: UseConversation', 'useInput: SnapshotSelectorHook', @@ -4438,6 +4681,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useSession: SessionSnapshotSelector', 'sessionId: SessionId', 'useProjection: UseProjection', + 'useAccess: (action: RemoteEndpoint) => boolean', 'useTrajectory: UseTrajectory', ], keyDomain: 'open: any string the owner dispatches (no compile-time key set), none are taken yet', @@ -4470,13 +4714,15 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'Workspace', ], standardProps: [ + 'useRemoteAccess: (action: RemoteEndpoint) => boolean', 'useResource: UseResource', 'useWorkspaces: SnapshotSelectorHook', 'usePanelInfo: UsePanelInfo', 'useSessions: UseSessions', 'useSessionStatus: UseSessionStatus', 'useSessionRetainInfo: UseSessionRetainInfo', - 'useWorkspaces: SnapshotSelectorHook', + 'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean', + 'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean', ], keyDomain: 'fixed by the owner\'s key table { [K in ToastKind]: ToastNotice }, already taken: archived, archivedNotOpenable, createFailed, defaultWorkspaceFailed, forkRequiresOpen, pinFailed, sessionIdCopied, sessionIdCopyFailed, shortcutForkFailed, stoppedAndArchived, unpinFailed', hookContext: '', diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json index 986c902a48..98bdaccdea 100644 --- a/packages/extensions/cordis-host-runner/package.json +++ b/packages/extensions/cordis-host-runner/package.json @@ -65,6 +65,7 @@ "@deepseek-ai/cordis-plugin-timer": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", diff --git a/packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts b/packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts index 321bc4cf13..2228fe462c 100644 --- a/packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts +++ b/packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts @@ -1,6 +1,8 @@ import { Context } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import TypertGateway from '@deepseek-ai/dsh-api-gateway' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' +import type { PeerId, PeerScope } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import DynamicCordisRunnerService from '../src/index.ts' @@ -77,12 +79,14 @@ function answer(query: ReturnType) { async function connectGatewayClient(): Promise { await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGateway) ctx.effect(() => ctx.typertGateway.registerRemoteEvents(async function* (signal) { await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) }, { home: '/fixture' })) const connection = new AbortController() - const stream = await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), undefined, connection.signal) + const peer: PeerScope = { id: 'inspect-registry-owner' as PeerId, ctx, principal: { kind: 'owner' }, signal: connection.signal, dispose: () => Promise.resolve() } + const stream = await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), peer, connection.signal) const client = stream[Symbol.asyncIterator]() ctx.effect(() => async () => { connection.abort() @@ -106,6 +110,7 @@ describe('Client inspect completion', () => { it('does not dispatch a query when the Gateway has no connected Client', async () => { await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGateway) const query = start() expect(requests).toEqual([]) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index ae9f632f95..62787c4b5d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -754,8 +754,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], }, { - signature: 'readonly operator: PeerScope', - description: 'The operator Peer every admitted request speaks for; its scope lives as long as Connection.', + signature: 'readonly browserPeer: PeerScope', + description: 'The Peer every authenticated browser connection shares; its principal is the owner and its scope lives as long as Connection.', parameters: [], }, { @@ -777,9 +777,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'admit(request: ConnectionTrustRequest): PeerAdmission', - description: 'Admit one request: it passes requestRejection and speaks for the operator, or it is refused with that status.', + description: 'Admit one network request: it passes requestRejection and speaks for browserPeer, or it is refused with that status.', parameters: [{ name: 'request', description: 'request headers from the HTTP or upgrade request.' }], - returns: 'the operator Peer, or the rejection status.', + returns: 'the admitted Peer, or the rejection status.', + }, + { + signature: 'createPeer(options: { readonly principal: Principal }): PeerScope', + description: 'Create an independent Peer. In-process only: no route, header, query, or cookie reaches it. Disposed with the Context that read `ctx.connection`, or earlier by its own `dispose()`.', + parameters: [{ name: 'options', description: '.principal - principal fixed for the Peer\'s lifetime.' }], + returns: 'a new Peer distinct from every other Peer.', }, { signature: 'authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean', @@ -1873,6 +1879,49 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'remoteAccess', + summary: 'Registry of access policies, the Gateway\'s access decisions, list filtering, and the per-Peer access snapshot.', + description: 'Registry of access policies, the Gateway\'s access decisions, list filtering, and the per-Peer access snapshot. Every composition that mounts the Gateway mounts this service too.', + methods: [ + { + signature: 'registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void', + description: 'Register the policy of one non-owner principal kind in the caller\'s fiber; open snapshots are recomputed on registration and removal.', + parameters: [{ name: 'kind', description: 'the principal kind the policy serves; never `owner`.' }, { name: 'policy', description: 'business-owned bindings and refusal reasons for principals of `kind`.' }], + returns: 'disposer removing this policy.', + throws: ['Error when `kind` already has a policy.'], + }, + { + signature: 'invalidate(principal?: Principal): void', + description: 'Report that the bindings or condition answers for `principal` may have changed. Business packages call this on every grant, revocation, mode change, and expiry; open snapshots re-push, and the next failure of each policy is logged again.', + parameters: [{ name: 'principal', description: 'affected principal; omitted for every principal.' }], + }, + { + signature: 'decide(request: RemoteAccessRequest): RemoteAccessDecision', + description: 'Gateway-only: decide one request whose resources the Gateway resolved. The owner is allowed, and so is `remoteAccess/follow`: every Peer reads its own snapshot. Otherwise the action is allowed only when, on every listed resource, some binding of the principal\'s policy names that resource, has a role listing the action, and has no condition or a condition that allows. `bindings()` is called once per decision.', + parameters: [{ name: 'request', description: 'the caller or recipient, the action, the resources it touches, and a call\'s arguments.' }], + returns: '`allow`; or `deny` with `no-allow` when the principal kind has no policy, the first refusing condition\'s reason on the first resource no binding allows, else `explain()`\'s reason, else `no-allow`; `policy-failed` when `bindings()`, a condition, or `explain()` throws, logged once per policy until the next `invalidate()`. The Gateway throws `remote-access/denied` for a denied call and drops a denied event.', + }, + { + signature: 'allows(resource: RemoteAccessResource): boolean', + description: 'Filter items inside a Remote method. The invocation is read from the Context that accessed this service, so call it as `this.ctx.remoteAccess.allows(resource)` from the Remote method. Each check reads the policy\'s bindings again, so a long-running stream sees binding changes from the next check on.', + parameters: [{ name: 'resource', description: 'resource of one result item.' }], + returns: 'true for the owner and outside a Remote call; otherwise whether the invocation\'s endpoint action is allowed on `resource` without call arguments, false when the policy is absent or fails.', + }, + { + signature: 'snapshotFor(principal: Principal): RemoteAccessBaseline', + description: 'The baseline a page opens with before its snapshot stream connects. A business package rendering a non-owner page assigns this value to REMOTE_ACCESS_SNAPSHOT_KEY after every index injection row, replacing the owner baseline the webServer index carries.', + parameters: [{ name: 'principal', description: 'the page\'s caller identity.' }], + returns: 'the owner baseline for the owner; otherwise the principal\'s current grants.', + }, + { + signature: '@Remote({ mode: \'stream\' }) async *follow(signal: AbortSignal): AsyncIterable', + description: 'Stream the calling Peer\'s access snapshot: one baseline, then `upsert` and `remove` frames after each `invalidate()` and each policy registration or removal that changes a grant. The owner receives only `{ owner: true }`. The generation ends when `signal` aborts or this service unloads, so a reloaded service starts every reader from a fresh baseline.', + parameters: [{ name: 'signal', description: 'generation cancellation.' }], + returns: 'the access frames of one generation.', + }, + ], + }, { key: 'sandbox', summary: 'Abstract process-sandbox service.', @@ -1983,8 +2032,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'visible Session summaries ordered by activity.', }, { - signature: '@Remote(\'search\') search(request: SessionSearchRequest, signal: AbortSignal): Promise', - description: 'Search visible Session content without resuming an Agent.', + signature: '@Remote(\'search\') async search(request: SessionSearchRequest, signal: AbortSignal): Promise', + description: 'Search visible Session content without resuming an Agent. A non-owner receives only the hits whose Session `allows()` admits; `hasMore` describes the bounded result before that filtering.', parameters: [{ name: 'request', description: 'literal message-content query.' }, { name: 'signal', description: 'cancellation for list and search reads.' }], returns: 'authorized bounded Session search results.', }, @@ -2051,7 +2100,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the new Session identity.', }, { - signature: '@Remote(\'prompt\') prompt(request: SessionPromptRequest, signal: AbortSignal): Promise', + signature: '@Remote(\'prompt\') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise', description: 'Admit one prompt after explicitly resuming its Session.', parameters: [{ name: 'request', description: 'Session identity, prompt content, source metadata, and delivery mode.' }, { name: 'signal', description: 'caller cancellation before prompt admission begins.' }], returns: 'acknowledgement that the Agent accepted the prompt.', @@ -2075,13 +2124,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'acknowledgement that cancellation was requested.', }, { - signature: '@Remote(\'page\') page(request: SessionPageRequest, signal: AbortSignal): Promise', + signature: '@Remote({ name: \'page\', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise', description: 'Read one cold-safe, message-aligned Session history page.', parameters: [{ name: 'request', description: 'durable address, backward cursor, and page budget.' }, { name: 'signal', description: 'cancellation for persistence reads.' }], returns: 'one chronological page.', }, { - signature: '@Remote({ mode: \'stream\' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable', + signature: '@Remote({ mode: \'stream\', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable', description: 'Follow one Session log from its opening or resume cursor.', parameters: [{ name: 'request', description: 'durable address and last committed sequence already held by the caller.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], returns: 'a complete opening snapshot followed by gap-free durable event frames and optional cursorless assistant-stream frames.', @@ -2093,7 +2142,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'a sequenced baseline, cached hints when migration is deferred, or null when absent.', }, { - signature: '@Remote({ mode: \'stream\' }) control(signal: AbortSignal): AsyncIterable', + signature: '@Remote({ mode: \'stream\' }) async *control(signal: AbortSignal): AsyncIterable', description: 'Stream a complete live-control baseline followed by replacement frames.', parameters: [{ name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }], returns: 'one complete baseline followed by live replacement frames.', @@ -3459,9 +3508,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'hasLiveClient(): boolean', - description: 'Check for an active Client event stream.', + description: 'Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls.', parameters: [], - returns: 'whether a stream is open and has not been cancelled.', + returns: 'whether an owner stream is open and has not been cancelled.', }, { signature: 'registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo, ): () => Promise', @@ -3768,8 +3817,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the complete resulting pin set, most recently pinned first.', }, { - signature: '@Remote({ mode: \'stream\' }) follow(signal: AbortSignal): AsyncIterable', - description: 'Stream a complete Workspace baseline followed by ordered increments.', + signature: '@Remote({ mode: \'stream\' }) async *follow(signal: AbortSignal): AsyncIterable', + description: 'Stream a complete Workspace baseline followed by ordered increments. A non-owner receives only the Workspaces and Sessions `allows()` admits. When a delivered Workspace is no longer admitted at its next change or the next order change, the stream sends a `replace` frame with the complete filtered state instead of a `remove`, because a Client never resurrects a removed Workspace; a `remove` frame reports a deletion and reaches only a Client that received that Workspace.', parameters: [{ name: 'signal', description: 'generation cancellation.' }], returns: 'baseline followed by ordered Workspace increments.', }, @@ -4993,7 +5042,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConnectionFetchHandler', - declaration: 'export interface ConnectionFetchHandler {\n requestBodyMode(request: {\n readonly method: string;\n readonly url: URL;\n }): ConnectionRequestBodyMode;\n fetch(request: Request): Promise;\n}', + declaration: 'export interface ConnectionFetchHandler {\n requestBodyMode(request: {\n readonly method: string;\n readonly url: URL;\n }): ConnectionRequestBodyMode;\n fetch(request: Request, peer: PeerScope): Promise;\n}', }, { name: 'ConnectionFetchMethod', @@ -5645,7 +5694,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'InvokeRemoteRequest', - declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n readonly uplink?: AsyncIterable;\n readonly uplinkWindow?: RemoteUplinkWindow | undefined;\n readonly peer?: PeerScope;\n readonly signal?: AbortSignal;\n}', + declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n readonly uplink?: AsyncIterable;\n readonly uplinkWindow?: RemoteUplinkWindow | undefined;\n readonly peer: PeerScope;\n readonly signal?: AbortSignal;\n}', }, { name: 'JobAppendOptions', @@ -6185,7 +6234,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PeerScope', - declaration: 'export interface PeerScope {\n readonly id: PeerId;\n readonly ctx: Context;\n dispose(): Promise;\n}', + declaration: 'export interface PeerScope {\n readonly id: PeerId;\n readonly ctx: Context;\n readonly principal: Principal;\n readonly signal: AbortSignal;\n dispose(): Promise;\n}', }, { name: 'PendingModule', @@ -6311,6 +6360,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PreToolDecision', declaration: 'export type PreToolDecision = {\n kind: \'allow\';\n} | {\n kind: \'deny\';\n reason: string;\n info?: ToolErrorInfo;\n} | {\n kind: \'cancel\';\n} | {\n kind: \'ask\';\n reason?: string;\n displayReason?: {\n readonly en: string;\n readonly [locale: string]: string;\n };\n};', }, + { + name: 'Principal', + declaration: 'export type Principal = PrincipalMap[keyof PrincipalMap];', + }, + { + name: 'PrincipalMap', + declaration: 'export interface PrincipalMap {\n owner: {\n readonly kind: \'owner\';\n };\n}', + }, { name: 'ProductEvent', declaration: 'export type ProductEvent = {\n [K in keyof ProductEventMap]: {\n eventName: K;\n attributes: ProductEventMap[K];\n timestamp: number;\n };\n}[keyof ProductEventMap];', @@ -6483,6 +6540,62 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'Reload', declaration: 'export interface Reload {\n filename: string;\n modules: ReloadModules;\n runtime?: Plugin.Runtime | undefined;\n}', }, + { + name: 'RemoteAccessAction', + declaration: 'export type RemoteAccessAction = RemoteAccessEndpoint | `event:${string}`;', + }, + { + name: 'RemoteAccessBaseline', + declaration: 'export type RemoteAccessBaseline = Extract;', + }, + { + name: 'RemoteAccessBinding', + declaration: 'export type RemoteAccessBinding = {\n [K in RemoteAccessResourceKind]: {\n readonly role: RemoteAccessRole;\n readonly resource: RemoteAccessResourceOf;\n readonly condition?: RemoteAccessCondition;\n };\n}[RemoteAccessResourceKind];', + }, + { + name: 'RemoteAccessCondition', + declaration: 'export type RemoteAccessCondition = (request: {\n readonly action: RemoteAccessAction;\n readonly resource: RemoteAccessResourceOf;\n readonly args?: Readonly>;\n}) => RemoteAccessDecision;', + }, + { + name: 'RemoteAccessDecision', + declaration: 'export type RemoteAccessDecision = {\n readonly kind: \'allow\';\n} | {\n readonly kind: \'deny\';\n readonly reason: string;\n};', + }, + { + name: 'RemoteAccessEndpoint', + declaration: 'export type RemoteAccessEndpoint = `${string}/${string}`;', + }, + { + name: 'RemoteAccessFrame', + declaration: 'export type RemoteAccessFrame = {\n readonly type: \'baseline\';\n readonly owner: true;\n} | {\n readonly type: \'baseline\';\n readonly owner: false;\n readonly grants: readonly RemoteAccessGrant[];\n} | {\n readonly type: \'upsert\';\n readonly grant: RemoteAccessGrant;\n} | {\n readonly type: \'remove\';\n readonly resource: RemoteAccessResource;\n};', + }, + { + name: 'RemoteAccessGrant', + declaration: 'export interface RemoteAccessGrant {\n readonly resource: RemoteAccessResource;\n readonly actions: readonly string[];\n}', + }, + { + name: 'RemoteAccessPolicy', + declaration: 'export interface RemoteAccessPolicy

{\n bindings(principal: P): readonly RemoteAccessBinding[];\n explain?(request: RemoteAccessRequest

): string | undefined;\n}', + }, + { + name: 'RemoteAccessRequest', + declaration: 'export interface RemoteAccessRequest

{\n readonly principal: P;\n readonly action: RemoteAccessAction;\n readonly resources: readonly RemoteAccessResource[];\n readonly args?: Readonly>;\n}', + }, + { + name: 'RemoteAccessResource', + declaration: 'export type RemoteAccessResource = {\n readonly kind: \'global\';\n} | {\n [Kind in TypertResourceKind]: {\n readonly kind: Kind;\n readonly id: TypertResourceId;\n };\n}[TypertResourceKind];', + }, + { + name: 'RemoteAccessResourceKind', + declaration: 'export type RemoteAccessResourceKind = \'global\' | TypertResourceKind;', + }, + { + name: 'RemoteAccessResourceOf', + declaration: 'export type RemoteAccessResourceOf = Extract;', + }, + { + name: 'RemoteAccessRole', + declaration: 'export interface RemoteAccessRole {\n readonly name: string;\n readonly resource: K;\n readonly actions: readonly RemoteAccessAction[];\n}', + }, { name: 'RemoteError', declaration: 'export class RemoteError extends Error {\n readonly isDSHRemoteError: true;\n constructor(readonly code: Code, message: string, readonly details: RemoteErrorDetailsMap[Code], options?: ErrorOptions);\n}', @@ -8173,7 +8286,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TypertGatewayWireStream', - declaration: 'export interface TypertGatewayWireStream {\n readonly open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope | undefined, signal: AbortSignal) => Promise>;\n readonly failure: (error: unknown) => {\n readonly code: string;\n readonly message: string;\n readonly details: object;\n };\n}', + declaration: 'export interface TypertGatewayWireStream {\n readonly open: (endpoint: string, payload: unknown, uplink: AsyncIterable, peer: PeerScope, signal: AbortSignal) => Promise>;\n readonly failure: (error: unknown) => {\n readonly code: string;\n readonly message: string;\n readonly details: object;\n };\n}', }, { name: 'TypertMemberModel', @@ -8205,7 +8318,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'TypertRemoteEventFrame', - declaration: 'export interface TypertRemoteEventFrame {\n readonly event: string;\n readonly args: readonly unknown[];\n}', + declaration: 'export interface TypertRemoteEventFrame {\n readonly event: string;\n readonly args: readonly unknown[];\n readonly resources?: Readonly>;\n}', }, { name: 'TypertRemoteEventInvocation', @@ -8227,6 +8340,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TypertRemoteValue', declaration: 'export interface TypertRemoteValue {\n readonly create: () => TypertSchema;\n readonly description?: string | undefined;\n readonly jsonSchema?: Readonly> | undefined;\n readonly jsonSchemaError?: string | undefined;\n}', }, + { + name: 'TypertResource', + declaration: 'export interface TypertResource {\n readonly [RESOURCE_ID]: Id;\n}', + }, + { + name: 'TypertResourceId', + declaration: 'export type TypertResourceId = Resource extends TypertResource ? Id : never;', + }, + { + name: 'TypertResourceKind', + declaration: 'export type TypertResourceKind = Extract;', + }, + { + name: 'TypertResourceMap', + declaration: 'export interface TypertResourceMap {\n}', + }, + { + name: 'TypertResourceReader', + declaration: 'export type TypertResourceReader = {\n read(...args: readonly unknown[]): unknown;\n}[\'read\'];', + }, { name: 'TypertSchema', declaration: 'export interface TypertSchema {\n parse(value: unknown): Output;\n}', @@ -8557,7 +8690,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'WorkspaceFollowIncrement', - declaration: 'export type WorkspaceFollowIncrement = {\n readonly type: \'upsert\';\n readonly workspace: WorkspaceView;\n} | {\n readonly type: \'remove\';\n readonly workspaceId: WorkspaceId;\n} | {\n readonly type: \'order\';\n readonly workspaceIds: readonly WorkspaceId[];\n} | {\n readonly type: \'archived\';\n readonly archivedSessionIds: readonly SessionId[];\n} | {\n readonly type: \'pinned\';\n readonly pinnedSessionIds: readonly SessionId[];\n};', + declaration: 'export type WorkspaceFollowIncrement = {\n readonly type: \'replace\';\n readonly value: WorkspaceBaseline;\n} | {\n readonly type: \'upsert\';\n readonly workspace: WorkspaceView;\n} | {\n readonly type: \'remove\';\n readonly workspaceId: WorkspaceId;\n} | {\n readonly type: \'order\';\n readonly workspaceIds: readonly WorkspaceId[];\n} | {\n readonly type: \'archived\';\n readonly archivedSessionIds: readonly SessionId[];\n} | {\n readonly type: \'pinned\';\n readonly pinnedSessionIds: readonly SessionId[];\n};', }, { name: 'WorkspaceInsertBeforeRequest', diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index fe9c2d0030..5bcdcf1fcb 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -14,6 +14,7 @@ import GoalService, { } from '@deepseek-ai/dsh-goal' import type { GoalChangeMeta, GoalRef, GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' +import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol' declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { @@ -181,6 +182,16 @@ describe('GoalService creation and replay', () => { }) }) + it('declares no resource readers on goals/get, leaving its Session resource to the Agent lookup', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) + const goals = new GoalService(ctx) + const marker = remoteMethods(goals).find(method => method.method === 'get') + expect(marker).toMatchObject({ method: 'get' }) + expect(marker?.resources).toBeUndefined() + }) + it('rejects invalid direct configuration', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index 8446d4bb43..e11a24e3e7 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -17,6 +17,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | `@deepseek-ai/dsh-api-account-controller` | no | Expose safe account operations over authenticated Remote | | `@deepseek-ai/dsh-api-gateway` | yes | Typert Remote Host dispatcher and Client API endpoint | | `@deepseek-ai/dsh-api-job-controller` | yes | Job Remote observation stream and the reference-counted client job-output service | +| `@deepseek-ai/dsh-api-remote-access` | no | Method-level Remote access control for non-owner Peers and the per-Peer access snapshot | | `@deepseek-ai/dsh-api-remotes` | no | Remote BFF assembly for application-selected Host capabilities | | `@deepseek-ai/dsh-api-session-controller` | yes | Session Remote commands, cold reads, and live control transport | | `@deepseek-ai/dsh-api-settings-controller` | yes | Remote owner for the configuration surfaces over the settings-domain seams | diff --git a/packages/session-query/session-log-export/tests/archive.host.spec.ts b/packages/session-query/session-log-export/tests/archive.host.spec.ts index 6e88d8a76f..61791185b3 100644 --- a/packages/session-query/session-log-export/tests/archive.host.spec.ts +++ b/packages/session-query/session-log-export/tests/archive.host.spec.ts @@ -180,7 +180,7 @@ async function buildApi( await fiber.await() const handler = connection.createSharedFetchHandler('/api') return { - fetch: handler, + fetch: { fetch: (request: Request) => handler.fetch(request, connection.browserPeer) }, downloads: { sessionLog: ( request: { sessionId: SessionId; includeDescendants: boolean }, @@ -189,7 +189,7 @@ async function buildApi( const url = new URL(`http://host${SessionLogExport.SESSION_LOG_EXPORT_PATH}`) url.searchParams.set('sessionId', request.sessionId) url.searchParams.set('includeDescendants', String(request.includeDescendants)) - return handler.fetch(new Request(url, { signal })) + return handler.fetch(new Request(url, { signal }), connection.browserPeer) }, }, } diff --git a/packages/session-query/session-log-export/tests/route.host.spec.ts b/packages/session-query/session-log-export/tests/route.host.spec.ts index 554fdb73e9..5d793aec0e 100644 --- a/packages/session-query/session-log-export/tests/route.host.spec.ts +++ b/packages/session-query/session-log-export/tests/route.host.spec.ts @@ -65,7 +65,7 @@ describe('Session log export Fetch route', () => { const response = await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, - )) + ), connection.browserPeer) expect(response.status).toBe(200) expect(response.headers.get('content-type')).toBe('application/zip') const files = unzipSync(new Uint8Array(await response.arrayBuffer())) @@ -73,26 +73,26 @@ describe('Session log export Fetch route', () => { const head = await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, { method: 'HEAD' }, - )) + ), connection.browserPeer) expect(head.status).toBe(200) expect(head.body).toBeNull() await dispose() expect((await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, - ))).status).toBe(404) + ), connection.browserPeer)).status).toBe(404) }) it('validates the query before reporting missing export services', async () => { const { connection, dispose } = await mounted(false) const shared = connection.createSharedFetchHandler('/api') - expect((await shared.fetch(new Request(`http://host${SESSION_LOG_EXPORT_PATH}`))).status).toBe(400) + expect((await shared.fetch(new Request(`http://host${SESSION_LOG_EXPORT_PATH}`), connection.browserPeer)).status).toBe(400) expect((await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1&includeDescendants=1`, - ))).status).toBe(400) + ), connection.browserPeer)).status).toBe(400) expect((await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, - ))).status).toBe(500) + ), connection.browserPeer)).status).toBe(500) await dispose() }) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index b536306a07..119cc26a6a 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -40,7 +40,7 @@ import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-ll import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' -import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, RemoteError, TypertRemoteService, principalOf, type Principal } from '@deepseek-ai/dsh-typert-protocol' import { rejectPrompt, validateControlRequest, } from './control.ts' @@ -178,6 +178,8 @@ interface BrowserPromptSource { readonly kind: 'user' readonly rpcId: SubagentPromptRequestId readonly clientTimeZone?: string + /** Caller that submitted this follow-up, written only when it is not the owner; absent means the owner. */ + readonly principal?: Principal } /** Host configuration for continuable subagent capacity. */ @@ -464,10 +466,12 @@ export class SubagentRuntime extends TypertRemoteService { { parentSessionId }, ) } + const principal = principalOf(this.ctx.invocation) const source: BrowserPromptSource = { kind: 'user', rpcId: request.requestId, ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }), + ...(principal === undefined ? {} : { principal }), } try { // Admission precedes delivery: image parts become durable references diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts index b6df7c53ed..20fdc49d6b 100644 --- a/packages/subagent/subagent/tests/control.spec.ts +++ b/packages/subagent/subagent/tests/control.spec.ts @@ -12,11 +12,20 @@ import SubagentRuntime, { type SubagentPromptRequestId, } from '@deepseek-ai/dsh-subagent' import { deliverSubagentPrompt, type HostPromptDeliverer } from '@deepseek-ai/dsh-subagent/internal' +import type { PeerId, Principal, RemoteInvocation } from '@deepseek-ai/dsh-typert-protocol' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } +} const PARENT = SessionId('parent') const CHILD = SessionId('child') const PARENT_CREATED_AT = 1 const REQUEST_ID = 'req-1' as SubagentPromptRequestId +const GUEST: Principal = { kind: 'test-guest', guest: 'subagent' } const signal = new AbortController().signal /** Durable-reference base for the fake store; per-test ids and media types override. */ const IMAGE_REF = { attachmentId: 'att', mediaType: 'image/png', bytes: 2, width: 1, height: 1 } @@ -194,6 +203,27 @@ describe('subagent prompt Remote', () => { ) }) + it('records a non-owner caller on the follow-up source and nothing for the owner', async () => { + const { ctx, subagents } = await bench({ [PARENT]: { status: 'idle' } }) + const delivery = promptDelivery(subagents).mockResolvedValue('m-guest' as MessageId) + const as = (principal: Principal) => ctx.extend({ + invocation: { + request: { namespace: 'subagents', method: 'prompt', args: {} }, + service: 'subagents', + peer: { id: 'subagent-peer' as PeerId, ctx, principal, signal, dispose: async () => {} }, + signal, + uplink: (): AsyncIterable => ({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }), + } satisfies RemoteInvocation, + }).subagents + + await expect(as(GUEST).prompt(promptRequest(), signal)).resolves.toEqual({ messageId: 'm-guest' }) + await expect(as({ kind: 'owner' }).prompt(promptRequest(), signal)).resolves.toEqual({ messageId: 'm-guest' }) + expect(delivery.mock.calls.map(call => call[3])).toEqual([ + { kind: 'user', rpcId: REQUEST_ID, principal: GUEST }, + { kind: 'user', rpcId: REQUEST_ID }, + ]) + }) + it('passes steer delivery through the same admission operation', async () => { const { subagents } = await bench({ [PARENT]: { status: 'running' } }) const delivery = promptDelivery(subagents).mockResolvedValue('m-steer' as MessageId) diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index f4989ed8a8..0f7e7badee 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -18,8 +18,8 @@ en: 12afbb217bc06678 zh: c07f001ed8bb3300 /deepseek-ai-dsh-client-test-runtime/use-this-package/setting-up-a-feature-spec: - en: e78bc5e3adf2ff44 - zh: 480cd66637d92047 + en: 620906bcbaba405a + zh: b7c34920ba5873d1 /deepseek-ai-dsh-client-test-runtime/use-this-package/local-dom-snapshots: en: 1abbbed6cc4cf57b zh: ab3975178b589653 @@ -30,8 +30,8 @@ en: 1442fc215c8128e8 zh: 8439861b08e46ea2 /deepseek-ai-dsh-client-test-runtime/use-this-package/roster-and-startup-behavior: - en: 964236f295b61c0c - zh: 863a9342b1169ad8 + en: 95c5c2121547bd60 + zh: 4dfb089ba7aac04d /deepseek-ai-dsh-client-test-runtime/use-this-package/when-to-use-it: en: eb2f9bc90acb7a62 zh: cb59a2134bc83106 @@ -45,8 +45,8 @@ en: 3264640018c1a0e8 zh: d777e0b8ad8b602f /deepseek-ai-dsh-client-test-runtime/understand-the-implementation/source-map: - en: b77fd15bc12265db - zh: 1a14b6999978b72a + en: e3ef8397426d484b + zh: 23f735f4bda1534a /deepseek-ai-dsh-client-test-runtime/understand-the-implementation/lifecycle: en: b42779f364504719 zh: a10bad7ea1161388 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 2a7690f4ab..dc22248ff0 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -44,6 +44,8 @@ await runtime.dispose() `mount` prechecks required services and fails loud when one is missing — `provide(name, value)` supplies an extra service first. The runtime provides a `fileUpload` stub that rejects every call until a suite replaces `runtime.fileUpload.upload`. `storeOf(key, scopeKey)` returns the live store instance the renderer hands a slot's component for identity and action-driven-write assertions. +The runtime installs `allowAllAccessPlugin` before ui-session: its `remoteAccess` service answers true for every resource, so every access Hook allows every action. Hand-built component props spread `...allowAllAccess` to receive the same Hooks; a spec of a refused path overrides one Hook. + The optional render options select a keyed entry with `entryKey` or a list item with `only`; `view.update(owner)` retains that selection. `runtime.panelInfo` supplies the default `usePanelInfo` source with no global panel selected. Release it with `releasePanelInfoSource()` before mounting the production Layout owner. `dispose()` releases both default Workspace and panel-info root sources; early release is idempotent and does not remove replacement owners. The Session fixture resolves subagent addresses from explicitly supplied addresses or loaded parent projections without retaining a Session generation. External catalog entries have no local browsing address. @@ -91,7 +93,7 @@ The [generic Remote proxy](../remote-mock/README.md#remote-proxy) supports every ### Roster and startup behavior -`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles, anchor, disabledContext)` accepts an explicit Loader evaluation scope for conditional `disabled` flags; without one, browser expressions are rejected. `webApp` supplies the Web profile name, not business Host services. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` nor the `document.fonts` event target (layout components observe size and font loading at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. +`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles, anchor, disabledContext)` accepts an explicit Loader evaluation scope for conditional `disabled` flags; without one, browser expressions are rejected. `webApp` supplies the Web profile name, not business Host services. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` nor the `document.fonts` event target (layout components observe size and font loading at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. The `@deepseek-ai/dsh-api-remote-access` row, which mounts its own generated contribution, is dropped too, and `start` installs `allowAllAccessPlugin` in its place; a plan `provide` entry for either row is refused. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. ### When to use it @@ -125,6 +127,7 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p | [`src/sessions.ts`](src/sessions.ts) + [`src/workspaces.ts`](src/workspaces.ts) | `ISessions`/`IWorkspaces` test doubles and `FixtureSession` behavior stubs | | [`src/fixtures.ts`](src/fixtures.ts) | Plain fixture builders: conversation snapshots, workspace list state | | [`src/snapshot.ts`](src/snapshot.ts) | DOM snapshot serializer (class-hash folding, `` fingerprint) | +| [`src/access.ts`](src/access.ts) | `allowAllAccess` props and `allowAllAccessPlugin`, the allow-all stand-in for the remote-access Client row | | [`src/remote.ts`](src/remote.ts) | `TestRemote` double for host RPC, `RemoteError` value re-export | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | Translation and pinned-browser-language test helpers | | [`src/config-form.ts`](src/config-form.ts) | `stubConfigForm` with test-driven publications and write spies | diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index a603d918c6..8c0a670afd 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -44,6 +44,8 @@ await runtime.dispose() `mount` 会预检必需服务,缺失时自明报错——先用 `provide(name, value)` 提供额外服务。运行时提供的 `fileUpload` 替身会拒绝每次调用,直到测试套件替换 `runtime.fileUpload.upload`。`storeOf(key, scopeKey)` 返回渲染器交给 slot 组件的实时存储实例,用于身份与动作驱动写入断言。 +运行时在 ui-session 之前安装 `allowAllAccessPlugin`:它的 `remoteAccess` 服务对每个资源都回答 true,因此每个访问 Hook 都允许每个 action。手工构造的组件 props 展开 `...allowAllAccess` 以获得同样的 Hook;测试被拒绝路径的 spec 覆盖其中一个 Hook。 + 可选渲染参数通过 `entryKey` 选择 keyed 条目,或通过 `only` 选择 list 条目;`view.update(owner)` 保留该选择。`runtime.panelInfo` 提供默认的 `usePanelInfo` 数据源,初始不选中全局面板。挂载生产 Layout 所有者之前,先调用 `releasePanelInfoSource()` 释放该数据源。`dispose()` 同时释放默认的工作区与面板信息根数据源;提前释放是幂等的,不会移除替代它们的所有者。 Session fixture 从显式提供的地址或已加载的父级投影解析子代理地址,不会保留 Session generation。外部目录项没有本地浏览地址。 @@ -91,7 +93,7 @@ test('registers into the sidebar', async ({ remote, start }) => { ### Roster 与启动行为 -`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles, anchor, disabledContext)` 接受显式 Loader 求值作用域来处理条件 `disabled` 标记;未提供作用域时拒绝浏览器表达式。`webApp` 仅提供 Web profile 名称,不提供业务 Host 服务。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver` 和 `document.fonts` 事件目标(布局组件挂载时观察尺寸与字体加载),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 +`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles, anchor, disabledContext)` 接受显式 Loader 求值作用域来处理条件 `disabled` 标记;未提供作用域时拒绝浏览器表达式。`webApp` 仅提供 Web profile 名称,不提供业务 Host 服务。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver` 和 `document.fonts` 事件目标(布局组件挂载时观察尺寸与字体加载),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。自行挂载其生成 contribution 的 `@deepseek-ai/dsh-api-remote-access` 行同样会被去掉,`start` 改为安装 `allowAllAccessPlugin`;计划中为这两行之一提供的 `provide` 项会被拒绝。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 ### 何时使用 @@ -125,6 +127,7 @@ test('registers into the sidebar', async ({ remote, start }) => { | [`src/sessions.ts`](src/sessions.ts) + [`src/workspaces.ts`](src/workspaces.ts) | `ISessions`/`IWorkspaces` 测试替身与 `FixtureSession` 行为桩 | | [`src/fixtures.ts`](src/fixtures.ts) | 普通 fixture 构造器:会话快照、workspace 列表状态 | | [`src/snapshot.ts`](src/snapshot.ts) | DOM 快照序列化器(类名哈希折叠、`` 指纹) | +| [`src/access.ts`](src/access.ts) | `allowAllAccess` props 与 `allowAllAccessPlugin`:remote-access Client 行的全允许替身 | | [`src/remote.ts`](src/remote.ts) | 用于 host RPC 的 `TestRemote` 替身、`RemoteError` 值转出 | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | 翻译与固定浏览器语言测试辅助 | | [`src/config-form.ts`](src/config-form.ts) | 带测试驱动发布与写入 spy 的 `stubConfigForm` | diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index e2f131880a..2144ed5c47 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/cordis-plugin-include": "workspace:~", "@deepseek-ai/cordis-plugin-loader": "workspace:~", "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", @@ -76,6 +77,7 @@ "@deepseek-ai/dsh-client-ui-session": "workspace:*", "@deepseek-ai/dsh-client-ui-settings": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", "@deepseek-ai/dsh-client-web": "workspace:*", "@deepseek-ai/dsh-remote-mock": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", diff --git a/packages/test-support/client-runtime/src/access.ts b/packages/test-support/client-runtime/src/access.ts new file mode 100644 index 0000000000..4c467ac61f --- /dev/null +++ b/packages/test-support/client-runtime/src/access.ts @@ -0,0 +1,53 @@ +/** + * Allow-all access for client specs. Production pages receive the + * `remoteAccess` Client service and the global-resource `useRemoteAccess` Hook + * from the remote-access Client plugin, and the Session and Workspace access + * Hooks (Session-scope `useAccess`, `useSessionAccess`, `useWorkspaceAccess`) + * from ui-session and ui-workspace over that service. The test runtimes + * install an allow-all service and `useRemoteAccess` Hook instead, and + * hand-built component props spread {@link allowAllAccess}; a spec of a + * refused path overrides one Hook. + * @module @deepseek-ai/dsh-client-test-runtime/src/access + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { ClientRemoteAccess } from '@deepseek-ai/dsh-api-remote-access/client' +import type { GlobalStandardProps, HostObservable, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-session/client' +import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' + +/** The access Hooks every Slot component receives. */ +export type AccessStandardProps = + & Pick + & Pick + +/** Access Hooks that allow every action on every resource, for hand-built component props. */ +export const allowAllAccess: AccessStandardProps = { + useRemoteAccess: () => true, + useAccess: () => true, + useSessionAccess: () => true, + useWorkspaceAccess: () => true, +} + +const ALLOWED: HostObservable = { getSnapshot: () => true, subscribe: () => () => {} } +const allowed = (): HostObservable => ALLOWED + +/** The `remoteAccess` Client service answering true for every resource. */ +const allowAllRemoteAccess: ClientRemoteAccess = { source: allowed } + +/** + * Plugin standing in for the remote-access Client row: it provides an + * allow-all `remoteAccess` service, so ui-session and ui-workspace publish + * their resource Hooks over it, and the allow-all `useRemoteAccess` Hook on + * the global resource. + */ +export const allowAllAccessPlugin = { + name: 'client-test-runtime:allow-all-access', + inject: ['slots'], + /** @param ctx - plugin Context with the Slot registry injected. */ + apply(ctx: Context): void { + ctx.provide('remoteAccess', allowAllRemoteAccess) + ctx.slots.provideRoot({ keyedHooks: { remoteAccess: allowed } }) + }, +} diff --git a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts index fbd46a38bc..0debc5a62a 100644 --- a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts +++ b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts @@ -53,5 +53,7 @@ export const remoteDefaultResponses: RemoteTable = { 'account/watch': openStream([{ status: 'signed-out', attempt: null, links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }]), // api-workspace-controller client `apply`: the follow stream's opening baseline, then open. 'workspace/follow': openStream([{ type: 'baseline', value: { items: [], archivedSessionIds: [], pinnedSessionIds: [] } }]), + // api-remote-access client `mountAccess`: the owner's access snapshot, then open. + 'remoteAccess/follow': openStream([{ type: 'baseline', owner: true }]), }, } diff --git a/packages/test-support/client-runtime/src/assembly/test-client.ts b/packages/test-support/client-runtime/src/assembly/test-client.ts index 2a54d235bc..acec237c87 100644 --- a/packages/test-support/client-runtime/src/assembly/test-client.ts +++ b/packages/test-support/client-runtime/src/assembly/test-client.ts @@ -18,6 +18,17 @@ import { act } from '@testing-library/react' import { createInProcessModules, loadPluginModules } from './modules.ts' import { assertPlan, graphFromRoster, type AssemblyPlan } from './roster.ts' import { REMOTES_PACKAGE, remoteNamespacesOf, remoteProxiesPlugin } from './remote-proxies.ts' +import { allowAllAccessPlugin } from '../access.ts' + +/** The remote-access row: it mounts its own generated Remote contribution and publishes the access Hooks. */ +const REMOTE_ACCESS_PACKAGE = '@deepseek-ai/dsh-api-remote-access' + +/** + * Rows whose generated Remote contributions exist only in built `lib/`. The + * tier drops them: `remote.` proxies stand in for the namespaces and + * allow-all access Hooks stand in for remote-access. + */ +const GENERATED_REMOTE_PACKAGES: readonly string[] = [REMOTES_PACKAGE, REMOTE_ACCESS_PACKAGE] /** Carrier options. */ export interface TestClientOptions { @@ -181,8 +192,10 @@ export class TestClient { * dropped from the roster: its generated Remote clients exist only in built * `lib/`, and the `remote.` services the roster injects (plus the * namespaces the mock has rules for at this point) are provided as - * contract-free proxies over the same Connection instead; a `provide` entry - * for that row is refused. On any failure the context is disposed, an owned + * contract-free proxies over the same Connection instead. The + * `@deepseek-ai/dsh-api-remote-access` row, which mounts its own generated + * contribution, is dropped too, and allow-all access Hooks replace it. A + * `provide` entry for either row is refused. On any failure the context is disposed, an owned * mount removed, and this client's hold on the shims released before the * original error is rethrown. * @param plan - roster and annotations. @@ -196,12 +209,13 @@ export class TestClient { options: TestClientOptions = {}, ): Promise { assertPlan(plan) - if (plan.provide?.[REMOTES_PACKAGE] !== undefined) { - throw new Error(`client-test-runtime: ${REMOTES_PACKAGE} cannot be provided; its remote. services are the tier's proxies`) + for (const name of GENERATED_REMOTE_PACKAGES) { + if (plan.provide?.[name] !== undefined) { + throw new Error(`client-test-runtime: ${name} cannot be provided; the tier replaces its generated Remote mount`) + } } - const roster = plan.roster.rows.some(row => row.name === REMOTES_PACKAGE) - ? plan.roster.without([REMOTES_PACKAGE]) - : plan.roster + const generated = GENERATED_REMOTE_PACKAGES.filter(name => plan.roster.rows.some(row => row.name === name)) + const roster = generated.length > 0 ? plan.roster.without(generated) : plan.roster const ctx = new Context() const pageLocation = typeof location === 'undefined' ? undefined @@ -230,6 +244,8 @@ export class TestClient { release = sharedJsdomShims.acquire() const system = createInProcessModules(graphFromRoster(roster.rows), modules) ctx.plugin(remoteProxiesPlugin(remoteNamespacesOf(modules.values(), mock), mock) as Plugin) + // The dropped remote-access row's Hooks: every action is allowed on every resource. + if (generated.includes(REMOTE_ACCESS_PACKAGE)) ctx.plugin(allowAllAccessPlugin) await bootClient({ ctx, modules: system, manifest: system.manifest }) if (mountPoint.element !== undefined) { if (ctx.get('uiRenderer') === undefined) { diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 67c37d8e1a..40403354af 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -42,6 +42,7 @@ import { TestRemote } from './remote.ts' import { TestSessions } from './sessions.ts' import { TestWorkspaces } from './workspaces.ts' import type { Stabilizer } from './fixtures.ts' +import { allowAllAccessPlugin } from './access.ts' export type { UseSession } from '@deepseek-ai/dsh-client-ui-session/client' export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts' @@ -57,6 +58,8 @@ export type { FixtureSnapshot, SessionBehaviorOverrides, SessionFixture, SessionFixtureSnapshot, Stabilizer, } from './fixtures.ts' export { makeTranslate } from './translate.ts' +export { allowAllAccess, allowAllAccessPlugin } from './access.ts' +export type { AccessStandardProps } from './access.ts' export { usePinnedBrowserLanguages } from './locale-env.ts' /** @@ -284,6 +287,7 @@ export class SlotTestRuntime { const fiber = ctx.plugin(SlotRegistry) await fiber.await() const runtime = new SlotTestRuntime(ctx, ctx.get('slots') as SlotRegistry) + await ctx.plugin(allowAllAccessPlugin).await() await ctx.plugin({ inject: [...uiSessionInject], apply: applyUiSession }).await() return runtime } diff --git a/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts index 748b6d3eab..e51ce69892 100644 --- a/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-test-client-node.client.spec.ts @@ -48,10 +48,22 @@ describe('TestClient (node environment)', () => { expect(client.connection.state.getSnapshot()).toBe('connected') }) - it('refuses a provide entry for the api-remotes row, whose services are the proxies', async () => { - const roster = webApp.closure(['@deepseek-ai/dsh-api-remotes']) - await expect(TestClient.start({ roster, provide: { '@deepseek-ai/dsh-api-remotes': { apply() {} } } }, RemoteMock.create())) - .rejects.toThrow('@deepseek-ai/dsh-api-remotes cannot be provided; its remote. services are the tier\'s proxies') + it.each(['@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-api-remote-access'])( + 'refuses a provide entry for the %s row, whose generated Remote mount the tier replaces', + async (name) => { + const roster = webApp.closure([name]) + await expect(TestClient.start({ roster, provide: { [name]: { apply() {} } } }, RemoteMock.create())) + .rejects.toThrow(`${name} cannot be provided; the tier replaces its generated Remote mount`) + }, + ) + + it('drops the remote-access row and answers every access question with true', async () => { + const mock = RemoteMock.create().load(remoteDefaultResponses) + const client = await TestClient.start({ roster: webApp.closure(['@deepseek-ai/dsh-api-remote-access']) }, mock) + onTestFinished(() => client.dispose()) + expect(client.ctx.get('remoteAccess')?.source('session', 's1', 'session/fork').getSnapshot()).toBe(true) + // The dropped row never mounts its namespace, so no access stream opens. + expect(mock.log.streams('remoteAccess/follow')).toEqual([]) }) it('reports unmatched requests alongside a failed teardown instead of hiding them', async () => { diff --git a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx index 6fe307748b..9c8cf56867 100644 --- a/packages/test-support/client-runtime/tests/helpers.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/helpers.client.spec.tsx @@ -5,10 +5,12 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-chat/client' import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { MainPanelId, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' import { + allowAllAccess, bindSnapshotSelector, chatSnapshot, conversationSnapshot, @@ -46,6 +48,26 @@ function entry(seq: number): SessionLiveEventEntry { } describe('fixture helpers', () => { + it('allows every action on every resource through the hand-built access Hooks', () => { + expect(allowAllAccess.useRemoteAccess('session/list')).toBe(true) + expect(allowAllAccess.useAccess('session/fork')).toBe(true) + expect(allowAllAccess.useSessionAccess('s1' as SessionId, 'session/fork')).toBe(true) + expect(allowAllAccess.useWorkspaceAccess('w1' as WorkspaceId, 'workspace/follow')).toBe(true) + }) + + it('installs an allow-all remoteAccess service whose sources never change', async () => { + const runtime = await SlotTestRuntime.create() + try { + const source = runtime.ctx.remoteAccess.source('session', 's1', 'session/fork') + expect(source.getSnapshot()).toBe(true) + const unsubscribe = source.subscribe(() => {}) + unsubscribe() + expect(source.getSnapshot()).toBe(true) + } finally { + await runtime.dispose() + } + }) + it('retracts default root sources without removing their live replacements', async () => { const runtime = await SlotTestRuntime.create() const hooks = { workspaces: runtime.workspaces.list, panelInfo: runtime.panelInfo } diff --git a/packages/test-support/client-runtime/tsconfig.json b/packages/test-support/client-runtime/tsconfig.json index 5503b5e71a..0867ea9b86 100644 --- a/packages/test-support/client-runtime/tsconfig.json +++ b/packages/test-support/client-runtime/tsconfig.json @@ -27,6 +27,9 @@ { "path": "../../api/workspace-controller/tsconfig.client.json" }, + { + "path": "../../api/remote-access/tsconfig.client.json" + }, { "path": "../../attachment/attachment" }, @@ -57,6 +60,9 @@ { "path": "../../client/ui-renderer" }, + { + "path": "../../client/ui-workspace" + }, { "path": "../../client/ui-session" }, diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index 90f901316b..e4e4d9f8a2 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -1409,36 +1409,27 @@ class FaceAnalyzer { } marker = { kind: 'direct', exportName } } else { - if (!ts.isObjectLiteralExpression(argument) || argument.properties.length === 0) { - this.fail(argument, 'Remote() options require name or mode: "stream"') - } - marker = { kind: 'direct' } - for (const property of argument.properties) { - if (!ts.isPropertyAssignment(property)) this.fail(property, 'Remote() options must use named values') - const name = memberName(property.name) - if (name === 'mode' && stringLiteralValue(property.initializer) === 'stream') marker = { ...marker, mode: 'stream' } - else if (name === 'name') { - const value = stringLiteralValue(property.initializer) - if (value === undefined || !isRemoteSegment(value)) this.fail(property, 'Remote() name must be an endpoint segment') - marker = { ...marker, exportName: value } - } else this.fail(property, 'Remote() options require name or mode: "stream"') - } + marker = { kind: 'direct', ...this.remoteOptions(argument, 'Remote') } } } else if (ts.isCallExpression(expression) && this.isTypeMetaSymbol(expression.expression, 'RemoteScope')) { if (expression.arguments.length < 1 || expression.arguments.length > 2) { - this.fail(expression, 'RemoteScope() requires a Context key and optional exported method name') + this.fail(expression, 'RemoteScope() requires a Context key and an optional exported method name or options object') } const context = stringLiteralValue(expression.arguments[0]) if (context === undefined || !isRemoteSegment(context)) { this.fail(expression.arguments[0] ?? expression, 'RemoteScope() key must be a string literal containing only RPC endpoint segment characters') } - const exportArgument = expression.arguments[1] - const exportName = exportArgument === undefined ? undefined : stringLiteralValue(exportArgument) - if (exportArgument !== undefined && (exportName === undefined || !isRemoteSegment(exportName))) { - this.fail(exportArgument, 'RemoteScope() name must be a string literal containing only RPC endpoint segment characters') + const option = expression.arguments[1] + const exportName = option === undefined ? undefined : stringLiteralValue(option) + if (option === undefined || exportName !== undefined) { + if (exportName !== undefined && !isRemoteSegment(exportName)) { + this.fail(option ?? expression, 'RemoteScope() name must be a string literal containing only RPC endpoint segment characters') + } + marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } } + } else { + marker = { kind: 'context', context, ...this.remoteOptions(option, 'RemoteScope') } } - marker = { kind: 'context', context, ...exportName === undefined ? {} : { exportName } } } else { continue } @@ -1448,6 +1439,37 @@ class FaceAnalyzer { return found } + /** + * Read the options object of `@Remote` or `@RemoteScope`. `resources` + * accepts any expression: its readers run only at runtime, where the + * Gateway reads them from the prototype descriptor, so generation records + * nothing for them. + */ + private remoteOptions( + argument: ts.Expression, + decorator: 'Remote' | 'RemoteScope', + ): { readonly exportName?: string; readonly mode?: 'stream' } { + const requirement = decorator === 'Remote' + ? 'Remote() options require name, mode: "stream", or resources' + : 'RemoteScope() options require name or resources' + if (!ts.isObjectLiteralExpression(argument) || argument.properties.length === 0) this.fail(argument, requirement) + let options: { readonly exportName?: string; readonly mode?: 'stream' } = {} + for (const property of argument.properties) { + if (!ts.isPropertyAssignment(property)) this.fail(property, `${decorator}() options must use named values`) + const name = memberName(property.name) + if (decorator === 'Remote' && name === 'mode' && stringLiteralValue(property.initializer) === 'stream') { + options = { ...options, mode: 'stream' } + } else if (name === 'name') { + const value = stringLiteralValue(property.initializer) + if (value === undefined || !isRemoteSegment(value)) this.fail(property, `${decorator}() name must be an endpoint segment`) + options = { ...options, exportName: value } + } else if (name !== 'resources') { + this.fail(property, requirement) + } + } + return options + } + /** * The item types a Remote method's authored return type declares. Unary * methods unwrap `Promise`; stream methods unwrap `Iterable`, diff --git a/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts b/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts index 1aafd6e2e6..fbfa27235a 100644 --- a/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts +++ b/packages/typert/generator/tests/fixtures/remote-model/typert-protocol.d.ts @@ -139,13 +139,16 @@ declare module '@deepseek-ai/dsh-typert-protocol' { context: ClassMethodDecoratorContext Result>, ): void - export function Remote(option: string | { readonly mode?: 'stream'; readonly name?: string }): + export function Remote(option: string | { readonly mode?: 'stream'; readonly name?: string; readonly resources?: object }): ( method: (this: This, ...args: Args) => Result, context: ClassMethodDecoratorContext Result>, ) => void - export function RemoteScope(key: Extract, exportName?: string): + export function RemoteScope( + key: Extract, + option?: string | { readonly name?: string; readonly resources?: object }, + ): ( method: (this: This, ...args: Args) => Result, context: ClassMethodDecoratorContext Result>, diff --git a/packages/typert/generator/tests/remote-model.spec.ts b/packages/typert/generator/tests/remote-model.spec.ts index cea8dfaa8c..c46e502615 100644 --- a/packages/typert/generator/tests/remote-model.spec.ts +++ b/packages/typert/generator/tests/remote-model.spec.ts @@ -540,7 +540,7 @@ export type {`, name: 'an unknown Remote mode', mode: 'duplex', method: 'async *attach(agent: Agent, signal: AbortSignal): AsyncIterable', - message: 'Remote\\(\\) options require name or mode: "stream"', + message: 'Remote\\(\\) options require name, mode: "stream", or resources', }, ])('rejects $name', ({ mode, method, message }) => { const root = copyFixture() @@ -678,6 +678,67 @@ export interface BoxPayload { assertRemoteConsumerTypechecks(artifact?.remote?.dts, artifact?.remote?.dtsMap, root) }) + it('reads export names from options whose resource readers stay runtime-only', () => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source + .replace( + ' @Remote\n async create(', + " @Remote({ name: 'createGoal', resources: { session: (agentId: string) => agentId } })\n async create(", + ) + .replace( + " @RemoteScope('agent')\n", + " @RemoteScope('agent', { name: 'renameGoal', resources: { session: (request: RenameGoalRequest) => request.title } })\n", + )) + + const model = remotePackage(root) + expect(model.invocations.map(invocation => [invocation.method, invocation.implementation])).toEqual([ + ['createGoal', 'create'], + ['renameGoal', 'rename'], + ['watch', undefined], + ]) + expect(JSON.stringify(model.invocations)).not.toContain('resources') + }) + + it.each([ + { + name: 'an unknown Remote option', + decorator: '@Remote({ resources: {}, audit: true })\n async create(', + message: 'Remote\\(\\) options require name, mode: "stream", or resources', + }, + { + name: 'a stream option on a Remote Scope', + decorator: "@RemoteScope('agent', { mode: 'stream' })\n rename(", + message: 'RemoteScope\\(\\) options require name or resources', + }, + { + name: 'empty Remote Scope options', + decorator: "@RemoteScope('agent', {})\n rename(", + message: 'RemoteScope\\(\\) options require name or resources', + }, + { + name: 'a computed Remote Scope name', + decorator: "@RemoteScope('agent', { name: `rename${'Goal'}` })\n rename(", + message: 'RemoteScope\\(\\) name must be an endpoint segment', + }, + { + name: 'shorthand Remote Scope options', + decorator: "@RemoteScope('agent', { resources })\n rename(", + message: 'RemoteScope\\(\\) options must use named values', + }, + { + name: 'an untransportable Remote Scope export name', + decorator: "@RemoteScope('agent', 'rename goal')\n rename(", + message: 'RemoteScope\\(\\) name must be a string literal containing only RPC endpoint segment characters', + }, + ])('rejects $name', ({ decorator, message }) => { + const root = copyFixture() + editFile(root, 'packages/remote/src/index.ts', source => source + .replace(decorator.includes('create(') ? '@Remote\n async create(' : "@RemoteScope('agent')\n rename(", decorator) + .replace('export class GoalService', 'const resources = {}\n\nexport class GoalService')) + + expect(() => analyzeRemote(root, false)).toThrow(new RegExp(message)) + }) + it.each(['create#v2', 'create goal', '.', '..'])('rejects untransportable Remote alias %s', (alias) => { const root = copyFixture() editFile(root, 'packages/remote/src/index.ts', source => source.replace( diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index 50ec613960..44cc5d7243 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/README.i18n.yaml @@ -18,8 +18,11 @@ en: c479b5b665822198 zh: 1ffb789c18043baf /deepseek-ai-dsh-typert-protocol/use-this-package/exposing-a-host-method: - en: 82a3db72a51105ff - zh: 46848784380732be + en: ca7df7ea76fb09d8 + zh: add884160fbbd5c7 +/deepseek-ai-dsh-typert-protocol/use-this-package/identifying-the-caller-and-admitting-non-owner-callers: + en: 78e08ae732a8f6f4 + zh: be15fb7599994fb9 /deepseek-ai-dsh-typert-protocol/use-this-package/associating-host-objects-and-contexts-with-wire-identities: en: 3db384ae6f215632 zh: 56207184b09d4018 @@ -36,8 +39,8 @@ en: 4f45bb46a1920984 zh: 70408d3b59d2735a /deepseek-ai-dsh-typert-protocol/understand-the-implementation/remote-markers: - en: 93b83a9441a5a3c2 - zh: c4eb143ddd167a27 + en: 082530713c5cea83 + zh: 592fe5d9636ef288 /deepseek-ai-dsh-typert-protocol/understand-the-implementation/protocol-maps-and-descriptors: en: c202904201402211 zh: 3db1d83683b4fd2a @@ -45,8 +48,8 @@ en: aac79a7b4682371e zh: bc9cc513c1ced3e6 /deepseek-ai-dsh-typert-protocol/understand-the-implementation/source-map: - en: 8b878e0e003b86ec - zh: 5d92998ad2964d7c + en: 74fdb78bf4e7753b + zh: dad439ba2a323ff9 /deepseek-ai-dsh-typert-protocol/further-exploration: en: 84191d1b07fb8bb6 zh: 9a5212732ed38ab6 @@ -57,8 +60,8 @@ en: b1c49d5e440d9d41 zh: 87ead78472415189 /deepseek-ai-dsh-typert-protocol/known-limitations-and-deferred-work: - en: 6079caacbcd01f08 - zh: 70295e443f35afe8 + en: 32a7ede4de20ad4e + zh: 57b86c314cd376b8 /deepseek-ai-dsh-typert-protocol/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index d0ffd3996a..b771f7b566 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -46,9 +46,9 @@ Generation turns the method into a wire endpoint under the service's namespace; A unary method can return `Uint8Array` directly or within nested objects, arrays, tuples, optional fields, unions, and recursive types. Generation supplies optional result codec `encode()` and `decode()` functions: encoding visits only subtrees whose types can contain bytes, while decoding validates reconstructed values; Client declarations use `Uint8Array` at every byte position while retaining other field types. Pure JSON results pass through without Host byte detection or Client parsing. Parameters, events, and stream items remain JSON-only; runtime object cycles are unsupported. -A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterable`, or `RemoteStream`. `In` declares the items the Client may send back on the same logical stream; the method reads them through `this.ctx.invocation.uplink()`, and the descriptor carries their codec. `RemoteInvocation` also names the receiving `service`, the calling `peer` (a `PeerScope` the connection layer admitted), and the carrier `signal`; `ctx.invocation` is `undefined` on a Context no Remote call derived: the first `bindTypertRemote()` binding in a tree, which every `TypertRemoteService` constructor makes, registers that accessor on the root. A generated Client stream method returns `RemoteStreamHandle`: the handle with `send`, `end`, and `dispose` beside the downlink iteration. Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through. +A stream method (`@Remote({ mode: 'stream' })`) returns `Iterable`, `AsyncIterable`, or `RemoteStream`. `In` declares the items the Client may send back on the same logical stream; the method reads them through `this.ctx.invocation.uplink()`, and the descriptor carries their codec. `RemoteInvocation` also names the receiving `service`, the calling `peer` (the `PeerScope` its carrier names), and the carrier `signal`; `ctx.invocation` is `undefined` on a Context no Remote call derived: the first `bindTypertRemote()` binding in a tree, which every `TypertRemoteService` constructor makes, registers that accessor on the root. A generated Client stream method returns `RemoteStreamHandle`: the handle with `send`, `end`, and `dispose` beside the downlink iteration. Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through. -`@Remote` options contain only `name` and `mode`; input policy belongs to the consumer, not a method annotation. `method.input(options?: TypertRemoteInputOptions)` returns a cached `TypertRemoteValue