diff --git a/apps/cli/composition.md b/apps/cli/composition.md
index 1da393bd06..0246f6163f 100644
--- a/apps/cli/composition.md
+++ b/apps/cli/composition.md
@@ -18,6 +18,12 @@ flowchart LR
cfg --> plugin_dsh_base_llm
plugin_dsh_base_session["session
@deepseek-ai/dsh-session"]
cfg --> plugin_dsh_base_session
+ plugin_dsh_base_typert["typert
@deepseek-ai/dsh-typert-registry"]
+ cfg --> plugin_dsh_base_typert
+ plugin_dsh_base_typert_loader["typert-loader
@deepseek-ai/dsh-typert-loader"]
+ cfg --> plugin_dsh_base_typert_loader
+ plugin_dsh_base_typert_gateway["typert-gateway
@deepseek-ai/dsh-host-api-gateway"]
+ cfg --> plugin_dsh_base_typert_gateway
plugin_dsh_base_session_title["session-title
@deepseek-ai/dsh-session-title"]
cfg --> plugin_dsh_base_session_title
plugin_dsh_base_session_title_llm["session-title-llm
@deepseek-ai/dsh-session-title-first-message-llm"]
@@ -159,6 +165,9 @@ flowchart LR
| `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` |
| `llm` | `@deepseek-ai/dsh-llm` |
| `session` | `@deepseek-ai/dsh-session` |
+| `typert` | `@deepseek-ai/dsh-typert-registry` |
+| `typert-loader` | `@deepseek-ai/dsh-typert-loader` |
+| `typert-gateway` | `@deepseek-ai/dsh-host-api-gateway` |
| `session-title` | `@deepseek-ai/dsh-session-title` |
| `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` |
| `user-interaction` | `@deepseek-ai/dsh-user-interaction` |
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 6a8f7943c2..18839bf3c2 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -32,6 +32,8 @@ flowchart LR
pkg_typert_registry["typert-registry"]
svc_typert["ctx.typert
Runtime type registry"]
pkg_typert_loader["typert-loader"]
+ pkg_api_gateway["api-gateway"]
+ svc_typertGateway["ctx.typertGateway
TypeRT Host invocation gateway"]
svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"]
pkg_session_persistence_jsonl["session-persistence-jsonl"]
pkg_session_persistence_sqlite["session-persistence-sqlite"]
@@ -171,6 +173,7 @@ flowchart LR
pkg_acp --> svc_approval
pkg_agent --> svc_agents
pkg_agent_loop --> svc_agentLoop
+ pkg_api_gateway --> svc_typertGateway
pkg_approval --> svc_approval
pkg_bash --> svc_bash
pkg_bash_env --> svc_bashEnv
@@ -347,6 +350,7 @@ flowchart LR
svc_tools --> pkg_tool_subagent
svc_tools --> pkg_tool_todo
svc_tools --> pkg_tool_web
+ svc_typert --> pkg_api_gateway
svc_typert --> pkg_typert_loader
svc_userInteraction --> pkg_tool_ask_user
svc_web --> pkg_tool_web
@@ -363,7 +367,8 @@ flowchart LR
| `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. |
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
| `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
-| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader) | - | Plugins register live zod contributions directly or through dsh-typert-loader; runtime consumers query schemas and reflection metadata at their own edges. |
+| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), `api-gateway` | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. |
+| `ctx.typertGateway` | `core` | `api-gateway` | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. |
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. |
| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. |
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index f9fa6e8bb2..5728ac4bed 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -291,7 +291,7 @@ Source: [`packages/examples/cli-demo/src/index.ts:26`](../packages/examples/cli-
## `@deepseek-ai/dsh-client-connection`
-Requires: `httpServer` · `apiProxy`
+Requires: `httpServer`
```ts config-catalog
/** Plugin config: the deployment's non-loopback serving authorities. */
@@ -308,7 +308,7 @@ export interface ConnectionConfig {
}
```
-Source: [`packages/client/connection/src/index.ts:21`](../packages/client/connection/src/index.ts)
+Source: [`packages/client/connection/src/index.ts:31`](../packages/client/connection/src/index.ts)
## `@deepseek-ai/dsh-client-hmr`
@@ -2548,6 +2548,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-commands` ([`packages/ui/commands/src/index.ts`](../packages/ui/commands/src/index.ts))
- `@deepseek-ai/dsh-fs-policy` ([`packages/fs/fs-policy/src/index.ts`](../packages/fs/fs-policy/src/index.ts))
- `@deepseek-ai/dsh-goal-session` — requires `agents` · `goals` · `sessions` ([`packages/goal/goal-session/src/index.ts`](../packages/goal/goal-session/src/index.ts))
+- `@deepseek-ai/dsh-host-api-gateway` — requires `typert` ([`packages/host/api-gateway/src/index.ts`](../packages/host/api-gateway/src/index.ts))
- `@deepseek-ai/dsh-host-directory-picker-auto` — requires `httpServer` · `loader` ([`packages/host/directory-picker-auto/src/index.ts`](../packages/host/directory-picker-auto/src/index.ts))
- `@deepseek-ai/dsh-host-directory-picker-native` ([`packages/host/directory-picker-native/src/index.ts`](../packages/host/directory-picker-native/src/index.ts))
- `@deepseek-ai/dsh-llm` ([`packages/llm/llm/src/index.ts`](../packages/llm/llm/src/index.ts))
@@ -2563,6 +2564,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts))
- `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts))
- `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts))
+- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts))
- `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts))
- `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts))
- `@deepseek-ai/dsh-workspace` — requires `storageDomain` · `sessionPersistence` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts))
@@ -2620,4 +2622,6 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
- `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts))
- `@deepseek-ai/dsh-telemetry` ([`packages/sdk/telemetry/src/index.ts`](../packages/sdk/telemetry/src/index.ts))
- `@deepseek-ai/dsh-timeout` ([`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts))
+- `@deepseek-ai/dsh-type-meta` ([`packages/typert/type-meta/src/index.ts`](../packages/typert/type-meta/src/index.ts))
- `@deepseek-ai/dsh-typert-generator` ([`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts))
+- `@deepseek-ai/dsh-typert-registry` ([`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts))
diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md
index 955adbe234..348d334e9f 100644
--- a/docs/cordis-catalog/events.md
+++ b/docs/cordis-catalog/events.md
@@ -542,7 +542,7 @@ Creation announcement during session publication. A synchronous throw vetoes and
Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
-Source: [`packages/core/session/src/index.ts:73`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:74`](../../packages/core/session/src/index.ts)
### `session/disposed` — emit
@@ -563,7 +563,7 @@ Emitted once when an announced session leaves the store, including publication r
Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
-Source: [`packages/core/session/src/index.ts:83`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:84`](../../packages/core/session/src/index.ts)
### `session/event` — emit
@@ -586,7 +586,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before
Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md)
-Source: [`packages/core/session/src/index.ts:95`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:96`](../../packages/core/session/src/index.ts)
### `session/flush` — parallel
@@ -606,7 +606,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await
Types: [Scoped](../core-data-structures/scope.md) · [Session](../core-data-structures/session.md)
-Source: [`packages/core/session/src/index.ts:104`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:105`](../../packages/core/session/src/index.ts)
## `settings/*`
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index b2f667681d..0a9af0bae5 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -216,7 +216,7 @@ roots(): Agent[]
Types: [Agent](../core-data-structures/core.md) · [SessionId](../core-data-structures/core.md)
-Source: [`packages/core/agent/src/index.ts:242`](../../packages/core/agent/src/index.ts)
+Source: [`packages/core/agent/src/index.ts:253`](../../packages/core/agent/src/index.ts)
## `ctx.approval` — `ApprovalService`
@@ -1748,7 +1748,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [PrepareSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md)
-Source: [`packages/core/session/src/index.ts:800`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:807`](../../packages/core/session/src/index.ts)
## `ctx.sessionTitle` — `SessionTitleService`
@@ -2527,16 +2527,17 @@ Source: [`packages/core/tools/src/index.ts:739`](../../packages/core/tools/src/i
## `ctx.typert` — `TypertRegistry`
-Registry of generated schemas and package reflection.
+Registry of generated schemas, package reflection, invocations, and Remote dependency providers.
```ts cordis-catalog
/**
* Register one generated contribution atomically for the calling fiber.
- * Duplicate package-face identities or schema keys reject the whole batch.
- * @param contribution - generated schemas and package metadata.
+ * Duplicate package-face identities, schemas, invocation ids, or endpoints
+ * reject the whole batch.
+ * @param contribution - generated schemas, reflection, and Host invocations.
* @returns the exact effect disposer that removes this contribution.
*/
-register(contribution: TypertContribution): () => void
+register(contribution: TypertContribution): TypeRTDisposer
/**
* Look up one schema by `#`.
@@ -2584,7 +2585,23 @@ listPackages(filter: TypertPackageFilter = {}): TypertPackageRecord[]
toJSONSchema(key: string, params?: z.core.ToJSONSchemaParams): z.core.JSONSchema.BaseSchema
```
-Source: [`packages/typert/registry/src/index.ts:67`](../../packages/typert/registry/src/index.ts)
+Source: [`packages/typert/registry/src/service.ts:319`](../../packages/typert/registry/src/service.ts)
+
+## `ctx.typertGateway` — `TypertGatewayService`
+
+Resolve strict generated definitions or conservative SRC markers against current Cordis Services and TypeRT providers.
+
+```ts cordis-catalog
+/**
+ * Invoke one live Remote method through strict generated reflection or SRC markers.
+ * @param request - decoded endpoint and exact named wire arguments.
+ * @returns the validated business result.
+ * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.
+ */
+async invoke(request: InvokeRemoteRequest): Promise
+```
+
+Source: [`packages/host/api-gateway/src/index.ts:94`](../../packages/host/api-gateway/src/index.ts)
## `ctx.userInteraction` — `UserInteractionService`
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 1a7ae5c8d2..fd9ac036a0 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -211,6 +211,7 @@ flowchart TD
end
subgraph group_host["packages/host"]
pkg_frontend_static["frontend-static"]
+ pkg_host_api_gateway["host-api-gateway"]
pkg_host_apiproxy["host-apiproxy"]
pkg_host_directory_picker["host-directory-picker"]
pkg_host_directory_picker_auto["host-directory-picker-auto"]
@@ -272,6 +273,7 @@ flowchart TD
pkg_session_telemetry_otel["session-telemetry-otel"]
end
subgraph group_typert["packages/typert"]
+ pkg_type_meta["type-meta"]
pkg_typert_generator["typert-generator"]
pkg_typert_loader["typert-loader"]
pkg_typert_registry["typert-registry"]
@@ -311,6 +313,7 @@ flowchart TD
pkg_host_webserver --> pkg_invariants
pkg_storage --> pkg_invariants
pkg_subprocess --> pkg_invariants
+ pkg_type_meta --> pkg_invariants
pkg_typert_generator --> pkg_invariants
pkg_typert_registry --> pkg_invariants
pkg_llm --> pkg_brand
@@ -374,6 +377,7 @@ flowchart TD
pkg_session --> pkg_invariants
pkg_session --> pkg_llm
pkg_session --> pkg_scope
+ pkg_session --> pkg_type_meta
pkg_system_prompt --> pkg_invariants
pkg_system_prompt --> pkg_llm
pkg_system_prompt --> pkg_scope
@@ -420,6 +424,9 @@ flowchart TD
pkg_credentials_local --> pkg_credentials
pkg_credentials_local --> pkg_invariants
pkg_credentials_local --> pkg_paths
+ pkg_host_api_gateway --> pkg_client_connection
+ pkg_host_api_gateway --> pkg_invariants
+ pkg_host_api_gateway --> pkg_typert_registry
pkg_lsp --> pkg_brand
pkg_lsp --> pkg_invariants
pkg_lsp --> pkg_llm
@@ -434,6 +441,7 @@ flowchart TD
pkg_agent --> pkg_scope
pkg_agent --> pkg_session
pkg_agent --> pkg_system_prompt
+ pkg_agent --> pkg_type_meta
pkg_bash --> pkg_invariants
pkg_bash --> pkg_sandbox
pkg_bash --> pkg_subprocess
@@ -1154,6 +1162,7 @@ flowchart TD
| [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
| [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) |
| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/support/invariants) |
+| [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) |
| [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) |
| [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) |
| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) |
@@ -1175,7 +1184,7 @@ flowchart TD
| [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) |
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
-| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
+| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) |
| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
@@ -1186,10 +1195,11 @@ flowchart TD
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
+| [`host-api-gateway`](../packages/host/api-gateway) | `host` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) |
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) |
-| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
+| [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) |
| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) |
| [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
diff --git a/package.json b/package.json
index 7bd84db93a..9d0cac6d5e 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,10 @@
],
"scripts": {
"build": "npm run build:lib && npm run build:web",
- "build:lib": "tsc -b && tsdown",
+ "build:lib": "npm run build:lib:host && npm run build:lib:client",
+ "build:lib:host": "npm run build:lib:contracts && tsc -b tsconfig.host.json",
+ "build:lib:contracts": "tsc -b packages/typert/generator && tsdown --config tsdown.typert-host.config.ts",
+ "build:lib:client": "tsc -b tsconfig.client.json && tsdown",
"build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build",
"clean": "tsx scripts/clean.ts",
"change-scope": "tsx scripts/change-scope.ts",
diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml
index f0577552cc..0b1cc43a50 100644
--- a/packages/bundle/base/cordis.patch.yml
+++ b/packages/bundle/base/cordis.patch.yml
@@ -34,6 +34,15 @@
- id: session
name: '@deepseek-ai/dsh-session'
+ - id: typert
+ name: '@deepseek-ai/dsh-typert-registry'
+
+ - id: typert-loader
+ name: '@deepseek-ai/dsh-typert-loader'
+
+ - id: typert-gateway
+ name: '@deepseek-ai/dsh-host-api-gateway'
+
- 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 c6519171ca..2ec17d9c66 100644
--- a/packages/bundle/base/package.json
+++ b/packages/bundle/base/package.json
@@ -49,6 +49,7 @@
"@deepseek-ai/dsh-fs-sandbox": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-session": "workspace:^",
+ "@deepseek-ai/dsh-host-api-gateway": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
@@ -95,6 +96,8 @@
"@deepseek-ai/dsh-tool-web": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
+ "@deepseek-ai/dsh-typert-loader": "workspace:^",
+ "@deepseek-ai/dsh-typert-registry": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-web": "workspace:^",
diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts
index 67b47b06c6..521e54160e 100644
--- a/packages/client/connection/src/client/index.ts
+++ b/packages/client/connection/src/client/index.ts
@@ -8,7 +8,9 @@ import type { IApiClient } from './api.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
import { WebApiClient } from './web-api-client.ts'
+import { createUnavailableConnectionRpc, createWebConnectionRpc } from './rpc.ts'
import { isLoopbackHostname } from '../loopback-hostname.ts'
+import type { ClientConnectionRpc } from '../rpc.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
@@ -36,6 +38,7 @@ export {
// Connection loop types are public through ConnectionHandle.start; the
// controller remains package-internal.
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
+export type { ClientConnectionRpc } from '../rpc.ts'
/** Required services (none — this is the wire root). */
@@ -51,6 +54,8 @@ export interface ConnectionHandle {
readonly api: IApiClient
/** Whether the current page authority is loopback; non-browser contexts default to true. */
readonly isLoopback: boolean
+ /** Generic logical RPC channels over the same Connection transport. */
+ readonly rpc: ClientConnectionRpc
/**
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
* One consumer owns the streams (the runtime object layer); a second call
@@ -70,10 +75,12 @@ export function apply(ctx: Context): void {
const pageLocation = typeof location === 'undefined' ? undefined : location
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient()
+ const rpc = fixture ? createUnavailableConnectionRpc() : createWebConnectionRpc()
let started = false
const handle: ConnectionHandle = {
api,
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
+ rpc,
start(sinks, config) {
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
started = true
diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts
new file mode 100644
index 0000000000..36e16426b2
--- /dev/null
+++ b/packages/client/connection/src/client/rpc.ts
@@ -0,0 +1,75 @@
+/** Browser caller for generic Connection unary RPC channels. */
+
+import {
+ RpcId,
+ serverResponseSchema,
+ type ClientRequest,
+} from '@deepseek-ai/dsh-host-apiproxy/api'
+import type { ClientConnectionRpc } from '../rpc.ts'
+
+const INTERNAL_BASE = 'http://dsh.internal'
+const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
+const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
+
+/**
+ * Create the browser-backed generic RPC caller.
+ * @returns caller that owns request correlation and response-envelope validation.
+ */
+export function createWebConnectionRpc(): ClientConnectionRpc {
+ return {
+ async call(channel, endpoint, payload, signal) {
+ assertTarget(channel, endpoint)
+ const rpcId = RpcId(crypto.randomUUID())
+ const message: ClientRequest = {
+ type: 'client-request',
+ rpcId,
+ method: endpoint,
+ payload,
+ }
+ const response = await globalThis.fetch(
+ new URL(`${channel}/${endpoint}`, resolveBase()),
+ {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(message),
+ ...signal === undefined ? {} : { signal },
+ },
+ )
+ if (!response.ok) {
+ throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`)
+ }
+ const full = serverResponseSchema.parse(await response.json())
+ if (full.rpcId !== rpcId) {
+ throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`)
+ }
+ return full.result
+ },
+ }
+}
+
+/**
+ * Create the fixture-mode caller, where no Host Remote registry exists.
+ * @returns caller that rejects every generic Remote invocation.
+ */
+export function createUnavailableConnectionRpc(): ClientConnectionRpc {
+ return {
+ call(channel, endpoint) {
+ return Promise.reject(new Error(`connection RPC ${channel}/${endpoint} is unavailable in fixture mode`))
+ },
+ }
+}
+
+function resolveBase(): string {
+ const location = (globalThis as { location?: { origin?: string } }).location
+ return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE
+}
+
+function assertTarget(channel: string, endpoint: string): void {
+ const segments = endpoint.split('/')
+ if (!CHANNEL_PATTERN.test(channel)
+ || segments.length === 0
+ || segments.some(segment =>
+ segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
+ throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`)
+ }
+}
diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts
index 2e27a78d70..d8b6ef8846 100644
--- a/packages/client/connection/src/index.ts
+++ b/packages/client/connection/src/index.ts
@@ -7,15 +7,25 @@ import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
import { bridge } from './http-bridge.ts'
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
+import { HostConnectionService } from './rpc-host.ts'
import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts'
+export type {
+ ConnectionRpcAuthority,
+ ConnectionRpcHandler,
+ ConnectionRpcHandlerOptions,
+ HostConnectionHandle,
+ HostConnectionRpc,
+} from './rpc.ts'
+export { HostConnectionService } from './rpc-host.ts'
+
export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
/** Stable Cordis plugin name. */
export const name = 'client-connection'
-/** Services required before mounting the route. */
-export const inject = ['httpServer', 'apiProxy']
+/** Services required before providing Connection; legacy `/api` attaches when apiProxy is present. */
+export const inject = ['httpServer']
/** Plugin config: the deployment's non-loopback serving authorities. */
export interface ConnectionConfig {
@@ -83,49 +93,52 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
// Config boundary: a malformed entry fails the load loudly here rather than
// silently authorizing its hostname prefix at request time.
for (const entry of trustedHosts) assertTrustedAuthority(entry)
- const apiHandler = toFetchHandler(ctx.apiProxy)
- const downlinks = new WebSocketDownlinks(ctx.apiProxy)
- const route: WebRoute = {
- kind: 'prefix',
- path: API_PATH,
- handler: async (req, res) => {
- const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
- const method = pathname.startsWith(`${API_PATH}/`)
- ? pathname.slice(API_PATH.length + 1)
- : undefined
- const allowed = method !== undefined && PRIVILEGED_METHODS.has(method)
- ? isTrustedApiRequest(req, [])
- : isTrustedApiRequest(req, trustedHosts)
- if (!allowed) {
- res.writeHead(403)
- res.end('forbidden')
- return
- }
- if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
- res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' })
- res.end('upgrade required')
- return
- }
- await bridge(req, res, apiHandler)
- },
- }
- ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
- const registerDownlink = (
- path: string,
- handle: WebUpgradeRoute['handler'],
- ): void => {
- ctx.effect(() => ctx.httpServer.registerUpgrade({
- path,
- handler: (req, socket, head) => {
- if (!isTrustedApiRequest(req, trustedHosts)) {
- rejectWebSocketUpgrade(socket)
+ new HostConnectionService(ctx, trustedHosts)
+ ctx.inject(['apiProxy'], (apiCtx) => {
+ const apiHandler = toFetchHandler(apiCtx.apiProxy)
+ const downlinks = new WebSocketDownlinks(apiCtx.apiProxy)
+ const route: WebRoute = {
+ kind: 'prefix',
+ path: API_PATH,
+ handler: async (req, res) => {
+ const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
+ const method = pathname.startsWith(`${API_PATH}/`)
+ ? pathname.slice(API_PATH.length + 1)
+ : undefined
+ const allowed = method !== undefined && PRIVILEGED_METHODS.has(method)
+ ? isTrustedApiRequest(req, [])
+ : isTrustedApiRequest(req, trustedHosts)
+ if (!allowed) {
+ res.writeHead(403)
+ res.end('forbidden')
return
}
- return handle(req, socket, head)
+ if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
+ res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' })
+ res.end('upgrade required')
+ return
+ }
+ await bridge(req, res, apiHandler)
},
- }), `client-connection: ${path} WebSocket`)
- }
- ctx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
- registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
- registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
+ }
+ apiCtx.effect(() => apiCtx.httpServer.register(route), 'client-connection: /api route')
+ const registerDownlink = (
+ path: string,
+ handle: WebUpgradeRoute['handler'],
+ ): void => {
+ apiCtx.effect(() => apiCtx.httpServer.registerUpgrade({
+ path,
+ handler: (req, socket, head) => {
+ if (!isTrustedApiRequest(req, trustedHosts)) {
+ rejectWebSocketUpgrade(socket)
+ return
+ }
+ return handle(req, socket, head)
+ },
+ }), `client-connection: ${path} WebSocket`)
+ }
+ apiCtx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
+ registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
+ registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
+ })
}
diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts
new file mode 100644
index 0000000000..be9eedca8f
--- /dev/null
+++ b/packages/client/connection/src/rpc-host.ts
@@ -0,0 +1,150 @@
+/** Host registry and HTTP adapter for generic Connection RPC channels. */
+
+import { Context, Service } from 'cordis'
+import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
+import {
+ clientRequestSchema,
+ RpcId,
+ type ClientRequest,
+ type RpcError,
+ type RpcId as RpcIdType,
+ type ServerResponse as RpcServerResponse,
+} from '@deepseek-ai/dsh-host-apiproxy/api'
+import { bridge } from './http-bridge.ts'
+import { isTrustedApiRequest } from './api-request-trust.ts'
+import type {
+ ConnectionRpcHandler,
+ ConnectionRpcHandlerOptions,
+ HostConnectionHandle,
+ HostConnectionRpc,
+} from './rpc.ts'
+
+const INVALID_REQUEST_RPC_ID = RpcId('invalid-request')
+const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
+const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
+
+/** Host Connection service whose channel registrations belong to the caller fiber. */
+export class HostConnectionService extends Service implements HostConnectionHandle {
+ /**
+ * Provide the Host half over the active HTTP server.
+ * @param ctx - owning Connection plugin context.
+ * @param trustedHosts - deployment authorities accepted by trusted-host channels.
+ */
+ constructor(ctx: Context, private readonly trustedHosts: readonly string[]) {
+ super(ctx, 'connection')
+ }
+
+ /** Generic channel registry scoped to the Context reading this service. */
+ get rpc(): HostConnectionRpc {
+ const owner = this.ctx
+ return {
+ handle: (channel, handler, options) => this.register(owner, channel, handler, options),
+ }
+ }
+
+ private register(
+ owner: Context,
+ channel: string,
+ handler: ConnectionRpcHandler,
+ options: ConnectionRpcHandlerOptions,
+ ): () => Promise {
+ assertChannel(channel)
+ const trustedHosts = options.authority === 'loopback' ? [] : this.trustedHosts
+ const fetchHandler = rpcFetchHandler(channel, handler)
+ const route: WebRoute = {
+ kind: 'prefix',
+ path: channel,
+ handler: async (req, res) => {
+ if (!isTrustedApiRequest(req, trustedHosts)) {
+ res.writeHead(403)
+ res.end('forbidden')
+ return
+ }
+ await bridge(req, res, fetchHandler)
+ },
+ }
+ return owner.effect(
+ () => owner.httpServer.register(route),
+ `client-connection: ${channel} rpc channel`,
+ )
+ }
+}
+
+function rpcFetchHandler(
+ channel: string,
+ handler: ConnectionRpcHandler,
+): { fetch: typeof fetch } {
+ return {
+ async fetch(input: RequestInfo | URL, init?: RequestInit): Promise {
+ const request = input instanceof Request ? input : new Request(input, init)
+ const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
+ if (request.method !== 'POST' || endpoint === undefined) {
+ return new Response('not found', { status: 404 })
+ }
+
+ const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
+ if (mediaType !== 'application/json') {
+ return new Response('content type must be application/json', { status: 415 })
+ }
+
+ let body: unknown
+ try {
+ body = await request.json()
+ } catch {
+ return new Response('body is not JSON', { status: 400 })
+ }
+
+ const envelope = clientRequestSchema.safeParse(body)
+ if (!envelope.success) {
+ const rawId = (body as { rpcId?: unknown } | null)?.rpcId
+ const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID
+ return errorResponse(rpcId, {
+ code: 'bad-request',
+ message: 'invalid client-request message',
+ details: { issues: envelope.error.issues },
+ })
+ }
+ const message: ClientRequest = envelope.data
+ if (message.method !== endpoint) {
+ return errorResponse(message.rpcId, {
+ code: 'bad-request',
+ message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
+ details: { issues: [] },
+ })
+ }
+
+ try {
+ const result = await handler(endpoint, message.payload, request.signal)
+ return fullResponse(message.rpcId, result)
+ } catch (error) {
+ return new Response(`handler failure: ${String(error)}`, { status: 500 })
+ }
+ },
+ }
+}
+
+function endpointFromPath(channel: string, pathname: string): string | undefined {
+ if (!pathname.startsWith(`${channel}/`)) return undefined
+ const endpoint = pathname.slice(channel.length + 1)
+ const segments = endpoint.split('/')
+ if (segments.length === 0 || segments.some(segment =>
+ segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
+ return undefined
+ }
+ return endpoint
+}
+
+function errorResponse(rpcId: RpcIdType, error: RpcError): Response {
+ return fullResponse(rpcId, { ok: false, error })
+}
+
+function fullResponse(rpcId: RpcIdType, result: RpcServerResponse['result']): Response {
+ const body: RpcServerResponse = { type: 'server-response', rpcId, result }
+ return Response.json(body)
+}
+
+function assertChannel(channel: string): void {
+ if (!CHANNEL_PATTERN.test(channel) || channel === '/api') {
+ throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`)
+ }
+}
diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts
new file mode 100644
index 0000000000..ab68783724
--- /dev/null
+++ b/packages/client/connection/src/rpc.ts
@@ -0,0 +1,59 @@
+/** Generic unary RPC contracts shared by the Host and Client Connection halves. */
+
+import type { RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
+
+/** Trust fence applied before a Host RPC channel reaches its handler. */
+export type ConnectionRpcAuthority = 'trusted-host' | 'loopback'
+
+/** Registration policy for one logical RPC channel. */
+export interface ConnectionRpcHandlerOptions {
+ /** Browser authority accepted by every endpoint in this channel. */
+ readonly authority: ConnectionRpcAuthority
+}
+
+/** Handler invoked after Connection has decoded the transport envelope. */
+export type ConnectionRpcHandler = (
+ endpoint: string,
+ payload: unknown,
+ signal: AbortSignal,
+) => Promise>
+
+/** Host registry for logical RPC channels carried by the current transport. */
+export interface HostConnectionRpc {
+ /**
+ * Register one absolute channel prefix and its trust policy.
+ * @param channel - absolute logical channel such as `/api2`.
+ * @param handler - decoded endpoint handler returning the existing RPC result shape.
+ * @param options - channel trust policy.
+ * @returns asynchronous disposer removing the channel and its physical route.
+ */
+ handle(
+ channel: string,
+ handler: ConnectionRpcHandler,
+ options: ConnectionRpcHandlerOptions,
+ ): () => Promise
+}
+
+/** Host `ctx.connection` shape consumed by transport-independent adapters. */
+export interface HostConnectionHandle {
+ /** Generic RPC channel registry. */
+ readonly rpc: HostConnectionRpc
+}
+
+/** Client caller for logical RPC channels carried by the current transport. */
+export interface ClientConnectionRpc {
+ /**
+ * Call one endpoint through an already registered logical channel.
+ * @param channel - absolute logical channel such as `/api2`.
+ * @param endpoint - channel-relative endpoint such as `goals/create`.
+ * @param payload - channel-owned request payload.
+ * @param signal - optional caller cancellation.
+ * @returns the existing RPC success/error result; correlation stays inside Connection.
+ */
+ call(
+ channel: string,
+ endpoint: string,
+ payload: unknown,
+ signal?: AbortSignal,
+ ): Promise>
+}
diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts
index 524983fb4f..d93844a2b8 100644
--- a/packages/client/connection/tests/client-apply.spec.ts
+++ b/packages/client/connection/tests/client-apply.spec.ts
@@ -203,4 +203,41 @@ describe('connection client apply', () => {
expect(sockets).toHaveLength(1)
expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
})
+
+ it('carries generic RPC calls over the isolated channel with rpcId echo validation', async () => {
+ ;(globalThis as Win).location = { hostname: 'localhost', search: '' }
+ const handle = await mount()
+ const original = globalThis.fetch
+ const seen: { url: string; body: unknown }[] = []
+ globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit) => {
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
+ if (typeof init?.body !== 'string') throw new TypeError('expected a JSON string request body')
+ const body = JSON.parse(init.body) as { rpcId: string }
+ seen.push({ url, body })
+ return Response.json({
+ type: 'server-response',
+ rpcId: body.rpcId,
+ result: { ok: true, value: { ref: 'goal-1' } },
+ })
+ }
+ try {
+ await expect(handle.rpc.call('/api2', 'goals/create', { args: { agentId: 'agent-1' } }))
+ .resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
+ } finally {
+ globalThis.fetch = original
+ }
+ expect(seen).toHaveLength(1)
+ expect(seen[0]?.url).toBe('http://dsh.internal/api2/goals/create')
+ expect(seen[0]?.body).toMatchObject({
+ type: 'client-request',
+ method: 'goals/create',
+ payload: { args: { agentId: 'agent-1' } },
+ })
+ })
+
+ it('keeps generic Remote calls unavailable in the client-only fixture', async () => {
+ ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
+ const handle = await mount()
+ await expect(handle.rpc.call('/api2', 'goals/create', {})).rejects.toThrow(/unavailable in fixture mode/)
+ })
})
diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts
index 3015881d2f..af85d4e510 100644
--- a/packages/client/connection/tests/node-half.spec.ts
+++ b/packages/client/connection/tests/node-half.spec.ts
@@ -7,8 +7,9 @@ import { describe, expect, it } from 'vitest'
import type { AddressInfo } from 'node:net'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
+import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
-import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts'
+import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts'
/** Structural httpServer fake recording both route registries. */
function fakeHttpServer(
@@ -17,6 +18,9 @@ function fakeHttpServer(
): Pick {
return {
register(route) {
+ if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
+ throw new Error(`duplicate route ${route.path}`)
+ }
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
@@ -36,15 +40,25 @@ function fakeRequest(headers: Record, url = `${API_PATH}/session
return request
}
+/** JSON POST carrying a complete client-request envelope. */
+function fakePost(headers: Record, url: string, body: unknown): IncomingMessage {
+ const request = Readable.from([Buffer.from(JSON.stringify(body))]) as unknown as IncomingMessage
+ Object.assign(request, { url, method: 'POST', headers: { 'content-type': 'application/json', ...headers } })
+ return request
+}
+
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
const state: { status?: number; body?: unknown } = {}
+ const chunks: Buffer[] = []
const response = Object.assign(new EventEmitter(), {
writableEnded: false,
writeHead(value: number) { state.status = value; return this },
- write() { return true },
+ write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true },
end(this: { writableEnded: boolean }, value?: unknown) {
- if (value !== undefined) state.body = value
+ if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value))
+ else if (value !== undefined) throw new TypeError('fake response only accepts string or Uint8Array bodies')
+ if (chunks.length > 0) state.body = Buffer.concat(chunks).toString()
this.writableEnded = true
return this
},
@@ -173,6 +187,78 @@ describe('connection node half', () => {
expect(declared.state.status).toBe(404)
await dispose()
})
+
+ it('provides a disposable generic RPC channel without requiring apiProxy', async () => {
+ const ctx = new Context()
+ const routes: WebRoute[] = []
+ ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
+ const fiber = ctx.plugin({ inject: [...inject], apply })
+ await fiber.await()
+ expect(routes).toHaveLength(0)
+
+ const connection = ctx.get('connection') as HostConnectionHandle
+ const calls: unknown[] = []
+ const remove = connection.rpc.handle('/api2', async (endpoint, payload) => {
+ calls.push({ endpoint, payload })
+ return { ok: true, value: { accepted: true } }
+ }, { authority: 'trusted-host' })
+ const route = routes.find(candidate => candidate.path === '/api2')
+ expect(route).toBeDefined()
+
+ const request: ClientRequest = {
+ type: 'client-request',
+ rpcId: RpcId('rpc-api2'),
+ method: 'goals/create',
+ payload: { args: { agentId: 'agent-1' } },
+ }
+ const result = fakeResponse()
+ await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/api2/goals/create', request), result.response)
+ expect(result.state.status).toBe(200)
+ expect(JSON.parse(String(result.state.body))).toEqual({
+ type: 'server-response',
+ rpcId: 'rpc-api2',
+ result: { ok: true, value: { accepted: true } },
+ })
+ expect(calls).toEqual([{
+ endpoint: 'goals/create',
+ payload: { args: { agentId: 'agent-1' } },
+ }])
+
+ expect(() => connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), {
+ authority: 'trusted-host',
+ })).toThrow(/duplicate route/)
+ await remove()
+ expect(routes).toHaveLength(0)
+ await fiber.dispose()
+ })
+
+ it('applies the configured trust fence and JSON envelope checks to generic channels', async () => {
+ const ctx = new Context()
+ const routes: WebRoute[] = []
+ ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
+ const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
+ await fiber.await()
+ const connection = ctx.get('connection') as HostConnectionHandle
+ const remove = connection.rpc.handle('/api2', async () => ({ ok: true, value: null }), {
+ authority: 'trusted-host',
+ })
+ const route = routes[0]!
+
+ const denied = fakeResponse()
+ await route.handler(fakePost({ host: 'other.example' }, '/api2/goals/create', {}), denied.response)
+ expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
+
+ const badEnvelope = fakeResponse()
+ await route.handler(fakePost({ host: 'harness.example' }, '/api2/goals/create', {
+ type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {},
+ }), badEnvelope.response)
+ expect(JSON.parse(String(badEnvelope.state.body))).toMatchObject({
+ rpcId: 'rpc-bad',
+ result: { ok: false, error: { code: 'bad-request' } },
+ })
+ await remove()
+ await fiber.dispose()
+ })
})
describe('connection node half over a real HTTP server', () => {
diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts
index e5691a0619..14e51fae8e 100644
--- a/packages/client/runtime/tests/client-apply.spec.ts
+++ b/packages/client/runtime/tests/client-apply.spec.ts
@@ -27,6 +27,9 @@ async function mount(): Promise {
const handle: ConnectionHandle = {
api,
isLoopback: true,
+ rpc: {
+ call: () => Promise.reject(new Error('unexpected generic RPC call')),
+ },
start: (sinks) => {
bench.sinks = sinks
return { stop: () => { bench.stopped += 1 } }
diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts
index 21e7f1fc06..f081eb54c1 100644
--- a/packages/client/runtime/tests/wire-events.spec.ts
+++ b/packages/client/runtime/tests/wire-events.spec.ts
@@ -21,6 +21,9 @@ async function mount(): Promise {
const handle: ConnectionHandle = {
api,
isLoopback: true,
+ rpc: {
+ call: () => Promise.reject(new Error('unexpected generic RPC call')),
+ },
start: (sinks) => {
bench.sinks = sinks
return { stop: () => {} }
diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts
index 1e45991080..74facbd69b 100644
--- a/packages/client/tsdown.client.ts
+++ b/packages/client/tsdown.client.ts
@@ -31,6 +31,9 @@ const CSS_VIRTUAL_SUFFIX = '.mjs'
*/
export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
+/** Generated descriptor/codec contribution with no shared runtime identity. */
+const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
+
/**
* Documented TEMPORARY exemption, not a platform module (hence not in
* platform.ts): the snapshot-store engine (createSnapshotStore/defineStore/
@@ -126,9 +129,9 @@ export function clientBundle(id: string, libEntry: readonly string[]): [UserConf
resolveId(source: string) {
if (!source.startsWith('@deepseek-ai/')) return null
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
- if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
+ if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point
throw new Error(
- `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
+ `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS), an inline-safe wire layer, or a generated /remote contribution — `
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
)
},
diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts
index 2bf1c1b2d9..b8da6049e8 100644
--- a/packages/cordis/tool-cordis/src/api-catalog.ts
+++ b/packages/cordis/tool-cordis/src/api-catalog.ts
@@ -1118,11 +1118,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'typert',
- summary: 'Registry of generated schemas and package reflection.',
+ summary: 'Registry of generated schemas, package reflection, invocations, and Remote dependency providers.',
methods: [
{
- signature: 'register(contribution: TypertContribution): () => void',
- jsDoc: '/**\n * Register one generated contribution atomically for the calling fiber.\n * Duplicate package-face identities or schema keys reject the whole batch.\n * @param contribution - generated schemas and package metadata.\n * @returns the exact effect disposer that removes this contribution.\n */',
+ signature: 'register(contribution: TypertContribution): TypeRTDisposer',
+ jsDoc: '/**\n * Register one generated contribution atomically for the calling fiber.\n * Duplicate package-face identities, schemas, invocation ids, or endpoints\n * reject the whole batch.\n * @param contribution - generated schemas, reflection, and Host invocations.\n * @returns the exact effect disposer that removes this contribution.\n */',
},
{
signature: 'get(key: string): TypertSchemaRecord | undefined',
@@ -1150,6 +1150,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
+ {
+ key: 'typertGateway',
+ summary: 'Resolve strict generated definitions or conservative SRC markers against current Cordis Services and TypeRT providers.',
+ methods: [
+ {
+ signature: 'async invoke(request: InvokeRemoteRequest): Promise',
+ jsDoc: '/**\n * Invoke one live Remote method through strict generated reflection or SRC markers.\n * @param request - decoded endpoint and exact named wire arguments.\n * @returns the validated business result.\n * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; business errors retain their identity.\n */',
+ },
+ ],
+ },
{
key: 'userInteraction',
summary: '`ctx.userInteraction`: one active UI provider plus an `ask()` surface.',
@@ -2057,6 +2067,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'InvariantInstaller',
declaration: 'export interface InvariantInstaller {\n (ctx: Context, fail: InvariantFailure): void | Promise;\n readonly inject?: Inject;\n}',
},
+ {
+ name: 'InvocationDescriptor',
+ declaration: 'export interface InvocationDescriptor {\n readonly id: string;\n readonly service: string;\n readonly namespace: string;\n readonly method: string;\n readonly implementation?: string;\n readonly invocation: {\n readonly kind: \'direct\';\n } | {\n readonly kind: \'context\';\n readonly context: string;\n readonly wire: string;\n readonly codec: TypeRTCodec;\n };\n readonly scope?: {\n readonly context: string;\n readonly wire: string;\n };\n readonly parameters: readonly InvocationParameterDescriptor[];\n readonly result: TypeRTCodec;\n readonly sourceLocation?: InvocationSourceLocation;\n}',
+ },
+ {
+ name: 'InvocationParameterDescriptor',
+ declaration: 'export interface InvocationParameterDescriptor {\n readonly name: string;\n readonly wire: string;\n readonly source: \'json\' | \'lookup\';\n readonly lookup?: string;\n readonly codec: TypeRTCodec;\n}',
+ },
+ {
+ name: 'InvocationSourceLocation',
+ declaration: 'export interface InvocationSourceLocation {\n readonly file: string;\n readonly line: number;\n readonly column: number;\n}',
+ },
+ {
+ name: 'InvokeRemoteRequest',
+ declaration: 'export interface InvokeRemoteRequest {\n readonly namespace: string;\n readonly method: string;\n readonly args: Readonly>;\n}',
+ },
{
name: 'JsonSchemaNode',
declaration: 'export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n}',
@@ -3037,9 +3063,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'TurnEndReasonMap',
declaration: 'export interface TurnEndReasonMap {\n completed: {\n kind: \'completed\';\n };\n aborted: {\n kind: \'aborted\';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: \'blocked\';\n };\n error: {\n kind: \'error\';\n error: LlmFailure;\n };\n \'max-tokens\': {\n kind: \'max-tokens\';\n };\n interrupted: {\n kind: \'interrupted\';\n };\n}',
},
+ {
+ name: 'TypeRTCodec',
+ declaration: 'export type TypeRTCodec = {\n readonly mode: \'strict\';\n readonly typeSymbol: string;\n readonly schema: TypeRTSchema;\n} | {\n readonly mode: \'src-json\';\n};',
+ },
{
name: 'TypertContribution',
- declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n}',
+ declaration: 'export interface TypertContribution {\n readonly package: string;\n readonly face: TypertFace;\n readonly schemas: readonly TypertSchema[];\n readonly model: TypertPackageModel;\n readonly invocations?: readonly InvocationDescriptor[];\n}',
+ },
+ {
+ name: 'TypeRTDisposer',
+ declaration: 'export type TypeRTDisposer = () => Promise;',
},
{
name: 'TypertDocTag',
@@ -3077,6 +3111,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'TypertSchema',
declaration: 'export interface TypertSchema {\n readonly name: string;\n readonly schema: z.ZodType;\n}',
},
+ {
+ name: 'TypeRTSchema',
+ declaration: 'export interface TypeRTSchema