From d7bd68ee7aa2280d5bd4f9df2fec954f262d8c28 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:46:52 +0800 Subject: [PATCH] feat(remote-access): add rbac policies and gate non-owner calls and events --- benchmarks/package.json | 1 + .../session-corpus/projection-list.worker.ts | 8 +- .../session-corpus/session-corpus.worker.ts | 8 +- packages/api/README.i18n.yaml | 4 +- packages/api/README.md | 1 + packages/api/README.zh.md | 1 + packages/api/gateway/README.i18n.yaml | 6 +- packages/api/gateway/README.md | 10 +- packages/api/gateway/README.zh.md | 12 +- packages/api/gateway/package.json | 4 +- packages/api/gateway/src/access.ts | 170 ++++ packages/api/gateway/src/index.ts | 87 +- packages/api/gateway/src/types.ts | 12 +- .../gateway/tests/echo-stream.host.spec.ts | 4 + .../gateway/tests/gateway-access.host.spec.ts | 767 ++++++++++++++++++ .../gateway/tests/gateway-stream.host.spec.ts | 64 +- .../api/gateway/tests/gateway.host.spec.ts | 89 +- .../gateway/tests/strict-source.host.spec.ts | 11 +- packages/api/gateway/tsconfig.host.json | 4 + packages/api/remote-access/README.i18n.yaml | 34 + packages/api/remote-access/README.md | 97 +++ packages/api/remote-access/README.zh.md | 97 +++ packages/api/remote-access/package.json | 83 ++ .../api/remote-access/src/client/index.ts | 33 + .../api/remote-access/src/client/mount.ts | 132 +++ .../api/remote-access/src/client/state.ts | 208 +++++ packages/api/remote-access/src/index.ts | 424 ++++++++++ packages/api/remote-access/src/snapshot.ts | 8 + packages/api/remote-access/src/types.ts | 137 ++++ .../tests/client-access.client.spec.ts | 291 +++++++ .../tests/remote-access.host.spec.ts | 610 ++++++++++++++ .../api/remote-access/tsconfig.client.json | 22 + packages/api/remote-access/tsconfig.host.json | 18 + packages/api/remote-access/tsconfig.json | 7 + packages/api/remote-access/tsdown.config.ts | 7 + packages/api/remotes/tests/built-lib.e2e.ts | 4 + packages/api/workspace-files/package.json | 1 + .../workspace-files/tests/binary-rpc.spec.ts | 7 +- packages/boot/plugin-manager/src/index.ts | 2 +- .../boot/plugin-manager/tests/manager.spec.ts | 1 + packages/bundle/base/cordis.patch.yml | 5 + packages/bundle/base/package.json | 1 + .../client-ui-cot-translation/package.json | 1 + .../tests/activation.host.spec.ts | 2 + .../cordis-host-runner/package.json | 1 + .../tests/inspect-registry.spec.ts | 7 +- .../src/assembly/remote-default-responses.ts | 2 + pnpm-lock.yaml | 70 ++ scripts/package-dependency-policy.ts | 1 - .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 3 + tsconfig.client.json | 1 + tsconfig.host.json | 1 + vitest.config.ts | 3 + 54 files changed, 3519 insertions(+), 66 deletions(-) create mode 100644 packages/api/gateway/src/access.ts create mode 100644 packages/api/gateway/tests/gateway-access.host.spec.ts create mode 100644 packages/api/remote-access/README.i18n.yaml create mode 100644 packages/api/remote-access/README.md create mode 100644 packages/api/remote-access/README.zh.md create mode 100644 packages/api/remote-access/package.json create mode 100644 packages/api/remote-access/src/client/index.ts create mode 100644 packages/api/remote-access/src/client/mount.ts create mode 100644 packages/api/remote-access/src/client/state.ts create mode 100644 packages/api/remote-access/src/index.ts create mode 100644 packages/api/remote-access/src/snapshot.ts create mode 100644 packages/api/remote-access/src/types.ts create mode 100644 packages/api/remote-access/tests/client-access.client.spec.ts create mode 100644 packages/api/remote-access/tests/remote-access.host.spec.ts create mode 100644 packages/api/remote-access/tsconfig.client.json create mode 100644 packages/api/remote-access/tsconfig.host.json create mode 100644 packages/api/remote-access/tsconfig.json create mode 100644 packages/api/remote-access/tsdown.config.ts diff --git a/benchmarks/package.json b/benchmarks/package.json index 4a2be7ed1d..d36d79bfdb 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -15,6 +15,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", diff --git a/benchmarks/session-corpus/projection-list.worker.ts b/benchmarks/session-corpus/projection-list.worker.ts index 106c520be2..5e95356c12 100644 --- a/benchmarks/session-corpus/projection-list.worker.ts +++ b/benchmarks/session-corpus/projection-list.worker.ts @@ -7,6 +7,7 @@ import { monitorEventLoopDelay, performance } from 'node:perf_hooks' import { scheduler, setTimeout as delay } from 'node:timers/promises' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '@deepseek-ai/dsh-api-session-controller' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, type SessionHeader } from '@deepseek-ai/dsh-session' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -196,10 +197,15 @@ async function mountHost(ctx: Context, root: string, workload: typeof WORKLOADS[ await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:', openAt: 'never' }) // No Agents, uploads, model calls, or workspace actions occur on this list path. const dispose = (): void => {} - ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose } } as never) + ctx.provide('typert', { + lookups: { configure: () => dispose }, + contexts: { configureHost: () => dispose }, + resources: { register: () => dispose }, + } as never) ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'synthetic', model: 'generated-model' }) } as never) ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) + new RemoteAccessService(ctx) return new SessionController(ctx, { nativeOpen: false, ...(listWorkSliceMs === undefined ? {} : { listWorkSliceMs }) }, { canOpenPath: () => false }) } diff --git a/benchmarks/session-corpus/session-corpus.worker.ts b/benchmarks/session-corpus/session-corpus.worker.ts index c533852fa1..2d1acd56ba 100644 --- a/benchmarks/session-corpus/session-corpus.worker.ts +++ b/benchmarks/session-corpus/session-corpus.worker.ts @@ -8,6 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '@deepseek-ai/dsh-api-session-controller' import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections' import { Session, SessionLogOffset } from '@deepseek-ai/dsh-session' @@ -148,10 +149,15 @@ async function mountHost(root: string): Promise<{ readonly ctx: Context; readonl await ctx.plugin(AgentLoop, { agents: [] }) // Transport, upload, model-default, and Workspace services are outside every measured path. const dispose = (): void => {} - ctx.provide('typert', { lookups: { configure: () => dispose }, contexts: { configureHost: () => dispose } } as never) + ctx.provide('typert', { + lookups: { configure: () => dispose }, + contexts: { configureHost: () => dispose }, + resources: { register: () => dispose }, + } as never) ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) ctx.provide('agentDefaultModel', { currentSelection: () => BENCH_MODEL } as never) ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) + new RemoteAccessService(ctx) const controller = new SessionController(ctx, { nativeOpen: false }, { canOpenPath: () => false }) return { ctx, controller } } diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index 7c3e86cc1c..bbe9d4ab52 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /api-remote-api-layers/packages: - en: 9bf51a5550fd4853 - zh: 4d13dd77d86f7f97 + en: 37388d921f2f47cf + zh: 24eb674c14dc2c6d /api-remote-api-layers/related-documentation: en: 507116ae09161df1 zh: f5e538ac5fdaf454 diff --git a/packages/api/README.md b/packages/api/README.md index 691a56856d..9f490b8134 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -28,6 +28,7 @@ The packages below provide the Remote layer; the package READMEs own the exhaust |---|---|---| | [`remotes/`](remotes/README.md) | Chooses which Host capabilities and events the Client can consume. | — | | [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams with their Client uplinks, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` | +| [`remote-access/`](remote-access/README.md) | Decides which Remote methods non-owner Peers may call, filters list results, and streams each Peer's access snapshot. | `ctx.remoteAccess` / `ctx.remote.remoteAccess` | | [`job-controller/`](job-controller/README.md) | Streams one background job's observation record to the Client. | `ctx.jobController` / `ctx.remote.job` | | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 9303fbaae7..3d7ae60113 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -28,6 +28,7 @@ kind: "package-group" |---|---|---| | [`remotes/`](remotes/README.zh.md) | 决定 Client 可以消费哪些 Host 能力与事件。 | — | | [`gateway/`](gateway/README.zh.md) | 承载类型化一元调用、带 Client 上行的多路复用流,以及转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | +| [`remote-access/`](remote-access/README.zh.md) | 决定非 owner Peer 可以调用哪些 Remote 方法,过滤列表结果,并为每个 Peer 推送其访问快照。 | `ctx.remoteAccess` / `ctx.remote.remoteAccess` | | [`job-controller/`](job-controller/README.zh.md) | 把一个后台任务的观测 record 流式送到 Client。 | `ctx.jobController` / `ctx.remote.job` | | [`session-controller/`](session-controller/README.zh.md) | 拥有会话命令、历史记录流、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 3c5a28123f..96eee8a741 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/README.i18n.yaml @@ -15,11 +15,11 @@ en: d9859219cdb0f367 zh: 2a3cfa8cb786e327 /deepseek-ai-dsh-api-gateway/host-service-typertgatewayservice-ctx-key-typertgateway: - en: 2aabd26516a45771 - zh: 5907f472a4d536df + en: 5427b6ade7a2e324 + zh: 7ca42589a6572c5d /deepseek-ai-dsh-api-gateway/client-service-clientremote-ctx-key-remote: en: c106f0ca3e547f84 - zh: 833e30d7151c41c7 + zh: 8eadd3bffde9e49d /deepseek-ai-dsh-api-gateway/model-experience: en: 6cb309d3378f035d zh: 998042a02ee32850 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index c212ec01ff..97aeec7da3 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -30,19 +30,23 @@ Strict mode reads generated invocation descriptors from `ctx.typert.local`. Look The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` is a `RemoteError` subclass whose `gateway/*` codes name the failures owned by dispatch, binding, providers, lookup, Context, arguments, codecs, and the uplink: `gateway/uplink-overflow` for an uplink whose buffered frames exceed `streamInboxBytes`, `gateway/protocol` for an uplink item after `end`, and the codec code `gateway/input-invalid` for a rejected uplink item. A resolver that refuses on policy grounds — a cold-resume failure or an ownership fence — throws its own `RemoteError`, and the code it chose reaches the caller unchanged. +Every call names the Peer it speaks for: `InvokeRemoteRequest.peer` and the `peer` argument of `wireStream.open()` are required, and the Gateway never supplies a default Peer. Dispatch from a disposed Peer fails with `gateway/peer-disposed`, for unary calls, streams, `$events`, and `$events/result` alike, and wire paths check it before they read the payload. The Host service injects `remoteAccess` and does not start without [`dsh-api-remote-access`](../remote-access/README.md). For a call whose Peer principal is not the owner, after the endpoint and argument-field checks and before lookup and Context resolution, the Gateway resolves the resources the call touches and asks `ctx.remoteAccess.decide({ principal, action, resources, args })` with the endpoint as the action, or refuses with reason `resource-invalid` without asking when the resources are invalid; a refused call throws `remote-access/denied` with details `{ action, reason }`, so a refused call fails before it activates any Session. An owner call is dispatched without resource resolution or a decision. + +Resources are read from the undecoded wire arguments. For each resource kind, a reader in the method's `@Remote({ resources })` or `@RemoteScope(key, { resources })` options wins; it is called with the method's positional wire arguments, where lookup positions hold wire identities and the cancellation position holds the call's signal, and is read from the class metadata of the root Service instance. Otherwise every lookup parameter whose provider declares that kind through `resource` contributes its wire identity, an omitted optional one contributing none, and a Context method whose Host Context adapter declares that kind contributes its Context identity, read from the arguments before the Context is resolved. Otherwise the kind's default field registered in `typert.resources` is read from a top-level wire argument of that name or, failing that, from the first object argument. A reader that throws or returns an empty list, a default field holding an array, or an identity, including a Context identity, that is not a non-empty string makes the resolution `invalid`; a call that names no resource touches the `global` resource alone, and a call that names resources touches only those. A forwarded event carries every resource its entry declares, so there a reader returning `undefined` also makes the resolution `invalid`, while an entry without readers touches `global`. + A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then returns a cancellation-aware iterable over the business items. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates and keeps it connected while idle. Connection owns the retry schedule; before each retry it asks the mux to cancel any candidate or active socket and make exactly one fresh physical attempt. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (two seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. A Pong, any received frame, or a downlink write that waited in the full local send buffer and then completed counts as peer activity; a socket with no activity across two Ping intervals is terminated, so a congested link that delays the Pong behind data stays connected. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. When the launcher supplies `ctx.appReady`, Gateway registers the WebSocket upgrade route only after successful application startup. Earlier connection attempts remain carrier failures handled by Connection's retry policy, so a restarting Host cannot accept streams while its controllers are still initializing. Unloading cancels a pending readiness subscription and closes any registered carrier. Embedded Hosts without `appReady` register immediately and own their startup ordering; in-process invocation is unchanged. -Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream, a top-level `undefined` item as an `item` frame without `value`, and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through unchecked. When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. `item`, `end`, and `cancel` frames for a stream id the Host has already finished are dropped; only a duplicate `open` closes the socket. Gateway-owned streams such as `$events` return their uplink as soon as they open, so their `item` frames are dropped instead of buffered. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for the operator Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the operator directly. +Every stream also carries a Client-to-Host uplink on the same logical stream. A method declares the uplink item type as the second type argument of its return type, `RemoteStream` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `In` defaults to `never`, and a method that returns `Iterable`, `AsyncIterable`, or `RemoteStream` declares that it reads none. The running method reads its call as `this.ctx.invocation`: the Gateway derives the receiver from a Context carrying that `RemoteInvocation` — `request`, `service`, `peer`, `signal`, and `uplink()` — so nothing enters the parameter list, and `ctx.invocation` is `undefined` on a Context no Remote call derived. `uplink()` is available once per call. With a descriptor codec it decodes every item before delivery; without one — an SRC method, or a method whose `In` is `never` — it delivers JSON-safe `unknown` values. The Client sends each item as an `item` frame on the open logical stream, a top-level `undefined` item as an `item` frame without `value`, and its half-close as an `end` frame; the method observes the half-close as the end of the `uplink()` iteration and cancellation through `signal`. A rejected item fails the whole stream with `gateway/input-invalid` (field `uplink`). Uplink items are validated one by one at the Host because they arrive from the browser; downlink items are values the Host method produced and pass through unchecked. When the method finishes its downlink the Gateway returns the uplink iterator and drops unread items; items sent to a method that never takes its uplink wait in the inbox until then. Each logical stream buffers at most `streamInboxBytes` (262144 by default) of uplink frame bytes; overflow fails the stream with `gateway/uplink-overflow`, and an item after `end` fails it with `gateway/protocol`, in both cases without closing the socket. `item`, `end`, and `cancel` frames for a stream id the Host has already finished are dropped; only a duplicate `open` closes the socket. Gateway-owned streams such as `$events` return their uplink as soon as they open, so their `item` frames are dropped instead of buffered. A unary method may also call `uplink()`; its items are readable only while the method runs. Every stream speaks for an explicitly named Peer: a WebSocket's streams for the Peer Connection admitted at upgrade, whose scope disposal closes the socket, and an in-process carrier's for the Peer its caller passes. A stream method's `signal` also aborts when that Peer is disposed, so the stream ends with `gateway/cancelled`, and a `$events` stream ends with its Peer; a unary call already running is not cancelled. Stream cancellation and stopping keep read-local state, not a history of delivered items, on the Host downlink, Host uplink, and Client uplink pump. Completing the downlink also wakes pending uplink reads. -Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. +Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. A `$events` stream whose Peer principal is not the owner never receives a scoped waterfall it could answer; it receives a broadcast emit frame, whole and untrimmed, only when `ctx.remoteAccess.decide({ principal, action: 'event:', resources })` allows it on the resources the frame's readers name, or on `global` for a frame without readers; the resources are resolved once per frame, a frame whose resources are invalid reaches no non-owner stream without a decision, and a denied frame is dropped for that stream alone. An `$events/result` reply from such a Peer fails with `gateway/invocation-unavailable`. -`hasLiveClient()` checks the existing `$events` records for an uncancelled stream. A cancelled stream does not count even while its iterator is still awaiting cleanup; a bare WebSocket does not count. This synchronous observation does not guarantee delivery or Client provider readiness. +`hasLiveClient()` checks the existing `$events` records for an uncancelled stream whose Peer principal is the owner, the only kind that can answer a forwarded waterfall. A cancelled stream does not count even while its iterator is still awaiting cleanup; a bare WebSocket does not count. This synchronous observation does not guarantee delivery or Client provider readiness. ## Client service: `ClientRemote` (ctx key: `remote`) diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index 1c3a9a2048..68e516dba6 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -30,19 +30,23 @@ kind: "package-reference" Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 是 `RemoteError` 的子类,其 `gateway/*` 码命名了分发、绑定、提供方、查找、Context、参数、编解码器以及上行各自负责的故障:缓冲帧超过 `streamInboxBytes` 的上行报 `gateway/uplink-overflow`,`end` 之后的上行项报 `gateway/protocol`,被 codec 拒绝的上行项报编解码码 `gateway/input-invalid`。因策略而拒绝的解析器——冷恢复失败或 ownership fence——抛出自己的 `RemoteError`,它选定的码原样到达调用方。 +每次调用都指明它所代表的 Peer:`InvokeRemoteRequest.peer` 与 `wireStream.open()` 的 `peer` 参数都是必填项,Gateway 从不提供默认 Peer。来自已释放 Peer 的分发以 `gateway/peer-disposed` 失败,一元调用、流、`$events` 与 `$events/result` 一律如此,wire 路径在读取 payload 之前检查。Host 服务注入 `remoteAccess`,没有 [`dsh-api-remote-access`](../remote-access/README.zh.md) 就不启动。对 Peer 的 principal 不是 owner 的调用,Gateway 在 endpoint 与参数字段检查之后、lookup 与 Context 解析之前解析调用访问的资源,并以端点为 action 询问 `ctx.remoteAccess.decide({ principal, action, resources, args })`,资源非法时不询问而以原因 `resource-invalid` 拒绝;被拒的调用抛出 details 为 `{ action, reason }` 的 `remote-access/denied`,因此被拒绝的调用在激活任何 Session 之前就失败。owner 的调用不解析资源、不询问判定即分派。 + +资源从未解码的 wire 参数中读取。对每个资源种类,方法 `@Remote({ resources })` 或 `@RemoteScope(key, { resources })` options 中的读取函数优先;它以方法的位置 wire 参数调用(lookup 位为 wire 身份,取消参数位为调用的 signal),从根 Service 实例的类元数据读取。否则 provider 经 `resource` 声明了该种类的每个 lookup 参数贡献其 wire 身份,省略的可选 lookup 参数不贡献;Host Context adapter 声明了该种类的 Context 方法贡献其 Context 身份,在解析 Context 之前直接从参数读取。再否则从同名顶层 wire 参数读取该种类在 `typert.resources` 中登记的默认字段,没有同名参数时读第一个对象参数的同名字段。读取函数抛错或返回空列表、默认字段读到数组、或某个身份(含 Context 身份)不是非空字符串时,解析结果为 `invalid`;不指明任何资源的调用只访问 `global` 资源,指明了资源的调用只访问这些资源。转发事件携带其条目声明的每个资源,因此在事件路径上读取函数返回 `undefined` 同样使解析结果为 `invalid`,而没有读取函数的条目访问 `global`。 + 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再返回可取消的业务项 iterable。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,并让它在空闲时保持连接。Connection 拥有重试调度;每次 retry 前,它要求 mux 取消候选或活动 socket,并且只做一次全新的物理连接尝试。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 2 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote 流帧。Pong、收到的任意帧,或曾在已满的本机发送缓冲区中等待后完成的下行写入,都视为对端活动;连续两个 Ping 间隔没有活动的 socket 会被终止,因此 Pong 被数据阻塞在拥塞链路上时连接仍会保持。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 启动器提供 `ctx.appReady` 时,Gateway 仅在应用成功启动后注册 WebSocket 升级路由。此前的连接尝试仍属于载体故障,由 Connection 的重试策略处理,因此重启中的 Host 不会在控制器仍在初始化时接受流。卸载会取消尚未触发的就绪订阅,并关闭已注册的载体。不提供 `appReady` 的嵌入式 Host 会立即注册,并自行负责启动顺序;进程内调用不变。 -每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送(顶层 `undefined` 项是不带 `value` 的 `item` 帧),以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,不经校验原样透传。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。发给 Host 已结束的流 id 的 `item`、`end` 与 `cancel` 帧被丢弃;只有重复的 `open` 会关闭 socket。`$events` 这类 Gateway 自有的流在打开时立即 return 其 uplink,因此它们的 `item` 帧被丢弃而不缓冲。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表操作者 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流直接代表操作者。 +每条流还在同一条逻辑流上携带从 Client 到 Host 的上行。方法把上行项类型声明为返回类型的第二个类型参数——来自 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `RemoteStream`;`In` 缺省为 `never`,返回 `Iterable`、`AsyncIterable` 或 `RemoteStream` 的方法即声明自己不读上行。运行中的方法通过 `this.ctx.invocation` 读取本次调用:Gateway 从携带该 `RemoteInvocation`(`request`、`service`、`peer`、`signal` 与 `uplink()`)的 Context 派生接收者,因此没有任何东西进入参数列表,而非 Remote 调用派生的 Context 上 `ctx.invocation` 为 `undefined`。`uplink()` 每次调用只能取一次。描述符带 codec 时它在交付前逐项解码;不带时——SRC 方法,或 `In` 为 `never` 的方法——它交付经 JSON 安全校验的 `unknown` 值。Client 在已打开的逻辑流上把每一项作为 `item` 帧发送(顶层 `undefined` 项是不带 `value` 的 `item` 帧),以 `end` 帧表示半关闭;方法通过 `uplink()` 迭代结束感知半关闭,通过 `signal` 感知取消。被拒绝的项使整条流以 `gateway/input-invalid`(字段 `uplink`)失败。上行项在 Host 逐项校验,因为它们来自浏览器;下行项是 Host 方法产出的值,不经校验原样透传。方法结束下行时 Gateway 调用 uplink 迭代器的 return 并丢弃未读的项;发给从未取用 uplink 的方法的项在 inbox 里等到那时为止。每条逻辑流最多缓冲 `streamInboxBytes`(默认 262144)字节的上行帧;超限使流以 `gateway/uplink-overflow` 失败,`end` 之后的项使流以 `gateway/protocol` 失败,两种情况都不关闭 socket。发给 Host 已结束的流 id 的 `item`、`end` 与 `cancel` 帧被丢弃;只有重复的 `open` 会关闭 socket。`$events` 这类 Gateway 自有的流在打开时立即 return 其 uplink,因此它们的 `item` 帧被丢弃而不缓冲。一元方法也可以调用 `uplink()`;其项只在方法运行期间可读。每条流都代表一个显式指明的 Peer:WebSocket 上的流代表 Connection 在升级时接纳的 Peer,该 scope 释放时 socket 关闭;进程内载体的流代表其调用方传入的 Peer。该 Peer 释放时,流方法的 `signal` 也随之中止,流以 `gateway/cancelled` 结束,`$events` 流也随其 Peer 结束;已在运行的一元调用不会被取消。 Host 下行、Host 上行和 Client 上行泵的取消与停止状态归属单次读取,不保留已交付项的历史。下行结束也会唤醒尚未完成的上行读取。 -Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。 +Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的流。事件名单、参数校验、每个 Client 的队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。Peer 的 principal 不是 owner 的 `$events` 流从不收到它可以作答的 scoped waterfall(瀑布式事件);只有当 `ctx.remoteAccess.decide({ principal, action: 'event:', resources })` 在该帧读取函数指明的资源上(帧没有读取函数时为 `global`)允许时,才整条、不裁剪地收到广播 emit 帧;每帧只解析一次资源,资源非法的帧不经判定即不送达任何非 owner 流,被拒的帧只对该流丢弃。这类 Peer 的 `$events/result` 回复以 `gateway/invocation-unavailable` 失败。 -`hasLiveClient()` 检查已有 `$events` 记录中是否有未取消的流。已取消的流即使尚未完成 iterator 清理也不计入,单独的 WebSocket 也不计入。这一同步观察不保证后续投递成功或 Client Provider 已就绪。 +`hasLiveClient()` 检查已有 `$events` 记录中是否有 Peer principal 为 owner 的未取消流,只有这类流能回答转发的 waterfall。已取消的流即使尚未完成 iterator 清理也不计入,单独的 WebSocket 也不计入。这一同步观察不保证后续投递成功或 Client Provider 已就绪。 ## Client 服务:`ClientRemote`(ctx key:`remote`) @@ -59,7 +63,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source `ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。一切终态失败离开本面时都是 `RemoteError`,包括重试耗尽和在 opening value 之前就结束的代次,因此流消费方与一元调用方用同一种方式判别。`RemoteStreamCarrierError` 命名的是可重试的物理丢失,它只作为 `carrierFailed` 回调参数到达领域,绝不作为终态结果。`RemoteSnapshotStream` 在此之上规定每代由一个初始快照和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。领域还可以携带无 cursor 的通知:通知绝不推进或修复持久 cursor,在缺口修复期间收到的通知只会在 replacement page 提交后发布。若更新代次取代该修复,旧代次 held notification 会与其 page 一同丢弃。对任一种流执行 dispose(资源释放)时,系统会取消该流的请求,并在活动 iterator 完全停止后完成资源释放。 -`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属调用方 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,无论当前是否存在 `$on` listener。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote 流故障、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 按持续且间隔封顶的带抖动指数退避重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall(瀑布式事件)允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 +`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属调用方 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,无论当前是否存在 `$on` listener。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote 流故障、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 按持续且间隔封顶的带抖动指数退避重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 Client waterfall 的 Context 解析保持同步。解析器可以返回借用的 Context 或 `TypertOwnedValue`;Gateway 仅在处理器使用和回复结算均结束后释放 owned value。Context 解析失败保留既有的记录错误并委托语义,处理器失败产生拒绝回复。取消会抑制迟到回复,但不会释放处理器仍在使用的 Context。每个 handler 都必须响应 `request.signal` 并在取消后结束;插件销毁与 Connection generation 替换会等待未结束的 handler 结算。Session Context 的获取本身不执行历史 I/O。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index d4469cf8cd..af26594447 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -64,10 +64,10 @@ "@deepseek-ai/dsh-typert-protocol": "workspace:*" }, "peerDependencies": { - "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-client-connection": "workspace:*" + "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-cmdline": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", diff --git a/packages/api/gateway/src/access.ts b/packages/api/gateway/src/access.ts new file mode 100644 index 0000000000..5c290e9f70 --- /dev/null +++ b/packages/api/gateway/src/access.ts @@ -0,0 +1,170 @@ +/** + * Resource resolution for non-owner access decisions: the resources one call + * or one forwarded event touches, read from undecoded wire values before any + * lookup or Context resolves them. + * @module @deepseek-ai/dsh-api-gateway/src/access + */ + +import type { + InvocationDescriptor, + RemoteAccessResource, + TypertRegistryContract, + TypertResourceReader, +} from '@deepseek-ai/dsh-typert-protocol' + +const GLOBAL: readonly RemoteAccessResource[] = Object.freeze([Object.freeze({ kind: 'global' })]) + +/** Resource ids one source produced for a kind, or `invalid` for a malformed value. */ +type ResourceIds = readonly unknown[] | 'invalid' + +/** + * Resolve the resources one call touches. For each resource kind, a + * `resources` reader of the method wins; otherwise every lookup parameter + * whose provider declares the kind contributes its wire resource id, and so + * does the Context identity of a Context method whose Host adapter declares + * the kind; otherwise the kind's default field registered in + * `typert.resources` is read from a top-level wire argument or, failing + * that, from the first object argument. + * @param typert - registry holding default fields, lookup providers, and Host Context adapters. + * @param readers - the method's `resources` readers, or `undefined` when it declares none. + * @param descriptor - receiver, parameter order, lookups, and cancellation slot of the method. + * @param args - named wire arguments, already matched to the descriptor's field set. + * @param signal - value placed in the cancellation slot of the positional arguments. + * @returns the touched resources, the global resource alone when none; + * `undefined` when the resources are invalid: a reader throws or returns + * an empty list, a default field holds an array, or a resource id is not a + * non-empty string. + */ +export function callResources( + typert: Pick, + readers: Readonly> | undefined, + descriptor: Pick, + args: Readonly>, + signal: AbortSignal, +): readonly RemoteAccessResource[] | undefined { + // Each later source replaces an earlier one for its kind and keeps the kind's position. + const sources = new Map ResourceIds>() + for (const { kind, field } of typert.resources.entries()) { + sources.set(kind, () => { + // A default field names exactly one resource id; only a reader may return several. + const id = defaultFieldId(descriptor, args, field) + return Array.isArray(id) ? 'invalid' : id === undefined ? [] : [id] + }) + } + for (const [kind, ids] of lookupResources(typert, descriptor, args)) { + sources.set(kind, () => ids) + } + let positional: readonly unknown[] | undefined + for (const [kind, reader] of Object.entries(readers ?? {})) { + sources.set(kind, () => { + positional ??= [ + ...descriptor.parameters.map(parameter => args[parameter.wire]), + ...descriptor.cancellation === undefined ? [] : [signal], + ] + return read(reader, positional, []) + }) + } + const resources: RemoteAccessResource[] = [] + for (const [kind, ids] of sources) { + if (!collect(resources, kind, ids())) return undefined + } + return resources.length === 0 ? GLOBAL : resources +} + +/** + * Resolve the resources one forwarded event notification touches. An event + * carries every resource it declares, so a reader returning `undefined` + * makes the notification invalid instead of a global one. + * @param readers - the event entry's declared readers, or `undefined` when it declares none. + * @param args - the event's validated JSON arguments, passed to each reader as its positional arguments. + * @returns the global resource alone without readers, including an empty + * reader set; otherwise the touched resources; `undefined` when the + * resources are invalid: a resource id is missing or malformed. + */ +export function eventResources( + readers: Readonly> | undefined, + args: readonly unknown[], +): readonly RemoteAccessResource[] | undefined { + const declared = Object.entries(readers ?? {}) + if (declared.length === 0) return GLOBAL + const resources: RemoteAccessResource[] = [] + for (const [kind, reader] of declared) { + if (!collect(resources, kind, read(reader, args, 'invalid'))) return undefined + } + return resources +} + +/** + * The resource ids lookup parameters and the Context identity name, per + * resource kind: the Context identity first, then lookup parameters in order. + * An optional lookup parameter the call omits names no resource id; the + * Context identity is required. + */ +function lookupResources( + typert: Pick, + descriptor: Pick, + args: Readonly>, +): ReadonlyMap { + const ids = new Map() + const add = (kind: string, id: unknown): void => { + const list = ids.get(kind) + if (list === undefined) ids.set(kind, [id]) + else list.push(id) + } + const receiver = descriptor.invocation + if (receiver.kind === 'context') { + const kind = typert.contexts.getHost(receiver.context)?.resource + // oxlint-disable-next-line typescript/no-unnecessary-condition -- domain packages merge resource kinds into TypertResourceMap. + if (kind !== undefined) add(kind, args[receiver.wire]) + } + for (const parameter of descriptor.parameters) { + if (parameter.lookup === undefined) continue + const kind = typert.lookups.get(parameter.lookup)?.resource + const id = args[parameter.wire] + // oxlint-disable-next-line typescript/no-unnecessary-condition -- domain packages merge resource kinds into TypertResourceMap. + if (kind !== undefined && id !== undefined) add(kind, id) + } + return ids +} + +/** Run one reader with spread positional arguments: `undefined` yields `absent`, a list names several and must not be empty. */ +function read(reader: TypertResourceReader, args: readonly unknown[], absent: ResourceIds): ResourceIds { + let value: unknown + try { + value = reader(...args) + } catch { + // A reader over unvalidated wire JSON fails on a malformed call; the call is refused. + return 'invalid' + } + if (value === undefined) return absent + if (!Array.isArray(value)) return [value] + return value.length === 0 ? 'invalid' : value +} + +/** Append one kind's resource ids; false when any is malformed. */ +function collect(resources: RemoteAccessResource[], kind: string, ids: ResourceIds): boolean { + if (ids === 'invalid') return false + for (const id of ids) { + if (typeof id !== 'string' || id.length === 0) return false + // Kinds come from registrations, lookups, Context adapters, and reader keys, all typed by the merged resource map. + resources.push({ kind, id } as RemoteAccessResource) + } + return true +} + +/** The default field: a top-level wire field, else that field of the first object argument. */ +function defaultFieldId( + descriptor: Pick, + args: Readonly>, + field: string, +): unknown { + if (descriptor.parameters.some(parameter => parameter.wire === field)) return args[field] + for (const parameter of descriptor.parameters) { + const value = args[parameter.wire] + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue + if (!Object.hasOwn(value, field)) return undefined + const id: unknown = Reflect.get(value, field) + return id + } + return undefined +} diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 42148cd2d3..6259358b21 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -11,6 +11,8 @@ import type { ConnectionRpcAttachment, ConnectionRpcHandler, } from '@deepseek-ai/dsh-client-connection' +import type { RemoteAccessDecision } from '@deepseek-ai/dsh-api-remote-access' +import { callResources, eventResources } from './access.ts' import { Deque } from '@deepseek-ai/dsh-deque' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -25,7 +27,10 @@ import { type InvocationDescriptor, type InvocationParameterDescriptor, type PeerScope, + type Principal, + type RemoteAccessResource, type RemoteInvocation, + type RemoteMethodMarker, type TypertCodec, type TypertGatewayBinding, } from '@deepseek-ai/dsh-typert-protocol' @@ -120,6 +125,10 @@ interface RemoteEventClient { readonly queue: RemoteEventQueue readonly signal: AbortSignal readonly deliveries: Map + /** The stream's Peer principal: only the owner answers scoped waterfalls and receives every broadcast. */ + readonly principal: Principal + /** Only owner streams receive scoped waterfalls they could answer; other Peers receive admitted broadcasts only. */ + readonly answers: boolean } interface PendingRemoteEvent { @@ -197,7 +206,7 @@ export class TypertGatewayError extends RemoteError { * @typert service typertGateway */ export class TypertGatewayService extends Service implements TypertGateway { - static inject = ['typert'] + static inject = ['typert', 'remoteAccess'] static Config: z = z.object({ websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS) .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS), @@ -277,12 +286,12 @@ export class TypertGatewayService extends Service implements TypertGateway { } /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean { for (const client of this.remoteEventClients.values()) { - if (!client.signal.aborted) return true + if (client.answers && !client.signal.aborted) return true } return false } @@ -330,6 +339,18 @@ export class TypertGatewayService extends Service implements TypertGateway { return this.srcClaims.has(endpoint) } + /** + * The `resources` readers of a method, read from the root Service's class + * metadata so a Context method's receiver is not resolved before access is + * decided; `undefined` when the Service is absent or the method declares none. + */ + private resourceReaders(descriptor: InvocationDescriptor): RemoteMethodMarker['resources'] { + const receiver: unknown = this.ctx.get(descriptor.service) + if (!isObject(receiver)) return undefined + const member = descriptor.implementation ?? descriptor.method + return remoteMethods(originalOf(receiver)).find(marker => marker.method === member)?.resources + } + private collectSrcClaims(): ReadonlySet { const claims = new Set() for (const [serviceKey, definition] of Object.entries(this.ctx.reflect.props)) { @@ -429,6 +450,10 @@ export class TypertGatewayService extends Service implements TypertGateway { if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) { try { assertPeerLive(peer, endpoint) + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (peer.principal.kind !== 'owner') { + throw new TypertGatewayError('gateway/invocation-unavailable', endpoint, 'only owner Peers answer forwarded Remote events') + } const result = parseRemoteEventResultPayload(payload) const client = this.remoteEventClients.get(result.clientId) if (client === undefined) { @@ -498,9 +523,14 @@ export class TypertGatewayService extends Service implements TypertGateway { queue: new RemoteEventQueue(), signal: lifetime, deliveries: new Map(), + principal: peer.principal, + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + answers: peer.principal.kind === 'owner', } this.remoteEventClients.set(clientId, client) - for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client) + if (client.answers) { + for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client) + } try { yield { ...REMOTE_EVENT_STREAM_READY, clientId, host: registration.host } yield* client.queue.iterate(lifetime) @@ -533,7 +563,21 @@ export class TypertGatewayService extends Service implements TypertGateway { event: frame.event, args: frame.args, } - for (const client of this.remoteEventClients.values()) client.queue.push(wire) + let resolved = false + let resources: readonly RemoteAccessResource[] | undefined + for (const client of this.remoteEventClients.values()) { + if (!client.answers) { + // A non-owner receives the whole notification or nothing: its resources decide, payloads are never trimmed, + // and a notification with invalid resources reaches no non-owner. + if (!resolved) { + resources = eventResources(frame.resources, frame.args) + resolved = true + } + if (resources === undefined) continue + if (this.ctx.remoteAccess.decide({ principal: client.principal, action: `event:${frame.event}`, resources }).kind === 'deny') continue + } + client.queue.push(wire) + } } private startRemoteEvent(source: TypertRemoteEventInvocation): void { @@ -589,7 +633,11 @@ export class TypertGatewayService extends Service implements TypertGateway { this.pendingRemoteEvents.set(id, pending) for (const signal of signals) signal.addEventListener('abort', abort, { once: true }) if ([...signals].some(signal => signal.aborted)) abort() - else for (const client of this.remoteEventClients.values()) this.deliverRemoteEvent(pending, client) + else { + for (const client of this.remoteEventClients.values()) { + if (client.answers) this.deliverRemoteEvent(pending, client) + } + } } catch (error) { source.reject(error) } @@ -694,6 +742,31 @@ export class TypertGatewayService extends Service implements TypertGateway { assertPeerLive(request.peer, endpoint) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) assertExactArguments(request.args, descriptor, endpoint) + // Access precedes lookup resolution, so a refused call activates nothing. + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (request.peer.principal.kind !== 'owner') { + const resources = callResources( + this.ctx.typert, + this.resourceReaders(descriptor), + descriptor, + request.args, + request.signal ?? NEVER_ABORTED_SIGNAL, + ) + const decision: RemoteAccessDecision = resources === undefined + ? { kind: 'deny', reason: 'resource-invalid' } + : this.ctx.remoteAccess.decide({ + principal: request.peer.principal, + action: `${descriptor.namespace}/${descriptor.method}`, + resources, + args: request.args, + }) + if (decision.kind === 'deny') { + throw new RemoteError('remote-access/denied', `remote access: ${endpoint}: access denied (${decision.reason})`, { + action: endpoint, + reason: decision.reason, + }) + } + } const receiverContext = await this.resolveReceiverContext(descriptor, request.args, endpoint) const receiver: unknown = receiverContext.get(descriptor.service) if (!isObject(receiver)) { diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index 27a0989ea2..d83f120ab3 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -4,7 +4,7 @@ */ import type { Context } from '@deepseek-ai/cordis' -import type { PeerScope, RemoteUplinkWindow } from '@deepseek-ai/dsh-typert-protocol' +import type { PeerScope, RemoteUplinkWindow, TypertResourceReader } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteEventHostInfo } from './stream-protocol.ts' /** One Remote method request after a carrier has decoded its envelope. */ @@ -34,6 +34,12 @@ export interface TypertRemoteEventFrame { readonly event: string /** Original event argument list after the owner validates it for JSON transport. */ readonly args: readonly unknown[] + /** + * Readers of the resources the arguments carry, keyed by resource kind and + * called with the arguments as positional parameters. Absent or empty for + * a notification that touches the global resource. + */ + readonly resources?: Readonly> } /** Live Host values used to project one scoped Remote Event. */ @@ -143,8 +149,8 @@ export interface TypertGateway { readonly wireStream: TypertGatewayWireStream /** - * Check for an active Client event stream. - * @returns whether a stream is open and has not been cancelled. + * Check for an active owner Client event stream, the only kind that can answer forwarded waterfalls. + * @returns whether an owner stream is open and has not been cancelled. */ hasLiveClient(): boolean diff --git a/packages/api/gateway/tests/echo-stream.host.spec.ts b/packages/api/gateway/tests/echo-stream.host.spec.ts index d56b529002..24a24f6918 100644 --- a/packages/api/gateway/tests/echo-stream.host.spec.ts +++ b/packages/api/gateway/tests/echo-stream.host.spec.ts @@ -7,7 +7,9 @@ import WebServer from '@deepseek-ai/dsh-host-webserver' import { Remote, TypertRemoteService, type RemoteStream } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import { browserCookie, provideBrowserCredentials } from './browser-credentials.ts' +import { ownerPeer } from './peer.ts' /** The smallest Remote stream that reads its uplink: every carrier has to serve it alike. */ class EchoService extends TypertRemoteService { @@ -36,6 +38,7 @@ describe('the echo stream on each carrier', () => { it('echoes uplink items through the in-process carrier', async () => { const ctx = await setup(false) const replies = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'echo', method: 'echo', args: { prefix: '> ' }, uplink: items(['a', 'b']), }) await expect(collect(replies)).resolves.toEqual(['> a', '> b']) @@ -73,6 +76,7 @@ async function setup(transport: boolean): Promise { provideBrowserCredentials(ctx) } await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGatewayService, {}) if (transport) await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) await ctx.plugin(EchoService) diff --git a/packages/api/gateway/tests/gateway-access.host.spec.ts b/packages/api/gateway/tests/gateway-access.host.spec.ts new file mode 100644 index 0000000000..fe7aeb4701 --- /dev/null +++ b/packages/api/gateway/tests/gateway-access.host.spec.ts @@ -0,0 +1,767 @@ +import { describe, expect, it, vi, afterEach } from 'vitest' +import { Context, symbols } from '@deepseek-ai/cordis' +import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' +import RemoteAccessService, { + type RemoteAccessAction, + type RemoteAccessBinding, + type RemoteAccessResource, + type RemoteAccessResourceKind, + type RemoteAccessRole, +} from '@deepseek-ai/dsh-api-remote-access' +import { + Remote, + RemoteScope, + TypertRemoteService, + type InvocationDescriptor, + type InvocationParameterDescriptor, + type PeerScope, + type Principal, + type PrincipalMap, + type TypertContext, + type TypertLookup, + type TypertResource, +} from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { z } from 'zod' +import TypertGatewayService, { type TypertRemoteEventDispatch } from '@deepseek-ai/dsh-api-gateway' +import { callResources, eventResources } from '../src/access.ts' +import { provideBrowserCredentials } from './browser-credentials.ts' +import { ownerPeer, peerFor } from './peer.ts' + +interface AccessAgent { + readonly id: string +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + } + interface TypertLookupMap { + gatewayAccessAgent: TypertLookup + } + interface TypertContextMap { + gatewayAccessScope: TypertContext + } + interface TypertResourceMap { + /** Test-only resource kinds; the Gateway itself names no kind. */ + 'test-shelf': TypertResource + 'test-desk': TypertResource + } +} + +type Guest = PrincipalMap['test-guest'] + +const OWNER: Principal = { kind: 'owner' } +const GUEST: Guest = { kind: 'test-guest', guest: 'gateway' } +const GLOBAL = { kind: 'global' } as const +const shelf = (id: string) => ({ kind: 'test-shelf', id }) as const +const desk = (id: string) => ({ kind: 'test-desk', id }) as const + +const FIXTURE_ACTIONS: readonly RemoteAccessAction[] = [ + 'accessFixture/open', 'accessFixture/read', 'accessFixture/byAgent', 'accessFixture/byLookup', + 'accessFixture/byLookupAndOption', 'accessFixture/byList', 'accessFixture/scoped', 'accessFixture/scopedOnDesk', + 'accessFixture/scopedByReader', 'absentAccess/read', 'event:fixture/notice', 'event:fixture/shelf', +] + +function role(resource: K): RemoteAccessRole { + return { name: `test:${resource}`, resource, actions: FIXTURE_ACTIONS } +} + +/** One binding granting every fixture action on `resource`. */ +function on(resource: RemoteAccessResource): RemoteAccessBinding { + switch (resource.kind) { + case 'global': + return { role: role('global'), resource } + case 'test-shelf': + return { role: role('test-shelf'), resource } + case 'test-desk': + return { role: role('test-desk'), resource } + default: + throw new Error('no fixture role for this resource kind') + } +} + +class AccessFixture extends TypertRemoteService { + readonly principals: Principal[] = [] + release: PromiseWithResolvers = Promise.withResolvers() + + constructor(ctx: Context) { + super(ctx, 'accessFixture') + } + + @Remote + open(): string { + return this.seen() + } + + @Remote + read(request: { readonly shelfId?: unknown; readonly principal?: unknown }): string { + void request + return this.seen() + } + + @Remote({ resources: { 'test-shelf': agentId => agentId } }) + byAgent(agentId: string): string { + void agentId + return this.seen() + } + + @Remote + byLookup(agent: AccessAgent): string { + return `${agent.id}:${this.seen()}` + } + + @Remote({ resources: { 'test-desk': (agentId, request) => [agentId, request.deskId] } }) + byLookupAndOption(agent: AccessAgent, request: { readonly deskId: string; readonly shelfId: string }): string { + void request + return `${agent.id}:${this.seen()}` + } + + @Remote({ resources: { 'test-shelf': shelfIds => shelfIds } }) + byList(shelfIds: readonly string[]): string { + void shelfIds + return this.seen() + } + + @RemoteScope('gatewayAccessScope') + scoped(): string { + return this.seen() + } + + @RemoteScope('gatewayAccessScope', { resources: { 'test-desk': request => request.deskId } }) + scopedOnDesk(request: { readonly deskId: string }): string { + void request + return this.seen() + } + + @RemoteScope('gatewayAccessScope', { resources: { 'test-shelf': request => request.shelfId } }) + scopedByReader(request: { readonly shelfId: string }): string { + void request + return this.seen() + } + + @Remote + async slow(): Promise { + await this.release.promise + return this.seen() + } + + @Remote({ mode: 'stream' }) + async *watch(signal: AbortSignal): AsyncIterable { + yield this.seen() + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + + private seen(): string { + const principal = this.ctx.invocation?.peer.principal + if (principal !== undefined) this.principals.push(principal) + return principal?.kind ?? 'none' + } +} + +const roots: Context[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +async function setup(options: { readonly connection?: boolean } = {}): Promise<{ + readonly ctx: Context + readonly fixture: AccessFixture + /** Replace the guest's bindings: every fixture action on each listed resource. */ + readonly hold: (...resources: RemoteAccessResource[]) => void + readonly bindings: ReturnType readonly RemoteAccessBinding[]>> +}> { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) + await ctx.plugin(TypertGatewayService) + if (options.connection === true) { + provideBrowserCredentials(ctx) + await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) + } + ctx.typert.resources.register('test-shelf', { field: 'shelfId' }) + await ctx.plugin(AccessFixture) + let held: readonly RemoteAccessBinding[] = [] + const bindings = vi.fn((guest: Guest): readonly RemoteAccessBinding[] => { void guest; return held }) + ctx.remoteAccess.registerPolicy('test-guest', { bindings }) + const receiver = ctx.get('accessFixture') as AccessFixture & { [symbols.original]?: AccessFixture } + return { + ctx, + fixture: receiver[symbols.original] ?? receiver, + hold: (...resources) => { held = resources.map(on) }, + bindings, + } +} + +/** Record every request the service decides from now on; the returned function lists their actions and resources in order. */ +function recordDecisions(): () => { readonly action: string; readonly resources: unknown }[] { + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + return () => decide.mock.calls.map(([request]) => ({ action: request.action, resources: request.resources })) +} + +function call(ctx: Context, peer: PeerScope, method: string, args: Record = {}): Promise { + return ctx.typertGateway.invoke({ peer, namespace: 'accessFixture', method, args }) +} + +function registerScope(ctx: Context, resource: 'test-shelf' | undefined, resolve = vi.fn((id: string) => { void id; return ctx })): typeof resolve { + ctx.typert.contexts.registerHost('gatewayAccessScope', { + wire: 'scopeId', + wireTypeSymbol: '@fixture/access#ScopeId', + ...resource === undefined ? {} : { resource }, + resolve, + }) + return resolve +} + +describe('Gateway access checks', () => { + it('dispatches the owner without resolving resources or reading the policy', async () => { + const { ctx, bindings } = await setup() + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + await expect(call(ctx, ownerPeer(ctx), 'open')).resolves.toBe('owner') + await expect(call(ctx, ownerPeer(ctx), 'read', { request: { shelfId: 7 } })).resolves.toBe('owner') + expect(decide).not.toHaveBeenCalled() + expect(bindings).not.toHaveBeenCalled() + }) + + it('starts only in a composition that mounts remoteAccess', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + void ctx.plugin(TypertGatewayService) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(ctx.get('typertGateway')).toBeUndefined() + await ctx.plugin(RemoteAccessService) + await vi.waitFor(() => { expect(ctx.get('typertGateway')).toBeDefined() }) + }) + + it('refuses a non-owner call no binding allows before the method runs', async () => { + const { ctx, fixture, hold } = await setup() + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'open')).rejects.toMatchObject({ + code: 'remote-access/denied', + message: 'remote access: accessFixture/open: access denied (no-allow)', + details: { action: 'accessFixture/open', reason: 'no-allow' }, + }) + expect(fixture.principals).toEqual([]) + hold(GLOBAL) + await expect(call(ctx, guest, 'open')).resolves.toBe('test-guest') + expect(fixture.principals).toEqual([GUEST]) + }) + + it('hands remoteAccess the endpoint action, the resolved resources, and the call arguments', async () => { + const { ctx, hold } = await setup() + hold(GLOBAL, shelf('s1'), shelf('s2')) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const guest = peerFor(ctx, GUEST) + await call(ctx, guest, 'read', { request: { shelfId: 's1' } }) + await call(ctx, guest, 'byAgent', { agentId: 's2' }) + await call(ctx, guest, 'read', { request: {} }) + expect(decide.mock.calls.map(([request]) => request)).toEqual([ + { principal: GUEST, action: 'accessFixture/read', resources: [shelf('s1')], args: { request: { shelfId: 's1' } } }, + { principal: GUEST, action: 'accessFixture/byAgent', resources: [shelf('s2')], args: { agentId: 's2' } }, + { principal: GUEST, action: 'accessFixture/read', resources: [GLOBAL], args: { request: {} } }, + ]) + }) + + it('reads a lookup parameter\'s resource and lets a reader replace it for its kind', async () => { + const { ctx, hold } = await setup() + hold(shelf('s9'), desk('a1'), desk('a2'), desk('d9')) + const decisions = recordDecisions() + ctx.typert.lookups.register('gatewayAccessAgent', { + parameter: 'agent', + wire: 'agentId', + resource: 'test-desk', + hostTypeSymbol: '@fixture/access#Agent', + wireTypeSymbol: '@fixture/access#AgentId', + resolve: (id: string) => ({ id }), + }) + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'byLookup', { agentId: 'a1' })).resolves.toBe('a1:test-guest') + await expect(call(ctx, guest, 'byLookupAndOption', { agentId: 'a2', request: { deskId: 'd9', shelfId: 's9' } })) + .resolves.toBe('a2:test-guest') + expect(decisions().map(({ resources }) => resources)).toEqual([ + [desk('a1')], + // The reader owns `test-desk`; the registered `shelfId` field still names `test-shelf`. + [shelf('s9'), desk('a2'), desk('d9')], + ]) + }) + + it('refuses an empty reader list and a malformed default field with resource-invalid without asking remoteAccess', async () => { + const { ctx, hold, bindings } = await setup() + hold(shelf('s1'), shelf('s2')) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'byList', { shelfIds: [] })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'accessFixture/byList', reason: 'resource-invalid' }, + }) + await expect(call(ctx, guest, 'read', { request: { shelfId: 7 } })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { reason: 'resource-invalid' }, + }) + expect(decide).not.toHaveBeenCalled() + expect(bindings).not.toHaveBeenCalled() + // Every listed resource must be allowed. + await expect(call(ctx, guest, 'byList', { shelfIds: ['s1', 's2'] })).resolves.toBe('test-guest') + await expect(call(ctx, guest, 'byList', { shelfIds: ['s1', 's3'] })).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + }) + + it('resolves a strict method whose Service is absent from its arguments alone', async () => { + const { ctx } = await setup() + const decisions = recordDecisions() + ctx.typert.register({ + package: '@fixture/gateway-access', + face: 'host', + schemas: [], + model: { services: [], events: [], objects: [] }, + invocations: [{ + id: '@fixture/gateway-access#absentAccess/read', + service: 'absentAccess', + namespace: 'absentAccess', + method: 'read', + invocation: { kind: 'direct' }, + parameters: [], + result: { mode: 'strict', typeSymbol: '@fixture/gateway-access#Result', create: () => z.string() }, + }], + }) + await expect(ctx.typertGateway.invoke({ peer: peerFor(ctx, GUEST), namespace: 'absentAccess', method: 'read', args: {} })) + .rejects.toMatchObject({ code: 'remote-access/denied', details: { action: 'absentAccess/read', reason: 'no-allow' } }) + expect(decisions()).toEqual([{ action: 'absentAccess/read', resources: [GLOBAL] }]) + }) + + it('checks access before resolving lookups, so a refused call activates nothing', async () => { + const { ctx, hold } = await setup() + const resolve = vi.fn((id: string) => ({ id })) + ctx.typert.lookups.register('gatewayAccessAgent', { + parameter: 'agent', + wire: 'agentId', + hostTypeSymbol: '@fixture/access#Agent', + wireTypeSymbol: '@fixture/access#AgentId', + resolve, + }) + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'byLookup', { agentId: 'a1' })).rejects.toMatchObject({ code: 'remote-access/denied' }) + expect(resolve).not.toHaveBeenCalled() + // A lookup without a resource kind contributes nothing, so the call touches the global resource. + hold(GLOBAL) + await expect(call(ctx, guest, 'byLookup', { agentId: 'a1' })).resolves.toBe('a1:test-guest') + expect(resolve).toHaveBeenCalledOnce() + }) + + it('keeps the principal out of reach of wire fields', async () => { + const { ctx, fixture, hold } = await setup({ connection: true }) + hold(GLOBAL) + const guest = ctx.connection.createPeer({ principal: GUEST }) + await expect(call(ctx, guest, 'read', { request: {}, principal: OWNER })).rejects.toMatchObject({ + code: 'gateway/arguments-invalid', + }) + const shared = ctx.connection.createSharedFetchHandler('/api') + const response = await shared.fetch(new Request('http://host/api/accessFixture/read', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-1', + method: 'accessFixture/read', + principal: OWNER, + payload: { args: { request: { principal: OWNER } } }, + }), + }), guest) + expect(await response.json()).toMatchObject({ result: { ok: true, value: 'test-guest' } }) + const payloadSibling = await shared.fetch(new Request('http://host/api/accessFixture/read', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-2', + method: 'accessFixture/read', + payload: { args: { request: {} }, principal: OWNER }, + }), + }), guest) + expect(await payloadSibling.json()).toMatchObject({ result: { ok: false } }) + expect(fixture.principals).toEqual([GUEST]) + }) +}) + +describe('Gateway access checks for Context methods', () => { + it('reads the Context identity as the resource its adapter declares, before resolving the Context', async () => { + const { ctx, fixture, hold } = await setup() + const resolve = registerScope(ctx, 'test-shelf') + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'scoped', { scopeId: 's1' })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'accessFixture/scoped', reason: 'no-allow' }, + }) + expect(resolve).not.toHaveBeenCalled() + hold(shelf('s1')) + await expect(call(ctx, guest, 'scoped', { scopeId: 's1' })).resolves.toBe('test-guest') + expect(resolve).toHaveBeenCalledWith('s1') + expect(fixture.principals).toEqual([GUEST]) + }) + + it('adds the resources a reader declares to the Context resource, and lets a reader replace it for its kind', async () => { + const { ctx, hold } = await setup() + registerScope(ctx, 'test-shelf') + const decisions = recordDecisions() + const guest = peerFor(ctx, GUEST) + hold(shelf('s1')) + await expect(call(ctx, guest, 'scopedOnDesk', { scopeId: 's1', request: { deskId: 'd1' } })).rejects.toMatchObject({ + details: { reason: 'no-allow' }, + }) + hold(shelf('s1'), desk('d1')) + await expect(call(ctx, guest, 'scopedOnDesk', { scopeId: 's1', request: { deskId: 'd1' } })).resolves.toBe('test-guest') + await expect(call(ctx, guest, 'scopedByReader', { scopeId: 's9', request: { shelfId: 's1' } })).resolves.toBe('test-guest') + expect(decisions().map(({ resources }) => resources)).toEqual([ + [shelf('s1'), desk('d1')], + [shelf('s1'), desk('d1')], + [shelf('s1')], + ]) + }) + + it('refuses a malformed Context identity with resource-invalid without asking remoteAccess or resolving the Context', async () => { + const { ctx, hold, bindings } = await setup() + const resolve = registerScope(ctx, 'test-shelf') + hold(GLOBAL, shelf('s1')) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const guest = peerFor(ctx, GUEST) + for (const scopeId of [7, '']) { + await expect(call(ctx, guest, 'scoped', { scopeId })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'accessFixture/scoped', reason: 'resource-invalid' }, + }) + } + expect(resolve).not.toHaveBeenCalled() + expect(decide).not.toHaveBeenCalled() + expect(bindings).not.toHaveBeenCalled() + }) + + it('resolves a Context method whose adapter declares no resource by the ordinary sources', async () => { + const { ctx, hold } = await setup() + registerScope(ctx, undefined) + const decisions = recordDecisions() + const guest = peerFor(ctx, GUEST) + await expect(call(ctx, guest, 'scoped', { scopeId: 's1' })).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + hold(GLOBAL, desk('d1')) + await expect(call(ctx, guest, 'scoped', { scopeId: 's1' })).resolves.toBe('test-guest') + await expect(call(ctx, guest, 'scopedOnDesk', { scopeId: 's1', request: { deskId: 'd1' } })).resolves.toBe('test-guest') + expect(decisions().map(({ resources }) => resources)).toEqual([ + [GLOBAL], + [GLOBAL], + [desk('d1')], + ]) + }) +}) + +describe('Gateway Peer lifetime', () => { + it('refuses calls and streams from a disposed Peer without affecting other Peers', async () => { + const { ctx } = await setup({ connection: true }) + const peer = peerFor(ctx, OWNER) + await peer.dispose() + await expect(call(ctx, peer, 'open')).rejects.toMatchObject({ code: 'gateway/peer-disposed' }) + await expect(ctx.typertGateway.stream({ peer, namespace: 'accessFixture', method: 'watch', args: {} })) + .rejects.toMatchObject({ code: 'gateway/peer-disposed' }) + await expect(ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), peer, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/peer-disposed' }) + const result = await ctx.connection.createSharedFetchHandler('/api').fetch(new Request('http://host/api/$events/result', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-disposed', + method: '$events/result', + payload: { args: { clientId: 'any', eventId: 'any', outcome: { kind: 'next' } } }, + }), + }), peer) + expect(await result.json()).toMatchObject({ result: { ok: false, error: { code: 'gateway/peer-disposed' } } }) + await expect(call(ctx, ownerPeer(ctx), 'open')).resolves.toBe('owner') + }) + + it('refuses a disposed Peer before reading its wire payload', async () => { + const { ctx } = await setup({ connection: true }) + const peer = peerFor(ctx, OWNER) + await peer.dispose() + for (const payload of [{}, { args: 1 }, { args: {}, extra: true }]) { + await expect(ctx.typertGateway.wireStream.open('accessFixture/watch', payload, (async function* () {})(), peer, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/peer-disposed' }) + } + const response = await ctx.connection.createSharedFetchHandler('/api').fetch(new Request('http://host/api/accessFixture/open', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId: 'rpc-malformed', method: 'accessFixture/open', payload: { args: 1 } }), + }), peer) + expect(await response.json()).toMatchObject({ result: { ok: false, error: { code: 'gateway/peer-disposed' } } }) + }) + + it('ends a disposed Peer\'s streams and lets its running unary calls finish', async () => { + const { ctx, fixture } = await setup() + const peer = peerFor(ctx, OWNER) + const other = peerFor(ctx, OWNER) + const stream = (await ctx.typertGateway.stream({ peer, namespace: 'accessFixture', method: 'watch', args: {} }))[Symbol.asyncIterator]() + const survivorAbort = new AbortController() + const survivor = (await ctx.typertGateway.stream({ + peer: other, namespace: 'accessFixture', method: 'watch', args: {}, signal: survivorAbort.signal, + }))[Symbol.asyncIterator]() + await expect(stream.next()).resolves.toEqual({ done: false, value: 'owner' }) + await expect(survivor.next()).resolves.toEqual({ done: false, value: 'owner' }) + const running = call(ctx, peer, 'slow') + const pending = stream.next() + await peer.dispose() + // The stream's method signal joins the Peer's, so disposal cancels it like a carrier cancel. + await expect(pending).rejects.toMatchObject({ code: 'gateway/cancelled' }) + fixture.release.resolve() + await expect(running).resolves.toBe('owner') + const next = survivor.next() + let settled = false + void next.then(() => { settled = true }, () => { settled = true }) + await new Promise(resolve => setTimeout(resolve, 10)) + expect(settled).toBe(false) + survivorAbort.abort() + await expect(next).rejects.toMatchObject({ code: 'gateway/cancelled' }) + }) +}) + +/** Install a pushable event source; `push` yields one dispatch to every open `$events` stream. */ +function eventSource(ctx: Context): (dispatch: TypertRemoteEventDispatch) => void { + const queue: TypertRemoteEventDispatch[] = [] + let wake: (() => void) | undefined + ctx.effect(() => ctx.typertGateway.registerRemoteEvents(async function* (signal) { + while (!signal.aborted) { + while (queue.length > 0) yield queue.shift() as TypertRemoteEventDispatch + await new Promise((resolve) => { + wake = resolve + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + }, { home: '/fixture' })) + return (dispatch) => { + queue.push(dispatch) + wake?.() + } +} + +describe('Gateway forwarded events for non-owner Peers', () => { + it('delivers only allowed broadcasts and no answerable waterfall to a non-owner, and refuses its results', async () => { + const { ctx, hold } = await setup({ connection: true }) + hold(shelf('s1')) + const decide = vi.spyOn(RemoteAccessService.prototype, 'decide') + const push = eventSource(ctx) + const guest = ctx.connection.createPeer({ principal: GUEST }) + const abort = new AbortController() + const guestEvents = (await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), guest, abort.signal))[Symbol.asyncIterator]() + const guestReady = await guestEvents.next() + // A non-owner stream receives the same Host facts but cannot answer, so it does not count as a live Client. + expect(guestReady.value).toMatchObject({ type: 'ready', host: { home: '/fixture' } }) + expect(ctx.typertGateway.hasLiveClient()).toBe(false) + const ownerEvents = (await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), ctx.connection.browserPeer, abort.signal))[Symbol.asyncIterator]() + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'ready', host: { home: '/fixture' } } }) + expect(ctx.typertGateway.hasLiveClient()).toBe(true) + + const agentCtx = ctx.extend({}) + const subject = {} + const settled = vi.fn() + push({ + event: 'fixture/ask', + request: { question: 'q', agent: subject }, + context: { value: agentCtx, subject, agentId: 'agent-1' }, + resolve: settled, + reject: settled, + }) + const resources = { 'test-shelf': (shelfId: unknown) => shelfId } + push({ event: 'fixture/notice', args: ['hello'] }) + push({ event: 'fixture/shelf', args: ['s2'], resources }) + push({ event: 'fixture/shelf', args: ['s1'], resources }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'waterfall', event: 'fixture/ask' } }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/notice' } }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf', args: ['s2'] } }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf', args: ['s1'] } }) + // The global notice and the denied shelf never reach the non-owner; the allowed one arrives whole. + await expect(guestEvents.next()).resolves.toEqual({ done: false, value: { type: 'emit', event: 'fixture/shelf', args: ['s1'] } }) + expect(decide.mock.calls.map(([request]) => request)).toEqual([ + { principal: GUEST, action: 'event:fixture/notice', resources: [GLOBAL] }, + { principal: GUEST, action: 'event:fixture/shelf', resources: [shelf('s2')] }, + { principal: GUEST, action: 'event:fixture/shelf', resources: [shelf('s1')] }, + ]) + + const clientId: unknown = Reflect.get(guestReady.value as object, 'clientId') + const shared = ctx.connection.createSharedFetchHandler('/api') + const refused = await shared.fetch(new Request('http://host/api/$events/result', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId: 'rpc-result', + method: '$events/result', + payload: { args: { clientId, eventId: 'any', outcome: { kind: 'next' } } }, + }), + }), guest) + expect(await refused.json()).toMatchObject({ + result: { ok: false, error: { code: 'gateway/invocation-unavailable', details: { endpoint: '$events/result' } } }, + }) + abort.abort() + await guestEvents.return?.() + await ownerEvents.return?.() + }) + + it('delivers a global event that a global role lists, and drops one whose resource is missing without asking remoteAccess', async () => { + const { ctx, hold } = await setup({ connection: true }) + hold(GLOBAL, shelf('s1')) + const decisions = recordDecisions() + const push = eventSource(ctx) + const abort = new AbortController() + const open = async (): Promise> => (await ctx.typertGateway.wireStream.open( + '$events', { args: {} }, (async function* () {})(), ctx.connection.createPeer({ principal: GUEST }), abort.signal, + ))[Symbol.asyncIterator]() + const streams = [await open(), await open()] + for (const events of streams) await events.next() + const missing = vi.fn(() => undefined) + push({ event: 'fixture/shelf', args: [null], resources: { 'test-shelf': missing } }) + push({ event: 'fixture/notice', args: ['hello'], resources: {} }) + for (const events of streams) { + await expect(events.next()).resolves.toEqual({ done: false, value: { type: 'emit', event: 'fixture/notice', args: ['hello'] } }) + } + // A frame's resources are resolved once for all non-owner streams. + expect(missing).toHaveBeenCalledOnce() + const notice = { action: 'event:fixture/notice', resources: [GLOBAL] } + expect(decisions()).toEqual([notice, notice]) + abort.abort() + for (const events of streams) await events.return?.() + }) + + it('contains a throwing policy to the non-owner deliveries it decides', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) + await ctx.plugin(TypertGatewayService) + provideBrowserCredentials(ctx) + await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + ctx.remoteAccess.registerPolicy('test-guest', { bindings: () => { throw new Error('binding store unavailable') } }) + const push = eventSource(ctx) + const abort = new AbortController() + const open = async (peer: PeerScope): Promise> => + (await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), peer, abort.signal))[Symbol.asyncIterator]() + const guestEvents = await open(ctx.connection.createPeer({ principal: GUEST })) + const ownerEvents = await open(ctx.connection.browserPeer) + await guestEvents.next() + await ownerEvents.next() + push({ event: 'fixture/shelf', args: ['s1'], resources: { 'test-shelf': (shelfId: unknown) => shelfId } }) + push({ event: 'fixture/notice', args: ['after'] }) + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/shelf' } }) + // The shared source survives the failure: later events still reach the owner. + await expect(ownerEvents.next()).resolves.toMatchObject({ value: { type: 'emit', event: 'fixture/notice' } }) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to list bindings for event:fixture/shelf; its further failures are not logged until access state changes') + const guestNext = guestEvents.next() + abort.abort() + await expect(guestNext).resolves.toEqual({ done: true, value: undefined }) + await ownerEvents.return?.() + }) +}) + +describe('resource resolution', () => { + const NEVER = new AbortController().signal + const ROOM = { kind: 'test-shelf', id: 'r1' } as const + const DESK = { kind: 'test-desk', id: 'd1' } as const + const DIRECT: InvocationDescriptor['invocation'] = { kind: 'direct' } + + /** A direct method's descriptor fields carrying only what resource resolution reads. */ + function method(...wires: string[]): Pick { + return { invocation: DIRECT, parameters: wires.map(wire => ({ name: wire, wire, source: 'json' }) as InvocationParameterDescriptor) } + } + + async function registry(): Promise { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + ctx.typert.resources.register('test-shelf', { field: 'roomId' }) + ctx.typert.resources.register('test-desk', { field: 'deskId' }) + return ctx.typert + } + + it('reads a default field from a top-level argument, else from the first object argument, else touches global', async () => { + const typert = await registry() + expect(callResources(typert, undefined, method('roomId'), { roomId: 'r1' }, NEVER)) + .toEqual([ROOM]) + expect(callResources(typert, undefined, method('count', 'tags', 'none', 'request'), { + count: 1, tags: ['r9'], none: null, request: { roomId: 'r1', deskId: 'd1' }, + }, NEVER)).toEqual([ROOM, DESK]) + // Only the first object argument is read; a later one naming the field does not count. + expect(callResources(typert, undefined, method('first', 'second'), { first: {}, second: { roomId: 'r1' } }, NEVER)) + .toEqual([GLOBAL]) + expect(callResources(typert, undefined, method(), {}, NEVER)).toEqual([GLOBAL]) + }) + + it('lets a reader replace the defaults for its kind, called with the positional wire arguments', async () => { + const typert = await registry() + const readers = { 'test-shelf': (ids: unknown) => ids } + expect(callResources(typert, readers, method('ids'), { ids: ['r1', 'r2'] }, NEVER)) + .toEqual([ROOM, { kind: 'test-shelf', id: 'r2' }]) + expect(callResources(typert, readers, method('ids', 'roomId'), { ids: undefined, roomId: 'r9' }, NEVER)) + .toEqual([GLOBAL]) + const signal = new AbortController().signal + const positional = vi.fn((...args: readonly unknown[]) => (args.at(-1) === signal ? args[1] : 'wrong')) + expect(callResources(typert, { 'test-desk': positional }, { + ...method('first', 'desk'), cancellation: { parameter: 'signal' }, + }, { first: 'f', desk: 'd1' }, signal)).toEqual([DESK]) + expect(positional).toHaveBeenCalledWith('f', 'd1', signal) + }) + + it('reads lookup parameters and a Context identity of a declared kind, an omitted optional lookup as none', async () => { + const typert = await registry() + typert.lookups.register('gatewayAccessAgent', { + parameter: 'agent', + wire: 'agentId', + resource: 'test-desk', + hostTypeSymbol: '@fixture/access#Agent', + wireTypeSymbol: '@fixture/access#AgentId', + resolve: (id: string) => ({ id }), + }) + typert.contexts.registerHost('gatewayAccessScope', { + wire: 'scopeId', wireTypeSymbol: '@fixture/access#ScopeId', resource: 'test-desk', resolve: () => undefined, + }) + const agent = { name: 'agent', wire: 'agentId', source: 'lookup', lookup: 'gatewayAccessAgent' } as InvocationParameterDescriptor + const unknownLookup = { name: 'other', wire: 'otherId', source: 'lookup', lookup: 'absentLookup' } as InvocationParameterDescriptor + expect(callResources(typert, undefined, { invocation: DIRECT, parameters: [agent, unknownLookup] }, { agentId: undefined, otherId: 'o1' }, NEVER)) + .toEqual([GLOBAL]) + expect(callResources(typert, undefined, { invocation: DIRECT, parameters: [agent] }, { agentId: 'd1' }, NEVER)) + .toEqual([DESK]) + const scoped = (context: string): InvocationDescriptor['invocation'] => ({ kind: 'context', context, wire: 'scopeId', codec: { mode: 'src-json' } }) + expect(callResources(typert, undefined, { invocation: scoped('gatewayAccessScope'), parameters: [agent] }, { scopeId: 'd0', agentId: 'd1' }, NEVER)) + .toEqual([{ kind: 'test-desk', id: 'd0' }, DESK]) + // A required Context identity is never omitted, so a missing one is malformed. + expect(callResources(typert, undefined, { invocation: scoped('gatewayAccessScope'), parameters: [] }, {}, NEVER)) + .toBeUndefined() + expect(callResources(typert, undefined, { invocation: scoped('absentScope'), parameters: [] }, { scopeId: 'd0' }, NEVER)) + .toEqual([GLOBAL]) + }) + + it('reports invalid resources when a reader throws or returns an empty list, or a resource id is not a non-empty string', async () => { + const typert = await registry() + const one = method('value') + const throwing = { 'test-shelf': () => { throw new TypeError('malformed call') } } + expect(callResources(typert, throwing, one, { value: 1 }, NEVER)).toBeUndefined() + for (const value of ['', 7, ['r1', ''], [null], []]) { + expect(callResources(typert, { 'test-shelf': (read: unknown) => read }, one, { value }, NEVER)).toBeUndefined() + } + // A default field names exactly one identity, so an array is malformed. + for (const roomId of [7, ['r1'], '']) { + expect(callResources(typert, undefined, method('roomId'), { roomId }, NEVER)).toBeUndefined() + } + }) + + it('reads a forwarded event through the readers its entry declares, and the global resource without them', () => { + expect(eventResources(undefined, ['r1'])).toEqual([GLOBAL]) + expect(eventResources({}, ['r1'])).toEqual([GLOBAL]) + expect(eventResources({ 'test-shelf': (id: unknown) => id }, ['r1'])).toEqual([ROOM]) + expect(eventResources({ 'test-desk': (_first: unknown, second: unknown) => second }, ['r1', 'd1'])) + .toEqual([DESK]) + // An event carries every resource it declares, so a missing one is invalid rather than a global event. + expect(eventResources({ 'test-shelf': () => undefined }, ['r1'])).toBeUndefined() + expect(eventResources({ 'test-shelf': () => [] }, ['r1'])).toBeUndefined() + expect(eventResources({ 'test-shelf': () => 7 }, ['r1'])).toBeUndefined() + }) +}) diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 2efcb950e0..72a46e21f6 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -21,6 +21,7 @@ import { RemoteError, } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' declare module '@deepseek-ai/dsh-typert-protocol' { interface RemoteErrorDetailsMap { @@ -41,6 +42,7 @@ import type { RemoteEventClientId, RemoteEventInvocationFrame, } from '../src/stream-protocol.ts' +import { ownerPeer, peerFor } from './peer.ts' vi.mock('node:crypto', async (importOriginal) => { const actual = await importOriginal() @@ -368,13 +370,13 @@ describe('Typert Remote streams', () => { const unregister = ctx.effect(() => ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST)) const abort = new AbortController() const empty = (async function* () {})() - const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, undefined, abort.signal) + const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, ownerPeer(ctx), abort.signal) const first = events[Symbol.asyncIterator]() await first.next() expect(ctx.typertGateway.hasLiveClient()).toBe(true) abort.abort() expect(ctx.typertGateway.hasLiveClient()).toBe(false) - const replacement = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, undefined, new AbortController().signal) + const replacement = await ctx.typertGateway.wireStream.open('$events', { args: {} }, empty, ownerPeer(ctx), new AbortController().signal) const second = replacement[Symbol.asyncIterator]() try { await second.next() @@ -432,7 +434,7 @@ describe('Typert Remote streams', () => { 'feed/sync', { args: { label: 'wire' } }, toAsync([]), - undefined, + ownerPeer(ctx), new AbortController().signal, ) await expect(collect(source)).resolves.toEqual(['wire:one', 'wire:two']) @@ -441,7 +443,7 @@ describe('Typert Remote streams', () => { 'feed/echo', { args: { prefix: 'wire:' } }, toAsync(['a', 'b']), - undefined, + ownerPeer(ctx), new AbortController().signal, ) await expect(collect(echoed)).resolves.toEqual(['wire:a', 'wire:b']) @@ -450,13 +452,16 @@ describe('Typert Remote streams', () => { it('delivers uplink items through the in-process carrier and ends on half-close', async () => { const { ctx, service } = await setup(false) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '> ' }, uplink: toAsync(['a', 'b']), }))).resolves.toEqual(['> a', '> b']) expect(service.returns).toBe(1) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: 'x' }, }))).resolves.toEqual([]) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: 'x' }, })).rejects.toMatchObject({ code: 'gateway/signature-invalid', @@ -474,6 +479,7 @@ describe('Typert Remote streams', () => { }), } await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'ignore', args: { prefix: 'i' }, uplink: unread, }))).resolves.toEqual(['i:one', 'i:two']) await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) @@ -482,6 +488,7 @@ describe('Typert Remote streams', () => { it('fails a stream whose uplink item fails its codec, even when the method swallows the failure', async () => { const { ctx, service } = await setup(false) const rejected = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync(['ok', 1]), }) const iterator = rejected[Symbol.asyncIterator]() @@ -496,6 +503,7 @@ describe('Typert Remote streams', () => { expect(service.returns).toBe(1) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'swallow', args: {}, uplink: toAsync([1]), }))).rejects.toMatchObject({ code: 'gateway/input-invalid' }) }) @@ -504,6 +512,7 @@ describe('Typert Remote streams', () => { const { ctx, service } = await setup(false) const abort = new AbortController() const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'peek', args: {}, uplink: neverYielding(), signal: abort.signal, }) const iterator = source[Symbol.asyncIterator]() @@ -520,6 +529,7 @@ describe('Typert Remote streams', () => { const { ctx, service } = await setup(false) const abort = new AbortController() const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'hold', args: {}, uplink: toAsync(['unread']), signal: abort.signal, }) const iterator = source[Symbol.asyncIterator]() @@ -536,7 +546,9 @@ describe('Typert Remote streams', () => { it('exposes the call as ctx.invocation, delivers codec-less uplink items as unknown, and hands the uplink out once', async () => { const { ctx, service } = await setup(false) + const caller = peerFor(ctx, { kind: 'owner' }) await expect(collect(await ctx.typertGateway.stream({ + peer: caller, namespace: 'feed', method: 'context', args: { label: 'ctx' }, uplink: toAsync([1, { nested: true }]), }))).resolves.toEqual([ 'feed/context:feed:{"label":"ctx"}:ctx', @@ -547,18 +559,22 @@ describe('Typert Remote streams', () => { expect(service.peers.at(-1)?.id).toMatch(/^[0-9a-f-]{36}$/) expect(service.invocationOutsideCall()).toBeUndefined() await expect(collect(await ctx.typertGateway.stream({ + peer: caller, namespace: 'feed', method: 'context', args: { label: 'again' }, }))).resolves.toHaveLength(2) + expect(service.peers.at(-1)).toBe(caller) expect(service.peers.at(-1)).toBe(service.peers.at(-2)) await expect(service.peers.at(-1)?.dispose()).resolves.toBeUndefined() // Items sent to a method that never takes its uplink wait in the carrier and are dropped when the downlink ends. await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'sync', args: { label: 'closed' }, uplink: toAsync(['unread']), }))).resolves.toEqual(['closed:one', 'closed:two']) // Without a codec an item still has to be a JSON-safe value. await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'context', args: { label: 'bad' }, uplink: toAsync([1n]), }))).rejects.toMatchObject({ code: 'gateway/input-invalid', @@ -579,6 +595,7 @@ describe('Typert Remote streams', () => { }), } const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'hold', args: {}, uplink: stuck, signal: abort.signal, }) const iterator = source[Symbol.asyncIterator]() @@ -592,6 +609,7 @@ describe('Typert Remote streams', () => { it('closes the uplink before returning a method blocked on it', async () => { const { ctx } = await setup(false) const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'drain', args: {}, uplink: neverYielding(), }) const iterator = source[Symbol.asyncIterator]() @@ -608,6 +626,7 @@ describe('Typert Remote streams', () => { let index = 0 const returned = vi.fn(async (): Promise> => ({ done: true, value: undefined })) const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'hold', args: {}, uplink: { [Symbol.asyncIterator]: () => ({ next: () => reads[index++]!.promise, return: returned }) }, }) @@ -639,9 +658,9 @@ describe('Typert Remote streams', () => { const { ctx, service } = await setup(false) service.source = { [Symbol.iterator]: () => ({ next: () => new ReadResult() }) } const source = await ctx.typertGateway.stream(direction === 'downlink' - ? { namespace: 'feed', method: 'items', args: {} } + ? { peer: ownerPeer(ctx), namespace: 'feed', method: 'items', args: {} } : { - namespace: 'feed', method: 'echo', args: { prefix: '' }, + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: { [Symbol.asyncIterator]: () => ({ next: async () => new ReadResult() }) }, }) const iterator = source[Symbol.asyncIterator]() @@ -664,6 +683,7 @@ describe('Typert Remote streams', () => { const next = vi.fn((): IteratorResult => ({ done: false, value: 'item' })) service.source = { [Symbol.iterator]: () => ({ next }) } const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'items', args: {}, signal: abort.signal, }) const iterator = source[Symbol.asyncIterator]() @@ -687,9 +707,9 @@ describe('Typert Remote streams', () => { } service.source = { [Symbol.iterator]: () => ({ next }) } const source = await ctx.typertGateway.stream(direction === 'downlink' - ? { namespace: 'feed', method: 'items', args: {}, signal: abort.signal } + ? { peer: ownerPeer(ctx), namespace: 'feed', method: 'items', args: {}, signal: abort.signal } : { - namespace: 'feed', method: 'hold', args: {}, signal: abort.signal, + peer: ownerPeer(ctx), namespace: 'feed', method: 'hold', args: {}, signal: abort.signal, uplink: { [Symbol.asyncIterator]: () => ({ next }) }, }) const iterator = source[Symbol.asyncIterator]() @@ -706,6 +726,7 @@ describe('Typert Remote streams', () => { it('reports done on every read after the uplink ended', async () => { const { ctx } = await setup(false) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'rereads', args: {}, uplink: toAsync(['a', 'b']), }))).resolves.toEqual(['a', 'b', 'done']) }) @@ -713,6 +734,7 @@ describe('Typert Remote streams', () => { it('accepts a top-level undefined uplink item where no codec applies and rejects it where one does', async () => { const { ctx } = await setup(false) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'context', args: { label: 'absent' }, uplink: toAsync([undefined, 'x']), }))).resolves.toEqual([ 'feed/context:feed:{"label":"absent"}:absent', @@ -721,6 +743,7 @@ describe('Typert Remote streams', () => { 'typert gateway: feed/context: invocation.uplink() is available once per call', ]) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync([undefined]), }))).rejects.toMatchObject({ code: 'gateway/input-invalid', details: { endpoint: 'feed/echo', field: 'uplink' } }) }) @@ -732,6 +755,7 @@ describe('Typert Remote streams', () => { [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}), return: returned }), } await expect(ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'unary', args: { label: 'a' }, uplink: unread, })).rejects.toMatchObject({ code: 'gateway/signature-invalid' }) await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) @@ -740,6 +764,7 @@ describe('Typert Remote streams', () => { it('lets a unary method read uplink items while it runs', async () => { const { ctx } = await setup(false) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'unaryUplink', args: {}, uplink: toAsync(['a', 'b']), })).resolves.toEqual(['a', 'b']) }) @@ -747,6 +772,7 @@ describe('Typert Remote streams', () => { it('derives SRC descriptors whose uplink arrives without a codec', async () => { const { ctx } = await setup(false) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'srcEcho', args: { prefix: 's:' }, uplink: toAsync(['a', 2]), }))).resolves.toEqual(['s:a', 's:2']) }) @@ -756,6 +782,7 @@ describe('Typert Remote streams', () => { const window = { maxBytes: 512, frameOverheadBytes: 40 } for (const uplinkWindow of [undefined, window]) { await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'echo', args: { prefix: '' }, uplink: toAsync(['a']), uplinkWindow, }))).resolves.toEqual(['a']) } @@ -803,7 +830,7 @@ describe('Typert Remote streams', () => { ]) }) expect(service.returns).toBe(1) - expect(service.peers.at(-1)).toBe(ctx.connection.operator) + expect(service.peers.at(-1)).toBe(ctx.connection.browserPeer) sendOpen(socket, 'rejected', 'feed/echo', { prefix: '' }) socket.send(JSON.stringify({ type: 'item', streamId: 'rejected', value: 1 })) @@ -860,6 +887,7 @@ describe('Typert Remote streams', () => { const { ctx, service } = await setup(false) const abort = new AbortController() const source = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'follow', args: { label: 'a' }, @@ -870,35 +898,44 @@ describe('Typert Remote streams', () => { const pending = iterator.next() abort.abort(new Error('fixture cancellation')) await expect(pending).rejects.toThrow('Remote invocation "feed/follow" was aborted') - expect(service.signals).toEqual([abort.signal]) + // A stream's method signal also ends with its Peer, so it follows the caller's signal rather than being it. + expect(service.signals).toHaveLength(1) + expect(service.signals[0]?.reason).toBe(abort.signal.reason) expect(service.returns).toBe(1) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'sync', args: { label: 'b' }, }))).resolves.toEqual(['b:one', 'b:two']) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'invalid', args: {}, }))).resolves.toEqual([42]) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'nonJson', args: {}, }))).resolves.toEqual([1n]) await expect(ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'missing', args: {}, })).rejects.toMatchObject({ code: 'gateway/result-invalid' }) await expect(collect(await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'src', args: { label: 'c' }, }))).resolves.toEqual(['c:src']) const abortedBeforeOpen = new AbortController() abortedBeforeOpen.abort(new Error('cancelled before open')) await expect(ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'abortBeforeOpen', args: {}, signal: abortedBeforeOpen.signal, })).rejects.toThrow('Remote invocation "feed/abortBeforeOpen" was aborted') const abortedBeforeIteration = new AbortController() abortedBeforeIteration.abort(new Error('cancelled before iteration')) const preCancelled = await ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'sync', args: { label: 'ignored' }, signal: abortedBeforeIteration.signal, }) await expect(collect(preCancelled)).rejects.toThrow('Remote invocation "feed/sync" was aborted') @@ -907,9 +944,11 @@ describe('Typert Remote streams', () => { it('keeps unary and stream invocation modes distinct', async () => { const { ctx } = await setup(false) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'sync', args: { label: 'a' }, })).rejects.toMatchObject({ code: 'gateway/signature-invalid' } satisfies Partial) await expect(ctx.typertGateway.stream({ + peer: ownerPeer(ctx), namespace: 'feed', method: 'unary', args: { label: 'a' }, })).rejects.toMatchObject({ code: 'gateway/signature-invalid' } satisfies Partial) }) @@ -1004,7 +1043,7 @@ describe('Typert Remote streams', () => { const idle: AsyncIterable = { [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}), return: returned }), } - const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, idle, undefined, new AbortController().signal) + const events = await ctx.typertGateway.wireStream.open('$events', { args: {} }, idle, ownerPeer(ctx), new AbortController().signal) await vi.waitFor(() => { expect(returned).toHaveBeenCalledOnce() }) await events[Symbol.asyncIterator]().return?.() @@ -1259,7 +1298,7 @@ describe('Typert Remote streams', () => { it('retries a colliding Remote event Client id before opening the second generation', async () => { const { ctx } = await setup(true) - // Connection minted the operator Peer id above; only the event Client ids are counted below. + // Connection minted the browser Peer id above; only the event Client ids are counted below. randomUuid.mockClear() const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) @@ -1593,6 +1632,7 @@ async function setup( provideBrowserCredentials(ctx) } await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGatewayService, gatewayConfig) if (transport) { await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index e0fcbec649..8f3f6fddc4 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -7,7 +7,6 @@ import { apply as applyConnection, inject as connectionInject } from '@deepseek- import type { ConnectionRpcHandler, HostConnectionHandle, - PeerId, PeerScope, } from '@deepseek-ai/dsh-client-connection' import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver' @@ -23,7 +22,9 @@ import { } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-api-gateway' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import { provideBrowserCredentials } from './browser-credentials.ts' +import { ownerPeer } from './peer.ts' interface FixtureAgent { readonly id: string @@ -116,12 +117,12 @@ class FakeConnectionService extends Service { channel: string | undefined matches: ((endpoint: string) => boolean) | undefined handler: FakeRpcHandler | undefined - /** The operator Peer every call this fake dispatches speaks for. */ - readonly operator: PeerScope + /** The browser Peer every call this fake dispatches speaks for. */ + readonly browserPeer: PeerScope constructor(ctx: Context) { super(ctx, 'connection') - this.operator = { id: 'fake-operator' as PeerId, ctx, dispose: () => Promise.resolve() } + this.browserPeer = ownerPeer(ctx) } get rpc() { @@ -135,7 +136,7 @@ class FakeConnectionService extends Service { owner.effect(() => { this.channel = channel this.matches = matches - this.handler = (endpoint, payload, signal) => handler(endpoint, payload, signal, this.operator) + this.handler = (endpoint, payload, signal) => handler(endpoint, payload, signal, this.browserPeer) return () => { this.channel = undefined this.matches = undefined @@ -407,6 +408,7 @@ describe('TypertGatewayService', () => { const abort = new AbortController() await expect(caller.typertGateway.invoke({ + peer: ownerPeer(caller), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: ' ship ' } }, @@ -416,6 +418,7 @@ describe('TypertGatewayService', () => { expect(service.lastSignal).toBe(abort.signal) await expect(caller.typertGateway.invoke({ + peer: ownerPeer(caller), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'again' } }, @@ -431,6 +434,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [renameDescriptor()]) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -446,6 +450,7 @@ describe('TypertGatewayService', () => { const abort = new AbortController() await expect(caller.typertGateway.invoke({ + peer: ownerPeer(caller), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -464,6 +469,7 @@ describe('TypertGatewayService', () => { registerAgentLookup(ctx, { id: 'agent-1' }) await expect(ctx.extend({ fixtureScope: 'unbound' }).typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -477,6 +483,7 @@ describe('TypertGatewayService', () => { await dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -490,6 +497,7 @@ describe('TypertGatewayService', () => { ctx.typert.contexts.registerHost('gatewayFixture', contextProvider(scoped)) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -503,15 +511,19 @@ describe('TypertGatewayService', () => { await ctx.plugin(InheritedMethodService) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'exported', method: 'execute', args: { value: 'ship' }, })).resolves.toBe('ship') await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'empty', method: 'ping', args: {}, })).resolves.toBe('pong') await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'inherited', method: 'run', args: { value: 'land' }, })).resolves.toBe('land') await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'other', method: 'absent', args: {}, }), 'gateway/invocation-unavailable') }) @@ -521,6 +533,7 @@ describe('TypertGatewayService', () => { await colliding.plugin(CollidingWireService) registerAgentLookup(colliding, { id: 'agent-1' }) await expectCode(colliding.typertGateway.invoke({ + peer: ownerPeer(colliding), namespace: 'colliding-wire', method: 'run', args: { agentId: 'agent-1' }, @@ -528,6 +541,7 @@ describe('TypertGatewayService', () => { const missing = await setup() await expectCode(missing.ctx.typertGateway.invoke({ + peer: ownerPeer(missing.ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -537,6 +551,7 @@ describe('TypertGatewayService', () => { await contextCollision.plugin(ContextWireService) contextCollision.typert.contexts.registerHost('gatewayFixture', contextProvider(contextCollision.extend())) await expectCode(contextCollision.typertGateway.invoke({ + peer: ownerPeer(contextCollision), namespace: 'context-wire', method: 'run', args: { agentId: 'agent-1' }, @@ -551,6 +566,7 @@ describe('TypertGatewayService', () => { await disposeLookup() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -559,6 +575,7 @@ describe('TypertGatewayService', () => { registerAgentLookup(ctx, agent) await serviceFiber.dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -573,6 +590,7 @@ describe('TypertGatewayService', () => { await dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -583,6 +601,7 @@ describe('TypertGatewayService', () => { resolve: () => { throw new Error('provider failed') }, }) const error = await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -600,6 +619,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [renameDescriptor()]) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -615,6 +635,7 @@ describe('TypertGatewayService', () => { wire: 'differentAgentId', }) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -626,6 +647,7 @@ describe('TypertGatewayService', () => { resolve: () => undefined, }) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'rename', args: { agentId: 'agent-1', request: { title: 'land' } }, @@ -640,6 +662,7 @@ describe('TypertGatewayService', () => { resolve: async () => { throw new Error('lookup failed') }, }) const failure = await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -652,6 +675,7 @@ describe('TypertGatewayService', () => { resolve: () => Promise.resolve(undefined), }) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -663,6 +687,7 @@ describe('TypertGatewayService', () => { resolve: async id => ({ id }), }) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -675,6 +700,7 @@ describe('TypertGatewayService', () => { await dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, @@ -684,12 +710,14 @@ describe('TypertGatewayService', () => { it('seeds the no-downgrade guard from definitions present before Gateway startup', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) const dispose = registerStrict(ctx, [passthroughDescriptor()]) await ctx.plugin(TypertGatewayService) await ctx.plugin(GoalService) await dispose() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, @@ -699,6 +727,7 @@ describe('TypertGatewayService', () => { it('retains the no-downgrade guard across Gateway Service reloads', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) const gatewayFiber = ctx.plugin(TypertGatewayService) await gatewayFiber await ctx.plugin(GoalService) @@ -709,6 +738,7 @@ describe('TypertGatewayService', () => { await ctx.plugin(TypertGatewayService) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: 'would pass through SRC' }, @@ -721,6 +751,7 @@ describe('TypertGatewayService', () => { await ctx.plugin(SecondSharedService) const error = await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'shared', method: 'run', args: { value: 'ship' }, @@ -739,6 +770,7 @@ describe('TypertGatewayService', () => { const ctx = await setupGateway() await ctx.plugin(testCase.plugin) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: testCase.namespace, method: 'run', args: testCase.args, @@ -753,6 +785,7 @@ describe('TypertGatewayService', () => { ctx.typert.lookups.register('gatewayFixtureAlias', provider) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -764,16 +797,19 @@ describe('TypertGatewayService', () => { registerAgentLookup(ctx, { id: 'agent-1' }) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { request: { title: 'ship' } }, }), 'gateway/arguments-invalid') await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' }, optional: true }, }), 'gateway/arguments-invalid') await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: [] as unknown as Record, @@ -786,6 +822,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [strictOnlyDescriptor()]) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'strictOnly', args: { request: { title: 1 } }, @@ -793,6 +830,7 @@ describe('TypertGatewayService', () => { service.nextResult = { title: 1 } await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'strictOnly', args: { request: { title: 'ship' } }, @@ -805,6 +843,7 @@ describe('TypertGatewayService', () => { service.nextResult = 1n await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'strictOnly', args: { request: { title: 'ship' } }, @@ -824,6 +863,7 @@ describe('TypertGatewayService', () => { ])('rejects non-JSON SRC input %#', async (value) => { const { ctx } = await setup() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value }, @@ -836,6 +876,7 @@ describe('TypertGatewayService', () => { // cannot see which are optional, so an absent field is admitted; the case // above keeps an explicitly undefined field rejected. await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: {}, @@ -848,6 +889,7 @@ describe('TypertGatewayService', () => { const cyclic: { self?: unknown } = {} cyclic.self = cyclic await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: cyclic }, @@ -856,6 +898,7 @@ describe('TypertGatewayService', () => { const result = new Date(0) service.nextResult = result await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: null }, @@ -865,6 +908,7 @@ describe('TypertGatewayService', () => { it('accepts dense JSON and rejects decorated arrays and object properties', async () => { const { ctx } = await setup() await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value: [1, { nested: true }] }, @@ -882,6 +926,7 @@ describe('TypertGatewayService', () => { Object.defineProperty(accessor, 'value', { get: () => true, enumerable: true }) for (const value of [sparseWithExtra, symbolArray, symbolObject, hidden, accessor]) { await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'passthrough', args: { value }, }), 'gateway/input-invalid') } @@ -896,6 +941,7 @@ describe('TypertGatewayService', () => { registerStrict(ctx, [createDescriptor()]) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'ship' } }, @@ -906,6 +952,7 @@ describe('TypertGatewayService', () => { const ctx = await setupGateway() await ctx.plugin(WrongBindingService) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'wrong-binding', method: 'run', args: { value: 'ship' }, @@ -914,6 +961,7 @@ describe('TypertGatewayService', () => { await ctx.plugin(GoalService) registerStrict(ctx, [{ ...passthroughDescriptor(), method: 'missing' }]) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'missing', args: { value: 'ship' }, @@ -931,6 +979,7 @@ describe('TypertGatewayService', () => { method: 'run', }]) await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'no-binding', method: 'run', args: { value: 'ship' }, }), 'gateway/binding-invalid') @@ -954,6 +1003,7 @@ describe('TypertGatewayService', () => { }], }) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'plain', method: 'run', args: { value: 'land' }, })).resolves.toBe('land') }) @@ -968,6 +1018,7 @@ describe('TypertGatewayService', () => { }) try { await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'missing-method', method: 'run', args: { value: 'ship' }, }), 'gateway/method-unavailable') } finally { @@ -981,6 +1032,7 @@ describe('TypertGatewayService', () => { service.businessError = failure await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'fail', args: { request: { reason: 'fixture' } }, @@ -990,6 +1042,7 @@ describe('TypertGatewayService', () => { it('reports an absent endpoint without retaining receiver state', async () => { const { ctx } = await setup() await expectCode(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'absent', args: {}, @@ -999,6 +1052,7 @@ describe('TypertGatewayService', () => { it('mounts a shared /api interceptor through an optional Connection and returns existing RPC results', async () => { const ctx = new Context().extend({ fixtureScope: 'rpc-caller' }) await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(FakeConnectionService) const gatewayFiber = ctx.plugin(TypertGatewayService) await gatewayFiber @@ -1031,11 +1085,12 @@ describe('TypertGatewayService', () => { abort.abort(new Error('client disconnected')) expect(service.lastSignal?.aborted).toBe(true) await expect(ctx.typertGateway.invoke({ + peer: ownerPeer(ctx), namespace: 'goals', method: 'create', args: { agentId: 'agent-1', request: { title: 'direct' } }, })).resolves.toMatchObject({ title: 'direct' }) - expect(service.lastPeer).toBe(connection.operator) + expect(service.lastPeer).toBe(connection.browserPeer) const invalid = await handler('goals/create', { invalid: true }, signal) expect(invalid).toMatchObject({ ok: false, @@ -1207,6 +1262,7 @@ describe('TypertGatewayService', () => { it('claims and validates in-process Remote event results for the active Client generation', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(FakeConnectionService) await ctx.plugin(TypertGatewayService) const connection = rawConnection(ctx) @@ -1229,7 +1285,9 @@ describe('TypertGatewayService', () => { }) })(), { home: '/home/fixture' }) const carrier = new AbortController() - const events = rawGatewayEventHarness(ctx).openRemoteEvents({ args: {} }, carrier.signal) + const events = (await ctx.typertGateway.wireStream.open( + '$events', { args: {} }, (async function* () {})(), ownerPeer(ctx), carrier.signal, + ))[Symbol.asyncIterator]() const opening = await events.next() expect(opening).toMatchObject({ done: false, @@ -1252,7 +1310,7 @@ describe('TypertGatewayService', () => { value: undefined, }) - await events.return(undefined) + await events.return?.(undefined) await unregister() await ctx.fiber.dispose() }) @@ -1260,6 +1318,7 @@ describe('TypertGatewayService', () => { it('preserves a lookup policy rejection through the Connection RPC result', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(FakeConnectionService) await ctx.plugin(TypertGatewayService) await ctx.plugin(GoalService) @@ -1284,6 +1343,7 @@ describe('TypertGatewayService', () => { it('caches SRC ownership until the Cordis Service set changes', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(FakeConnectionService) await ctx.plugin(TypertGatewayService) const observedFiber = ctx.plugin(ObservedClaimService) @@ -1319,6 +1379,7 @@ describe('TypertGatewayService', () => { const connectionFiber = ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) await connectionFiber await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) const gatewayFiber = ctx.plugin(TypertGatewayService) await gatewayFiber const goalFiber = ctx.plugin(GoalService) @@ -1428,6 +1489,7 @@ async function setup(): Promise<{ async function setupGateway(): Promise { const ctx = new Context() await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGatewayService) return ctx } @@ -1444,17 +1506,6 @@ function rawConnection(ctx: Context): FakeConnectionService { return receiver[symbols.original] ?? receiver } -interface GatewayEventHarness { - openRemoteEvents(payload: unknown, signal: AbortSignal): AsyncGenerator -} - -function rawGatewayEventHarness(ctx: Context): GatewayEventHarness { - const receiver = ctx.get('typertGateway') as unknown as GatewayEventHarness & { - [symbols.original]?: GatewayEventHarness - } - return receiver[symbols.original] ?? receiver -} - function registerStrict(ctx: Context, descriptors: readonly InvocationDescriptor[]): () => Promise { return ctx.typert.register({ package: '@fixture/gateway', diff --git a/packages/api/gateway/tests/strict-source.host.spec.ts b/packages/api/gateway/tests/strict-source.host.spec.ts index 2bd9c35a17..fcf4988c12 100644 --- a/packages/api/gateway/tests/strict-source.host.spec.ts +++ b/packages/api/gateway/tests/strict-source.host.spec.ts @@ -3,7 +3,9 @@ import { afterEach, expect, it } from 'vitest' import { z } from 'zod' import { bindTypertRemote, Remote } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import TypertGateway from '../src/index.ts' +import { ownerPeer } from './peer.ts' const contexts: Context[] = [] afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) }) @@ -39,6 +41,7 @@ async function setup() { const ctx = new Context() contexts.push(ctx) await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGateway) const service = new ItemsService(ctx) return { ctx, service } @@ -47,11 +50,11 @@ async function setup() { it('validates source dispatch with the ordinary local Registry before invoking the Service', async () => { const { ctx, service } = await setup() ctx.typert.register(contribution) - await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } } })) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } }, peer: ownerPeer(ctx) })) .resolves.toBe('ready') for (const args of [{}, { request: { title: 'ready', extra: true } }, { request: { title: 1 } }]) { const code: unknown = expect.stringMatching(/^gateway\/(arguments|input)-invalid$/) - await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args })) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args, peer: ownerPeer(ctx) })) .rejects.toMatchObject({ code }) } expect(service.calls).toBe(1) @@ -59,7 +62,7 @@ it('validates source dispatch with the ordinary local Registry before invoking t it('allows limited source fallback without recovering erased object constraints', async () => { const { ctx, service } = await setup() - await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready', extra: true } } })) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready', extra: true } }, peer: ownerPeer(ctx) })) .resolves.toBe('ready') expect(service.calls).toBe(1) }) @@ -71,7 +74,7 @@ it('refuses source fallback after the strict definition owner unloads', async () expect(ctx.typert.local.get('items/read')).toBe(contribution.invocations[0]) await owner.dispose() expect(ctx.typert.local.get('items/read')).toBeUndefined() - await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } } })) + await expect(ctx.typertGateway.invoke({ namespace: 'items', method: 'read', args: { request: { title: 'ready' } }, peer: ownerPeer(ctx) })) .rejects.toMatchObject({ code: 'gateway/definition-unavailable' }) expect(service.calls).toBe(0) }) diff --git a/packages/api/gateway/tsconfig.host.json b/packages/api/gateway/tsconfig.host.json index 5cb68a7183..7694cea7cb 100644 --- a/packages/api/gateway/tsconfig.host.json +++ b/packages/api/gateway/tsconfig.host.json @@ -6,6 +6,7 @@ "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" }, "files": [ + "src/access.ts", "src/index.ts", "src/remote-error-codes.ts", "src/stream-protocol.ts", @@ -28,6 +29,9 @@ { "path": "../../client/connection/tsconfig.host.json" }, + { + "path": "../remote-access/tsconfig.host.json" + }, { "path": "../../host/webserver" }, diff --git a/packages/api/remote-access/README.i18n.yaml b/packages/api/remote-access/README.i18n.yaml new file mode 100644 index 0000000000..6a423304bb --- /dev/null +++ b/packages/api/remote-access/README.i18n.yaml @@ -0,0 +1,34 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/remote-access/README.md +/: + en: bbd88553f12d4f3d + zh: c5fa4ac3f9746170 +/deepseek-ai-dsh-api-remote-access: + en: 15713a51f827c9c1 + zh: a1349a5f83efc82c +/deepseek-ai-dsh-api-remote-access/summary: + en: ee81bdee09f42c9b + zh: 41c6864e59c24519 +/deepseek-ai-dsh-api-remote-access/table-of-contents: + en: a18ecc232e3c3933 + zh: b8e43580403f6d77 +/deepseek-ai-dsh-api-remote-access/host-service-remoteaccessservice-ctx-key-remoteaccess: + en: 42dd46f104e790f2 + zh: c15b37ecda2a1f0d +/deepseek-ai-dsh-api-remote-access/client-face: + en: 1db01235b6af84fa + zh: c0ced511cc1841a0 +/deepseek-ai-dsh-api-remote-access/model-experience: + en: 8b07250cecacd3aa + zh: d28ab93980bdcdad +/deepseek-ai-dsh-api-remote-access/model-experience/kv-cache-effect: + en: 60835eca1cee4e0e + zh: 3b5579ceccd5b5e7 +/deepseek-ai-dsh-api-remote-access/known-limitations-and-deferred-work: + en: 66b483bf5144ac46 + zh: 84bde5e0b1af270b +/deepseek-ai-dsh-api-remote-access/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: e88aeb617ffb74ea diff --git a/packages/api/remote-access/README.md b/packages/api/remote-access/README.md new file mode 100644 index 0000000000..5ccfb9e3f4 --- /dev/null +++ b/packages/api/remote-access/README.md @@ -0,0 +1,97 @@ +--- +description: "Method-level Remote access control for non-owner Peers: role-based policy decisions, list filtering, and the per-Peer access snapshot." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-remote-access + +English | [中文](README.zh.md) + +## Summary + +Decides which Remote methods a non-owner Peer may call and which forwarded events it may receive. Business packages define roles, each an action set over one resource kind, and register one policy per non-owner principal kind that binds roles to resources. The [API Gateway](../gateway/README.md) resolves the resources a call or event touches and asks this package before it resolves lookups or decodes arguments. List methods filter items through `allows()`, and each Peer follows its own access snapshot, so the Client can hide entries the Host would refuse. The [Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md) owns the design. + +## Table of Contents + +- [Host service: `RemoteAccessService` (ctx key: `remoteAccess`)](#host-service) +- [Client face](#client-face) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Host service: `RemoteAccessService` (ctx key: `remoteAccess`) + +Every composition that mounts [API Gateway](../gateway/README.md) mounts this service: the Gateway injects `remoteAccess` and does not start without it. [`bundle/base`](../../bundle/base/cordis.patch.yml) loads it beside `typert-gateway`. The owner (`{ kind: 'owner' }`) is never checked; every method below concerns non-owner principals. + +| Member | Contract | +|---|---| +| `registerPolicy(kind, policy)` | Registers the `RemoteAccessPolicy` of one non-owner principal kind; its `bindings()` and `explain()` receive principals typed by that kind. A second policy for the same kind throws. The registration is a Cordis effect of the caller's fiber; registration and removal recompute open snapshots. | +| `invalidate(principal?)` | Reports that bindings or condition answers for one principal, or for every principal, may have changed. A business package calls it on every grant, revocation, mode change, and expiry. | +| `decide(request)` | Gateway-only. `request` is `{ principal, action, resources, args? }`: the caller or recipient, the endpoint `ns/method` or `event:`, the valid resources the Gateway resolved, and a call's wire arguments. Returns `{ kind: 'allow' }` or `{ kind: 'deny', reason }`; the Gateway throws `remote-access/denied` with details `{ action, reason }` for a denied call and drops a denied event without building an error. The owner and `remoteAccess/follow` are always allowed. | +| `allows(resource)` | Reads the invocation of the Context that accessed the service, so a list method calls `this.ctx.remoteAccess.allows(resource)` for each item. Decides the call's endpoint action on that resource without arguments, reading the bindings again on every check. True for the owner and outside a Remote call. | +| `snapshotFor(principal)` | The baseline a page opens with: `{ type: 'baseline', owner: true }` for the owner, otherwise the principal's current grants. | +| `follow(signal)` | Remote stream `remoteAccess/follow`: one `baseline`, then `upsert` and `remove` frames. The owner receives only `{ type: 'baseline', owner: true }`. The stream ends when `signal` aborts or the service unloads, so a reloaded service starts every reader from a fresh baseline. | + +A role `{ name, resource, actions }` is a plain value naming an action set over one resource kind, `global` or a kind declared in `TypertResourceMap`. A binding `{ role, resource, condition? }` grants the role on one resource of the role's kind. A policy answers `bindings(principal)` and an optional `explain(request)` synchronously from state it holds in memory. A request is allowed only when, on every resolved resource, some binding names that resource, its role lists the action, and it has no condition or its condition allows. Resources are independent: a grant on `global` implies nothing on any other resource, and no resource implies another. A condition receives `{ action, resource, args? }` and runs only for a binding whose resource and role match; `args` is absent for events, snapshots, and list filtering. `bindings()` runs once per `decide()`, once per snapshot computation, and once per Remote call that filters through `allows()` and again after each `invalidate()` of its principal or policy registration or removal; it is never cached across decisions, so time-dependent state applies to the next call. + +The Gateway resolves resources from `@Remote` and `@RemoteScope` `resources` readers, lookup and Host Context adapter `resource` declarations, and the default fields in `typert.resources`, and the [API Gateway](../gateway/README.md) owns that order; this package reads no argument field. A call that names no resource touches the `global` resource alone, so only a `global` role can allow it; putting an action in a `global` role lets the principal call it on any resource the call does not name. The Gateway refuses a call whose resources are invalid with `resource-invalid` without asking `decide()`. + +When `bindings()`, a condition, or `explain()` throws, the decision is refused with `policy-failed`; in a snapshot, a throwing `bindings()` leaves the principal no grant and a throwing condition excludes its action. A faulty rule therefore never breaks the shared `$events` source, the snapshot stream, or page rendering. The service logger reports the first failure of each policy after each `invalidate()` and each policy registration or removal; later failures of that policy stay silent until then. + +A forwarded event is decided as the action `event:` on the resources its forwarding entry's readers name, or on `global` when the entry declares none, and reaches a non-owner whole or not at all; its payload is never trimmed. An event carries every resource it declares, so a reader that returns `undefined` makes the event's resources invalid, and the Gateway delivers it to no non-owner without asking `decide()`. An event names at most one resource, so collection changes travel on follow streams (`session/follow`, `session/control`, `workspace/follow`), whose methods decide each item inline through `allows()`. + +The service adds a `global` row named `REMOTE_ACCESS_SNAPSHOT_KEY` (`__DSH_REMOTE_ACCESS__`) holding the owner baseline `{ type: 'baseline', owner: true }` to every index the webServer renders through `webserver/index-inject`. A business package that renders a non-owner page, such as a liveshare viewer page rendered on the owner Host, places an assignment of that global to `ctx.remoteAccess.snapshotFor(principal)` after every index injection row, for example a `global` row appended to the end of `ctx.webServer.collectIndexInjections()`. The later assignment replaces the owner baseline, and the Client entry reads the global when it mounts, so the page opens with the viewer's grants instead of the owner's. + +| `details.reason` of `remote-access/denied` | Cause | +|---|---| +| `resource-invalid` | Given by the Gateway before `decide()`: a resource reader threw or returned an empty list, or a resource id, including a Context identity, is not a non-empty string. | +| `no-allow` | The principal kind has no policy, or no binding allows the action on some resource and neither a condition nor `explain()` names a reason. | +| `policy-failed` | `bindings()`, a condition, or `explain()` threw; the failure is logged as described above. | +| condition or `explain()` reason | On the first resource no binding allows, the first refusing condition of a matching binding names the reason; without one, `explain(request)` does. | + +Snapshot grants list, per resource the principal's bindings name, the sorted endpoint actions of those bindings' roles that `decide()` allows there without call arguments, so the snapshot agrees with `decide()` for the same principal, action, and resource; a resource with no such action gets no grant, and the stream pushes `remove` once its allowed set becomes empty. `event:` actions never enter the snapshot. Snapshots are recomputed only after `invalidate()` and policy registration or removal. + + +## Client face + +The Client entry reads the baseline the index injected under `REMOTE_ACCESS_SNAPSHOT_KEY` synchronously, mounts the generated `remoteAccess` contribution itself through `mountAccess(ctx, contribution, initial)`, follows the snapshot stream, and provides: + +- the Client service `ctx.remoteAccess.source(kind, id, action)`, an identity-stable boolean source per `(kind, id, action)` for one resource of a declared kind; +- the standard Hook `useRemoteAccess(action)`, which answers on the `global` resource. + +Domain UI packages publish their resource Hooks over `source()`: [ui-session](../../client/ui-session/README.md) provides `useSessionAccess(sessionId, action)` and the Session-scope `useAccess(action)` on Session resources, a different name from the `global` `useRemoteAccess`, and [ui-workspace](../../client/ui-workspace/README.md) provides `useWorkspaceAccess(workspaceId, action)` on Workspace resources. A Hook's `action` is a `RemoteEndpoint`, an endpoint some Client Remote namespace declares. A non-owner answer is whether that resource's grant lists the action and never falls back to the `global` answer, so a resource without a grant, or whose grant lists no action, answers false. Every answer is true for the owner; a non-owner page without an injected baseline, and a non-owner page whose stream fails terminally, answers false; the terminal failure is reported with its error, for a non-owner with the advice to reload the page and for the owner with the note that its answers are unaffected; a reconnect keeps the last snapshot. A malformed injected baseline fails the mount. Hooks never issue requests, and the Host still checks every call. [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md) replaces this row with allow-all access in Client specs. + + +## Model Experience + +None, as this package decides Remote access for Client callers and registers nothing model-facing. + +#### KV Cache effect + +No direct effect. + +## Known Limitations and Deferred Work + + + +- Snapshot and list-filtering decisions carry no call arguments, so a condition that refines its answer from `args` can refuse a call that the snapshot shows as allowed. +- Resource readers and automatic resolution must cover every resource a method touches; a resource the call does not name is not checked, so a principal granted on the named resources reaches it. +- Default resource resolution matches wire field names; adding a field with a registered kind's name to a method adds that kind's resource to the call. +- Exact Connection Fetch routes do not pass through the Gateway; the shared Fetch handler refuses them to every non-owner Peer with 403 until each route declares its own access rule. +- Interaction participation (answering forwarded waterfalls) is owner-only: non-owner `$events` streams receive only the broadcast frames whose `event:` action the policy allows on the event's resources. +- `api-session/added` carries the whole `SessionSummary`, so a non-owner allowed to receive it also learns its `parentSessionId`. +- A granted Session's cwd and tool paths already contain the owner's absolute paths, so the `$events` ready frame gives a non-owner the full `host`, including `home`; the Gateway does not hide `home` separately. +- `workspace/follow` notices a Workspace whose visibility a policy revoked only when that Workspace or the Workspace order next changes, and then replaces the non-owner's baseline; an access change alone sends no frame. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/api/remote-access/README.zh.md b/packages/api/remote-access/README.zh.md new file mode 100644 index 0000000000..c14033e993 --- /dev/null +++ b/packages/api/remote-access/README.zh.md @@ -0,0 +1,97 @@ +--- +description: "非 owner Peer 的 Remote 方法级访问控制:基于角色的 policy 判定、列表过滤与每个 Peer 的访问快照。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-api-remote-access + +[English](README.md) | 中文 + +## 概述 + +决定非 owner Peer 可以调用哪些 Remote 方法、可以收到哪些转发事件。业务包定义 role(限定一种资源种类的 action 集合),并为每种非 owner principal kind 注册一个 policy,把 role 绑定到资源上。[API Gateway](../gateway/README.zh.md) 解析一次调用或一个事件访问的资源,并在解析 lookup、解码参数之前询问本包。列表方法经 `allows()` 逐项过滤;每个 Peer 订阅自己的访问快照,Client 据此隐藏 Host 会拒绝的入口。设计由 [Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md) 负责。 + +## 目录 + +- [Host 服务:`RemoteAccessService`(ctx key:`remoteAccess`)](#host-service) +- [Client 面](#client-face) +- [模型体验](#model-experience) +- [已知限制与暂缓事项](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## Host 服务:`RemoteAccessService`(ctx key:`remoteAccess`) + +挂载 [API Gateway](../gateway/README.zh.md) 的组合都要挂载本服务:Gateway 注入 `remoteAccess`,没有它就不启动。[`bundle/base`](../../bundle/base/cordis.patch.yml) 紧随 `typert-gateway` 装配它。owner(`{ kind: 'owner' }`)从不受检查;下列成员都只针对非 owner principal。 + +| 成员 | 契约 | +|---|---| +| `registerPolicy(kind, policy)` | 为一种非 owner principal kind 注册 `RemoteAccessPolicy`;其 `bindings()` 与 `explain()` 收到的 principal 按该 kind 定型。同一 kind 再注册一个 policy 即抛错。登记是调用方 fiber 的 Cordis effect;注册与撤回都会重新计算已打开的快照。 | +| `invalidate(principal?)` | 报告某个 principal(省略时为所有 principal)的 binding 或 condition 结论可能已变化。业务包在每次授权、撤权、模式切换与过期时调用。 | +| `decide(request)` | 仅供 Gateway 使用。`request` 为 `{ principal, action, resources, args? }`:调用方或接收方、端点 `ns/method` 或 `event:`、Gateway 解析出的合法资源,以及调用的 wire 参数。返回 `{ kind: 'allow' }` 或 `{ kind: 'deny', reason }`;调用被拒时由 Gateway 抛出 details 为 `{ action, reason }` 的 `remote-access/denied`,事件被拒时由 Gateway 直接丢弃、不构造错误。owner 与 `remoteAccess/follow` 恒放行。 | +| `allows(resource)` | 读访问本服务的 Context 上的 invocation,因此列表方法对每一项调用 `this.ctx.remoteAccess.allows(resource)`。不带参数判定该调用的端点 action 在该资源上是否被允许,每次判定都重新读取 binding。owner 与 Remote 调用之外恒为 true。 | +| `snapshotFor(principal)` | 页面打开时的 baseline:owner 为 `{ type: 'baseline', owner: true }`,否则为该 principal 当前的 grant。 | +| `follow(signal)` | Remote stream `remoteAccess/follow`:一个 `baseline`,之后是 `upsert` 与 `remove` 帧。owner 只收到 `{ type: 'baseline', owner: true }`。`signal` abort 或服务卸载时流结束,因此重载后的服务从新的 baseline 回答每个读者。 | + +role `{ name, resource, actions }` 是普通值,表示限定一种资源种类(`global` 或在 `TypertResourceMap` 中声明的种类)的 action 集合。binding `{ role, resource, condition? }` 在该 role 种类的一个资源上授予该 role。policy 根据预先载入内存的状态同步回答 `bindings(principal)` 与可选的 `explain(request)`。只有在每个已解析资源上都有某个 binding 指向该资源、其 role 列出该 action、且没有 condition 或 condition 放行时,请求才被放行。资源之间相互独立:`global` 上的授权不蕴含任何其他资源上的授权,任何资源也不蕴含另一个资源。condition 收到 `{ action, resource, args? }`,只对资源与 role 都匹配的 binding 调用;事件、快照与列表过滤时没有 `args`。`bindings()` 在每次 `decide()`、每次快照计算、每次经 `allows()` 过滤的 Remote 调用中各调用一次,且在其 principal 每次被 `invalidate()` 或 policy 注册、撤回后再调用一次,不跨判定缓存,因此依赖时间的状态对下一次调用即时生效。 + +Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lookup 与 Host Context adapter 的 `resource` 声明、`typert.resources` 中的默认字段解析资源,解析顺序由 [API Gateway](../gateway/README.zh.md) 负责;本包不读取任何参数字段。不指明任何资源的调用只访问 `global` 资源,因此只有 `global` 级 role 能放行;把某个 action 放进 `global` 级 role,等于允许该 principal 在调用未指明的任何资源上调用它。资源非法的调用由 Gateway 以 `resource-invalid` 拒绝,不询问 `decide()`。 + +`bindings()`、condition 或 `explain()` 抛错时,本次判定以 `policy-failed` 拒绝;计算快照时,`bindings()` 抛错即该 principal 没有 grant,condition 抛错即该 action 不放行。有问题的规则因此不会破坏共享的 `$events` 事件源、快照流或页面渲染。每次 `invalidate()` 以及每次 policy 注册或撤回之后,服务日志对每个 policy 只报告第一次失败;此后该 policy 的失败不再记录,直到下一次上述变化。 + +转发事件按其转发条目的读取函数指明的资源(条目未声明时为 `global`)判定 action `event:`,对非 owner 整条送达或整条不送,从不裁剪 payload。事件携带其声明的每个资源,因此读取函数返回 `undefined` 时该事件的资源非法,Gateway 不询问 `decide()`,不送达任何非 owner。一个事件至多指明一个资源,因此集合类变化走 follow 类流(`session/follow`、`session/control`、`workspace/follow`),由这些方法内联经 `allows()` 逐项判定。 + +本服务经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入名为 `REMOTE_ACCESS_SNAPSHOT_KEY`(`__DSH_REMOTE_ACCESS__`)的 `global` 行,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如在 owner Host 上渲染 liveshare 读者页)在所有 index 注入行之后放置一条把该全局赋值为 `ctx.remoteAccess.snapshotFor(principal)` 的语句,例如追加到 `ctx.webServer.collectIndexInjections()` 末尾的一条 `global` 行。后写的赋值覆盖 owner baseline,Client 入口在挂载时读取该全局,因此页面以读者的 grant 而非 owner 的快照打开。 + +| `remote-access/denied` 的 `details.reason` | 原因 | +|---|---| +| `resource-invalid` | 由 Gateway 在 `decide()` 之前给出:资源读取函数抛错或返回空列表,或某个资源 id(含 Context 身份)不是非空字符串。 | +| `no-allow` | 该 principal kind 没有 policy,或在某个资源上没有 binding 放行该 action,且 condition 与 `explain()` 都没有给出原因。 | +| `policy-failed` | `bindings()`、condition 或 `explain()` 抛错;该失败按上文规则记录日志。 | +| condition 或 `explain()` 的原因 | 在第一个没有 binding 放行的资源上,取匹配 binding 中第一个拒绝的 condition 的原因;没有时取 `explain(request)` 的返回值。 | + +快照 grant 按该 principal 的 binding 所指的每个资源,列出这些 binding 的 role 中、经 `decide()` 在该资源上不带调用参数放行的端点 action(已排序),因此在同一 principal、action 与资源上快照与 `decide()` 一致;没有此类 action 的资源不出 grant,放行集合变空时推送 `remove`。`event:` action 不进入快照。快照只在 `invalidate()` 与 policy 注册或撤回之后重算。 + + +## Client 面 + +Client 入口同步读取 index 注入在 `REMOTE_ACCESS_SNAPSHOT_KEY` 下的 baseline,经 `mountAccess(ctx, contribution, initial)` 自行挂载生成的 `remoteAccess` contribution,订阅快照流,并提供: + +- Client 服务 `ctx.remoteAccess.source(kind, id, action)`,为已声明种类的某个资源上的每个 `(kind, id, action)` 提供一个身份稳定的布尔 source; +- 标准 Hook `useRemoteAccess(action)`,回答 `global` 资源上的访问。 + +领域 UI 包在 `source()` 之上发布各自资源的 Hook:[ui-session](../../client/ui-session/README.zh.md) 提供 Session 资源上的 `useSessionAccess(sessionId, action)` 与会话作用域的 `useAccess(action)`(与 `global` 资源上的 `useRemoteAccess` 不同名),[ui-workspace](../../client/ui-workspace/README.zh.md) 提供 Workspace 资源上的 `useWorkspaceAccess(workspaceId, action)`。Hook 的 `action` 类型为 `RemoteEndpoint`,即某个 Client Remote namespace 声明的端点。非 owner 的答案是该资源的 grant 是否列出该 action,不回退 `global` 答案,因此没有 grant 或 grant 未列出任何 action 的资源回答 false。owner 的所有答案为 true;没有注入 baseline 的非 owner 页面、快照流终止失败的非 owner 页面,所有答案为 false;终止失败会连同错误一并报告,非 owner 附“刷新页面重连”的提示,owner 附“答案不受影响”的说明;重连期间保留上次快照。注入的 baseline 格式错误时挂载失败。Hook 从不发请求,Host 仍检查每次调用。[`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 在 Client spec 中以全允许的访问替换本行。 + + +## 模型体验 + +无:本包为 Client 调用方判别 Remote 访问,不注册任何面向模型的内容。 + +#### KV Cache 影响 + +无直接影响。 + +## 已知限制与暂缓事项 + + + +- 快照与列表过滤的判定不带调用参数,因此按 `args` 细化结论的 condition 可能拒绝快照显示为允许的调用。 +- 资源读取函数与自动解析必须覆盖方法访问的全部资源;调用未指明的资源不受检查,持有已指明资源授权的 principal 可以访问它。 +- 默认资源解析按 wire 字段名匹配;给方法新增与已登记种类同名的字段,会让调用多访问该种类的资源。 +- exact Connection Fetch route 不经过 Gateway;在各路由声明自己的访问规则之前,共享 Fetch handler 对所有非 owner Peer 返回 403。 +- 交互参与(回答转发的 waterfall)仅限 owner:非 owner 的 `$events` 流只收到 policy 在事件资源上放行其 `event:` action 的广播帧。 +- `api-session/added` 携带完整的 `SessionSummary`,因此被允许接收它的非 owner 也会得知其 `parentSessionId`。 +- 被授权 Session 的 cwd 与工具路径本就包含 owner 的绝对路径,因此 `$events` ready 帧向非 owner 提供完整的 `host`(含 `home`);Gateway 不单独隐藏 `home`。 +- policy 撤销某个 Workspace 的可见性后,`workspace/follow` 只在该 Workspace 或 Workspace 顺序下一次变化时察觉并重推非 owner 的 baseline;仅访问变化不会发送帧。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/api/remote-access/package.json b/packages/api/remote-access/package.json new file mode 100644 index 0000000000..74d0b741db --- /dev/null +++ b/packages/api/remote-access/package.json @@ -0,0 +1,83 @@ +{ + "name": "@deepseek-ai/dsh-api-remote-access", + "description": "Method-level Remote access control for non-owner Peers and the per-Peer access snapshot", + "version": "0.2.1-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/remote-access" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-ui-renderer" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "zod": "4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-host-webserver": "workspace:*", + "@deepseek-ai/dsh-typert-registry": "workspace:*" + } +} diff --git a/packages/api/remote-access/src/client/index.ts b/packages/api/remote-access/src/client/index.ts new file mode 100644 index 0000000000..396f56a16a --- /dev/null +++ b/packages/api/remote-access/src/client/index.ts @@ -0,0 +1,33 @@ +/** + * Client face: starts from the access snapshot the index injected, follows + * the calling Peer's snapshot stream, provides the `remoteAccess` Client + * service whose `source(kind, id, action)` answers for any declared-kind + * resource, and publishes `useRemoteAccess(action)` on the global resource. + * Domain UI adapters publish their own resource Hooks over `source()`. Every + * answer is a boolean: true for the owner, false without a snapshot or after + * a non-owner stream fails; the Host enforces every call regardless. + * @module @deepseek-ai/dsh-api-remote-access/client + */ + +import type { Context } from '@deepseek-ai/cordis' +import remoteAccessRemote from '@deepseek-ai/dsh-api-remote-access/remote' +import { REMOTE_ACCESS_SNAPSHOT_KEY } from '../snapshot.ts' +import { mountAccess } from './mount.ts' +import { parseInjectedBaseline } from './state.ts' + +export type { + RemoteAccessAction, RemoteAccessEndpoint, RemoteAccessFrame, RemoteAccessGrant, RemoteAccessResource, TypertResourceKind, +} from '../types.ts' +export type { ClientRemoteAccess, RemoteEndpoint } from './mount.ts' + +/** Required Client services: the Remote mount face and the Slot registry. */ +export const inject = ['remote', 'slots'] + +/** + * Mount the generated `remoteAccess` namespace and publish the access Hooks. + * @param ctx - Client root Context. + * @returns disposer that withdraws the namespace after the stream stops. + */ +export function apply(ctx: Context): Promise<() => Promise> { + return mountAccess(ctx, remoteAccessRemote, parseInjectedBaseline(Reflect.get(globalThis, REMOTE_ACCESS_SNAPSHOT_KEY))) +} diff --git a/packages/api/remote-access/src/client/mount.ts b/packages/api/remote-access/src/client/mount.ts new file mode 100644 index 0000000000..257aa396c2 --- /dev/null +++ b/packages/api/remote-access/src/client/mount.ts @@ -0,0 +1,132 @@ +/** + * Client access mount: follows the calling Peer's access snapshot, provides + * the `remoteAccess` Client service, and publishes the global-resource `useRemoteAccess` Hook. + * The entry module supplies the generated `remoteAccess` contribution, so + * specs mount a fake one. + * @module @deepseek-ai/dsh-api-remote-access/src/client/mount + */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import { + RemoteSnapshotStream, + RemoteStreamCarrierError, + type ClientRemote, +} from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-remote-access/remote' +import type { HostObservable, RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: the SlotRegistry (`ctx.slots`) merge. +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type { TypertRemoteContribution, TypertRemoteMap, TypertResourceKind } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteAccessFrame } from '../types.ts' +import { ClientAccessState, type Baseline } from './state.ts' + +/** Every endpoint the Client Remote namespaces declare. */ +export type RemoteEndpoint = Extract + +/** Client access answers over the calling Peer's snapshot; it never issues a request. */ +export interface ClientRemoteAccess { + /** + * Whether the current Peer may call `action` on one resource. + * True for the owner; false without a baseline and after a non-owner + * stream fails; otherwise whether the resource's grant lists the action. + * @param kind - resource kind declared in `TypertResourceMap`. + * @param id - resource identity. + * @param action - endpoint action the caller would invoke. + * @returns identity-stable boolean source for this `(kind, id, action)`. + */ + source(kind: TypertResourceKind, id: string, action: string): HostObservable +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Client access answers for the calling Peer. */ + remoteAccess: ClientRemoteAccess + } +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface GlobalStandardProps { + /** Whether the current Peer may call `action` on the global resource. */ + useRemoteAccess: (action: RemoteEndpoint) => boolean + } +} + +type IncrementFrame = Exclude + +/** The `remoteAccess` Client service over one snapshot owner. */ +class ClientRemoteAccessService extends Service implements ClientRemoteAccess { + /** + * @param ctx - Client Context the service is provided on. + * @param state - snapshot owner answering every source. + */ + constructor(ctx: Context, private readonly state: ClientAccessState) { + super(ctx, 'remoteAccess') + } + + source(kind: TypertResourceKind, id: string, action: string): HostObservable { + return this.state.resource(kind, id, action) + } +} + +/** + * Mount the `remoteAccess` namespace, follow the access snapshot, provide the + * `remoteAccess` Client service, and publish the global-resource `useRemoteAccess` Hook. + * Answers start from the page's injected baseline, or false without one, + * and follow the stream's frames from then on. + * @param ctx - Client root Context injecting `remote` and `slots`. + * @param contribution - generated `remoteAccess` Remote contribution. + * @param initial - the page's injected opening baseline, read before the stream connects. + * @returns disposer that withdraws the namespace after the stream stops. + */ +export async function mountAccess( + ctx: Context, + contribution: TypertRemoteContribution, + initial?: Baseline, +): Promise<() => Promise> { + const state = new ClientAccessState(initial) + new ClientRemoteAccessService(ctx, state) + ctx.slots.provideRoot({ + keyedHooks: { remoteAccess: action => state.global(action) }, + } satisfies RootStandardSourceContribution) + const unmount = await ctx.remote.$mount(contribution) + const follow = ctx.inject(['remote.remoteAccess'], (remoteCtx) => { + const control = createAccessStream(remoteCtx.remote, state, remoteCtx.logger) + control.start() + remoteCtx.effect(() => async () => { await control.dispose() }, 'remote-access.client.follow') + }) + return async () => { + await follow.dispose() + await unmount() + } +} + +/** + * Create the reconnecting access snapshot stream. A carrier loss keeps the + * last snapshot while the stream reconnects; a terminal failure keeps the + * owner's answers and turns a non-owner's answers false. + */ +function createAccessStream( + remote: ClientRemote, + state: ClientAccessState, + logger: Context['logger'], +): RemoteSnapshotStream { + const stream = remote.$stream({ + name: 'Remote access stream', + open: signal => remote.remoteAccess.follow(signal), + ended: accepted => accepted + ? new RemoteStreamCarrierError('Remote access stream ended without a terminal result') + : new Error('Remote access stream ended before its opening snapshot'), + }) + return new RemoteSnapshotStream(stream, { + name: 'Remote access stream', + isSnapshot: (frame): frame is Baseline => frame.type === 'baseline', + replace: (frame) => { state.replace(frame) }, + update: (frame) => { state.update(frame) }, + failed: (error) => { + // Only a non-owner's answers turn false after a terminal failure, so only its report names the recovery. + if (state.owner) logger.error('access snapshot stream failed; owner access answers are unaffected', error) + else logger.error('access snapshot stream failed; reload the page to reconnect', error) + state.unavailable() + }, + }) +} diff --git a/packages/api/remote-access/src/client/state.ts b/packages/api/remote-access/src/client/state.ts new file mode 100644 index 0000000000..b0ee02b400 --- /dev/null +++ b/packages/api/remote-access/src/client/state.ts @@ -0,0 +1,208 @@ +/** + * React-free Client access state: the latest `remoteAccess/follow` snapshot + * and identity-stable boolean sources per resource and action. + * @module @deepseek-ai/dsh-api-remote-access/src/client/state + */ + +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' +import type { RemoteAccessFrame, RemoteAccessGrant, RemoteAccessResource } from '../types.ts' + +/** Actions allowed per resource identity, for one declared resource kind. */ +type KindGrants = ReadonlyMap> + +type AccessSnapshot = + /** No baseline, or a non-owner snapshot stream failed: every answer is false. */ + | { readonly kind: 'closed' } + | { readonly kind: 'owner' } + | { + readonly kind: 'granted' + readonly global: ReadonlySet + readonly resources: ReadonlyMap + } + +type GrantedSnapshot = Extract + +const CLOSED: AccessSnapshot = Object.freeze({ kind: 'closed' }) +const OWNER_SNAPSHOT: AccessSnapshot = Object.freeze({ kind: 'owner' }) +const GLOBAL_KEY = '\u0000global' + +/** The opening frame of a snapshot generation. */ +export type Baseline = Extract + +/** + * Validate the opening baseline an index injection row assigned to the page. + * @param value - the injected global; `undefined` when the page carries none. + * @returns the baseline, or `undefined` when none was injected. + * @throws TypeError when the injected value is not a baseline frame. + */ +export function parseInjectedBaseline(value: unknown): Baseline | undefined { + if (value === undefined) return undefined + if (!isRecord(value) || value['type'] !== 'baseline') throw new TypeError('remote access: the injected access snapshot is not a baseline frame') + if (value['owner'] === true) return { type: 'baseline', owner: true } + const grants = value['grants'] + if (value['owner'] !== false || !Array.isArray(grants) || !grants.every(isGrant)) { + throw new TypeError('remote access: the injected access snapshot carries malformed grants') + } + return { type: 'baseline', owner: false, grants } +} + +function isGrant(value: unknown): value is RemoteAccessGrant { + if (!isRecord(value) || !isResource(value['resource'])) return false + const actions = value['actions'] + return Array.isArray(actions) && actions.every(action => typeof action === 'string') +} + +function isResource(value: unknown): value is RemoteAccessResource { + if (!isRecord(value) || typeof value['kind'] !== 'string') return false + return value['kind'] === 'global' || typeof value['id'] === 'string' +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Snapshot owner for one Client generation. Sources are cached per + * `(kind, id, action)` so Hook bindings keep their identity; each source + * computes its boolean from the current snapshot. + */ +export class ClientAccessState { + private snapshot: AccessSnapshot = CLOSED + + /** @param initial - the page's injected opening baseline; without one every answer is false until a baseline arrives. */ + constructor(initial?: Baseline) { + if (initial !== undefined) this.replace(initial) + } + private readonly listeners = new Set<() => void>() + private readonly sources = new Map>() + + /** + * Answer for one action on the global resource. + * @param action - endpoint action the caller may invoke. + * @returns identity-stable boolean source. + */ + global(action: string): HostObservable { + return this.cached(GLOBAL_KEY, '', action) + } + + /** + * Answer for one action on one resource of a declared kind. + * @param kind - declared resource kind. + * @param id - resource identity. + * @param action - endpoint action the caller may invoke. + * @returns identity-stable boolean source. + */ + resource(kind: string, id: string, action: string): HostObservable { + return this.cached(kind, id, action) + } + + /** + * Replace the snapshot from one generation's opening frame. + * @param frame - owner or non-owner baseline. + */ + replace(frame: Baseline): void { + if (frame.owner) { + this.publish(OWNER_SNAPSHOT) + return + } + let global: ReadonlySet = new Set() + const resources = new Map>>() + // The baseline is unpublished, so its maps are filled in place. + for (const { resource, actions } of frame.grants) { + if (resource.kind === 'global') { + global = new Set(actions) + continue + } + let grants = resources.get(resource.kind) + if (grants === undefined) { + grants = new Map() + resources.set(resource.kind, grants) + } + grants.set(resource.id, new Set(actions)) + } + this.publish({ kind: 'granted', global, resources }) + } + + /** + * Apply one increment of the current generation. + * @param frame - `upsert` or `remove`. + */ + update(frame: Exclude): void { + const current = this.snapshot + if (current.kind !== 'granted') return + switch (frame.type) { + case 'upsert': + this.publish(withGrant(current, frame.grant)) + return + case 'remove': + this.publish(withGrant(current, { resource: frame.resource, actions: [] }, true)) + return + /* v8 ignore next -- the generated Remote codec validates this closed union. */ + default: + return assertNever(frame) + } + } + + /** Whether the current snapshot is the owner's, whose answers never depend on the stream. */ + get owner(): boolean { + return this.snapshot.kind === 'owner' + } + + /** The stream failed terminally: the owner keeps every answer; a non-owner's answers become false. */ + unavailable(): void { + if (this.snapshot.kind !== 'owner') this.publish(CLOSED) + } + + private cached(kind: string, id: string, action: string): HostObservable { + const key = `${kind}\u0000${id}\u0000${action}` + let source = this.sources.get(key) + if (source === undefined) { + source = { + getSnapshot: () => answer(this.snapshot, kind, id, action), + subscribe: (listener) => { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + }, + } + this.sources.set(key, source) + } + return source + } + + private publish(next: AccessSnapshot): void { + this.snapshot = next + for (const listener of [...this.listeners]) listener() + } +} + +function answer(snapshot: AccessSnapshot, kind: string, id: string, action: string): boolean { + switch (snapshot.kind) { + case 'closed': + return false + case 'owner': + return true + case 'granted': { + const allowed = kind === GLOBAL_KEY ? snapshot.global : snapshot.resources.get(kind)?.get(id) + return allowed?.has(action) ?? false + } + /* v8 ignore next -- closed union. */ + default: + return assertNever(snapshot) + } +} + +function withGrant(snapshot: GrantedSnapshot, grant: RemoteAccessGrant, remove = false): GrantedSnapshot { + const resource = grant.resource + if (resource.kind === 'global') return { ...snapshot, global: new Set(grant.actions) } + const resources = new Map(snapshot.resources) + const grants = new Map(resources.get(resource.kind)) + if (remove) grants.delete(resource.id) + else grants.set(resource.id, new Set(grant.actions)) + resources.set(resource.kind, grants) + return { ...snapshot, resources } +} + +/* v8 ignore next 3 -- closed-union backstop. */ +function assertNever(value: never): never { + throw new Error(`unreachable access frame: ${JSON.stringify(value)}`) +} diff --git a/packages/api/remote-access/src/index.ts b/packages/api/remote-access/src/index.ts new file mode 100644 index 0000000000..5b3cf41e0f --- /dev/null +++ b/packages/api/remote-access/src/index.ts @@ -0,0 +1,424 @@ +/** + * Host method-level access control. The Gateway asks `decide()` before it + * dispatches a non-owner call or forwards an event to a non-owner Peer; list + * methods filter items through `allows()`; every Peer reads its own access + * snapshot, first from the index and then from `follow()`. Business packages + * register one policy per non-owner principal kind. + * @module @deepseek-ai/dsh-api-remote-access + */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-host-webserver' +import { + Remote, + TypertRemoteService, + principalOf, + type Principal, + type PrincipalMap, + type RemoteAccessResource, +} from '@deepseek-ai/dsh-typert-protocol' +import { REMOTE_ACCESS_SNAPSHOT_KEY } from './snapshot.ts' +import type { + RemoteAccessAction, + RemoteAccessDecision, + RemoteAccessFrame, + RemoteAccessGrant, + RemoteAccessPolicy, + RemoteAccessRequest, +} from './types.ts' + +export type * from './types.ts' +export { REMOTE_ACCESS_SNAPSHOT_KEY } from './snapshot.ts' + +/** The opening frame of a snapshot generation. */ +export type RemoteAccessBaseline = Extract + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host method-level access control for non-owner Peers. */ + remoteAccess: RemoteAccessService + } +} + +/** The snapshot endpoint every Peer may read for itself. */ +const FOLLOW_ACTION: RemoteAccessAction = 'remoteAccess/follow' +const ALLOW: RemoteAccessDecision = Object.freeze({ kind: 'allow' }) +const NO_ALLOW: RemoteAccessDecision = Object.freeze({ kind: 'deny', reason: 'no-allow' }) +const POLICY_FAILED: RemoteAccessDecision = Object.freeze({ kind: 'deny', reason: 'policy-failed' }) +const OWNER: Principal = Object.freeze({ kind: 'owner' }) +const OWNER_BASELINE: RemoteAccessBaseline = Object.freeze({ type: 'baseline', owner: true }) + +/** + * A binding seen without its resource kind. Method syntax keeps the condition + * of every kind assignable; each condition is called only with a resource + * equal to its own binding's resource. + */ +interface ErasedBinding { + readonly role: { readonly actions: readonly RemoteAccessAction[] } + readonly resource: RemoteAccessResource + condition?(request: { + readonly action: RemoteAccessAction + readonly resource: RemoteAccessResource + readonly args?: Readonly> + }): RemoteAccessDecision +} + +/** One registered policy with the principal kind it serves. */ +interface PolicyEntry { + readonly kind: string + readonly policy: RemoteAccessPolicy +} + +/** A condition's refusal. */ +type Refusal = Extract + +/** The answer for one action on one resource, before any `explain()`. */ +type Evaluation = + | { readonly kind: 'allow' } + /** No binding allows; `refusal` is the first refusing condition's decision, if any condition ran. */ + | { readonly kind: 'refuse'; readonly refusal: Refusal | undefined } + | { readonly kind: 'failed' } + +const ALLOWED: Evaluation = Object.freeze({ kind: 'allow' }) +const FAILED: Evaluation = Object.freeze({ kind: 'failed' }) + +/** One open non-owner snapshot generation; changes coalesce until its reader wakes. */ +class AccessFollower { + private dirty = false + private wake: (() => void) | undefined + + /** @param principal - the generation's caller identity; never the owner. */ + constructor(readonly principal: Principal) {} + + /** Mark the generation stale and wake its reader. */ + touch(): void { + this.dirty = true + this.wake?.() + } + + /** + * Wait until the generation is stale or cancelled. + * @param signal - generation cancellation. + * @returns true when stale; false once cancelled. + */ + async changed(signal: AbortSignal): Promise { + const abort = (): void => { this.wake?.() } + signal.addEventListener('abort', abort, { once: true }) + try { + while (!this.dirty && !signal.aborted) { + await new Promise((resolve) => { this.wake = resolve }) + this.wake = undefined + } + } finally { + signal.removeEventListener('abort', abort) + } + this.dirty = false + return !signal.aborted + } +} + +/** + * Registry of access policies, the Gateway's access decisions, list + * filtering, and the per-Peer access snapshot. Every composition that mounts + * the Gateway mounts this service too. + */ +export class RemoteAccessService extends TypertRemoteService { + private readonly policies = new Map() + /** Policies whose failure was logged since the last `invalidate()`; their later failures stay silent. */ + private readonly reported = new Set() + private readonly followers = new Set() + /** Aborted when this instance unloads; every open snapshot generation ends with it. */ + private readonly lifetime = new AbortController() + + /** @param ctx - owning Host plugin Context. */ + constructor(ctx: Context) { + super(ctx, 'remoteAccess') + ctx.effect(() => () => { this.lifetime.abort() }, 'remoteAccess: snapshot generations') + // Every index the webServer renders belongs to the owner; snapshotFor documents non-owner pages. + ctx.inject(['webServer'], (webCtx) => { + webCtx.on('webserver/index-inject', (table) => { + table.push({ kind: 'global', name: REMOTE_ACCESS_SNAPSHOT_KEY, value: OWNER_BASELINE }) + }) + }) + } + + /** + * Register the policy of one non-owner principal kind in the caller's + * fiber; open snapshots are recomputed on registration and removal. + * @param kind - the principal kind the policy serves; never `owner`. + * @param policy - business-owned bindings and refusal reasons for principals of `kind`. + * @returns disposer removing this policy. + * @throws Error when `kind` already has a policy. + */ + registerPolicy>(kind: K, policy: RemoteAccessPolicy): () => void { + const policies = this.policies + if (policies.has(kind)) throw new Error(`remote access: principal kind ${JSON.stringify(kind)} already has a policy`) + const entry: PolicyEntry = { kind, policy } + const dispose = this.ctx.effect(() => { + policies.set(kind, entry) + this.invalidate() + return () => { + policies.delete(kind) + this.invalidate() + } + }, `remoteAccess.registerPolicy(${JSON.stringify(kind)})`) + return () => { void dispose() } + } + + /** + * Report that the bindings or condition answers for `principal` may have + * changed. Business packages call this on every grant, revocation, mode + * change, and expiry; open snapshots re-push, and the next failure of each + * policy is logged again. + * @param principal - affected principal; omitted for every principal. + */ + invalidate(principal?: Principal): void { + this.reported.clear() + const key = principal === undefined ? undefined : canonicalJson(principal) + for (const follower of this.followers) { + if (key === undefined || canonicalJson(follower.principal) === key) follower.touch() + } + } + + /** + * Gateway-only: decide one request whose resources the Gateway resolved. + * The owner is allowed, and so is `remoteAccess/follow`: every Peer reads + * its own snapshot. Otherwise the action is allowed only when, on every + * listed resource, some binding of the principal's policy names that + * resource, has a role listing the action, and has no condition or a + * condition that allows. `bindings()` is called once per decision. + * @param request - the caller or recipient, the action, the resources it + * touches, and a call's arguments. + * @returns `allow`; or `deny` with `no-allow` when the principal kind has + * no policy, the first refusing condition's reason on the first resource + * no binding allows, else `explain()`'s reason, else `no-allow`; + * `policy-failed` when `bindings()`, a condition, or `explain()` throws, + * logged once per policy until the next `invalidate()`. The Gateway + * throws `remote-access/denied` for a denied call and drops a denied event. + */ + decide(request: RemoteAccessRequest): RemoteAccessDecision { + const { principal, action } = request + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (principal.kind === 'owner' || action === FOLLOW_ACTION) return ALLOW + const entry = this.policies.get(principal.kind) + if (entry === undefined) return NO_ALLOW + const bindings = this.bindingsOf(entry, principal, action) + if (bindings === undefined) return POLICY_FAILED + for (const resource of request.resources) { + const evaluation = this.evaluate(entry, bindings, action, resource, request.args) + switch (evaluation.kind) { + case 'allow': + continue + case 'failed': + return POLICY_FAILED + case 'refuse': + return evaluation.refusal ?? this.explain(entry, request) + /* v8 ignore next -- closed union. */ + default: + return assertNever(evaluation) + } + } + return ALLOW + } + + /** + * Filter items inside a Remote method. The invocation is read from the + * Context that accessed this service, so call it as + * `this.ctx.remoteAccess.allows(resource)` from the Remote method. Each + * check reads the policy's bindings again, so a long-running stream sees + * binding changes from the next check on. + * @param resource - resource of one result item. + * @returns true for the owner and outside a Remote call; otherwise whether + * the invocation's endpoint action is allowed on `resource` without call + * arguments, false when the policy is absent or fails. + */ + allows(resource: RemoteAccessResource): boolean { + const invocation = this.ctx.invocation + const principal = principalOf(invocation) + if (invocation === undefined || principal === undefined) return true + const action: RemoteAccessAction = `${invocation.request.namespace}/${invocation.request.method}` + const entry = this.policies.get(principal.kind) + if (entry === undefined) return false + const bindings = this.bindingsOf(entry, principal, action) + return bindings !== undefined && this.evaluate(entry, bindings, action, resource, undefined).kind === 'allow' + } + + /** + * The baseline a page opens with before its snapshot stream connects. A + * business package rendering a non-owner page assigns this value to + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, + * replacing the owner baseline the webServer index carries. + * @param principal - the page's caller identity. + * @returns the owner baseline for the owner; otherwise the principal's current grants. + */ + snapshotFor(principal: Principal): RemoteAccessBaseline { + // oxlint-disable-next-line typescript/no-unnecessary-condition -- other packages merge non-owner kinds into PrincipalMap. + if (principal.kind === 'owner') return OWNER_BASELINE + return { type: 'baseline', owner: false, grants: this.grantsFor(principal) } + } + + /** + * Stream the calling Peer's access snapshot: one baseline, then `upsert` + * and `remove` frames after each `invalidate()` and each policy + * registration or removal that changes a grant. The owner receives only + * `{ owner: true }`. The generation ends when `signal` aborts or this + * service unloads, so a reloaded service starts every reader from a fresh + * baseline. + * @param signal - generation cancellation. + * @returns the access frames of one generation. + */ + @Remote({ mode: 'stream' }) + async *follow(signal: AbortSignal): AsyncIterable { + signal.throwIfAborted() + const generation = AbortSignal.any([signal, this.lifetime.signal]) + const principal = principalOf(this.ctx.invocation) + if (principal === undefined) { + yield this.snapshotFor(OWNER) + await abortion(generation) + return + } + const follower = new AccessFollower(principal) + this.followers.add(follower) + try { + let sent = this.grantsFor(principal) + yield { type: 'baseline', owner: false, grants: sent } + while (await follower.changed(generation)) { + const next = this.grantsFor(principal) + const previous = sent + sent = next + for (const grant of next) { + const before = previous.find(other => sameResource(other.resource, grant.resource)) + if (before === undefined || !sameActions(before.actions, grant.actions)) yield { type: 'upsert', grant } + } + for (const grant of previous) { + if (!next.some(other => sameResource(other.resource, grant.resource))) yield { type: 'remove', resource: grant.resource } + } + } + } finally { + this.followers.delete(follower) + } + } + + /** One `bindings()` call; `undefined` after a logged failure. */ + private bindingsOf(entry: PolicyEntry, principal: Principal, purpose: string): readonly ErasedBinding[] | undefined { + try { + return entry.policy.bindings(principal) + } catch (error) { + // A policy failure is contained to this question, so one faulty rule cannot break a shared event stream. + this.report(entry, `list bindings for ${purpose}`, error) + return undefined + } + } + + /** Find a binding on `resource` whose role lists `action` and whose condition, if any, allows. */ + private evaluate( + entry: PolicyEntry, + bindings: readonly ErasedBinding[], + action: RemoteAccessAction, + resource: RemoteAccessResource, + args: Readonly> | undefined, + ): Evaluation { + let refusal: Refusal | undefined + for (const binding of bindings) { + if (!sameResource(binding.resource, resource) || !binding.role.actions.includes(action)) continue + if (binding.condition === undefined) return ALLOWED + let decision: RemoteAccessDecision + try { + decision = binding.condition(args === undefined ? { action, resource } : { action, resource, args }) + } catch (error) { + this.report(entry, `evaluate a condition for ${action}`, error) + return FAILED + } + if (decision.kind === 'allow') return ALLOWED + refusal ??= decision + } + return { kind: 'refuse', refusal } + } + + /** The refusal reason `explain()` names, else `no-allow`; `policy-failed` when it throws. */ + private explain(entry: PolicyEntry, request: RemoteAccessRequest): RemoteAccessDecision { + let reason: string | undefined + try { + reason = entry.policy.explain?.(request) + } catch (error) { + this.report(entry, `explain a refusal of ${request.action}`, error) + return POLICY_FAILED + } + return reason === undefined ? NO_ALLOW : { kind: 'deny', reason } + } + + /** + * One grant per bound resource, in first-binding order: the endpoint + * actions of the resource's bindings that `decide()` would allow there + * without call arguments. A resource with no such action gets no grant; + * event actions stay out of the snapshot. + */ + private grantsFor(principal: Principal): readonly RemoteAccessGrant[] { + const entry = this.policies.get(principal.kind) + if (entry === undefined) return [] + const bindings = this.bindingsOf(entry, principal, 'a snapshot') + if (bindings === undefined) return [] + const grants: RemoteAccessGrant[] = [] + const seen: RemoteAccessResource[] = [] + for (const { resource } of bindings) { + if (seen.some(other => sameResource(other, resource))) continue + seen.push(resource) + const candidates = new Set() + for (const binding of bindings) { + if (!sameResource(binding.resource, resource)) continue + for (const action of binding.role.actions) { + if (!action.startsWith('event:')) candidates.add(action) + } + } + const actions = [...candidates].filter(action => this.evaluate(entry, bindings, action, resource, undefined).kind === 'allow').sort() + if (actions.length > 0) grants.push(Object.freeze({ resource, actions: Object.freeze(actions) })) + } + return grants + } + + /** Log a policy failure once per policy until the next `invalidate()`; snapshots and lists ask many times per change. */ + private report(entry: PolicyEntry, action: string, error: unknown): void { + if (this.reported.has(entry)) return + this.reported.add(entry) + this.ctx.logger.warn( + `remote access: the ${JSON.stringify(entry.kind)} policy failed to ${action}; its further failures are not logged until access state changes`) + this.ctx.logger.warn(error) + } +} + +/* v8 ignore next 3 -- closed-union backstop. */ +function assertNever(value: never): never { + throw new Error(`remote access: unreachable value ${JSON.stringify(value)}`) +} + +/** Key-order-independent JSON text identifying one principal. */ +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` + if (typeof value === 'object' && value !== null) { + // Own keys are unique, so no two entries compare equal. + const entries = Object.entries(value).sort(([left], [right]) => (left < right ? -1 : 1)) + return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}` + } + return JSON.stringify(value) +} + +/** Resources are equal when their kinds and ids are; `global` carries no id. */ +function sameResource( + left: { readonly kind: string; readonly id?: unknown }, + right: { readonly kind: string; readonly id?: unknown }, +): boolean { + return left.kind === right.kind && left.id === right.id +} + +function sameActions(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((action, index) => action === right[index]) +} + +function abortion(signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} + +export default RemoteAccessService diff --git a/packages/api/remote-access/src/snapshot.ts b/packages/api/remote-access/src/snapshot.ts new file mode 100644 index 0000000000..ad3a490205 --- /dev/null +++ b/packages/api/remote-access/src/snapshot.ts @@ -0,0 +1,8 @@ +/** + * The index global carrying a page's first access snapshot, shared by the + * Host row that writes it and the Client mount that reads it. + * @module @deepseek-ai/dsh-api-remote-access/src/snapshot + */ + +/** `globalThis` property an index injection row assigns to the page's opening baseline. */ +export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' diff --git a/packages/api/remote-access/src/types.ts b/packages/api/remote-access/src/types.ts new file mode 100644 index 0000000000..fe38b77eef --- /dev/null +++ b/packages/api/remote-access/src/types.ts @@ -0,0 +1,137 @@ +/** + * Method-level access control contracts: actions, roles, bindings, policies, + * decision requests, and the access snapshot stream. + * @module @deepseek-ai/dsh-api-remote-access/types + */ + +import type { + Principal, + RemoteAccessResource, + RemoteAccessResourceKind, + RemoteAccessResourceOf, +} from '@deepseek-ai/dsh-typert-protocol' + +export type { + RemoteAccessResource, + RemoteAccessResourceKind, + RemoteAccessResourceOf, + TypertResourceKind, + TypertResourceMap, +} from '@deepseek-ai/dsh-typert-protocol' + +/** Canonical `/` Remote endpoint; its segments never contain `:`. */ +export type RemoteAccessEndpoint = `${string}/${string}` + +/** A Remote endpoint, or `event:` for one forwarded event. */ +export type RemoteAccessAction = RemoteAccessEndpoint | `event:${string}` + +/** A named action set over one resource kind; a plain value the business package owns. */ +export interface RemoteAccessRole { + /** Name for diagnostics; decisions read only `resource` and `actions`. */ + readonly name: string + /** The resource kind every binding of this role names. */ + readonly resource: K + /** Actions the role allows on its binding's resource. */ + readonly actions: readonly RemoteAccessAction[] +} + +/** Answer of `decide()` and of a condition. */ +export type RemoteAccessDecision = + | { readonly kind: 'allow' } + | { readonly kind: 'deny'; readonly reason: string } + +/** + * Refines one binding for one action on its resource. Called synchronously, + * only for a binding whose resource matches and whose role lists the action. + * @param request - the action, the binding's resource, and the named wire + * arguments of a concrete call; `args` is absent for events, snapshots, and + * list filtering, where the condition answers for the action alone. + * @returns allow, or deny with the reason a refused call reports. + */ +export type RemoteAccessCondition = (request: { + readonly action: RemoteAccessAction + readonly resource: RemoteAccessResourceOf + readonly args?: Readonly> +}) => RemoteAccessDecision + +/** One role on one resource of the role's kind; the resource type follows from the role. */ +export type RemoteAccessBinding = { + [K in RemoteAccessResourceKind]: { + readonly role: RemoteAccessRole + readonly resource: RemoteAccessResourceOf + /** Without a condition, the binding allows every action its role lists. */ + readonly condition?: RemoteAccessCondition + } +}[RemoteAccessResourceKind] + +/** One access question: may `principal` take `action` on `resources`. */ +export interface RemoteAccessRequest

{ + readonly principal: P + readonly action: RemoteAccessAction + /** + * Every resource the action touches, the global resource alone when it + * names none; allowed only when the action is allowed on every listed resource. + */ + readonly resources: readonly RemoteAccessResource[] + /** Named wire arguments of a concrete call, undecoded; absent for events, snapshots, and list filtering. */ + readonly args?: Readonly> +} + +/** + * Business rules for one non-owner principal kind. Every member answers + * synchronously from state the business package holds in memory. + */ +export interface RemoteAccessPolicy

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

): string | undefined +} + +/** Details of a refused non-owner call. */ +export interface RemoteAccessDeniedDetails { + /** Canonical `/` endpoint of the refused call. */ + readonly action: string + /** + * A condition's or `explain()`'s reason, `no-allow`, or `policy-failed`; + * the Gateway refuses a call whose resources are invalid with + * `resource-invalid` before `decide()` is asked. + */ + readonly reason: string +} + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface RemoteErrorDetailsMap { + 'remote-access/denied': RemoteAccessDeniedDetails + } +} + +/** The endpoint actions one principal may call on one resource. */ +export interface RemoteAccessGrant { + readonly resource: RemoteAccessResource + /** + * Endpoint actions allowed on this resource without call arguments, sorted; + * plain strings because Typert generates no codec for template literal types. + */ + readonly actions: readonly string[] +} + +/** + * One frame of `remoteAccess/follow`. A generation opens with one baseline; + * an owner baseline is the only frame of its generation. + */ +export type RemoteAccessFrame = + | { readonly type: 'baseline'; readonly owner: true } + | { readonly type: 'baseline'; readonly owner: false; readonly grants: readonly RemoteAccessGrant[] } + | { readonly type: 'upsert'; readonly grant: RemoteAccessGrant } + | { readonly type: 'remove'; readonly resource: RemoteAccessResource } diff --git a/packages/api/remote-access/tests/client-access.client.spec.ts b/packages/api/remote-access/tests/client-access.client.spec.ts new file mode 100644 index 0000000000..b73944ec58 --- /dev/null +++ b/packages/api/remote-access/tests/client-access.client.spec.ts @@ -0,0 +1,291 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { HostObservable, RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots' +import type { TypertRemoteContribution, TypertResource } from '@deepseek-ai/dsh-typert-protocol' +import type { RemoteAccessFrame, RemoteAccessGrant } from '../src/types.ts' +import { ClientAccessState, parseInjectedBaseline, type Baseline } from '../src/client/state.ts' +import { mountAccess } from '../src/client/mount.ts' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertResourceMap { + /** Test-only resource kinds; the Client face itself names no kind. */ + 'test-shelf': TypertResource + 'test-desk': TypertResource + } +} + +const GRANTED: Extract = { + type: 'baseline', + owner: false, + grants: [ + { resource: { kind: 'global' }, actions: ['session/list'] }, + { resource: { kind: 'test-shelf', id: 's1' }, actions: ['session/fork', 'session/prompt'] }, + { resource: { kind: 'test-desk', id: 'd1' }, actions: ['workspace/follow'] }, + ], +} + +describe('ClientAccessState', () => { + it('answers false without a baseline and after a non-owner stream fails, and true for the owner', () => { + const state = new ClientAccessState() + expect(state.global('session/create').getSnapshot()).toBe(false) + expect(state.resource('test-shelf', 's9', 'session/fork').getSnapshot()).toBe(false) + state.replace({ type: 'baseline', owner: true }) + expect(state.resource('test-desk', 'd9', 'workspace/follow').getSnapshot()).toBe(true) + state.unavailable() + expect(state.resource('test-desk', 'd9', 'workspace/follow').getSnapshot()).toBe(true) + state.replace(GRANTED) + expect(state.global('session/list').getSnapshot()).toBe(true) + state.unavailable() + expect(state.global('session/list').getSnapshot()).toBe(false) + }) + + it('starts from the injected baseline before any stream frame', () => { + expect(new ClientAccessState({ type: 'baseline', owner: true }).global('session/create').getSnapshot()).toBe(true) + const granted = new ClientAccessState(GRANTED) + expect(granted.global('session/list').getSnapshot()).toBe(true) + expect(granted.resource('test-shelf', 's1', 'session/fork').getSnapshot()).toBe(true) + expect(granted.global('session/create').getSnapshot()).toBe(false) + }) + + it('validates the injected baseline and refuses a malformed one', () => { + expect(parseInjectedBaseline(undefined)).toBeUndefined() + expect(parseInjectedBaseline({ type: 'baseline', owner: true, extra: 1 })).toEqual({ type: 'baseline', owner: true }) + expect(parseInjectedBaseline(JSON.parse(JSON.stringify(GRANTED)))).toEqual(GRANTED) + for (const malformed of [ + null, 'baseline', [], { type: 'upsert' }, + { type: 'baseline' }, { type: 'baseline', owner: false }, { type: 'baseline', owner: false, grants: [7] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 'test-shelf' }, actions: [] }] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 7 }, actions: [] }] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 'global' }, actions: 'session/list' }] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 'global' }, actions: [7] }] }, + { type: 'baseline', owner: false, grants: [{ resource: { kind: 'global' } }] }, + { type: 'baseline', owner: false, grants: [{ scope: { kind: 'page' }, endpoints: ['session/list'] }] }, + ]) { + expect(() => parseInjectedBaseline(malformed)).toThrow(TypeError) + } + }) + + it('answers each resource from its own grant without falling back to the global resource', () => { + const state = new ClientAccessState() + state.replace(GRANTED) + expect(state.global('session/list').getSnapshot()).toBe(true) + expect(state.global('session/fork').getSnapshot()).toBe(false) + expect(state.resource('test-shelf', 's1', 'session/fork').getSnapshot()).toBe(true) + expect(state.resource('test-shelf', 's1', 'session/list').getSnapshot()).toBe(false) + expect(state.resource('test-shelf', 's2', 'session/fork').getSnapshot()).toBe(false) + expect(state.resource('test-desk', 'd1', 'workspace/follow').getSnapshot()).toBe(true) + expect(state.resource('test-desk', 'd2', 'workspace/follow').getSnapshot()).toBe(false) + }) + + it('applies upserts and removals and notifies subscribers until they unsubscribe', () => { + const state = new ClientAccessState() + state.replace(GRANTED) + const fork = state.resource('test-shelf', 's1', 'session/fork') + const listener = vi.fn() + const unsubscribe = fork.subscribe(listener) + state.update({ type: 'upsert', grant: { resource: { kind: 'test-shelf', id: 's1' }, actions: ['session/prompt'] } }) + expect(fork.getSnapshot()).toBe(false) + expect(listener).toHaveBeenCalledOnce() + state.update({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['session/create'] } }) + expect(state.global('session/create').getSnapshot()).toBe(true) + state.update({ type: 'upsert', grant: { resource: { kind: 'test-desk', id: 'd2' }, actions: ['workspace/follow'] } }) + expect(state.resource('test-desk', 'd2', 'workspace/follow').getSnapshot()).toBe(true) + state.update({ type: 'remove', resource: { kind: 'test-desk', id: 'd2' } }) + expect(state.resource('test-desk', 'd2', 'workspace/follow').getSnapshot()).toBe(false) + state.update({ type: 'remove', resource: { kind: 'test-shelf', id: 's1' } }) + expect(state.resource('test-shelf', 's1', 'session/prompt').getSnapshot()).toBe(false) + // Removing a kind that never had a grant leaves the other kinds intact. + state.update({ type: 'remove', resource: { kind: 'test-shelf', id: 'absent' } }) + unsubscribe() + state.update({ type: 'remove', resource: { kind: 'global' } }) + expect(listener).toHaveBeenCalledTimes(6) + expect(state.global('session/create').getSnapshot()).toBe(false) + }) + + it('applies a baseline of many grants of one kind in one pass and notifies once', () => { + const state = new ClientAccessState() + const first = state.resource('test-shelf', 's0', 'session/fork') + const listener = vi.fn() + first.subscribe(listener) + const grants: RemoteAccessGrant[] = Array.from({ length: 20_000 }, (_, index) => ({ + resource: { kind: 'test-shelf', id: `s${index}` }, + actions: ['session/fork'], + })) + // Copying the kind's grants once per grant takes seconds at this size and exceeds the spec timeout. + state.replace({ type: 'baseline', owner: false, grants }) + expect(listener).toHaveBeenCalledOnce() + expect(first.getSnapshot()).toBe(true) + expect(state.resource('test-shelf', 's19999', 'session/fork').getSnapshot()).toBe(true) + expect(state.resource('test-shelf', 's20000', 'session/fork').getSnapshot()).toBe(false) + }) + + it('ignores increments outside a non-owner generation and keeps source identity stable', () => { + const state = new ClientAccessState() + state.update({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['session/list'] } }) + expect(state.global('session/list').getSnapshot()).toBe(false) + state.replace({ type: 'baseline', owner: true }) + state.update({ type: 'remove', resource: { kind: 'global' } }) + expect(state.global('session/list').getSnapshot()).toBe(true) + expect(state.global('session/list')).toBe(state.global('session/list')) + expect(state.resource('test-shelf', 's1', 'session/fork')).toBe(state.resource('test-shelf', 's1', 'session/fork')) + expect(state.resource('test-shelf', 's1', 'session/fork')).not.toBe(state.resource('test-desk', 's1', 'session/fork')) + }) +}) + +interface FakeStreamOptions { + readonly name: string + readonly open: (signal: AbortSignal) => unknown + readonly ended: (accepted: boolean) => Error +} + +/** One controllable generation of `remoteAccess/follow`, standing in for the Gateway-owned carrier. */ +class FakeAccessStream { + private readonly frames: RemoteAccessFrame[] = [] + private wake: (() => void) | undefined + private done = false + private failure: Error | undefined + + constructor(readonly options: FakeStreamOptions) {} + + push(frame: RemoteAccessFrame): void { + this.frames.push(frame) + this.wake?.() + } + + fail(error: Error): void { + this.failure = error + this.wake?.() + } + + restart(): void {} + + async dispose(): Promise { + this.done = true + this.wake?.() + } + + async *[Symbol.asyncIterator](): AsyncIterator<{ + readonly generation: number + readonly value: RemoteAccessFrame + readonly accept: () => void + }> { + while (!this.done) { + if (this.failure !== undefined) throw this.failure + const frame = this.frames.shift() + if (frame !== undefined) { + yield { generation: 1, value: frame, accept: () => {} } + continue + } + await new Promise((resolve) => { this.wake = resolve }) + } + } +} + +function value(source: HostObservable | undefined): unknown { + if (source === undefined) throw new Error('missing source') + return source.getSnapshot() +} + +async function mounted(initial?: Baseline): Promise<{ + readonly ctx: Context + readonly stream: () => FakeAccessStream + readonly global: (action: string) => HostObservable | undefined + readonly follow: ReturnType + readonly mount: ReturnType + readonly unmount: ReturnType + readonly contribution: TypertRemoteContribution + readonly dispose: () => Promise +}> { + const ctx = new Context() + let stream: FakeAccessStream | undefined + const follow = vi.fn() + const unmount = vi.fn(async () => {}) + const remote = { + $mount: vi.fn(async () => unmount), + $stream: (options: FakeStreamOptions) => { + stream = new FakeAccessStream(options) + return stream + }, + remoteAccess: { follow }, + } + let root: RootStandardSourceContribution | undefined + ctx.provide('remote', remote as never) + ctx.provide('slots', { + provideRoot: (contribution: RootStandardSourceContribution) => { + root = contribution + return () => {} + }, + } as never) + const contribution: TypertRemoteContribution = { package: '@deepseek-ai/dsh-api-remote-access', descriptors: [] } + let dispose: (() => Promise) | undefined + await ctx.plugin({ + inject: ['remote', 'slots'], + apply: async (pluginCtx: Context) => { dispose = await mountAccess(pluginCtx, contribution, initial) }, + }) + // The generated namespace arrives after the mount, as `$mount` provides it. + ctx.provide('remote.remoteAccess', remote.remoteAccess as never) + await vi.waitFor(() => { expect(stream).toBeDefined() }) + return { + ctx, + stream: () => stream as FakeAccessStream, + global: action => root?.keyedHooks?.['remoteAccess']?.(action), + follow, + mount: remote.$mount, + unmount, + contribution, + dispose: async () => { await dispose?.() }, + } +} + +describe('remote-access Client mount', () => { + it('provides the global-resource Hook and the remoteAccess service over the followed snapshot', async () => { + const access = await mounted() + expect(access.mount).toHaveBeenCalledWith(access.contribution) + const globalFork = access.global('session/fork') + const shelfFork = access.ctx.remoteAccess.source('test-shelf', 's1', 'session/fork') + const deskFollow = access.ctx.remoteAccess.source('test-desk', 'd1', 'workspace/follow') + expect(access.ctx.remoteAccess.source('test-shelf', 's1', 'session/fork')).toBe(shelfFork) + expect([value(globalFork), value(shelfFork), value(deskFollow)]).toEqual([false, false, false]) + + access.stream().push(GRANTED) + await vi.waitFor(() => { expect(value(shelfFork)).toBe(true) }) + expect(value(globalFork)).toBe(false) + expect(value(deskFollow)).toBe(true) + access.stream().push({ type: 'remove', resource: { kind: 'test-shelf', id: 's1' } }) + await vi.waitFor(() => { expect(value(shelfFork)).toBe(false) }) + // Reading an answer never issues a request; only the stream factory reaches the namespace. + expect(access.follow).not.toHaveBeenCalled() + access.stream().options.open(new AbortController().signal) + expect(access.follow).toHaveBeenCalledOnce() + expect(access.stream().options.ended(true).message).toMatch(/without a terminal result/) + expect(access.stream().options.ended(false).message).toMatch(/before its opening snapshot/) + + await access.dispose() + expect(access.unmount).toHaveBeenCalledOnce() + }) + + it('answers from the injected baseline at once and false once a non-owner stream fails terminally', async () => { + const access = await mounted(GRANTED) + const report = vi.spyOn(access.ctx.logger, 'error').mockImplementation(() => undefined) + const globalList = access.global('session/list') + expect(value(globalList)).toBe(true) + const failure = new Error('carrier closed for good') + access.stream().fail(failure) + await vi.waitFor(() => { expect(value(globalList)).toBe(false) }) + expect(report).toHaveBeenCalledWith('access snapshot stream failed; reload the page to reconnect', failure) + await access.dispose() + }) + + it('keeps the owner\'s answers when its stream fails terminally', async () => { + const access = await mounted({ type: 'baseline', owner: true }) + const report = vi.spyOn(access.ctx.logger, 'error').mockImplementation(() => undefined) + const globalFork = access.global('session/fork') + expect(value(globalFork)).toBe(true) + const failure = new Error('carrier closed for good') + access.stream().fail(failure) + await vi.waitFor(() => { expect(report).toHaveBeenCalledOnce() }) + expect(report).toHaveBeenCalledWith('access snapshot stream failed; owner access answers are unaffected', failure) + expect(value(globalFork)).toBe(true) + await access.dispose() + }) +}) diff --git a/packages/api/remote-access/tests/remote-access.host.spec.ts b/packages/api/remote-access/tests/remote-access.host.spec.ts new file mode 100644 index 0000000000..f29d4d1744 --- /dev/null +++ b/packages/api/remote-access/tests/remote-access.host.spec.ts @@ -0,0 +1,610 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import TypertGatewayService from '@deepseek-ai/dsh-api-gateway' +import { Peer } from '@deepseek-ai/dsh-client-connection' +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import { + Remote, + TypertRemoteService, + type PeerScope, + type Principal, + type PrincipalMap, + type RemoteInvocation, + type TypertResource, +} from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import RemoteAccessService, { + REMOTE_ACCESS_SNAPSHOT_KEY, + type RemoteAccessBinding, + type RemoteAccessCondition, + type RemoteAccessFrame, + type RemoteAccessGrant, + type RemoteAccessPolicy, + type RemoteAccessRequest, + type RemoteAccessResource, + type RemoteAccessResourceOf, + type RemoteAccessRole, +} from '../src/index.ts' + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface PrincipalMap { + /** Test-only non-owner caller shared by the access specs. */ + 'test-guest': { readonly kind: 'test-guest'; readonly guest: string } + /** Test-only non-owner caller whose identity carries a list. */ + 'test-crew': { readonly kind: 'test-crew'; readonly members: readonly string[] } + } + interface TypertResourceMap { + /** Test-only resource kinds; the service itself names no kind. */ + 'test-shelf': TypertResource + 'test-desk': TypertResource + } +} + +type Guest = PrincipalMap['test-guest'] + +const OWNER: Principal = { kind: 'owner' } +const GUEST: Guest = { kind: 'test-guest', guest: 'a' } +const OTHER: Guest = { kind: 'test-guest', guest: 'b' } +const CREW: Principal = { kind: 'test-crew', members: ['a', 'b'] } +const GLOBAL: RemoteAccessResourceOf<'global'> = { kind: 'global' } +const ROOM: RemoteAccessResourceOf<'test-shelf'> = { kind: 'test-shelf', id: 'r1' } +const ROOM2: RemoteAccessResourceOf<'test-shelf'> = { kind: 'test-shelf', id: 'r2' } +const DESK: RemoteAccessResourceOf<'test-desk'> = { kind: 'test-desk', id: 'd1' } + +const READER: RemoteAccessRole<'global'> = { + name: 'test:reader', + resource: 'global', + actions: ['notes/read', 'notes/rooms', 'notes/write', 'event:notes/changed'], +} +const SHELF_READER: RemoteAccessRole<'test-shelf'> = { + name: 'test:shelf-reader', + resource: 'test-shelf', + actions: ['notes/rooms', 'notes/read', 'event:notes/changed'], +} +const SHELF_WRITER: RemoteAccessRole<'test-shelf'> = { name: 'test:shelf-writer', resource: 'test-shelf', actions: ['notes/write'] } +const DESK_WRITER: RemoteAccessRole<'test-desk'> = { name: 'test:desk-writer', resource: 'test-desk', actions: ['notes/write'] } + +class Notes extends TypertRemoteService { + static inject = ['remoteAccess'] + + constructor(ctx: Context) { super(ctx, 'notes') } + + @Remote + read(): string { return 'read' } + + /** Resolves to the shelf its request names through the registered default field. */ + @Remote + write(request: { readonly shelfId: string; readonly text: string }): string { return request.text } + + @Remote + hidden(): string { return 'hidden' } + + /** A list method: a global call, then one `allows()` check per item. */ + @Remote + rooms(): string[] { + return ['r1', 'r2'].filter(id => this.ctx.remoteAccess.allows({ kind: 'test-shelf', id })) + } +} + +/** A policy whose bindings live in memory per guest, the way a business package preloads them. */ +class GuestPolicy implements RemoteAccessPolicy { + readonly held = new Map() + readonly bindings = vi.fn((guest: Guest): readonly RemoteAccessBinding[] => this.held.get(guest.guest) ?? []) + + hold(guest: Guest, ...bindings: RemoteAccessBinding[]): void { + this.held.set(guest.guest, bindings) + } +} + +const roots: Context[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +async function setup(): Promise<{ readonly ctx: Context; readonly access: RemoteAccessService }> { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) + await ctx.plugin(TypertGatewayService) + ctx.typert.resources.register('test-shelf', { field: 'shelfId' }) + await ctx.plugin(Notes) + return { ctx, access: ctx.remoteAccess } +} + +function call(ctx: Context, principal: Principal, method: string, args: Record = {}): Promise { + return ctx.typertGateway.invoke({ peer: new Peer(ctx, { principal }), namespace: 'notes', method, args }) +} + +/** A request by `principal` for `action` on `resources`. */ +function ask(principal: Principal, action: RemoteAccessRequest['action'], ...resources: RemoteAccessResource[]): RemoteAccessRequest { + return { principal, action, resources } +} + +const NEVER = new AbortController().signal + +/** The invocation a Gateway binds while dispatching `remoteAccess/follow` for one principal. */ +function invocationOf(ctx: Context, principal: Principal, method = 'follow', namespace = 'remoteAccess'): RemoteInvocation { + return { + request: { namespace, method, args: {} }, + service: namespace, + peer: new Peer(ctx, { principal }), + signal: NEVER, + uplink: (): AsyncIterable => ({ [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }), + } +} + +async function follow(ctx: Context, principal: Principal): Promise<{ + readonly frames: RemoteAccessFrame[] + readonly next: () => Promise + readonly stop: () => Promise +}> { + const abort = new AbortController() + const peer: PeerScope = new Peer(ctx, { principal }) + const stream = await ctx.typertGateway.stream({ peer, namespace: 'remoteAccess', method: 'follow', args: {}, signal: abort.signal }) + const iterator = stream[Symbol.asyncIterator]() + const frames: RemoteAccessFrame[] = [] + return { + frames, + next: async () => { + const result = await iterator.next() + if (result.done === true) throw new Error('access stream ended') + const frame = result.value as RemoteAccessFrame + frames.push(frame) + return frame + }, + stop: async () => { + abort.abort() + await iterator.return?.() + }, + } +} + +interface SpiedListenerMethod { + readonly mock: { readonly calls: readonly unknown[][]; readonly contexts: readonly unknown[] } +} + +/** `abort` registrations a spied `EventTarget` method received on AbortSignals. */ +function abortListenerCalls(spy: SpiedListenerMethod): number { + return spy.mock.calls.filter((args, index) => args[0] === 'abort' && spy.mock.contexts[index] instanceof AbortSignal).length +} + +describe('RemoteAccessService registrations', () => { + it('removes a policy with its disposer and with the plugin fiber that registered it', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + const dispose = access.registerPolicy('test-guest', policy) + await expect(call(ctx, GUEST, 'read')).resolves.toBe('read') + dispose() + await expect(call(ctx, GUEST, 'read')).rejects.toMatchObject({ code: 'remote-access/denied', details: { reason: 'no-allow' } }) + const fiber = ctx.plugin({ + inject: ['remoteAccess'], + apply: (pluginCtx: Context) => { pluginCtx.remoteAccess.registerPolicy('test-guest', policy) }, + }) + await fiber + await expect(call(ctx, GUEST, 'read')).resolves.toBe('read') + await fiber.dispose() + await expect(call(ctx, GUEST, 'read')).rejects.toMatchObject({ code: 'remote-access/denied', details: { reason: 'no-allow' } }) + }) + + it('accepts one policy per principal kind', async () => { + const { access } = await setup() + access.registerPolicy('test-guest', new GuestPolicy()) + expect(() => access.registerPolicy('test-guest', new GuestPolicy())) + .toThrow('remote access: principal kind "test-guest" already has a policy') + access.registerPolicy('test-crew', { bindings: () => [{ role: READER, resource: GLOBAL }] }) + expect(access.decide(ask(CREW, 'notes/read', GLOBAL))).toEqual({ kind: 'allow' }) + }) +}) + +describe('RemoteAccessService.decide', () => { + it('allows the owner without any policy and refuses a kind without a policy', async () => { + const { ctx, access } = await setup() + expect(access.decide(ask(OWNER, 'notes/hidden', ROOM))).toEqual({ kind: 'allow' }) + await expect(call(ctx, OWNER, 'hidden')).resolves.toBe('hidden') + await expect(call(ctx, GUEST, 'read')).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'notes/read', reason: 'no-allow' }, + }) + }) + + it('allows only actions a bound role lists on the resource the binding names', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + policy.hold(GUEST, { role: READER, resource: GLOBAL }, { role: SHELF_WRITER, resource: ROOM }) + await expect(call(ctx, GUEST, 'read')).resolves.toBe('read') + await expect(call(ctx, GUEST, 'hidden')).rejects.toMatchObject({ details: { action: 'notes/hidden', reason: 'no-allow' } }) + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'hi' } })).resolves.toBe('hi') + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r2', text: 'hi' } })).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + await expect(call(ctx, OTHER, 'read')).rejects.toMatchObject({ details: { reason: 'no-allow' } }) + // Resources are independent: a global grant implies nothing on a shelf, and a shelf grant nothing on global. + expect(access.decide(ask(GUEST, 'notes/read', ROOM))).toEqual({ kind: 'deny', reason: 'no-allow' }) + expect(access.decide(ask(GUEST, 'notes/write', GLOBAL))).toEqual({ kind: 'allow' }) + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }) + expect(access.decide(ask(GUEST, 'notes/write', GLOBAL))).toEqual({ kind: 'deny', reason: 'no-allow' }) + }) + + it('requires the action on every resource of the target', async () => { + const { access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const both = ask(GUEST, 'notes/write', ROOM, DESK) + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }) + expect(access.decide(both)).toEqual({ kind: 'deny', reason: 'no-allow' }) + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }, { role: DESK_WRITER, resource: DESK }) + expect(access.decide(both)).toEqual({ kind: 'allow' }) + expect(policy.bindings).toHaveBeenCalledTimes(2) + }) + + it('asks a matching binding\'s condition, which can refuse with its own reason', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const paused = vi.fn>(() => ({ kind: 'deny', reason: 'shelf-paused' })) + const locked = vi.fn>(() => ({ kind: 'deny', reason: 'shelf-locked' })) + const short = vi.fn>(({ args }) => { + const request: unknown = args?.['request'] + return typeof request === 'object' && request !== null && Reflect.get(request, 'text') === 'ok' + ? { kind: 'allow' } + : { kind: 'deny', reason: 'text-refused' } + }) + const unrelated = vi.fn>(() => ({ kind: 'allow' })) + policy.hold(GUEST, + { role: SHELF_READER, resource: ROOM, condition: unrelated }, + { role: SHELF_WRITER, resource: ROOM2, condition: unrelated }, + { role: SHELF_WRITER, resource: ROOM, condition: paused }, + { role: SHELF_WRITER, resource: ROOM, condition: locked }) + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'x' } })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'notes/write', reason: 'shelf-paused' }, + }) + expect(paused).toHaveBeenCalledWith({ action: 'notes/write', resource: ROOM, args: { request: { shelfId: 'r1', text: 'x' } } }) + expect(locked).toHaveBeenCalledOnce() + // A condition runs only for a binding whose resource matches and whose role lists the action. + expect(unrelated).not.toHaveBeenCalled() + + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM, condition: short }) + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'x' } })).rejects.toMatchObject({ details: { reason: 'text-refused' } }) + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'ok' } })).resolves.toBe('ok') + + // Any binding without a condition, or with an allowing one, allows. + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM, condition: paused }, { role: SHELF_WRITER, resource: ROOM }) + expect(access.decide(ask(GUEST, 'notes/write', ROOM))).toEqual({ kind: 'allow' }) + expect(paused).toHaveBeenLastCalledWith({ action: 'notes/write', resource: ROOM }) + }) + + it('names the reason explain() gives once no binding allows, without changing the decision', async () => { + const { access } = await setup() + const policy = new GuestPolicy() + const explain = vi.fn((request: { readonly action: string }) => (request.action === 'notes/write' ? 'share-not-writable' : undefined)) + access.registerPolicy('test-guest', { bindings: policy.bindings, explain }) + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + expect(access.decide(ask(GUEST, 'notes/read', GLOBAL))).toEqual({ kind: 'allow' }) + expect(explain).not.toHaveBeenCalled() + const write = { ...ask(GUEST, 'notes/write', ROOM, DESK), args: { request: {} } } + expect(access.decide(write)).toEqual({ kind: 'deny', reason: 'share-not-writable' }) + expect(explain).toHaveBeenCalledWith({ principal: GUEST, action: 'notes/write', resources: [ROOM, DESK], args: { request: {} } }) + expect(access.decide(ask(GUEST, 'notes/hidden', GLOBAL))).toEqual({ kind: 'deny', reason: 'no-allow' }) + expect(explain).toHaveBeenLastCalledWith({ principal: GUEST, action: 'notes/hidden', resources: [GLOBAL] }) + // A refusing condition's reason comes first. + const paused: RemoteAccessCondition<'test-shelf'> = () => ({ kind: 'deny', reason: 'shelf-paused' }) + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM, condition: paused }) + expect(access.decide(ask(GUEST, 'notes/write', ROOM))).toEqual({ kind: 'deny', reason: 'shelf-paused' }) + expect(explain).toHaveBeenCalledTimes(2) + }) + + it('refuses with policy-failed when bindings(), a condition, or explain() throws, logging each policy once per change', async () => { + const { ctx, access } = await setup() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const failures = (): unknown[] => warn.mock.calls.flatMap(([value]) => (value instanceof Error ? [value.message] : [])) + let mode: 'bindings' | 'condition' | 'explain' = 'bindings' + access.registerPolicy('test-guest', { + bindings: () => { + if (mode === 'bindings') throw new Error('binding store unavailable') + return [{ role: SHELF_WRITER, resource: ROOM, condition: () => { throw new Error('condition failed') } }] + }, + explain: () => { throw new Error('explain failed') }, + }) + access.registerPolicy('test-crew', { bindings: () => { throw new Error('crew store unavailable') } }) + for (let round = 0; round < 3; round++) { + expect(access.decide(ask(GUEST, 'notes/write', ROOM))).toEqual({ kind: 'deny', reason: 'policy-failed' }) + expect(access.decide(ask(CREW, 'notes/write', ROOM))).toEqual({ kind: 'deny', reason: 'policy-failed' }) + } + await expect(call(ctx, GUEST, 'write', { request: { shelfId: 'r1', text: 'x' } })).rejects.toMatchObject({ + code: 'remote-access/denied', details: { action: 'notes/write', reason: 'policy-failed' }, + }) + expect(failures()).toEqual(['binding store unavailable', 'crew store unavailable']) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to list bindings for notes/write; its further failures are not logged until access state changes') + + mode = 'condition' + access.invalidate(OTHER) + expect(access.decide(ask(GUEST, 'notes/write', ROOM))).toEqual({ kind: 'deny', reason: 'policy-failed' }) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to evaluate a condition for notes/write; its further failures are not logged until access state changes') + + mode = 'explain' + access.invalidate() + expect(access.decide(ask(GUEST, 'notes/read', ROOM))).toEqual({ kind: 'deny', reason: 'policy-failed' }) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to explain a refusal of notes/read; its further failures are not logged until access state changes') + expect(failures()).toEqual(['binding store unavailable', 'crew store unavailable', 'condition failed', 'explain failed']) + }) + + it('always lets a Peer open its own access snapshot', async () => { + const { ctx, access } = await setup() + access.registerPolicy('test-guest', { bindings: () => [] }) + const guest = await follow(ctx, GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + await guest.stop() + }) +}) + +describe('RemoteAccessService.allows', () => { + it('filters list items for non-owners on one resource without arguments', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const reader = vi.fn>(() => ({ kind: 'allow' })) + policy.hold(GUEST, { role: READER, resource: GLOBAL }, { role: SHELF_READER, resource: ROOM, condition: reader }) + await expect(call(ctx, GUEST, 'rooms')).resolves.toEqual(['r1']) + expect(reader).toHaveBeenCalledWith({ action: 'notes/rooms', resource: ROOM }) + // One call for the Gateway's decision and one per item check. + expect(policy.bindings).toHaveBeenCalledTimes(3) + policy.bindings.mockClear() + await expect(call(ctx, OWNER, 'rooms')).resolves.toEqual(['r1', 'r2']) + expect(access.allows(ROOM)).toBe(true) + expect(policy.bindings).not.toHaveBeenCalled() + }) + + it('reads the bindings on every check, so a running call sees binding changes from its next check on', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + policy.hold(GUEST, { role: SHELF_READER, resource: ROOM }) + // One long-running call, such as a follow stream that filters every frame. + const stream = ctx.extend({ invocation: invocationOf(ctx, GUEST, 'rooms', 'notes') }).remoteAccess + expect(stream.allows(ROOM)).toBe(true) + expect(stream.allows(ROOM2)).toBe(false) + policy.hold(GUEST, { role: SHELF_READER, resource: ROOM2 }) + expect(stream.allows(ROOM)).toBe(false) + expect(stream.allows(ROOM2)).toBe(true) + }) + + it('allows nothing when the principal kind has no policy or the policy fails', async () => { + const { ctx, access } = await setup() + vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const crewCall = ctx.extend({ invocation: invocationOf(ctx, CREW, 'rooms', 'notes') }) + expect(crewCall.remoteAccess.allows(ROOM)).toBe(false) + const bindings = vi.fn((): readonly RemoteAccessBinding[] => { throw new Error('binding store unavailable') }) + access.registerPolicy('test-guest', { bindings }) + const guestCall = ctx.extend({ invocation: invocationOf(ctx, GUEST, 'rooms', 'notes') }) + expect(guestCall.remoteAccess.allows(ROOM)).toBe(false) + expect(guestCall.remoteAccess.allows(ROOM2)).toBe(false) + expect(bindings).toHaveBeenCalledTimes(2) + }) +}) + +describe('first snapshots', () => { + it('gives the owner the owner baseline and a non-owner its current grants', async () => { + const { access } = await setup() + const policy = new GuestPolicy() + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + access.registerPolicy('test-guest', policy) + expect(access.snapshotFor(OWNER)).toEqual({ type: 'baseline', owner: true }) + expect(access.snapshotFor(GUEST)).toEqual({ + type: 'baseline', owner: false, grants: [{ resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }], + }) + expect(access.snapshotFor(CREW)).toEqual({ type: 'baseline', owner: false, grants: [] }) + expect(REMOTE_ACCESS_SNAPSHOT_KEY).toBe('__DSH_REMOTE_ACCESS__') + }) + + it('injects the owner baseline into every index the webServer renders', async () => { + const { ctx } = await setup() + const render = (): IndexInjection[] => { + const table: IndexInjection[] = [] + ctx.emit('webserver/index-inject', table) + return table + } + expect(render()).toEqual([]) + ctx.provide('webServer', {} as Context['webServer']) + await vi.waitFor(() => { + expect(render()).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: true } }]) + }) + }) + + it('lists per bound resource the endpoint actions decide() allows there without arguments, and no grant where none is allowed', async () => { + const { ctx, access } = await setup() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const writeOnly: RemoteAccessCondition<'test-shelf'> = ({ action }) => + action === 'notes/write' ? { kind: 'allow' } : { kind: 'deny', reason: 'read-paused' } + policy.hold(GUEST, + { role: SHELF_READER, resource: ROOM, condition: writeOnly }, + { role: SHELF_WRITER, resource: ROOM }, + { role: DESK_WRITER, resource: DESK, condition: () => { throw new Error('condition failed') } }, + { role: READER, resource: GLOBAL }) + const grants: RemoteAccessGrant[] = [ + { resource: ROOM, actions: ['notes/write'] }, + { resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }, + ] + expect(access.snapshotFor(GUEST)).toEqual({ type: 'baseline', owner: false, grants }) + for (const resource of [ROOM, GLOBAL]) { + for (const action of ['notes/read', 'notes/rooms', 'notes/write'] as const) { + const listed = grants.find(grant => grant.resource === resource)?.actions.includes(action) === true + expect(access.decide(ask(GUEST, action, resource)).kind).toBe(listed ? 'allow' : 'deny') + } + } + // Event actions are decided per notification and never enter the snapshot. + expect(access.decide(ask(GUEST, 'event:notes/changed', GLOBAL))).toEqual({ kind: 'allow' }) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'condition failed' })) + }) + + it('gives a principal whose bindings() throws no grant', async () => { + const { ctx, access } = await setup() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + access.registerPolicy('test-guest', { bindings: () => { throw new Error('binding store unavailable') } }) + expect(access.snapshotFor(GUEST)).toEqual({ type: 'baseline', owner: false, grants: [] }) + const guest = await follow(ctx, GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + expect(warn).toHaveBeenCalledWith( + 'remote access: the "test-guest" policy failed to list bindings for a snapshot; its further failures are not logged until access state changes') + await guest.stop() + }) +}) + +describe('remoteAccess/follow', () => { + it('sends the owner one owner baseline and nothing else', async () => { + const { ctx } = await setup() + const owner = await follow(ctx, OWNER) + await expect(owner.next()).resolves.toEqual({ type: 'baseline', owner: true }) + await owner.stop() + expect(owner.frames).toHaveLength(1) + + // Outside a Remote call the caller is trusted in-process code, answered as the owner. + for (const abortWhileWaiting of [false, true]) { + const abort = new AbortController() + const direct = ctx.remoteAccess.follow(abort.signal)[Symbol.asyncIterator]() + await expect(direct.next()).resolves.toEqual({ done: false, value: { type: 'baseline', owner: true } }) + if (!abortWhileWaiting) abort.abort() + const end = direct.next() + if (abortWhileWaiting) abort.abort() + await expect(end).resolves.toEqual({ done: true, value: undefined }) + } + }) + + it('pushes upserts and removals after invalidation and policy changes', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const guest = await follow(ctx, GUEST) + const other = await follow(ctx, OTHER) + const crew = await follow(ctx, CREW) + await expect(guest.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + await other.next() + await expect(crew.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'upsert', grant: { resource: ROOM, actions: ['notes/write'] } }) + + policy.hold(GUEST, { role: SHELF_WRITER, resource: ROOM }, { role: SHELF_READER, resource: ROOM }) + access.invalidate() + await expect(guest.next()).resolves.toEqual({ + type: 'upsert', grant: { resource: ROOM, actions: ['notes/read', 'notes/rooms', 'notes/write'] }, + }) + + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ + type: 'upsert', grant: { resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }, + }) + await expect(guest.next()).resolves.toEqual({ type: 'remove', resource: ROOM }) + + // An unchanged grant is not pushed again. + policy.hold(GUEST, { role: READER, resource: GLOBAL }, { role: SHELF_WRITER, resource: ROOM2 }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'upsert', grant: { resource: ROOM2, actions: ['notes/write'] } }) + policy.hold(GUEST, + { role: { ...READER, actions: ['notes/hidden', 'notes/read', 'notes/rooms'] }, resource: GLOBAL }, + { role: SHELF_WRITER, resource: ROOM2 }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ + type: 'upsert', grant: { resource: GLOBAL, actions: ['notes/hidden', 'notes/read', 'notes/rooms'] }, + }) + // A resource on which no action stays allowed loses its grant. + const paused: RemoteAccessCondition<'test-shelf'> = () => ({ kind: 'deny', reason: 'shelf-paused' }) + policy.hold(GUEST, + { role: { ...READER, actions: ['notes/hidden', 'notes/read', 'notes/rooms'] }, resource: GLOBAL }, + { role: SHELF_WRITER, resource: ROOM2, condition: paused }) + access.invalidate(GUEST) + await expect(guest.next()).resolves.toEqual({ type: 'remove', resource: ROOM2 }) + + const disposeCrew = access.registerPolicy('test-crew', { bindings: () => [{ role: SHELF_WRITER, resource: ROOM2 }] }) + await expect(crew.next()).resolves.toEqual({ type: 'upsert', grant: { resource: ROOM2, actions: ['notes/write'] } }) + access.invalidate({ kind: 'test-crew', members: ['a', 'b'] }) + disposeCrew() + await expect(crew.next()).resolves.toEqual({ type: 'remove', resource: ROOM2 }) + + await guest.stop() + await crew.stop() + // Every recomputation for the other principal produced the same grants, so it received no increment. + expect(other.frames).toHaveLength(1) + await other.stop() + }) + + it('keeps no abort listener from a wait that a change ended', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + access.registerPolicy('test-guest', policy) + const abort = new AbortController() + const stream = ctx.extend({ invocation: invocationOf(ctx, GUEST) }).remoteAccess.follow(abort.signal)[Symbol.asyncIterator]() + await stream.next() + const add = vi.spyOn(EventTarget.prototype, 'addEventListener') + const remove = vi.spyOn(EventTarget.prototype, 'removeEventListener') + try { + for (let round = 0; round < 30; round++) { + // The reader is already waiting when the change arrives. + const pending = stream.next() + policy.hold(GUEST, { role: round % 2 === 0 ? READER : { ...READER, actions: ['notes/read'] }, resource: GLOBAL }) + access.invalidate(GUEST) + await expect(pending).resolves.toMatchObject({ done: false, value: { type: 'upsert' } }) + } + expect(abortListenerCalls(add)).toBeGreaterThan(0) + expect(abortListenerCalls(add) - abortListenerCalls(remove)).toBe(0) + } finally { + add.mockRestore() + remove.mockRestore() + } + abort.abort() + await stream.return?.() + }) + + it('ends every open generation when the service unloads, so a reloaded service starts from a fresh baseline', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + const service = ctx.plugin(RemoteAccessService) + await service + await ctx.plugin(TypertGatewayService) + await ctx.plugin(Notes) + const policy = new GuestPolicy() + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + ctx.remoteAccess.registerPolicy('test-guest', policy) + const open = async (principal: Principal): Promise> => + (await ctx.typertGateway.stream({ + peer: new Peer(ctx, { principal }), namespace: 'remoteAccess', method: 'follow', args: {}, + }))[Symbol.asyncIterator]() + const guest = await open(GUEST) + const owner = await open(OWNER) + await expect(guest.next()).resolves.toMatchObject({ + value: { type: 'baseline', grants: [{ resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }] }, + }) + await expect(owner.next()).resolves.toMatchObject({ value: { type: 'baseline', owner: true } }) + const guestEnd = guest.next() + await service.dispose() + await expect(guestEnd).resolves.toEqual({ done: true, value: undefined }) + await expect(owner.next()).resolves.toEqual({ done: true, value: undefined }) + + await ctx.plugin(RemoteAccessService) + await vi.waitFor(() => { expect(ctx.get('typertGateway')).toBeDefined() }) + const reopened = await follow(ctx, GUEST) + await expect(reopened.next()).resolves.toEqual({ type: 'baseline', owner: false, grants: [] }) + await reopened.stop() + }) + + it('stops a non-owner generation that is cancelled while waiting', async () => { + const { ctx, access } = await setup() + access.registerPolicy('test-guest', new GuestPolicy()) + const abort = new AbortController() + const stream = ctx.extend({ invocation: invocationOf(ctx, GUEST) }).remoteAccess.follow(abort.signal)[Symbol.asyncIterator]() + await expect(stream.next()).resolves.toMatchObject({ value: { type: 'baseline', owner: false } }) + const pending = stream.next() + abort.abort() + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + // A cancelled generation no longer receives invalidations. + access.invalidate() + }) +}) diff --git a/packages/api/remote-access/tsconfig.client.json b/packages/api/remote-access/tsconfig.client.json new file mode 100644 index 0000000000..0ddeaeac30 --- /dev/null +++ b/packages/api/remote-access/tsconfig.client.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/client/mount.ts", + "src/client/state.ts", + "src/snapshot.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../gateway/tsconfig.client.json" }, + { "path": "../../client/ui-renderer" }, + { "path": "../../client/ui-slots" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/remote-access/tsconfig.host.json b/packages/api/remote-access/tsconfig.host.json new file mode 100644 index 0000000000..0d9e8a79c8 --- /dev/null +++ b/packages/api/remote-access/tsconfig.host.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/snapshot.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../host/webserver" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/remote-access/tsconfig.json b/packages/api/remote-access/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/api/remote-access/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/api/remote-access/tsdown.config.ts b/packages/api/remote-access/tsdown.config.ts new file mode 100644 index 0000000000..0e42a1163e --- /dev/null +++ b/packages/api/remote-access/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + import.meta.dirname, + ['lib/types/index.js'], + { hostPhase: true }, +) diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index 991c1ac46e..5b8200c657 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -24,6 +24,7 @@ const requiredArtifacts = [ 'packages/goal/goal/lib/typert.host.js', 'packages/api/gateway/lib/client.js', 'packages/api/gateway/lib/index.js', + 'packages/api/remote-access/lib/index.js', 'packages/typert/registry/lib/client.js', 'packages/typert/registry/lib/index.js', 'packages/session/session-projection/lib/index.js', @@ -39,6 +40,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { connectionHost: 'packages/client/connection/lib/index.js', goal: 'packages/goal/goal/lib/index.js', goalTypert: 'packages/goal/goal/lib/typert.host.js', + remoteAccess: 'packages/api/remote-access/lib/index.js', registryClient: 'packages/typert/registry/lib/client.js', registryHost: 'packages/typert/registry/lib/index.js', remotesClient: 'packages/api/remotes/lib/client.js', @@ -55,6 +57,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { const { default: AgentRegistry } = await import(urls.agent) const connectionHost = await import(urls.connectionHost) const { default: TypertRemoteService } = await import(urls.apiGatewayHost) + const { default: RemoteAccessService } = await import(urls.remoteAccess) const { default: GoalService } = await import(urls.goal) const { default: SessionProjectionRegistry } = await import(urls.sessionProjections) const { TYPERT } = await import(urls.goalTypert) @@ -84,6 +87,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply }) await host.plugin(TypertRegistry) await host.plugin(AgentRegistry) + await host.plugin(RemoteAccessService) await host.plugin(TypertRemoteService) await host.plugin(SessionProjectionRegistry) await host.plugin(GoalService) diff --git a/packages/api/workspace-files/package.json b/packages/api/workspace-files/package.json index f77a965ad0..5aebc36735 100644 --- a/packages/api/workspace-files/package.json +++ b/packages/api/workspace-files/package.json @@ -64,6 +64,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-resources": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", diff --git a/packages/api/workspace-files/tests/binary-rpc.spec.ts b/packages/api/workspace-files/tests/binary-rpc.spec.ts index 35058d58a1..dc42bfbe02 100644 --- a/packages/api/workspace-files/tests/binary-rpc.spec.ts +++ b/packages/api/workspace-files/tests/binary-rpc.spec.ts @@ -5,6 +5,7 @@ import { beforeEach, afterEach, expect, it, vi } from 'vitest' import SessionStore from '@deepseek-ai/dsh-session' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import TypertGateway from '@deepseek-ai/dsh-api-gateway' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import { HostConnectionService } from '@deepseek-ai/dsh-client-connection/src/rpc-host.ts' import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' import { createWebConnectionRpc } from '@deepseek-ai/dsh-client-connection/src/client/rpc.ts' @@ -19,12 +20,14 @@ beforeEach(async () => { const ctx = harness.ctx await ctx.plugin(SessionStore) await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGateway) await ctx.plugin((scope) => { new HostConnectionService(scope, [], {} as BrowserAuth) }) await ctx.plugin(WorkspaceFiles, { maxBytes: 8, maxFileBytes: 4, maxLines: 100, maxEntries: 100 }) ctx.sessions.create(harness.scope.sessionId, { meta: { cwd: harness.workspace, origin: 'subagent' } }) - const handler = (ctx.get('connection') as HostConnectionService).createSharedFetchHandler('/api') - rpc = createWebConnectionRpc(async (path, init) => handler.fetch(new Request(new URL(path, 'http://host'), init))) + const connection = ctx.get('connection') as HostConnectionService + const handler = connection.createSharedFetchHandler('/api') + rpc = createWebConnectionRpc(async (path, init) => handler.fetch(new Request(new URL(path, 'http://host'), init), connection.browserPeer)) }) afterEach(async () => { diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index ebfb72d6e2..6734d2872b 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -71,7 +71,7 @@ const protectedModules = new Set([ '@deepseek-ai/dsh-host-webserver', '@deepseek-ai/dsh-client-modules', '@deepseek-ai/dsh-client-ui-settings-plugin-inventory', '@deepseek-ai/dsh-client-ui-plugin-manager', '@deepseek-ai/dsh-host-plugin-inventory', '@deepseek-ai/dsh-typert-registry', - '@deepseek-ai/dsh-api-remotes', + '@deepseek-ai/dsh-api-remotes', '@deepseek-ai/dsh-api-remote-access', '@deepseek-ai/cordis-plugin-timer', '@deepseek-ai/dsh-client-connection', '@deepseek-ai/dsh-host-frontend-static', '@deepseek-ai/dsh-tools', '@deepseek-ai/dsh-hmr', diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 0600956068..844d376b26 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -735,6 +735,7 @@ it.each([ '@deepseek-ai/dsh-host-plugin-inventory', '@deepseek-ai/dsh-typert-registry', '@deepseek-ai/dsh-api-remotes', + '@deepseek-ai/dsh-api-remote-access', ])('protects the management dependency %s and its containing bundle', async (name) => { const { ctx, manager, bundle, profile, dir } = await fixture('startup') bundle('extra', [{ id: 'dependency', name, disabled: true }]) diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 21e24f3865..ee2f8f280b 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -52,6 +52,11 @@ - id: typert-gateway name: '@deepseek-ai/dsh-api-gateway' + # Method-level access control for non-owner Peers; the Gateway requires it, + # and its Client half publishes the standard access Hooks. + - id: remote-access + name: '@deepseek-ai/dsh-api-remote-access' + - id: session-title name: '@deepseek-ai/dsh-session-title' config: diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index fa80fcfb55..8ec635fde1 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-goal": "workspace:*", "@deepseek-ai/dsh-goal-round-driver": "workspace:*", "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek-api-key": "workspace:*", "@deepseek-ai/dsh-llm-deepseek-account": "workspace:*", diff --git a/packages/experimental/client-ui-cot-translation/package.json b/packages/experimental/client-ui-cot-translation/package.json index 22125d92bd..d302906ff2 100644 --- a/packages/experimental/client-ui-cot-translation/package.json +++ b/packages/experimental/client-ui-cot-translation/package.json @@ -86,6 +86,7 @@ "react-dom": "^18.2.0", "@testing-library/react": "^16.3.3", "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", diff --git a/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts b/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts index 715e149dd8..87b2c75895 100644 --- a/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts +++ b/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '@deepseek-ai/dsh-api-session-controller' import Translator from '@deepseek-ai/dsh-experimental-translator' import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' @@ -66,6 +67,7 @@ async function fixture(origin?: 'subagent') { saveSelection: async () => {}, } as never) ctx.provide('fileUploads', { registerAgentResolver: () => () => {} } as never) + await ctx.plugin(RemoteAccessService) const sessions = new SessionController(ctx, { nativeOpen: false }) await ctx.plugin(Translator, Translator.Config({ bingEndpoint: `http://127.0.0.1:${address.port}/bing` })) const ui = ctx.plugin(CotTranslationController, {}) diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json index 986c902a48..98bdaccdea 100644 --- a/packages/extensions/cordis-host-runner/package.json +++ b/packages/extensions/cordis-host-runner/package.json @@ -65,6 +65,7 @@ "@deepseek-ai/cordis-plugin-timer": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-api-gateway": "workspace:*", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", diff --git a/packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts b/packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts index 321bc4cf13..2228fe462c 100644 --- a/packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts +++ b/packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts @@ -1,6 +1,8 @@ import { Context } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import TypertGateway from '@deepseek-ai/dsh-api-gateway' +import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' +import type { PeerId, PeerScope } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import DynamicCordisRunnerService from '../src/index.ts' @@ -77,12 +79,14 @@ function answer(query: ReturnType) { async function connectGatewayClient(): Promise { await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGateway) ctx.effect(() => ctx.typertGateway.registerRemoteEvents(async function* (signal) { await new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) }, { home: '/fixture' })) const connection = new AbortController() - const stream = await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), undefined, connection.signal) + const peer: PeerScope = { id: 'inspect-registry-owner' as PeerId, ctx, principal: { kind: 'owner' }, signal: connection.signal, dispose: () => Promise.resolve() } + const stream = await ctx.typertGateway.wireStream.open('$events', { args: {} }, (async function* () {})(), peer, connection.signal) const client = stream[Symbol.asyncIterator]() ctx.effect(() => async () => { connection.abort() @@ -106,6 +110,7 @@ describe('Client inspect completion', () => { it('does not dispatch a query when the Gateway has no connected Client', async () => { await ctx.plugin(TypertRegistry) + await ctx.plugin(RemoteAccessService) await ctx.plugin(TypertGateway) const query = start() expect(requests).toEqual([]) diff --git a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts index fbd46a38bc..0debc5a62a 100644 --- a/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts +++ b/packages/test-support/client-runtime/src/assembly/remote-default-responses.ts @@ -53,5 +53,7 @@ export const remoteDefaultResponses: RemoteTable = { 'account/watch': openStream([{ status: 'signed-out', attempt: null, links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' } }]), // api-workspace-controller client `apply`: the follow stream's opening baseline, then open. 'workspace/follow': openStream([{ type: 'baseline', value: { items: [], archivedSessionIds: [], pinnedSessionIds: [] } }]), + // api-remote-access client `mountAccess`: the owner's access snapshot, then open. + 'remoteAccess/follow': openStream([{ type: 'baseline', owner: true }]), }, } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52f30f185c..3ac8d8bddb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -956,6 +956,9 @@ importers: '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:* version: link:../packages/preset/agent-preset-registry + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../packages/api/remote-access '@deepseek-ai/dsh-api-session-controller': specifier: workspace:* version: link:../packages/api/session-controller @@ -1201,6 +1204,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../remote-access '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand @@ -1269,6 +1275,37 @@ importers: specifier: workspace:* version: link:../../typert/registry + packages/api/remote-access: + dependencies: + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:* + version: link:../../typert/protocol + zod: + specifier: 4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:* + version: link:../gateway + '@deepseek-ai/dsh-client-connection': + specifier: workspace:* + version: link:../../client/connection + '@deepseek-ai/dsh-client-ui-renderer': + specifier: workspace:* + version: link:../../client/ui-renderer + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:* + version: link:../../client/ui-slots + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:* + version: link:../../host/webserver + '@deepseek-ai/dsh-typert-registry': + specifier: workspace:* + version: link:../../typert/registry + packages/api/remotes: dependencies: '@deepseek-ai/dsh-deque': @@ -1368,6 +1405,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:* version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:* + version: link:../../core/session '@deepseek-ai/dsh-session-reference': specifier: workspace:* version: link:../../context/session-reference @@ -1429,6 +1469,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:* version: link:../gateway + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../remote-access '@deepseek-ai/dsh-attachment': specifier: workspace:* version: link:../../attachment/attachment @@ -1666,6 +1709,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:* version: link:../gateway + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../remote-access '@deepseek-ai/dsh-client-connection': specifier: workspace:* version: link:../../client/connection @@ -1715,6 +1761,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:* version: link:../gateway + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../remote-access '@deepseek-ai/dsh-client-connection': specifier: workspace:* version: link:../../client/connection @@ -2057,6 +2106,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:* version: link:../../api/gateway + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-attachment-local': specifier: workspace:* version: link:../../attachment/attachment-local @@ -4733,6 +4785,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes @@ -6188,6 +6243,9 @@ importers: '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:* version: link:../../preset/agent-preset-registry + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes @@ -7915,6 +7973,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:* version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes @@ -9314,6 +9375,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:* version: link:../../api/gateway + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand @@ -14070,6 +14134,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:* version: link:../../api/gateway + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes @@ -14121,6 +14188,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:* version: link:../../client/ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:* + version: link:../../client/ui-workspace '@deepseek-ai/dsh-client-web': specifier: workspace:* version: link:../../client/web diff --git a/scripts/package-dependency-policy.ts b/scripts/package-dependency-policy.ts index bfd48aecda..733abda7bf 100644 --- a/scripts/package-dependency-policy.ts +++ b/scripts/package-dependency-policy.ts @@ -61,7 +61,6 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = { /** Runtime exports that require every consumer to resolve the provider's shared peer instance. */ const PEER_REQUIRED_HOST_EXPORTS = { - '@deepseek-ai/dsh-client-connection': ['OperatorPeer'], '@deepseek-ai/dsh-scope': ['carrierKeyOf', 'createScope', 'scopeOf', 'scopeTarget'], '@deepseek-ai/dsh-session': ['SESSION_FORMAT_VERSION'], '@deepseek-ai/dsh-session-persistence': ['SessionPersistenceNotFoundError'], diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 848ef35b89..e9a1e01e1f 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -115,6 +115,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/connection': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/file-upload': { kind: 'none', reason: 'Browser-side request-body transport; registers nothing model-facing.' }, 'packages/api/remotes': { kind: 'none', reason: 'The Remote BFF selects business methods and forwarded events; selected services own any model-visible effect.' }, + 'packages/api/remote-access': { kind: 'none', reason: 'Remote access decisions for Client callers; registers nothing model-facing.' }, 'packages/experimental/client-ui-agent-team': { kind: 'none', reason: 'The browser projection and task controls register no model-facing input.' }, 'packages/experimental/client-ui-claude-code-mods': { kind: 'none', reason: 'The browser draws Host-held mod trees and relays clicks; the mods\' own hooks own any model-visible effect.' }, 'packages/client/shortcuts': { kind: 'none', reason: 'Browser keyboard routing; registers nothing model-facing.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 8e65cfb83d..6353fb86ce 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -119,6 +119,9 @@ "@deepseek-ai/dsh-api-session-controller/remote-events": ["./packages/api/session-controller/src/remote-events.ts"], "@deepseek-ai/dsh-api-settings-controller": ["./packages/api/settings-controller/src/index.ts"], "@deepseek-ai/dsh-api-settings-controller/types": ["./packages/api/settings-controller/src/types.ts"], + "@deepseek-ai/dsh-api-remote-access": ["./packages/api/remote-access/src/index.ts"], + "@deepseek-ai/dsh-api-remote-access/client": ["./packages/api/remote-access/src/client/index.ts"], + "@deepseek-ai/dsh-api-remote-access/types": ["./packages/api/remote-access/src/types.ts"], "@deepseek-ai/dsh-api-workspace-controller": ["./packages/api/workspace-controller/src/index.ts"], "@deepseek-ai/dsh-api-workspace-controller/client": ["./packages/api/workspace-controller/src/client/index.ts"], "@deepseek-ai/dsh-api-workspace-controller/default-workspace": ["./packages/api/workspace-controller/src/default-workspace.ts"], diff --git a/tsconfig.client.json b/tsconfig.client.json index b4c4e42041..fc87ea5394 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -84,6 +84,7 @@ { "path": "./packages/typert/registry" }, { "path": "./packages/api/gateway/tsconfig.client.json" }, { "path": "./packages/api/session-controller/tsconfig.client.json" }, + { "path": "./packages/api/remote-access/tsconfig.client.json" }, { "path": "./packages/api/workspace-controller/tsconfig.client.json" }, { "path": "./packages/api/remotes/tsconfig.client.json" }, { "path": "./packages/extensions/cordis-client-runner" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 93c52c2a4d..ec1bcc2316 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -318,6 +318,7 @@ { "path": "./packages/api/session-controller/tsconfig.host.json" }, { "path": "./packages/api/workspace-files/tsconfig.host.json" }, { "path": "./packages/api/settings-controller" }, + { "path": "./packages/api/remote-access/tsconfig.host.json" }, { "path": "./packages/api/workspace-controller/tsconfig.host.json" }, { "path": "./packages/typert/loader" }, { "path": "./packages/session/session-persistence" }, diff --git a/vitest.config.ts b/vitest.config.ts index 7722718045..c355d1d7db 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -333,6 +333,9 @@ export default defineConfig({ // The mods band entry imports the bridge's generated Remote contribution, which exists only in lib; // the Web snapshot exercises the built entry, while source tests cover mountModsBand. 'packages/experimental/client-ui-claude-code-mods/src/client/index.ts', + // The access entry imports its generated Remote contribution, which exists only in lib; + // the Web snapshot exercises the built entry, while source tests cover mountAccess. + 'packages/api/remote-access/src/client/index.ts', // Slash/command/input round: per-file gaps deferred with the same // client-lane debt. TODO(gui): cover and remove with the lane above. 'packages/client/ui-commands/src/index.ts',