Peer principals and method-level Remote access control
Peer 身份属性与 Remote 方法级访问控制
A Remote method on the Host could not tell whom one call spoke for, and could not restrict callable methods by caller. The [`PeerScope`](../../../../packages/typert/protocol/src/types.ts) contract stated "Who the Peer is and what it may do are not recorded here"; [Connection](../../../../packages/client/connection/src/rpc-host.ts) had a single operator Peer, `admit()` returned it for every authenticated request, and
English
Problem
A Remote method on the Host could not tell whom one call spoke for, and could not restrict callable methods by caller. The PeerScope contract stated "Who the Peer is and what it may do are not recorded here"; Connection had a single operator Peer, admit() returned it for every authenticated request, and the /api route and the interceptor's Fetch handler used it unconditionally instead of the admission result; Gateway silently fell back to operatorPeer() when a caller named no Peer.
The first non-owner caller is a liveshare viewer. Viewer requests reach the owner's liveshare Host plugin through the Relay, and the plugin dispatched them in the owner's process as the operator, so the owner's Session treated a viewer's prompt as the owner's own input. PR #3854 pushed liveshare semantics into session-controller for this: the request body carries origin, every prompt writes a required prompt/origin event correlated by requestId, the controller folds origins, serializeAdmission extends to every prompt, and a new api-session/external-admission hook appears; liveshare maintains its own READ_ENDPOINTS allowlist and trims list/follow results; the frontend clientWritePolicy lives in ui-conversation, with three ways of reading it across five packages and seven places that hide UI by whether a callback was injected.
One general mechanism is needed:
- In-process Host code assigns the caller identity; no wire field can set or override it.
- Every call path names its Peer explicitly; there is no implicit fallback.
- Method-level permission is computed and enforced in one place on the Host; the frontend reads the same result to show or hide entries and follows rule changes automatically.
- A prompt records its caller with the user message, so a later check can tell who initiated each turn.
Decision
Scope
| Part | Content |
|---|---|
| Peer identity | PeerScope.principal and PeerScope.signal; one Peer implementation Peer (renamed from OperatorPeer) with an options constructor; removal of the operator concept and every implicit Peer fallback; connection.browserPeer, connection.createPeer(), and fetch(request, peer); a disposed Peer is always refused with gateway/peer-disposed; the user-rpc source records principal? |
| Method-level access control | resources readers on @Remote, TypertResourceMap resource kinds, and the typert.resources registry (typert-protocol, typert-registry); Gateway resolution of calls and forwarded events to resources; new package @deepseek-ai/dsh-api-remote-access (Host and Client faces): RBAC decisions over roles, bindings, and policies through decide(request), list filtering through allows(), the first snapshot in the index and the access snapshot stream, and access hooks |
| Documentation | One code-review rule and terminology |
Two layers: principal and peer
| Layer | Concept | Example | Cardinality |
|---|---|---|---|
| Authorization | principal: the caller identity that permission decisions read | share-viewer{shareId, actor} proven by a share token; the local user { kind: 'owner' } | one principal has N peers |
| Connection | peer: the lifetime scope of one connection | a browser connection, one connection of a liveshare viewer | one peer per connection |
| Concern | Layer |
|---|---|
| Method-level decisions, access hooks, access snapshots | principal |
Recording source.principal | principal |
Subscriptions, streams, connection-lifetime registrations (peer.ctx) | peer |
| Revocation | both: after revocation the policy gives that principal no binding, so its calls are all refused; the creator disposes every peer under it |
The creator owns the principal→peers table; liveshare, for example, keeps one object per share grant that records every peer opened under it.
Peer model
There is one Peer concept and one implementation; the operator concept is removed.
// @deepseek-ai/dsh-typert-protocol
export interface PeerScope {
readonly id: PeerId
/** Connection-lifetime registrations; disposed with the Peer. */
readonly ctx: Context
/** Caller identity fixed at creation by in-process Host code; no wire field can set it. */
readonly principal: Principal
/** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */
readonly signal: AbortSignal
/** Forwarded human-interaction participation; independent of authorization. */
readonly interactions: 'answer' | 'none'
dispose(): Promise<void>
}
// @deepseek-ai/dsh-client-connection
export class Peer implements PeerScope {
constructor(owner: Context, options: { readonly principal: Principal; readonly interactions?: 'answer' | 'none' })
}
interface HostConnectionHandle {
/** The Peer every authenticated browser connection shares; principal { kind: 'owner' }. */
readonly browserPeer: PeerScope
/** Network admission: the admitted Peer or the refusal status. */
admit(request: ConnectionTrustRequest): PeerAdmission
/** In-process only; disposed with the Context that read `ctx.connection`, or earlier by dispose(). */
createPeer(options: { readonly principal: Principal; readonly interactions?: 'answer' | 'none' }): PeerScope
}
interface ConnectionFetchHandler {
fetch(request: Request, peer: PeerScope): Promise<Response>
}
bridge(req, res, apiHandler, peer, maxRequestBodyBytes?)
// @deepseek-ai/dsh-api-gateway
interface InvokeRemoteRequest { …; readonly peer: PeerScope } // was `peer?`
wireStream.open(endpoint, payload, uplink, peer: PeerScope, signal) // was `PeerScope | undefined`
| Rule | Detail |
|---|---|
| Every path names its Peer | Network paths take it from admit(); in-process paths receive it from the caller. Gateway deletes operatorPeer() and request.peer ?? … and never creates a Peer |
Browser connections share browserPeer | Every browser connection shares one Peer, and every use site names connection.browserPeer explicitly; there is no fallback variable. The in-page Host (webworker-runtime) also passes browserPeer explicitly |
| In-process Host automation | Code that calls Remote methods creates its own Peer with createPeer({ principal }). No production caller exists yet, so no fixed Host Peer is pre-provisioned |
| Multiple Peers | Each createPeer() returns an independent Peer; liveshare is to create one per viewer connection (wired on the #3854 side) |
| Interaction participation | Peer(owner, { principal, interactions? }) and createPeer() default omitted interactions to answer. none refuses $events and $events/result for every principal, even owner; this is independent of method-level RBAC. Answering owner Peers receive waterfalls, and non-owner Peers receive only RBAC-authorized broadcasts and cannot answer |
| Exact Fetch routes | Exact routes such as file-upload, present-open, media-references, session-log-export, and inspector bypass the Gateway; the shared Fetch handler answers 403 to every non-owner Peer |
Identity types
// @deepseek-ai/dsh-typert-protocol
/**
* Merge-extensible caller identities. Each driver declares its own entry.
* Every entry is a JSON value with a unique literal `kind`, carries no
* credential, and is safe to persist in a Session log.
*/
export interface PrincipalMap {
owner: { readonly kind: 'owner' }
}
export type Principal = PrincipalMap[keyof PrincipalMap]
/**
* @param invocation - `ctx.invocation`; `undefined` outside a Remote call.
* @returns the caller's principal when it is not the owner; `undefined` for the
* owner and for trusted in-process code outside a Remote call.
*/
export function principalOf(invocation: RemoteInvocation | undefined): Principal | undefined
The single read point is this.ctx.invocation.peer.principal; an owner test is written inline as principal.kind === 'owner', and an unknown kind is never the owner. External drivers extend PrincipalMap by declaration merging in their own package; liveshare's share-viewer{ shareId, actor } is declared on the #3854 side, and liveshare itself is unchanged here.
Peer lifetime and revocation
| Event | Behavior |
|---|---|
| Gateway dispatch | The first step checks peer.signal.aborted and refuses a disposed Peer with gateway/peer-disposed, on HTTP, WebSocket, and in-process paths alike; wire paths check before they read the payload, so a malformed payload from a disposed Peer gets the same code |
| Streams when a Peer is disposed | The signal a Remote stream method observes joins peer.signal, and so does the $events stream lifetime; a WebSocket still closes with 1001 |
| Unary calls when a Peer is disposed | A unary call already running is not cancelled; later calls are refused |
| Revocation | After a business package revokes a grant, its policy gives that principal no binding, and the business package calls invalidate(principal); the creator disposes every peer in its principal→peers table |
Method-level access control
Resource types and the resources readers of @Remote live in @deepseek-ai/dsh-typert-protocol; resource kinds live in typert beside lookups and Contexts: TypertResourceMap declares the kinds, typert.resources holds each kind's default wire field, and lookups and Host Context adapters declare the resource kind their wire identity names. The Gateway resolves one call or forwarded event to the resources it touches. Decisions, list filtering, and the access snapshot live in the new package @deepseek-ai/dsh-api-remote-access (packages/api/remote-access), which reads no argument field. The Gateway and domain packages depend on remote-access only for types and inject remoteAccess; their only value dependency is dsh-typert-protocol, so package-dependency-policy gains no remote-access entry. Business packages define and register roles, bindings, and policies; liveshare is the first user. Apart from the built-in global, no resource-kind name appears in remote-access or the Gateway, which follows the packages/api carries generic orchestration only review rule.
Correspondence with RBAC
| This design | RBAC / IAM term | Meaning |
|---|---|---|
| principal | Subject / Principal | The caller identity, one entry of PrincipalMap |
| Peer | Session (RBAC session) | One connection of a principal; one principal has N Peers |
| owner | Superuser | Skips every check |
| action | Action / Permission | A Remote endpoint ns/method, or a forwarded event event:<name>; the Gateway still calls it an endpoint internally |
| resource | Resource / Object | { kind: 'global' }, or { kind, id } of a declared resource kind |
| role | Role | An action set over one resource kind; a plain value of the business package |
| binding | RoleBinding (role assignment) | The principal holds a role on a resource, optionally with a condition |
| condition | Condition (IAM Conditions) | Attached to a binding; allows by the request's action, resource, and arguments, or refuses with a reason |
| policy | Allow policy | The bindings and optional explain a business package gives for one non-owner principal kind |
| explain | Denial explanation | Names a refusal reason when no binding allows; never changes the decision |
| Gateway | PEP (Policy Enforcement Point) | Resolves resources and enforces the decision |
| remote-access | PDP (Policy Decision Point) | Computes decisions, list filtering, and access snapshots |
| Rule | Content |
|---|---|
| Default deny | A non-owner is allowed only when the role of some binding explicitly lists the action; an action no role lists, a resource without a binding, and a principal kind without a policy are all refused. Every Remote method is under access control; there is no separate accessibility marker |
| No inheritance between resources | global is not a parent of other resources: a grant on global implies no grant on any Session or Workspace, and a grant on a Workspace implies no grant on its Sessions |
A global grant is an explicit global grant | A call that resolves to no resource touches global. Putting an action in a global role lets that principal call it without resource restriction, which is a high-risk grant; once the method is allowed, it can still decide each item inside with allows(resource) |
Resources and resource readers (@deepseek-ai/dsh-typert-protocol, @deepseek-ai/dsh-typert-registry)
/** Merge-extensible resource kinds: the key is the kind, the value declares its identity type. */
export interface TypertResourceMap {}
// @deepseek-ai/dsh-session/types: session: TypertResource<SessionId>
// @deepseek-ai/dsh-workspace/types: workspace: TypertResource<WorkspaceId>
// Runtime registries in typert
ctx.typert.resources.register(kind, { field }): TypertDisposer
ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names
ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter
export interface TypertHostContextAdapter<Wire = unknown> {
readonly wire: string
readonly wireTypeSymbol: string
/** Resource kind the wire identity names; access checks read the identity from args before resolve(). */
readonly resource?: TypertResourceKind
resolve(id: Wire): Context | undefined | Promise<Context | undefined>
}
export type RemoteAccessResourceKind = 'global' | TypertResourceKind
export type RemoteAccessResource =
| { readonly kind: 'global' }
| { readonly kind: K; readonly id: TypertResourceId<TypertResourceMap[K]> } // for each declared kind K
export type RemoteAccessResourceOf<K extends RemoteAccessResourceKind> = Extract<RemoteAccessResource, { readonly kind: K }>
/** Per-kind readers, called with the method's positional wire arguments. */
export type TypertResourceReaders<WireArgs extends readonly unknown[]> = {
readonly [K in TypertResourceKind]?: (...args: WireArgs) => Id | readonly Id[] | undefined // Id = TypertResourceId<TypertResourceMap[K]>
}
export interface RemoteMethodOptions<WireArgs extends unknown[]> {
readonly name?: string
readonly mode?: 'stream'
readonly resources?: TypertResourceReaders<WireArgs>
}
export function Remote<This extends object, Args extends unknown[], Result>(
option: string | RemoteMethodOptions<NoInfer<RemoteWireArgs<Args>>>,
): (method: (this: This, ...args: Args) => Result,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>) => void
export interface RemoteScopeOptions<WireArgs extends unknown[]> {
readonly name?: string
readonly resources?: TypertResourceReaders<WireArgs>
}
export function RemoteScope<This extends object, Args extends unknown[], Result>(
key: Extract<keyof TypertContextMap, string>,
option?: string | RemoteScopeOptions<NoInfer<RemoteWireArgs<Args>>>,
): (method: (this: This, ...args: Args) => Result,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>) => void
| Rule | Content |
|---|---|
| Syntax | @Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } }); a Context method writes @RemoteScope('agent') and gets its Session resource from the Agent Context's resource: 'session', and writes @RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } }) when it needs another resource. Most methods write no resources and get their resources from automatic resolution |
| Resource kinds | The lower-level packages that declare the identity types merge the resource kinds: session: TypertResource<SessionId> in @deepseek-ai/dsh-session/types and workspace: TypertResource<WorkspaceId> in @deepseek-ai/dsh-workspace/types. session-controller references the commands package, so a merge in session-controller would make commands reference session-controller and form a project-reference cycle. remote-access builds in global, which has no id and stays out of TypertResourceMap; typert reserves the name, and typert.resources.register('global', …) throws |
| Default fields and declarations | The domain that owns a kind registers its default wire field when its controller is constructed: the SessionController constructor calls typert.resources.register('session', { field: 'sessionId' }) and the WorkspaceController constructor registers workspace → workspaceId; registering a kind twice throws. The Agent, Session, and workspaceFileScope lookups declare resource: 'session', and the agent package's Agent Context declares resource: 'session' |
| Resolution precedence | Each resource kind takes, in order: ① that kind's reader in the method's resources; ② every lookup parameter that declares that kind as its resource contributes its wire identity, and an omitted optional lookup parameter contributes none; when the Context of a Context method declares that kind as its resource, the Context's wire identity contributes too; ③ that kind's default field: a top-level named argument of that name when one exists, otherwise that field of the first object argument. A default field names one identity, so an array there is an invalid value |
| Argument values | Resource resolution and the args of decide() read the named arguments validated against their Typert-compiled types: each JSON argument is decoded once by its codec, which drops object fields its type does not declare, and dispatch reuses that value, so a caller cannot change the resources with an undeclared field; lookup parameters and the Context identity keep their wire identities, and no lookup or Agent is resolved at that point |
| Resolution result | The resources of every kind together are the resources the call touches; with none, the call touches [global]; when resources resolve, only those are used and global is not added |
| Readers | Called with the method's positional arguments: the named arguments mapped by the descriptor back to the method's parameter order (the validated value in a JSON position, the wire identity in a lookup position, and the call's signal in the cancellation position); return one id, an id array (every one must be allowed), or undefined (no resource of that kind) |
| Invalid values | A reader that throws or returns an empty array, or a read value (including a Context's wire identity) that is not a non-empty string, makes the resources invalid: the Gateway refuses the call with remote-access/denied (reason resource-invalid) without asking decide() |
| Completeness | resources and automatic resolution must cover every resource the method touches. A call that resolves no resource falls to global, which only a global role can allow, so it is refused by default; when only some resources resolve, the rest go unchecked and a principal granted on the resolved resources can reach them, which the method author must prevent |
| Type inference | The parameter types of @Remote and @RemoteScope readers are inferred from the decorated method: the factory parameter uses NoInfer, so Args is inferred only from the returned decorator; lookup parameters map to wire types through TypertLookupMap (for a method whose first parameter is agent: Agent, the reader's first parameter is SessionId). A missing property, more parameters than the method has, and a global key fail compilation |
| Storage | resources readers live on the class prototype with the other @Remote / @RemoteScope metadata, are recorded on instance construction, and are safe across duplicate installations; they do not enter Typert artifacts |
| Context methods | @RemoteScope(key, option?) matches @Remote: a string is the export name and an object is { name?, resources? }. A Context's wire identity takes part in resolution like a lookup parameter that declares a resource, with the same precedence, and a resources reader for that kind overrides it; the Gateway reads that identity directly from args before resolveReceiverContext, so no Agent is activated. A Context that declares no resource contributes no resource from its identity, and the method resolves by the ordinary rules |
Endpoint resources
The table lists the endpoints #3854 viewers use and where their resources come from. Only session/page and session/follow need explicit resources; the rest rely on a lookup's resource or the default field:
| Endpoint | Resource | Resolution source | Filtering inside the method |
|---|---|---|---|
workspace/follow | global | no resource | yes: Workspaces and Sessions |
session/control | global | no resource | yes: Sessions |
session/list | global (_request has no sessionId) | no resource | yes: Session rows |
session/modelCatalog | global | no resource | no |
session/canOpenWorkspacePath | global | no resource | no |
llm/listConfigurableProviders | global | no resource | no |
llm/listProviders | global | no resource | no |
commands/list | session: args.agentId (the Agent lookup's wire field) | Agent lookup resource: 'session' | no |
goals/get | session: args.agentId | Agent lookup resource: 'session' | no |
session/attachment | session: args.request.sessionId | default field | no |
skills/list | session: args.request.sessionId | default field | no |
session/fork | session: args.request.sessionId | default field | no |
session/prompt | session: args.request.sessionId | default field | no |
session/cancel | session: args.request.sessionId | default field | no |
session/projections | session: args.request.sessionId | default field | no |
session/page | session: args.request.address.sessionId for a Session address; args.request.address.childSessionId for a subagent address | resources | no |
session/follow | same as above | resources | no |
remoteAccess/follow | global | no resource; remote-access allows it for every principal | no |
A subagent address reads the child transcript, so its resource is the child Session. A Client rereads the projections of known Sessions after a reconnect, including the parent of a subagent Session; a reader without a grant on that parent has its projection read refused. Whether a grant on a parent Session covers its subagent children is the policy's decision, that is, whether it gives a binding for the child Session; the generic layer derives no parent resource.
Methods that need no non-owner access declare nothing, and a method no role lists is refused. The request fields of subagent prompt are parentSessionId / childSessionId, so automatic resolution finds no Session and resolves it to global; it must therefore stay out of every global role. session/search filters its hits per Session inside the method. subagents/list is not a Remote endpoint (SubagentRuntime.list() carries no @Remote).
Roles, bindings, and policies (@deepseek-ai/dsh-api-remote-access)
/** Remote endpoint `ns/method`, or `event:<name>` for one forwarded event; endpoint segments never contain `:`. */
export type RemoteAccessAction = RemoteAccessEndpoint | `event:${string}`
/** A named action set over one resource kind; a plain value owned by the business package. */
export interface RemoteAccessRole<K extends RemoteAccessResourceKind> {
readonly name: string
readonly resource: K
readonly actions: readonly RemoteAccessAction[]
}
/** Answer of decide() and of a condition. */
export type RemoteAccessDecision =
| { readonly kind: 'allow' }
| { readonly kind: 'deny'; readonly reason: string }
/** Refines one binding for one action on its resource; `args` only for a concrete call. */
export type RemoteAccessCondition<K extends RemoteAccessResourceKind> = (request: {
readonly action: RemoteAccessAction
readonly resource: RemoteAccessResourceOf<K>
readonly args?: Readonly<Record<string, unknown>>
}) => RemoteAccessDecision
/** One role on one resource of the role's kind; the resource type follows from the role. */
export type RemoteAccessBinding = {
[K in RemoteAccessResourceKind]: {
readonly role: RemoteAccessRole<K>
readonly resource: RemoteAccessResourceOf<K>
readonly condition?: RemoteAccessCondition<K>
}
}[RemoteAccessResourceKind]
/** One access question; the Gateway passes only valid resources, [global] when none. */
export interface RemoteAccessRequest<P extends Principal> {
readonly principal: P
readonly action: RemoteAccessAction
/** Allow only when the action is allowed on every listed resource. */
readonly resources: readonly RemoteAccessResource[]
/** Named arguments of a concrete call, validated against their Typert-compiled types with lookup parameters as wire identities; absent for events, snapshots, and list filtering. */
readonly args?: Readonly<Record<string, unknown>>
}
/** Business rules for one non-owner principal kind; synchronous over in-memory state. */
export interface RemoteAccessPolicy<P extends Principal> {
bindings(principal: P): readonly RemoteAccessBinding[]
/** Reason for a request no binding allows; never changes the decision. */
explain?(request: RemoteAccessRequest<P>): string | undefined
}
/** details of `remote-access/denied` */
export interface RemoteAccessDeniedDetails {
readonly action: string
readonly reason: string
}
class RemoteAccessService { // ctx.remoteAccess; Remote namespace remoteAccess
registerPolicy<K extends Exclude<keyof PrincipalMap, 'owner'>>(kind: K, policy: RemoteAccessPolicy<PrincipalMap[K]>): () => void
invalidate(principal?: Principal): void
decide(request: RemoteAccessRequest<Principal>): RemoteAccessDecision // Gateway-only
allows(resource: RemoteAccessResource): boolean // reads the accessing ctx's invocation
snapshotFor(principal: Principal): RemoteAccessBaseline // a page's opening baseline
follow(signal): AsyncIterable<RemoteAccessFrame> // remoteAccess/follow
}
| Rule | Content |
|---|---|
| Owner | The Gateway does not resolve resources or call decide() for the owner, and reads no policy |
| Registration | registerPolicy(kind, policy) returns a disposer, and a business package calls it inside ctx.effect(). Each non-owner principal kind has at most one policy, and a second registration for the same kind throws; bindings() and explain() receive principals typed by the kind |
| Built-in allow | remoteAccess/follow is allowed for every principal, so every Peer can read its own snapshot |
| Decision | A principal kind without a policy is refused with no-allow. Otherwise bindings(principal) is read, and a linear scan finds for each requested resource one binding whose resource is equal (the same kind and, for a non-global resource, the same id), whose role's actions contains the action, and that has no condition or whose condition allows; the request is allowed only when every resource has one (AND). A condition runs only for a binding whose resource is equal and whose role contains the action |
| Refusal reason | The first resource not allowed decides the reason, in this precedence: when matching bindings exist on that resource but all their conditions refuse, the first condition's reason; otherwise the return value of explain(request); without explain, or when it returns undefined, no-allow. explain runs only after the decision is already a refusal and never changes it |
| Failure isolation | When bindings(), a condition, or explain() throws, the decision is refused with policy-failed; in a snapshot computation, a throwing bindings() leaves that principal no grant and a throwing condition excludes that action. A faulty rule therefore never breaks the shared $events source, a snapshot stream, or page rendering; after each invalidate() or policy change, the first failure of each policy is logged once |
| Synchronous | bindings(), conditions, and explain() return synchronously from state the business package preloads in memory, so list filtering can decide per item and snapshots can be computed |
| Evaluation timing | Each decide(), each allows(), and each snapshot computation calls bindings() once, with no caching; time-dependent state (such as expiry) applies immediately, and a long stream (such as workspace/follow or session/control) reflects added or removed bindings at its next per-item decision; snapshots are recomputed only after invalidate(), so a business package also calls invalidate() when a time event such as expiry happens |
| Notification | When a business state change (grant, revocation, mode switch) alters a binding or a condition's answer, the business package must call invalidate(principal?); snapshots are recomputed only after invalidate() and policy registration or removal |
remote-access/denied | Only a call refusal becomes an error, with details { action, reason }, where reason is the reason a condition or explain gave, no-allow, policy-failed, or the resource-invalid the Gateway gives for invalid resources; a refused event is simply not pushed |
| Required service | The Gateway declares static inject = ['typert', 'remoteAccess']; a composition that mounts the Gateway mounts remote-access, which bundle/base assembles right after typert-gateway; the plugin manager protects it like the Gateway, so it cannot be disabled from the Plugins page |
Example: share viewer
The share-viewer principal that liveshare declares shows that the derivation from business state to bindings stays in the business package; shares in the example stands for the share state the business package holds in memory:
const globalReader = { name: 'share:global-reader', resource: 'global', actions: ['session/list', 'session/control', 'workspace/follow', 'session/modelCatalog', 'llm/listProviders', 'commands/list'] }
const sessionReader = { name: 'share:session-reader', resource: 'session', actions: ['session/page', 'session/follow', 'session/list', 'session/control', 'workspace/follow', 'commands/list', 'event:api-session/status', 'event:api-session/added'] }
const workspaceReader = { name: 'share:workspace-reader', resource: 'workspace', actions: ['workspace/follow'] }
const forker = { name: 'share:forker', resource: 'session', actions: ['session/fork'] }
const writer = { name: 'share:writer', resource: 'session', actions: ['session/prompt', 'session/cancel'] }
ctx.effect(() => ctx.remoteAccess.registerPolicy('share-viewer', {
bindings(viewer) {
const share = shares.serving(viewer) // undefined once stopped, expired, or no longer admitted
if (share === undefined) return []
const source = { kind: 'session', id: share.sessionId }
return [
{ role: globalReader, resource: { kind: 'global' } },
{ role: sessionReader, resource: source },
...(share.allowsFork ? [{ role: forker, resource: source }] : []),
...(share.mode === 'write' ? [{ role: writer, resource: source, condition: sourceWrite(share) }] : []),
...shares.forksOf(viewer).flatMap(child => [
{ role: sessionReader, resource: { kind: 'session', id: child.id } },
...(child.revoked ? [] : [{ role: writer, resource: { kind: 'session', id: child.id }, condition: childPrompt(child) }]),
]),
...shares.workspacesShowing(viewer).map(id => ({ role: workspaceReader, resource: { kind: 'workspace', id } })),
]
},
explain({ principal, action }) {
return shares.refusal(principal) // 'share-expired', 'share-stopped', …
?? (action === 'session/prompt' ? 'share-not-writable' : undefined)
},
}))
// Without args (snapshots, list filtering) a condition answers for the action alone.
const sourceWrite = share => () => share.confined ? allow : deny('source-write-unavailable')
const childPrompt = child => ({ action, args }) =>
action === 'session/prompt' && args !== undefined && !child.allowsPrompt() ? deny('fork-prompt-refused') : allow
| Owned by | Content |
|---|---|
| Business package | The action lists of roles; the derivation from share state to bindings (stop, expiry, admission, mode, the viewer's own forks, revocation, Workspaces containing visible Sessions); per-request conditions; refusal reasons for the viewer page |
| Generic layer | Resource resolution, AND across resources, role and action matching, condition calls, reason precedence, list filtering, snapshot computation, and failure isolation |
That viewer's snapshot contains grants on global, the source Session, each of its own forks, and each visible Workspace; session/list appears in both a global role and a session role, see List and aggregate methods.
Host access-check order
Every call path (HTTP /api, WebSocket streams, in-process fetch(request, peer) / invoke / stream / wireStream.open) passes through one Gateway sequence:
| Step | Action | Failure |
|---|---|---|
| 1 | Admission yields the Peer: network paths from Connection admit() (browserPeer), in-process paths from the caller. The Gateway checks peer.signal before it reads the wire payload | Failed network admission answers HTTP 401 / 403 without entering the Gateway; a disposed Peer: gateway/peer-disposed |
| 2 | Resolve the endpoint and validate the argument field set (resolveDescriptor, assertExactArguments), then validate the JSON arguments against their Typert-compiled types, decoding each once | gateway/invocation-unavailable, gateway/ambiguous-endpoint, gateway/definition-unavailable; extra or missing fields: gateway/arguments-invalid; a JSON argument failing validation: gateway/input-invalid (for owners and non-owners alike, before the access check) |
| 3 | Owner pass-through: for principal.kind === 'owner' go straight to step 5 | — |
| 4 | Non-owner: the Gateway resolves resources from the arguments validated in step 2 (resources readers, then lookup parameters and the resource a Context declares, then default fields; lookup and Context identities are wire identities, and neither a lookup nor the Context is resolved), then asks remoteAccess.decide({ principal, action: endpoint, resources, args }) with the validated arguments as args; invalid resources are refused with resource-invalid without asking decide(); a deny decision becomes the thrown error | remote-access/denied with details { action, reason } |
| 5 | Dispatch: resolveReceiverContext (which can activate an Agent), lookup resolution (JSON arguments reuse the step 2 values), and the method call | Existing codes unchanged, such as gateway/lookup-not-found, gateway/context-not-found, gateway/input-invalid, gateway/service-unavailable, gateway/method-unavailable, and business errors |
The access check (steps 3–4) precedes lookup and Context resolution, so a refused call activates no Session and causes no other side effect. The only new Gateway error code is gateway/peer-disposed; remote-access declares the refusal code remote-access/denied.
Gateway-owned endpoints: a non-owner may subscribe to $events but never receives waterfall frames it could answer; $events/result from a non-owner is refused with the existing gateway/invocation-unavailable; both run the step-1 check.
Forwarded events
An emit entry of API_REMOTE_FORWARDED_EVENTS may declare resources: readers keyed by resource kind and typed by that event's own parameters through TypertForwardableEventEntry, for example { event: 'api-session/added', mode: 'emit', resources: { session: (summary) => summary.sessionId } }. API Remotes attaches the readers to each forwarded frame; for every non-owner $events stream the Gateway resolves the resources once per frame and pushes the frame only when remoteAccess.decide({ principal, action: 'event:<name>', resources }) allows it.
| Rule | Content |
|---|---|
| Declared events | api-session/activity, api-session/added, api-session/error, api-session/removed, and api-session/status declare their Session |
| Undeclared resources | An event without resources, or with an empty resources: {}, touches global and reaches a non-owner only when a global role lists event:<name> |
| Missing resource | An event carries every resource it declares, so a reader returning undefined makes the event's resources invalid, and the Gateway delivers it to no non-owner without asking decide(); a call missing a resource touches global instead |
| Whole or nothing | An allowed event arrives with its payload unchanged; no payload is trimmed or projected per Peer |
| One resource per event | A notification carries at most one resource; collection changes travel on follow streams (session/follow, session/control, workspace/follow), whose methods decide each item inline through allows() |
| Waterfalls | Non-owners never receive waterfall frames, and no policy decides them |
| Non-owner stream facts | A non-owner $events stream opens with the same host as an owner stream, including home; hasLiveClient() counts only owner streams, the ones that can answer waterfalls |
<a id="list-and-aggregate-methods"></a>
List and aggregate methods
Calls of list, paging, and aggregate methods resolve to global and are allowed by a global role; inside, the method calls this.ctx.remoteAccess.allows(resource) for every item to decide whether the current call's action is granted on that resource (without args). A list action therefore appears both in a global role (allowing the call) and in resource roles (allowing each item): session/list must be granted on global to be called, and each Session row also requires session/list granted on that Session. allows() reads the invocation on the Context that accessed the service; it is always true for the owner and for in-process code outside a Remote call, which are not filtered.
| Method | Filtered items |
|---|---|
session/list | Session rows (session resource) |
session/search | search hits (session resource) |
session/control | baseline projections and projection increments (session resource) |
workspace/follow | Workspaces in the baseline, upsert frames, and order ids (workspace resource), the Sessions inside Workspaces, and the archived / pinned Session sets (session resource). The stream tracks the Workspaces it delivered; when one is no longer allowed at its next change or in a new order, it sends a replace increment with the complete filtered state instead of a remove, because the Client never resurrects a removed id, and the Workspace reappears through an ordinary upsert once access returns. A remove frame reports a deletion and reaches only a caller that received that Workspace |
Access snapshot stream
// Remote: remoteAccess/follow (stream; every Peer may read its own snapshot)
type RemoteAccessFrame =
| { readonly type: 'baseline'; readonly owner: true }
| { readonly type: 'baseline'; readonly owner: false; readonly grants: readonly RemoteAccessGrant[] }
| { readonly type: 'upsert'; readonly grant: RemoteAccessGrant }
| { readonly type: 'remove'; readonly resource: RemoteAccessResource }
interface RemoteAccessGrant {
readonly resource: RemoteAccessResource // global, or one declared-kind resource
readonly actions: readonly string[] // endpoint actions allowed on this resource without args, sorted
}
/** Window global carrying a page's opening baseline. */
export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__'
| Behavior | Detail |
|---|---|
| First snapshot | snapshotFor(principal) computes a page's opening baseline. remote-access adds the global REMOTE_ACCESS_SNAPSHOT_KEY, set to the owner baseline { type: 'baseline', owner: true }, to every index the webServer renders, through webserver/index-inject. A business package that renders a non-owner page (such as liveshare on the owner Host) places an assignment of that global to snapshotFor(principal) after every index injection row; the later assignment wins, and the Client reads the global when it mounts |
| Subscription | Each Peer subscribes to one access snapshot stream, whose baseline replaces the injected one |
| Computation | All bindings of the policy for the principal's kind are merged by equal resource; each resource's actions are the endpoint actions of those bindings' roles that the same decision as a call allows (one resource, without args, conditions called without args); only a resource with at least one allowed endpoint action gets a grant. Decisions and snapshots share one source: for the same principal, action, and resource (without arguments), the snapshot answer equals decide() |
| Endpoint actions only | event:<name> actions do not enter the snapshot; the frontend decides entry visibility from endpoint actions only |
| Increments | invalidate() or a policy registration or removal recomputes and pushes upsert / remove, and a resource whose allowed set becomes empty pushes remove; unchanged resources push nothing |
| Lifetime | A generation ends when its signal aborts or when the remote-access service unloads, so a reloaded service answers every reader from a fresh baseline; the Client treats the end as a carrier loss and reconnects |
| Owner | Receives only { owner: true }, so every frontend answer is true |
| Wire type | actions is readonly string[]: Typert cannot generate a codec for template-literal types |
Access hooks judge only by action and resource and never look at call arguments; at call time a condition may also read args for a finer decision, and the Host result prevails.
Client face
The Client uses framework standard-hook injection and no React context. Components receive hooks only through props and import no remote-access value.
// @deepseek-ai/dsh-api-remote-access/client
interface ClientRemoteAccess { // Client service ctx.remoteAccess
source(kind: TypertResourceKind, id: string, action: string): HostObservable<boolean>
}
GlobalStandardProps.useRemoteAccess: (action: RemoteEndpoint) => boolean // remote-access; the global resource
// @deepseek-ai/dsh-client-ui-session (injects remoteAccess)
GlobalStandardProps.useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean
SessionStandardProps.useAccess: (action: RemoteEndpoint) => boolean
SessionMaybeStandardProps.useAccess: (action: RemoteEndpoint) => boolean | undefined
// @deepseek-ai/dsh-client-ui-workspace (injects remoteAccess)
GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean
// RemoteEndpoint = Extract<keyof TypertRemoteMap, string>
| Concern | Decision |
|---|---|
| Snapshot and mount | The remote-access Client plugin starts from the baseline the index injected (read synchronously at startup and validated), follows the snapshot stream, applies each baseline in one pass, provides the Client service ctx.remoteAccess.source(kind, id, action) (an identity-stable boolean source), and injects useRemoteAccess on the global resource. The entry module imports the generated /remote contribution and hands it with the injected baseline to mountAccess(ctx, contribution, initial), which mounts it through ctx.remote.$mount (unit specs pass a fake contribution), following experimental client-ui-cot-translation, voice-input, and claude-code-mods; a central mount in api/remotes would form the project-reference cycle ui-session → api/remotes → remote-access → ui-session |
| Session resource | ui-session gives the bound Session's answer through uiSession.provide({ keyedHooks: ['access'], resolve }); the framework binds the Session as it does for useProjection. useSessionAccess is injected through provideRoot |
| Workspace resource | ui-workspace injects useWorkspaceAccess through provideRoot |
| Distinct names | The global answer is useRemoteAccess and the Session answer is useAccess, so neither shadows the other; in a session-maybe scope without a Session, useAccess is undefined, which callers treat as not allowed, and a global decision reads useRemoteAccess |
| Two-argument calls | Framework keyed hooks take a standard second argument: KeyedStandardSource is (key, member?) => source; a string second argument makes the hook read the resolver's (key, member) source without a selector, and the resolver returns the same source for the same arguments. Existing (key, selector?, equal?) and (selector, equal?) calls are unchanged. Curried forms are forbidden |
| Return value | Every answer on a resource is a boolean; action types come from the ctx.remote namespaces and methods |
| No fallback | Asking a Session resource about a non-session method (such as useAccess('session/create')) reads the decision on that Session resource and never falls back to the global answer |
| No snapshot | True for the owner, also after its stream fails terminally; a non-owner page without an injected baseline, or whose stream fails terminally, answers false, and the terminal failure is reported with its error and the advice to reload; a reconnect keeps the last snapshot; a malformed injected baseline fails the mount; the Host still checks every call. Hooks never issue requests |
| Example consumers | The ui-workspace Session row menu's fork entry reads useSessionAccess(sessionId, 'session/fork') and is not rendered when it is false; the ui-chat turn tail's branch button reads useAccess('session/fork') on the Session resource and is not rendered when it is false. Replacing #3854's clientWritePolicy stays with #3854 |
| Tests | @deepseek-ai/dsh-client-test-runtime exports allowAllAccess props and allowAllAccessPlugin; TestClient drops the remote-access row as it drops api-remotes and installs the allow-all service, and SlotTestRuntime installs it before ui-session |
Security invariants
| Invariant | Basis and enforcement point |
|---|---|
| Only in-process Host code supplies a principal, when it creates a Peer | PeerScope.principal is read-only; createPeer() has no route |
| No wire field can set or override a principal | Principals never come from args, the envelope, headers, the query, or cookies. Extra top-level args fields fail assertExactArguments; the payload must be exactly { args }; clientRequestSchema drops unknown envelope fields; a same-named field inside a request body is business data |
| Browser network admission yields the owner | Connections that pass browser token authentication receive browserPeer |
| No implicit Peer | Neither the Gateway nor Connection supplies a default Peer; required parameters expose a missing Peer at compile time |
| A disposed Peer cannot dispatch | gateway/peer-disposed |
| A non-owner cannot call without authorization | A call is allowed only when a binding's role explicitly lists the action and its resource matches; a kind without a policy is refused; the Gateway depends on remoteAccess and does not start without it |
A global grant implies no resource grant | Resources do not inherit; allows() inside a method decides each item independently |
| An unknown principal kind is not the owner | Only kind === 'owner' is the owner |
| Non-owners answer no waterfall | $events gives non-owners no waterfall frame, and $events/result refuses them |
| A non-owner receives only authorized notifications | Each forwarded frame is decided by event:<name> and its resources, and reaches a non-owner only when allowed |
Recording the message source
The MessageSourceMap entry in the session-controller types gains an optional field:
'user-rpc': {
kind: 'user'
rpcId: SessionRequestId
clientTimeZone?: string
/** Non-owner caller that submitted this prompt; absent means the owner. */
principal?: Principal
}
Two producers build a kind: 'user' + rpcId user-message source inside a Remote call. Both call principalOf(this.ctx.invocation) in the Remote method and write principal only when it is defined:
| Producer | Location |
|---|---|
SessionController.prompt() builds the source and SessionCommandController.prompt(request, source) only delivers it | session-controller/src/index.ts, commands.ts |
subagent prompt() (BrowserPromptSource); no role lists it, so a non-owner reaches it only through in-process Host code, not through the Gateway | subagent/src/index.ts |
Business code does not pass a principal down as a function parameter: the identity is read once from the invocation in the Remote method, and the built source travels down. The Agent inbox has no interception point that could add the field on enqueue (followup / steer accept built messages, and in-process producers such as the SDK and scheduling run outside Remote calls), so each producer records it, and the code-review rule covers new producers.
| Fact | Detail |
|---|---|
kind is unchanged | Still 'user'; existing kind === 'user' checks are unaffected |
| Owner messages are unchanged | An owner call writes no field, so its source is byte-identical to the source before principals existed; a message without the field (including every older Session) is the owner's |
| Not part of model requests | LLM adapters read only the model source (llm-deepseek/src/replay.ts) |
| Session format | SESSION_FORMAT_VERSION is not bumped; per the persistence type-change cookbook the change records an acknowledgement |
Example consumer use, outside this Note's scope: tool guards and pre-step rechecks read source.principal of the user messages in a turn and judge by the non-owner caller when any message carries one; a turn mixing owner and viewer messages takes the strictest.
Code-review rule and documentation
The review rule's home is the "@Remote marks capability methods" section of Remote and API rules: @Remote({ resources }) and automatic resolution must cover every resource a method touches; aggregate results filter every item through allows(); the caller identity is read only from invocation.peer.principal, never from arguments; every call path names its Peer explicitly; business code does not pass a principal as a function parameter.
| Document | Content |
|---|---|
docs/subsystems/typert.md, docs/api-gateway.md | PeerScope, RemoteInvocation, InvokeRemoteRequest, explicit Peers, resources on @Remote, and the access-check order |
| READMEs of connection, gateway, typert-protocol, typert-registry, api-remotes, remote-access, session-controller, workspace-controller, ui-session, ui-workspace, ui-slots, and client-test-runtime | Peer / browserPeer in place of the operator; the remote-access role, binding, and policy contract; access hooks |
Out of scope and future work
- The API-only
/connecthandshake, API cookies, one Peer per API connection, and WebSocket lifetimes are described in Peer-scoped connect handshake. - Multiple browser Peers (such as one per tab): every browser connection shares
browserPeer. - Interaction participation is attached to each Peer. A non-owner with
interactions: 'answer'still receives only RBAC-authorized broadcast frames and cannot answer owner waterfalls; per-principal approval handling remains outside this mechanism. agent-preset/selectedand the other forwarded events declare no resources and are decided onglobal; when its viewers need a Session event, #3854 declares that event's resources and listsevent:<name>in a role.- Liveshare integration (declaring
share-viewer, one Peer per viewer connection, defining roles and registering a policy, removingorigin/prompt/origin/READ_ENDPOINTS) happens on the #3854 side. session/cancelownership checks, the forkchildSessionId, andToolExecutionInput.turndo not change.- Precise resource recognition (for example, the Typert generator locating request fields by branded id type and writing them into descriptors): this change uses the
resourcethat lookups and Contexts declare, default fields, andresourcesreaders, and resource resolution can later be replaced without changing the policy interface. - Answering 403 to every non-owner on exact Fetch routes is temporary; each route is to declare its own access rule later.
- The resource declaration of subagent
promptis open: its request fields areparentSessionId/childSessionId, so it currently resolves toglobal. The producer recordssource.principalfor in-process callers; no role lists it, so non-owners cannot reach it through the Gateway. - Predefined and aggregate roles: when a second non-owner principal kind appears, domain packages declare the generic roles a method belongs to (such as
session:viewer) with@Remote({ roles }), and each policy binds those roles; roles are currently defined by the business package as explicit action lists.
Relation to existing Notes
- Remote duplex stream: partially superseded. The replaced parts are "the Peer records no identity or permission", "this Host has exactly one Peer, the operator", "an in-process carrier without a Peer means the operator", and its alternative stating that "the Access mechanism is business semantics"; the rest (the duplex stream, uplink, and
ctx.invocationbinding) stands. Both Notes stay active and cross-linked, and that Note's current facts are updated. - Browser token authentication: unaffected. Its rejection of per-method privilege concerns owner authentication; an authenticated browser is still the owner and is not checked per method.
- Typert Remote method calls: unaffected;
@Remoteand@RemoteScopegain optionalresourcesreaders (the second argument of@RemoteScopemay be{ name?, resources? }), read only by the Gateway's access check, and change neither endpoint naming, call modes, nor generated artifacts. - Producer-owned message sources: unaffected; the producer that builds a source writes
principal.
Alternatives considered
A separate prompt/origin event correlated by requestId (the #3854 approach). Every prompt writes a required event, so even ordinary Sessions become unreadable by older versions; correlation serializes every prompt and maintains an origin fold, and messages outside the fold window lose their initiator.
Principal and peer as parallel invocation fields. One caller's identity would have two sources; every admission point would have to produce and pass both, and a path that passes only one would mismatch them.
Keep "the Peer records no identity". When business plugins attach identity through peer.ctx, every reader must know who attached it and how to read it, and the wire admission path cannot guarantee the attachment happened.
Keep a default-Peer fallback (request.peer ?? …). An implicit default silently grants owner identity to a call that forgot its Peer; a required parameter turns the omission into a compile error.
A new source kind. Every existing kind === 'user' check would need review, and one miss makes viewer messages disappear from titles, outlines, queues, or chat rendering.
Identity in a request-body field. Any caller able to build a request body could forge or override it.
React context for frontend resources. Business components read no React context; the framework binds standard hooks per resource, and components receive them only through props.
Driver-maintained endpoint allowlists that trim results (the #3854 approach). The allowlist and the result trimming both live in the driver, the Gateway does not enforce them, and the snapshot and the frontend each derive their own; the generic layer enforces role action lists, and allows() inside the method performs list filtering.
Business packages implement decide() / abstain providers whose answers are merged. Each business package hand-writes action × resource branches plus a separate resource list for snapshots, and the two stay consistent only by hand; a policy gives only bindings, decisions and snapshots are computed from the same bindings, and each principal kind has one policy, so no merge rule is needed.
A separate @RemoteAccess marker. Role action lists already guarantee default deny, so a marker is only a second consent; a method that resolves no resource falls to global, and only an explicit global grant allows it. Resource readers describe the method, so they live on @Remote.
Calling resource-less calls page or host. page describes a frontend location, not a resource; host reads easily as the parent of every resource on this Host. global means a grant tied to no resource.
Resource inheritance (GCP style: a grant on global or on a Workspace covers lower resources). List methods allowed by a global grant must decide each item, and inheritance would make any global grant imply every Session; without inheritance, a binding grants each resource explicitly.
A role registry and predefined aggregate roles. The only non-owner principal today is managed by one business package, so roles can be plain values of that package; aggregate roles would let methods a new package declares enter a share viewer's reach automatically, so they wait for a second non-owner principal kind.
Conditions attached to a role's actions. A role stays reusable plain data; a refinement by business state belongs to the binding that grants the role to a principal.
Refusal reasons from conditions only. Without a binding there is no condition to give a reason, so a viewer page could not tell "share expired" from "share not writable"; explain gives a reason only after the decision is already a refusal and never changes it.
A global resource-resolver registry (domain packages register recognizers by field name). Field recognition separates from the method, nothing flags a method signature change, and same-named fields in other packages are misread; resource resolution uses a default convention on the method plus per-method resources, and a domain package registers only its own kind's default field.
Dot-path resource declarations ('request.address.sessionId'). Types cannot constrain a path; the parameter types of resources readers are inferred from the decorated method, so a typo fails compilation.
Generator recognition of resource fields by branded id type. Requires changing Typert artifacts and the descriptor format, and SRC descriptors carry no types; kept as future work.
A central mount of the remote-access generated contribution in api/remotes. The remote-access Client depends on ui-session and ui-session depends on api/remotes, so a central mount forms a project-reference cycle; remote-access mounts its own contribution instead.
Decisions inside the Gateway (a check() with rules written in the Gateway). The Gateway would carry access-control semantics and extra error codes; it resolves resources as it resolves lookups and calls remoteAccess.decide() before dispatch, and the rules and refusal code belong to remote-access.
Asynchronous policy decisions. List filtering and snapshot computation need synchronous answers; business packages preload asynchronous state into memory.
On-demand permission queries from the frontend. Request volume grows with the number of entries and cannot follow rule changes; the Host pushes a snapshot stream and the frontend reads it synchronously.
An access check after lookup resolution. A lookup can activate an Agent, so an unauthorized call would cause side effects first.
Resource fields registered in remote-access (registerScope). The access package would recognize argument fields although lookups already know which wire field names a Session; typert holds resource kinds beside lookups and Contexts, so the Gateway resolves calls and events in one place and remote-access reads no argument field.
Trimming forwarded event payloads per Peer. A per-event projection would repeat list filtering inside every notification; an event carries at most one resource and is delivered whole or not at all, while collection changes travel on follow streams that filter inline.
Answering true until the first snapshot arrives. A non-owner page would briefly show entries it cannot use; the index carries the first snapshot, so a page without one has no access.
Consequences
Verification
| Case | Behavior | Test location |
|---|---|---|
| The wire tries to set a principal (top-level args, a payload sibling, the envelope, inside the request body) | The first two fail argument or payload validation; the rest are dropped or treated as business data; invocation.peer remains the admitted Peer | Gateway host spec |
| Every call path names its Peer | InvokeRemoteRequest.peer, the wireStream.open peer, fetch(request, peer), and the bridge peer are required; the Gateway has no fallback; the /api route passes admission.peer | typecheck, Connection host spec |
Browser connections and createPeer() | browserPeer has the owner principal; each createPeer() returns an independent Peer released with the Context that read Connection; exact routes answer 403 to non-owners | Connection host spec |
| Disposed Peers | Later unary, stream, and $events dispatches fail with gateway/peer-disposed, also when the wire payload is malformed; its streams end; a running unary call completes; other Peers are unaffected | Gateway host spec |
| Check order | The owner calls any method normally without reading a policy; a method no role lists refuses non-owners with remote-access/denied (no-allow); the access check precedes lookups, so a refused call activates no Session | Gateway host spec |
| Policy registration | One policy per non-owner principal kind, and a second registration throws; once the disposer withdraws it, that kind is refused with no-allow; it is withdrawn with its fiber | remote-access host spec |
| Decisions | Every resource needs a matching binding (AND); a role without the action, an unequal resource, or a refusing condition does not allow; the refusal reason follows condition > explain > no-allow; explain never changes the decision; conditions receive args for calls but not for snapshots and list filtering; a throwing bindings(), condition, or explain() refuses with policy-failed, each policy is logged once between invalidate() calls, and on the event path the owner stream and the shared event source continue; remoteAccess/follow is allowed for every principal | remote-access host spec, Gateway host spec |
| Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its resource yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through resources; a resources reader overrides its kind, an array yields several resources, and undefined none; with nothing the call is [global]; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with resource-invalid without asking decide(); a Context that declares no resource resolves by the ordinary rules; resources and args read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns gateway/input-invalid before the access check | Gateway host spec (gateway-access) |
| Resource registry | typert.resources registers one default field per kind, rejects a second registration and global, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's resource; a Host Context adapter keeps its resource | typert-registry spec |
| Forwarded events | A non-owner $events stream receives an event, whole, only when event:<name> is granted on its resources; an event that declares no resources is decided on global; an event whose reader returns undefined does not reach non-owners, and decide() is not asked; the owner receives all | Gateway host spec (gateway-access) |
| First snapshot | snapshotFor() returns the owner baseline or the principal's grants; remote-access injects the owner baseline into every rendered index; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs |
| Type inference | The parameter types of resources readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a global key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) |
| List filtering | session/list, session/search, session/control, and workspace/follow return non-owners only items for which allows() is true; workspace/follow sends a replace increment when a delivered Workspace loses visibility, and the Workspace reappears once access returns; owner results are unchanged | session-controller / workspace-controller host spec |
| Access snapshots | The allowed set in a snapshot equals the Host decision for the same principal, action, and resource without arguments; it contains endpoint actions only; a resource with no allowed action gets no grant, and one whose allowed set becomes empty is removed; invalidate() and policy registration or removal push increments; a wait that a change ends leaves no abort listener behind; unloading the service ends every open snapshot stream; the owner receives only { owner: true } | remote-access host spec |
| Access hooks | source(), useRemoteAccess, useAccess, useSessionAccess, and useWorkspaceAccess follow the snapshot; useAccess on a Session resource reads that Session's answer; the owner answers true; a non-owner without a snapshot or after its snapshot stream fails answers false; hooks issue no requests | remote-access client spec, ui-session / ui-workspace client spec |
| Two-argument keyed hooks | A string second argument reads the (key, member) source; existing call forms are unchanged | ui-renderer client spec |
A non-owner dispatches session/prompt | The recorded user message's source.principal equals that principal, and a cold restore reads the same value | session-controller host spec |
An in-process non-owner invocation of subagent prompt | The child Session's recorded user message has source.principal equal to that principal | subagent host spec |
| Owner messages | The source is byte-identical to the source before this change; existing keyless recorded-session snapshots are unchanged | pnpm run test:snapshot needs no re-recording |
| Persistence record | verify-persistence-changes passes | doc-sync |
The SDK projections are unaffected: the TypeScript SDK server builds its own { kind: 'user' } messages (sdk/server/src/server.ts) outside the Gateway and does not project the source; the Python SDK does not read the user source.
Costs and limits
- The change spans protocol, Connection, Gateway, two domain controllers, subagent, a new package, and the frontend, with breaking API renames; it necessarily conflicts with pr-5945 in Connection and the Gateway, handled by the migration list.
- The Gateway depends on
remoteAccess: a custom composition that mounts the Gateway without remote-access leaves the Gateway waiting. - Session logs store viewer identities and travel with exported or shared Sessions, so no principal kind may carry a credential.
- Every browser connection shares one Peer, so disposal cannot distinguish tabs until multiple browser Peers exist.
- The default field convention reads by field name: a method that gains a same-named field touches that resource, which review must watch; precise recognition is future work.
- A method that resolves only some resources lets a principal exceed its grant: unresolved resources go unchecked, and the method author ensures
resourcesis complete. - Role actions are strings and are not checked against loaded endpoints: a misspelled or renamed action never matches and shows up as a refusal.
api-session/addedcarries the wholeSessionSummary, so a non-owner allowed to receive it also learns the Session'sparentSessionId.- A granted Session's cwd and tool paths already contain the owner's absolute paths, so the Gateway does not hide
homefrom non-owner$eventsstreams separately. workspace/follownotices a Workspace whose visibility a policy revoked only at that Workspace's next change or the next order change, and then sends the non-owner areplaceincrement; an access change alone sends no frame.
Migration list for pr-5945
| File | Migration |
|---|---|
packages/client/connection/src/peer.ts | Replaces operator-peer.ts; construct OperatorPeer as new Peer(owner, { principal }) |
packages/client/connection/src/rpc-host.ts | this.operator becomes browserPeer with principal { kind: 'owner' } |
packages/client/connection/src/rpc.ts | HostConnectionHandle merges admitConnection with browserPeer / createPeer |
packages/api/gateway/src/index.ts | Replace peer ?? this.operatorPeer() where $events opens with an explicit Peer; reuse gateway/peer-disposed |
| The operator-connect Note, upgrade guide, and subsystem changes | Rewrite operator terms as Peer / browserPeer; an API connection's Peer has principal { kind: 'owner' } |
中文
问题
Host 上的 Remote 方法此前无法得知本次调用代表谁,也无法按调用方限制可调用的方法。PeerScope 的契约曾写明「Who the Peer is and what it may do are not recorded here」;Connection 只有一个 operator Peer,admit() 对每个通过认证的请求都返回它,/api 路由与 interceptor 的 Fetch handler 固定使用它而不使用准入结果;Gateway 在调用方未给出 Peer 时隐式回退到 operatorPeer()。
首个非 owner 调用方是 liveshare 读者。读者请求经 Relay 到达 owner 的 liveshare Host 插件,插件此前在 owner 进程内以 operator 身份分派,owner 的 Session 因此把读者的 prompt 当作 owner 本人的输入。PR #3854 为此把 liveshare 语义塞进 session-controller:请求体携带 origin,每条 prompt 都写 required 的 prompt/origin 事件并按 requestId 关联,controller 内做来源 fold,serializeAdmission 扩到全部 prompt,新增 api-session/external-admission 钩子;liveshare 自己维护接口白名单 READ_ENDPOINTS 并裁剪 list/follow 结果;前端的 clientWritePolicy 定义在 ui-conversation,5 个包三种读法,7 处靠「是否注入某回调」隐藏界面。
需要一套通用机制:
- 调用方身份由 Host 进程内代码赋予,wire 上任何字段都无法设置或覆盖。
- 每条调用路径都显式给出 Peer,不存在隐式兜底。
- 方法级权限在后端一处计算、一处把关;前端读同一份结果决定入口显隐,规则变化时自动同步。
- prompt 投递时身份随用户消息落盘,供之后按 turn 判定发起者。
决策
范围
| 部分 | 内容 |
|---|---|
| Peer 身份 | PeerScope.principal 与 PeerScope.signal;唯一 Peer 实现 Peer(OperatorPeer 改名)与 options 构造;删除 operator 概念与所有隐式 Peer 兜底;connection.browserPeer、connection.createPeer() 与 fetch(request, peer);已 dispose 的 Peer 一律以 gateway/peer-disposed 拒绝;user-rpc source 落盘 principal? |
| 方法级访问控制 | @Remote 的 resources 读取函数、TypertResourceMap 资源种类与 typert.resources 注册表(typert-protocol、typert-registry);Gateway 把调用与转发事件解析为资源;新包 @deepseek-ai/dsh-api-remote-access(Host 面 + Client 面):按 role、binding 与 policy 的 RBAC 判定 decide(request)、列表过滤 allows()、index 中的首份快照与访问快照流、access hooks |
| 文档 | 一条代码评审标准与术语 |
两层概念:principal 与 peer
| 层 | 概念 | 例子 | 数量关系 |
|---|---|---|---|
| 授权层 | principal:调用方身份,权限判定的依据 | 一份分享授权 token 证明的 share-viewer{shareId, actor};本机用户 { kind: 'owner' } | 一个 principal 对应 N 个 peer |
| 连接层 | peer:一条连接的生命周期作用域 | 浏览器连接、liveshare 读者的一条连接 | 每条连接一个 peer |
| 事项 | 归属层 |
|---|---|
| 方法级权限判定、access hooks、访问快照 | principal |
source.principal 落盘 | principal |
订阅、stream、连接期注册(peer.ctx) | peer |
| 撤销授权 | 两层:撤权后 policy 不为该 principal 给出 binding,其调用一律被拒;创建方 dispose 其名下全部 peer |
principal→peers 的对应表由创建方持有,例如 liveshare 每份授权一个对象,记录该授权下建立的全部 peer。
Peer 模型
只有 Peer 一个概念、一个实现,删除 operator 概念。
// @deepseek-ai/dsh-typert-protocol
export interface PeerScope {
readonly id: PeerId
/** Connection-lifetime registrations; disposed with the Peer. */
readonly ctx: Context
/** Caller identity fixed at creation by in-process Host code; no wire field can set it. */
readonly principal: Principal
/** Aborted once the Peer is disposed; Remote streams that speak for the Peer end with it. */
readonly signal: AbortSignal
/** Forwarded human-interaction participation; independent of authorization. */
readonly interactions: 'answer' | 'none'
dispose(): Promise<void>
}
// @deepseek-ai/dsh-client-connection
export class Peer implements PeerScope {
constructor(owner: Context, options: { readonly principal: Principal; readonly interactions?: 'answer' | 'none' })
}
interface HostConnectionHandle {
/** The Peer every authenticated browser connection shares; principal { kind: 'owner' }. */
readonly browserPeer: PeerScope
/** Network admission: the admitted Peer or the refusal status. */
admit(request: ConnectionTrustRequest): PeerAdmission
/** In-process only; disposed with the Context that read `ctx.connection`, or earlier by dispose(). */
createPeer(options: { readonly principal: Principal; readonly interactions?: 'answer' | 'none' }): PeerScope
}
interface ConnectionFetchHandler {
fetch(request: Request, peer: PeerScope): Promise<Response>
}
bridge(req, res, apiHandler, peer, maxRequestBodyBytes?)
// @deepseek-ai/dsh-api-gateway
interface InvokeRemoteRequest { …; readonly peer: PeerScope } // was `peer?`
wireStream.open(endpoint, payload, uplink, peer: PeerScope, signal) // was `PeerScope | undefined`
| 规则 | 说明 |
|---|---|
| 每条路径显式给出 Peer | 网络路径由 admit() 的结果给出;进程内路径由调用方传入。Gateway 删除 operatorPeer() 与 request.peer ?? …,不再自建 Peer |
浏览器连接共享 browserPeer | 所有浏览器连接共享一个 Peer,每个使用处都显式引用 connection.browserPeer,不存在兜底变量;页内 Host(webworker-runtime)同样显式传 browserPeer |
| Host 进程内自动化 | 需要调用 Remote 方法时用 createPeer({ principal }) 创建自己的 Peer。目前没有这类生产调用方,因此不预置固定的 Host Peer |
| 多 Peer | createPeer() 每次返回独立 Peer;liveshare 将用它为读者的每条连接建一个 Peer(在 #3854 侧接入) |
| 交互参与 | Peer(owner, { principal, interactions? }) 与 createPeer() 省略 interactions 时默认 answer。none 对所有 principal 拒绝 $events 与 $events/result;交互模式不赋予方法权限。回答型 owner 能接收并回答 waterfall,非 owner 只能接收 RBAC 授权的广播,不能回答 |
| exact Fetch route | file-upload、present-open、media-references、session-log-export、inspector 等 exact route 不经 Gateway;共享 Fetch handler 对非 owner Peer 一律返回 403 |
身份类型
// @deepseek-ai/dsh-typert-protocol
/**
* Merge-extensible caller identities. Each driver declares its own entry.
* Every entry is a JSON value with a unique literal `kind`, carries no
* credential, and is safe to persist in a Session log.
*/
export interface PrincipalMap {
owner: { readonly kind: 'owner' }
}
export type Principal = PrincipalMap[keyof PrincipalMap]
/**
* @param invocation - `ctx.invocation`; `undefined` outside a Remote call.
* @returns the caller's principal when it is not the owner; `undefined` for the
* owner and for trusted in-process code outside a Remote call.
*/
export function principalOf(invocation: RemoteInvocation | undefined): Principal | undefined
读取入口统一为 this.ctx.invocation.peer.principal;owner 判断就地写 principal.kind === 'owner',未知 kind 永远不是 owner。外部 driver 在自己的包内用声明合并扩展 PrincipalMap;liveshare 的 share-viewer{ shareId, actor } 在 #3854 侧声明,此处不改 liveshare。
Peer 生命周期与撤权
| 事件 | 行为 |
|---|---|
| Gateway 分派 | 第一步检查 peer.signal.aborted,已 dispose 即以 gateway/peer-disposed 拒绝。对 HTTP、WebSocket 与进程内路径都生效;wire 路径在读取 payload 之前检查,已 dispose 的 Peer 携带畸形 payload 也得到同一错误码 |
| Peer dispose 时的 stream | Remote stream 方法看到的 signal 并入 peer.signal,$events 流的生命周期同样并入;WebSocket 照旧以 1001 关闭 |
| Peer dispose 时的 unary | 已在执行的 unary 调用不被取消;之后的调用被拒绝 |
| 撤销授权 | 业务包撤权后 policy 不为该 principal 给出 binding,业务包调用 invalidate(principal);创建方按 principal→peers 表 dispose 全部 peer |
方法级访问控制
资源类型与 @Remote 的 resources 读取函数在 @deepseek-ai/dsh-typert-protocol;资源种类在 typert 中与 lookups、Contexts 并列:TypertResourceMap 声明种类,typert.resources 保存每个种类的默认 wire 字段,lookup 与 Host Context adapter 声明其 wire 身份所属的资源种类。Gateway 把一次调用或一个转发事件解析为它访问的资源。判定、列表过滤与访问快照在新包 @deepseek-ai/dsh-api-remote-access(packages/api/remote-access),该包不读取任何参数字段。Gateway 与领域包对 remote-access 只做 type-only 依赖并 inject remoteAccess,值依赖只有 dsh-typert-protocol,不为 remote-access 新增 package-dependency-policy 条目。role、binding 与 policy 由业务包定义和注册,liveshare 为首个使用方。除内置的 global 外,remote-access 与 Gateway 中不出现任何资源种类名,符合 packages/api 只承载通用编排 的评审规则。
与 RBAC 的对应
| 本设计 | RBAC / IAM 术语 | 含义 |
|---|---|---|
| principal | Subject / Principal | 调用方身份,PrincipalMap 的一个条目 |
| Peer | Session(RBAC 会话) | principal 的一条连接;一个 principal 对应 N 个 Peer |
| owner | 超级用户 | 跳过全部检查 |
| action | Action / Permission | Remote 端点 ns/method,或转发事件 event:<name>;Gateway 内部仍称端点 |
| resource | Resource / Object | { kind: 'global' },或某个已声明资源种类的 { kind, id } |
| role | Role | 限定一种资源种类的 action 集合,是业务包的普通值 |
| binding | RoleBinding(角色指派) | principal 在某个资源上拥有某个 role,可附 condition |
| condition | Condition(IAM Conditions) | 挂在 binding 上,按请求的 action、资源与参数放行或带原因拒绝 |
| policy | Allow policy | 业务包为一种非 owner principal kind 给出 binding 与可选 explain |
| explain | 拒绝说明 | 没有 binding 放行时给出拒绝原因,不改变结论 |
| Gateway | PEP(Policy Enforcement Point) | 解析资源、执行结论 |
| remote-access | PDP(Policy Decision Point) | 计算结论、列表过滤与访问快照 |
| 规则 | 内容 |
|---|---|
| 默认拒绝 | 非 owner 只有在某个 binding 的 role 显式列出该 action 时才被放行;role 未列出的 action、没有 binding 的资源、没有 policy 的 principal kind 一律被拒。所有 Remote 方法都受访问控制,没有单独的可访问标记 |
| 资源之间无继承 | global 不是其他资源的上级:global 上的授权不蕴含任何会话或工作区上的授权,工作区上的授权也不蕴含其中会话的授权 |
global 授权即显式全局授权 | 调用解析不出任何资源即访问 global。把某个 action 放进 global 级 role,等于允许该 principal 不受资源限制地调用它,属高危授权;方法被放行后仍可在内部用 allows(resource) 逐项细判 |
资源与资源读取(@deepseek-ai/dsh-typert-protocol、@deepseek-ai/dsh-typert-registry)
/** Merge-extensible resource kinds: the key is the kind, the value declares its identity type. */
export interface TypertResourceMap {}
// @deepseek-ai/dsh-session/types: session: TypertResource<SessionId>
// @deepseek-ai/dsh-workspace/types: workspace: TypertResource<WorkspaceId>
// Runtime registries in typert
ctx.typert.resources.register(kind, { field }): TypertDisposer
ctx.typert.lookups.register(key, { parameter, wire, resource?, … }) // resource: the kind its wire id names
ctx.typert.contexts.registerHost(key, { wire, resource?, … }) // a TypertHostContextAdapter
export interface TypertHostContextAdapter<Wire = unknown> {
readonly wire: string
readonly wireTypeSymbol: string
/** Resource kind the wire identity names; access checks read the identity from args before resolve(). */
readonly resource?: TypertResourceKind
resolve(id: Wire): Context | undefined | Promise<Context | undefined>
}
export type RemoteAccessResourceKind = 'global' | TypertResourceKind
export type RemoteAccessResource =
| { readonly kind: 'global' }
| { readonly kind: K; readonly id: TypertResourceId<TypertResourceMap[K]> } // for each declared kind K
export type RemoteAccessResourceOf<K extends RemoteAccessResourceKind> = Extract<RemoteAccessResource, { readonly kind: K }>
/** Per-kind readers, called with the method's positional wire arguments. */
export type TypertResourceReaders<WireArgs extends readonly unknown[]> = {
readonly [K in TypertResourceKind]?: (...args: WireArgs) => Id | readonly Id[] | undefined // Id = TypertResourceId<TypertResourceMap[K]>
}
export interface RemoteMethodOptions<WireArgs extends unknown[]> {
readonly name?: string
readonly mode?: 'stream'
readonly resources?: TypertResourceReaders<WireArgs>
}
export function Remote<This extends object, Args extends unknown[], Result>(
option: string | RemoteMethodOptions<NoInfer<RemoteWireArgs<Args>>>,
): (method: (this: This, ...args: Args) => Result,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>) => void
export interface RemoteScopeOptions<WireArgs extends unknown[]> {
readonly name?: string
readonly resources?: TypertResourceReaders<WireArgs>
}
export function RemoteScope<This extends object, Args extends unknown[], Result>(
key: Extract<keyof TypertContextMap, string>,
option?: string | RemoteScopeOptions<NoInfer<RemoteWireArgs<Args>>>,
): (method: (this: This, ...args: Args) => Result,
context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Result>) => void
| 规则 | 内容 |
|---|---|
| 写法 | @Remote({ name: 'page', resources: { session: (request) => request.address.kind === 'session' ? request.address.sessionId : request.address.childSessionId } });Context 方法写 @RemoteScope('agent'),由 Agent Context 的 resource: 'session' 得到会话资源,需要其他资源时写 @RemoteScope('agent', { resources: { workspace: (request) => request.workspaceId } })。多数方法不写 resources,由自动解析得到资源 |
| 资源种类 | 资源种类由声明 id 类型的底层包合并:session: TypertResource<SessionId> 在 @deepseek-ai/dsh-session/types,workspace: TypertResource<WorkspaceId> 在 @deepseek-ai/dsh-workspace/types。session-controller 引用 commands 包,若在 session-controller 合并,commands 要引用 session-controller,形成 project reference 环。global 由 remote-access 内置,没有 id,不进 TypertResourceMap;typert 保留该名,typert.resources.register('global', …) 抛错 |
| 默认字段与声明 | 拥有该种类的领域在控制器构造时登记默认 wire 字段:SessionController 构造函数调用 typert.resources.register('session', { field: 'sessionId' }),WorkspaceController 构造函数登记 workspace → workspaceId;同一种类重复登记即抛错。Agent、Session 与 workspaceFileScope lookup 声明 resource: 'session',agent 包的 Agent Context 声明 resource: 'session' |
| 解析优先级 | 对每个资源种类依次取:① 该方法 resources 中该种类的读取函数;② 声明了该种类 resource 的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Context 方法的 Context 声明了该种类 resource 时,其 wire 身份同样贡献;③ 该种类的默认字段:命名参数中有同名顶层参数就读它,否则读第一个对象参数的同名字段。默认字段只表示一个身份,读到数组即为非法值 |
| 参数来源 | 资源解析与 decide() 的 args 读取经 Typert 编译类型校验后的命名参数:每个 JSON 参数由其 codec 解码一次,类型未声明的对象字段被移除,分派复用该值,因此调用方无法用未声明字段改变资源;lookup 参数与 Context 身份取 wire 身份,此时不解析 lookup、不激活 Agent |
| 解析结果 | 各种类的资源合在一起即本次调用访问的资源;一个都没有即 [global];解析出资源时只用这些资源,不附加 global |
| 读取函数 | 以方法的位置参数调用:按描述符把命名参数映射回方法参数顺序(JSON 位为校验后的值,lookup 位为 wire 身份,取消参数位为调用的 signal);返回一个 id、id 数组(每个都须被允许)或 undefined(该种类无资源) |
| 非法值 | 读取函数抛错或返回空数组,或读到的值(含 Context 的 wire 身份)不是非空字符串即资源非法:Gateway 不询问 decide(),直接以 remote-access/denied(reason resource-invalid)拒绝调用 |
| 完整性 | resources 与自动解析必须覆盖方法访问的全部资源。一个资源都没解析出的调用落到 global,只有 global 级 role 能放行,因而默认被拒;只解析出部分资源时,其余资源不受检查,持有已解析资源授权的 principal 可越权访问它们,由方法作者负责避免 |
| 类型推断 | @Remote 与 @RemoteScope 读取函数的参数类型从被装饰方法自动推断:工厂参数用 NoInfer,Args 只从返回的装饰器推断;lookup 参数按 TypertLookupMap 映射为 wire 类型(首个参数为 agent: Agent 的方法,其读取函数的第一个参数为 SessionId)。访问不存在的属性、参数多于方法参数与 global 键均为编译错误 |
| 存放 | resources 读取函数与 @Remote / @RemoteScope 的其他元数据一同存在类原型上,随实例构造登记,跨重复安装安全;不进入 Typert 生成物 |
| Context 方法 | @RemoteScope(key, option?) 与 @Remote 一致:字符串为导出名,对象为 { name?, resources? }。Context 的 wire 身份按声明了 resource 的 lookup 参数参与解析,优先级与 lookup 相同,该种类有 resources 读取函数时被覆盖;Gateway 在 resolveReceiverContext 之前直接从 args 读取该身份,不激活 Agent。Context 未声明 resource 时其身份不贡献资源,方法按普通规则解析 |
端点的资源
下表列出 #3854 读者用到的端点及其资源来源。只有 session/page 与 session/follow 需要显式 resources,其余靠 lookup 的 resource 或默认字段:
| 端点 | 资源 | 解析来源 | 方法内过滤 |
|---|---|---|---|
workspace/follow | global | 无资源 | 是:工作区与会话 |
session/control | global | 无资源 | 是:会话 |
session/list | global(_request 无 sessionId) | 无资源 | 是:会话行 |
session/modelCatalog | global | 无资源 | 否 |
session/canOpenWorkspacePath | global | 无资源 | 否 |
llm/listConfigurableProviders | global | 无资源 | 否 |
llm/listProviders | global | 无资源 | 否 |
commands/list | session:args.agentId(Agent lookup 的 wire 字段) | Agent lookup resource: 'session' | 否 |
goals/get | session:args.agentId | Agent lookup resource: 'session' | 否 |
session/attachment | session:args.request.sessionId | 默认字段 | 否 |
skills/list | session:args.request.sessionId | 默认字段 | 否 |
session/fork | session:args.request.sessionId | 默认字段 | 否 |
session/prompt | session:args.request.sessionId | 默认字段 | 否 |
session/cancel | session:args.request.sessionId | 默认字段 | 否 |
session/projections | session:args.request.sessionId | 默认字段 | 否 |
session/page | session:Session 地址读 args.request.address.sessionId;subagent 地址读 args.request.address.childSessionId | resources | 否 |
session/follow | 同上 | resources | 否 |
remoteAccess/follow | global | 无资源;remote-access 对任何 principal 放行 | 否 |
subagent 地址读取的是子会话转录,因此其资源是子会话。Client 在重连后会重新读取已知会话的 projections,其中包括 subagent 会话的父会话;没有该父会话授权的读者,其 projection 读取被拒。父会话的授权是否覆盖其 subagent 子会话由 policy 决定,即是否为子会话给出 binding;通用层不推导父资源。
不需要非 owner 访问的方法不做任何声明,没有 role 列出它即被拒。subagent prompt 的请求字段为 parentSessionId / childSessionId,自动解析不到会话,解析为 global,因此不得放进任何 global 级 role;session/search 在方法内按会话逐项过滤命中。subagents/list 不是 Remote 端点(SubagentRuntime.list() 未标 @Remote)。
role、binding 与 policy(@deepseek-ai/dsh-api-remote-access)
/** Remote endpoint `ns/method`, or `event:<name>` for one forwarded event; endpoint segments never contain `:`. */
export type RemoteAccessAction = RemoteAccessEndpoint | `event:${string}`
/** A named action set over one resource kind; a plain value owned by the business package. */
export interface RemoteAccessRole<K extends RemoteAccessResourceKind> {
readonly name: string
readonly resource: K
readonly actions: readonly RemoteAccessAction[]
}
/** Answer of decide() and of a condition. */
export type RemoteAccessDecision =
| { readonly kind: 'allow' }
| { readonly kind: 'deny'; readonly reason: string }
/** Refines one binding for one action on its resource; `args` only for a concrete call. */
export type RemoteAccessCondition<K extends RemoteAccessResourceKind> = (request: {
readonly action: RemoteAccessAction
readonly resource: RemoteAccessResourceOf<K>
readonly args?: Readonly<Record<string, unknown>>
}) => RemoteAccessDecision
/** One role on one resource of the role's kind; the resource type follows from the role. */
export type RemoteAccessBinding = {
[K in RemoteAccessResourceKind]: {
readonly role: RemoteAccessRole<K>
readonly resource: RemoteAccessResourceOf<K>
readonly condition?: RemoteAccessCondition<K>
}
}[RemoteAccessResourceKind]
/** One access question; the Gateway passes only valid resources, [global] when none. */
export interface RemoteAccessRequest<P extends Principal> {
readonly principal: P
readonly action: RemoteAccessAction
/** Allow only when the action is allowed on every listed resource. */
readonly resources: readonly RemoteAccessResource[]
/** Named arguments of a concrete call, validated against their Typert-compiled types with lookup parameters as wire identities; absent for events, snapshots, and list filtering. */
readonly args?: Readonly<Record<string, unknown>>
}
/** Business rules for one non-owner principal kind; synchronous over in-memory state. */
export interface RemoteAccessPolicy<P extends Principal> {
bindings(principal: P): readonly RemoteAccessBinding[]
/** Reason for a request no binding allows; never changes the decision. */
explain?(request: RemoteAccessRequest<P>): string | undefined
}
/** details of `remote-access/denied` */
export interface RemoteAccessDeniedDetails {
readonly action: string
readonly reason: string
}
class RemoteAccessService { // ctx.remoteAccess; Remote namespace remoteAccess
registerPolicy<K extends Exclude<keyof PrincipalMap, 'owner'>>(kind: K, policy: RemoteAccessPolicy<PrincipalMap[K]>): () => void
invalidate(principal?: Principal): void
decide(request: RemoteAccessRequest<Principal>): RemoteAccessDecision // Gateway-only
allows(resource: RemoteAccessResource): boolean // reads the accessing ctx's invocation
snapshotFor(principal: Principal): RemoteAccessBaseline // a page's opening baseline
follow(signal): AsyncIterable<RemoteAccessFrame> // remoteAccess/follow
}
| 规则 | 内容 |
|---|---|
| owner | Gateway 对 owner 不解析资源、不调用 decide(),也不读取任何 policy |
| 注册 | registerPolicy(kind, policy) 返回 disposer,业务包在 ctx.effect() 中调用。每个非 owner principal kind 至多一个 policy,同一 kind 重复注册抛错;bindings() 与 explain() 收到的 principal 按 kind 定型 |
| 内置放行 | remoteAccess/follow 对任何 principal 放行,每个 Peer 都能读自己的快照 |
| 判定 | 没有该 principal kind 的 policy 以 no-allow 拒绝。否则取 bindings(principal),对请求的每个资源线性扫描找一个 binding:资源相等(kind 相同,且非 global 时 id 相同)、role 的 actions 含该 action、没有 condition 或 condition 放行;每个资源都找到才放行(AND)。condition 只对资源相等且 role 含该 action 的 binding 调用 |
| 拒绝原因 | 第一个未被放行的资源决定原因,优先级为:该资源上存在匹配 binding 但其 condition 全部拒绝时,取第一个 condition 的原因;否则取 explain(request) 的返回值;没有 explain 或其返回 undefined 时为 no-allow。explain 只在结论已为拒绝后调用,不改变结论 |
| 失败隔离 | bindings()、condition 或 explain() 抛错时,本次判定以 policy-failed 拒绝;计算快照时 bindings() 抛错即该 principal 没有 grant,condition 抛错即该 action 不放行。有问题的规则因此不会破坏共享的 $events 事件源、快照流或页面渲染;每次 invalidate() 或 policy 变更之后,每个 policy 的第一次失败只记录一次日志 |
| 同步 | bindings()、condition 与 explain() 同步返回,状态由业务包预先载入内存,以便列表逐项过滤与快照计算 |
| 求值时机 | 每次 decide()、allows() 与快照计算都调用一次 bindings(),不缓存;依赖时间的状态(如过期)即时生效,长流(如 workspace/follow、session/control)在 binding 增删后的下一次逐项判定即反映;快照只在 invalidate() 后重算,业务包在过期等时间事件发生时也须调用 invalidate() |
| 通知 | 业务状态变化(授权、撤权、模式切换)改变 binding 或 condition 的结论时,业务包必须调用 invalidate(principal?);快照只在 invalidate() 与 policy 注册、撤回后重算 |
remote-access/denied | 只有调用的拒绝变成错误,details { action, reason },reason 为 condition 或 explain 给出的原因、no-allow、policy-failed,或 Gateway 对非法资源给出的 resource-invalid;被拒的事件只是不推送 |
| 必需服务 | Gateway static inject = ['typert', 'remoteAccess'];挂载 Gateway 的组合必须挂载 remote-access,bundle/base 紧随 typert-gateway 装配;plugin manager 像保护 Gateway 一样保护它,插件页面无法禁用 |
示例:分享读者
以 liveshare 声明的 share-viewer principal 为例,说明业务状态到 binding 的推导留在业务包;示例中的 shares 代表业务包在内存中持有的分享状态:
const globalReader = { name: 'share:global-reader', resource: 'global', actions: ['session/list', 'session/control', 'workspace/follow', 'session/modelCatalog', 'llm/listProviders', 'commands/list'] }
const sessionReader = { name: 'share:session-reader', resource: 'session', actions: ['session/page', 'session/follow', 'session/list', 'session/control', 'workspace/follow', 'commands/list', 'event:api-session/status', 'event:api-session/added'] }
const workspaceReader = { name: 'share:workspace-reader', resource: 'workspace', actions: ['workspace/follow'] }
const forker = { name: 'share:forker', resource: 'session', actions: ['session/fork'] }
const writer = { name: 'share:writer', resource: 'session', actions: ['session/prompt', 'session/cancel'] }
ctx.effect(() => ctx.remoteAccess.registerPolicy('share-viewer', {
bindings(viewer) {
const share = shares.serving(viewer) // undefined once stopped, expired, or no longer admitted
if (share === undefined) return []
const source = { kind: 'session', id: share.sessionId }
return [
{ role: globalReader, resource: { kind: 'global' } },
{ role: sessionReader, resource: source },
...(share.allowsFork ? [{ role: forker, resource: source }] : []),
...(share.mode === 'write' ? [{ role: writer, resource: source, condition: sourceWrite(share) }] : []),
...shares.forksOf(viewer).flatMap(child => [
{ role: sessionReader, resource: { kind: 'session', id: child.id } },
...(child.revoked ? [] : [{ role: writer, resource: { kind: 'session', id: child.id }, condition: childPrompt(child) }]),
]),
...shares.workspacesShowing(viewer).map(id => ({ role: workspaceReader, resource: { kind: 'workspace', id } })),
]
},
explain({ principal, action }) {
return shares.refusal(principal) // 'share-expired', 'share-stopped', …
?? (action === 'session/prompt' ? 'share-not-writable' : undefined)
},
}))
// Without args (snapshots, list filtering) a condition answers for the action alone.
const sourceWrite = share => () => share.confined ? allow : deny('source-write-unavailable')
const childPrompt = child => ({ action, args }) =>
action === 'session/prompt' && args !== undefined && !child.allowsPrompt() ? deny('fork-prompt-refused') : allow
| 归属 | 内容 |
|---|---|
| 业务包 | role 的 action 清单;分享状态到 binding 的推导(停止、过期、准入、模式、读者自己的分支、撤销、包含可见会话的工作区);按请求细化的 condition;面向读者页面的拒绝原因 |
| 通用层 | 资源解析、按资源 AND、role 与 action 匹配、condition 调用、原因优先级、列表过滤、快照计算与失败隔离 |
该读者的快照包含 global、源会话、自己的每个分支与每个可见工作区上的 grant;session/list 同时出现在 global 级与 session 级 role 中,见列表与聚合方法。
Host 侧鉴权调用顺序
所有调用路径(HTTP /api、WebSocket stream、进程内 fetch(request, peer) / invoke / stream / wireStream.open)都经过同一段 Gateway 逻辑:
| 步 | 动作 | 失败结果 |
|---|---|---|
| 1 | 准入得到 Peer:网络路径由 Connection admit() 给出(browserPeer);进程内路径由调用方传入。Gateway 在读取 wire payload 之前检查 peer.signal | 网络准入失败返回 HTTP 401 / 403(不进入 Gateway);Peer 已 dispose:gateway/peer-disposed |
| 2 | 解析端点并做参数字段集校验(resolveDescriptor、assertExactArguments),再按 Typert 编译类型校验 JSON 参数,每个参数解码一次 | gateway/invocation-unavailable、gateway/ambiguous-endpoint、gateway/definition-unavailable;字段多余或缺失:gateway/arguments-invalid;JSON 参数校验失败:gateway/input-invalid(owner 与非 owner 相同,先于访问检查) |
| 3 | owner 直通:principal.kind === 'owner' 时直接进入第 5 步 | — |
| 4 | 非 owner:Gateway 从第 2 步校验后的参数解析资源(resources 读取函数、其次 lookup 参数与 Context 声明的 resource、再次默认字段;lookup 与 Context 身份取 wire 身份,不解析 lookup 与 Context),再以校验后的参数为 args 询问 remoteAccess.decide({ principal, action: endpoint, resources, args });资源非法时不询问 decide(),直接以 resource-invalid 拒绝;deny 结论变为抛出的错误 | remote-access/denied,details { action, reason } |
| 5 | 分派:resolveReceiverContext(可能激活 Agent)、lookup 解析(JSON 参数复用第 2 步的值)、调用方法 | 既有错误码不变,如 gateway/lookup-not-found、gateway/context-not-found、gateway/input-invalid、gateway/service-unavailable、gateway/method-unavailable 与业务错误 |
鉴权(第 3–4 步)位于 lookup 与 Context 解析之前,未授权调用不会激活会话或产生其他副作用。Gateway 新增的错误码只有 gateway/peer-disposed;拒绝码 remote-access/denied 由 remote-access 声明。
Gateway 自有端点:非 owner 可订阅 $events,但从不收到需要回答的 waterfall 帧;$events/result 对非 owner 以既有的 gateway/invocation-unavailable 拒绝;两者都做第 1 步的检查。
转发事件
API_REMOTE_FORWARDED_EVENTS 的 emit 条目可以声明 resources:以资源种类为键、经 TypertForwardableEventEntry 按该事件自身参数定型的读取函数,例如 { event: 'api-session/added', mode: 'emit', resources: { session: (summary) => summary.sessionId } }。API Remotes 把读取函数附在每个转发帧上;对每条非 owner 的 $events 流,Gateway 每帧解析一次资源,只有 remoteAccess.decide({ principal, action: 'event:<name>', resources }) 允许时才推送。
| 规则 | 内容 |
|---|---|
| 已声明事件 | api-session/activity、api-session/added、api-session/error、api-session/removed 与 api-session/status 声明其 Session |
| 未声明资源 | 未声明 resources 或声明为空 resources: {} 的事件访问 global,只有 global 级 role 列出 event:<name> 时才送达非 owner |
| 缺失资源 | 事件携带其声明的每个资源,因此读取函数返回 undefined 时该事件的资源非法,Gateway 不询问 decide(),不送达任何非 owner;调用缺少资源时则为 global |
| 整条放行或拒绝 | 允许的事件 payload 原样送达;不按 Peer 裁剪或投影 payload |
| 每个事件一个资源 | 通知至多携带一个资源;集合类变化走 follow 类流(session/follow、session/control、workspace/follow),由这些方法内联经 allows() 逐项判定 |
| waterfall | 非 owner 从不收到 waterfall 帧,不经 policy 判定 |
| 非 owner 流的 Host 信息 | 非 owner 的 $events 流与 owner 流一样以完整 host(含 home)打开;hasLiveClient() 只统计能回答 waterfall 的 owner 流 |
<a id="list-and-aggregate-methods"></a>
列表与聚合方法
列表、分页与聚合类方法的调用解析为 global,由 global 级 role 放行;方法内部对每一项调用 this.ctx.remoteAccess.allows(resource),判定当前调用的 action 在该资源上是否被授权(不带 args)。因此列表类 action 同时出现在 global 级 role(放行调用)与资源级 role(放行逐项)中:session/list 在 global 上被授权才能调用,每个会话行还要求 session/list 在该会话上被授权。allows() 读访问该服务的 Context 上的 invocation,owner 与 Remote 调用之外的进程内代码恒为 true,不过滤。
| 方法 | 过滤对象 |
|---|---|
session/list | 会话行(session 资源) |
session/search | 搜索命中(session 资源) |
session/control | baseline 的 projections 与 projection 增量(session 资源) |
workspace/follow | baseline、upsert 帧与 order id 中的工作区(workspace 资源)、工作区内会话、archived / pinned 会话集合(session 资源)。流记录已下发的工作区;某个工作区在下一次变化或新顺序中已不被允许时,发送一个携带完整过滤后状态的 replace 增量帧而不是 remove,因为 Client 不会复活已移除的 id,访问恢复后该工作区经普通 upsert 重新出现。remove 帧表示删除,只发给收到过该工作区的调用方 |
访问快照流
// Remote: remoteAccess/follow (stream; every Peer may read its own snapshot)
type RemoteAccessFrame =
| { readonly type: 'baseline'; readonly owner: true }
| { readonly type: 'baseline'; readonly owner: false; readonly grants: readonly RemoteAccessGrant[] }
| { readonly type: 'upsert'; readonly grant: RemoteAccessGrant }
| { readonly type: 'remove'; readonly resource: RemoteAccessResource }
interface RemoteAccessGrant {
readonly resource: RemoteAccessResource // global, or one declared-kind resource
readonly actions: readonly string[] // endpoint actions allowed on this resource without args, sorted
}
/** Window global carrying a page's opening baseline. */
export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__'
| 行为 | 说明 |
|---|---|
| 首份快照 | snapshotFor(principal) 计算页面打开时的 baseline。remote-access 经 webserver/index-inject 为 webServer 渲染的每个 index 加入全局变量 REMOTE_ACCESS_SNAPSHOT_KEY,值为 owner baseline { type: 'baseline', owner: true }。渲染非 owner 页面的业务包(例如 owner Host 上的 liveshare)在所有 index 注入行之后放置一条把该全局变量赋值为 snapshotFor(principal) 的语句;后写的赋值生效,Client 在挂载时读取该全局变量 |
| 订阅 | 每个 Peer 订阅一条访问快照流,其 baseline 取代注入的快照 |
| 计算 | 取该 principal kind 的 policy 的全部 binding,按资源相等归并;每个资源的 actions 为这些 binding 的 role 中、经与调用相同的判定(单个资源、不带 args,condition 以无 args 调用)放行的端点 action;只为至少放行一个端点 action 的资源给出 grant。判定与快照同源:同一 principal、action 与资源(不带参数)上,快照答案与 decide() 一致 |
| 只含端点 action | event:<name> action 不进入快照;前端只据端点 action 决定入口显隐 |
| 增量 | invalidate() 或 policy 注册、撤回时重算并推送 upsert / remove,放行集合变空的资源推送 remove;未变化的资源不推送 |
| 生命周期 | 一代快照流在其 signal abort 或 remote-access 服务卸载时结束,因此重载后的服务从新的 baseline 回答每个读者;Client 把结束视为载体中断并重连 |
| owner | 只推送 { owner: true },前端全部为 true |
| wire 类型 | actions 为 readonly string[]:Typert 无法为模板字面量类型生成 codec |
access hooks 只按 action 与资源判断,不看调用参数;调用时 condition 可额外读取 args 做更细判断,以服务端结果为准。
Client 面
Client 面走框架标准 hook 注入,不引入 React context。组件只经 props 拿到 hook,不 import remote-access 的值。
// @deepseek-ai/dsh-api-remote-access/client
interface ClientRemoteAccess { // Client service ctx.remoteAccess
source(kind: TypertResourceKind, id: string, action: string): HostObservable<boolean>
}
GlobalStandardProps.useRemoteAccess: (action: RemoteEndpoint) => boolean // remote-access; the global resource
// @deepseek-ai/dsh-client-ui-session (injects remoteAccess)
GlobalStandardProps.useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean
SessionStandardProps.useAccess: (action: RemoteEndpoint) => boolean
SessionMaybeStandardProps.useAccess: (action: RemoteEndpoint) => boolean | undefined
// @deepseek-ai/dsh-client-ui-workspace (injects remoteAccess)
GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean
// RemoteEndpoint = Extract<keyof TypertRemoteMap, string>
| 事项 | 定稿 |
|---|---|
| 快照与挂载 | remote-access Client 插件从 index 注入的 baseline 起步(启动时同步读取并校验),订阅快照流,对每个 baseline 一次构建完成,提供 Client 服务 ctx.remoteAccess.source(kind, id, action)(身份稳定的布尔 source)并注入 global 资源上的 useRemoteAccess。入口模块 import 生成的 /remote contribution,连同注入的 baseline 交给 mountAccess(ctx, contribution, initial) 经 ctx.remote.$mount 自行挂载(单测传入假 contribution),先例为 experimental client-ui-cot-translation、voice-input、claude-code-mods;若由 api/remotes 集中挂载,会形成 ui-session → api/remotes → remote-access → ui-session 的 project reference 环 |
| 会话资源 | ui-session 经 uiSession.provide({ keyedHooks: ['access'], resolve }) 给出本会话答案,会话由框架绑定,与 useProjection 相同;useSessionAccess 经 provideRoot 注入 |
| 工作区资源 | ui-workspace 经 provideRoot 注入 useWorkspaceAccess |
| 名称区分 | global 答案是 useRemoteAccess,会话答案是 useAccess,两者互不遮蔽;session-maybe 无会话时 useAccess 为 undefined,调用方按不允许处理,需要 global 判断时读 useRemoteAccess |
| 双参数调用 | 框架 keyed hook 的标准双参数:KeyedStandardSource 为 (key, member?) => source;第二个参数为字符串时 hook 读取 resolver 对 (key, member) 返回的 source,不带 selector;resolver 对同一参数返回同一 source。既有 (key, selector?, equal?) 与 (selector, equal?) 调用不变。禁止柯里化形式 |
| 返回值 | 有资源的答案均为布尔值;action 类型来自 ctx.remote 的 namespace 与方法 |
| 不回退 | 会话资源上查询非会话方法(如 useAccess('session/create'))读该会话资源上的判定结果,不回退 global 答案 |
| 无快照 | owner 为 true,快照流终止失败后亦然;没有注入 baseline 或快照流终止失败的非 owner 页面为 false,终止失败会连同错误与“刷新页面”的提示一并报告;断线重连期间保留上次快照;注入的 baseline 格式错误时挂载失败;Host 仍对每次调用执行检查。hook 从不发请求 |
| 示例消费方 | ui-workspace 会话行菜单的 fork 入口读 useSessionAccess(sessionId, 'session/fork'),不允许时不渲染;ui-chat turn tail 的分叉按钮读会话资源上的 useAccess('session/fork'),不允许时不渲染。替换 #3854 的 clientWritePolicy 留给 #3854 |
| 测试 | @deepseek-ai/dsh-client-test-runtime 导出 allowAllAccess props 与 allowAllAccessPlugin;TestClient 像 api-remotes 一样去掉 remote-access 行并装入全允许服务,SlotTestRuntime 在 ui-session 之前装入 |
安全不变量
| 不变量 | 依据与落点 |
|---|---|
| principal 只能由进程内 Host 代码在创建 Peer 时给出 | PeerScope.principal 只读;createPeer() 不对应任何路由 |
| wire 上任何字段都不能设置或覆盖 principal | principal 不来自 args、envelope、header、query 或 cookie。args 顶层多余字段被 assertExactArguments 拒绝;payload 必须恰为 { args };envelope 未知字段被 clientRequestSchema 丢弃;请求体内同名字段只是业务数据 |
| 浏览器网络准入为 owner | 通过浏览器 token 认证的连接得到 browserPeer |
| 没有隐式 Peer | Gateway 与 Connection 都不补默认 Peer;required 参数让漏传在编译期暴露 |
| 已 dispose 的 Peer 不能再分派 | gateway/peer-disposed |
| 非 owner 未经授权不能调用 | 只有 binding 的 role 显式列出该 action 且资源匹配时才放行;没有该 kind 的 policy 即拒;Gateway 依赖 remoteAccess,缺失时 Gateway 不启动 |
global 授权不蕴含资源授权 | 资源之间无继承;方法内 allows() 对每一项独立判定 |
| 未知 principal kind 不是 owner | 只有 kind === 'owner' 是 owner |
| 非 owner 不回答 waterfall | $events 不给非 owner waterfall 帧,$events/result 拒绝非 owner |
| 非 owner 只收到被授权的通知 | 每个转发帧按 event:<name> 与其资源判定,被允许才转发给非 owner |
消息来源落盘
session-controller 类型 的 MessageSourceMap 条目增加可选字段:
'user-rpc': {
kind: 'user'
rpcId: SessionRequestId
clientTimeZone?: string
/** Non-owner caller that submitted this prompt; absent means the owner. */
principal?: Principal
}
在 Remote 调用中构造 kind: 'user' + rpcId 用户消息 source 的生产者共两处,都在 Remote 方法内调用 principalOf(this.ctx.invocation),返回值非 undefined 才写 principal:
| 生产者 | 位置 |
|---|---|
SessionController.prompt() 构造 source,SessionCommandController.prompt(request, source) 只负责投递 | session-controller/src/index.ts、commands.ts |
subagent prompt()(BrowserPromptSource);没有 role 列出它,非 owner 只能经进程内 Host 代码到达,不能经 Gateway 到达 | subagent/src/index.ts |
业务代码不把 principal 当函数参数逐层传递:身份只在 Remote 方法内从 invocation 读一次,向下传的是已构造的 source。Agent inbox 没有可在消息入队时统一补字段的拦截点(followup / steer 接受已构造的消息,SDK、调度等进程内生产者不在 Remote 调用中),因此按生产者落实,并由代码评审标准约束新增的生产者。
| 事实 | 说明 |
|---|---|
kind 不变 | 仍为 'user';现有 kind === 'user' 判断不受影响 |
| owner 消息不变 | owner 调用时不写字段,source 与引入 principal 之前逐字节相同;没有该字段的消息(含全部旧会话)即为 owner |
| 不进入模型请求 | llm 适配器只读 model source(llm-deepseek/src/replay.ts) |
| 会话格式 | 不 bump SESSION_FORMAT_VERSION;按 persistence 类型变更 cookbook 为该持久化类型变更记录 acknowledgement |
消费方用法示例(不在本 Note 范围内实现):工具守卫与 pre-step 复检按 turn 读取该 turn 内用户消息的 source.principal,任一条带非 owner principal 即按该调用方判定;同一 turn 混有 owner 与读者消息时取最严格者。
代码评审标准与文档
评审标准的正家为 Remote and API rules 的「@Remote 标在能力方法上」一节:@Remote({ resources }) 与自动解析必须覆盖方法访问的全部资源;聚合结果逐项经 allows() 过滤;调用方身份只读 invocation.peer.principal,不从参数读取;每条调用路径显式给出 Peer;业务代码不把 principal 当函数参数传递。
| 文档 | 内容 |
|---|---|
docs/subsystems/typert.md、docs/api-gateway.md | PeerScope、RemoteInvocation、InvokeRemoteRequest、显式 Peer、@Remote 的 resources、访问检查顺序 |
| connection、gateway、typert-protocol、typert-registry、api-remotes、remote-access、session-controller、workspace-controller、ui-session、ui-workspace、ui-slots、client-test-runtime 的 README | 以 Peer / browserPeer 取代 operator;remote-access 的 role、binding、policy 契约;access hooks |
不在范围与 Future work
- 仅供 API 的
/connect握手、API Cookie、每条 API 连接一个 Peer 与 WebSocket 生命周期见 Peer 作用域建连握手。 - 浏览器多 Peer(如每标签页一个):所有浏览器连接共享
browserPeer。 - 交互参与属于 Peer;
interactions: 'answer'的非 owner 依然只能收到 RBAC 授权的广播,不能回答 owner 的 waterfall;按 principal 处理审批不在本机制内。 agent-preset/selected等其余转发事件未声明资源,按global判定;读者需要某个会话事件时,#3854 为其声明资源并在 role 中列出event:<name>。- liveshare 接入(声明
share-viewer、每条读者连接一个 Peer、定义 role 并注册 policy、删除origin/prompt/origin/READ_ENDPOINTS):#3854 侧。 session/cancel归属检查、fork 的childSessionId、ToolExecutionInput.turn:不改。- 资源识别的精确方式(例如 Typert 生成器按 branded id 类型在请求对象中定位字段并写入描述符):目前用 lookup 与 Context 声明的
resource、默认字段加resources读取函数,以后可替换资源解析而不改 policy 接口。 - exact Fetch route 对非 owner 一律 403 是临时处理;以后由各路由声明访问规则。
- subagent
prompt的资源声明待定:其请求字段为parentSessionId/childSessionId,目前解析为global。生产者为进程内调用方记录source.principal;没有 role 列出它,非 owner 不能经 Gateway 到达。 - 预定义与聚合 role:出现第二种非 owner principal kind 时,由各领域包以
@Remote({ roles })声明方法所属的通用 role(如session:viewer),各 policy 绑定这些 role;目前 role 由业务包以显式 action 清单定义。
与现有 Note 的关系
- Remote 双工流:部分 supersede。被替换的部分包括:「Peer 不记录身份与权限」「本 Host 只有一个 Peer:operator」「in-process carrier 不给 Peer 即 operator」,以及其替代方案中「Access 机制属于业务语义」的表述;其余(双工流、uplink、
ctx.invocation绑定)保持。两篇保持 active 并互链,该 Note 的现行事实已同步更新。 - 浏览器 token 认证:不受影响。该 Note 否决「按方法区分特权」针对的是 owner 的认证;通过认证的浏览器仍是 owner 且不受方法级检查。
- Typert Remote 方法调用:不受影响;
@Remote与@RemoteScope增加可选的resources读取函数(@RemoteScope的第二个参数可为{ name?, resources? }),只供 Gateway 的访问检查读取,不改变端点命名、调用模式与生成物。 - 生产方拥有的消息来源:不受影响;
principal由构造 source 的生产者写入。
考虑过的替代方案
独立 prompt/origin 事件并按 requestId 关联(#3854 做法)。 每条 prompt 都写 required 事件,普通会话也无法被旧版本读取;关联要求全部 prompt 串行并维护来源 fold,fold 窗口外的消息失去发起者。
principal 与 peer 并列挂在 invocation 上。 同一调用方的身份拆成两处来源,准入点要同时产出并传递两者,任一路径只传其一就会错配。
保留「Peer 不记录身份」的原约定。 身份由业务插件经 peer.ctx 附加时,每个读取方都要知道由谁附加、如何读取,wire 准入路径也无法保证附加发生。
保留默认 Peer 兜底(request.peer ?? …)。 隐式默认让漏传 Peer 的调用静默获得 owner 身份;required 参数把漏传变成编译错误。
新开 source kind。 现有 kind === 'user' 判断都要逐个审视,漏一处就让读者消息在标题、大纲、队列或聊天渲染中消失。
请求体字段携带身份。 任何能构造请求体的调用方都能伪造或覆盖它。
前端 React context 提供资源。 业务组件不得读 React context;标准 hook 由框架按资源绑定,组件只经 props 拿到。
由驱动方自行维护端点白名单并裁剪结果(#3854 做法)。 白名单与结果裁剪都在驱动方内,Gateway 不执行,快照与前端各自推导;role 的 action 清单由通用层执行,列表过滤由方法内的 allows() 完成。
业务包实现 decide() / abstain 的 provider,并合并多个 provider 的答案。 每个业务包手写 action × 资源的判别分支,另写一份资源列表供快照,两者靠人工保持一致;policy 只给出 binding,判定与快照由同一份 binding 算出,每种 principal kind 只有一个 policy,不需要合并规则。
独立的 @RemoteAccess 标记。 默认拒绝已由 role 的显式 action 清单保证,标记只是第二道同意;一个资源都没解析出的方法落到 global,只有显式的全局授权能放行。资源读取函数是方法描述信息,放在 @Remote 上。
把无资源的调用称为 page 或 host。 page 描述前端位置而不是资源;host 容易被读作这台 Host 上全部资源的上级。global 表示不关联任何资源的授权。
资源继承(GCP 式:global 或工作区上的授权覆盖下级资源)。 列表方法被 global 授权放行后需要逐项细判,继承会让任何 global 授权蕴含全部会话;无继承时每个资源的授权都由 binding 显式给出。
role 注册表与预定义聚合 role。 目前唯一的非 owner principal 由一个业务包管理,role 作为该包的普通值即可;聚合 role 会让新包声明的方法自动进入分享读者的可访问范围,推迟到出现第二种非 owner principal。
condition 挂在 role 的 action 上。 role 保持为可复用的纯数据;按业务状态细化的判断属于把 role 授予某个 principal 的 binding。
拒绝原因只由 condition 给出。 没有 binding 时没有 condition 可以给出原因,读者页面无法区分「分享已过期」「分享不允许写入」等情况;explain 只在结论已为拒绝后给出原因,不改变结论。
全局资源 resolver 注册表(领域包按字段名注册识别函数)。 字段识别与方法分离,方法改签名时无人提醒,跨包同名字段也会被误认;资源解析采用方法上的默认约定加 per-method resources,领域包只登记自己种类的默认字段。
按点路径声明资源('request.address.sessionId')。 路径无法由类型推导约束;resources 读取函数的参数类型从被装饰方法推断,拼错即编译失败。
由生成器按 branded id 类型识别资源字段。 需要改 Typert 生成物与描述符格式,SRC 描述符没有类型信息;留作 Future work。
由 api/remotes 集中挂载 remote-access 的 generated contribution。 remote-access Client 依赖 ui-session,ui-session 依赖 api/remotes,集中挂载形成 project reference 环;改为自挂载。
在 Gateway 内实现判定(带规则的 check() 写在 Gateway)。 Gateway 会承载访问控制语义并多出错误码;Gateway 像解析 lookup 一样解析资源,在分派前调用 remoteAccess.decide(),规则与拒绝码归 remote-access。
policy 异步判定。 列表逐项过滤与快照计算需要同步答案;异步状态由业务包预先载入内存。
前端按需查询权限。 请求量随入口数增长,且与规则变化无法同步;快照流由 Host 主动推送,前端同步读取。
访问检查放在 lookup 解析之后。 lookup 可能激活 Agent,未授权调用会先产生副作用。
在 remote-access 中登记资源字段(registerScope)。 访问控制包要识别参数字段,而 lookup 本已知道哪个 wire 字段指向 Session;typert 把资源种类与 lookups、Contexts 并列保存,Gateway 在一处解析调用与事件,remote-access 不读取任何参数字段。
按 Peer 裁剪转发事件的 payload。 逐事件投影会在每个通知里重复一遍列表过滤;一个事件至多携带一个资源,整条放行或拒绝,集合类变化走内联过滤的 follow 类流。
首份快照到达前一律回答 true。 非 owner 页面会短暂显示无权使用的入口;index 携带首份快照,没有快照的页面即无权限。
后果
验证
| 用例 | 行为 | 测试落点 |
|---|---|---|
| wire 试图设置 principal(args 顶层、payload 并列、envelope、请求体内) | 前两种以参数或 payload 校验拒绝;其余被丢弃或当作业务数据;invocation.peer 仍为准入给出的 Peer | Gateway host spec |
| 所有调用路径显式给出 Peer | InvokeRemoteRequest.peer、wireStream.open 的 peer、fetch(request, peer)、bridge 的 peer 为 required;Gateway 中不存在兜底;/api 路由传递 admission.peer | typecheck、Connection host spec |
浏览器连接与 createPeer() | browserPeer 的 principal 为 owner;createPeer() 每次返回独立 Peer,随读取 Connection 的 Context 释放;exact route 对非 owner 返回 403 | Connection host spec |
| 已 dispose 的 Peer | 之后的 unary、stream 与 $events 分派以 gateway/peer-disposed 拒绝,wire payload 畸形时亦然;其名下 stream 结束;运行中的 unary 完成;不影响其他 Peer | Gateway host spec |
| 鉴权顺序 | owner 调用任何方法照常分派且不读取 policy;没有 role 列出的方法对非 owner 以 remote-access/denied(no-allow)拒绝;访问检查早于 lookup,被拒调用不激活会话 | Gateway host spec |
| policy 注册 | 每个非 owner principal kind 一个 policy,重复注册抛错;disposer 撤回后该 kind 以 no-allow 拒绝;随 fiber 撤回 | remote-access host spec |
| 判定 | 每个资源都需要匹配的 binding(AND);role 不含该 action、资源不相等或 condition 拒绝都不放行;拒绝原因按 condition > explain > no-allow 取;explain 不改变结论;condition 在调用时收到 args,在快照与列表过滤时不收;bindings()、condition 或 explain() 抛错以 policy-failed 拒绝,每个 policy 在两次 invalidate() 之间只记录一次日志,事件路径上 owner 流与共享事件源照常;remoteAccess/follow 对任何 principal 放行 | remote-access host spec、Gateway host spec |
| 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 resource 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 resources 声明其他资源;resources 读取函数覆盖该种类,数组为多个资源,undefined 为无;都没有为 [global];抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 resource-invalid 拒绝且不询问 decide();未声明 resource 的 Context 按普通规则解析;资源与 args 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 gateway/input-invalid | Gateway host spec(gateway-access) |
| 资源注册表 | typert.resources 每个种类登记一个默认字段,拒绝重复登记与 global,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 resource;Host Context adapter 保留其 resource | typert-registry spec |
| 转发事件 | 非 owner 的 $events 流只在 event:<name> 于其资源上被授权时整条收到事件;未声明资源的事件按 global 判定;读取函数返回 undefined 的事件不送达非 owner,且不询问 decide();owner 全部收到 | Gateway host spec(gateway-access) |
| 首份快照 | snapshotFor() 返回 owner baseline 或该 principal 的 grant;remote-access 为渲染的每个 index 注入 owner baseline;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec |
| 类型推断 | resources 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 global 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) |
| 列表过滤 | session/list、session/search、session/control、workspace/follow 对非 owner 只返回 allows() 为 true 的项;已下发工作区失去可见性时 workspace/follow 发送 replace 增量帧,访问恢复后该工作区重新出现;owner 结果不变 | session-controller / workspace-controller host spec |
| 访问快照 | 快照中的允许集合与 Host 对同一 principal、action、资源(不带参数)的判定一致;只含端点 action;没有放行 action 的资源不出 grant,放行集合变空时推送 remove;invalidate() 与 policy 注册、撤回后推送增量;被变化结束的等待不残留 abort 监听器;卸载服务即结束所有打开的快照流;owner 只收 { owner: true } | remote-access host spec |
| access hooks | source()、useRemoteAccess、useAccess、useSessionAccess、useWorkspaceAccess 与快照一致;会话资源上的 useAccess 读本会话答案;owner 为 true;没有快照或快照流失败的非 owner 为 false;hook 不发请求 | remote-access client spec、ui-session / ui-workspace client spec |
| keyed hook 双参数 | 第二个参数为字符串时读取 (key, member) source;既有调用形式不变 | ui-renderer client spec |
非 owner 分派 session/prompt | 落盘 user 消息 source.principal 等于该 principal;冷恢复后仍读到同一值 | session-controller host spec |
进程内以非 owner invocation 调用 subagent prompt | 子会话落盘的 user 消息 source.principal 等于该 principal | subagent host spec |
| owner 消息 | source 与本变更之前逐字节相同;现有 keyless recorded-session snapshot 不变 | pnpm run test:snapshot 不需要重录 |
| 持久化记录 | verify-persistence-changes 通过 | doc-sync |
SDK 投影不受影响:TypeScript SDK server 自行构造 { kind: 'user' } 消息(sdk/server/src/server.ts),不经 Gateway,也不投影 source;Python SDK 不读取 user source。
代价与限制
- 改动面横跨 protocol、Connection、Gateway、两个领域控制器、subagent、新包与前端,并带破坏性 API 改名;与 pr-5945 在 Connection 与 Gateway 上必然冲突,按迁移清单处理。
- Gateway 依赖
remoteAccess:自建组合若挂载 Gateway 而不挂载 remote-access,Gateway 停在等待状态。 - Session 日志会保存读者身份;导出或分享会话时随之外传,每个 principal 种类不得携带凭据。
- 浏览器所有连接共享一个 Peer:dispose 无法按标签页区分,直到浏览器多 Peer 落地。
- 默认字段约定按字段名读取:方法新增同名字段即访问对应资源,评审需留意;精确识别列为 Future work。
- 只解析出部分资源的方法会放行越权:未解析的资源不受检查,由方法作者保证
resources完整。 - role 的 action 是字符串,不与已加载端点核对:拼错或改名后的 action 永不匹配,表现为拒绝。
api-session/added携带完整的SessionSummary,因此被允许接收它的非 owner 也会得知该会话的parentSessionId。- 被授权会话的 cwd 与工具路径本就包含 owner 的绝对路径,因此 Gateway 不对非 owner 的
$events流单独隐藏home。 - policy 撤销某个工作区的可见性后,
workspace/follow只在该工作区下一次变化或顺序下一次变化时察觉并向非 owner 发送replace增量帧;仅访问变化不会发送帧。
留给 pr-5945 的迁移清单
| 文件 | 迁移 |
|---|---|
packages/client/connection/src/peer.ts | 取代 operator-peer.ts;OperatorPeer 构造改为 new Peer(owner, { principal }) |
packages/client/connection/src/rpc-host.ts | this.operator 改为 browserPeer,principal 为 { kind: 'owner' } |
packages/client/connection/src/rpc.ts | HostConnectionHandle 合并 admitConnection 与 browserPeer / createPeer |
packages/api/gateway/src/index.ts | $events 打开处的 peer ?? this.operatorPeer() 改为显式 Peer;复用 gateway/peer-disposed |
| operator-connect 的 Note、升级指南与 subsystems 改动 | operator 术语改写为 Peer / browserPeer;API 连接的 Peer 为 { kind: 'owner' } |