From 4f00a8b82af9145d9ee19d5201972ef92fb311da Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:51:58 +0800 Subject: [PATCH] refactor(api): remove ApiProxy package --- apps/cli/package.json | 1 - apps/cli/tsconfig.json | 3 - packages/bundle/web-app/cordis.patch.yml | 5 - packages/bundle/web-app/package.json | 1 - .../extensions/tool-cordis/src/api-catalog.ts | 60 +- packages/host/apiproxy/README.i18n.yaml | 6 - packages/host/apiproxy/README.md | 135 ---- packages/host/apiproxy/README.zh.md | 135 ---- packages/host/apiproxy/package.json | 74 -- packages/host/apiproxy/src/api-proxy.ts | 137 ---- .../host/apiproxy/src/api/downloads.schema.ts | 25 - packages/host/apiproxy/src/api/downloads.ts | 24 - packages/host/apiproxy/src/api/host.schema.ts | 21 - packages/host/apiproxy/src/api/host.ts | 30 - packages/host/apiproxy/src/api/ids.schema.ts | 7 - packages/host/apiproxy/src/api/index.ts | 44 -- packages/host/apiproxy/src/api/rpc-map.ts | 23 - packages/host/apiproxy/src/api/rpc.schema.ts | 83 -- packages/host/apiproxy/src/api/rpc.ts | 104 --- packages/host/apiproxy/src/fetch/client.ts | 204 ----- packages/host/apiproxy/src/fetch/handler.ts | 158 ---- packages/host/apiproxy/src/index.ts | 91 --- packages/host/apiproxy/src/invariant.ts | 32 - packages/host/apiproxy/src/session-export.ts | 457 ----------- .../apiproxy/tests/api-proxy-config.spec.ts | 236 ------ .../apiproxy/tests/api-proxy-host.spec.ts | 51 -- .../apiproxy/tests/client-handler.spec.ts | 228 ------ .../host/apiproxy/tests/fetch-carrier.spec.ts | 207 ----- .../host/apiproxy/tests/rpc-schemas.spec.ts | 96 --- .../apiproxy/tests/session-export.spec.ts | 708 ------------------ packages/host/apiproxy/tsconfig.json | 54 -- .../generator/tests/cordis-catalog.spec.ts | 3 - pnpm-lock.yaml | 88 +-- scripts/check-workspace-constraints.ts | 2 +- scripts/doc-typecheck-paths.ts | 2 +- scripts/gen-cordis-catalog.ts | 6 +- scripts/gen-doc-graphs.ts | 26 +- .../verify-package-readme-model-experience.ts | 1 - tsconfig.base.json | 9 +- tsconfig.client.json | 2 +- tsconfig.host.json | 1 - vitest.config.ts | 3 - 42 files changed, 54 insertions(+), 3529 deletions(-) delete mode 100644 packages/host/apiproxy/README.i18n.yaml delete mode 100644 packages/host/apiproxy/README.md delete mode 100644 packages/host/apiproxy/README.zh.md delete mode 100644 packages/host/apiproxy/package.json delete mode 100644 packages/host/apiproxy/src/api-proxy.ts delete mode 100644 packages/host/apiproxy/src/api/downloads.schema.ts delete mode 100644 packages/host/apiproxy/src/api/downloads.ts delete mode 100644 packages/host/apiproxy/src/api/host.schema.ts delete mode 100644 packages/host/apiproxy/src/api/host.ts delete mode 100644 packages/host/apiproxy/src/api/ids.schema.ts delete mode 100644 packages/host/apiproxy/src/api/index.ts delete mode 100644 packages/host/apiproxy/src/api/rpc-map.ts delete mode 100644 packages/host/apiproxy/src/api/rpc.schema.ts delete mode 100644 packages/host/apiproxy/src/api/rpc.ts delete mode 100644 packages/host/apiproxy/src/fetch/client.ts delete mode 100644 packages/host/apiproxy/src/fetch/handler.ts delete mode 100644 packages/host/apiproxy/src/index.ts delete mode 100644 packages/host/apiproxy/src/invariant.ts delete mode 100644 packages/host/apiproxy/src/session-export.ts delete mode 100644 packages/host/apiproxy/tests/api-proxy-config.spec.ts delete mode 100644 packages/host/apiproxy/tests/api-proxy-host.spec.ts delete mode 100644 packages/host/apiproxy/tests/client-handler.spec.ts delete mode 100644 packages/host/apiproxy/tests/fetch-carrier.spec.ts delete mode 100644 packages/host/apiproxy/tests/rpc-schemas.spec.ts delete mode 100644 packages/host/apiproxy/tests/session-export.spec.ts delete mode 100644 packages/host/apiproxy/tsconfig.json diff --git a/apps/cli/package.json b/apps/cli/package.json index 5076da6592..a07102d212 100644 --- a/apps/cli/package.json +++ b/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:^", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 135838240c..7b0a769721 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -32,9 +32,6 @@ { "path": "../../packages/bundle/web-app" }, - { - "path": "../../packages/host/apiproxy" - }, { "path": "../../packages/host/webserver" }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index d69e66753a..f63af2885a 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/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' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 0f216073de..85a61a1d5b 100644 --- a/packages/bundle/web-app/package.json +++ b/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:^", diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 37d29ce704..4012db1069 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/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', 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, expectedRevision: number | undefined, ): Promise', 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', + signature: 'registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo, ): () => Promise', 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 {\n readonly valueSchema: ZodType;\n readonly __key?: K;\n}', }, - { - name: 'DownloadsApi', - declaration: 'export interface DownloadsApi {\n sessionLog(request: {\n sessionId: SessionId;\n includeDescendants?: boolean;\n }, signal: AbortSignal): Promise;\n}', - }, { name: 'DshEnvironment', declaration: 'export type DshEnvironment = Readonly>;', @@ -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 = {\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;\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(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml deleted file mode 100644 index a339fcb16a..0000000000 --- a/packages/host/apiproxy/README.i18n.yaml +++ /dev/null @@ -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 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md deleted file mode 100644 index 131ea9c510..0000000000 --- a/packages/host/apiproxy/README.md +++ /dev/null @@ -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) - ------ - - -## 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//`, and each referenced image under `media/.`. `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. - ------ - - -## Understand the implementation - -
-Implementation internals — click to expand - -### 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/` 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. - -
- ------ - - -## 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. - ------ - - -## 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 - - - - -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. - - -### Dev Note - -
-Working context for maintainers — click to expand - -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. - -
diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md deleted file mode 100644 index 4578e89acc..0000000000 --- a/packages/host/apiproxy/README.zh.md +++ /dev/null @@ -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) - ------ - - -## 使用本包 - -当 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//` 下,每张被引用的图片位于 `media/.` 下。`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 的穷尽式真源。 - ------ - - -## 理解实现 - -
-实现细节——点击展开 - -### 设计理念 - -本包建立在一个分离之上:API 约定与通道无关,物理传输只是围绕它的载体。协议消息构成一个二元可辨识联合——`ClientRequest`(POST `/api/` 的请求体)与 `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 分发与流;业务包持有各自的方法和结果类型。 - -
- ------ - - -## 进一步探索 - -当包级约定不够用时阅读以下内容。它们从分层决策进入浏览器侧消费架构与相邻子系统。 - -- [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)——每个受支持配置字段及其源声明。 - ------ - - -## 模型体验 - -无。该协议约定与 fetch 载体只搬运已组装好的消息,不注册任何面向模型的内容。 - -#### KV Cache 影响 - -无;该包既不组装也不发送提供方请求。 - -## 已知限制与延期工作 - - - - -这些限制说明网关在何处不合适;它们是当前包约束,不是任务积压。 - -- **没有协议版本字段**——客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 - - -### 开发备注 - -
-维护者的工作上下文——点击展开 - -本开发备注是维护者的工作上下文:开放方向。它明确不具权威性——已交付行为与限制见上文各节。协议版本字段等待独立发布的客户端;多用户载体必须把提供方搜索诊断替换为可安全公开的文本;按连接的自适应目录选择(本地浏览器用 native、远程浏览器用 browse)仍是宿主表面的一个未定方向。 - -
diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json deleted file mode 100644 index b1eecd9a6b..0000000000 --- a/packages/host/apiproxy/package.json +++ /dev/null @@ -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:^" - } -} diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts deleted file mode 100644 index 6e502172f7..0000000000 --- a/packages/host/apiproxy/src/api-proxy.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Host-side ApiProxy implementation. Signature discipline: unary takes the - * narrow RpcRequest

and echoes request.rpcId on the RpcResponse. - */ - -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(request: RpcRequest, value: T): RpcResponse { - 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)}"`, - }, - }, - ) - }, - }, - } -} diff --git a/packages/host/apiproxy/src/api/downloads.schema.ts b/packages/host/apiproxy/src/api/downloads.schema.ts deleted file mode 100644 index cd95cfbd45..0000000000 --- a/packages/host/apiproxy/src/api/downloads.schema.ts +++ /dev/null @@ -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[0]> diff --git a/packages/host/apiproxy/src/api/downloads.ts b/packages/host/apiproxy/src/api/downloads.ts deleted file mode 100644 index fc8728e497..0000000000 --- a/packages/host/apiproxy/src/api/downloads.ts +++ /dev/null @@ -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 -} diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts deleted file mode 100644 index 5429b8cab0..0000000000 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ /dev/null @@ -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>> - -/** 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>> diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts deleted file mode 100644 index b256afbc00..0000000000 --- a/packages/host/apiproxy/src/api/host.ts +++ /dev/null @@ -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> - -} diff --git a/packages/host/apiproxy/src/api/ids.schema.ts b/packages/host/apiproxy/src/api/ids.schema.ts deleted file mode 100644 index a79d7dc848..0000000000 --- a/packages/host/apiproxy/src/api/ids.schema.ts +++ /dev/null @@ -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 diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts deleted file mode 100644 index 7f6e5e8018..0000000000 --- a/packages/host/apiproxy/src/api/index.ts +++ /dev/null @@ -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' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts deleted file mode 100644 index e3e67a9c16..0000000000 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ /dev/null @@ -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 = Parameters[0]['payload'] - -/** Business return value of method K (reaches through the RpcResponse narrow form to infer the ok value of result). */ -export type ResponseValue = - Awaited> extends RpcResponse ? T : never diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts deleted file mode 100644 index c8a322fced..0000000000 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ /dev/null @@ -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` is unusable across the board; anchoring is always - * written `satisfies z.ZodType>` — 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 extends readonly (infer E)[] ? Wire[] - : T extends object ? { [K in keyof T]: Wire | 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 - -/** Error body: discriminated by code, per-branch details aligned to RpcErrorDetailsMap; details is required. */ -export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', [ - z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom()) }) }), - 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 - -/** - * Business success/failure result schema (generic, reusable). - * @param value - Schema for the business value. - * @returns Schema for RpcResult. - */ -export function rpcResultSchema(value: z.ZodType): z.ZodUnion { - 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 - -/** 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 - -/** Wire full-form union (discriminated by type). */ -export const rpcMessageSchema = z.discriminatedUnion('type', [ - clientRequestSchema as unknown as z.ZodObject, - serverResponseSchema as unknown as z.ZodObject, -]) diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts deleted file mode 100644 index d3d8643301..0000000000 --- a/packages/host/apiproxy/src/api/rpc.ts +++ /dev/null @@ -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 = { 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(error: unknown): RpcResult { - 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

{ - rpcId: RpcId - payload: P -} - -/** Signature-layer narrow form, response side: rpcId always echoes the matching request. */ -export interface RpcResponse { - rpcId: RpcId - result: RpcResult -} - -// ---- Wire full forms ---- - -/** Call initiated by the client (wire carrier: POST /api/ 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 -} - -/** Authoritative wire full-form union; narrow via `switch (message.type)`. */ -export type RpcMessage = ClientRequest | ServerResponse diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts deleted file mode 100644 index 4c49240d24..0000000000 --- a/packages/host/apiproxy/src/fetch/client.ts +++ /dev/null @@ -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>> - } -} - -/** - * 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>> } = { - '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 - - /** - * 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 { - 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( - method: K, - payload: RequestPayload, - signal?: AbortSignal, - ): Promise>> { - 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 - 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 { - 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') -} diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts deleted file mode 100644 index 82142ef923..0000000000 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ /dev/null @@ -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>> - invoke(api: ApiProxy, request: RpcRequest>, signal: AbortSignal): Promise>> - } -} - -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): 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( - api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal, -): Promise { - 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 { - 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) - }, - } -} diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts deleted file mode 100644 index 474ad6beef..0000000000 --- a/packages/host/apiproxy/src/index.ts +++ /dev/null @@ -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 = z.object({ - nativeOpen: z.boolean(), - sessionExportCompressionLevel: z.number().step(1).min(0).max(9) - .default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z, - }) - - 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 diff --git a/packages/host/apiproxy/src/invariant.ts b/packages/host/apiproxy/src/invariant.ts deleted file mode 100644 index 9e9489aaff..0000000000 --- a/packages/host/apiproxy/src/invariant.ts +++ /dev/null @@ -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 */ diff --git a/packages/host/apiproxy/src/session-export.ts b/packages/host/apiproxy/src/session-export.ts deleted file mode 100644 index 72b40a016d..0000000000 --- a/packages/host/apiproxy/src/session-export.ts +++ /dev/null @@ -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//`; each image referenced - * by any included log under `media/.` (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, - id: SessionId, - signal?: AbortSignal, -): Promise { - 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 = { - '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): 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): 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 { - const refs = new Map() - 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 { - const media = new Map() - 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]) - const collect = async function* ( - nodes: readonly SessionLineageNode[], - ): AsyncGenerator { - 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, - signal: AbortSignal, - ): Promise { - signal.throwIfAborted() - if (controller.desiredSize === null || controller.desiredSize > 0) return - await new Promise((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, - capacity: ResponseCapacityGate, - signal: AbortSignal, -): Promise { - 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, - capacity: ResponseCapacityGate, - signal: AbortSignal, -): Promise { - 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 { - 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({ - 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, - }) -} diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts deleted file mode 100644 index f6ed7916b2..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ /dev/null @@ -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 - - constructor(ctx: ConstructorParameters[0], options?: { - doc?: Record - 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 { - return Promise.resolve(this.preparedPath ?? this.documentPath) - } - - protected load(): Promise> { - return Promise.resolve(structuredClone(this.doc)) - } - - protected persist(ns: SettingsNamespace, section: Record): Promise { - 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() - - constructor(ctx: ConstructorParameters[0], options?: { shadowed?: string[] }) { - super(ctx) - this.shadowed = new Set(options?.shadowed ?? []) - } - - private readonly shadowed: Set - - resolve(ref: CredentialRef): Promise { - 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 { - 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 { - 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 { - 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 { - return Promise.resolve(undefined) - } - - describeRecord(): Promise { - return Promise.resolve({ configured: false, writable: true }) - } - - listRecords(): Promise { - return Promise.resolve([]) - } - - modifyRecord( - _key: CredentialKey, - mutate: (current: CredentialRecord | undefined) => Promise, - ): Promise { - return mutate(undefined) - } - - deleteRecord(): Promise { - 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 - readOnly?: boolean - documentPath?: string - preparedPath?: string - } - credentials?: false | { shadowed?: string[] } -}): Promise { - 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, -): Promise> { - const updates: Array = [] - 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')]) - }) - - - - - - -}) diff --git a/packages/host/apiproxy/tests/api-proxy-host.spec.ts b/packages/host/apiproxy/tests/api-proxy-host.spec.ts deleted file mode 100644 index 180be46507..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-host.spec.ts +++ /dev/null @@ -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

(payload: P): RpcRequest

{ - return { rpcId: RpcId(`host-${String(nextRpc++)}`), payload } -} - -function expectOk(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()) - }) - -}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts deleted file mode 100644 index e587346061..0000000000 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ /dev/null @@ -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(request: RpcRequest, value: T): Promise> { - 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 { - 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((_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(() => {}) }, 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(() => {}) }, 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 { - 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([]) - }) -}) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts deleted file mode 100644 index 844c4d5039..0000000000 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ /dev/null @@ -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 { - 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 { - 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 - } - }) -}) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts deleted file mode 100644 index 33cf4b2eca..0000000000 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ /dev/null @@ -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() - }) -}) diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts deleted file mode 100644 index aa4137e031..0000000000 --- a/packages/host/apiproxy/tests/session-export.spec.ts +++ /dev/null @@ -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, - descendants: SessionLineageNode[] = [], - services: { - query?: boolean - persistence?: boolean | 'throw' | 'unsupported' - attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise>) - sessions?: { - get(id: SessionId): { readonly id: SessionId } | undefined - flush(session: { readonly id: SessionId }): Promise - } - readRaw?: (id: SessionId, signal?: AbortSignal) => Promise - 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 { - 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// 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 = { - 'session-root': artifact('session-root', undefined, 'stale root'), - 'child-a': artifact('child-a', sid('session-root'), 'stale child'), - } - const durable: Record = { - '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((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((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((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/.', 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') - }) -}) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json deleted file mode 100644 index 048fffcc72..0000000000 --- a/packages/host/apiproxy/tsconfig.json +++ /dev/null @@ -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" - } - ] -} diff --git a/packages/typert/generator/tests/cordis-catalog.spec.ts b/packages/typert/generator/tests/cordis-catalog.spec.ts index ab62291aed..240aeab248 100644 --- a/packages/typert/generator/tests/cordis-catalog.spec.ts +++ b/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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a021582cd9..06d5d75a10 100644 --- a/pnpm-lock.yaml +++ b/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': diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index f464732b40..e76c248aa7 100644 --- a/scripts/check-workspace-constraints.ts +++ b/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. diff --git a/scripts/doc-typecheck-paths.ts b/scripts/doc-typecheck-paths.ts index ec17f1b29c..1a0ae8ecd9 100644 --- a/scripts/doc-typecheck-paths.ts +++ b/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` } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 5c2fcca659..dcca3d0722 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -58,7 +58,6 @@ export const SERVICE_PAGE: Record = { 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> = { 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> = { 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> = { 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', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1119f7be56..65876fd5d0 100644 --- a/scripts/gen-doc-graphs.ts +++ b/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', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 4ea84b36fe..46e80a86c1 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -114,7 +114,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { '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.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 1c06baeac9..661a546bf8 100644 --- a/tsconfig.base.json +++ b/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- 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"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 8b70f8d2c7..115c328537 100644 --- a/tsconfig.client.json +++ b/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": { diff --git a/tsconfig.host.json b/tsconfig.host.json index a707fa8df7..14bfe0822f 100644 --- a/tsconfig.host.json +++ b/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" }, diff --git a/vitest.config.ts b/vitest.config.ts index 2127545939..ecfd00ded4 100644 --- a/vitest.config.ts +++ b/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.