diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cf0d89c6b9..585c58bccc 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -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:92`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:100`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index edfd0f9567..ef09ec3bcb 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -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:92`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:100`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ diff --git a/packages/api/gateway/src/access.ts b/packages/api/gateway/src/access.ts index b9c5ae42be..b4a27485c6 100644 --- a/packages/api/gateway/src/access.ts +++ b/packages/api/gateway/src/access.ts @@ -22,9 +22,9 @@ 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. + * 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 readers - the method's `resources` readers, or `undefined` when it declares none. * @param descriptor - receiver, parameter order, lookups, and cancellation slot of the method. @@ -53,7 +53,7 @@ export function callResources( for (const declaration of typert.resources.entries()) { sources.set(declaration.kind, () => { if ('resolve' in declaration) { - return read(() => declaration.resolve(...positionalArgs), []) + return read(() => declaration.resolve({ args, parameters: descriptor.parameters }), []) } const id = defaultFieldId(descriptor, args, declaration.field) return Array.isArray(id) ? 'invalid' : id === undefined ? [] : [id] diff --git a/packages/api/gateway/tests/gateway-access.host.spec.ts b/packages/api/gateway/tests/gateway-access.host.spec.ts index 374fcf0cfa..672764ffc9 100644 --- a/packages/api/gateway/tests/gateway-access.host.spec.ts +++ b/packages/api/gateway/tests/gateway-access.host.spec.ts @@ -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' @@ -872,8 +873,12 @@ describe('resource resolution', () => { const ctx = new Context() roots.push(ctx) await ctx.plugin(TypertRegistry) - const resolve = vi.fn((request?: string | { address?: { id: string } }) => { - return typeof request === 'string' ? request : request?.address?.id + 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]) @@ -882,12 +887,14 @@ describe('resource resolution', () => { 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.other, args.later) + 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({ address: { id: 'r1' } }, NEVER) + 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')]) diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index e6d0acb551..bed8b292c0 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -12,7 +12,7 @@ import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativ import type { SessionId } from '@deepseek-ai/dsh-session' 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' @@ -79,13 +79,21 @@ declare module '@deepseek-ai/cordis' { /** * Extract the Session resource from an ID or a history address. - * @param request - first wire argument carrying a Session ID or address. - * @returns the target Session identity when present. + * @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. */ -function resolveSessionResource(request?: { readonly sessionId?: SessionId; readonly address?: SessionAddress }): SessionId | undefined { - const address = request?.address - return request?.sessionId ?? (address === undefined ? undefined - : address.kind === 'session' ? address.sessionId : 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 + return request.sessionId ?? (address === undefined ? undefined + : address.kind === 'session' ? address.sessionId : address.childSessionId) + } + return undefined } /** Session Controller deployment policy. */ diff --git a/packages/api/session-controller/tests/remote-access.host.spec.ts b/packages/api/session-controller/tests/remote-access.host.spec.ts index 7fff981917..b69a7063a5 100644 --- a/packages/api/session-controller/tests/remote-access.host.spec.ts +++ b/packages/api/session-controller/tests/remote-access.host.spec.ts @@ -118,13 +118,34 @@ describe('Session Controller access', () => { if (resource === undefined || !('resolve' in resource)) 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'] - ?? ((...args: readonly unknown[]) => resource.resolve(...args)) + ?? ((request: unknown) => resource.resolve({ args: { request }, parameters: [{ name: 'request', wire: 'request' }] })) expect(reader?.({ address: { kind: 'session', sessionId: SHARED } })).toBe(SHARED) // A subagent address reads the child transcript; whether the parent's access covers it is the policy's rule. expect(reader?.({ address: { kind: 'subagent', parentSessionId: SHARED, childSessionId: PRIVATE } })).toBe(PRIVATE) } }) + it('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 === undefined || !('resolve' in resource)) throw new Error('Session resource resolver is missing') + const resolve = (args: Readonly>, ...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('lets a global role call session/list and lists only the Sessions a Session role admits', async () => { const { ctx, as } = await harness() await ctx.plugin(TypertGatewayService) diff --git a/packages/typert/protocol/README.i18n.yaml b/packages/typert/protocol/README.i18n.yaml index c2c98225b5..018aebf137 100644 --- a/packages/typert/protocol/README.i18n.yaml +++ b/packages/typert/protocol/README.i18n.yaml @@ -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 diff --git a/packages/typert/protocol/README.md b/packages/typert/protocol/README.md index b36b0eb211..8613555bf7 100644 --- a/packages/typert/protocol/README.md +++ b/packages/typert/protocol/README.md @@ -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` (`dsh-session` declares `session: TypertResource`, `dsh-workspace` declares `workspace: TypertResource`). 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` (`dsh-session` declares `session: TypertResource`, `dsh-workspace` declares `workspace: TypertResource`). 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 diff --git a/packages/typert/protocol/README.zh.md b/packages/typert/protocol/README.zh.md index e99844fcc8..f02a8f5217 100644 --- a/packages/typert/protocol/README.zh.md +++ b/packages/typert/protocol/README.zh.md @@ -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` 声明自己的资源种类(`dsh-session` 声明 `session: TypertResource`,`dsh-workspace` 声明 `workspace: TypertResource`)。`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` 声明自己的资源种类(`dsh-session` 声明 `session: TypertResource`,`dsh-workspace` 声明 `workspace: TypertResource`)。`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 diff --git a/packages/typert/protocol/src/index.ts b/packages/typert/protocol/src/index.ts index e2af8932b6..ac8e23ecff 100644 --- a/packages/typert/protocol/src/index.ts +++ b/packages/typert/protocol/src/index.ts @@ -73,6 +73,7 @@ export type { TypertResourceReader, TypertResourceReaders, TypertResourceRegistry, + TypertResourceResolveInput, TypertLocalRegistry, TypertLookup, TypertLookupDefinition, diff --git a/packages/typert/protocol/src/types.ts b/packages/typert/protocol/src/types.ts index 6b1d658b50..a9b362cfb8 100644 --- a/packages/typert/protocol/src/types.ts +++ b/packages/typert/protocol/src/types.ts @@ -878,12 +878,18 @@ export interface TypertContextRegistry { subscribe(listener: TypertRegistryListener): TypertDisposer } +/** Validated wire values and their ordered business parameters for resource extraction. */ +export interface TypertResourceResolveInput { + readonly args: Readonly> + readonly parameters: readonly Pick[] +} + /** Default identity extraction for one resource kind. */ export type TypertResourceDeclaration = | { readonly field: string } | { - /** Called once with spread positional wire arguments. Undefined means no matching resource. */ - resolve(...args: readonly unknown[]): string | undefined + /** Called once before lookups resolve. Undefined means no matching resource. */ + resolve(input: TypertResourceResolveInput): string | undefined } /** One registered resource kind with its default identity extraction. */