mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
fix(typert): harden default resource declarations and sync contracts
This commit is contained in:
1 parent
3c4a79cc1b
commit
048845f841
19 files changed
+141
-62
No files matched your search
+8
-8
@@ -27,14 +27,14 @@
|
||||
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: b79a4e9401b44978
|
||||
zh: eaca7cd4d07f485a
|
||||
en: d50ae22978165c57
|
||||
zh: 8c1fcfb7dc2d3367
|
||||
/agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/endpoint-resources:
|
||||
en: 056a141b31270cba
|
||||
zh: 3f6caafb3a13c192
|
||||
@@ -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,8 +75,8 @@
|
||||
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
|
||||
|
||||
+5
-5
@@ -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,7 +213,7 @@ 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 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 |
|
||||
@@ -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.
|
||||
|
||||
|
||||
+5
-5
@@ -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,7 +213,7 @@ 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', …)` 抛错 |
|
||||
| 默认提取声明 | 资源所属领域登记 `{ field }` 或 `{ resolve }`,两者互斥。SessionController 登记 `resolveSessionResource`;WorkspaceController 登记 `workspaceId`。重复种类被拒绝;lookup 和 Host Context 的资源声明独立于默认规则 |
|
||||
| 解析优先级 | 每个种类依次采用方法显式 reader、lookup 与 Host Context 身份、注册的默认字段或 resolver。字段读取同名顶层参数或首个对象参数。resolver 在 lookup 解析前调用一次,接收已校验的命名 `args` 及有序参数描述。默认规则返回一个非空字符串或 undefined,数组非法 |
|
||||
@@ -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` 读取函数的参数类型从被装饰方法推断,拼错即编译失败。
|
||||
|
||||
|
||||
@@ -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:104`](../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. */
|
||||
|
||||
@@ -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:104`](../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. */
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ type ResourceIds = readonly unknown[] | 'invalid'
|
||||
* 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, lookup providers, and Host Context adapters.
|
||||
* @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'>,
|
||||
@@ -52,17 +52,18 @@ export function callResources(
|
||||
]
|
||||
for (const declaration of typert.resources.entries()) {
|
||||
sources.set(declaration.kind, () => {
|
||||
let id: unknown
|
||||
if (declaration.resolve !== undefined) {
|
||||
let id: unknown
|
||||
const resolve = declaration.resolve
|
||||
try {
|
||||
id = declaration.resolve({ args, parameters: descriptor.parameters })
|
||||
} catch (error) {
|
||||
id = resolve({ args, parameters: descriptor.parameters })
|
||||
} catch {
|
||||
// A resolver failure cannot identify an authorized resource.
|
||||
return 'invalid'
|
||||
}
|
||||
return Array.isArray(id) ? 'invalid' : id === undefined ? [] : [id]
|
||||
} else {
|
||||
id = defaultFieldId(descriptor, args, declaration.field)
|
||||
}
|
||||
const id = defaultFieldId(descriptor, args, declaration.field)
|
||||
return Array.isArray(id) ? 'invalid' : id === undefined ? [] : [id]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,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> {
|
||||
@@ -79,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')
|
||||
}
|
||||
@@ -127,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)
|
||||
@@ -366,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 }))
|
||||
|
||||
@@ -79,6 +79,11 @@ declare module '@deepseek-ai/cordis' {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
@@ -63,7 +63,6 @@ describe('SessionController facade', () => {
|
||||
ctx.on('api-session/error', failure)
|
||||
ctx.on('api-session/activity', activity)
|
||||
|
||||
|
||||
const session = ctx.sessions.create(sessionId, { meta: header })
|
||||
const agent = {
|
||||
id: sessionId,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -158,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>
|
||||
}
|
||||
|
||||
@@ -880,17 +880,26 @@ export interface TypertContextRegistry {
|
||||
|
||||
/** 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'>[]
|
||||
}
|
||||
|
||||
/** Default identity extraction for one 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: string
|
||||
readonly resolve?: never
|
||||
}
|
||||
| {
|
||||
readonly field?: never
|
||||
/** Called once before lookups resolve. Returns one identity, or undefined when absent; throws to reject the resource. */
|
||||
resolve(input: TypertResourceResolveInput): string | undefined
|
||||
/**
|
||||
* 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. */
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
en: 9ca425515904d5c9
|
||||
zh: b251226ecfa32002
|
||||
/deepseek-ai-dsh-typert-registry/use-this-package/lookup-and-context-providers:
|
||||
en: a1ba884253d02d96
|
||||
zh: 90165b44da9473f0
|
||||
en: a5392d1b04cde813
|
||||
zh: 20e09e9a70701821
|
||||
/deepseek-ai-dsh-typert-registry/understand-the-implementation:
|
||||
en: 1a78dfbae50a8b0d
|
||||
zh: dc10b4b3f3d832ed
|
||||
|
||||
@@ -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 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. `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.
|
||||
`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.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -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` 中每个资源种类的默认身份提取规则。拥有该种类的领域登记 `{ field }` 或 `{ resolve }`,不能同时提供两者,登记是调用方 fiber 的 Cordis effect;同一种类重复登记即抛错,登记内置的无身份资源种类 `global` 同样抛错。`get(kind)` 读取一个种类的声明,`entries()` 按登记顺序列出每个 `kind` 及其字段或 resolver 声明。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 调用的资源时读取这三者。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
if (declaration.field !== undefined) 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: { ...declaration }, 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,
|
||||
@@ -453,7 +454,7 @@ describe('TypertRegistry', () => {
|
||||
const ctx = await makeCtx()
|
||||
const resolve = () => 'fixture-id'
|
||||
const changes: TypertRegistryChange[] = []
|
||||
ctx.typert.resources.subscribe(change => { changes.push(change) })
|
||||
ctx.typert.resources.subscribe((change) => { changes.push(change) })
|
||||
const fiber = ctx.plugin({
|
||||
inject: ['typert'],
|
||||
apply: (pluginCtx: Context) => { pluginCtx.typert.resources.register('registryFixture', { resolve }) },
|
||||
@@ -471,6 +472,23 @@ describe('TypertRegistry', () => {
|
||||
])
|
||||
})
|
||||
|
||||
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[] = []
|
||||
|
||||
Reference in new issue
Block a user