Merge pull request #7003 from deepseek-harness/session-baseline-split-6998

feat(typert): extract Session baseline cleanup and default resource resolvers
This commit is contained in:
imccyu authored and GitHub committed 2026-10-23 18:06:28 +08:00
commit 83a0433bc7
42 files changed
+440 -349

No files matched your search

@@ -27,17 +27,17 @@
en: a01ac57fc71a84f0
zh: 3e488ef3da5e4b8c
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control:
en: a55516339b0d49dd
zh: 3281df9ea7878fc0
en: 725a2d4145c0a75e
zh: b98a64532cadeaba
/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
en: d50ae22978165c57
zh: 8c1fcfb7dc2d3367
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/endpoint-resources:
en: ec1677d8e338e115
zh: 6bedd163f208eaa9
en: 056a141b31270cba
zh: 3f6caafb3a13c192
/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
@@ -45,8 +45,8 @@
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
en: 2d97a251cc4c9db0
zh: df072d5a96a9e82f
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/forwarded-events:
en: 5fe34ee5ec31942a
zh: ef39b020b0539b8f
@@ -75,14 +75,14 @@
en: 305ec45a7cd85ed7
zh: d2cd4209a56b1d0c
/agent-note-peer-principals-and-method-level-remote-access-control/alternatives-considered:
en: 130df2981ba550df
zh: e9a0a89af6fb50a3
en: 60da38bca6e9c3d6
zh: 61ea578fe8185b61
/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: 2279a8c153cc5b89
zh: 63c0b6462722f0a3
en: d7d0909974609247
zh: 6ebf20714f8d545b
/agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits:
en: 1e254cec680b1a37
zh: 06a31e057ba965aa
@@ -131,7 +131,7 @@ The single read point is `this.ctx.invocation.peer.principal`; an owner test is
### 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.
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 or resolver, 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
@@ -165,7 +165,7 @@ export interface TypertResourceMap {}
// @deepseek-ai/dsh-workspace/types: workspace: TypertResource<WorkspaceId>
// Runtime registries in typert
ctx.typert.resources.register(kind, { field }): TypertDisposer
ctx.typert.resources.register(kind, { field } | { resolve }): 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
@@ -213,11 +213,11 @@ export function RemoteScope<This extends object, Args extends unknown[], Result>
| 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 |
| Syntax | `@Remote({ name: 'prompt', resources: { session: (request) => request.sessionId } })` when a method must name its resource by hand; `session/page` and `session/follow` instead rely on the registered `session` default resolver; a Context method writes `@RemoteScope('agent')` and gets its Session resource from the Agent Context's `resource: 'session'`, and writes `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })` when it needs another resource. Most methods write no `resources` and get their resources from automatic resolution |
| Resource kinds | The lower-level packages that declare the identity types merge the resource kinds: `session: TypertResource<SessionId>` in `@deepseek-ai/dsh-session/types` and `workspace: TypertResource<WorkspaceId>` in `@deepseek-ai/dsh-workspace/types`. session-controller references the commands package, so a merge in session-controller would make commands reference session-controller and form a project-reference cycle. remote-access builds in `global`, which has no id and stays out of `TypertResourceMap`; typert reserves the name, and `typert.resources.register('global', …)` throws |
| Default fields and declarations | The domain that owns a kind registers its default wire field when its controller is constructed: the `SessionController` constructor calls `typert.resources.register('session', { field: 'sessionId' })` and the `WorkspaceController` constructor registers `workspace` → `workspaceId`; registering a kind twice throws. The Agent, Session, and `workspaceFileScope` lookups declare `resource: 'session'`, and the agent package's Agent Context declares `resource: 'session'` |
| Resolution precedence | Each resource kind takes, in order: ① that kind's reader in the method's `resources`; ② every lookup parameter that declares that kind as its `resource` contributes its wire identity, and an omitted optional lookup parameter contributes none; when the Context of a Context method declares that kind as its `resource`, the Context's wire identity contributes too; ③ that kind's default field: a top-level named argument of that name when one exists, otherwise that field of the first object argument. A default field names one identity, so an array there is an invalid value |
| Argument values | Resource resolution and the `args` of `decide()` read the named arguments validated against their Typert-compiled types: each JSON argument is decoded once by its codec, which drops object fields its type does not declare, and dispatch reuses that value, so a caller cannot change the resources with an undeclared field; lookup parameters and the Context identity keep their wire identities, and no lookup or Agent is resolved at that point |
| Default extraction declarations | The owning domain registers either `{ field }` or `{ resolve }`, never both. SessionController registers `resolveSessionResource`; WorkspaceController registers `workspaceId`. Duplicate kinds are rejected. Lookup and Host Context resource declarations remain independent of these defaults |
| Resolution precedence | Per kind: explicit method reader, then lookup and Host Context identities, then the registered default field or resolver. A field reads the named top-level parameter or the first object argument. A resolver receives validated named `args` and ordered parameter descriptions once before lookup resolution. Defaults return one non-empty string or undefined; arrays are invalid |
| Argument values | Compiled codecs strip undeclared object fields; SRC fallback checks JSON safety without stripping them. Resolution and dispatch use the same validated values. The Session resolver rejects a request object containing both sessionId and address, even when equal. Lookup and Context identities remain wire values until after authorization |
| 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()` |
@@ -228,7 +228,7 @@ export function RemoteScope<This extends object, Args extends unknown[], Result>
#### 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:
The table lists the endpoints #3854 viewers use and where their resources come from. Session history endpoints use the registered Session resolver; special method readers can override defaults:
| Endpoint | Resource | Resolution source | Filtering inside the method |
|---|---|---|---|
@@ -241,14 +241,14 @@ The table lists the endpoints #3854 viewers use and where their resources come f
| `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 |
| `session/attachment` | session: `args.request.sessionId` | default resolver | no |
| `skills/list` | session: `args.request.sessionId` | default resolver | no |
| `session/fork` | session: `args.request.sessionId` | default resolver | no |
| `session/prompt` | session: `args.request.sessionId` | default resolver | no |
| `session/cancel` | session: `args.request.sessionId` | default resolver | no |
| `session/projections` | session: `args.request.sessionId` | default resolver | no |
| `session/page` | session: `args.request.address.sessionId` for a Session address; `args.request.address.childSessionId` for a subagent address | default resolver | no |
| `session/follow` | same as above | default resolver | 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.
@@ -392,7 +392,7 @@ Every call path (HTTP `/api`, WebSocket streams, in-process `fetch(request, peer
| 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 }` |
| 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 the registered default field or resolver; 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`.
@@ -600,7 +600,7 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R
**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.
**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 or resolver.
**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.
@@ -635,8 +635,8 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R
| 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 |
| Resource resolution | A default field or resolver yields one resource; a default resolver returning an array is invalid; 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, with undeclared fields stripped only by compiled codecs, 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 or resolver per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec |
| Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:<name>` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) |
| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the snapshot of the Peer each index speaks for; 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) |
@@ -131,7 +131,7 @@ export function principalOf(invocation: RemoteInvocation | undefined): Principal
### 方法级访问控制
资源类型与 `@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) 的评审规则。
资源类型与 `@Remote` 的 `resources` 读取函数在 `@deepseek-ai/dsh-typert-protocol`;资源种类在 typert 中与 lookups、Contexts 并列:`TypertResourceMap` 声明种类,`typert.resources` 保存每个种类的默认 wire 字段或 resolver,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 的对应
@@ -165,7 +165,7 @@ export interface TypertResourceMap {}
// @deepseek-ai/dsh-workspace/types: workspace: TypertResource<WorkspaceId>
// Runtime registries in typert
ctx.typert.resources.register(kind, { field }): TypertDisposer
ctx.typert.resources.register(kind, { field } | { resolve }): 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
@@ -213,11 +213,11 @@ export function RemoteScope<This extends object, Args extends unknown[], Result>
| 规则 | 内容 |
|---|---|
| 写法 | `@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`,由自动解析得到资源 |
| 写法 | 方法必须手工点名资源时写 `@Remote({ name: 'prompt', resources: { session: (request) => request.sessionId } })`;`session/page` 与 `session/follow` 则依赖已注册的 `session` 默认 resolver;Context 方法写 `@RemoteScope('agent')`,由 Agent Context 的 `resource: 'session'` 得到会话资源,需要其他资源时写 `@RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })`。多数方法不写 `resources`,由自动解析得到资源 |
| 资源种类 | 资源种类由声明 id 类型的底层包合并:`session: TypertResource<SessionId>` 在 `@deepseek-ai/dsh-session/types`,`workspace: TypertResource<WorkspaceId>` 在 `@deepseek-ai/dsh-workspace/types`。session-controller 引用 commands 包,若在 session-controller 合并,commands 要引用 session-controller,形成 project reference 环。`global` 由 remote-access 内置,没有 id,不进 `TypertResourceMap`;typert 保留该名,`typert.resources.register('global', …)` 抛错 |
| 默认字段与声明 | 拥有该种类的领域在控制器构造时登记默认 wire 字段:`SessionController` 构造函数调用 `typert.resources.register('session', { field: 'sessionId' })`,`WorkspaceController` 构造函数登记 `workspace` → `workspaceId`;同一种类重复登记即抛错。Agent、Session 与 `workspaceFileScope` lookup 声明 `resource: 'session'`,agent 包的 Agent Context 声明 `resource: 'session'` |
| 解析优先级 | 对每个资源种类依次取:① 该方法 `resources` 中该种类的读取函数;② 声明了该种类 `resource` 的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Context 方法的 Context 声明了该种类 `resource` 时,其 wire 身份同样贡献;③ 该种类的默认字段:命名参数中有同名顶层参数就读它,否则读第一个对象参数的同名字段。默认字段只表示一个身份,读到数组即为非法值 |
| 参数来源 | 资源解析与 `decide()` 的 `args` 读取经 Typert 编译类型校验后的命名参数:每个 JSON 参数由其 codec 解码一次,类型未声明的对象字段被移除,分派复用该值,因此调用方无法用未声明字段改变资源;lookup 参数与 Context 身份取 wire 身份,此时不解析 lookup、不激活 Agent |
| 默认提取声明 | 资源所属领域登记 `{ field }` 或 `{ resolve }`,两者互斥。SessionController 登记 `resolveSessionResource`;WorkspaceController 登记 `workspaceId`。重复种类被拒绝;lookup 和 Host Context 的资源声明独立于默认规则 |
| 解析优先级 | 每个种类依次采用方法显式 reader、lookup 与 Host Context 身份、注册的默认字段或 resolver。字段读取同名顶层参数或首个对象参数。resolver 在 lookup 解析前调用一次,接收已校验的命名 `args` 及有序参数描述。默认规则返回一个非空字符串或 undefined,数组非法 |
| 参数来源 | 编译 codec 移除未声明的对象字段;SRC fallback 只检查 JSON 安全性,不移除字段。解析和分派使用相同的已校验值。Session resolver 拒绝同时包含 sessionId 与 address 的请求对象,即使两者相同。lookup 和 Context 身份在授权完成前保持 wire 值 |
| 解析结果 | 各种类的资源合在一起即本次调用访问的资源;一个都没有即 `[global]`;解析出资源时只用这些资源,不附加 `global` |
| 读取函数 | 以方法的位置参数调用:按描述符把命名参数映射回方法参数顺序(JSON 位为校验后的值,lookup 位为 wire 身份,取消参数位为调用的 signal);返回一个 id、id 数组(每个都须被允许)或 `undefined`(该种类无资源) |
| 非法值 | 读取函数抛错或返回空数组,或读到的值(含 Context 的 wire 身份)不是非空字符串即资源非法:Gateway 不询问 `decide()`,直接以 `remote-access/denied`(reason `resource-invalid`)拒绝调用 |
@@ -228,7 +228,7 @@ export function RemoteScope<This extends object, Args extends unknown[], Result>
#### 端点的资源
下表列出 #3854 读者用到的端点及其资源来源。只有 `session/page` 与 `session/follow` 需要显式 `resources`,其余靠 lookup 的 `resource` 或默认字段:
下表列出 #3854 读者用到的端点及其资源来源。Session 历史端点使用注册的 Session resolver;特殊方法可用显式 reader 覆盖默认规则:
| 端点 | 资源 | 解析来源 | 方法内过滤 |
|---|---|---|---|
@@ -241,14 +241,14 @@ export function RemoteScope<This extends object, Args extends unknown[], Result>
| `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` | 否 |
| `session/attachment` | session:`args.request.sessionId` | 默认 resolver | 否 |
| `skills/list` | session:`args.request.sessionId` | 默认 resolver | 否 |
| `session/fork` | session:`args.request.sessionId` | 默认 resolver | 否 |
| `session/prompt` | session:`args.request.sessionId` | 默认 resolver | 否 |
| `session/cancel` | session:`args.request.sessionId` | 默认 resolver | 否 |
| `session/projections` | session:`args.request.sessionId` | 默认 resolver | 否 |
| `session/page` | session:Session 地址读 `args.request.address.sessionId`;subagent 地址读 `args.request.address.childSessionId` | 默认 resolver | 否 |
| `session/follow` | 同上 | 默认 resolver | 否 |
| `remoteAccess/follow` | `global` | 无资源;remote-access 对任何 principal 放行 | 否 |
subagent 地址读取的是子会话转录,因此其资源是子会话。Client 在重连后会重新读取已知会话的 projections,其中包括 subagent 会话的父会话;没有该父会话授权的读者,其 projection 读取被拒。父会话的授权是否覆盖其 subagent 子会话由 policy 决定,即是否为子会话给出 binding;通用层不推导父资源。
@@ -392,7 +392,7 @@ const childPrompt = child => ({ action, args }) =>
| 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 }` |
| 4 | 非 owner:Gateway 从第 2 步校验后的参数解析资源(`resources` 读取函数、其次 lookup 参数与 Context 声明的 `resource`、再次注册的默认字段或 resolver;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 声明。
@@ -600,7 +600,7 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot
**拒绝原因只由 condition 给出。** 没有 binding 时没有 condition 可以给出原因,读者页面无法区分「分享已过期」「分享不允许写入」等情况;`explain` 只在结论已为拒绝后给出原因,不改变结论。
**全局资源 resolver 注册表(领域包按字段名注册识别函数)。** 字段识别与方法分离,方法改签名时无人提醒,跨包同名字段也会被误认;资源解析采用方法上的默认约定加 per-method `resources`,领域包只登记自己种类的默认字段。
**全局资源 resolver 注册表(领域包按字段名注册识别函数)。** 字段识别与方法分离,方法改签名时无人提醒,跨包同名字段也会被误认;资源解析采用方法上的默认约定加 per-method `resources`,领域包只登记自己种类的默认字段或 resolver。
**按点路径声明资源(`'request.address.sessionId'`)。** 路径无法由类型推导约束;`resources` 读取函数的参数类型从被装饰方法推断,拼错即编译失败。
@@ -635,8 +635,8 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot
| 鉴权顺序 | 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 |
| 资源解析 | 默认字段或 resolver 得到一个资源,默认 resolver 返回数组为非法;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,只有编译 codec 才移除未声明字段,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) |
| 资源注册表 | `typert.resources` 每个种类登记一个默认字段或 resolver,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec |
| 转发事件 | 非 owner 的 `$events` 流只在 `event:<name>` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) |
| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为每个 index 注入其所代表 Peer 的快照;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec |
| 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) |
+2 -2
View File
@@ -6,8 +6,8 @@
en: 735eb4f22d5f6f71
zh: 697eef269b133039
/api-gateway/programming-model:
en: 78eb70d6c2ce6608
zh: 826f0ccc206b7299
en: 008d81750e966b5d
zh: c31ca07da77ffdcc
/api-gateway/component-responsibilities:
en: fe382a9bcc768cc2
zh: 78e3d50d59780c3d
+1 -1
View File
@@ -12,7 +12,7 @@ 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.
`@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 or resolvers 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.
+1 -1
View File
@@ -14,7 +14,7 @@
`@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)负责该顺序。
`@Remote` 接受导出名或 `{ name?, mode?, resources? }`;`@RemoteScope(key, option?)` 的第二个参数接受导出名(如下文的 `@RemoteScope('agent', 'current')`)或 `{ name?, resources? }`。`resources` 按资源种类给出读取函数,返回一次调用所访问资源的身份,供访问控制使用;每个读取函数以方法的 wire 参数作为自身参数,类型从被装饰方法推断。多数方法不声明读取函数,因为 Gateway 还会从声明了 `resource` 的 lookup 参数与作用域 Context 以及 `typert.resources` 中的默认字段或 resolver 读取身份;[Typert 子系统](subsystems/typert.zh.md)负责该顺序。
服务通常继承 `TypertRemoteService`,让 Cordis 服务 key 与默认 Remote namespace 在构造器中显式绑定。已有其他基类的服务可以改为声明 `readonly typertRemote = bindTypertRemote(this, serviceKey)`;两种方式都会留下可检查的公开 binding,不依赖编译器向构造函数注入 symbol。
+1 -1
View File
@@ -243,7 +243,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
- `source`: [`packages/api/session-controller/src/index.ts:109`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
/** Session Controller deployment policy. */
+1 -1
View File
@@ -245,7 +245,7 @@ export interface Config {
## `@deepseek-ai/dsh-api-session-controller`
- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry`
- `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts)
- `source`: [`packages/api/session-controller/src/index.ts:109`](../packages/api/session-controller/src/index.ts)
```ts config-catalog
/** Session Controller deployment policy. */
+3 -11
View File
@@ -839,14 +839,6 @@ Host service backing the generated `ctx.remote.session` namespace.
*/
resolveAgent(sessionId: SessionId): Promise<ApiSessionAgentResult>
/**
* Inspect one attached or persisted Session without activating its Agent.
* @param sessionId - durable Session identity.
* @param signal - optional caller cancellation for persistence reads.
* @returns the current attached state or persisted header and event prefix.
*/
inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspection>
/**
* Read all visible Session rows without resuming an Agent.
* @param _request - reserved empty list request.
@@ -972,7 +964,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
* @param signal - cancellation for persistence reads.
* @returns one chronological page.
*/
@Remote({ name: 'page', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage>
@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage>
/**
* Follow one Session log from its opening or resume cursor.
@@ -981,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', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame>
@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame>
/**
* Read exact projections, returning cached hints only when migration is required.
@@ -999,7 +991,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
@Remote({ mode: 'stream' }) async *control(signal: AbortSignal): AsyncIterable<SessionControlFrame>
```
Types: [SessionId](core.md) · [SessionInspection](persistence.md) · [SessionSearchRequest](session-query.md)
Types: [SessionId](core.md) · [SessionSearchRequest](session-query.md)
Source: [`packages/api/session-controller/src/index.ts`](../../packages/api/session-controller/src/index.ts)
+3 -11
View File
@@ -843,14 +843,6 @@ Host service backing the generated `ctx.remote.session` namespace.
*/
resolveAgent(sessionId: SessionId): Promise<ApiSessionAgentResult>
/**
* Inspect one attached or persisted Session without activating its Agent.
* @param sessionId - durable Session identity.
* @param signal - optional caller cancellation for persistence reads.
* @returns the current attached state or persisted header and event prefix.
*/
inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspection>
/**
* Read all visible Session rows without resuming an Agent.
* @param _request - reserved empty list request.
@@ -976,7 +968,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
* @param signal - cancellation for persistence reads.
* @returns one chronological page.
*/
@Remote({ name: 'page', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage>
@Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage>
/**
* Follow one Session log from its opening or resume cursor.
@@ -985,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', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame>
@Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame>
/**
* Read exact projections, returning cached hints only when migration is required.
@@ -1003,7 +995,7 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
@Remote({ mode: 'stream' }) async *control(signal: AbortSignal): AsyncIterable<SessionControlFrame>
```
Types: [SessionId](core.zh.md) · [SessionInspection](persistence.zh.md) · [SessionSearchRequest](session-query.zh.md)
Types: [SessionId](core.zh.md) · [SessionSearchRequest](session-query.zh.md)
Source: [`packages/api/session-controller/src/index.ts`](../../packages/api/session-controller/src/index.ts)
+4 -4
View File
@@ -12,11 +12,11 @@
en: 21e88d68154c2c92
zh: 8c9e05c65ef0f34d
/typert-remote-calls/remote-access-resources:
en: fa95f70437c07395
zh: d4870b678b569a89
en: 2e27d0745a4e3eac
zh: eb4ad825852c595b
/typert-remote-calls/typert-registry:
en: 5827ef50011811c9
zh: 21a9e7b368ba211a
en: d891e9390cf890da
zh: f45a31cc2ce2021e
/typert-remote-calls/host-gateway:
en: 58b923de12e48f18
zh: d3c2372d8becf65e
+10 -7
View File
@@ -286,7 +286,7 @@ 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.
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 or resolver 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
/**
@@ -334,11 +334,14 @@ interface RemoteScopeOptions<WireArgs extends readonly unknown[] = never> {
* 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.
* and of lookup parameters declaring it, else the registered default of
* `typert.resources`: a `resolve` function receiving the validated named
* wire values and their ordered parameters, or a `field` naming a top-level
* argument or, failing that, a field of the first object argument. A
* throwing resolver, an array result, or a field holding an array refuses
* the call. 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<WireArgs>
}
@@ -358,7 +361,7 @@ interface RemoteMethodOptions<WireArgs extends readonly unknown[] = never> exten
## Typert registry
`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.
`ctx.typert` separates current-environment descriptors, explicitly selected Remote contributions, lookup providers, scoped Context providers, and the default fields or resolvers 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. */
+10 -7
View File
@@ -286,7 +286,7 @@ 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) 负责。
每个 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` 中登记的默认字段或 resolver;不指向任何资源的调用访问 `global`。决策由 [Peer 身份属性 Agent Note](../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 负责。
```ts type-equiv
/**
@@ -334,11 +334,14 @@ interface RemoteScopeOptions<WireArgs extends readonly unknown[] = never> {
* 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.
* and of lookup parameters declaring it, else the registered default of
* `typert.resources`: a `resolve` function receiving the validated named
* wire values and their ordered parameters, or a `field` naming a top-level
* argument or, failing that, a field of the first object argument. A
* throwing resolver, an array result, or a field holding an array refuses
* the call. 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<WireArgs>
}
@@ -358,7 +361,7 @@ interface RemoteMethodOptions<WireArgs extends readonly unknown[] = never> exten
## Typert 注册表
`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方、作用域上下文提供方与资源种类的默认字段。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。
`ctx.typert` 分开保存当前环境的 descriptor、显式选择的 Remote contribution、lookup 提供方、作用域上下文提供方与资源种类的默认字段或 resolver。lookup 提供方拥有稳定 wire 声明和默认 resolver;Host 组合可以为同一个 key 配置 effect-scoped 同步或异步 resolver,配置卸载后恢复默认策略。各项注册都是由 Cordis 持有的 effect,并返回可等待的 disposer。
```ts type-equiv
/** Minimal Typert runtime consumed through dependency inversion. */
+2 -2
View File
@@ -15,8 +15,8 @@
en: d9859219cdb0f367
zh: 2a3cfa8cb786e327
/deepseek-ai-dsh-api-gateway/host-service-typertgatewayservice-ctx-key-typertgateway:
en: 891bab7fceee4f0a
zh: be55b40151f049a1
en: b35573c6707d00d9
zh: 619bbba900b0fb8f
/deepseek-ai-dsh-api-gateway/client-service-clientremote-ctx-key-remote:
en: 0916113cdbe794fb
zh: 14893c2cba95bfd6
+1 -1
View File
@@ -32,7 +32,7 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api
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`.
Resources are read from validated arguments: Typert-compiled codecs drop undeclared object fields, while SRC fallback checks JSON safety without stripping them; 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 declaration in `typert.resources` supplies either a default field or a synchronous resolver. A field reads a top-level argument of that name or, failing that, the first object argument. A resolver is called once with `{ args, parameters }`: named validated wire values and ordered parameter descriptions containing `name` and `wire`. Both defaults return one identity or `undefined`; arrays are invalid. An explicit reader may return several identities. A resolver or reader that throws, a reader returning an empty list, 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.
+1 -1
View File
@@ -32,7 +32,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle
每次调用都指明它所代表的 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`。
资源从已校验参数中读取:Typert 编译 codec 会移除未声明的对象字段,SRC fallback 只检查 JSON 安全性而不移除字段;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` 中登记的默认字段或同步 resolver。字段规则读取同名顶层参数,没有时读取第一个对象参数的同名字段。resolver 以 `{ args, parameters }` 调用一次:命名的已校验 wire 值,以及包含 `name`、`wire` 的有序参数描述。两种默认规则均返回单个身份或 `undefined`,数组无效。显式读取函数可以返回多个身份。resolver 或读取函数抛错、读取函数返回空列表、或某个身份(含 Context 身份)不是非空字符串时,解析结果为 `invalid`;不指明任何资源的调用只访问 `global` 资源,指明了资源的调用只访问这些资源。转发事件携带其条目声明的每个资源,因此在事件路径上读取函数返回 `undefined` 同样使解析结果为 `invalid`,而没有读取函数的条目访问 `global`。
支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。
+30 -23
View File
@@ -22,10 +22,10 @@ type ResourceIds = readonly unknown[] | 'invalid'
* `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.
* the kind; otherwise the kind's registered default resolver receives named
* wire values and ordered parameter descriptions. A default field instead
* reads a top-level argument or, failing that, the first object argument.
* @param typert - registry holding default fields or resolvers, 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
@@ -33,9 +33,9 @@ type ResourceIds = readonly unknown[] | 'invalid'
* 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.
* `undefined` when the resources are invalid: a reader or resolver throws,
* a reader returns an empty list, a default field or resolver holds an
* array, or a resource id is not a non-empty string.
*/
export function callResources(
typert: Pick<TypertRegistryContract, 'contexts' | 'lookups' | 'resources'>,
@@ -46,25 +46,32 @@ export function callResources(
): readonly RemoteAccessResource[] | undefined {
// Each later source replaces an earlier one for its kind and keeps the kind's position.
const sources = new Map<string, () => 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)
const positionalArgs: readonly unknown[] = [
...descriptor.parameters.map(parameter => args[parameter.wire]),
...descriptor.cancellation === undefined ? [] : [signal],
]
for (const declaration of typert.resources.entries()) {
sources.set(declaration.kind, () => {
let id: unknown
if (declaration.resolve !== undefined) {
const resolve = declaration.resolve
try {
id = resolve({ args, parameters: descriptor.parameters })
} catch {
// A resolver failure cannot identify an authorized resource.
return 'invalid'
}
} else {
id = defaultFieldId(descriptor, args, declaration.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, [])
})
sources.set(kind, () => read(() => reader(...positionalArgs), []))
}
const resources: RemoteAccessResource[] = []
for (const [kind, ids] of sources) {
@@ -91,7 +98,7 @@ export function eventResources(
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
if (!collect(resources, kind, read(() => reader(...args), 'invalid'))) return undefined
}
return resources
}
@@ -129,11 +136,11 @@ function lookupResources(
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 {
/** Read resource identities: `undefined` yields `absent`, a list names several and must not be empty. */
function read(reader: () => unknown, absent: ResourceIds): ResourceIds {
let value: unknown
try {
value = reader(...args)
value = reader()
} catch {
// Any reader error means it cannot name its resources, so they are invalid.
return 'invalid'
@@ -21,6 +21,7 @@ import {
type TypertContext,
type TypertLookup,
type TypertResource,
type TypertResourceResolveInput,
} from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { z } from 'zod'
@@ -48,6 +49,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' {
/** Test-only resource kinds; the Gateway itself names no kind. */
'test-shelf': TypertResource<string>
'test-desk': TypertResource<string>
'test-frame': TypertResource<string>
}
}
@@ -58,11 +60,12 @@ 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 frame = (id: string) => ({ kind: 'test-frame', 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',
'accessFixture/scopedByReader', 'accessFixture/annotate', 'accessFixture/bySession', 'absentAccess/read', 'event:fixture/notice', 'event:fixture/shelf',
]
function role<K extends RemoteAccessResourceKind>(resource: K): RemoteAccessRole<K> {
@@ -78,6 +81,8 @@ function on(resource: RemoteAccessResource): RemoteAccessBinding {
return { role: role('test-shelf'), resource }
case 'test-desk':
return { role: role('test-desk'), resource }
case 'test-frame':
return { role: role('test-frame'), resource }
default:
throw new Error('no fixture role for this resource kind')
}
@@ -126,6 +131,12 @@ class AccessFixture extends TypertRemoteService {
return this.seen()
}
@Remote
bySession(sessionId: string): string {
void sessionId
return this.seen()
}
@Remote
annotate(request: { readonly title: string }): string {
this.requests.push(request)
@@ -365,6 +376,22 @@ describe('Gateway access checks', () => {
expect(decisions()).toEqual([{ action: 'absentAccess/read', resources: [GLOBAL] }])
})
it('passes declared parameter names to a registered resolver through dispatch', async () => {
const { ctx, hold } = await setup()
const resolve = vi.fn(({ args, parameters }: TypertResourceResolveInput) => {
const named = parameters.find(parameter => parameter.name === 'sessionId')
return named === undefined ? undefined : args[named.wire] as string
})
const dispose = ctx.typert.resources.register('test-frame', { resolve })
const guest = peerFor(ctx, GUEST)
hold(frame('f1'))
await expect(call(ctx, guest, 'bySession', { sessionId: 'f1' })).resolves.toBe('test-guest')
await expect(call(ctx, guest, 'bySession', { sessionId: 'f2' }))
.rejects.toMatchObject({ code: 'remote-access/denied' })
expect(resolve).toHaveBeenCalledTimes(2)
await dispose()
})
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 }))
@@ -868,6 +895,55 @@ describe('resource resolution', () => {
expect(callResources(typert, undefined, method(), {}, NEVER)).toEqual([GLOBAL])
})
it('resolves default identities through the registered function and preserves explicit overrides', async () => {
const ctx = new Context()
roots.push(ctx)
await ctx.plugin(TypertRegistry)
const resolve = vi.fn(({ args, parameters }: TypertResourceResolveInput) => {
const named = parameters.find(parameter => parameter.wire === 'roomId')
if (named !== undefined) return args[named.wire] as string | undefined
const first = parameters[0]
const request = first === undefined ? undefined : args[first.wire] as { address?: { id: string } } | undefined
return request?.address?.id
})
const dispose = ctx.typert.resources.register('test-shelf', { resolve })
expect(callResources(ctx.typert, undefined, method('roomId'), { roomId: 'r1' }, NEVER)).toEqual([ROOM])
expect(callResources(ctx.typert, undefined, method('request'), { request: { address: { id: 'r1' } } }, NEVER)).toEqual([ROOM])
expect(callResources(ctx.typert, undefined, method('request'), { request: {} }, NEVER)).toEqual([GLOBAL])
resolve.mockClear()
const args = { other: {}, later: { address: { id: 'r1' } } }
expect(callResources(ctx.typert, undefined, method('other', 'later'), args, NEVER)).toEqual([GLOBAL])
expect(resolve).toHaveBeenCalledExactlyOnceWith({ args, parameters: method('other', 'later').parameters })
resolve.mockClear()
expect(callResources(ctx.typert, undefined, {
...method('renamed'), cancellation: { parameter: 'signal' },
}, { renamed: { address: { id: 'r1' } } }, NEVER)).toEqual([ROOM])
expect(resolve).toHaveBeenCalledExactlyOnceWith({
args: { renamed: { address: { id: 'r1' } } }, parameters: method('renamed').parameters,
})
expect(callResources(ctx.typert, { 'test-shelf': () => 'override' }, method('request'), {
request: { address: { id: 'r1' } },
}, NEVER)).toEqual([shelf('override')])
await dispose()
expect(ctx.typert.resources.entries()).toEqual([])
ctx.typert.resources.register('test-shelf', { resolve: () => { throw new Error('cannot resolve') } })
expect(callResources(ctx.typert, undefined, method('request'), { request: {} }, NEVER)).toBeUndefined()
})
it('rejects arrays from a default resolver instead of authorizing several identities', async () => {
const ctx = new Context()
roots.push(ctx)
await ctx.plugin(TypertRegistry)
for (const ids of [[], ['r1'], ['r1', 'r2']]) {
const dispose = ctx.typert.resources.register('test-shelf', {
// Exercise invalid results from an untyped provider at the authorization boundary.
resolve: () => ids as never,
})
expect(callResources(ctx.typert, undefined, method(), {}, NEVER)).toBeUndefined()
await dispose()
}
})
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 }
+2 -2
View File
@@ -15,8 +15,8 @@
en: a18ecc232e3c3933
zh: b8e43580403f6d77
/deepseek-ai-dsh-api-remote-access/host-service-remoteaccessservice-ctx-key-remoteaccess:
en: 8a4be795c7aa2ef0
zh: 02550b975e2e8d61
en: 757a6bdf95e824c6
zh: e8ae9712ad28c2f3
/deepseek-ai-dsh-api-remote-access/client-face:
en: d4cf8f646fdf2d39
zh: 247f643e991aa806
+1 -1
View File
@@ -37,7 +37,7 @@ Every composition that mounts [API Gateway](../gateway/README.md) mounts this se
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()`.
The Gateway resolves resources from `@Remote` and `@RemoteScope` `resources` readers, lookup and Host Context adapter `resource` declarations, and the default field or resolver declarations 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.
+1 -1
View File
@@ -37,7 +37,7 @@ kind: "package-reference"
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()`。
Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lookup 与 Host Context adapter 的 `resource` 声明、`typert.resources` 中的默认字段或 resolver 声明解析资源,解析顺序由 [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 的失败不再记录,直到下一次上述变化。
@@ -15,8 +15,8 @@
en: 1fdf707322fb6996
zh: 0204f7e9d4aa3cce
/session-controller/use-this-package:
en: 966e2852184146bf
zh: 31e3587651d991be
en: 0c8945a47f87b5f7
zh: b099d66521dca175
/session-controller/client-references:
en: 41bdda1ac32df558
zh: aa5d0e325924066b
+1 -1
View File
@@ -35,7 +35,7 @@ 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`. A receipt names a staged file or a staged image, and an image receipt subjects the prompt to the same image-input check as an encoded image. 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.
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 a default resolver for the `session` resource kind through `typert.resources`. It reads a named `sessionId` parameter, otherwise the first object argument's `sessionId` or `address`; unrelated string parameters name no Session. An object containing both `sessionId` and `address` is invalid, even when they name the same Session. `session/page` and `session/follow` use this resolver to read `address.sessionId` for an ordinary Session address and `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.
+1 -1
View File
@@ -35,7 +35,7 @@ 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。
每个 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` 为 `session` 资源种类登记默认 resolver。它读取具名 `sessionId` 参数,否则读取第一个对象参数的 `sessionId` 或 `address`;无关字符串参数不指明 Session。对象同时包含 `sessionId` 和 `address` 时无效,即使两者指向同一 Session。`session/page` 与 `session/follow` 使用该 resolver:对普通 Session 地址读 `address.sessionId`,对 subagent 地址读 `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。
@@ -9,7 +9,6 @@ import { installModelSelection } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-preset-registry'
import { brandString } from '@deepseek-ai/dsh-brand'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-typert-registry'
@@ -123,40 +122,6 @@ export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessi
)
}
/**
* Inspect one cold Session without repairing, resuming, or publishing it.
* @param ctx - Host context carrying Session persistence.
* @param sessionId - durable Session identity.
* @param signal - optional cancellation for persistence reads.
* @returns the persisted header and complete event prefix.
*/
export async function inspectApiSession(
ctx: Context,
sessionId: SessionId,
signal?: AbortSignal,
): Promise<SessionInspection> {
try {
using observation = await ctx.sessionQuery.observeSession(sessionId, {
...(signal === undefined ? {} : { signal }),
projectionMode: 'none',
})
if (observation.header.cwd === undefined) {
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
}
return {
meta: observation.header,
inheritedEventCount: observation.inheritedEventCount,
events: [...observation.events],
}
} catch (error: unknown) {
if (error instanceof SessionQueryError
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
throw new ApiSessionNotFound(`session "${sessionId}" not found`)
}
throw error
}
}
/** Owns every operation that may create, resume, or configure a Web Agent. */
export class ApiSessionAgentController {
private readonly resumes = new Map<SessionId, Promise<Agent>>()
@@ -31,7 +31,6 @@ import {
ApiSessionSubagentOwnership,
apiSessionSubagentOwnershipError,
hasApiSessionSubagentOwner,
inspectApiSession,
type ApiSessionCreated,
} from './agent.ts'
import type {
@@ -54,12 +53,6 @@ import type {
SessionRequestId,
} from './types.ts'
interface SessionReadState {
readonly id: SessionId
readonly header: SessionHeader
readonly events: readonly SessionEvent[]
}
type PromptContentCandidate =
| SessionPromptRequest['content'][number]
| Extract<SessionUpdateQueueRequest['action'], { readonly kind: 'edit' }>['content'][number]
@@ -371,11 +364,16 @@ export class SessionCommandController {
* @returns the durable attachment reference and base64-encoded bytes.
*/
async attachment(request: SessionAttachmentRequest): Promise<SessionAttachmentValue> {
let source: SessionReadState
let ref: ImageAttachmentRef | undefined
try {
source = await this.readSessionState(request.sessionId)
using observation = await this.ctx.sessionQuery.observeSession(request.sessionId, { projectionMode: 'none' })
if (observation.source === 'prepared' && observation.header.cwd === undefined) {
throw new ApiSessionNotFound(`session "${request.sessionId}" not found`)
}
ref = referencedImage(observation.events, String(request.attachmentId))
} catch (error) {
if (error instanceof ApiSessionNotFound) {
if (error instanceof ApiSessionNotFound
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND')) {
throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId })
}
throw new RemoteError(
@@ -384,7 +382,6 @@ export class SessionCommandController {
{},
)
}
const ref = referencedImage(source.events, String(request.attachmentId))
if (ref === undefined) {
throw new RemoteError(
'session/attachment-invalid',
@@ -533,16 +530,6 @@ export class SessionCommandController {
throw new RemoteError('gateway/internal', `failed to create session: ${String(error)}`, {})
}
private async readSessionState(sessionId: SessionId): Promise<SessionReadState> {
const attached = this.ctx.sessions.get(sessionId)
if (attached !== undefined) {
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
return { id: attached.id, header: attached.header, events: attached.snapshotEvents() }
}
const inspected = await inspectApiSession(this.ctx, sessionId)
return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }
}
private async forkWorkspace(source: SessionHeader): Promise<Workspace | undefined> {
const workspaces = this.ctx.workspaceRegistry.list()
const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id))
+28 -37
View File
@@ -10,16 +10,14 @@ import { errorChain, ReasoningEffortId, type MessageSource } from '@deepseek-ai/
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, principalOf } from '@deepseek-ai/dsh-typert-protocol'
import { Remote, RemoteError, TypertRemoteService, principalOf, type TypertResourceResolveInput } 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, hasPromptContent } from './commands.ts'
@@ -80,13 +78,31 @@ 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.
* Extract the Session resource from an ID or a history address.
*
* Registers as the `session` kind default, so it applies to every Remote method of
* every namespace: a top-level `sessionId` parameter, otherwise the first object
* parameter carrying `sessionId` or an `address`. A new request type whose first
* object parameter has an unrelated `address` field would be read as an address.
* @param input - validated wire arguments and their ordered parameter descriptions.
* @returns the named Session identity, otherwise the first object's Session identity or history address.
* @throws Error when the request object supplies both sessionId and address.
*/
function addressedSession(request: { readonly address: SessionAddress }): SessionId {
return request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId
function resolveSessionResource({ args, parameters }: TypertResourceResolveInput): SessionId | undefined {
const sessionParameter = parameters.find(parameter => parameter.name === 'sessionId' || parameter.wire === 'sessionId')
if (sessionParameter !== undefined) return args[sessionParameter.wire] as SessionId | undefined
for (const parameter of parameters) {
const value = args[parameter.wire]
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
const request = value as { readonly sessionId?: SessionId; readonly address?: SessionAddress }
const address = request.address
if (request.sessionId !== undefined && address !== undefined) {
throw new Error('Session resource request must not contain both sessionId and address')
}
return request.sessionId ?? (address === undefined ? undefined
: address.kind === 'session' ? address.sessionId : address.childSessionId)
}
return undefined
}
/** Session Controller deployment policy. */
@@ -167,7 +183,7 @@ export class SessionController extends TypertRemoteService {
protected async [Service.init](): Promise<void> {
const ctx = this.ctx
ctx.typert.resources.register('session', { field: 'sessionId' })
ctx.typert.resources.register('session', { resolve: resolveSessionResource })
this.agents = new ApiSessionAgentController(ctx)
this.agents.initialize()
this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
@@ -240,28 +256,6 @@ export class SessionController extends TypertRemoteService {
return this.agents.resolveAgent(sessionId)
}
/**
* Inspect one attached or persisted Session without activating its Agent.
* @param sessionId - durable Session identity.
* @param signal - optional caller cancellation for persistence reads.
* @returns the current attached state or persisted header and event prefix.
*/
inspect(
sessionId: SessionId,
signal?: AbortSignal,
): Promise<SessionInspection> {
const attached = this.ctx.sessions.get(sessionId)
if (attached !== undefined) {
return Promise.resolve({
meta: attached.header,
inheritedEventCount: attached.inheritedEventCount,
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
events: attached.snapshotEvents(),
})
}
return inspectApiSession(this.ctx, sessionId, signal)
}
/**
* Read all visible Session rows without resuming an Agent.
* @param _request - reserved empty list request.
@@ -517,7 +511,7 @@ export class SessionController extends TypertRemoteService {
* @param signal - cancellation for persistence reads.
* @returns one chronological page.
*/
@Remote({ name: 'page', resources: { session: addressedSession } })
@Remote('page')
page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
return this.history.page(request, signal)
}
@@ -529,7 +523,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', resources: { session: addressedSession } })
@Remote({ mode: 'stream' })
follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
return this.history.follow(request, signal)
}
@@ -610,9 +604,6 @@ export class SessionController extends TypertRemoteService {
}
}
}
}
export { buildModelCatalog }
export default SessionController
@@ -6,7 +6,7 @@ import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry'
import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionHeader } from '@deepseek-ai/dsh-session'
import { SessionAlreadyOwnedError } from '@deepseek-ai/dsh-session-persistence'
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
@@ -15,8 +15,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
ApiSessionAgentController,
ApiSessionCwdConflict,
ApiSessionNotFound,
inspectApiSession,
} from '../src/agent.ts'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
@@ -83,61 +81,6 @@ describe('ApiSession identity failures', () => {
expect(new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/wanted', '/existing').message)
.toContain('belongs to "/existing"')
})
it('maps absent and cwd-less point observations to not found', async () => {
const ctx = new Context()
roots.push(ctx)
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
await expect(inspectApiSession(ctx, SessionId('missing')))
.rejects.toBeInstanceOf(ApiSessionNotFound)
const inspect = vi.fn(() => Promise.resolve(undefined))
const stat = vi.fn(() => Promise.resolve(undefined))
const disposeMissing = providePersistence(ctx, {
list: () => Promise.resolve([]),
stat,
inspect,
})
await expect(inspectApiSession(ctx, SessionId('missing'))).rejects.toBeInstanceOf(ApiSessionNotFound)
// Absence is decided by the stat preflight; the log itself is never opened.
expect(stat).toHaveBeenCalledOnce()
expect(inspect).not.toHaveBeenCalled()
disposeMissing()
const listed = header('cwd-less-catalog', null)
const disposeListed = providePersistence(ctx, {
list: () => Promise.resolve([listed]),
inspect: () => Promise.resolve({ meta: listed, events: [] }),
})
await expect(inspectApiSession(ctx, listed.id)).rejects.toBeInstanceOf(ApiSessionNotFound)
disposeListed()
const catalog = header('cwd-less-inspect')
const inspected = header('cwd-less-inspect', null)
providePersistence(ctx, {
list: () => Promise.resolve([catalog]),
inspect: () => Promise.resolve({ meta: inspected, events: [] }),
})
await expect(inspectApiSession(ctx, catalog.id)).rejects.toBeInstanceOf(ApiSessionNotFound)
})
it('forwards an explicit inspection signal', async () => {
const ctx = new Context()
roots.push(ctx)
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
const meta = header('signalled-inspection')
const inspect = vi.fn(() => Promise.resolve({ meta, inheritedEventCount: SessionLogOffset(0), events: [] }))
providePersistence(ctx, {
list: () => Promise.resolve([meta]),
inspect,
})
const signal = new AbortController().signal
await expect(inspectApiSession(ctx, meta.id, signal)).resolves.toEqual({ meta, inheritedEventCount: SessionLogOffset(0), events: [] })
expect(inspect).toHaveBeenCalledWith(meta.id, signal)
})
})
describe('ApiSession Agent lookup and recovery', () => {
@@ -342,6 +342,7 @@ function event(type: string, seq: SessionSeq, data: unknown): SessionEvent {
async function persistedController(
events: SessionEvent[],
readImage: (ref: ImageAttachmentRef) => Promise<{ ref: ImageAttachmentRef; data: Uint8Array }>,
workspace: Pick<SessionHeader, 'cwd'> = { cwd: '/workspace' },
): Promise<{ ctx: Context; controller: SessionCommandController; sessionId: SessionId }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -350,7 +351,7 @@ async function persistedController(
version: SESSION_FORMAT_VERSION,
id: sessionId,
createdAt: 1,
cwd: '/workspace',
...workspace,
isSeeded: false,
}
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
@@ -563,6 +564,23 @@ describe('Session attachment authorization', () => {
}
})
it('rejects a cold Session without cwd before reading its referenced image', async () => {
const ref = imageRef('missing-cwd')
const readImage = vi.fn(async () => ({ ref, data: new Uint8Array([1]) }))
const fixture = await persistedController(
[event('user/message', SessionSeq(0), { id: 'image', role: 'user', source: { kind: 'user' }, content: [{ type: 'image', attachment: ref }] })],
readImage,
{},
)
try {
await expect(fixture.controller.attachment({ sessionId: fixture.sessionId, attachmentId: ref.attachmentId }))
.rejects.toMatchObject({ code: 'session/not-found', details: { sessionId: fixture.sessionId } })
expect(readImage).not.toHaveBeenCalled()
} finally {
await fixture.ctx.fiber.dispose()
}
})
it('maps a cold observation failure to an internal authorization error', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -63,13 +63,6 @@ describe('SessionController facade', () => {
ctx.on('api-session/error', failure)
ctx.on('api-session/activity', activity)
await expect(controller.inspect(sessionId)).resolves.toEqual({
meta: header,
inheritedEventCount: SessionLogOffset(0),
events,
})
expect(inspect).toHaveBeenCalledOnce()
const session = ctx.sessions.create(sessionId, { meta: header })
const agent = {
id: sessionId,
@@ -91,12 +84,6 @@ describe('SessionController facade', () => {
await expect(resolveUploadAgent(sessionId)).rejects.toBe(activationError)
await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent })
await expect(controller.inspect(sessionId)).resolves.toEqual({
meta: header,
inheritedEventCount: SessionLogOffset(0),
events,
})
expect(inspect).toHaveBeenCalledOnce()
ctx.emit('agent/status', { agent, status: 'running' })
ctx.emit('agent/error', { agent, turn: 1, step: 0, error: new Error('fixture failure') })
session.append('user/message', createUserMessage({
@@ -1,10 +1,10 @@
/**
* 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.
* registers and its default resolver, which supplies the identities 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'
@@ -86,8 +86,8 @@ async function harness(): Promise<{
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 shared = await loop.create(SHARED, {}, { cwd: '/tmp' })
const hidden = await loop.create(PRIVATE, {}, { cwd: '/tmp' })
const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const as = (principal: Principal, method: string): Context => {
const invocation: RemoteInvocation = {
@@ -107,18 +107,77 @@ function message(text: string) {
}
describe('Session Controller access', () => {
it('owns the Session resource kind and its default sessionId field', async () => {
it('owns the Session resource kind and its default resolver', 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()
const { ctx, controller } = await harness()
const resource = ctx.typert.resources.get('session')
if (resource?.resolve === undefined) throw new Error('Session resource resolver is missing')
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)
const marker = remoteMethods(controller).find(marker => marker.method === method)
expect(marker).toBeDefined()
expect(marker?.resources?.['session']).toBeUndefined()
}
const resolve = (request: unknown) => resource.resolve({ args: { request }, parameters: [{ name: 'request', wire: 'request' }] })
expect(resolve({ address: { kind: 'session', sessionId: SHARED } })).toBe(SHARED)
expect(resolve({ address: { kind: 'subagent', parentSessionId: SHARED, childSessionId: PRIVATE } })).toBe(PRIVATE)
})
it('resolves named Session IDs and first-object requests without treating unrelated strings as identities', async () => {
const { ctx } = await harness()
const resource = ctx.typert.resources.get('session')
if (resource?.resolve === undefined) throw new Error('Session resource resolver is missing')
const resolve = (args: Readonly<Record<string, unknown>>, ...names: string[]) => resource.resolve({
args, parameters: names.map(name => ({ name, wire: name })),
})
expect(resolve({ sessionId: SHARED, zzz: 'other' }, 'sessionId', 'zzz')).toBe(SHARED)
expect(resolve({ options: { sessionId: SHARED }, yyy: 'other' }, 'options', 'yyy')).toBe(SHARED)
expect(resolve({ options: { address: { kind: 'session', sessionId: SHARED } }, xxx: 'other' }, 'options', 'xxx')).toBe(SHARED)
expect(resolve({ options: { address: { kind: 'subagent', parentSessionId: SHARED, childSessionId: PRIVATE } } }, 'options')).toBe(PRIVATE)
expect(resolve({ ns: SHARED, value: 'other' }, 'ns', 'value')).toBeUndefined()
expect(resolve({ ref: SHARED, value: 'other' }, 'ref', 'value')).toBeUndefined()
expect(resolve({ zzz: 'other', sessionId: SHARED }, 'zzz', 'sessionId')).toBe(SHARED)
expect(resolve({ first: {}, later: { sessionId: SHARED } }, 'first', 'later')).toBeUndefined()
expect(resolve({ count: 1, tags: [], empty: null, options: { sessionId: SHARED } }, 'count', 'tags', 'empty', 'options')).toBe(SHARED)
expect(resolve({}, 'sessionId')).toBeUndefined()
expect(resolve({})).toBeUndefined()
expect(resource.resolve({ args: { target: SHARED }, parameters: [{ name: 'sessionId', wire: 'target' }] })).toBe(SHARED)
})
it.each(['page', 'follow'])('authorizes %s by Address and rejects coexisting Session IDs through SRC dispatch', async (method) => {
const { ctx, shared, hidden } = await harness()
shared.inbox.append('next-turn', message('shared history'))
hidden.inbox.append('next-turn', message('private history'))
await ctx.plugin(TypertGatewayService)
guestPolicy(ctx, {
role: { name: 'test:history-reader', resource: 'session', actions: ['session/page', 'session/follow'] },
resource: { kind: 'session', id: SHARED },
})
const abort = new AbortController()
const peer: PeerScope = { id: 'history-access-peer' as PeerId, ctx, principal: GUEST, interactions: 'answer', signal: abort.signal, dispose: async () => {} }
const invoke = async (request: Record<string, unknown>): Promise<unknown> => {
const call = { peer, namespace: 'session', method, args: { request }, signal: abort.signal }
if (method === 'page') return ctx.typertGateway.invoke(call)
const stream = (await ctx.typertGateway.stream(call))[Symbol.asyncIterator]()
try {
return (await stream.next()).value
} finally {
await stream.return?.()
}
}
const request = (sessionId: SessionId) => ({ address: { kind: 'session', sessionId }, throughSeq: 0 })
try {
await expect(invoke(request(SHARED))).resolves.toBeDefined()
await expect(invoke(request(PRIVATE))).rejects.toMatchObject({ code: 'remote-access/denied', details: { reason: 'no-allow' } })
for (const target of [SHARED, PRIVATE]) {
await expect(invoke({ ...request(target), sessionId: SHARED }))
.rejects.toMatchObject({ code: 'remote-access/denied', details: { reason: 'resource-invalid' } })
}
} finally {
abort.abort()
}
})
@@ -2262,12 +2262,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'sessionId', description: 'Session identity whose Agent owns the operation.' }],
returns: 'the live Agent or the stable Session-domain failure.',
},
{
signature: 'inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionInspection>',
description: 'Inspect one attached or persisted Session without activating its Agent.',
parameters: [{ name: 'sessionId', description: 'durable Session identity.' }, { name: 'signal', description: 'optional caller cancellation for persistence reads.' }],
returns: 'the current attached state or persisted header and event prefix.',
},
{
signature: '@Remote(\'list\') async list(_request: SessionListRequest, signal: AbortSignal): Promise<SessionListValue>',
description: 'Read all visible Session rows without resuming an Agent.',
@@ -2367,13 +2361,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'acknowledgement that cancellation was requested.',
},
{
signature: '@Remote({ name: \'page\', resources: { session: addressedSession } }) page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage>',
signature: '@Remote(\'page\') page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage>',
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\', resources: { session: addressedSession } }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame>',
signature: '@Remote({ mode: \'stream\' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame>',
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.',
+2 -2
View File
@@ -21,8 +21,8 @@
en: 63968de5fc817d06
zh: fbe2804b2a2d70d3
/deepseek-ai-dsh-typert-protocol/use-this-package/identifying-the-caller-and-admitting-non-owner-callers:
en: 78e08ae732a8f6f4
zh: be15fb7599994fb9
en: 668e1adf84ba4c50
zh: 56561e73ec79ee38
/deepseek-ai-dsh-typert-protocol/use-this-package/associating-host-objects-and-contexts-with-wire-identities:
en: 3db384ae6f215632
zh: 56207184b09d4018
+1 -1
View File
@@ -60,7 +60,7 @@ The invoker takes a caller-owned, mounted, unscoped Client Remote and resolves i
`PeerScope.principal` is the caller identity that in-process Host code fixes when it creates the Peer; no wire field can set it, and `PeerScope.signal` aborts when the Peer scope ends. `PrincipalMap` is merge-extensible: each driver declares one JSON entry with a unique literal `kind` and no credential, because a principal can be persisted in a Session log; the built-in entry is `owner: { kind: 'owner' }`. A Remote method that records its caller on durable output calls `principalOf(this.ctx.invocation)`, which returns the principal of a non-owner caller and `undefined` for the owner and outside a Remote call, and writes the field only when it is defined.
Every Remote method is under access control: [`dsh-api-remote-access`](../../api/remote-access/README.md) decides whether a non-owner caller may reach it on the resources the call touches. `TypertResourceMap` is merge-extensible beside `TypertLookupMap`: the package that owns an identity type declares its resource kind as `TypertResource<Id>` (`dsh-session` declares `session: TypertResource<SessionId>`, `dsh-workspace` declares `workspace: TypertResource<WorkspaceId>`). A `RemoteAccessResource` is either the built-in identity-less `{ kind: 'global' }` or one declared kind with its identity; resources are independent, so access to `global` implies nothing about any session. At runtime the domain that owns a kind registers its default wire field through `typert.resources.register(kind, { field })`, which rejects `global`; a lookup provider and a Host Context adapter name the kind their wire identity carries through `resource`, so a method with an Agent lookup parameter or an Agent-scoped receiver carries a `session` resource without options. The `resources` option of `@Remote` and `@RemoteScope` maps a kind to a reader that receives the method's wire arguments as its own parameters; their types are inferred from the decorated method, with lookup parameters mapped to their wire types, so `@Remote({ name: 'page', resources: { session: request => request.address.sessionId } })` reads a field the defaults cannot. A call whose readers and defaults name no resource touches `global`; they must name every resource the method accesses, because access control never checks one they omit. A forwarded `emit` entry of `TypertForwardableEventEntry` declares `resources` readers typed by its own event's parameters, and an entry without them touches `global`. Resource resolution belongs to the [API Gateway](../../api/gateway/README.md); decisions and refusal reasons belong to [`dsh-api-remote-access`](../../api/remote-access/README.md).
Every Remote method is under access control: [`dsh-api-remote-access`](../../api/remote-access/README.md) decides whether a non-owner caller may reach it on the resources the call touches. `TypertResourceMap` is merge-extensible beside `TypertLookupMap`: the package that owns an identity type declares its resource kind as `TypertResource<Id>` (`dsh-session` declares `session: TypertResource<SessionId>`, `dsh-workspace` declares `workspace: TypertResource<WorkspaceId>`). A `RemoteAccessResource` is either the built-in identity-less `{ kind: 'global' }` or one declared kind with its identity; resources are independent, so access to `global` implies nothing about any session. At runtime the domain that owns a kind registers its default extraction through `typert.resources.register(kind, { field })` or `{ resolve }`, which rejects `global`. A default resolver receives `{ args, parameters }`: validated named wire values and ordered parameter descriptions containing `name` and `wire`, before lookup resolution; it returns an identity or `undefined` when absent. This lets a domain distinguish a named identity parameter from an unrelated string; a lookup provider and a Host Context adapter name the kind their wire identity carries through `resource`, so a method with an Agent lookup parameter or an Agent-scoped receiver carries a `session` resource without options. The `resources` option of `@Remote` and `@RemoteScope` maps a kind to a reader that receives the method's wire arguments as its own parameters; their types are inferred from the decorated method, with lookup parameters mapped to their wire types, so `@Remote({ name: 'page', resources: { session: request => request.address.sessionId } })` reads a field the defaults cannot. A call whose readers and defaults name no resource touches `global`; they must name every resource the method accesses, because access control never checks one they omit. A forwarded `emit` entry of `TypertForwardableEventEntry` declares `resources` readers typed by its own event's parameters, and an entry without them touches `global`. Resource resolution belongs to the [API Gateway](../../api/gateway/README.md); decisions and refusal reasons belong to [`dsh-api-remote-access`](../../api/remote-access/README.md).
### Associating Host objects and Contexts with wire identities
+1 -1
View File
@@ -60,7 +60,7 @@ export class GoalService extends TypertRemoteService {
`PeerScope.principal` 是进程内 Host 代码创建 Peer 时固定的调用方身份;任何 wire 字段都无法设置它,`PeerScope.signal` 则在 Peer scope 结束时中止。`PrincipalMap` 可合并扩展:每个驱动方声明一个 JSON 条目,带唯一的字面量 `kind` 且不含凭据,因为 principal 可能持久化到会话日志中;内置条目是 `owner: { kind: 'owner' }`。在持久输出上记录调用方的 Remote 方法调用 `principalOf(this.ctx.invocation)`:调用方不是 owner 时它返回该 principal,对 owner 以及在 Remote 调用之外返回 `undefined`;方法只在其有定义时写入该字段。
每个 Remote 方法都受访问控制:由 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md) 判定非 owner 调用方能否在该调用触及的资源上调用它。`TypertResourceMap` 与 `TypertLookupMap` 并列、可合并扩展:拥有身份类型的包以 `TypertResource<Id>` 声明自己的资源种类(`dsh-session` 声明 `session: TypertResource<SessionId>`,`dsh-workspace` 声明 `workspace: TypertResource<WorkspaceId>`)。`RemoteAccessResource` 要么是内置、无身份的 `{ kind: 'global' }`,要么是某个已声明种类加其身份;资源之间相互独立,`global` 上的访问不蕴含任何会话上的访问。运行时由拥有该种类的领域经 `typert.resources.register(kind, { field })` 登记默认 wire 字段,该登记拒绝 `global`;lookup provider 与 Host Context adapter 经 `resource` 声明其 wire 身份所属的种类,因此带 Agent 查找参数或 Agent 作用域接收器的方法无需选项即携带 `session` 资源。`@Remote` 与 `@RemoteScope` 的 `resources` 选项把种类映射到一个以方法 wire 参数为自身位置参数的读取函数;这些参数的类型从被装饰的方法推断,查找参数映射为其 wire 类型,因此 `@Remote({ name: 'page', resources: { session: request => request.address.sessionId } })` 可以读取默认规则读不到的字段。读取函数与默认规则都未给出资源的调用触及 `global`;它们必须给出方法访问的全部资源,因为访问控制从不检查被遗漏的资源。`TypertForwardableEventEntry` 中的转发 `emit` 条目可以声明 `resources` 读取函数,其类型由该事件自身的参数决定;未声明的条目触及 `global`。资源解析归 [API Gateway](../../api/gateway/README.zh.md);判定与拒绝原因归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md)。
每个 Remote 方法都受访问控制:由 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md) 判定非 owner 调用方能否在该调用触及的资源上调用它。`TypertResourceMap` 与 `TypertLookupMap` 并列、可合并扩展:拥有身份类型的包以 `TypertResource<Id>` 声明自己的资源种类(`dsh-session` 声明 `session: TypertResource<SessionId>`,`dsh-workspace` 声明 `workspace: TypertResource<WorkspaceId>`)。`RemoteAccessResource` 要么是内置、无身份的 `{ kind: 'global' }`,要么是某个已声明种类加其身份;资源之间相互独立,`global` 上的访问不蕴含任何会话上的访问。运行时由拥有该种类的领域经 `typert.resources.register(kind, { field })` 或 `{ resolve }` 登记默认提取规则,该登记拒绝 `global`。默认 resolver 在 lookup 解析前接收 `{ args, parameters }`:已校验的命名 wire 值,以及包含 `name`、`wire` 的有序参数描述;返回身份,无匹配时返回 `undefined`。领域因此可以区分具名身份参数与无关字符串;lookup provider 与 Host Context adapter 经 `resource` 声明其 wire 身份所属的种类,因此带 Agent 查找参数或 Agent 作用域接收器的方法无需选项即携带 `session` 资源。`@Remote` 与 `@RemoteScope` 的 `resources` 选项把种类映射到一个以方法 wire 参数为自身位置参数的读取函数;这些参数的类型从被装饰的方法推断,查找参数映射为其 wire 类型,因此 `@Remote({ name: 'page', resources: { session: request => request.address.sessionId } })` 可以读取默认规则读不到的字段。读取函数与默认规则都未给出资源的调用触及 `global`;它们必须给出方法访问的全部资源,因为访问控制从不检查被遗漏的资源。`TypertForwardableEventEntry` 中的转发 `emit` 条目可以声明 `resources` 读取函数,其类型由该事件自身的参数决定;未声明的条目触及 `global`。资源解析归 [API Gateway](../../api/gateway/README.zh.md);判定与拒绝原因归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md)。
### 把 Host 对象与 Context 关联到 wire identity
+9 -5
View File
@@ -73,6 +73,7 @@ export type {
TypertResourceReader,
TypertResourceReaders,
TypertResourceRegistry,
TypertResourceResolveInput,
TypertLocalRegistry,
TypertLookup,
TypertLookupDefinition,
@@ -157,11 +158,14 @@ export interface RemoteScopeOptions<WireArgs extends readonly unknown[] = never>
* 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.
* and of lookup parameters declaring it, else the registered default of
* `typert.resources`: a `resolve` function receiving the validated named
* wire values and their ordered parameters, or a `field` naming a top-level
* argument or, failing that, a field of the first object argument. A
* throwing resolver, an array result, or a field holding an array refuses
* the call. 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<WireArgs>
}
+29 -11
View File
@@ -878,25 +878,43 @@ export interface TypertContextRegistry {
subscribe(listener: TypertRegistryListener): TypertDisposer
}
/** Default wire field of one resource kind. */
export interface TypertResourceDeclaration {
/** Top-level or first-object wire field holding one identity of the kind. */
readonly field: string
/** Validated wire values and their ordered business parameters for resource extraction. */
export interface TypertResourceResolveInput {
/** Wire field to its validated value, including lookup and Context identities as wire values. */
readonly args: Readonly<Record<string, unknown>>
/** Declared parameters in call order, so a resolver can match a name or an aliased wire field. */
readonly parameters: readonly Pick<InvocationParameterDescriptor, 'name' | 'wire'>[]
}
/** One registered resource kind with its default wire field. */
export interface TypertResourceEntry extends TypertResourceDeclaration {
/** Registered resource kind. */
/** Default identity extraction for one resource kind; exactly one of `field` and `resolve` is declared. */
export type TypertResourceDeclaration =
| {
readonly field: string
readonly resolve?: never
}
| {
readonly field?: never
/**
* Called once before lookups resolve. It takes no receiver, so it reads only its
* input. Returns one identity, or undefined when absent; throws or returns an
* array to reject the call.
*/
resolve(this: void, input: TypertResourceResolveInput): string | undefined
}
/** One registered resource kind with its default identity extraction. */
export type TypertResourceEntry = TypertResourceDeclaration & {
/** Resource kind contributed by its owning domain. */
readonly kind: string
}
/** Runtime registry of resource kinds' default wire fields. */
/** Runtime registry of resource kinds' default identity extraction. */
export interface TypertResourceRegistry {
/**
* Register one kind's default wire field for the calling fiber. Only the
* Register one kind's default identity extraction for the calling fiber. Only the
* domain that owns the kind registers it.
* @param kind - merge-declared resource kind.
* @param declaration - the kind's default wire field.
* @param declaration - either a default wire field or a resolver, never both.
* @returns disposer withdrawing the exact registration.
* @throws Error when the kind is already registered or is `global`, the built-in resource kind.
*/
@@ -907,7 +925,7 @@ export interface TypertResourceRegistry {
* @returns the live declaration, or `undefined` when unregistered.
*/
get(kind: string): TypertResourceDeclaration | undefined
/** @returns a registration-order snapshot of registered kinds with their fields. */
/** @returns a registration-order snapshot of registered kinds with their extraction declarations. */
entries(): readonly TypertResourceEntry[]
/**
* Observe later resource-kind changes.
+4 -4
View File
@@ -27,8 +27,8 @@
en: 9ca425515904d5c9
zh: b251226ecfa32002
/deepseek-ai-dsh-typert-registry/use-this-package/lookup-and-context-providers:
en: 937dfd14d9e18a5c
zh: 6cf6aa352c8825c6
en: a5392d1b04cde813
zh: 20e09e9a70701821
/deepseek-ai-dsh-typert-registry/understand-the-implementation:
en: 1a78dfbae50a8b0d
zh: dc10b4b3f3d832ed
@@ -36,8 +36,8 @@
en: 4d50f3efeb45eaa4
zh: ec3575d29d5a040b
/deepseek-ai-dsh-typert-registry/understand-the-implementation/sub-registries:
en: d6007eef4d33f3a4
zh: 499ff578453d5cc0
en: 6e38f43ea3a1d5f6
zh: 5ff82d78d6afbab5
/deepseek-ai-dsh-typert-registry/understand-the-implementation/identity-and-validation:
en: 4764fc33a7e16e60
zh: 1ddf03cbe16197da
+2 -2
View File
@@ -51,7 +51,7 @@ Harness offline integrations register an ordinary `TYPERT_REMOTE` contribution w
Remote calls resolve Host objects and scoped Contexts through `ctx.typert.lookups` and `ctx.typert.contexts`. `registerHost()` installs the Host wire declaration and its wire-to-Context resolver, while `configureHost()` replaces only that resolver. `registerClient()` installs the bidirectional Client adapter for the same merge-declared kind. Host-to-Client event sources carry their domain identity explicitly instead of projecting it back from a Context.
`ctx.typert.resources` holds the default wire field of each resource kind declared in `TypertResourceMap`. The domain that owns a kind registers it with `register(kind, { field })` as a Cordis effect of the calling fiber; a second registration of one kind throws, and so does `global`, the built-in identity-less resource kind. `get(kind)` reads one declaration, and `entries()` lists every registered `{ kind, field }` in registration order. A lookup provider and a Host Context adapter may name the resource kind of their wire identity through `resource`, and a configured resolver keeps it. The Gateway reads all three when it resolves the resources of a non-owner call.
`ctx.typert.resources` holds the default identity extraction of each resource kind declared in `TypertResourceMap`. The domain that owns a kind registers either `{ field }` or `{ resolve }`, never both, as a Cordis effect of the calling fiber; a second registration of one kind throws, and so does `global`, the built-in identity-less resource kind. A resolver is called once, as a plain function without a receiver, so it reads only its `{ args, parameters }` input; a declaration with neither member throws at registration. `get(kind)` reads one declaration, and `entries()` lists each `kind` with its field or resolver declaration in registration order. A lookup provider and a Host Context adapter may name the resource kind of their wire identity through `resource`, and a configured resolver keeps it. The Gateway reads all three when it resolves the resources of a non-owner call.
-----
@@ -73,7 +73,7 @@ The registry is built on one principle: a contribution is one atomic, fiber-owne
- `ctx.typert.remotes` — consumer-selected contributions mounted in the calling fiber.
- `ctx.typert.lookups` — lookup providers plus composition-owned resolver overrides per key.
- `ctx.typert.contexts` — Host Context providers and Client Context binders per scoped key.
- `ctx.typert.resources` — the default wire field per resource kind.
- `ctx.typert.resources` — the default field or resolver per resource kind.
Each sub-registry publishes `TypertRegistryChange` events to subscribed listeners; a throwing listener is logged and does not stop later listeners.
+2 -2
View File
@@ -51,7 +51,7 @@ Harness 离线集成在各自的 profile Context 中通过 `ctx.typert.remotes.r
Remote 调用通过 `ctx.typert.lookups` 与 `ctx.typert.contexts` 解析 Host 对象与作用域 Context。`registerHost()` 安装 Host wire 声明及其 wire 到 Context 的 resolver,`configureHost()` 只替换该 resolver。`registerClient()` 为同一个 merge-declared kind 安装双向 Client adapter。Host 到 Client 的事件源显式携带领域 identity,而不从 Context 反向投影。
`ctx.typert.resources` 保存 `TypertResourceMap` 中每个资源种类的默认 wire 字段。拥有该种类的领域以 `register(kind, { field })` 登记,登记是调用方 fiber 的 Cordis effect;同一种类重复登记即抛错,登记内置的无身份资源种类 `global` 同样抛错。`get(kind)` 读取一个种类的声明,`entries()` 按登记顺序列出全部已登记的 `{ kind, field }`。lookup provider 与 Host Context adapter 可经 `resource` 声明其 wire 身份所属的资源种类,配置的 resolver 保留该声明。Gateway 解析非 owner 调用的资源时读取这三者。
`ctx.typert.resources` 保存 `TypertResourceMap` 中每个资源种类的默认身份提取规则。拥有该种类的领域登记 `{ field }` 或 `{ resolve }`,不能同时提供两者,登记是调用方 fiber 的 Cordis effect;同一种类重复登记即抛错,登记内置的无身份资源种类 `global` 同样抛错。resolver 只以纯函数形式调用一次、不接收 receiver,因此只读取其 `{ args, parameters }` 输入;两个成员都不提供的声明在登记时抛错。`get(kind)` 读取一个种类的声明,`entries()` 按登记顺序列出每个 `kind` 及其字段或 resolver 声明。lookup provider 与 Host Context adapter 可经 `resource` 声明其 wire 身份所属的资源种类,配置的 resolver 保留该声明。Gateway 解析非 owner 调用的资源时读取这三者。
-----
@@ -73,7 +73,7 @@ Remote 调用通过 `ctx.typert.lookups` 与 `ctx.typert.contexts` 解析 Host
- `ctx.typert.remotes`——在调用方 fiber 中挂载的、由消费方选中的贡献。
- `ctx.typert.lookups`——lookup 提供方,以及按键配置的组合方解析器覆盖。
- `ctx.typert.contexts`——按作用域键配置的 Host Context 提供方与 Client Context 绑定器。
- `ctx.typert.resources`——每个资源种类的默认 wire 字段。
- `ctx.typert.resources`——每个资源种类的默认字段或 resolver。
每个子注册表都会向已订阅的监听器发布 `TypertRegistryChange` 事件;抛异常的监听器会被记录日志,且不会阻止后续监听器。
+15 -3
View File
@@ -461,7 +461,7 @@ class ResourceStore {
return {
register: (kind, declaration) => this.register(ctx, kind, declaration),
get: kind => this.declarations.get(kind)?.provider,
entries: () => [...this.declarations].map(([kind, entry]) => Object.freeze({ kind, field: entry.provider.field })),
entries: () => [...this.declarations].map(([kind, entry]) => Object.freeze({ kind, ...entry.provider })),
subscribe: listener => this.changes.subscribe(ctx, listener),
}
}
@@ -471,9 +471,21 @@ class ResourceStore {
if (kind === GLOBAL_RESOURCE_KIND) {
throw new Error(`typert: resource kind "${GLOBAL_RESOURCE_KIND}" is reserved for the built-in global resource`)
}
validateWireName('resource field', declaration.field)
if (this.declarations.has(kind)) throw new Error(`typert: resource kind "${kind}" is already registered`)
const entry: ProviderEntry<TypertResourceDeclaration> = { provider: { field: declaration.field }, owner: {} }
// Copy by branch so a prototype method cannot vanish in a spread; an opaque declaration fails here
// rather than resolving every call of this kind to the global resource.
const resolve = declaration.resolve
const field = declaration.field
let provider: TypertResourceDeclaration
if (resolve !== undefined) {
provider = { resolve }
} else if (field !== undefined) {
validateWireName('resource field', field)
provider = { field }
} else {
throw new Error(`typert: resource kind "${kind}" declares neither a field nor a resolve function`)
}
const entry: ProviderEntry<TypertResourceDeclaration> = { provider, owner: {} }
const { declarations, changes } = this
return ctx.effect(function* () {
declarations.set(kind, entry)
@@ -11,6 +11,7 @@ import type {
InvocationDescriptor,
TypertContext,
TypertResource,
TypertResourceDeclaration,
TypertResourceKind,
TypertLookup,
TypertRegistryChange,
@@ -449,6 +450,45 @@ describe('TypertRegistry', () => {
expect(ctx.typert.resources.get('registryFixture')).toBeUndefined()
})
it('preserves a default resolver in get and entries and withdraws it with its fiber', async () => {
const ctx = await makeCtx()
const resolve = () => 'fixture-id'
const changes: TypertRegistryChange[] = []
ctx.typert.resources.subscribe((change) => { changes.push(change) })
const fiber = ctx.plugin({
inject: ['typert'],
apply: (pluginCtx: Context) => { pluginCtx.typert.resources.register('registryFixture', { resolve }) },
})
await fiber
expect(ctx.typert.resources.get('registryFixture')).toEqual({ resolve })
expect(ctx.typert.resources.entries()).toEqual([{ kind: 'registryFixture', resolve }])
expect(() => ctx.typert.resources.register('registryFixture', { resolve })).toThrow('already registered')
await fiber.dispose()
expect(ctx.typert.resources.get('registryFixture')).toBeUndefined()
expect(ctx.typert.resources.entries()).toEqual([])
expect(changes).toEqual([
{ kind: 'resource', key: 'registryFixture' },
{ kind: 'resource', key: 'registryFixture' },
])
})
it('keeps a prototype resolver callable and rejects a declaration that names no default', async () => {
const ctx = await makeCtx()
class PrototypeResolver {
resolve(): string { return 'prototype-id' }
}
const declaration: TypertResourceDeclaration = new PrototypeResolver()
ctx.typert.resources.register('registryFixture', declaration)
const resolve = ctx.typert.resources.get('registryFixture')?.resolve
expect(resolve?.({ args: {}, parameters: [] })).toBe('prototype-id')
const opaque = {} as TypertResourceDeclaration
const other = new Context()
await other.plugin(TypertRegistry)
expect(() => other.typert.resources.register('registryFixture', opaque))
.toThrow('declares neither a field nor a resolve function')
await other.fiber.dispose()
})
it('reserves the built-in global resource kind', async () => {
const ctx = await makeCtx()
const changes: TypertRegistryChange[] = []