Bladeren bron

refactor(api): remove ApiProxy package

imccyu 1 week geleden
bovenliggende
commit
4f00a8b82a
42 gewijzigde bestanden met toevoegingen van 54 en 3529 verwijderingen
  1. 0 1
      apps/cli/package.json
  2. 0 3
      apps/cli/tsconfig.json
  3. 0 5
      packages/bundle/web-app/cordis.patch.yml
  4. 0 1
      packages/bundle/web-app/package.json
  5. 18 42
      packages/extensions/tool-cordis/src/api-catalog.ts
  6. 0 6
      packages/host/apiproxy/README.i18n.yaml
  7. 0 135
      packages/host/apiproxy/README.md
  8. 0 135
      packages/host/apiproxy/README.zh.md
  9. 0 74
      packages/host/apiproxy/package.json
  10. 0 137
      packages/host/apiproxy/src/api-proxy.ts
  11. 0 25
      packages/host/apiproxy/src/api/downloads.schema.ts
  12. 0 24
      packages/host/apiproxy/src/api/downloads.ts
  13. 0 21
      packages/host/apiproxy/src/api/host.schema.ts
  14. 0 30
      packages/host/apiproxy/src/api/host.ts
  15. 0 7
      packages/host/apiproxy/src/api/ids.schema.ts
  16. 0 44
      packages/host/apiproxy/src/api/index.ts
  17. 0 23
      packages/host/apiproxy/src/api/rpc-map.ts
  18. 0 83
      packages/host/apiproxy/src/api/rpc.schema.ts
  19. 0 104
      packages/host/apiproxy/src/api/rpc.ts
  20. 0 204
      packages/host/apiproxy/src/fetch/client.ts
  21. 0 158
      packages/host/apiproxy/src/fetch/handler.ts
  22. 0 91
      packages/host/apiproxy/src/index.ts
  23. 0 32
      packages/host/apiproxy/src/invariant.ts
  24. 0 457
      packages/host/apiproxy/src/session-export.ts
  25. 0 236
      packages/host/apiproxy/tests/api-proxy-config.spec.ts
  26. 0 51
      packages/host/apiproxy/tests/api-proxy-host.spec.ts
  27. 0 228
      packages/host/apiproxy/tests/client-handler.spec.ts
  28. 0 207
      packages/host/apiproxy/tests/fetch-carrier.spec.ts
  29. 0 96
      packages/host/apiproxy/tests/rpc-schemas.spec.ts
  30. 0 708
      packages/host/apiproxy/tests/session-export.spec.ts
  31. 0 54
      packages/host/apiproxy/tsconfig.json
  32. 0 3
      packages/typert/generator/tests/cordis-catalog.spec.ts
  33. 15 73
      pnpm-lock.yaml
  34. 1 1
      scripts/check-workspace-constraints.ts
  35. 1 1
      scripts/doc-typecheck-paths.ts
  36. 3 3
      scripts/gen-cordis-catalog.ts
  37. 9 17
      scripts/gen-doc-graphs.ts
  38. 0 1
      scripts/verify-package-readme-model-experience.ts
  39. 6 3
      tsconfig.base.json
  40. 1 1
      tsconfig.client.json
  41. 0 1
      tsconfig.host.json
  42. 0 3
      vitest.config.ts

+ 0 - 1
apps/cli/package.json

@@ -110,7 +110,6 @@
     "@deepseek-ai/dsh-fs-observation-policy": "workspace:^",
     "@deepseek-ai/dsh-fs-sandbox": "workspace:^",
     "@deepseek-ai/dsh-host-frontend-static": "workspace:^",
-    "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
     "@deepseek-ai/dsh-host-webserver": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-llm-deepseek": "workspace:^",

+ 0 - 3
apps/cli/tsconfig.json

@@ -32,9 +32,6 @@
     {
       "path": "../../packages/bundle/web-app"
     },
-    {
-      "path": "../../packages/host/apiproxy"
-    },
     {
       "path": "../../packages/host/webserver"
     },

+ 0 - 5
packages/bundle/web-app/cordis.patch.yml

@@ -95,11 +95,6 @@
     - id: workspace-controller
       name: '@deepseek-ai/dsh-api-workspace-controller'
 
-    # The API gateway: the transport-agnostic dispatch face every client shape
-    # shares. The base layer's agent-default-model service owns the default model.
-    - id: api-gateway
-      name: '@deepseek-ai/dsh-host-apiproxy'
-
     - id: cordis-host-runner
       name: '@deepseek-ai/dsh-cordis-host-runner'
 

+ 0 - 1
packages/bundle/web-app/package.json

@@ -94,7 +94,6 @@
     "@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
     "@deepseek-ai/dsh-web-frontend": "workspace:^",
     "@deepseek-ai/dsh-host-frontend-static": "workspace:^",
-    "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
     "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^",
     "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
     "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",

+ 18 - 42
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -429,18 +429,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
     ],
   },
-  {
-    key: 'apiProxy',
-    summary: 'Root interface of the unified API.',
-    description: 'Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row.',
-    methods: [
-      {
-        signature: 'downloads: DownloadsApi',
-        description: 'Host-only download surfaces (GET, no wire envelope); absent from IApiClient.',
-        parameters: [],
-      },
-    ],
-  },
   {
     key: 'approval',
     summary: 'Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session.',
@@ -1382,6 +1370,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [],
         returns: 'provider-grouped models, the deployment default, and isolated provider failures.',
       },
+      {
+        signature: '@Remote canOpenWorkspacePath(): boolean',
+        description: 'Report whether this deployment can hand a Session workspace path to a native desktop.',
+        parameters: [],
+        returns: 'true when the matching open operation is available.',
+      },
       {
         signature: '@Remote(\'openWorkspacePath\') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue>',
         description: 'Open one path prepared by a Session-aware caller on the Host desktop.',
@@ -1966,6 +1960,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         returns: 'provider writability, local-document presence, and one view per namespace.',
         throws: ['TypertRemoteFailure when no settings provider is mounted.'],
       },
+      {
+        signature: '@Remote canOpenAgentPresetDirectory(): boolean',
+        description: 'Report whether this deployment can open an authored Agent preset directory natively.',
+        parameters: [],
+        returns: 'true when the matching open operation is available.',
+      },
       {
         signature: '@Remote update( ns: string, patch: Record<string, JsonValue>, expectedRevision: number | undefined, ): Promise<SettingsNamespaceView>',
         description: 'Merge a patch into one namespace\'s stored user section.',
@@ -2610,9 +2610,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         parameters: [],
       },
       {
-        signature: 'registerRemoteEvents(source: TypertRemoteEventSource): () => Promise<void>',
+        signature: 'registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo, ): () => Promise<void>',
         description: 'Register the sole application-selected forwarded-event source.',
-        parameters: [{ name: 'source', description: 'stream factory installed by the Remote assembly.' }],
+        parameters: [{ name: 'source', description: 'stream factory installed by the Remote assembly.' }, { name: 'host', description: 'stable Host facts included in each Client generation\'s opening frame.' }],
         returns: 'disposer removing this source and cancelling its active streams.',
       },
       {
@@ -3934,10 +3934,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'DomainTableSpec',
     declaration: 'export interface DomainTableSpec<K extends string = string, V = unknown> {\n    readonly valueSchema: ZodType<V>;\n    readonly __key?: K;\n}',
   },
-  {
-    name: 'DownloadsApi',
-    declaration: 'export interface DownloadsApi {\n    sessionLog(request: {\n        sessionId: SessionId;\n        includeDescendants?: boolean;\n    }, signal: AbortSignal): Promise<Response>;\n}',
-  },
   {
     name: 'DshEnvironment',
     declaration: 'export type DshEnvironment = Readonly<Record<DshEnvironmentKey, string>>;',
@@ -4606,6 +4602,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'RedactedSecret',
     declaration: 'export interface RedactedSecret {\n    path: string[];\n    set: boolean;\n}',
   },
+  {
+    name: 'RemoteEventHostInfo',
+    declaration: 'export interface RemoteEventHostInfo {\n    readonly home: string;\n}',
+  },
   {
     name: 'ReplayEnvelope',
     declaration: 'export interface ReplayEnvelope {\n    response: unknown;\n    blocks?: readonly unknown[];\n}',
@@ -4662,26 +4662,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'ResumeAgentOptions',
     declaration: 'export interface ResumeAgentOptions {\n    readonly resumeSessionId: SessionId;\n    readonly agentOptions?: AgentOptions;\n    readonly signal?: AbortSignal;\n    readonly setup?: AgentSetup;\n}',
   },
-  {
-    name: 'RpcError',
-    declaration: 'export type RpcError = {\n    [C in RpcErrorCode]: {\n        code: C;\n        message: string;\n        details: RpcErrorDetailsMap[C];\n    };\n}[RpcErrorCode];',
-  },
-  {
-    name: 'RpcErrorCode',
-    declaration: 'export type RpcErrorCode = keyof RpcErrorDetailsMap;',
-  },
-  {
-    name: 'RpcErrorDetailsMap',
-    declaration: 'export interface RpcErrorDetailsMap {\n    \'bad-request\': {\n        issues: ZodIssue[];\n    };\n    \'cancelled\': {};\n    \'session-not-found\': {\n        sessionId: SessionId;\n    };\n    \'invalid-time-zone\': {\n        value: string;\n    };\n    \'agent-preset-read-only\': {\n        agentPreset: string;\n        reason: string;\n    };\n    \'agent-preset-locked\': {\n        sessionId: SessionId;\n        agentPreset: string;\n    };\n    \'agent-preset-not-found\': {\n        agentPreset: string;\n        available: readonly string[];\n    };\n    \'agent-preset-invalid\': {\n        agentPreset: string;\n        reason: string;\n    };\n    \'agent-busy\': {\n        reason: string;\n    };\n    \'internal\': {};\n}',
-  },
-  {
-    name: 'RpcId',
-    declaration: 'export type RpcId = Branded<\'rpc-id\'>;',
-  },
-  {
-    name: 'RpcResult',
-    declaration: 'export type RpcResult<T> = {\n    ok: true;\n    value: T;\n} | {\n    ok: false;\n    error: RpcError;\n};',
-  },
   {
     name: 'RunnerFailureRule',
     declaration: 'export interface RunnerFailureRule {\n    allowedExitCodes?: readonly number[];\n    fatalSignatures: readonly string[];\n    informationalLines?: readonly string[];\n}',
@@ -4758,10 +4738,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'SendTeamMessageResult',
     declaration: 'export interface SendTeamMessageResult {\n    readonly messageId: TeamMessageId;\n    readonly status: \'accepted\' | \'queued\';\n}',
   },
-  {
-    name: 'ServerResponse',
-    declaration: 'export interface ServerResponse {\n    type: \'server-response\';\n    rpcId: RpcId;\n    result: RpcResult<unknown>;\n}',
-  },
   {
     name: 'Session',
     declaration: 'export class Session {\n    get surface(): SessionSurface;\n    readonly header: SessionHeader;\n    get id(): SessionId;\n    readonly firstLiveSeq: number;\n    static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n    static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n    get events(): readonly SessionEvent[];\n    get seq(): number;\n    append<T extends SessionEventType>(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n        opts: SurfaceIntent\n    ] : [\n    ]): SessionEvent<T>;\n    requestHeader(): EpochHeader | undefined;\n    requestContext(): RequestContext | undefined;\n    deriveMessages(): Message[];\n    deriveEventMessage(event: SessionEvent): Message | null;\n}',

+ 0 - 6
packages/host/apiproxy/README.i18n.yaml

@@ -1,6 +0,0 @@
-# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
-# side as of the last confirmed-consistent state. Both languages carry equal authority;
-# after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
-README.md: 131ea9c510733740664ca8b46510650110bca234
-README.zh.md: 4578e89acc73f1a77648dce087da1bca6b364f16

+ 0 - 135
packages/host/apiproxy/README.md

@@ -1,135 +0,0 @@
----
-description: "Legacy HTTP transport for Host bootstrap metadata and streamed Session-log ZIP downloads while generated Typert Remotes own business operations."
-kind: "package-reference"
----
-
-# @deepseek-ai/dsh-host-apiproxy
-
-English | [中文](README.zh.md)
-
-## Summary
-
-`dsh-host-apiproxy` carries the two Host operations that do not yet belong to a generated business Remote: the `host.describe` bootstrap snapshot and streamed Session-log ZIP downloads. Its browser-safe envelope and fetch adapters serve HTTP and in-process clients, while API Gateway carries all ordinary business operations. The shipped Web composition assembles both transports in [`dsh-web-app`](../../bundle/web-app/README.md).
-
-## Table of Contents
-
-- [Use this package](#use-this-package)
-- [Understand the implementation](#understand-the-implementation)
-- [Further Exploration](#further-exploration)
-- [Model Experience](#model-experience)
-- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
-- [Dev Note](#dev-note)
-
------
-
-<a id="use-this-package"></a>
-## Use this package
-
-Compose this package when a GUI host needs bootstrap metadata and Session-log export: load `ApiProxyService`, wrap `ctx.apiProxy` in a carrier, and use generated Remotes for all other business calls.
-
-### Choosing a carrier
-
-`toFetchHandler(api)` turns the gateway into a pure WHATWG fetch function for an HTTP server (the shipped Web composition exposes it behind `/api/…` routes), while `InProcessApiClient` runs the same serialization and validation path in-process — the isomorphic point for callers and tests that need the full wire path without a network.
-
-```text
-const client = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
-const response = await client.host.describe({})
-```
-
-The HTTP carrier refuses non-JSON POST bodies with 415 before dispatch, so cross-site simple requests can never run a side-effectful method blind. The browser carrier applies the same Host/Origin checks and signed-cookie authentication to every Host API method ([`dsh-client-connection`](../../client/connection/README.md)); individual Client features may still withhold native or persistent operations on non-loopback pages.
-
-### What the gateway exposes
-
-The unary map contains only `host.describe`; the direct download route is `GET` or `HEAD /api/session.export`. Session, workspace, settings, credentials, LLM, skill, file-reference, command, and interaction operations are generated Remotes owned by their business packages and assembled by [`dsh-api-remotes`](../../api/remotes/README.md).
-
-### Exporting sessions
-
-`GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP of the session's stored artifact text verbatim, every subagent descendant under `subagents/<id>/`, and each referenced image under `media/<attachmentId>.<ext>`. `HEAD` runs the same root preparation without a body, so browsers detect pre-stream failures before handing the GET to the download manager. The response is chunked as it is produced, and `sessionExportCompressionLevel` (0–9, default 6) trades CPU and latency against archive size. Missing persistence, session-query, or attachment services answer 500, a backend without per-session raw artifacts 501, and a missing root session 404.
-
-### Configuration
-
-| Field | Default | Meaning |
-|---|---|---|
-| `nativeOpen` | platform-detected | Whether the deployment can hand paths to a native desktop opener |
-| `sessionExportCompressionLevel` | `6` | DEFLATE level for every session-log ZIP entry, 0–9 |
-
-The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-host-apiproxy) is the exhaustive source for every accepted field and its JSDoc.
-
------
-
-<a id="understand-the-implementation"></a>
-## Understand the implementation
-
-<details>
-<summary>Implementation internals — click to expand</summary>
-
-### Design concept
-
-The package is built on one separation: the API contract is channel-independent, and physical transports are carriers around it. Wire messages form a two-member discriminated union — `ClientRequest` (the POST `/api/<method>` body) and `ServerResponse` (that POST's response body) — decoupled from the physical channel. Responses always echo the matching request's `rpcId` and never mint a new one. Business errors ride the `RpcResult` error branch with a closed `RpcErrorDetailsMap`; HTTP status expresses only the carrier. The layering and protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md).
-
-### Source map
-
-| File | Role |
-|---|---|
-| [`src/api/`](src/api/) | Contract layer: domain interfaces, payload types, zod schemas, `RpcMethodMap` — zero Node dependencies |
-| [`src/fetch/handler.ts`](src/fetch/handler.ts) | Host carrier: `toFetchHandler`, envelope parsing, unary dispatch, session export |
-| [`src/fetch/client.ts`](src/fetch/client.ts) | Client carrier: `AbstractApiClient` plus platform subclasses, `InProcessApiClient` |
-| [`src/api-proxy.ts`](src/api-proxy.ts) | Gateway implementation: `createApiProxy` over the composed host context |
-| [`src/session-export.ts`](src/session-export.ts) | Session-log ZIP export: raw artifact reads, media collection, fflate streaming |
-
-### The gateway service
-
-`ApiProxyService` provides `ctx.apiProxy`, reports process metadata through `host.describe`, and delegates Session archive production to the persistence, query, attachment, and live Session services. The Host cwd is the default project directory. Product `dsh --profile headless` is a direct core entry point and does not mount this package.
-
-### Request flow
-
-A `host.describe` request enters the fetch carrier, which parses the envelope and payload, dispatches the method, and returns a response echoing the request's `rpcId`. Session export bypasses that envelope because its streamed ZIP body and HTTP status are the result.
-
-### What the gateway owns
-
-The package owns its legacy envelope, Host bootstrap snapshot, and archive download. API Gateway owns generated Remote dispatch and streams; business packages own their methods and result types.
-
-</details>
-
------
-
-<a id="further-exploration"></a>
-## Further Exploration
-
-Read these when the package-level contract is not enough. They move from the layering decision to the browser-side consumption architecture and the adjacent subsystems.
-
-- [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) — the layering model and the channel-independent message protocol.
-- [Web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — how the browser consumes the API.
-- [Browser HTTP carrier](../../client/connection/README.md) — Host/Origin checks, signed-cookie authentication, and the routes the shipped Web composition registers.
-- [Web-server subsystem](../../../docs/subsystems/web-server.md) — the HTTP server the carrier rides on.
-- [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-host-apiproxy) — every accepted config field and its source declaration.
-
------
-
-<a id="model-experience"></a>
-## Model Experience
-
-None, as the wire contract and fetch carriers move already-composed messages and register nothing model-facing.
-
-#### KV Cache effect
-
-None; this package neither assembles nor sends a provider request.
-
-## Known Limitations and Deferred Work
-
-<a id="known-limitations-and-deferred-work"></a>
-
-
-These limits define where the gateway is a poor fit; they are current package constraints, not a task backlog.
-
-- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
-
-<a id="dev-note"></a>
-### Dev Note
-
-<details>
-<summary>Working context for maintainers — click to expand</summary>
-
-This Dev Note is working context for maintainers: open directions. It is explicitly non-authoritative — shipped behavior and limits live in the sections above. A protocol version field waits for an independently released client; a multi-user carrier must replace provider search diagnostics with public-safe text; per-connection picker adaptivity (native for a local browser, browse for a remote one) remains an undecided direction for the host surface.
-
-</details>

+ 0 - 135
packages/host/apiproxy/README.zh.md

@@ -1,135 +0,0 @@
----
-description: "Host 启动元数据与 Session 日志 ZIP 流下载的旧版 HTTP 载体;普通业务操作由生成的 Typert Remote 持有。"
-kind: "package-reference"
----
-
-# @deepseek-ai/dsh-host-apiproxy
-
-[English](README.md) | 中文
-
-## 概述
-
-`dsh-host-apiproxy` 承载尚不属于生成业务 Remote 的两项 Host 操作:`host.describe` 启动快照与流式 Session 日志 ZIP 下载。它的浏览器安全 envelope 与 fetch adapter 服务 HTTP 和进程内客户端,其余普通业务操作由 API Gateway 承载。随发行版交付的 Web 组合在 [`dsh-web-app`](../../bundle/web-app/README.zh.md) 中组装两种传输。
-
-## 目录
-
-- [使用本包](#use-this-package)
-- [理解实现](#understand-the-implementation)
-- [进一步探索](#further-exploration)
-- [模型体验](#model-experience)
-- [已知限制与延期工作](#known-limitations-and-deferred-work)
-- [开发备注](#dev-note)
-
------
-
-<a id="use-this-package"></a>
-## 使用本包
-
-当 GUI Host 需要启动元数据与 Session 日志导出时组合本包:加载 `ApiProxyService`,把 `ctx.apiProxy` 包进一个载体,其他业务调用使用生成的 Remote。
-
-### 选择载体
-
-`toFetchHandler(api)` 把网关变成纯 WHATWG fetch 函数,供 HTTP 服务器使用(随发行版交付的 Web 组合把它暴露在 `/api/…` 路由之后);`InProcessApiClient` 则在进程内运行同一条序列化与校验路径——这是需要完整协议路径但不需要网络的调用方与测试的同构接点。
-
-```text
-const client = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
-const response = await client.host.describe({})
-```
-
-HTTP 载体在分发前以 415 拒绝非 JSON 的 POST 请求体,因此跨站「简单请求」永远无法盲目执行有副作用的方法。浏览器载体对每个 Host API 方法实施相同的 Host/Origin 检查与签名 cookie 认证([`dsh-client-connection`](../../client/connection/README.zh.md));各 Client 功能仍可以在非 loopback 页面上拒绝原生操作或持久化操作。
-
-### 网关暴露什么
-
-一元映射只包含 `host.describe`;直接下载路由是 `GET` 或 `HEAD /api/session.export`。Session、workspace、settings、credentials、LLM、skill、file-reference、command 与 interaction 操作都是由各业务包持有、并由 [`dsh-api-remotes`](../../api/remotes/README.zh.md) 组装的生成 Remote。
-
-### 导出会话
-
-`GET /api/session.export?sessionId=…&includeDescendants=true` 流式输出一个 ZIP,其中每个会话的已存工件文本原样包含,每个子代理后代位于 `subagents/<id>/` 下,每张被引用的图片位于 `media/<attachmentId>.<ext>` 下。`HEAD` 在无请求体的情况下运行同样的根准备,因此浏览器能在把 GET 交给下载管理器之前检测到流前失败。响应边生成边分块输出,`sessionExportCompressionLevel`(0–9,默认 6)在 CPU 与延迟之间权衡归档大小。缺少 persistence、session-query 或 attachment 服务时回答 500,后端没有按会话原始工件时回答 501,根会话缺失时回答 404。
-
-### 配置
-
-| 字段 | 默认值 | 含义 |
-|---|---|---|
-| `nativeOpen` | 平台探测 | 部署能否把路径交给原生桌面打开器 |
-| `sessionExportCompressionLevel` | `6` | 每个会话日志 ZIP 条目的 DEFLATE 级别,0–9 |
-
-生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-host-apiproxy)是每个受支持字段及其 JSDoc 的穷尽式真源。
-
------
-
-<a id="understand-the-implementation"></a>
-## 理解实现
-
-<details>
-<summary>实现细节——点击展开</summary>
-
-### 设计理念
-
-本包建立在一个分离之上:API 约定与通道无关,物理传输只是围绕它的载体。协议消息构成一个二元可辨识联合——`ClientRequest`(POST `/api/<method>` 的请求体)与 `ServerResponse`(该 POST 的响应体)——与物理通道解耦。响应始终回显对应请求的 `rpcId`,绝不签发新值。业务错误由 `RpcResult` 的错误分支承载,其 `RpcErrorDetailsMap` 封闭错误码集合;HTTP 状态只表达载体层结果。分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md) 中。
-
-### 源码地图
-
-| 文件 | 职责 |
-|---|---|
-| [`src/api/`](src/api/) | 约定层:领域接口、payload 类型、zod schema、`RpcMethodMap`——零 Node 依赖 |
-| [`src/fetch/handler.ts`](src/fetch/handler.ts) | 宿主载体:`toFetchHandler`、信封解析、一元分发、会话导出 |
-| [`src/fetch/client.ts`](src/fetch/client.ts) | 客户端载体:`AbstractApiClient` 及平台子类、`InProcessApiClient` |
-| [`src/api-proxy.ts`](src/api-proxy.ts) | 网关实现:基于所组合宿主上下文的 `createApiProxy` |
-| [`src/session-export.ts`](src/session-export.ts) | 会话日志 ZIP 导出:原始工件读取、媒体收集、fflate 流式输出 |
-
-### 网关服务
-
-`ApiProxyService` 提供 `ctx.apiProxy`,通过 `host.describe` 报告进程元数据,并把 Session 归档生成委派给 persistence、query、attachment 与 live Session 服务。Host cwd 是默认项目目录。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。
-
-### 请求流
-
-`host.describe` 请求进入 fetch 载体,载体解析 envelope 与 payload、分发方法,并返回回显请求 `rpcId` 的响应。Session 导出不使用该 envelope,因为其流式 ZIP body 与 HTTP 状态就是结果。
-
-### 网关拥有什么
-
-本包持有旧版 envelope、Host 启动快照与归档下载。API Gateway 持有生成的 Remote 分发与流;业务包持有各自的方法和结果类型。
-
-</details>
-
------
-
-<a id="further-exploration"></a>
-## 进一步探索
-
-当包级约定不够用时阅读以下内容。它们从分层决策进入浏览器侧消费架构与相邻子系统。
-
-- [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)——分层模型与通道无关的消息协议。
-- [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——浏览器如何消费该 API。
-- [浏览器 HTTP 载体](../../client/connection/README.zh.md)——Host/Origin 检查、签名 cookie 认证,以及随发行版交付的 Web 组合注册的路由。
-- [Web 服务器子系统](../../../docs/subsystems/web-server.zh.md)——载体所搭乘的 HTTP 服务器。
-- [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-host-apiproxy)——每个受支持配置字段及其源声明。
-
------
-
-<a id="model-experience"></a>
-## 模型体验
-
-无。该协议约定与 fetch 载体只搬运已组装好的消息,不注册任何面向模型的内容。
-
-#### KV Cache 影响
-
-无;该包既不组装也不发送提供方请求。
-
-## 已知限制与延期工作
-
-<a id="known-limitations-and-deferred-work"></a>
-
-
-这些限制说明网关在何处不合适;它们是当前包约束,不是任务积压。
-
-- **没有协议版本字段**——客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
-
-<a id="dev-note"></a>
-### 开发备注
-
-<details>
-<summary>维护者的工作上下文——点击展开</summary>
-
-本开发备注是维护者的工作上下文:开放方向。它明确不具权威性——已交付行为与限制见上文各节。协议版本字段等待独立发布的客户端;多用户载体必须把提供方搜索诊断替换为可安全公开的文本;按连接的自适应目录选择(本地浏览器用 native、远程浏览器用 browse)仍是宿主表面的一个未定方向。
-
-</details>

+ 0 - 74
packages/host/apiproxy/package.json

@@ -1,74 +0,0 @@
-{
-  "name": "@deepseek-ai/dsh-host-apiproxy",
-  "description": "API gateway: the ApiProxy contract (api/), the fetch carrier pair (fetch/), and the host-side gateway plugin providing ctx.apiProxy",
-  "version": "0.1.1-rc.2",
-  "publishConfig": {
-    "access": "public"
-  },
-  "repository": {
-    "type": "git",
-    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
-    "directory": "packages/host/apiproxy"
-  },
-  "type": "module",
-  "main": "lib/index.js",
-  "types": "lib/types/index.d.ts",
-  "exports": {
-    ".": {
-      "types": "./lib/types/index.d.ts",
-      "default": "./lib/index.js"
-    },
-    "./invariant": {
-      "types": "./lib/types/invariant.d.ts",
-      "default": "./lib/invariant.js"
-    },
-    "./src/*": "./src/*",
-    "./package.json": "./package.json",
-    "./api": {
-      "types": "./lib/types/api/index.d.ts",
-      "default": "./lib/types/api/index.js"
-    },
-    "./api/*": {
-      "types": "./lib/types/api/*.d.ts",
-      "default": "./lib/types/api/*.js"
-    },
-    "./client": {
-      "types": "./lib/types/fetch/client.d.ts",
-      "default": "./lib/types/fetch/client.js"
-    }
-  },
-  "files": [
-    "lib/index.js",
-    "lib/invariant.js",
-    "lib/types/**/*.js",
-    "lib/types/**/*.d.ts"
-  ],
-  "license": "MIT",
-  "dependencies": {
-    "@deepseek-ai/dsh-agent": "workspace:^",
-    "@deepseek-ai/dsh-agent-default-model": "workspace:^",
-    "@deepseek-ai/dsh-attachment": "workspace:^",
-    "@deepseek-ai/dsh-api-session-controller": "workspace:^",
-    "@deepseek-ai/dsh-brand": "workspace:^",
-    "@deepseek-ai/dsh-native-command": "workspace:^",
-    "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-session-persistence": "workspace:^",
-    "@deepseek-ai/dsh-session-query": "workspace:^",
-    "@deepseek-ai/dsh-util-crypto": "workspace:^",
-    "@deepseek-ai/schemastery": "workspace:^",
-    "fflate": "^0.8.2",
-    "zod": "^4.4.3"
-  },
-  "peerDependencies": {
-    "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-invariants": "workspace:^"
-  },
-  "devDependencies": {
-    "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-credentials": "workspace:^",
-    "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/dsh-settings": "workspace:^",
-    "@deepseek-ai/dsh-typert-protocol": "workspace:^",
-    "@deepseek-ai/dsh-typert-registry": "workspace:^"
-  }
-}

+ 0 - 137
packages/host/apiproxy/src/api-proxy.ts

@@ -1,137 +0,0 @@
-/**
- * Host-side ApiProxy implementation. Signature discipline: unary takes the
- * narrow RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
- */
-
-import { homedir } from 'node:os'
-import type { Context } from '@deepseek-ai/cordis'
-import type { ModelSelection } from '@deepseek-ai/dsh-agent'
-import { canOpenNativePath } from '@deepseek-ai/dsh-native-command'
-import type { ApiProxy } from './api/index.ts'
-import {
-  DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
-  flushLiveSessionLog,
-  sessionLogExportDeps,
-  sessionLogZipFilename,
-  streamSessionLogZip,
-  type SessionLogExportReady,
-  type SessionLogCompressionLevel,
-} from './session-export.ts'
-import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
-import type { RpcRequest, RpcResponse } from './api/rpc.ts'
-
-/** Wrap an ok result echoing the request's rpcId. */
-function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
-  return { rpcId: request.rpcId, result: { ok: true, value } }
-}
-
-/** Deployment metadata and Host integrations consumed by the API implementation. */
-export interface ApiProxyDefaults {
-  /** Current deployment model selection reported by `host.describe`. */
-  defaultModelSelection: () => ModelSelection
-  /** Project hint reported by `host.describe`; must match Session Controller's default cwd. */
-  cwd: string
-  /** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */
-  sessionExportCompressionLevel?: SessionLogCompressionLevel
-  /**
-   * Whether `host.describe` reports that the Client may offer native path actions.
-   * Absent, platform detection decides ({@link canOpenNativePath}).
-   */
-  canOpenPath?: () => boolean
-}
-
-/**
- * Implement ApiProxy over a composed host context.
- * @param ctx - a context with the Host spine mounted.
- * @param defaults - host routing and project-directory defaults.
- * @returns the ApiProxy implementation.
- */
-export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
-  const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel
-    ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL
-  /** Whether this deployment can hand a path to a native opener at all. */
-  function canOpenPaths(): boolean {
-    if (defaults.canOpenPath !== undefined) return defaults.canOpenPath()
-    return canOpenNativePath()
-  }
-
-  return {
-    host: {
-      describe(request) {
-        // TODO(apiproxy-version): read the version from apps/cli/package.json.
-        const selection = defaults.defaultModelSelection()
-        return Promise.resolve(ok(request, {
-          version: '0.0.1',
-          // This must match the default cwd supplied to Session Controller so
-          // the UI's project hint names where a cwd-less create request lands.
-          cwd: defaults.cwd,
-          // Read live for the same reason: this is what the NEXT session will
-          // start from, so a saved default has to be what it reports.
-          provider: selection.provider,
-          model: selection.model,
-          attachedSessions: ctx.agents.list().length,
-          home: homedir(),
-          canOpenPath: canOpenPaths(),
-        }))
-      },
-
-    },
-
-    downloads: {
-      async sessionLog(request, signal) {
-        // Clean error path first: missing services answer 500 and a missing
-        // root artifact 404 before any zip byte is produced. The root content
-        // read here is reused as the first zip entry, so nothing is read twice.
-        const deps = sessionLogExportDeps(ctx)
-        if (deps.sessionQuery === undefined || deps.sessionPersistence === undefined || deps.attachments === undefined) {
-          return new Response(
-            'session log export is unavailable: missing session-query, session-persistence, or attachments service',
-            { status: 500 },
-          )
-        }
-        if (!deps.sessionPersistence.supportsRawArtifacts) {
-          return new Response(
-            'session log export is unavailable: the persistence backend does not expose per-session raw artifacts',
-            { status: 501 },
-          )
-        }
-        const ready: SessionLogExportReady = {
-          sessionQuery: deps.sessionQuery,
-          sessionPersistence: deps.sessionPersistence,
-          attachments: deps.attachments,
-          sessions: deps.sessions,
-        }
-        let root: SessionRawArtifact | undefined
-        try {
-          await flushLiveSessionLog(deps, request.sessionId, signal)
-          root = await deps.sessionPersistence.readRaw(request.sessionId, signal)
-          signal.throwIfAborted()
-        } catch {
-          signal.throwIfAborted()
-          // Root preparation failure: answer 500 without echoing the error,
-          // which may carry absolute host paths into the browser error bar.
-          return new Response('session log export failed to prepare the stored artifact', { status: 500 })
-        }
-        if (root === undefined) {
-          return new Response('session not found', { status: 404 })
-        }
-        return new Response(
-          streamSessionLogZip(
-            ready,
-            root,
-            request.sessionId,
-            request.includeDescendants === true,
-            sessionExportCompressionLevel,
-            signal,
-          ),
-          {
-            headers: {
-              'content-type': 'application/zip',
-              'content-disposition': `attachment; filename="${sessionLogZipFilename(request.sessionId)}"`,
-            },
-          },
-        )
-      },
-    },
-  }
-}

+ 0 - 25
packages/host/apiproxy/src/api/downloads.schema.ts

@@ -1,25 +0,0 @@
-/**
- * downloads domain zod schemas. The download surface has no wire
- * envelope: the request arrives as query parameters (all strings), so its
- * request schema parses the raw query-parameter object into the method's
- * exact request shape.
- */
-
-import { z } from 'zod'
-import type { DownloadsApi } from './downloads.ts'
-import { sessionIdSchema } from './ids.schema.ts'
-
-/**
- * session.export query params → the sessionLog request. `includeDescendants`
- * accepts exactly `true`/`false`/absent; any other value is rejected (400) so
- * a misspelled flag cannot silently under-export.
- */
-export const sessionLogQuerySchema = z
-  .object({
-    sessionId: sessionIdSchema,
-    includeDescendants: z.union([z.literal('true'), z.literal('false')]).optional(),
-  })
-  .transform(query => ({
-    sessionId: query.sessionId,
-    ...(query.includeDescendants === 'true' ? { includeDescendants: true } : {}),
-  })) satisfies z.ZodType<Parameters<DownloadsApi['sessionLog']>[0]>

+ 0 - 24
packages/host/apiproxy/src/api/downloads.ts

@@ -1,24 +0,0 @@
-/**
- * downloads domain contract: Host-only GET download surfaces with no wire
- * envelope. Carrier routes answer these directly, and the browser
- * `IApiClient` never exposes them.
- */
-
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
-
-/** Host-only download surfaces (no wire envelope; absent from IApiClient). */
-export interface DownloadsApi {
-  /**
-   * Stream one session-log ZIP — the root artifact verbatim plus each subagent
-   * descendant's — as an attachment response. The carrier's GET route answers
-   * this directly; the browser never calls it.
-   * @param request - the root session id and whether to include descendants.
-   * @param signal - cancellation for the underlying reads.
-   * @returns the ZIP attachment response; missing services answer 500 and a
-   * missing root session 404 before any byte is produced.
-   */
-  sessionLog(
-    request: { sessionId: SessionId; includeDescendants?: boolean },
-    signal: AbortSignal,
-  ): Promise<Response>
-}

+ 0 - 21
packages/host/apiproxy/src/api/host.schema.ts

@@ -1,21 +0,0 @@
-/**
- * host domain zod schemas (names derived from map keys).
- */
-
-import { z } from 'zod'
-import type { RequestPayload, ResponseValue } from './rpc-map.ts'
-import type { Wire } from './rpc.schema.ts'
-
-/** host.describe request payload (empty object literal). */
-export const hostDescribeRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'host.describe'>>>
-
-/** host.describe response value. */
-export const hostDescribeValueSchema = z.object({
-  version: z.string(),
-  cwd: z.string(),
-  provider: z.string().optional(),
-  model: z.string().optional(),
-  attachedSessions: z.number().int().nonnegative(),
-  home: z.string(),
-  canOpenPath: z.boolean(),
-}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>

+ 0 - 30
packages/host/apiproxy/src/api/host.ts

@@ -1,30 +0,0 @@
-/**
- * host domain contract. No protocol version: client and host ship
- * together; introduce protocolVersion only when an independently released client appears.
- */
-
-import type { RpcRequest, RpcResponse } from './rpc.ts'
-
-/** Host-level unary methods. */
-export interface HostApi {
-  /**
-   * One-shot host snapshot. Empty payload uses the literal `{}` (extend in place when fields arrive).
-   * version = the host app's (apps/cli) package.json version; cwd = the host process working
-   * directory (root for session persistence and tool execution); provider/model = the defaults
-   * applied when a new agent doesn't specify them explicitly, absent when the host configures
-   * no explicit default (the adapter falls back internally);
-   * attachedSessions = count of currently attached sessions (those with a live agent);
-   * home = the host account home directory (Web display abbreviation on POSIX);
-   * canOpenPath = whether this deployment can hand a path to a user-visible native desktop.
-   */
-  describe(request: RpcRequest<{}>): Promise<RpcResponse<{
-    version: string
-    cwd: string
-    provider?: string
-    model?: string
-    attachedSessions: number
-    home: string
-    canOpenPath: boolean
-  }>>
-
-}

+ 0 - 7
packages/host/apiproxy/src/api/ids.schema.ts

@@ -1,7 +0,0 @@
-/** Branded identity schemas shared by the remaining API Proxy domains. */
-
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import { z } from 'zod'
-
-/** Non-empty Session identity after transport validation. */
-export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType<SessionId>

+ 0 - 44
packages/host/apiproxy/src/api/index.ts

@@ -1,44 +0,0 @@
-/**
- * apiproxy contract-layer barrel. api/ has zero Node dependencies and is
- * importable from the browser; the TypeScript interfaces are authoritative,
- * while HTTP supplies the carrier.
- */
-
-import type { HostApi } from './host.ts'
-import type { DownloadsApi } from './downloads.ts'
-
-/** Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. */
-export interface ApiProxy {
-  host: HostApi
-  /** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */
-  downloads: DownloadsApi
-}
-
-// ---- Domain interfaces and payload entities ----
-export type {
-  ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
-  ModelReasoningEffort, ModelSelection,
-} from '@deepseek-ai/dsh-api-session-controller/types'
-export type { HostApi } from './host.ts'
-export type { DownloadsApi } from './downloads.ts'
-
-// ---- Message layer: narrow forms (domain-signature view) ----
-export type { RpcRequest, RpcResponse } from './rpc.ts'
-
-// ---- Message layer: unary wire forms ----
-export type {
-  ClientRequest,
-  RpcMessage,
-  ServerResponse,
-} from './rpc.ts'
-
-// ---- Errors and ids ----
-export { RpcId, transportError } from './rpc.ts'
-export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts'
-export {
-  clientRequestSchema,
-  serverResponseSchema,
-} from './rpc.schema.ts'
-
-// ---- Method registry and derived generics ----
-export type { RequestPayload, ResponseValue, RpcMethodMap } from './rpc-map.ts'

+ 0 - 23
packages/host/apiproxy/src/api/rpc-map.ts

@@ -1,23 +0,0 @@
-/**
- * RPC method registry and signature-derived generics. Map keys are the wire
- * path segments of API Proxy unary calls.
- */
-
-import type { HostApi } from './host.ts'
-import type { RpcResponse } from './rpc.ts'
-
-/**
- * Method name → method signature. Signatures are the single source of truth; payload/value
- * types are always derived from here. A method may declare a trailing AbortSignal after the
- * request; the carrier passes its request signal, never a wire field.
- */
-export interface RpcMethodMap {
-  'host.describe': HostApi['describe']
-}
-
-/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */
-export type RequestPayload<K extends keyof RpcMethodMap> = Parameters<RpcMethodMap[K]>[0]['payload']
-
-/** Business return value of method K (reaches through the RpcResponse narrow form to infer the ok value of result). */
-export type ResponseValue<K extends keyof RpcMethodMap> =
-  Awaited<ReturnType<RpcMethodMap[K]>> extends RpcResponse<infer T> ? T : never

+ 0 - 83
packages/host/apiproxy/src/api/rpc.schema.ts

@@ -1,83 +0,0 @@
-/**
- * Message-layer zod schemas for API Proxy unary calls and Host pushes. The
- * payload slot is unknown in the full-form schemas — business payloads get a
- * second parse dispatched by method (two-level parse discipline). Brand cast
- * point: rpcIdSchema, and only there.
- */
-
-import { z } from 'zod'
-import type { z as zCore } from 'zod'
-type ZodIssue = zCore.core.$ZodIssue
-import type { ClientRequest, RpcError, RpcId, ServerResponse } from './rpc.ts'
-
-/**
- * Wire widening of a contract type: widens every property (deeply) to `original | undefined`.
- * The repo enables exactOptionalPropertyTypes while zod `.optional()` outputs `T | undefined`,
- * so `satisfies z.ZodType<ContractType>` is unusable across the board; anchoring is always
- * written `satisfies z.ZodType<Wire<ContractType>>` — the widening only adds undefined, so
- * missing fields / wrong types still fail to compile. On the JSON wire, "absent" and
- * "value undefined" serialize identically, so the widening loses no validation semantics.
- */
-export type Wire<T> = T extends readonly (infer E)[] ? Wire<E>[]
-  : T extends object ? { [K in keyof T]: Wire<T[K]> | undefined }
-    : T
-
-/**
- * RpcId: one brand cast after schema validation (the only cast point in this
- * file). No min-length: the id is an opaque echo token, and rejecting values
- * here would only turn a correlatable error report into a client-side parse
- * failure (the handler substitutes a sentinel when a request's id is unreadable).
- */
-export const rpcIdSchema = z.string() as unknown as z.ZodType<RpcId>
-
-/** Error body: discriminated by code, per-branch details aligned to RpcErrorDetailsMap; details is required. */
-export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code', [
-  z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom<ZodIssue>()) }) }),
-  z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
-  z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
-  z.object({ code: z.literal('invalid-time-zone'), message: z.string(), details: z.object({ value: z.string() }) }),
-  z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
-  z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }),
-  z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }),
-  z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
-  z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
-  z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
-]) as unknown as z.ZodType<RpcError>
-
-/**
- * Business success/failure result schema (generic, reusable).
- * @param value - Schema for the business value.
- * @returns Schema for RpcResult<T>.
- */
-export function rpcResultSchema<T>(value: z.ZodType<T>): z.ZodUnion<readonly [z.ZodType, z.ZodType]> {
-  return z.union([
-    z.object({ ok: z.literal(true), value }),
-    z.object({ ok: z.literal(false), error: rpcErrorSchema }),
-  ])
-}
-
-// ---- Wire envelope schemas (payload/result.value stay wide for the second business parse) ----
-// The wide value slot is optional: a void business result serializes with no
-// `value` field at all. Each endpoint's own second parse still requires its
-// declared value, so absence never passes for a method that returns data.
-
-/** ClientRequest full form (payload stays wide — the business layer runs the second parse). */
-export const clientRequestSchema = z.object({
-  type: z.literal('client-request'),
-  rpcId: rpcIdSchema,
-  method: z.string(),
-  payload: z.unknown(),
-}) as unknown as z.ZodType<ClientRequest>
-
-/** ServerResponse full form (result.value stays wide). */
-export const serverResponseSchema = z.object({
-  type: z.literal('server-response'),
-  rpcId: rpcIdSchema,
-  result: rpcResultSchema(z.unknown().optional()),
-}) as unknown as z.ZodType<ServerResponse>
-
-/** Wire full-form union (discriminated by type). */
-export const rpcMessageSchema = z.discriminatedUnion('type', [
-  clientRequestSchema as unknown as z.ZodObject<z.ZodRawShape>,
-  serverResponseSchema as unknown as z.ZodObject<z.ZodRawShape>,
-])

+ 0 - 104
packages/host/apiproxy/src/api/rpc.ts

@@ -1,104 +0,0 @@
-/**
- * API Proxy request and response message model. Logical messages remain
- * independent of their physical carrier.
- * api/ contract layer: zero Node dependencies, importable from the browser.
- */
-
-import type { z as zCore } from 'zod'
-type ZodIssue = zCore.core.$ZodIssue
-import type { Branded } from '@deepseek-ai/dsh-brand'
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
-
-/**
- * Message correlation id: the initiator mints it on a request; a response
- * echoes the matching request's rpcId and never mints a new one.
- */
-export type RpcId = Branded<'rpc-id'>
-
-/**
- * Brands a string as RpcId (same precedent as core `SessionId()`). The Client
- * mints each request id and the Host echoes it in the response.
- * @param id - Raw id string (implementations mint UUIDs; tests may pass fixtures).
- * @returns The same string, branded (compile-time cast, zero runtime cost).
- */
-export function RpcId(id: string): RpcId {
-  return id as RpcId
-}
-
-/** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */
-export interface RpcErrorDetailsMap {
-  'bad-request': { issues: ZodIssue[] }
-  'cancelled': {}
-  'session-not-found': { sessionId: SessionId }
-  'invalid-time-zone': { value: string }
-  'agent-preset-read-only': { agentPreset: string; reason: string }
-  'agent-preset-locked': { sessionId: SessionId; agentPreset: string }
-  'agent-preset-not-found': { agentPreset: string; available: readonly string[] }
-  'agent-preset-invalid': { agentPreset: string; reason: string }
-  'agent-busy': { reason: string }
-  'internal': {}
-}
-
-/** Closed error-code union (the keys of RpcErrorDetailsMap). */
-export type RpcErrorCode = keyof RpcErrorDetailsMap
-
-/**
- * Distributive union expanded from the map: code is the discriminant, so
- * `switch (error.code)` narrows details. details is required (internal uses an explicit {}).
- */
-export type RpcError = {
-  [C in RpcErrorCode]: { code: C; message: string; details: RpcErrorDetailsMap[C] }
-}[RpcErrorCode]
-
-/** Business success/failure result: the result slot of a unary response; methods never throw business errors. */
-export type RpcResult<T> = { ok: true; value: T } | { ok: false; error: RpcError }
-
-/**
- * Fold a transport exception into the RpcResult error branch (unified error
- * API; 'internal' as the catch-all code). Lives with RpcResult so every
- * carrier consumer folds the same way.
- * @param error - the thrown value from the carrier.
- * @returns the error branch of an RpcResult.
- */
-export function transportError<T>(error: unknown): RpcResult<T> {
-  return {
-    ok: false,
-    error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
-  }
-}
-
-/**
- * Signature-layer narrow form, request side (domain-interface view, shared by
- * both directions): rpcId is explicit in the signature, never mixed into the
- * business payload; the type tag and method are filled in by the carrier layer.
- */
-export interface RpcRequest<P> {
-  rpcId: RpcId
-  payload: P
-}
-
-/** Signature-layer narrow form, response side: rpcId always echoes the matching request. */
-export interface RpcResponse<T> {
-  rpcId: RpcId
-  result: RpcResult<T>
-}
-
-// ---- Wire full forms ----
-
-/** Call initiated by the client (wire carrier: POST /api/<method> body). */
-export interface ClientRequest {
-  type: 'client-request'
-  rpcId: RpcId
-  method: string
-  payload: unknown
-}
-
-/** Response to a ClientRequest (wire carrier: the HTTP response body of that POST); rpcId echoed. */
-export interface ServerResponse {
-  type: 'server-response'
-  rpcId: RpcId
-  result: RpcResult<unknown>
-}
-
-/** Authoritative wire full-form union; narrow via `switch (message.type)`. */
-export type RpcMessage = ClientRequest | ServerResponse

+ 0 - 204
packages/host/apiproxy/src/fetch/client.ts

@@ -1,204 +0,0 @@
-/**
- * Client side of the fetch carrier. AbstractApiClient holds request correlation,
- * envelope wrap/unwrap, zod parsing, and the payload-direct
- * IApiClient domain methods (business code never mints). Platform differences ride two aspects:
- * abstract doFetch (transport) + overridable onEnvelope (tap). ApiProxy (the impl face) is untouched.
- */
-
-import type { z } from 'zod'
-import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
-import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts'
-import type { ClientRequest, RpcMessage, RpcResponse } from '../api/rpc.ts'
-import { RpcId } from '../api/rpc.ts'
-import type { Wire } from '../api/rpc.schema.ts'
-import { serverResponseSchema } from '../api/rpc.schema.ts'
-import { hostDescribeValueSchema } from '../api/host.schema.ts'
-
-/**
- * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
- * methods take the business payload directly — the carrier mints the rpcId and wraps the
- * envelope. Business code needing the call's rpcId reads it from the RpcResponse echo.
- * Unary methods accept an optional external AbortSignal as the last parameter.
- * Bounded calls merge it with the instance timeout via AbortSignal.any; user-paced calls
- * carry only that external signal. In both cases the signal rides beside the request, never
- * on the wire, like the stream signatures.
- * Relationship: ApiProxy is the narrow-form signature contract the impl side implements;
- * IApiClient is the payload-direct view clients consume; AbstractApiClient bridges the two.
- * Derived per method key from RpcMethodMap so a map row addition updates this mechanically.
- */
-export interface IApiClient {
-  host: {
-    describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
-  }
-}
-
-/**
- * S→C second-level parse table: value schema by method (the response-path
- * mirror of the handler's request table; key coverage compiler-enforced against RpcMethodMap).
- */
-const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseValue<K>>> } = {
-  'host.describe': hostDescribeValueSchema,
-}
-
-/** Default timeout for bounded unary calls (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
-const DEFAULT_TIMEOUT_MS = 30_000
-
-/** URL base for in-process handler injection (fake authority, opencode precedent). */
-const INTERNAL_BASE = 'http://dsh.internal'
-
-/**
- * Abstract fetch-carrier client. Subclasses supply the transport (doFetch) and may refine the
- * per-message tap (onEnvelope) — platform aspects stay in subclasses, protocol invariants stay
- * here. Envelope observation is a first-class aspect of this data middle layer: the instance
- * owns a microtask-batched buffer (frame storms must not cost one consumer update per frame),
- * and observers subscribe via subscribeEnvelopes. The isomorphic point survives: an in-process
- * subclass whose doFetch is toFetchHandler(api).fetch never touches the network.
- */
-export abstract class AbstractApiClient implements IApiClient {
-  /** Instance-owned observation buffer (module-level state would leak across instances/tests). */
-  private envelopeBatch: RpcMessage[] = []
-  private flushScheduled = false
-  private readonly envelopeListeners = new Set<(batch: readonly RpcMessage[]) => void>()
-
-  /** @param timeoutMs - timeout for unary calls. */
-  constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {}
-
-  /** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */
-  protected abstract doFetch(input: URL, init?: RequestInit): Promise<Response>
-
-  /**
-   * Subscribe to batched envelope observation (diagnostics/logging consumers).
-   * Batches follow microtask boundaries; a listener throw is isolated (observation
-   * must never break the carrier).
-   * @param listener - receives each flushed batch in arrival order.
-   * @returns unsubscribe function.
-   */
-  subscribeEnvelopes(listener: (batch: readonly RpcMessage[]) => void): () => void {
-    this.envelopeListeners.add(listener)
-    return () => {
-      this.envelopeListeners.delete(listener)
-    }
-  }
-
-  /** Per-message tap: feeds the instance buffer. Subclasses may override to observe unbatched (call super to keep batching). */
-  protected onEnvelope(message: RpcMessage): void {
-    if (this.envelopeListeners.size === 0) return
-    this.envelopeBatch.push(message)
-    if (this.flushScheduled) return
-    this.flushScheduled = true
-    queueMicrotask(() => {
-      this.flushScheduled = false
-      // Never empty here: a flush is only ever scheduled by the push above,
-      // and this callback is the sole drain point.
-      const batch = this.envelopeBatch
-      this.envelopeBatch = []
-      for (const notify of this.envelopeListeners) {
-        try {
-          notify(batch)
-        } catch (error) {
-          console.error('[apiproxy] envelope listener threw:', error)
-        }
-      }
-    })
-  }
-
-  /** Browser = same-origin (a fake authority would fail DNS on real requests); no-location env (Node) = fake authority. */
-  protected resolveBase(): string {
-    const loc = (globalThis as { location?: { origin?: string } }).location
-    return loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : INTERNAL_BASE
-  }
-
-  protected mintRpcId(): RpcId {
-    // Not crypto.randomUUID: browsers withhold it outside secure contexts,
-    // and this base also mints on pages served over plain HTTP.
-    return RpcId(randomUUID())
-  }
-
-  /**
-   * Shared POST leg of unary calls: JSON body,
-   * default timeout merged with the caller's external signal, non-2xx → transport throw.
-   */
-  private async postJson(
-    path: string,
-    body: ClientRequest,
-    signal: AbortSignal | undefined,
-  ): Promise<Response> {
-    const requestSignal = signal === undefined
-      ? AbortSignal.timeout(this.timeoutMs)
-      : AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal])
-    const response = await this.doFetch(new URL(path, this.resolveBase()), {
-      method: 'POST',
-      headers: { 'content-type': 'application/json' },
-      body: JSON.stringify(body),
-      signal: requestSignal,
-    })
-    if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`)
-    return response
-  }
-
-  /**
-   * Unary protocol path: mint → tap → POST full form → envelope parse → verify
-   * echo → value parse → tap → narrow. Virtual so a fake carrier (fixture) can
-   * override transport at this layer.
-   */
-  protected async callUnary<K extends keyof RpcMethodMap>(
-    method: K,
-    payload: RequestPayload<K>,
-    signal?: AbortSignal,
-  ): Promise<RpcResponse<ResponseValue<K>>> {
-    const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload }
-    this.onEnvelope(message)
-    const response = await this.postJson(`/api/${method}`, message, signal)
-    const full = serverResponseSchema.parse(await response.json())
-    this.onEnvelope(full)
-    if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`)
-    if (!full.result.ok) return { rpcId: full.rpcId, result: full.result }
-    // Second-level S→C parse: the ok value must match the method's Value schema (mirror of the
-    // handler's request-payload parse). The cast collapses the Wire<> widening, same as the handler side.
-    const value = UNARY_VALUE_SCHEMAS[method].parse(full.result.value) as ResponseValue<K>
-    return { rpcId: full.rpcId, result: { ok: true, value } }
-  }
-
-  // ---- IApiClient API (arrow properties so destructured/passed references stay bound) ----
-
-  readonly host: IApiClient['host'] = {
-    describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
-  }
-
-}
-
-/**
- * In-process client over an injected fetch-shaped handler (the isomorphic point:
- * `new InProcessApiClient(toFetchHandler(api))` never touches the network). Lives here because
- * in-process injection is this package's own capability (handler and client are both local).
- */
-export class InProcessApiClient extends AbstractApiClient {
-  constructor(private readonly handler: { fetch: typeof fetch }, timeoutMs?: number) {
-    super(timeoutMs)
-  }
-
-  /**
-   * Faithful to real fetch: reject on signal abort even when the in-process
-   * handler ignores the signal (a hung impl must not defeat timeout/cancel).
-   */
-  protected doFetch(input: URL, init?: RequestInit): Promise<Response> {
-    const signal = init?.signal ?? undefined
-    if (signal === undefined) return this.handler.fetch(input, init)
-    if (signal.aborted) return Promise.reject(abortError(signal))
-    return new Promise((resolve, reject) => {
-      const onAbort = (): void => { reject(abortError(signal)) }
-      signal.addEventListener('abort', onAbort, { once: true })
-      this.handler.fetch(input, init)
-        .then(resolve, reject)
-        .finally(() => { signal.removeEventListener('abort', onAbort) })
-    })
-  }
-}
-
-/** Mirror fetch's abort rejection: the signal's reason when present, else a DOMException-style AbortError. */
-function abortError(signal: AbortSignal): Error {
-  const reason: unknown = signal.reason
-  if (reason instanceof Error) return reason
-  if (typeof reason === 'string') return new Error(reason)
-  return new Error('This operation was aborted')
-}

+ 0 - 158
packages/host/apiproxy/src/fetch/handler.ts

@@ -1,158 +0,0 @@
-/**
- * Server side of the fetch carrier: maps an ApiProxy onto a pure
- * WHATWG Request->Response function. Two-level parse: full form (type/rpcId/method +
- * path==method) -> payload dispatched per method. HTTP status expresses only the carrier
- * (404 unknown path / 415 non-JSON media type / 400 non-JSON body / 500 handler crash);
- * business errors are always 200 + ServerResponse.
- */
-
-import type { z } from 'zod'
-import type { ApiProxy } from '../api/index.ts'
-import { sessionLogQuerySchema } from '../api/downloads.schema.ts'
-import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts'
-import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerResponse } from '../api/rpc.ts'
-import { RpcId } from '../api/rpc.ts'
-import type { Wire } from '../api/rpc.schema.ts'
-import { clientRequestSchema } from '../api/rpc.schema.ts'
-import { hostDescribeRequestSchema } from '../api/host.schema.ts'
-
-/**
- * Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
- * route row fails to compile, and each row's schema/invoke pair is checked against that row's
- * payload type — a schema pasted onto the wrong row is a type error, not a runtime surprise.
- * Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation
- * documented on Wire); the dispatch point carries the one Wire→exact cast.
- * Every invoke receives the carrier Request's signal; routes whose contract
- * declares a signal parameter forward it, and the rest ignore it.
- */
-type UnaryRoutes = {
-  [K in keyof RpcMethodMap]: {
-    schema: z.ZodType<Wire<RequestPayload<K>>>
-    invoke(api: ApiProxy, request: RpcRequest<RequestPayload<K>>, signal: AbortSignal): Promise<RpcResponse<ResponseValue<K>>>
-  }
-}
-
-const UNARY_ROUTES: UnaryRoutes = {
-  'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
-}
-
-/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */
-function methodFor(path: string): keyof RpcMethodMap | undefined {
-  return Object.hasOwn(UNARY_ROUTES, path) ? path as keyof RpcMethodMap : undefined
-}
-
-/**
- * Sentinel rpcId for error responses to envelopes whose own rpcId is unreadable: the response
- * must still be a valid ServerResponse (a self-violating shape would turn the server's explicit
- * bad-request report into a client-side parse failure). Fixed value, documented here as wire contract.
- */
-const INVALID_REQUEST_RPC_ID = RpcId('invalid-request')
-
-/** Wrap a business error as a ServerResponse full form (rpcId backfilled; an unreadable rpcId uses the invalid-request sentinel). */
-function errorResponse(rpcId: RpcId, error: RpcError): Response {
-  const body: ServerResponse = { type: 'server-response', rpcId, result: { ok: false, error } }
-  return Response.json(body)
-}
-
-/** Complete the impl's narrow form into a ServerResponse full form. */
-function fullResponse(narrow: RpcResponse<unknown>): Response {
-  const body: ServerResponse = { type: 'server-response', rpcId: narrow.rpcId, result: narrow.result }
-  return Response.json(body)
-}
-
-/**
- * Parse the payload and invoke one unary route. Generic over the map key so
- * the row's schema/invoke pairing typechecks; the only cast collapses the
- * Wire<> widening back to the exact payload (undefined-valued properties and
- * absent ones are indistinguishable after JSON transport).
- */
-// K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own
-// schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection.
-// oxlint-disable-next-line typescript/no-unnecessary-type-parameters
-async function handleUnary<K extends keyof RpcMethodMap>(
-  api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal,
-): Promise<Response> {
-  const route = UNARY_ROUTES[method]
-  const payload = route.schema.safeParse(message.payload)
-  if (!payload.success) {
-    return errorResponse(message.rpcId, { code: 'bad-request', message: `invalid payload for ${method}`, details: { issues: payload.error.issues } })
-  }
-  try {
-    return fullResponse(await route.invoke(api, { rpcId: message.rpcId, payload: payload.data }, signal))
-  } catch (error: unknown) {
-    // The impl never throws business errors; reaching here means the implementation itself crashed — 500, carrier layer.
-    return new Response(`handler failure: ${String(error)}`, { status: 500 })
-  }
-}
-
-/**
- * Wraps an ApiProxy into a pure fetch function (isomorphic point: feed the returned fetch straight to InProcessApiClient).
- * @param api - the host-side ApiProxy implementation.
- * @returns an object holding `fetch(Request)`; paths outside /api/ return 404.
- */
-export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
-  return {
-    // Signature matches global fetch: the isomorphic point hands this function to InProcessApiClient as its transport aspect,
-    // Clients call in (url, init) form — normalize to Request before handling.
-    async fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
-      const req = input instanceof Request ? input : new Request(input, init)
-      const url = new URL(req.url)
-      const path = url.pathname
-
-      // No-envelope Host-only download channel:
-      // physical routes that answer directly, without a wire envelope.
-      if (path === '/api/session.export' && (req.method === 'GET' || req.method === 'HEAD')) {
-        // Query params are a different boundary from the POST envelope, but
-        // the request still casts its brands only through the domain schema.
-        const parsed = sessionLogQuerySchema.safeParse(Object.fromEntries(url.searchParams))
-        if (!parsed.success) {
-          return new Response('missing or invalid sessionId query parameter', { status: 400 })
-        }
-        const response = await api.downloads.sessionLog(parsed.data, req.signal)
-        if (req.method === 'GET') return response
-        await response.body?.cancel()
-        return new Response(null, { status: response.status, headers: response.headers })
-      }
-
-      if (req.method !== 'POST' || !path.startsWith('/api/')) {
-        return new Response('not found', { status: 404 })
-      }
-
-      // Cross-site write fence: browsers send "simple" POSTs (text/plain,
-      // form encodings) without a CORS preflight, so a malicious page could
-      // otherwise execute side-effectful RPCs blind — the response stays
-      // unreadable cross-origin, but the requested mutation would still run. Only the
-      // JSON media type is accepted; anything else is forced into a preflight
-      // this server never answers. 415 = carrier layer, like the 400 below.
-      const mediaType = req.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 req.json()
-      } catch {
-        // 400 = carrier layer (body is not even JSON); valid JSON with a bad shape goes 200 + bad-request.
-        return new Response('body is not JSON', { status: 400 })
-      }
-
-      const method = methodFor(path.slice('/api/'.length))
-      if (method === undefined) return new Response('not found', { status: 404 })
-
-      const envelope = clientRequestSchema.safeParse(body)
-      if (!envelope.success) {
-        // Best effort at correlation: salvage a string rpcId from the raw body;
-        // otherwise the fixed sentinel keeps the response a valid ServerResponse.
-        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 !== method) {
-        return errorResponse(message.rpcId, { code: 'bad-request', message: `method "${message.method}" does not match path "${method}"`, details: { issues: [] } })
-      }
-      return handleUnary(api, method, message, req.signal)
-    },
-  }
-}

+ 0 - 91
packages/host/apiproxy/src/index.ts

@@ -1,91 +0,0 @@
-/**
- * @deepseek-ai/dsh-host-apiproxy — the API gateway every client shape shares:
- * the ApiProxy contract (api/: types + zod schemas, browser-safe), the fetch
- * carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient +
- * platform subclasses on the client side), and the host-side implementation
- * (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing
- * `ctx.apiProxy`). Transport-agnostic by design: this package registers no
- * routes — physical carriers wrap `ctx.apiProxy` themselves.
- *
- * The gateway consumes `ctx.agentDefaultModel` only for the deployment metadata
- * returned by `host.describe`; Session Controller owns Session model selection.
- */
-
-import { Context, Service } from '@deepseek-ai/cordis'
-import z from '@deepseek-ai/schemastery'
-import type {} from '@deepseek-ai/dsh-agent-default-model'
-import type { ApiProxy } from './api/index.ts'
-import { createApiProxy } from './api-proxy.ts'
-import {
-  DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
-  type SessionLogCompressionLevel,
-} from './session-export.ts'
-
-export type * from './api/index.ts'
-export { RpcId } from './api/rpc.ts'
-export { toFetchHandler } from './fetch/handler.ts'
-export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts'
-export type { IApiClient } from './fetch/client.ts'
-export { createApiProxy } from './api-proxy.ts'
-export type { ApiProxyDefaults } from './api-proxy.ts'
-
-declare module '@deepseek-ai/cordis' {
-  interface Context {
-    /** The host-side ApiProxy implementation (the transport-agnostic gateway face). */
-    apiProxy: ApiProxy
-  }
-}
-
-/** Gateway plugin configuration. */
-export interface Config {
-  /**
-   * Whether this deployment can hand paths to a native desktop opener —
-   * the `hasDocument` capability the agent-preset roster reports. Absent,
-   * the platform is asked (macOS/Windows/WSL yes; Linux only with a display
-   * server); set it explicitly where detection misleads, e.g. `false` in a
-   * container whose DISPLAY points nowhere a user can see.
-   */
-  nativeOpen?: boolean
-  /**
-   * DEFLATE level for every session-log ZIP entry: `0` stores without
-   * compression, `1` favors CPU/latency, and `9` favors archive size.
-   * @default 6
-   */
-  sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
-}
-
-/**
- * The API gateway service: implements the ApiProxy contract over the composed
- * host context and provides it as `ctx.apiProxy`. Its cwd metadata must match
- * the default project directory supplied to Session Controller.
- */
-export class ApiProxyService extends Service implements ApiProxy {
-  static inject = [
-    'agentDefaultModel', 'agents', 'attachments', 'sessions', 'sessionQuery',
-  ]
-
-  static Config: z<Config> = z.object({
-    nativeOpen: z.boolean(),
-    sessionExportCompressionLevel: z.number().step(1).min(0).max(9)
-      .default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z<SessionLogCompressionLevel>,
-  })
-
-  readonly host: ApiProxy['host']
-  readonly downloads: ApiProxy['downloads']
-
-  constructor(ctx: Context, config: Config) {
-    super(ctx, 'apiProxy')
-    const api = createApiProxy(ctx, {
-      defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(),
-      cwd: process.cwd(),
-      ...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean },
-      ...(config.sessionExportCompressionLevel === undefined
-        ? {}
-        : { sessionExportCompressionLevel: config.sessionExportCompressionLevel }),
-    })
-    this.host = api.host
-    this.downloads = api.downloads
-  }
-}
-
-export default ApiProxyService

+ 0 - 32
packages/host/apiproxy/src/invariant.ts

@@ -1,32 +0,0 @@
-/**
- * Package-owned invariant companion for `@deepseek-ai/dsh-host-apiproxy`.
- * @module @deepseek-ai/dsh-host-apiproxy/invariant
- */
-
-/* jscpd:ignore-start */
-import type { Context } from '@deepseek-ai/cordis'
-import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
-
-const PACKAGE_NAME = '@deepseek-ai/dsh-host-apiproxy'
-
-/** Cordis companion plugin name. */
-export const name = 'host-apiproxy-invariant'
-/** Service required before the companion can reserve package ownership. */
-export const inject = ['invariants']
-
-/**
- * No runtime invariant: this package is the wire contract layer plus the
- * host-side unary gateway over services owned elsewhere. rpcId round-trip and
- * schema acceptance are enforced at the carrier boundary and exercised by the
- * protocol-isomorphism suite.
- */
-const install: InvariantInstaller = () => {}
-
-/**
- * Register this package's invariant companion.
- * @param ctx - Cordis context carrying the invariant service.
- * @returns the installed registration's disposer after setup succeeds.
- */
-export const apply = (ctx: Context): Promise<() => void> =>
-  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
-/* jscpd:ignore-end */

+ 0 - 457
packages/host/apiproxy/src/session-export.ts

@@ -1,457 +0,0 @@
-/**
- * Host-side session-log download: streams one ZIP archive whose files are the
- * sessions' stored artifact text verbatim plus every referenced media object.
- * The root artifact sits under its original base name (`session.jsonl`); each
- * subagent descendant under `subagents/<id>/<filename>`; each image referenced
- * by any included log under `media/<attachmentId>.<ext>` (content-addressed,
- * so one archive never duplicates a shared image). No manifest is written —
- * every file is byte-identical to the backend's durable artifact or attachment
- * store and self-describing through its own header line or media type. Before
- * each live session's artifact read, the SessionStore flush barrier makes the
- * current in-memory log durable; cold sessions need no barrier. Request abort
- * and response-consumer cancellation share one producer signal and terminate
- * the active compressor.
- * Compression runs on the host with fflate's streaming Zip API, so the archive
- * bytes are produced incrementally and the host never holds the whole archive
- * in one buffer; production waits for consumer pull whenever the response queue
- * reaches its byte high-water mark, so a slow consumer bounds accumulation to
- * the fixed 64 KiB response queue plus one synchronous fflate push.
- * @module
- */
-
-import { Zip, ZipDeflate } from 'fflate'
-import type { Context } from '@deepseek-ai/cordis'
-import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
-import type { SessionLineageNode, SessionQueryEngine } from '@deepseek-ai/dsh-session-query'
-import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session'
-import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
-
-/** Valid fflate DEFLATE levels accepted by session-log export. */
-export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
-
-/** Balanced default used when a direct createApiProxy caller omits deployment config. */
-export const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL: SessionLogCompressionLevel = 6
-
-/** The services a session-log export needs (the live-session store is optional). */
-export interface SessionLogExportDeps {
-  readonly sessionQuery: SessionQueryEngine | undefined
-  readonly sessionPersistence: SessionPersistence | undefined
-  readonly attachments: AttachmentStore | undefined
-  readonly sessions: SessionStore | undefined
-}
-
-/** The export services narrowed to the mounted ones streaming actually reads. */
-export interface SessionLogExportReady {
-  readonly sessionQuery: SessionQueryEngine
-  readonly sessionPersistence: SessionPersistence
-  readonly attachments: AttachmentStore
-  readonly sessions: SessionStore | undefined
-}
-
-/**
- * Resolve the persistence, session-query, and attachment services a log export needs.
- * @param ctx - the composed host context.
- * @returns the export services (absent when the deployment does not mount them).
- */
-export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps {
-  return {
-    sessionQuery: ctx.get('sessionQuery'),
-    sessionPersistence: ctx.get('sessionPersistence'),
-    attachments: ctx.get('attachments'),
-    sessions: ctx.get('sessions'),
-  }
-}
-
-/**
- * Flush one currently live session through the store's authoritative durability
- * barrier immediately before its raw artifact is read. A cold or absent id has
- * no in-memory work to flush.
- * @param deps - export services, including the optional live-session store.
- * @param id - the session whose artifact is about to be read.
- * @param signal - optional cancellation observed around the flush barrier.
- */
-export async function flushLiveSessionLog(
-  deps: Pick<SessionLogExportDeps, 'sessions'>,
-  id: SessionId,
-  signal?: AbortSignal,
-): Promise<void> {
-  signal?.throwIfAborted()
-  const sessions = deps.sessions
-  if (sessions === undefined) return
-  const session = sessions.get(id)
-  if (session === undefined) return
-  await sessions.flush(session)
-  signal?.throwIfAborted()
-}
-
-/** One exported file: a stored artifact text or one referenced media object. */
-export type SessionLogZipEntry =
-  | { readonly path: string; readonly content: string }
-  | { readonly path: string; readonly data: Uint8Array }
-
-/** Zip extension for each accepted raster media type. */
-const MEDIA_TYPE_EXTENSIONS: Record<ImageAttachmentRef['mediaType'], string> = {
-  'image/png': 'png',
-  'image/jpeg': 'jpg',
-  'image/webp': 'webp',
-  'image/gif': 'gif',
-}
-
-/**
- * The zip path for one media object: content-addressed by the opaque
- * attachment id so shared images land once and the id in the log maps back to
- * the archive entry without a manifest.
- * @param ref - the durable reference from a session log.
- * @returns the archive path.
- */
-function mediaEntryPath(ref: ImageAttachmentRef): string {
-  return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}`
-}
-
-/**
- * Collect every image reference inside one content array, descending into
- * nested tool results the way the live attachment route does.
- * @param content - an event content array (or nested tool-result content).
- * @param refs - the dedupe map being filled (keyed by attachment id).
- */
-function collectImageRefs(content: unknown, refs: Map<string, ImageAttachmentRef>): void {
-  if (!Array.isArray(content)) return
-  const pending: unknown[] = []
-  for (const item of content) pending.push(item)
-  while (pending.length > 0) {
-    const value = pending.pop()
-    if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
-    const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
-    if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
-      const ref = block.attachment as ImageAttachmentRef
-      refs.set(String(ref.attachmentId), ref)
-    }
-    if (Array.isArray(block.content)) {
-      for (const item of block.content) pending.push(item)
-    }
-  }
-}
-
-/**
- * Collect every image reference one session event carries, across the same
- * carriers the live attachment route scans (direct content, message content,
- * inserted messages, and completed assistant chunk blocks).
- * @param event - one parsed JSONL event object.
- * @param refs - the dedupe map being filled (keyed by attachment id).
- */
-function collectEventImageRefs(event: unknown, refs: Map<string, ImageAttachmentRef>): void {
-  const data = (event as { data?: unknown }).data
-  if (typeof data !== 'object' || data === null) return
-  const carrier = data as {
-    content?: unknown
-    message?: { content?: unknown }
-    inserted?: Array<{ content?: unknown }>
-    chunk?: { type?: unknown; block?: unknown }
-  }
-  collectImageRefs(carrier.content, refs)
-  if (carrier.message !== undefined) collectImageRefs(carrier.message.content, refs)
-  if (carrier.inserted !== undefined) {
-    for (const message of carrier.inserted) collectImageRefs(message.content, refs)
-  }
-  if (carrier.chunk?.type === 'block-end') collectImageRefs([carrier.chunk.block], refs)
-}
-
-/**
- * Collect the distinct media references one stored artifact text names.
- * Lines that fail to parse cannot reference media and are skipped (the
- * artifact text itself is exported verbatim regardless).
- * @param content - the stored artifact text.
- * @returns the dedupe map keyed by attachment id.
- */
-function imageRefsInArtifact(content: string): Map<string, ImageAttachmentRef> {
-  const refs = new Map<string, ImageAttachmentRef>()
-  for (const line of content.split('\n')) {
-    if (line === '') continue
-    let event: unknown
-    try {
-      event = JSON.parse(line)
-    } catch {
-      continue
-    }
-    collectEventImageRefs(event, refs)
-  }
-  return refs
-}
-
-/**
- * One safe zip path segment from an untrusted session id. Session ids are
- * host-controlled, but the brand allows any non-empty string, so `../`, dot
- * segments, and separator characters are neutralized before they can shape
- * archive entries. Distinct ids may collapse onto one segment (id collision
- * is impossible for the host-minted UUIDs, so no uniqueness suffix is kept).
- * @param id - the raw session id.
- * @returns a filesystem-safe single path segment.
- */
-function safeSessionIdSegment(id: string): string {
-  return id.replace(/[^A-Za-z0-9_-]/g, '_')
-}
-
-/**
- * The export archive filename for one root session.
- * @param sessionId - the root session id (sanitized to one safe path segment).
- * @returns the attachment filename for the session's export archive.
- */
-export function sessionLogZipFilename(sessionId: string): string {
-  return `dsh-session-${safeSessionIdSegment(sessionId)}.zip`
-}
-
-/**
- * Yield the export entries in zip order: the preloaded root artifact first,
- * then every subagent descendant in lineage order (each flushed when live,
- * read from the persistence backend right before it is yielded, and dropped
- * after the consumer moves on), then every distinct media object referenced by any of
- * the included logs (read and verified from the attachment store, one archive
- * entry per attachment id). The host holds at most one descendant's artifact
- * text and one media object at a time beyond the root.
- * @param deps - the mounted export services (the caller answered 500 before this runs).
- * @param root - the already-read root artifact (read by the caller so the
- * missing-session path can answer cleanly before streaming starts).
- * @param sessionId - the root session id.
- * @param includeDescendants - whether to include every subagent descendant.
- * @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads.
- * @returns the export entries in zip order.
- */
-export async function* sessionLogZipEntries(
-  deps: SessionLogExportReady,
-  root: SessionRawArtifact,
-  sessionId: SessionId,
-  includeDescendants: boolean,
-  signal?: AbortSignal,
-): AsyncGenerator<SessionLogZipEntry> {
-  const media = new Map<string, ImageAttachmentRef>()
-  const rememberMedia = (content: string): void => {
-    for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref)
-  }
-  rememberMedia(root.content)
-  yield { path: root.filename, content: root.content }
-  if (includeDescendants) {
-    const seen = new Set<SessionId>([sessionId])
-    const collect = async function* (
-      nodes: readonly SessionLineageNode[],
-    ): AsyncGenerator<SessionLogZipEntry> {
-      for (const node of nodes) {
-        signal?.throwIfAborted()
-        const id = node.session.header.id
-        if (seen.has(id)) continue
-        seen.add(id)
-        await flushLiveSessionLog(deps, id, signal)
-        const raw = await deps.sessionPersistence.readRaw(id, signal)
-        signal?.throwIfAborted()
-        if (raw === undefined) {
-          throw new Error(`subagent "${id}" has no stored log artifact`)
-        }
-        rememberMedia(raw.content)
-        yield {
-          path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`,
-          content: raw.content,
-        }
-        yield* collect(node.descendants)
-      }
-    }
-    const lineage = await deps.sessionQuery.traceSession(sessionId, signal)
-    signal?.throwIfAborted()
-    yield* collect(lineage.descendants)
-  }
-  for (const ref of media.values()) {
-    signal?.throwIfAborted()
-    const stored = await deps.attachments.readImage(ref, signal)
-    signal?.throwIfAborted()
-    yield { path: mediaEntryPath(ref), data: stored.data }
-  }
-}
-
-/** How many code units of artifact text one zip push carries (bounded encode memory). */
-const PUSH_CHUNK_CODE_UNITS = 1 << 16
-
-/** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */
-const PUSH_CHUNK_BYTES = 1 << 16
-
-/** Byte capacity retained by the response stream before ZIP production waits for pull. */
-const RESPONSE_HIGH_WATER_MARK_BYTES = 1 << 16
-
-/** One producer waiter released only when ReadableStream pull restores capacity. */
-class ResponseCapacityGate {
-  private releasePending: (() => void) | undefined
-
-  /**
-   * Wait until the response queue has positive byte capacity or cancellation wins.
-   * @param controller - response controller whose desired size owns capacity.
-   * @param signal - combined request/consumer cancellation.
-   */
-  async wait(
-    controller: ReadableStreamDefaultController<Uint8Array>,
-    signal: AbortSignal,
-  ): Promise<void> {
-    signal.throwIfAborted()
-    if (controller.desiredSize === null || controller.desiredSize > 0) return
-    await new Promise<void>((resolve) => {
-      const release = (): void => {
-        this.releasePending = undefined
-        signal.removeEventListener('abort', release)
-        resolve()
-      }
-      this.releasePending = release
-      signal.addEventListener('abort', release, { once: true })
-    })
-    signal.throwIfAborted()
-  }
-
-  /** Release the current producer waiter after a consumer pull. */
-  pulled(): void {
-    this.releasePending?.()
-  }
-}
-
-/**
- * Push one media object's bytes into a deflate stream in bounded chunks,
- * waiting for consumer capacity between chunks like the artifact path does.
- * @param deflate - the zip entry's deflate stream.
- * @param data - the stored image bytes.
- * @param controller - response queue controller.
- * @param capacity - pull-driven response-capacity gate.
- * @param signal - cancellation; throws when aborted.
- */
-async function pushBinaryChunks(
-  deflate: ZipDeflate,
-  data: Uint8Array,
-  controller: ReadableStreamDefaultController<Uint8Array>,
-  capacity: ResponseCapacityGate,
-  signal: AbortSignal,
-): Promise<void> {
-  let offset = 0
-  do {
-    signal.throwIfAborted()
-    const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength)
-    const finalChunk = end >= data.byteLength
-    deflate.push(data.subarray(offset, end), finalChunk)
-    offset = end
-    await capacity.wait(controller, signal)
-  } while (offset < data.byteLength)
-}
-
-/**
- * Push one artifact's text into a deflate stream in bounded chunks, never
- * splitting a surrogate pair across a chunk boundary (a lone high surrogate
- * re-encodes as U+FFFD and would silently corrupt the exported artifact).
- * @param deflate - the zip entry's deflate stream.
- * @param content - the artifact text verbatim.
- * @param controller - response queue controller.
- * @param capacity - pull-driven response-capacity gate.
- * @param signal - cancellation; throws when aborted.
- */
-async function pushArtifactChunks(
-  deflate: ZipDeflate,
-  content: string,
-  controller: ReadableStreamDefaultController<Uint8Array>,
-  capacity: ResponseCapacityGate,
-  signal: AbortSignal,
-): Promise<void> {
-  const encoder = new TextEncoder()
-  let offset = 0
-  let finalChunk: boolean
-  do {
-    signal.throwIfAborted()
-    let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length)
-    if (end < content.length && end - offset > 1) {
-      // Back off one code unit when the boundary lands inside a surrogate
-      // pair: the pair then starts the next chunk whole.
-      const last = content.charCodeAt(end - 1)
-      if (last >= 0xd800 && last <= 0xdbff) end -= 1
-    }
-    finalChunk = end >= content.length
-    deflate.push(encoder.encode(content.slice(offset, end)), finalChunk)
-    offset = end
-    await capacity.wait(controller, signal)
-  } while (!finalChunk)
-}
-
-/**
- * Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is
- * read and validated by the caller before this is called (missing root or
- * missing services answer cleanly before any byte is produced); each entry is
- * then encoded and deflated in bounded chunks as it is produced, so the
- * archive bytes arrive incrementally. A descendant that fails to read errors
- * the stream (fail-loud, never silent under-export).
- * @param deps - the mounted export services (the caller answered 500 before this runs).
- * @param root - the already-read root artifact (first zip entry).
- * @param sessionId - the root session id.
- * @param includeDescendants - whether to include every subagent descendant.
- * @param compressionLevel - validated fflate DEFLATE level for every ZIP entry.
- * @param signal - request cancellation combined with response-consumer cancellation.
- * @returns the zip byte stream.
- */
-export function streamSessionLogZip(
-  deps: SessionLogExportReady,
-  root: SessionRawArtifact,
-  sessionId: SessionId,
-  includeDescendants: boolean,
-  compressionLevel: SessionLogCompressionLevel,
-  signal: AbortSignal,
-): ReadableStream<Uint8Array> {
-  const consumerAbort = new AbortController()
-  const producerSignal = AbortSignal.any([signal, consumerAbort.signal])
-  let zip: Zip | undefined
-  let zipTerminated = false
-  const capacity = new ResponseCapacityGate()
-  const terminateZip = (): void => {
-    if (zip === undefined || zipTerminated) return
-    zipTerminated = true
-    zip.terminate()
-  }
-  return new ReadableStream<Uint8Array>({
-    start(controller) {
-      // fflate invokes the callback synchronously per compressed chunk, so a
-      // single push can enqueue ahead of a slow consumer; the capacity gate
-      // waits for pull between pushes once the byte queue is full, bounding
-      // accumulation to the queue high-water mark plus one synchronous push.
-      const archive = new Zip((error, data, final) => {
-        /* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */
-        if (error) {
-          controller.error(error)
-          return
-        }
-        /* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */
-        if (data.byteLength > 0) controller.enqueue(data)
-        if (final) controller.close()
-      })
-      zip = archive
-      void (async () => {
-        try {
-          for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) {
-            const deflate = new ZipDeflate(entry.path, { level: compressionLevel })
-            archive.add(deflate)
-            if ('content' in entry) {
-              await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal)
-            } else {
-              await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal)
-            }
-          }
-          archive.end()
-        } catch (error) {
-          // A mid-stream failure (missing descendant, cancellation, read
-          // error) must fail the download rather than ship a truncated archive.
-          /* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */
-          terminateZip()
-          controller.error(error instanceof Error ? error : new Error(String(error)))
-        }
-      })()
-    },
-    pull() {
-      capacity.pulled()
-    },
-    cancel(reason) {
-      consumerAbort.abort(
-        reason instanceof Error ? reason : new Error('session log export stream cancelled'),
-      )
-      terminateZip()
-    },
-  }, {
-    highWaterMark: RESPONSE_HIGH_WATER_MARK_BYTES,
-    size: chunk => chunk.byteLength,
-  })
-}

+ 0 - 236
packages/host/apiproxy/tests/api-proxy-config.spec.ts

@@ -1,236 +0,0 @@
-/**
- * Settings events consumed by Client model and permission surfaces.
- */
-
-import { describe, expect, it } from 'vitest'
-import { Context } from '@deepseek-ai/cordis'
-import z from '@deepseek-ai/schemastery'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
-import SessionStore from '@deepseek-ai/dsh-session'
-import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
-import ToolRuntime from '@deepseek-ai/dsh-tools'
-import { SettingsProvider, settingsNamespace } from '@deepseek-ai/dsh-settings'
-import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
-import { CredentialProvider } from '@deepseek-ai/dsh-credentials'
-import type {
-  CredentialInfo,
-  CredentialKey,
-  CredentialRecord,
-  CredentialRecordEntry,
-  CredentialRecordInfo,
-  CredentialRef,
-  ResolvedCredential,
-} from '@deepseek-ai/dsh-credentials'
-import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
-
-/** In-memory settings provider: the Service Definition base class owns all tested behavior. */
-class MemorySettings extends SettingsProvider {
-  doc: Record<string, unknown>
-
-  constructor(ctx: ConstructorParameters<typeof SettingsProvider>[0], options?: {
-    doc?: Record<string, unknown>
-    readOnly?: boolean
-    documentPath?: string
-    preparedPath?: string
-  }) {
-    super(ctx)
-    this.doc = structuredClone(options?.doc ?? {})
-    this.readOnly = options?.readOnly ?? false
-    this.path = options?.documentPath
-    this.preparedPath = options?.preparedPath
-  }
-
-  private readonly readOnly: boolean
-  private readonly path: string | undefined
-  private readonly preparedPath: string | undefined
-
-  get writable(): boolean {
-    return !this.readOnly
-  }
-
-  override get documentPath(): string | undefined {
-    return this.path
-  }
-
-  override prepareDocument(): Promise<string | undefined> {
-    return Promise.resolve(this.preparedPath ?? this.documentPath)
-  }
-
-  protected load(): Promise<Record<string, unknown>> {
-    return Promise.resolve(structuredClone(this.doc))
-  }
-
-  protected persist(ns: SettingsNamespace, section: Record<string, unknown>): Promise<void> {
-    this.doc[ns] = structuredClone(section)
-    return Promise.resolve()
-  }
-}
-
-/** In-memory credential provider with an env-shadow double for the rejection path. */
-class MemoryCredentials extends CredentialProvider {
-  private readonly values = new Map<string, string>()
-
-  constructor(ctx: ConstructorParameters<typeof CredentialProvider>[0], options?: { shadowed?: string[] }) {
-    super(ctx)
-    this.shadowed = new Set(options?.shadowed ?? [])
-  }
-
-  private readonly shadowed: Set<string>
-
-  resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined> {
-    if (this.shadowed.has(ref)) return Promise.resolve({ value: 'from-env', source: 'env' })
-    const value = this.values.get(ref)
-    return Promise.resolve(value === undefined ? undefined : { value, source: 'file' })
-  }
-
-  describe(ref: CredentialRef): Promise<CredentialInfo> {
-    if (this.shadowed.has(ref)) return Promise.resolve({ configured: true, source: 'env', writable: false })
-    const configured = this.values.has(ref)
-    return Promise.resolve({ configured, ...configured ? { source: 'file' } : {}, writable: true })
-  }
-
-  set(ref: CredentialRef, value: string): Promise<void> {
-    if (this.shadowed.has(ref)) {
-      return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`))
-    }
-    this.values.set(ref, value)
-    this.ctx.emit('credentials/reference-updated', ref)
-    return Promise.resolve()
-  }
-
-  unset(ref: CredentialRef): Promise<void> {
-    if (this.shadowed.has(ref)) {
-      return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`))
-    }
-    this.values.delete(ref)
-    this.ctx.emit('credentials/reference-updated', ref)
-    return Promise.resolve()
-  }
-
-  // The record half has no wire face on this proxy, so the double answers the
-  // empty store rather than modelling storage the tests never exercise.
-  readRecord(): Promise<CredentialRecord | undefined> {
-    return Promise.resolve(undefined)
-  }
-
-  describeRecord(): Promise<CredentialRecordInfo> {
-    return Promise.resolve({ configured: false, writable: true })
-  }
-
-  listRecords(): Promise<readonly CredentialRecordEntry[]> {
-    return Promise.resolve([])
-  }
-
-  modifyRecord(
-    _key: CredentialKey,
-    mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>,
-  ): Promise<CredentialRecord | undefined> {
-    return mutate(undefined)
-  }
-
-  deleteRecord(): Promise<void> {
-    return Promise.resolve()
-  }
-}
-
-const NS = settingsNamespace('llm-deepseek')
-
-const AdapterConfig = z.object({
-  apiKey: z.string().role('secret'),
-  apiKeyEnv: z.string().default('DEEPSEEK_API_KEY'),
-  baseURL: z.string(),
-})
-
-async function harness(options?: {
-  settings?: false | {
-    doc?: Record<string, unknown>
-    readOnly?: boolean
-    documentPath?: string
-    preparedPath?: string
-  }
-  credentials?: false | { shadowed?: string[] }
-}): Promise<Context> {
-  const ctx = new Context()
-  await ctx.plugin(SessionStore)
-  await ctx.plugin(SystemPrompt, { persona: '' })
-  await ctx.plugin(ToolRuntime)
-  await ctx.plugin(AgentRegistry)
-  if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings)
-  if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials)
-  return ctx
-}
-
-/** Observe settings commits while one API operation runs. */
-async function captureSettingsUpdates(
-  ctx: Context,
-  run: () => Promise<void>,
-): Promise<Array<readonly [SettingsNamespace, number]>> {
-  const updates: Array<readonly [SettingsNamespace, number]> = []
-  const dispose = ctx.on('settings/document-updated', (namespace, revision) => {
-    updates.push([namespace, revision])
-  })
-  try {
-    await run()
-    return updates
-  } finally {
-    dispose()
-  }
-}
-
-/** Expected settings event tuple with its owner-assigned revision. */
-function expectedSettingsUpdate(ns: string): readonly unknown[] {
-  return [ns, expect.any(Number)]
-}
-
-describe('settings events', () => {
-  it('forwards a provider settings change for model-catalog consumers', async () => {
-    // Editing `models` changes no route, so llm/adapters-updated never fires
-    // and an open model picker would keep serving the stale catalog. Storing
-    // an override equal to the resolved value emits nothing on
-    // settings/updated, so another tab would never learn the field became
-    // overridden.
-    const ctx = await harness()
-    ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } })
-    const updates = await captureSettingsUpdates(ctx, async () => {
-      await ctx.settings.update(settingsNamespace('llm-deepseek'), { baseURL: 'https://base' })
-    })
-    expect(updates).toEqual([expectedSettingsUpdate('llm-deepseek')])
-    // The resolved value never moved: base already said https://base.
-    expect(ctx.settings.describe().find(view => String(view.ns) === 'llm-deepseek')?.value)
-      .toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' })
-  })
-
-  it('broadcasts a permission change without invalidating the model catalog', async () => {
-    const ctx = await harness()
-    const permission = ctx.settings.register(settingsNamespace('permission'), z.object({
-      defaultPreset: z.union(['read-only', 'workspace-write']).required(),
-    }), {
-      base: { defaultPreset: 'read-only' },
-    })
-    const updates = await captureSettingsUpdates(ctx, async () => {
-      await permission.update({ defaultPreset: 'workspace-write' })
-    })
-    expect(updates).toEqual([expectedSettingsUpdate('permission')])
-  })
-
-  it('forwards an Agent-default settings change for model-catalog consumers', async () => {
-    const ctx = await harness()
-    const defaultModel = ctx.settings.register(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, z.object({
-      provider: z.string().required(),
-      model: z.string().required(),
-    }), { base: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } })
-    // The shared section names the selection every blank session resolves to,
-    // so an externally edited default — another tab, a
-    // hand-edited settings.yaml — has to reach an open selector as well.
-    const updates = await captureSettingsUpdates(ctx, async () => {
-      await defaultModel.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' })
-    })
-    expect(updates).toEqual([expectedSettingsUpdate('agent-default-model')])
-  })
-
-
-
-
-
-
-})

+ 0 - 51
packages/host/apiproxy/tests/api-proxy-host.spec.ts

@@ -1,51 +0,0 @@
-import { homedir } from 'node:os'
-import { afterEach, describe, expect, it } from 'vitest'
-import { Context } from '@deepseek-ai/cordis'
-import AgentRegistry from '@deepseek-ai/dsh-agent'
-import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
-import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
-import { createApiProxy } from '../src/api-proxy.ts'
-
-let nextRpc = 1
-const contexts: Context[] = []
-
-afterEach(async () => {
-  await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
-})
-
-function request<P>(payload: P): RpcRequest<P> {
-  return { rpcId: RpcId(`host-${String(nextRpc++)}`), payload }
-}
-
-function expectOk<T>(response: { readonly result: { readonly ok: true; readonly value: T } | { readonly ok: false } }): T {
-  expect(response.result.ok).toBe(true)
-  if (!response.result.ok) throw new Error('unreachable')
-  return response.result.value
-}
-
-async function harness(
-  extras: {
-    canOpenPath?: () => boolean
-  } = {},
-) {
-  const ctx = new Context()
-  contexts.push(ctx)
-  await ctx.plugin(AgentRegistry)
-  const api = createApiProxy(ctx, {
-    defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
-    cwd: '/tmp/dsh-apiproxy-host',
-    ...extras.canOpenPath === undefined ? {} : { canOpenPath: extras.canOpenPath },
-  })
-  return { api }
-}
-
-describe('host.describe', () => {
-  it('describes whether the deployment can reach a native desktop', async () => {
-    const visible = await harness({ canOpenPath: () => true })
-    const headless = await harness({ canOpenPath: () => false })
-    expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true)
-    expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false)
-    expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir())
-  })
-
-})

+ 0 - 228
packages/host/apiproxy/tests/client-handler.spec.ts

@@ -1,228 +0,0 @@
-/**
- * Wire-protocol coverage over the isomorphic point: InProcessApiClient →
- * toFetchHandler(scripted impl) runs the real envelope wrap/unwrap, zod
- * two-level parse, and rpcId discipline with no network or browser. Each case
- * scripts its own minimal ApiProxy.
- */
-
-import { describe, expect, it, vi } from 'vitest'
-import type { ApiProxy, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy'
-import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
-
-function ok<T>(request: RpcRequest<unknown>, value: T): Promise<RpcResponse<T>> {
-  return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } })
-}
-
-/** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */
-function scriptedApi(overrides: {
-  host?: Partial<ApiProxy['host']>
-} = {}): ApiProxy {
-  return {
-    host: {
-      describe: r => ok(r, {
-        version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true,
-      }),
-      ...overrides.host,
-    },
-    downloads: { sessionLog: async () => new Response('stub', { status: 404 }) },
-  }
-}
-
-function client(api: ApiProxy, timeoutMs?: number): InProcessApiClient {
-  return new InProcessApiClient(toFetchHandler(api), timeoutMs)
-}
-
-describe('unary round trip', () => {
-  it('carries payload out and value back through the full wire form', async () => {
-    let seen: RpcRequest<{}> | undefined
-    const api = scriptedApi({
-      host: {
-        describe: (request) => {
-          seen = request
-          return ok(request, { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true })
-        },
-      },
-    })
-    const response = await client(api).host.describe({})
-    expect(seen?.payload).toEqual({})
-    expect(seen?.rpcId).toBeTruthy()
-    expect(response.rpcId).toBe(seen?.rpcId)
-    expect(response.result).toMatchObject({ ok: true, value: { version: '0-test' } })
-  })
-
-  it('passes business errors through as 200 + err result, not a throw', async () => {
-    const api = scriptedApi({
-      host: {
-        describe: request => Promise.resolve({
-          rpcId: request.rpcId,
-          result: { ok: false, error: { code: 'internal', message: 'nope', details: {} } },
-        }),
-      },
-    })
-    const response = await client(api).host.describe({})
-    expect(response.result).toEqual({ ok: false, error: { code: 'internal', message: 'nope', details: {} } })
-  })
-
-  it('throws on rpcId echo mismatch', async () => {
-    const api = scriptedApi({
-      host: {
-        describe: () => Promise.resolve({
-          rpcId: RpcId('forged'),
-          result: { ok: true, value: { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true } },
-        }),
-      },
-    })
-    await expect(client(api).host.describe({})).rejects.toThrow(/rpcId mismatch/)
-  })
-
-  it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => {
-    const handler = toFetchHandler(scriptedApi())
-    // No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse.
-    const noId = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) })
-    expect(noId.status).toBe(200)
-    const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } }
-    expect(noIdParsed.result.ok).toBe(false)
-    expect(noIdParsed.rpcId).toBe('invalid-request')
-    // A string rpcId in the otherwise-bad body is salvaged for correlation.
-    const withId = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) })
-    const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } }
-    expect(withIdParsed.result.ok).toBe(false)
-    expect(withIdParsed.rpcId).toBe('salvage-me')
-  })
-
-  it('maps carrier failures to HTTP statuses and the client throws transport failure', async () => {
-    const handler = toFetchHandler(scriptedApi())
-    // Unknown method → 404.
-    const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })
-    expect(notFound.status).toBe(404)
-    // Non-JSON body → 400.
-    const badBody = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' })
-    expect(badBody.status).toBe(400)
-    // Impl crash → 500, and through the client that is a throw, not an err result.
-    const crashing = scriptedApi({ host: { describe: () => { throw new Error('impl exploded') } } })
-    await expect(client(crashing).host.describe({})).rejects.toThrow(/transport failure .*500/)
-  })
-
-  it('rejects non-JSON media types before executing anything (cross-site simple-request fence)', async () => {
-    const describe = vi.fn((request: RpcRequest<{}>) => ok(request, {
-      version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true,
-    }))
-    const handler = toFetchHandler(scriptedApi({ host: { describe } }))
-    const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} })
-    // A "simple" browser POST (text/plain — sent with no CORS preflight) is
-    // refused at the carrier before the impl runs.
-    const plain = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'text/plain' }, body })
-    expect(plain.status).toBe(415)
-    // A string body with no explicit header defaults to text/plain — same fence.
-    const unlabelled = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', body })
-    expect(unlabelled.status).toBe(415)
-    expect(describe).not.toHaveBeenCalled()
-    // Media-type parameters pass: the fence checks the type, not the exact string.
-    const charset = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body })
-    expect(charset.status).toBe(200)
-    expect(describe).toHaveBeenCalledTimes(1)
-  })
-
-  it('rejects when the transport never resolves within timeoutMs', async () => {
-    // AbortSignal.timeout is immune to fake timers; a short real timeout keeps this fast.
-    const never = new InProcessApiClient({
-      fetch: (_i: RequestInfo | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
-        init?.signal?.addEventListener('abort', () => { reject(new Error('aborted by timeout')) })
-      }),
-    }, 25)
-    await expect(never.host.describe({})).rejects.toThrow()
-  })
-
-  it('aborts a unary call through the caller-supplied external signal', async () => {
-    // Real-fetch semantics: on abort the rejection is the signal's reason, and the abort
-    // works even when the transport ignores the signal entirely (hung impl).
-    const gate = new AbortController()
-    const hung = new InProcessApiClient({ fetch: () => new Promise<Response>(() => {}) }, 60_000)
-    const call = hung.host.describe({}, gate.signal)
-    gate.abort(new Error('externally aborted'))
-    await expect(call).rejects.toThrow(/externally aborted/)
-  })
-
-  it('rejects an already-aborted signal before touching the transport, mapping a string reason to an Error', async () => {
-    let touched = false
-    const c = new InProcessApiClient({
-      fetch: () => {
-        touched = true
-        return Promise.resolve(new Response('{}'))
-      },
-    }, 60_000)
-    const gate = new AbortController()
-    gate.abort('gone before start')
-    await expect(c.host.describe({}, gate.signal)).rejects.toThrow('gone before start')
-    expect(touched).toBe(false)
-  })
-
-  it('maps a non-Error, non-string abort reason to the default AbortError message', async () => {
-    const gate = new AbortController()
-    const hung = new InProcessApiClient({ fetch: () => new Promise<Response>(() => {}) }, 60_000)
-    const call = hung.host.describe({}, gate.signal)
-    gate.abort(42)
-    await expect(call).rejects.toThrow('This operation was aborted')
-  })
-
-  it('passes a signal-less doFetch straight through to the handler', async () => {
-    class Probe extends InProcessApiClient {
-      direct(url: URL): Promise<Response> {
-        return this.doFetch(url)
-      }
-    }
-    const probe = new Probe({ fetch: () => Promise.resolve(new Response('raw')) })
-    const response = await probe.direct(new URL('http://dsh.internal/probe'))
-    expect(await response.text()).toBe('raw')
-  })
-
-  it('throws on an S→C ok value that fails the method value schema (second-level parse)', async () => {
-    // Impl echoes rpcId but returns a wrong-shaped value: envelope parse passes, value parse must reject.
-    const api = scriptedApi({
-      host: { describe: request => Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { version: 1 } } }) as never },
-    })
-    await expect(client(api).host.describe({})).rejects.toThrow()
-  })
-})
-
-describe('envelope tap', () => {
-  it('delivers one microtask batch of full forms per unary call', async () => {
-    const api = scriptedApi()
-    const tapped = client(api)
-    const batches: (readonly RpcMessage[])[] = []
-    tapped.subscribeEnvelopes(batch => batches.push(batch))
-    await tapped.host.describe({})
-    await vi.waitFor(() => { expect(batches.length).toBeGreaterThan(0) })
-    const all = batches.flat()
-    expect(all.map(m => m.type)).toEqual(['client-request', 'server-response'])
-    expect(all[0]?.rpcId).toBe(all[1]?.rpcId)
-  })
-
-  it('isolates a throwing listener and keeps serving the call', async () => {
-    const api = scriptedApi()
-    const tapped = client(api)
-    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
-    try {
-      const good: string[] = []
-      tapped.subscribeEnvelopes(() => { throw new Error('listener bug') })
-      tapped.subscribeEnvelopes(batch => good.push(...batch.map(m => m.type)))
-      const response = await tapped.host.describe({})
-      expect(response.result.ok).toBe(true)
-      await vi.waitFor(() => { expect(good).toContain('server-response') })
-    } finally {
-      errorSpy.mockRestore()
-    }
-  })
-
-  it('buffers nothing with zero subscribers and unsubscribes cleanly', async () => {
-    const api = scriptedApi()
-    const tapped = client(api)
-    await tapped.host.describe({}) // no subscribers: must not accumulate
-    const batches: (readonly RpcMessage[])[] = []
-    const unsubscribe = tapped.subscribeEnvelopes(batch => batches.push(batch))
-    unsubscribe()
-    await tapped.host.describe({})
-    await new Promise(resolve => setTimeout(resolve, 0))
-    expect(batches).toEqual([])
-  })
-})

+ 0 - 207
packages/host/apiproxy/tests/fetch-carrier.spec.ts

@@ -1,207 +0,0 @@
-import { describe, expect, it, vi } from 'vitest'
-import type { ApiProxy } from '../src/api/index.ts'
-import type { RpcMessage } from '../src/api/rpc.ts'
-import { toFetchHandler } from '../src/fetch/handler.ts'
-import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts'
-
-/** Minimal in-memory ApiProxy that echoes rpcIds. */
-function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy {
-  return {
-    host: {
-      async describe(request) {
-        if (overrides.crashOn === 'host.describe') throw new Error('impl crashed')
-        return {
-          rpcId: request.rpcId,
-          result: {
-            ok: true,
-            value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true },
-          },
-        }
-      },
-    },
-    downloads: {
-      async sessionLog() {
-        return new Response('stub', { status: 404 })
-      },
-    },
-  }
-}
-
-function client(api: ApiProxy = fakeApi(), timeoutMs?: number): InProcessApiClient {
-  return new InProcessApiClient(toFetchHandler(api), timeoutMs)
-}
-
-describe('unary round trip (handler ⇄ client, no network)', () => {
-  it('carries a success result and echoes the minted rpcId', async () => {
-    const response = await client().host.describe({})
-    expect(response.result).toMatchObject({ ok: true, value: { version: 'v', cwd: '/w' } })
-    expect(response.rpcId).toMatch(/[0-9a-f-]{36}/)
-  })
-
-  it('carries a business error as 200 + error result', async () => {
-    const api = fakeApi()
-    api.host.describe = request => Promise.resolve({
-      rpcId: request.rpcId,
-      result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } },
-    })
-    const response = await client(api).host.describe({})
-    expect(response.result.ok).toBe(false)
-    if (!response.result.ok) expect(response.result.error.code).toBe('internal')
-  })
-
-})
-
-describe('handler carrier-layer statuses', () => {
-  const handler = toFetchHandler(fakeApi())
-
-  it('404s unknown paths and non-POST non-stream methods', async () => {
-    expect((await handler.fetch(new Request('http://x/other', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }))).status).toBe(404)
-    expect((await handler.fetch(new Request('http://x/api/host.describe', { method: 'GET' }))).status).toBe(404)
-    expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404)
-  })
-
-  it('400s a non-JSON body', async () => {
-    const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not json' }))
-    expect(response.status).toBe(400)
-  })
-
-  it('rejects a malformed envelope with bad-request and the invalid-request sentinel rpcId', async () => {
-    const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nope: true }) }))
-    expect(response.status).toBe(200)
-    const body = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } }
-    expect(body.rpcId).toBe('invalid-request')
-    expect(body.result.error?.code).toBe('bad-request')
-  })
-
-  it('rejects an invalid payload with the zod issues attached', async () => {
-    const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'host.describe', payload: null })
-    const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
-    const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } }
-    expect(parsed.result.error?.code).toBe('bad-request')
-    expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0)
-  })
-
-  it('rejects a request whose envelope method does not match its path', async () => {
-    const body = JSON.stringify({ type: 'client-request', rpcId: 'r-mismatch', method: 'other.method', payload: {} })
-    const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
-    const parsed = await response.json() as { result: { error?: { code: string; message: string } } }
-    expect(parsed.result.error).toMatchObject({
-      code: 'bad-request',
-      message: 'method "other.method" does not match path "host.describe"',
-    })
-  })
-
-  it('500s when the impl itself throws', async () => {
-    const crashing = toFetchHandler(fakeApi({ crashOn: 'host.describe' }))
-    const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'host.describe', payload: {} })
-    const response = await crashing.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
-    expect(response.status).toBe(500)
-    expect(await response.text()).toContain('impl crashed')
-  })
-
-  it('accepts (url, init) form fetch invocation', async () => {
-    const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'host.describe', payload: {} })
-    const response = await handler.fetch('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body })
-    expect(response.status).toBe(200)
-  })
-})
-
-describe('client transport failures', () => {
-  it('throws on a non-OK unary transport', async () => {
-    const broken = new InProcessApiClient({ fetch: async () => new Response('down', { status: 503 }) })
-    await expect(broken.host.describe({})).rejects.toThrow('transport failure for /api/host.describe: HTTP 503')
-  })
-
-  it('throws on an rpcId echo mismatch', async () => {
-    const lying = new InProcessApiClient({
-      fetch: async () => Response.json({
-        type: 'server-response',
-        rpcId: 'someone-else',
-        result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true } },
-      }),
-    })
-    await expect(lying.host.describe({})).rejects.toThrow('rpcId mismatch')
-  })
-})
-
-describe('envelope observation', () => {
-  it('batches envelopes per microtask and isolates a throwing listener', async () => {
-    const c = client()
-    const batches: (readonly RpcMessage[])[] = []
-    const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
-    const unsubscribeThrowing = c.subscribeEnvelopes(() => { throw new Error('observer bug') })
-    const unsubscribe = c.subscribeEnvelopes((batch) => { batches.push(batch) })
-    await c.host.describe({})
-    await new Promise((resolve) => { setTimeout(resolve, 0) })
-    // request and response tap in separate microtask windows (the await between
-    // them yields), so both arrive but batch count is timing-defined
-    expect(batches.flatMap(batch => batch.map(message => message.type))).toEqual(['client-request', 'server-response'])
-    expect(errorSpy).toHaveBeenCalled()
-    unsubscribe()
-    unsubscribeThrowing()
-    errorSpy.mockRestore()
-  })
-
-  it('skips buffering entirely with no listeners and after unsubscribe', async () => {
-    const c = client()
-    const seen: RpcMessage[] = []
-    const unsubscribe = c.subscribeEnvelopes((batch) => { seen.push(...batch) })
-    unsubscribe()
-    await c.host.describe({})
-    await new Promise((resolve) => { setTimeout(resolve, 0) })
-    expect(seen).toHaveLength(0)
-  })
-
-  it('coalesces multiple calls in one microtask window into one flush', async () => {
-    const c = client()
-    const batches: (readonly RpcMessage[])[] = []
-    c.subscribeEnvelopes((batch) => { batches.push(batch) })
-    await Promise.all([c.host.describe({}), c.host.describe({})])
-    await new Promise((resolve) => { setTimeout(resolve, 0) })
-    const total = batches.reduce((n, batch) => n + batch.length, 0)
-    expect(total).toBe(4)
-  })
-})
-
-describe('resolveBase', () => {
-  it('prefers a real location.origin and falls back to the internal authority', async () => {
-    class Probe extends AbstractApiClient {
-      urls: string[] = []
-      protected async doFetch(input: URL): Promise<Response> {
-        this.urls.push(input.href)
-        return Response.json({
-          type: 'server-response',
-          rpcId: this.lastMinted,
-          result: {
-            ok: true,
-            value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true },
-          },
-        })
-      }
-
-      lastMinted = ''
-      protected override mintRpcId(): ReturnType<AbstractApiClient['mintRpcId']> {
-        const id = super.mintRpcId()
-        this.lastMinted = id
-        return id
-      }
-    }
-    const probe = new Probe()
-    await probe.host.describe({})
-    expect(probe.urls[0]).toMatch(/^http:\/\/dsh\.internal\//)
-
-    const globalWithLocation = globalThis as { location?: { origin?: string } }
-    globalWithLocation.location = { origin: 'http://host.example' }
-    try {
-      const probe2 = new Probe()
-      await probe2.host.describe({})
-      expect(probe2.urls[0]).toMatch(/^http:\/\/host\.example\//)
-      globalWithLocation.location = { origin: 'null' } // sandboxed iframe shape
-      const probe3 = new Probe()
-      await probe3.host.describe({})
-      expect(probe3.urls[0]).toMatch(/^http:\/\/dsh\.internal\//)
-    } finally {
-      delete globalWithLocation.location
-    }
-  })
-})

+ 0 - 96
packages/host/apiproxy/tests/rpc-schemas.spec.ts

@@ -1,96 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { RpcId, transportError } from '../src/api/rpc.ts'
-import {
-  clientRequestSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema,
-  rpcResultSchema, serverResponseSchema,
-} from '../src/api/rpc.schema.ts'
-import { z } from 'zod'
-import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
-
-describe('RpcId', () => {
-  it('brands a raw string at zero runtime cost', () => {
-    expect(RpcId('abc')).toBe('abc')
-    expect(rpcIdSchema.parse('abc')).toBe('abc')
-    // No min-length: the id is an opaque echo token (see rpcIdSchema's contract).
-    expect(rpcIdSchema.parse('')).toBe('')
-    expect(() => rpcIdSchema.parse(42)).toThrow()
-  })
-})
-
-describe('transportError', () => {
-  it('folds Error and non-Error throws into the internal error branch', () => {
-    expect(transportError(new Error('wire down'))).toEqual({ ok: false, error: { code: 'internal', message: 'wire down', details: {} } })
-    expect(transportError('raw')).toMatchObject({ ok: false, error: { code: 'internal', message: 'raw' } })
-  })
-})
-
-describe('rpcErrorSchema', () => {
-  it('accepts every code branch with its required details', () => {
-    expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
-    expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled')
-    expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
-    expect(rpcErrorSchema.parse({ code: 'invalid-time-zone', message: 'm', details: { value: 'CST' } }).code).toBe('invalid-time-zone')
-    expect(rpcErrorSchema.parse({ code: 'agent-preset-read-only', message: 'm', details: { agentPreset: 'p', reason: 'system' } }).code).toBe('agent-preset-read-only')
-    expect(rpcErrorSchema.parse({ code: 'agent-preset-locked', message: 'm', details: { sessionId: 's', agentPreset: 'p' } }).code).toBe('agent-preset-locked')
-    expect(rpcErrorSchema.parse({ code: 'agent-preset-not-found', message: 'm', details: { agentPreset: 'p', available: [] } }).code).toBe('agent-preset-not-found')
-    expect(rpcErrorSchema.parse({ code: 'agent-preset-invalid', message: 'm', details: { agentPreset: 'p', reason: 'bad' } }).code).toBe('agent-preset-invalid')
-    expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
-    expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
-  })
-
-  it('rejects a known code with missing details', () => {
-    expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow()
-    expect(() => rpcErrorSchema.parse({ code: 'internal', message: 'm' })).toThrow()
-    expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow()
-  })
-})
-
-describe('rpcResultSchema', () => {
-  it('accepts both result branches and rejects hybrids', () => {
-    const schema = rpcResultSchema(z.object({ n: z.number() }))
-    expect(schema.parse({ ok: true, value: { n: 1 } })).toEqual({ ok: true, value: { n: 1 } })
-    const err = schema.parse({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
-    expect(err).toMatchObject({ ok: false })
-    expect(() => schema.parse({ ok: true, error: {} })).toThrow()
-  })
-})
-
-describe('wire full-form schemas', () => {
-  it('parses both carrier forms and the union discriminates on type', () => {
-    const cq = { type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} }
-    const sr = { type: 'server-response', rpcId: 'r1', result: { ok: true, value: 1 } }
-    expect(clientRequestSchema.parse(cq).method).toBe('host.describe')
-    expect(serverResponseSchema.parse(sr).rpcId).toBe('r1')
-    for (const message of [cq, sr]) expect(rpcMessageSchema.parse(message)).toBeTruthy()
-    expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow()
-  })
-
-  it('rejects a quadrant missing its members but accepts a valueless success result', () => {
-    expect(() => clientRequestSchema.parse({ type: 'client-request', rpcId: 'r1' })).toThrow()
-    expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1' })).toThrow()
-    expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: {} })).toThrow()
-    // A void business result carries no value field; the endpoint's own second
-    // parse is what requires a value for methods that return data.
-    expect(serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: { ok: true } }).rpcId)
-      .toBe('r1')
-  })
-})
-
-describe('host domain schemas', () => {
-  it('validates describe request/value', () => {
-    expect(hostDescribeRequestSchema.parse({})).toEqual({})
-    const value = hostDescribeValueSchema.parse({
-      version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, home: '/h', canOpenPath: true,
-    })
-    expect(value).toMatchObject({ provider: 'p', model: 'm', attachedSessions: 2, canOpenPath: true })
-    expect(hostDescribeValueSchema.parse({
-      version: '1', cwd: '/x', attachedSessions: 0, home: '/h', canOpenPath: false,
-    }).provider).toBeUndefined()
-    expect(() => hostDescribeValueSchema.parse({
-      version: '1', cwd: '/x', attachedSessions: 0,
-    })).toThrow()
-    expect(() => hostDescribeValueSchema.parse({
-      version: '1', cwd: '/x', attachedSessions: 0, canOpenPath: true,
-    })).toThrow()
-  })
-})

+ 0 - 708
packages/host/apiproxy/tests/session-export.spec.ts

@@ -1,708 +0,0 @@
-/**
- * session.export host path: the GET download endpoint streams a ZIP whose
- * files are the stored artifacts verbatim (root + optional descendants), and
- * the degenerate compositions fail loudly (missing services → 500, missing
- * root → 404, missing descendant → errored stream).
- */
-
-import { randomBytes } from 'node:crypto'
-import { describe, expect, it, vi } from 'vitest'
-import { Context } from '@deepseek-ai/cordis'
-import { unzipSync, strFromU8 } from 'fflate'
-import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
-import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
-import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query'
-import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
-import ApiProxyService, { createApiProxy, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
-
-const sid = (id: string): SessionId => id as SessionId
-
-function header(id: string, parentSession?: SessionId): SessionHeader {
-  return {
-    version: 0,
-    id: sid(id),
-    createdAt: 1000,
-    cwd: '/proj',
-    ...parentSession === undefined ? {} : { parentSession },
-    delegationDepth: parentSession === undefined ? 0 : 1,
-  }
-}
-
-function artifact(id: string, parentSession?: SessionId, content?: string): SessionRawArtifact {
-  return {
-    meta: header(id, parentSession),
-    filename: 'session.jsonl',
-    content: content ?? `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`,
-  }
-}
-
-function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode {
-  return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants }
-}
-
-/** One durable image object served by the fake attachment store. */
-function storedImage(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png') {
-  return {
-    ref: { attachmentId: sid(id), mediaType, bytes: 4, width: 2, height: 2 } as unknown as ImageAttachmentRef,
-    data: new Uint8Array([1, 2, 3, 4]),
-  }
-}
-
-/** A user/message event line carrying one image reference. */
-function imageEventLine(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): string {
-  return `{"type":"user/message","seq":1,"time":1000,"data":{"content":[{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}]}}`
-}
-
-async function buildApi(
-  artifacts: Record<string, SessionRawArtifact>,
-  descendants: SessionLineageNode[] = [],
-  services: {
-    query?: boolean
-    persistence?: boolean | 'throw' | 'unsupported'
-    attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise<ReturnType<typeof storedImage>>)
-    sessions?: {
-      get(id: SessionId): { readonly id: SessionId } | undefined
-      flush(session: { readonly id: SessionId }): Promise<boolean>
-    }
-    readRaw?: (id: SessionId, signal?: AbortSignal) => Promise<SessionRawArtifact | undefined>
-    traceSession?: (id: SessionId, signal?: AbortSignal) => Promise<{
-      target: { header: SessionHeader; live: boolean; persisted: boolean }
-      ancestors: readonly SessionLineageNode[]
-      complete: boolean
-      root: { header: SessionHeader; live: boolean; persisted: boolean }
-      descendants: readonly SessionLineageNode[]
-    }>
-    compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
-  } = {},
-) {
-  const ctx = new Context()
-  const query = services.query ?? true
-  const persistence = services.persistence ?? true
-  if (query) {
-    ctx.provide('sessionQuery', {
-      traceSession: services.traceSession ?? (async () => ({
-        target: { header: header('session-root'), live: false, persisted: true },
-        ancestors: [],
-        complete: true,
-        root: { header: header('session-root'), live: false, persisted: true },
-        descendants,
-      })),
-    } as never)
-  }
-  if (persistence) {
-    ctx.provide('sessionPersistence', {
-      supportsRawArtifacts: persistence !== 'unsupported',
-      readRaw: services.readRaw ?? (async (id: SessionId) => {
-        if (persistence === 'throw') throw new Error('/host/private/session.jsonl')
-        return artifacts[id]
-      }),
-    } as never)
-  }
-  if (services.attachments !== false) {
-    const readImage = typeof services.attachments === 'function'
-      ? services.attachments
-      : async (ref: ImageAttachmentRef) => storedImage(String(ref.attachmentId), ref.mediaType)
-    ctx.provide('attachments', {
-      imageLimits: {} as never,
-      validateImage: async () => {},
-      saveImage: async () => { throw new Error('export never saves images') },
-      readImage,
-    } as never)
-  }
-  if (services.sessions !== undefined) ctx.provide('sessions', services.sessions as never)
-  return createApiProxy(ctx, {
-    defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
-    cwd: '/tmp',
-    ...services.compressionLevel === undefined
-      ? {}
-      : { sessionExportCompressionLevel: services.compressionLevel },
-  })
-}
-
-async function responseBytes(response: Response): Promise<Uint8Array> {
-  return new Uint8Array(await response.arrayBuffer())
-}
-
-describe('session export compression config', () => {
-  it('defaults to level 6 and rejects values outside the integer 0-9 range', () => {
-    expect(ApiProxyService.Config({})).toEqual({
-      sessionExportCompressionLevel: 6,
-    })
-    expect(ApiProxyService.Config({ sessionExportCompressionLevel: 0 }))
-      .toEqual({ sessionExportCompressionLevel: 0 })
-    expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 }))
-      .toEqual({ sessionExportCompressionLevel: 9 })
-    for (const value of [-1, 10, 1.5]) {
-      expect(() => ApiProxyService.Config({ sessionExportCompressionLevel: value } as never)).toThrow()
-    }
-  })
-})
-
-describe('session.export download endpoint', () => {
-  it('streams a ZIP with the root artifact verbatim under its original filename', async () => {
-    const api = await buildApi({ 'session-root': artifact('session-root') })
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root'),
-    )
-    expect(response.status).toBe(200)
-    expect(response.headers.get('content-type')).toBe('application/zip')
-    expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip')
-    const files = unzipSync(await responseBytes(response))
-    expect(Object.keys(files)).toEqual(['session.jsonl'])
-    expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content)
-  })
-
-  it('preflights root preparation through HEAD without streaming a body', async () => {
-    const readRaw = vi.fn(async () => artifact('session-root'))
-    const api = await buildApi({}, [], { readRaw })
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }),
-    )
-
-    expect(response.status).toBe(200)
-    expect(response.headers.get('content-type')).toBe('application/zip')
-    expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip')
-    expect(response.body).toBeNull()
-    expect(readRaw).toHaveBeenCalledOnce()
-  })
-
-  it('returns a bodyless preparation error from HEAD', async () => {
-    const api = await buildApi({})
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }),
-    )
-
-    expect(response.status).toBe(404)
-    expect(response.body).toBeNull()
-  })
-
-  it('uses the resolved compression level for ZIP entries', async () => {
-    const root = artifact('session-root', undefined, 'compressible\n'.repeat(32 * 1024))
-    const storedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 0 })
-    const compressedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 9 })
-    const stored = await storedApi.downloads.sessionLog(
-      { sessionId: sid('session-root'), includeDescendants: false },
-      new AbortController().signal,
-    )
-    const compressed = await compressedApi.downloads.sessionLog(
-      { sessionId: sid('session-root'), includeDescendants: false },
-      new AbortController().signal,
-    )
-    const storedBytes = await responseBytes(stored)
-    const compressedBytes = await responseBytes(compressed)
-    expect(compressedBytes.byteLength).toBeLessThan(storedBytes.byteLength)
-    expect(strFromU8(unzipSync(compressedBytes)['session.jsonl'] as Uint8Array)).toBe(root.content)
-  })
-
-  it('includes descendant artifacts under subagents/<id>/ when requested', async () => {
-    const api = await buildApi({
-      'session-root': artifact('session-root'),
-      'child-a': artifact('child-a', sid('session-root')),
-      'grandchild-a': artifact('grandchild-a', sid('child-a')),
-    }, [
-      node('child-a', node('grandchild-a')),
-    ])
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
-    )
-    expect(response.status).toBe(200)
-    const files = unzipSync(await responseBytes(response))
-    expect(Object.keys(files).sort()).toEqual([
-      'session.jsonl',
-      'subagents/child-a/session.jsonl',
-      'subagents/grandchild-a/session.jsonl',
-    ])
-    expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array))
-      .toBe(artifact('child-a').content)
-  })
-
-  it('flushes each live root and descendant immediately before reading its artifact', async () => {
-    const stored: Record<string, SessionRawArtifact> = {
-      'session-root': artifact('session-root', undefined, 'stale root'),
-      'child-a': artifact('child-a', sid('session-root'), 'stale child'),
-    }
-    const durable: Record<string, SessionRawArtifact> = {
-      'session-root': artifact('session-root', undefined, 'durable root'),
-      'child-a': artifact('child-a', sid('session-root'), 'durable child'),
-    }
-    const flushed: SessionId[] = []
-    const api = await buildApi(stored, [node('child-a')], {
-      sessions: {
-        get: id => durable[id] === undefined ? undefined : { id },
-        flush: async (session) => {
-          const artifactAfterFlush = durable[session.id]
-          if (artifactAfterFlush === undefined) throw new Error('unexpected session')
-          flushed.push(session.id)
-          stored[session.id] = artifactAfterFlush
-          return true
-        },
-      },
-    })
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
-    )
-    const files = unzipSync(await responseBytes(response))
-    expect(flushed).toEqual([sid('session-root'), sid('child-a')])
-    expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('durable root')
-    expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)).toBe('durable child')
-  })
-
-  it('reads a cold artifact without asking the live-session store to flush', async () => {
-    const flush = vi.fn(async () => true)
-    const root = artifact('session-root')
-    const api = await buildApi({ 'session-root': root }, [], {
-      sessions: {
-        get: () => undefined,
-        flush,
-      },
-    })
-    const response = await api.downloads.sessionLog(
-      { sessionId: sid('session-root'), includeDescendants: false },
-      new AbortController().signal,
-    )
-    const files = unzipSync(await responseBytes(response))
-    expect(flush).not.toHaveBeenCalled()
-    expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
-  })
-
-  it('answers 404 for a missing root session', async () => {
-    const api = await buildApi({})
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root'),
-    )
-    expect(response.status).toBe(404)
-  })
-
-  it('answers 501 when the persistence backend has no per-session raw artifacts', async () => {
-    const api = await buildApi({}, [], { persistence: 'unsupported' })
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root'),
-    )
-    expect(response.status).toBe(501)
-    expect(await response.text()).toContain('does not expose per-session raw artifacts')
-  })
-
-  it('answers 400 when the sessionId query parameter is absent', async () => {
-    const api = await buildApi({ 'session-root': artifact('session-root') })
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?includeDescendants=true'),
-    )
-    expect(response.status).toBe(400)
-  })
-
-  it('answers 400 for an includeDescendants value other than true or false', async () => {
-    const api = await buildApi({ 'session-root': artifact('session-root') })
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'),
-    )
-    expect(response.status).toBe(400)
-  })
-
-  it('answers 500 when the deployment mounts no persistence or session-query service', async () => {
-    const api = await buildApi({}, [], { query: false, persistence: false })
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root'),
-    )
-    expect(response.status).toBe(500)
-    expect(await response.text()).toContain('session-query')
-  })
-
-  it('fails the whole export when a descendant has no stored artifact', async () => {
-    const api = await buildApi({
-      'session-root': artifact('session-root'),
-    }, [node('child-missing')])
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
-    )
-    expect(response.status).toBe(200)
-    // The stream errors before completing, so the body read rejects rather
-    // than returning a truncated-but-valid archive.
-    await expect(response.arrayBuffer()).rejects.toThrow()
-  })
-
-  it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => {
-    // The push loop slices by 2^16 code units and must back off one unit when
-    // the boundary lands inside a surrogate pair; otherwise the pair re-encodes
-    // as U+FFFD and the exported artifact is silently corrupted.
-    const root = { ...artifact('session-root'), content: `${'a'.repeat((1 << 16) - 1)}😀tail` }
-    const api = await buildApi({ 'session-root': root })
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root'),
-    )
-    const files = unzipSync(await responseBytes(response))
-    expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
-  })
-
-  it('splits a long artifact on a plain code-unit boundary without backoff', async () => {
-    // A boundary that lands on a BMP character needs no surrogate backoff; the
-    // round trip must still be byte-identical across the multi-chunk push.
-    const root = { ...artifact('session-root'), content: 'z'.repeat((1 << 16) + 4096) }
-    const api = await buildApi({ 'session-root': root })
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root'),
-    )
-    const files = unzipSync(await responseBytes(response))
-    expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content)
-  })
-
-  it('waits for response pull capacity before reading the next archive entry', async () => {
-    const root = artifact('session-root', undefined, [
-      imageEventLine('after-root'),
-      randomBytes(512 * 1024).toString('base64'),
-    ].join('\n'))
-    let imageReads = 0
-    const api = await buildApi({ 'session-root': root }, [], {
-      attachments: async (ref) => {
-        imageReads += 1
-        return storedImage(String(ref.attachmentId), ref.mediaType)
-      },
-    })
-    vi.useFakeTimers()
-    let response: Response | undefined
-    try {
-      response = await toFetchHandler(api).fetch(
-        new Request('http://host/api/session.export?sessionId=session-root'),
-      )
-      // Exhausting timer turns must not advance a producer whose byte queue is
-      // full; only a consumer pull can release it.
-      await vi.runAllTimersAsync()
-      expect(imageReads).toBe(0)
-    } finally {
-      vi.useRealTimers()
-    }
-    if (response === undefined) throw new Error('missing export response')
-    const files = unzipSync(await responseBytes(response))
-    expect(imageReads).toBe(1)
-    expect(files['media/after-root.png']).toEqual(storedImage('after-root').data)
-  })
-
-  it('exports an empty artifact as an empty zip entry', async () => {
-    const root = { ...artifact('session-root'), content: '' }
-    const api = await buildApi({ 'session-root': root })
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root'),
-    )
-    const files = unzipSync(await responseBytes(response))
-    expect(Object.keys(files)).toEqual(['session.jsonl'])
-    expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('')
-  })
-
-  it('exports a shared lineage node once (seen-set dedup)', async () => {
-    const api = await buildApi({
-      'session-root': artifact('session-root'),
-      'child-a': artifact('child-a', sid('session-root')),
-      'child-b': artifact('child-b', sid('session-root')),
-      shared: artifact('shared', sid('child-a')),
-    }, [
-      node('child-a', node('shared')),
-      node('child-b', node('shared')),
-    ])
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
-    )
-    const files = unzipSync(await responseBytes(response))
-    expect(Object.keys(files).sort()).toEqual([
-      'session.jsonl',
-      'subagents/child-a/session.jsonl',
-      'subagents/child-b/session.jsonl',
-      'subagents/shared/session.jsonl',
-    ])
-  })
-
-  it('answers 500 without leaking the backend error when the root artifact read fails', async () => {
-    const api = await buildApi({}, [], { query: true, persistence: 'throw' })
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root'),
-    )
-    expect(response.status).toBe(500)
-    const body = await response.text()
-    expect(body).toBe('session log export failed to prepare the stored artifact')
-    expect(body).not.toContain('/host/private/')
-  })
-
-  it('answers the private-error-safe 500 when the live root flush fails', async () => {
-    const api = await buildApi({ 'session-root': artifact('session-root') }, [], {
-      sessions: {
-        get: id => ({ id }),
-        flush: async () => { throw new Error('/host/private/flush-state') },
-      },
-    })
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root'),
-    )
-    expect(response.status).toBe(500)
-    const body = await response.text()
-    expect(body).toBe('session log export failed to prepare the stored artifact')
-    expect(body).not.toContain('/host/private/')
-  })
-
-  it('forwards one request signal through root, lineage, and descendant reads', async () => {
-    const reads: Array<{ id: SessionId; signal: AbortSignal | undefined }> = []
-    const traces: AbortSignal[] = []
-    const api = await buildApi({}, [node('child-a')], {
-      readRaw: async (id, signal) => {
-        reads.push({ id, signal })
-        return id === sid('session-root')
-          ? artifact('session-root')
-          : artifact('child-a', sid('session-root'))
-      },
-      traceSession: async (_id, signal) => {
-        if (signal !== undefined) traces.push(signal)
-        return {
-          target: { header: header('session-root'), live: false, persisted: true },
-          ancestors: [],
-          complete: true,
-          root: { header: header('session-root'), live: false, persisted: true },
-          descendants: [node('child-a')],
-        }
-      },
-    })
-    const controller = new AbortController()
-    const response = await api.downloads.sessionLog(
-      { sessionId: sid('session-root'), includeDescendants: true },
-      controller.signal,
-    )
-    await response.arrayBuffer()
-    const producerSignal = traces[0]
-    if (producerSignal === undefined) throw new Error('missing lineage signal')
-    expect(reads[0]).toEqual({ id: sid('session-root'), signal: controller.signal })
-    expect(reads[1]).toEqual({ id: sid('child-a'), signal: producerSignal })
-    const cancellation = new Error('request cancelled after response')
-    controller.abort(cancellation)
-    expect(producerSignal.aborted).toBe(true)
-    expect(producerSignal.reason).toBe(cancellation)
-  })
-
-  it('preserves request cancellation instead of translating it to HTTP 500', async () => {
-    const api = await buildApi({ 'session-root': artifact('session-root') })
-    const controller = new AbortController()
-    const cancellation = new Error('request cancelled')
-    controller.abort(cancellation)
-    await expect(api.downloads.sessionLog(
-      { sessionId: sid('session-root'), includeDescendants: false },
-      controller.signal,
-    )).rejects.toBe(cancellation)
-  })
-
-  it('aborts descendant work and terminates ZIP production when its reader cancels', async () => {
-    let reportDescendantStarted!: (signal: AbortSignal) => void
-    const descendantStarted = new Promise<AbortSignal>((resolve) => {
-      reportDescendantStarted = resolve
-    })
-    const api = await buildApi({}, [node('child-a')], {
-      readRaw: async (id, signal) => {
-        if (id === sid('session-root')) return artifact('session-root')
-        if (signal === undefined) throw new Error('missing descendant signal')
-        reportDescendantStarted(signal)
-        return new Promise((_, reject) => {
-          signal.addEventListener('abort', () => {
-            reject(signal.reason as Error)
-          }, { once: true })
-        })
-      },
-    })
-    const response = await api.downloads.sessionLog(
-      { sessionId: sid('session-root'), includeDescendants: true },
-      new AbortController().signal,
-    )
-    const reader = response.body?.getReader()
-    if (reader === undefined) throw new Error('missing response body')
-    const descendantSignal = await descendantStarted
-    const cancellation = new Error('download consumer left')
-    await reader.cancel(cancellation)
-    expect(descendantSignal.aborted).toBe(true)
-    expect(descendantSignal.reason).toBe(cancellation)
-  })
-
-  it('aborts attachment reads when its reader cancels', async () => {
-    let reportAttachmentStarted!: (signal: AbortSignal) => void
-    const attachmentStarted = new Promise<AbortSignal>((resolve) => {
-      reportAttachmentStarted = resolve
-    })
-    const root = artifact('session-root', undefined, [
-      '{"type":"session","version":0,"id":"session-root","createdAt":1000}',
-      imageEventLine('slow-img'),
-    ].join('\n') + '\n')
-    const api = await buildApi({ 'session-root': root }, [], {
-      attachments: async (_ref, signal) => {
-        if (signal === undefined) throw new Error('missing attachment signal')
-        reportAttachmentStarted(signal)
-        return new Promise((_, reject) => {
-          signal.addEventListener('abort', () => {
-            reject(signal.reason as Error)
-          }, { once: true })
-        })
-      },
-    })
-    const response = await api.downloads.sessionLog(
-      { sessionId: sid('session-root'), includeDescendants: false },
-      new AbortController().signal,
-    )
-    const reader = response.body?.getReader()
-    if (reader === undefined) throw new Error('missing response body')
-    const attachmentSignal = await attachmentStarted
-    const cancellation = new Error('download consumer left during attachment read')
-    await reader.cancel(cancellation)
-    expect(attachmentSignal.aborted).toBe(true)
-    expect(attachmentSignal.reason).toBe(cancellation)
-  })
-
-  it('uses a stable Error reason when its reader cancels without one', async () => {
-    let reportDescendantStarted!: (signal: AbortSignal) => void
-    const descendantStarted = new Promise<AbortSignal>((resolve) => {
-      reportDescendantStarted = resolve
-    })
-    const api = await buildApi({}, [node('child-a')], {
-      readRaw: async (id, signal) => {
-        if (id === sid('session-root')) return artifact('session-root')
-        if (signal === undefined) throw new Error('missing descendant signal')
-        reportDescendantStarted(signal)
-        return new Promise((_, reject) => {
-          signal.addEventListener('abort', () => {
-            reject(signal.reason as Error)
-          }, { once: true })
-        })
-      },
-    })
-    const response = await api.downloads.sessionLog(
-      { sessionId: sid('session-root'), includeDescendants: true },
-      new AbortController().signal,
-    )
-    const reader = response.body?.getReader()
-    if (reader === undefined) throw new Error('missing response body')
-    const descendantSignal = await descendantStarted
-    await reader.cancel()
-    expect(descendantSignal.reason).toEqual(new Error('session log export stream cancelled'))
-  })
-
-  it('normalizes a non-Error descendant failure before erroring the stream', async () => {
-    const api = await buildApi({}, [node('child-a')], {
-      readRaw: async (id) => {
-        if (id === sid('session-root')) return artifact('session-root')
-        throw 'descendant read failed'
-      },
-    })
-    const response = await api.downloads.sessionLog(
-      { sessionId: sid('session-root'), includeDescendants: true },
-      new AbortController().signal,
-    )
-    await expect(response.arrayBuffer()).rejects.toEqual(new Error('descendant read failed'))
-  })
-
-  it('includes media objects referenced by the root log under media/<id>.<ext>', async () => {
-    const root = artifact('session-root', undefined, [
-      '{"type":"session","version":0,"id":"session-root","createdAt":1000}',
-      imageEventLine('img-1'),
-    ].join('\n') + '\n')
-    const api = await buildApi({ 'session-root': root })
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root'),
-    )
-    expect(response.status).toBe(200)
-    const files = unzipSync(await responseBytes(response))
-    expect(Object.keys(files).sort()).toEqual(['media/img-1.png', 'session.jsonl'])
-    expect(files['media/img-1.png']).toEqual(storedImage('img-1').data)
-  })
-
-  it('collects media referenced from nested tool results', async () => {
-    const nested = '{"type":"assistant/message","seq":2,"time":2000,"data":{"content":[{"type":"tool-result","content":[{"type":"image","attachment":{"attachmentId":"nested-1","mediaType":"image/webp","bytes":4,"width":2,"height":2}}]}]}}'
-    const root = artifact('session-root', undefined, [
-      '{"type":"session","version":0,"id":"session-root","createdAt":1000}',
-      nested,
-    ].join('\n') + '\n')
-    const api = await buildApi({ 'session-root': root })
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root'),
-    )
-    const files = unzipSync(await responseBytes(response))
-    expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', 'session.jsonl'])
-  })
-
-  it('scans the wrapped, inserted, and chunk carriers plus non-object content items', async () => {
-    const block = (id: string, mediaType: string) =>
-      `{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}`
-    const wrapped = `{"type":"assistant/message","seq":2,"time":2000,"data":{"message":{"role":"assistant","content":["noise",${block('wrapped-1', 'image/jpeg')}]}}}`
-    const inserted = `{"type":"context/inserted","seq":3,"time":3000,"data":{"inserted":[{"content":[${block('inserted-1', 'image/gif')}]}]}}`
-    const chunk = `{"type":"assistant/chunk","seq":4,"time":4000,"data":{"chunk":{"type":"block-end","block":${block('chunk-1', 'image/png')}}}}`
-    const root = artifact('session-root', undefined, [
-      '{"type":"session","version":0,"id":"session-root","createdAt":1000}',
-      wrapped,
-      inserted,
-      chunk,
-    ].join('\n') + '\n')
-    const api = await buildApi({ 'session-root': root })
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root'),
-    )
-    const files = unzipSync(await responseBytes(response))
-    expect(Object.keys(files).sort()).toEqual([
-      'media/chunk-1.png',
-      'media/inserted-1.gif',
-      'media/wrapped-1.jpg',
-      'session.jsonl',
-    ])
-  })
-
-  it('deduplicates one media object referenced by several included logs', async () => {
-    const line = imageEventLine('shared-img')
-    const root = artifact('session-root', undefined, [
-      '{"type":"session","version":0,"id":"session-root","createdAt":1000}',
-      line,
-    ].join('\n') + '\n')
-    const child = artifact('child-a', sid('session-root'), [
-      '{"type":"session","version":0,"id":"child-a","createdAt":1000}',
-      line,
-    ].join('\n') + '\n')
-    const api = await buildApi({ 'session-root': root, 'child-a': child }, [node('child-a')])
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
-    )
-    const files = unzipSync(await responseBytes(response))
-    expect(files['media/shared-img.png']).toEqual(storedImage('shared-img').data)
-    expect(Object.keys(files).filter(name => name.startsWith('media/'))).toEqual(['media/shared-img.png'])
-  })
-
-  it('includes descendant media only when descendants are requested', async () => {
-    const child = artifact('child-a', sid('session-root'), [
-      '{"type":"session","version":0,"id":"child-a","createdAt":1000}',
-      imageEventLine('child-img'),
-    ].join('\n') + '\n')
-    const api = await buildApi({ 'session-root': artifact('session-root'), 'child-a': child }, [node('child-a')])
-    const without = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root'),
-    )
-    expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual(['session.jsonl'])
-    const withDescendants = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
-    )
-    expect(Object.keys(unzipSync(await responseBytes(withDescendants))).sort()).toEqual([
-      'media/child-img.png',
-      'session.jsonl',
-      'subagents/child-a/session.jsonl',
-    ])
-  })
-
-  it('fails the whole export when a referenced image cannot be read', async () => {
-    const root = artifact('session-root', undefined, [
-      '{"type":"session","version":0,"id":"session-root","createdAt":1000}',
-      imageEventLine('gone-img'),
-    ].join('\n') + '\n')
-    const api = await buildApi({ 'session-root': root }, [], {
-      attachments: async () => { throw new Error('attachment bytes missing') },
-    })
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root'),
-    )
-    expect(response.status).toBe(200)
-    await expect(response.arrayBuffer()).rejects.toThrow('attachment bytes missing')
-  })
-
-  it('answers 500 when the deployment mounts no attachments service', async () => {
-    const api = await buildApi({ 'session-root': artifact('session-root') }, [], { attachments: false })
-    const response = await toFetchHandler(api).fetch(
-      new Request('http://host/api/session.export?sessionId=session-root'),
-    )
-    expect(response.status).toBe(500)
-    expect(await response.text()).toContain('attachments')
-  })
-})

+ 0 - 54
packages/host/apiproxy/tsconfig.json

@@ -1,54 +0,0 @@
-{
-  "extends": "../../../tsconfig.base.json",
-  "compilerOptions": {
-    "rootDir": "src",
-    "outDir": "lib/types"
-  },
-  "include": [
-    "src"
-  ],
-  "references": [
-    {
-      "path": "../../credentials/credentials"
-    },
-    {
-      "path": "../../../vendor/cordis"
-    },
-    {
-      "path": "../../../vendor/schemastery"
-    },
-    {
-      "path": "../../api/session-controller/tsconfig.host.json"
-    },
-    {
-      "path": "../../util/brand"
-    },
-    {
-      "path": "../../attachment/attachment"
-    },
-    {
-      "path": "../../core/agent"
-    },
-    {
-      "path": "../../core/agent-default-model"
-    },
-    {
-      "path": "../../core/session"
-    },
-    {
-      "path": "../../session/session-persistence"
-    },
-    {
-      "path": "../../session-query/session-query"
-    },
-    {
-      "path": "../../runtime-diagnostics/invariants"
-    },
-    {
-      "path": "../../util/native-command"
-    },
-    {
-      "path": "../../util/crypto"
-    }
-  ]
-}

+ 0 - 3
packages/typert/generator/tests/cordis-catalog.spec.ts

@@ -90,9 +90,6 @@ describe('Typert-backed Cordis catalog', () => {
     // An interface-typed key is described by its Service Definition: that is where
     // the contract and, by repository convention, the member JSDoc live.
     expect(byKey.get('lsp')?.type).toBe('LspService')
-    // The Service Definition may sit anywhere in the package, including a nested
-    // contract directory (`src/api/`), while the Context merge stays in `src`.
-    expect(byKey.get('apiProxy')?.type).toBe('ApiProxy')
     // Two packages describe `ctx.typert` — a merge-extensible interface in
     // type-meta and the implementing class in registry. The class wins: it is the
     // object a caller meets and it carries the documentation.

+ 15 - 73
pnpm-lock.yaml

@@ -376,9 +376,6 @@ importers:
       '@deepseek-ai/dsh-fs-sandbox':
         specifier: workspace:^
         version: link:../../packages/fs/fs-sandbox
-      '@deepseek-ai/dsh-host-apiproxy':
-        specifier: workspace:^
-        version: link:../../packages/host/apiproxy
       '@deepseek-ai/dsh-host-frontend-static':
         specifier: workspace:^
         version: link:../../packages/host/frontend-static
@@ -1575,9 +1572,6 @@ importers:
       '@deepseek-ai/dsh-file-reference-local':
         specifier: workspace:^
         version: link:../../context/file-reference-local
-      '@deepseek-ai/dsh-host-apiproxy':
-        specifier: workspace:^
-        version: link:../../host/apiproxy
       '@deepseek-ai/dsh-host-directory-picker-auto':
         specifier: workspace:^
         version: link:../../host/directory-picker-auto
@@ -1654,6 +1648,9 @@ importers:
       '@deepseek-ai/schemastery':
         specifier: link:../../../vendor/schemastery
         version: link:../../../vendor/schemastery
+      zod:
+        specifier: ^4.4.3
+        version: 4.4.3
     devDependencies:
       '@deepseek-ai/cordis':
         specifier: workspace:^
@@ -1661,15 +1658,15 @@ importers:
       '@deepseek-ai/dsh-attachment':
         specifier: workspace:^
         version: link:../../attachment/attachment
+      '@deepseek-ai/dsh-brand':
+        specifier: workspace:^
+        version: link:../../util/brand
       '@deepseek-ai/dsh-commands':
         specifier: workspace:^
         version: link:../../interaction/commands
       '@deepseek-ai/dsh-credentials':
         specifier: workspace:^
         version: link:../../credentials/credentials
-      '@deepseek-ai/dsh-host-apiproxy':
-        specifier: workspace:^
-        version: link:../../host/apiproxy
       '@deepseek-ai/dsh-host-directory-picker':
         specifier: workspace:^
         version: link:../../host/directory-picker
@@ -2274,12 +2271,18 @@ importers:
       '@deepseek-ai/cordis':
         specifier: workspace:^
         version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-api-remotes':
+        specifier: workspace:^
+        version: link:../../api/remotes
       '@deepseek-ai/dsh-client-connection':
         specifier: workspace:^
         version: link:../connection
       '@deepseek-ai/dsh-client-locale':
         specifier: workspace:^
         version: link:../locale
+      '@deepseek-ai/dsh-client-store':
+        specifier: workspace:^
+        version: link:../store
       '@deepseek-ai/dsh-client-test-runtime':
         specifier: workspace:^
         version: link:../../test-support/client-runtime
@@ -4974,12 +4977,12 @@ importers:
       '@deepseek-ai/dsh-bash-sandbox':
         specifier: workspace:^
         version: link:../../shell/bash-sandbox
+      '@deepseek-ai/dsh-client-connection':
+        specifier: workspace:^
+        version: link:../../client/connection
       '@deepseek-ai/dsh-client-modules':
         specifier: workspace:^
         version: link:../../client/modules
-      '@deepseek-ai/dsh-host-apiproxy':
-        specifier: workspace:^
-        version: link:../../host/apiproxy
       '@deepseek-ai/dsh-host-webserver':
         specifier: workspace:^
         version: link:../../host/webserver
@@ -5799,67 +5802,6 @@ importers:
         specifier: workspace:^
         version: link:../../core/tools
 
-  packages/host/apiproxy:
-    dependencies:
-      '@deepseek-ai/dsh-agent':
-        specifier: workspace:^
-        version: link:../../core/agent
-      '@deepseek-ai/dsh-agent-default-model':
-        specifier: workspace:^
-        version: link:../../core/agent-default-model
-      '@deepseek-ai/dsh-api-session-controller':
-        specifier: workspace:^
-        version: link:../../api/session-controller
-      '@deepseek-ai/dsh-attachment':
-        specifier: workspace:^
-        version: link:../../attachment/attachment
-      '@deepseek-ai/dsh-brand':
-        specifier: workspace:^
-        version: link:../../util/brand
-      '@deepseek-ai/dsh-native-command':
-        specifier: workspace:^
-        version: link:../../util/native-command
-      '@deepseek-ai/dsh-session':
-        specifier: workspace:^
-        version: link:../../core/session
-      '@deepseek-ai/dsh-session-persistence':
-        specifier: workspace:^
-        version: link:../../session/session-persistence
-      '@deepseek-ai/dsh-session-query':
-        specifier: workspace:^
-        version: link:../../session-query/session-query
-      '@deepseek-ai/dsh-util-crypto':
-        specifier: workspace:^
-        version: link:../../util/crypto
-      '@deepseek-ai/schemastery':
-        specifier: link:../../../vendor/schemastery
-        version: link:../../../vendor/schemastery
-      fflate:
-        specifier: ^0.8.2
-        version: 0.8.3
-      zod:
-        specifier: ^4.4.3
-        version: 4.4.3
-    devDependencies:
-      '@deepseek-ai/cordis':
-        specifier: workspace:^
-        version: link:../../../vendor/cordis
-      '@deepseek-ai/dsh-credentials':
-        specifier: workspace:^
-        version: link:../../credentials/credentials
-      '@deepseek-ai/dsh-invariants':
-        specifier: workspace:^
-        version: link:../../runtime-diagnostics/invariants
-      '@deepseek-ai/dsh-settings':
-        specifier: workspace:^
-        version: link:../../settings/settings
-      '@deepseek-ai/dsh-typert-protocol':
-        specifier: workspace:^
-        version: link:../../typert/protocol
-      '@deepseek-ai/dsh-typert-registry':
-        specifier: workspace:^
-        version: link:../../typert/registry
-
   packages/host/directory-picker:
     devDependencies:
       '@deepseek-ai/cordis':

+ 1 - 1
scripts/check-workspace-constraints.ts

@@ -198,7 +198,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str
     ...exportDefault(manifest, './worker') === './lib/worker.js' ? ['lib/worker.js'] : [],
     // UI plugin packages ship their browser bundle beside the node lib
     // (single-artifact ruling: dist/ retired, ./client resolves lib/client.js).
-    // Keyed on the artifact path, not the subpath name: apiproxy's ./client is
+    // Keyed on the artifact path, not the subpath name: a package's ./client is
     // a browser-safe source channel, not a bundle.
     ...exportDefault(manifest, './client') === './lib/client.js' ? ['lib/client.js'] : [],
     // runtime's shell-held loader subpath ships as its own bundle beside the client half.

+ 1 - 1
scripts/doc-typecheck-paths.ts

@@ -1,7 +1,7 @@
 /** Map one workspace source alias target to its declaration-build target. */
 export function builtDeclarationPath(candidate: string): string {
   // Two workspace path forms exist: whole-package entries end in /src, subpath
-  // wildcards (apiproxy's browser-safe /api and /client channels) in /src/*.
+  // wildcards (browser-safe /types and /client channels) in /src/*.
   if (candidate.endsWith('/src')) {
     return `${candidate.slice(0, -'/src'.length)}/lib/types`
   }

+ 3 - 3
scripts/gen-cordis-catalog.ts

@@ -58,7 +58,6 @@ export const SERVICE_PAGE: Record<string, string> = {
   agentDefaultModel: 'core.md',
   agentPresets: 'core.md',
   agents: 'core.md',
-  apiProxy: 'typert.md',
   approval: 'approval.md',
   attachments: 'attachment.md',
   shell: 'shell.md',
@@ -618,6 +617,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   DirectoryListing: 'workspace.md',
   TypertContribution: 'invariants.md',
   TypertRemoteEventSource: 'typert.md',
+  RemoteEventHostInfo: 'typert.md',
   TypertFace: 'invariants.md',
   TypertPackageFilter: 'invariants.md',
   TypertPackageRecord: 'invariants.md',
@@ -661,7 +661,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
   BashEnvVariableInfo: 'service-local metadata type is owned by packages/shell/tool-bash/src/index.ts',
   CompactionAgentContext: 'compaction service input is owned by packages/compaction/compaction/src/index.ts',
   ManualCompactAgentContext: 'manual compaction service input is owned by packages/compaction/compaction/src/index.ts',
-  ClientResponse: 'wire response message is owned by packages/host/apiproxy/src/api/rpc.ts',
+  ClientResponse: 'wire response message is owned by packages/client/connection/src/rpc.ts',
   ApprovalRequestId: 'dynamic Plugin approval identity is owned by packages/extensions/cordis-host-runner/src/types.ts',
   CordisErrorDetails: 'Cordis runtime error payload is owned by packages/extensions/cordis-host-runner/src/types.ts',
   CordisInspectPlatform: 'Cordis inspect platform identity is owned by packages/extensions/cordis-host-runner/src/types.ts',
@@ -714,7 +714,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
   PermissionSelect: 'permissions projection payload is owned by packages/interaction/permission-presets/src/types.ts',
   PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
   RequestRunId: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
-  RpcReceipt: 'carrier-layer receipt is owned by packages/host/apiproxy/src/api/rpc.ts',
+  RpcReceipt: 'carrier-layer receipt is owned by packages/client/connection/src/rpc.ts',
   Sandbox: 'external E2B SDK handle is owned by packages/e2b/e2b/README.md',
   SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
   SubagentRunEndInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts',

+ 9 - 17
scripts/gen-doc-graphs.ts

@@ -104,7 +104,7 @@ const SERVICE_ROLES: ServiceRole[] = [
     title: 'Durable binary attachment storage',
     mode: 'seam',
     implementations: ['attachment-local'],
-    consumers: ['api-session-controller', 'host-apiproxy', 'tool-fs', 'llm-pi-ai', 'llm-deepseek'],
+    consumers: ['api-session-controller', 'tool-fs', 'llm-pi-ai', 'llm-deepseek'],
     note: 'The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content.',
   },
   {
@@ -236,8 +236,8 @@ const SERVICE_ROLES: ServiceRole[] = [
     title: 'User-settings seam',
     mode: 'seam',
     implementations: ['settings-file'],
-    consumers: ['llm-deepseek', 'llm-pi-ai', 'host-apiproxy'],
-    note: '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.',
+    consumers: ['api-settings-controller', 'llm-deepseek', 'llm-pi-ai'],
+    note: '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 settings controller serves redacted layered descriptors and writes the user layer.',
   },
   {
     key: 'subagentModelSelection',
@@ -253,8 +253,8 @@ const SERVICE_ROLES: ServiceRole[] = [
     title: 'Credential seam',
     mode: 'seam',
     implementations: ['credentials-local'],
-    consumers: ['llm-deepseek', 'llm-pi-ai', 'host-apiproxy'],
-    note: '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.',
+    consumers: ['api-settings-controller', 'llm-deepseek', 'llm-pi-ai'],
+    note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage.',
   },
   {
     key: 'authorization',
@@ -389,15 +389,15 @@ const SERVICE_ROLES: ServiceRole[] = [
     pkg: 'session-projection',
     title: 'Session projection units',
     mode: 'core',
-    consumers: ['tool-todo', 'session-title', 'host-apiproxy'],
-    note: 'Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values.',
+    consumers: ['api-session-controller', 'tool-todo', 'session-title'],
+    note: 'Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values.',
   },
   {
     key: 'sessionProjectionCache',
     pkg: 'session-projection-cache',
     title: 'Persisted projection cache',
     mode: 'core',
-    consumers: ['host-apiproxy'],
+    consumers: ['api-session-controller', 'session-query', 'session-reference', 'subagent'],
     note: 'Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs.',
   },
   {
@@ -422,7 +422,7 @@ const SERVICE_ROLES: ServiceRole[] = [
     pkg: 'agent-default-model',
     title: 'Default Agent model selection',
     mode: 'core',
-    consumers: ['headless', 'host-apiproxy'],
+    consumers: ['api-session-controller', 'headless'],
     note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner.',
   },
   {
@@ -648,14 +648,6 @@ const SERVICE_ROLES: ServiceRole[] = [
     consumers: ['tool-lsp'],
     note: 'Provider registration and selection plus normalized query execution over exactly four operations; the seam offers no protocol escape hatch, so a backend translates into the normalized request and result.',
   },
-  {
-    key: 'apiProxy',
-    pkg: 'host-apiproxy',
-    title: 'Host API dispatch',
-    mode: 'core',
-    consumers: ['client-connection'],
-    note: 'The transport-agnostic host gateway face: it dispatches browser API calls, and each open host stream subscribes to the events it forwards rather than being pushed to through a broadcast verb.',
-  },
   {
     key: 'dynamicCordisRunner',
     pkg: 'cordis-host-runner',

+ 0 - 1
scripts/verify-package-readme-model-experience.ts

@@ -114,7 +114,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/e2b/fs-e2b': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
   'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
   'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
-  'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register nothing model-facing.' },
   'packages/host/directory-picker': { kind: 'none', reason: 'The GUI-host picking seam registers nothing model-facing.' },
   'packages/host/directory-picker-auto': { kind: 'none', reason: 'The GUI-host picking chooser only mounts a backend row; it registers nothing model-facing.' },
   'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers nothing model-facing.' },

+ 6 - 3
tsconfig.base.json

@@ -136,7 +136,12 @@
       "@deepseek-ai/dsh-headless/startup": ["./packages/bundle/headless/src/startup.ts"],
       "@deepseek-ai/dsh-web-app/startup": ["./packages/bundle/web-app/src/startup.ts"],
       "@deepseek-ai/dsh-client-*/client": ["./packages/client/*/src/client"],
-      "@deepseek-ai/dsh-host-apiproxy": ["./packages/host/apiproxy/src"],
+      // One wildcard maps every @deepseek-ai/dsh-<name> to its source. Package
+      // dir names are unique across groups, so first-on-disk-wins resolution is
+      // unambiguous; adding a package under an existing group needs no edit
+      // here. The aggregates' project references (tsconfig.host.json /
+      // tsconfig.client.json) stay explicit — TS project references have no
+      // wildcard form.
       "@deepseek-ai/dsh-host-directory-picker": ["./packages/host/directory-picker/src"],
       "@deepseek-ai/dsh-host-directory-picker/*": ["./packages/host/directory-picker/src/*"],
       "@deepseek-ai/dsh-host-directory-picker-browse": ["./packages/host/directory-picker-browse/src"],
@@ -145,8 +150,6 @@
       "@deepseek-ai/dsh-host-directory-picker-native/*": ["./packages/host/directory-picker-native/src/*"],
       "@deepseek-ai/dsh-host-directory-picker-auto": ["./packages/host/directory-picker-auto/src"],
       "@deepseek-ai/dsh-host-directory-picker-auto/*": ["./packages/host/directory-picker-auto/src/*"],
-      "@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"],
-      "@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"],
       "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"],
       "@deepseek-ai/dsh-host-frontend-static": ["./packages/host/frontend-static/src"],
       "@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"],

+ 1 - 1
tsconfig.client.json

@@ -2,7 +2,7 @@
   // Client-side typecheck aggregate: packages/client tests (.ts and .tsx).
   // Split from the host aggregate because both sides merge cordis Context
   // under the same keys (sessions, loader) with different services; shared
-  // leaves (session/llm/tools/apiproxy/...) build once and are referenced by
+  // leaves (session/llm/tools/...) build once and are referenced by
   // both programs through each client package's own references.
   "extends": "./tsconfig.base.client.json",
   "compilerOptions": {

+ 0 - 1
tsconfig.host.json

@@ -313,7 +313,6 @@
     { "path": "./packages/hooks/hooks-claude-code" },
     { "path": "./packages/hooks/hooks-codex" },
     { "path": "./packages/mcp/mcp-client" },
-    { "path": "./packages/host/apiproxy" },
     { "path": "./packages/host/directory-picker" },
     { "path": "./packages/host/directory-picker-auto" },
     { "path": "./packages/host/directory-picker-browse" },

+ 0 - 3
vitest.config.ts

@@ -330,9 +330,6 @@ export default defineConfig({
         // by decision: its correctness signal is its uninstrumented suite and
         // the packer's end-to-end image spec.
         'packages/experimental/webworker-runtime/src/**/*.ts',
-        'packages/host/apiproxy/src/index.ts',
-        'packages/host/apiproxy/src/invariant.ts',
-        'packages/host/apiproxy/src/api-proxy.ts',
         // Projection/command round: executor lifecycle branches and the
         // registry's drive tails need the same maturing lanes. TODO(gui):
         // cover and remove with the client test lane above.