Merge remote-tracking branch 'origin/master' into worktree/drop-create-by-name
# Conflicts: # .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml # .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md # .agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md # apps/cli/reference/README.i18n.yaml # docs/config-catalog.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-approval.spec.ts # packages/host/apiproxy/tests/api-proxy-blank.spec.ts # packages/host/apiproxy/tests/api-proxy-cold.spec.ts # packages/host/apiproxy/tests/api-proxy-commands.spec.ts # packages/host/apiproxy/tests/api-proxy-config.spec.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/api-proxy-projections.spec.ts # packages/host/apiproxy/tests/api-proxy-question.spec.ts # packages/host/apiproxy/tests/api-proxy-rename.spec.ts # packages/host/apiproxy/tests/api-proxy-search.spec.ts # packages/host/apiproxy/tests/api-proxy-subagents.spec.ts # packages/host/apiproxy/tests/api-proxy-view.spec.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/todo/tool-todo/tests/projection.spec.ts # scripts/hero-composer-dom-continuity.mjs
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# 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/client/connection/README.md
|
||||
README.md: 1393e79aacecbbf7b186f19e4c42269595854b0e
|
||||
README.zh.md: 70380ceba1b16b2970e947fb6cd9b2af9085ae51
|
||||
README.md: 85ff46052ba2f032ee6a95b16c396d45e766d3ba
|
||||
README.zh.md: 89cbb19a984d88e09b7af0890f57ecd15d46d3a5
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md); the protocol contract is api-contracts v3 §3.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered TypeRT interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md).
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md);协议契约见 api-contracts v3 §3。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 TypeRT interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何 event stream 前拒绝握手。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏是可达性策略,而不是认证;Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载明确报错:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何 event stream 前拒绝握手。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏是可达性策略,而不是认证;Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
|
||||
## `/api` WebSocket 下行
|
||||
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
import type { IncomingHttpHeaders } from 'node:http'
|
||||
import { isLoopbackHostname } from './loopback-hostname.ts'
|
||||
|
||||
/** The request facts the fence reads (structural subset of IncomingMessage). */
|
||||
/** The request facts the fence reads from either HTTP representation. */
|
||||
interface ApiTrustRequest {
|
||||
headers: IncomingHttpHeaders
|
||||
headers: IncomingHttpHeaders | Headers
|
||||
}
|
||||
|
||||
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
|
||||
function header(headers: IncomingHttpHeaders | Headers, name: string): string | undefined {
|
||||
if (headers instanceof Headers) return headers.get(name) ?? undefined
|
||||
const value = headers[name]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
@@ -88,7 +89,7 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool
|
||||
|
||||
/**
|
||||
* Decide whether one /api request may reach the RPC bridge.
|
||||
* @param request - node HTTP request facts (headers).
|
||||
* @param request - Node HTTP or Fetch request facts (headers).
|
||||
* @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port.
|
||||
* @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin.
|
||||
*/
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Central contract re-export point: every contract import inside
|
||||
// web-runtime goes through this single file.
|
||||
// Types and runtime protocol helpers/bounds come from the apiproxy api/ layer
|
||||
// (zero Node deps, browser-safe); AbstractApiClient is the client seam.
|
||||
// (zero Node deps, browser-safe); AbstractApiClient is the client boundary.
|
||||
// NEVER import the package root: it drags bootHost/cordis into the browser bundle.
|
||||
// The ./api and ./client subpath exports are the browser-safe channels added for this.
|
||||
// The ./api and ./client subpath exports are the browser-safe channels.
|
||||
|
||||
export type {
|
||||
ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
|
||||
@@ -12,7 +12,7 @@ export type {
|
||||
WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
|
||||
GoalsApi, GoalRef,
|
||||
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
|
||||
CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi,
|
||||
@@ -23,9 +23,9 @@ export type {
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
// transportError moved down to the apiproxy api layer (it belongs beside
|
||||
// RpcResult, its subject); re-exported here so connection consumers keep one
|
||||
// contract entry point.
|
||||
// transportError lives in the apiproxy api layer (beside RpcResult, its
|
||||
// subject); re-exported here so connection consumers keep one contract
|
||||
// entry point.
|
||||
export {
|
||||
RpcId,
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts'
|
||||
|
||||
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; web-cordis §B.1 lists
|
||||
* these as the future `ctx.connection` plugin Config). All fields optional; defaults below. */
|
||||
/** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the
|
||||
* future `ctx.connection` plugin's Config). All fields optional; defaults below. */
|
||||
export interface ConnectionConfig {
|
||||
/** First-retry backoff cap in ms (jittered: actual delay is cap/2..cap). */
|
||||
backoffBaseMs?: number
|
||||
@@ -10,9 +10,9 @@ export interface ConnectionConfig {
|
||||
/** Upper bound for the backoff cap in ms. */
|
||||
backoffMaxMs?: number
|
||||
/** Cap on waiting for both streams' onOpen before onConnected, in ms. The strict handshake
|
||||
* (audit C2) waits for mux+host stream establishment plus describe; a carrier that never
|
||||
* waits for mux+host stream establishment plus describe; a carrier that never
|
||||
* fires onOpen (misbehaving proxy) must not wedge the connection forever — on timeout the
|
||||
* generation proceeds as connected and the live-gap repair path (audit S3) covers stragglers. */
|
||||
* generation proceeds as connected and the live-gap repair path covers stragglers. */
|
||||
streamOpenTimeoutMs?: number
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
/** Coarse connection state for the UI (audit C1): 'connected' after each generation's handshake,
|
||||
/** Coarse connection state for the UI: 'connected' after each generation's handshake,
|
||||
* 'reconnecting' the moment the generation fails (covers the whole backoff+retry span). */
|
||||
export type ConnectionState = 'connected' | 'reconnecting'
|
||||
|
||||
@@ -125,7 +125,7 @@ export class ConnectionController {
|
||||
})
|
||||
|
||||
try {
|
||||
// Strict readiness handshake (audit C2): describe proves unary reachability, onOpen
|
||||
// Strict readiness handshake: describe proves unary reachability, onOpen
|
||||
// proves each physical stream is established before any frame —
|
||||
// only then may onConnected fire, so the resync it triggers cannot outrun the
|
||||
// subscribed baseline. The timeout guards against a carrier that never fires onOpen
|
||||
|
||||
@@ -30,15 +30,17 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface'
|
||||
import type {
|
||||
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
|
||||
ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
|
||||
ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
|
||||
ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView,
|
||||
} from './api.ts'
|
||||
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts'
|
||||
import { randomUuid } from './random-uuid.ts'
|
||||
import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
/** The fake carrier mints like a real one (business code never mints). */
|
||||
function rpcRequest<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(crypto.randomUUID()), payload }
|
||||
return { rpcId: RpcId(randomUuid()), payload }
|
||||
}
|
||||
|
||||
function text(t: string): ContentBlock[] {
|
||||
@@ -94,7 +96,7 @@ function sgr(code: number, body: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal output sample for fixture turn 65, authored to carry every feature
|
||||
* Terminal output sample for fixture turn 66, authored to carry every feature
|
||||
* the terminal card draws that turn 60's two prompt rows cannot reach:
|
||||
* basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
|
||||
* `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
|
||||
@@ -139,7 +141,7 @@ const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: stri
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured grep result for the search sample (turn 66): matches grouped by
|
||||
* Structured grep result for the search sample (turn 67): matches grouped by
|
||||
* file, authored inline because the client-side fixture cannot import the tool
|
||||
* that produces the canonical value. `truncated` with a larger `total` than the
|
||||
* retained match count exercises the search card's capped indicator; the file
|
||||
@@ -155,19 +157,19 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'packages/client/ui-conversation/src/client/contract/search-card-model.ts',
|
||||
path: 'packages/client/ui-tool/src/client/tool/models/search-card-model.ts',
|
||||
matches: [
|
||||
{ lineNumber: 24, line: 'export const CHAT_SEARCH_MAX_LINES = 8' },
|
||||
{ lineNumber: 60, line: 'export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {' },
|
||||
{ lineNumber: 45, line: 'export const CHAT_SEARCH_MAX_LINES = 8' },
|
||||
{ lineNumber: 130, line: 'export function searchCardModel(block: ToolCallBlock): SearchCardModel | null {' },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'packages/client/ui-conversation/src/client/toolviews/search-row.tsx',
|
||||
path: 'packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx',
|
||||
matches: [
|
||||
{ lineNumber: 33, line: 'export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {' },
|
||||
{ lineNumber: 35, line: ' const search = searchCardModel(block)' },
|
||||
{ lineNumber: 52, line: ' search={search}' },
|
||||
{ lineNumber: 78, line: " yield ctx.slots.register({ name: 'conversation.chat.toolview', key: 'grep', locale: NS }, SearchRow)" },
|
||||
{ lineNumber: 34, line: 'export function SearchRow({ toolName, block, inspect, t }: SearchRowProps) {' },
|
||||
{ lineNumber: 36, line: ' const search = searchCardModel(block)' },
|
||||
{ lineNumber: 56, line: ' search={search}' },
|
||||
{ lineNumber: 78, line: " yield ctx.slots.register({ name: 'tool.call.toolview', key: 'grep', locale: NS }, SearchRow)" },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -189,15 +191,15 @@ const SEARCH_MATCHES_TEXT = [
|
||||
].join('\n')
|
||||
|
||||
/**
|
||||
* Structured glob result for the search sample (turn 67): a flat path list,
|
||||
* Structured glob result for the search sample (turn 68): a flat path list,
|
||||
* truncated with a larger `total` so the path card shows its capped indicator.
|
||||
*/
|
||||
const SEARCH_PATHS_FIXTURE = [
|
||||
'packages/client/ui-primitives/src/SearchBlock.tsx',
|
||||
'packages/client/ui-primitives/src/SearchBlock.module.css',
|
||||
'packages/client/ui-conversation/src/client/contract/search-card-model.ts',
|
||||
'packages/client/ui-conversation/src/client/toolviews/search-row.tsx',
|
||||
'packages/client/ui-conversation/tests/search-card.spec.tsx',
|
||||
'packages/client/ui-tool/src/client/tool/models/search-card-model.ts',
|
||||
'packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx',
|
||||
'packages/client/ui-tool/tests/search-card.spec.tsx',
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -424,19 +426,19 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
|
||||
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
|
||||
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
|
||||
// Turn 67: a multi-hunk edit — two scattered replacements in one file. Named
|
||||
// Turn 64: a multi-hunk edit — two scattered replacements in one file. Named
|
||||
// `edit` so it lands on the keyed FileMutationRow (the resident diff card the
|
||||
// single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker
|
||||
// the presenter reads to emit the two-hunk sample: the card draws one path
|
||||
// header, the first hunk, a `⋯` gap, then the second (the same-file
|
||||
// second-hunk arm turns 62/63 cannot reach).
|
||||
toolTurn(67, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
|
||||
// Turn 64: one run_code turn with three logged sub-dispatches — the Code
|
||||
toolTurn(64, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
|
||||
// Turn 65: one run_code turn with three logged sub-dispatches — the Code
|
||||
// Mode acceptance surface (parent code row + nested native-identical rows,
|
||||
// including an isError sub-call and a bash sub-call that must hit the same
|
||||
// keyed registration a top-level bash row uses).
|
||||
{
|
||||
const turn = 64
|
||||
const turn = 65
|
||||
const callId = `fx-call-${turn}`
|
||||
const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n'
|
||||
+ 'const demo = await tools.read({ file_path: "notes/demo.txt" })\n'
|
||||
@@ -454,12 +456,12 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
const dispatchPair = (n: number, name: string, dispatchArgs: Record<string, unknown>, resultText: string, isError = false): void => {
|
||||
push({
|
||||
type: 'tool/code-dispatch-start',
|
||||
data: { parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
|
||||
data: { rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name, arguments: dispatchArgs },
|
||||
})
|
||||
push({
|
||||
type: 'tool/code-dispatch',
|
||||
data: {
|
||||
parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
|
||||
rootCallId: callId, parentCallId: callId, subCallId: `${callId}:code:${n}`, name,
|
||||
arguments: dispatchArgs, isError, content: [{ type: 'text', text: resultText }],
|
||||
},
|
||||
})
|
||||
@@ -474,7 +476,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
// Turn 71: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// Turn 72: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// todo/write snapshot event feeding the TodoPanel plan strip. Two items are
|
||||
// in_progress: this fixture chooses the parallel policy, so both surfaces
|
||||
// must render a parallel plan rather than the first active item alone.
|
||||
@@ -484,7 +486,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
{ content: '跑后台构建', status: 'in_progress' },
|
||||
{ content: '浏览器验收', status: 'pending' },
|
||||
]
|
||||
// Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover —
|
||||
// Turn 66: the terminal sample turn 60's two clean prompt rows cannot cover —
|
||||
// ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
|
||||
// whose prompt label is its last segment, and a non-zero exit authored beside
|
||||
// the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
|
||||
@@ -496,45 +498,45 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
// Ordered BEFORE the todo turn deliberately: the standing plan retires at the
|
||||
// next `turn/start`, so a turn appended after it would leave the dock's plan
|
||||
// strip empty and take the todo surfaces' own coverage with it.
|
||||
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
|
||||
toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
|
||||
|
||||
// Turns 66-67: the search card's two shapes. `grep` emits a `card: 'search'`
|
||||
// Turns 67-68: the search card's two shapes. `grep` emits a `card: 'search'`
|
||||
// `shape: 'matches'` result view (grouped-by-file matches, truncated with a
|
||||
// larger `total`), `glob` emits `shape: 'paths'` (a flat path list, likewise
|
||||
// truncated). Both ride the keyed SearchRow registration under their own
|
||||
// names; the render-site fallback row is covered by the model derivation
|
||||
// tests, since every fixture search tool has a keyed row. Ordered before the
|
||||
// todo turn for the same standing-plan reason the bash turn is.
|
||||
toolTurn(66, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
|
||||
toolTurn(67, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
|
||||
toolTurn(67, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT)
|
||||
toolTurn(68, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT)
|
||||
|
||||
// Turn 68: the read sample — a WINDOW past an offset so the card draws file
|
||||
// Turn 69: the read sample — a WINDOW past an offset so the card draws file
|
||||
// line numbers starting above 1 and a "showing N of M" note (the window is
|
||||
// shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path
|
||||
// highlights. Named `read`, so it exercises the keyed ReadRow registration.
|
||||
// The render-site fallback ROW SHAPE (a read call on the generic flattened
|
||||
// path) is covered by the turn 64 run_code read sub-dispatches, which
|
||||
// path) is covered by the turn 65 run_code read sub-dispatches, which
|
||||
// session.ts folds with resultView: null; the fallback-row + read-CARD
|
||||
// combination is pinned by the web_fetch case in read-card.spec.tsx, not by
|
||||
// this fixture. The read render intent is result-side only, so its pending
|
||||
// call stays a generic `kind: 'read'` card; presentResult carries the
|
||||
// structured window.
|
||||
toolTurn(68, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
|
||||
toolTurn(69, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
|
||||
|
||||
// Turns 69-70: the web render intent — a web_search whose result view carries
|
||||
// Turns 70-71: the web render intent — a web_search whose result view carries
|
||||
// structured sources plus an answer (the citation list, one source lacking a
|
||||
// title so its hostname labels the link, the capped indicator on), and a
|
||||
// web_fetch whose result view carries the fetched URL and its HTTP status.
|
||||
// Both keep a generic pending call view and add the `web` card only at
|
||||
// result time, which is the contract's result-only web shape. Named after
|
||||
// the real tools so they hit the keyed WebRow registration. Ordered BEFORE
|
||||
// the todo turn for the same reason turn 65 is: the standing plan retires at
|
||||
// the todo turn for the same reason turn 66 is: the standing plan retires at
|
||||
// the next turn/start, so a turn after it would empty the dock's plan strip.
|
||||
toolTurn(69, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
|
||||
toolTurn(70, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
|
||||
toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
|
||||
toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
|
||||
|
||||
const todoArgs = JSON.stringify({ todos: fixtureTodos })
|
||||
toolTurn(71, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
|
||||
toolTurn(72, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
|
||||
// The real tool appends the snapshot mid-execution — between tool/call and
|
||||
// tool/result — so the fixture reproduces that exact ordering (the last
|
||||
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
|
||||
@@ -576,7 +578,7 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
|
||||
case 'read':
|
||||
return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] }
|
||||
case 'edit':
|
||||
// The multi-hunk sample (turn 67) is keyed on its file_path, so the two
|
||||
// The multi-hunk sample (turn 64) is keyed on its file_path, so the two
|
||||
// scattered hunks share one path header and the card draws the `⋯` gap.
|
||||
if (str(args.file_path) === 'src/config.ts') {
|
||||
return {
|
||||
@@ -689,9 +691,9 @@ function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventVi
|
||||
|
||||
/**
|
||||
* Fixture parallel of the plan unit's double-event fold: `command/run`
|
||||
* records named `plan` set the wanted target (`off` → false, else true);
|
||||
* `plan/mode` commits and clears it. `wanted` is exposed for the prompt
|
||||
* boundary (the fixture's step/start parallel).
|
||||
* records named `plan` with recorded input set the wanted target (`off` →
|
||||
* false, else true); `plan/mode` commits and clears it. `wanted` is exposed
|
||||
* for the prompt boundary (the fixture's step/start parallel).
|
||||
*/
|
||||
function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boolean; wanted: boolean | null } {
|
||||
let active = false
|
||||
@@ -700,7 +702,8 @@ function foldPlan(log: readonly SessionEvent[]): { active: boolean; pending: boo
|
||||
const item = event as unknown as { type: string; data?: Record<string, unknown> }
|
||||
if (item.type === 'command/run' && item.data?.['name'] === 'plan') {
|
||||
const args = item.data['args']
|
||||
wanted = (typeof args === 'string' ? args : '').trim() !== 'off'
|
||||
if (typeof args !== 'string') continue
|
||||
wanted = args.trim() !== 'off'
|
||||
} else if (item.type === 'plan/mode') {
|
||||
active = item.data?.['active'] === true
|
||||
wanted = null
|
||||
@@ -1007,9 +1010,11 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event:
|
||||
seq: event.seq,
|
||||
}]
|
||||
}
|
||||
// The plan unit advances on its two folded event kinds.
|
||||
// The plan unit advances on its two folded event kinds when the command
|
||||
// lifecycle contains the input that represents a plan selection.
|
||||
const commandData = event as unknown as { data: { name?: string; args?: unknown } }
|
||||
if (type === 'plan/mode' || (type === 'command/run'
|
||||
&& (event as unknown as { data: { name?: string } }).data.name === 'plan')) {
|
||||
&& commandData.data.name === 'plan' && typeof commandData.data.args === 'string')) {
|
||||
return [{
|
||||
type: 'session/projection',
|
||||
sessionId: id,
|
||||
@@ -1279,7 +1284,7 @@ export interface FixtureOptions {
|
||||
|
||||
/** Inbox pump shared by both stream generators (FrameQueue pattern: ONE abort listener hung
|
||||
* outside the loop — a per-iteration {once:true} listener never fires for non-final rounds and
|
||||
* piles up for the stream's lifetime, audit C5). breakNow force-ends the stream without the
|
||||
* piles up for the stream's lifetime). breakNow force-ends the stream without the
|
||||
* client's signal (timing hook: simulated connection loss). */
|
||||
class FxInbox<F> implements StreamConn<F> {
|
||||
private readonly inbox: RpcRequest<F>[] = []
|
||||
@@ -1325,6 +1330,16 @@ class FxInbox<F> implements StreamConn<F> {
|
||||
* @returns an ApiProxy backed entirely by in-memory state — no host process, no network.
|
||||
*/
|
||||
export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return createFixtureWorld(options).api
|
||||
}
|
||||
|
||||
interface FixtureWorld {
|
||||
readonly api: ApiProxy
|
||||
readonly rpc: ClientConnectionRpc
|
||||
}
|
||||
|
||||
/** Build the fixture's legacy API and Remote RPC faces over one state graph. */
|
||||
function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
// The resident fixture sessions all carry history, so none of them is blank.
|
||||
const sessions: SessionSummary[] = options.empty ? [] : [
|
||||
{ sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' },
|
||||
@@ -1332,7 +1347,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
{ sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' },
|
||||
]
|
||||
const logs = new Map<SessionId, SessionEvent[]>([[sid('fx-alpha'), buildAlphaLog()]])
|
||||
const modelTargets = new Map<SessionId, ModelTarget>(sessions.map(session => [
|
||||
const modelSelections = new Map<SessionId, ModelSelection>(sessions.map(session => [
|
||||
session.sessionId,
|
||||
{ provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
]))
|
||||
@@ -1342,6 +1357,17 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
// DeepSeek route so unrelated GUI journeys do not enter first-run setup.
|
||||
['DEEPSEEK_API_KEY', true],
|
||||
])
|
||||
/**
|
||||
* Preset compositions the fixture serves. Held as state rather than
|
||||
* constants so the settings editor's save and delete are exercisable: the
|
||||
* roster a GUI journey sees after writing is the text it wrote.
|
||||
*/
|
||||
const fixturePresets = new Map<string, { trust: 'system' | 'user'; content: string }>([
|
||||
['standard', { trust: 'system', content: "- id: tool-bash\n name: '@deepseek-ai/dsh-tool-bash'\n" }],
|
||||
['minimal', { trust: 'system', content: "- id: tool-web-search\n name: '@deepseek-ai/dsh-tool-web-search'\n" }],
|
||||
['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }],
|
||||
])
|
||||
let fixtureDefaultPreset = 'standard'
|
||||
const nextTurn = new Map<SessionId, number>([[sid('fx-alpha'), 60]])
|
||||
let nextSession = 1
|
||||
let nextRpc = 1
|
||||
@@ -1503,37 +1529,147 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return backscanGoal(log) as FxGoalProjection
|
||||
}
|
||||
|
||||
/** Shared CAS mutation path of the goal verbs (undefined next = invalid transition). */
|
||||
const fxMutateGoal = (
|
||||
request: RpcRequest<{ sessionId: SessionId; ref: { id: string; revision: number } }>,
|
||||
ref: { id: string; revision: number },
|
||||
next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined,
|
||||
): Promise<RpcResponse<{ ref: { id: never; revision: number } }>> => {
|
||||
const missing = requireSession(request)
|
||||
type FxGoalRef = { id: string; revision: number }
|
||||
type FxGoalView = FxGoalProjection['goal'] & {
|
||||
roundsStarted: number
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
activation: 'armed' | 'disarmed'
|
||||
}
|
||||
|
||||
const goalFailure = <T>(message: string): RpcResult<T> => ({
|
||||
ok: false,
|
||||
error: { code: 'internal', message, details: {} },
|
||||
})
|
||||
|
||||
const requireGoalSession = (id: SessionId): RpcResult<never> | undefined => (
|
||||
summaryOf(id) === undefined
|
||||
? { ok: false, error: { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } } }
|
||||
: undefined
|
||||
)
|
||||
|
||||
const goalView = (projection: FxGoalProjection): FxGoalView => ({
|
||||
...projection.goal,
|
||||
roundsStarted: projection.roundsStarted,
|
||||
createdAt: projection.createdAt,
|
||||
updatedAt: projection.updatedAt,
|
||||
activation: projection.goal.phase === 'active' ? 'armed' : 'disarmed',
|
||||
})
|
||||
|
||||
/** Canonical fixture implementation of the generated Goal Remote contract. */
|
||||
const goalRemotes = {
|
||||
create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> {
|
||||
const missing = requireGoalSession(id)
|
||||
if (missing !== undefined) return missing
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current !== null && current.goal.phase !== 'complete') {
|
||||
return goalFailure(`goal "${current.goal.id}" already exists`)
|
||||
}
|
||||
const now = Date.now()
|
||||
const projection = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'create',
|
||||
goal: {
|
||||
id: `fx-goal-${logOf(id).length}`,
|
||||
revision: 1,
|
||||
objective: request.objective,
|
||||
phase: 'active',
|
||||
maxGoalRounds: request.maxGoalRounds ?? 256,
|
||||
},
|
||||
roundsStarted: 0, createdAt: now, updatedAt: now,
|
||||
})
|
||||
return { ok: true, value: { ref: { id: projection.goal.id, revision: projection.goal.revision } } }
|
||||
},
|
||||
edit(id: SessionId, ref: FxGoalRef, request: { objective?: string; maxGoalRounds?: number }): RpcResult<FxGoalView> {
|
||||
return mutateGoal(id, ref, current => ({
|
||||
...current.goal,
|
||||
revision: current.goal.revision + 1,
|
||||
...request.objective === undefined ? {} : { objective: request.objective },
|
||||
...request.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.maxGoalRounds },
|
||||
}))
|
||||
},
|
||||
pause(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalView> {
|
||||
return mutateGoal(id, ref, current => (
|
||||
current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' }
|
||||
: undefined
|
||||
))
|
||||
},
|
||||
resume(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalView> {
|
||||
return mutateGoal(id, ref, current => (
|
||||
current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' }
|
||||
: undefined
|
||||
))
|
||||
},
|
||||
complete(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalView> {
|
||||
return mutateGoal(id, ref, current => (
|
||||
current.goal.phase === 'complete'
|
||||
? undefined
|
||||
: { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' }
|
||||
))
|
||||
},
|
||||
clear(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalRef> {
|
||||
const resolved = resolveGoal(id, ref)
|
||||
if (!resolved.ok) return resolved
|
||||
const current = resolved.value
|
||||
const tombstone = { id: current.goal.id, revision: current.goal.revision + 1 }
|
||||
appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'clear', cleared: tombstone, clearedAt: Date.now(),
|
||||
})
|
||||
return { ok: true, value: tombstone }
|
||||
},
|
||||
}
|
||||
|
||||
/** Resolve one current goal revision for a canonical Remote mutation. */
|
||||
function resolveGoal(id: SessionId, ref: FxGoalRef): RpcResult<FxGoalProjection> {
|
||||
const missing = requireGoalSession(id)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current === null || current.goal.id !== ref.id || current.goal.revision !== ref.revision) {
|
||||
return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
|
||||
return goalFailure('stale or missing goal revision')
|
||||
}
|
||||
return { ok: true, value: current }
|
||||
}
|
||||
|
||||
/** Shared CAS mutation path behind the canonical Remote verbs. */
|
||||
function mutateGoal(
|
||||
id: SessionId,
|
||||
ref: FxGoalRef,
|
||||
next: (current: FxGoalProjection) => FxGoalProjection['goal'] | undefined,
|
||||
): RpcResult<FxGoalView> {
|
||||
const resolved = resolveGoal(id, ref)
|
||||
if (!resolved.ok) return resolved
|
||||
const current = resolved.value
|
||||
const goal = next(current)
|
||||
if (goal === undefined) {
|
||||
return err(request, { code: 'internal', message: `invalid goal transition from "${current.goal.phase}"`, details: { goalCode: 'GOAL_INVALID_TRANSITION' } })
|
||||
return goalFailure(`invalid goal transition from "${current.goal.phase}"`)
|
||||
}
|
||||
const projection = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1,
|
||||
operation: goal.phase === current.goal.phase ? 'edit' : goal.phase === 'paused' ? 'pause' : goal.phase === 'active' ? 'resume' : 'complete',
|
||||
goal, roundsStarted: current.roundsStarted, createdAt: current.createdAt, updatedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
|
||||
return { ok: true, value: goalView(projection) }
|
||||
}
|
||||
|
||||
const mapGoalResult = <T, U>(result: RpcResult<T>, map: (value: T) => U): RpcResult<U> => (
|
||||
result.ok ? { ok: true, value: map(result.value) } : result
|
||||
)
|
||||
|
||||
const goalRefResult = (result: RpcResult<FxGoalView>): RpcResult<{ ref: { id: never; revision: number } }> => (
|
||||
mapGoalResult(result, view => ({ ref: { id: view.id as never, revision: view.revision } }))
|
||||
)
|
||||
|
||||
const legacyGoalResponse = <P, T>(request: RpcRequest<P>, result: RpcResult<T>): Promise<RpcResponse<T>> => (
|
||||
Promise.resolve({ rpcId: request.rpcId, result })
|
||||
)
|
||||
|
||||
/** At most one in-flight replay per session; cancel clears it. */
|
||||
const replays = new Map<SessionId, { timer: ReturnType<typeof setTimeout>; finish(aborted: boolean): void }>()
|
||||
|
||||
/** history transit delay (timing hooks below); the page snapshot is taken at request time, like a real host. */
|
||||
let historyDelayMs = 0
|
||||
/** One-shot history failure (timing hook: the doomed in-flight request of the S4 reconnect scenario). */
|
||||
/** One-shot history failure (timing hook: a pre-disconnect history request already doomed when reconnect lands). */
|
||||
let failNextHistory = false
|
||||
/** Force-enders for currently open stream generators (timing hook: simulated connection loss). */
|
||||
const streamBreakers = new Set<() => void>()
|
||||
@@ -1542,8 +1678,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
/** The single opt-in browser stress producer; normal fixture journeys never start it. */
|
||||
let activeReasoningChunkStorm: ReasoningChunkStormState | null = null
|
||||
|
||||
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which
|
||||
// is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let
|
||||
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is
|
||||
// ideally timed. These let
|
||||
// browser acceptance runs create slow-history, lost-frame, and reconnect
|
||||
// windows a real host produces naturally.
|
||||
const timingHooks = {
|
||||
@@ -1773,7 +1909,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
replays.set(id, { timer: setTimeout(tick, 80), finish })
|
||||
}
|
||||
|
||||
return {
|
||||
const api: ApiProxy = {
|
||||
sessions: {
|
||||
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
|
||||
search: (request, signal) => {
|
||||
@@ -1864,7 +2000,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd,
|
||||
}
|
||||
sessions.push(created)
|
||||
modelTargets.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' })
|
||||
attachedSessions += 1
|
||||
const emitSession = (): void => {
|
||||
// Mirrors the host: the frame fires at creation, so blank is constantly true.
|
||||
@@ -1973,20 +2109,23 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
|
||||
},
|
||||
models: request => ok(request, {
|
||||
current: modelTargets.get(request.payload.sessionId)
|
||||
current: modelSelections.get(request.payload.sessionId)
|
||||
?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
|
||||
// The fixture's routes all serve; a surface exercising the blocked
|
||||
// posture drives it through its own stub.
|
||||
routable: true,
|
||||
groups: fixtureModelGroups(),
|
||||
failures: [],
|
||||
}),
|
||||
selectModel: (request) => {
|
||||
const selected: ModelTarget = {
|
||||
const selected: ModelSelection = {
|
||||
provider: request.payload.provider,
|
||||
model: request.payload.model,
|
||||
...request.payload.reasoningEffort === undefined
|
||||
? {}
|
||||
: { reasoningEffort: request.payload.reasoningEffort },
|
||||
}
|
||||
modelTargets.set(request.payload.sessionId, selected)
|
||||
modelSelections.set(request.payload.sessionId, selected)
|
||||
return ok(request, { selected })
|
||||
},
|
||||
prompt: (request) => {
|
||||
@@ -2025,11 +2164,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
// Capacity parallel of the host token-meter's request/context record:
|
||||
// log-only, appended inside the open turn, and deduplicated against the
|
||||
// route already recorded (the fixture never varies contextWindow).
|
||||
const target = modelTargets.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
if (lastRequestContext(logOf(id))?.model !== target.model) {
|
||||
const selection = modelSelections.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' }
|
||||
if (lastRequestContext(logOf(id))?.model !== selection.model) {
|
||||
append(id, {
|
||||
type: 'request/context',
|
||||
data: { provider: target.provider, model: target.model, contextWindow: 128_000 },
|
||||
data: { provider: selection.provider, model: selection.model, contextWindow: 128_000 },
|
||||
})
|
||||
}
|
||||
startReply(
|
||||
@@ -2039,9 +2178,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
? MARKDOWN_FIXTURE
|
||||
: userText === 'report model'
|
||||
? (() => {
|
||||
const target = modelTargets.get(id)
|
||||
return `当前模型:${target?.provider ?? 'unknown'}/${target?.model ?? 'unknown'}`
|
||||
+ (target?.reasoningEffort === undefined ? '' : ` · 推理等级:${target.reasoningEffort}`)
|
||||
const selection = modelSelections.get(id)
|
||||
return `当前模型:${selection?.provider ?? 'unknown'}/${selection?.model ?? 'unknown'}`
|
||||
+ (selection?.reasoningEffort === undefined ? '' : ` · 推理等级:${selection.reasoningEffort}`)
|
||||
})()
|
||||
: `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`,
|
||||
)
|
||||
@@ -2075,6 +2214,7 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
prompt: request => Promise.resolve(ok(request, {
|
||||
messageId: `fixture-message-${request.payload.childSessionId}` as never,
|
||||
})),
|
||||
interrupt: request => Promise.resolve(ok(request, { accepted: true as const })),
|
||||
},
|
||||
host: {
|
||||
describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions }),
|
||||
@@ -2314,72 +2454,139 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return ok(request, { matched: true as const, commandId })
|
||||
},
|
||||
},
|
||||
agentPresets: {
|
||||
// Both trusts appear, because a surface must present a locally authored
|
||||
// preset differently from one the deployment vetted.
|
||||
list: request => ok(request, {
|
||||
presets: [...fixturePresets].map(([id, preset]) => ({
|
||||
id,
|
||||
trust: preset.trust,
|
||||
isDefault: id === fixtureDefaultPreset,
|
||||
})),
|
||||
authorable: true,
|
||||
hasDocument: true,
|
||||
}),
|
||||
select: (request) => {
|
||||
fixtureDefaultPreset = request.payload.agentPreset
|
||||
return ok(request, { agentPreset: request.payload.agentPreset })
|
||||
},
|
||||
read: (request) => {
|
||||
const { agentPreset } = request.payload
|
||||
const preset = fixturePresets.get(agentPreset)
|
||||
if (preset === undefined) {
|
||||
return err(request, {
|
||||
code: 'agent-preset-not-found',
|
||||
message: `unknown agent preset "${agentPreset}"`,
|
||||
details: { agentPreset, available: [...fixturePresets.keys()] },
|
||||
})
|
||||
}
|
||||
return ok(request, {
|
||||
agentPreset,
|
||||
trust: preset.trust,
|
||||
content: preset.content,
|
||||
})
|
||||
},
|
||||
copy: (request) => {
|
||||
const { from, agentPreset } = request.payload
|
||||
const source = fixturePresets.get(from)
|
||||
if (source === undefined) {
|
||||
return err(request, {
|
||||
code: 'agent-preset-not-found',
|
||||
message: `unknown agent preset "${from}"`,
|
||||
details: { agentPreset: from, available: [...fixturePresets.keys()] },
|
||||
})
|
||||
}
|
||||
if (fixturePresets.has(agentPreset)) {
|
||||
return err(request, {
|
||||
code: 'agent-preset-invalid',
|
||||
message: `agent preset "${agentPreset}" already exists`,
|
||||
details: { agentPreset, reason: 'already exists' },
|
||||
})
|
||||
}
|
||||
fixturePresets.set(agentPreset, { trust: 'user', content: source.content })
|
||||
return ok(request, { agentPreset })
|
||||
},
|
||||
// Native opens are deterministic no-op successes in this fixture, so the
|
||||
// open-directory affordance renders and the path-text fallback stays a
|
||||
// component-test concern.
|
||||
openDocument: (request) => {
|
||||
const { agentPreset } = request.payload
|
||||
const existing = fixturePresets.get(agentPreset)
|
||||
if (existing === undefined || existing.trust === 'system') {
|
||||
return err(request, {
|
||||
code: 'agent-preset-read-only',
|
||||
message: `agent preset "${agentPreset}" ships with the deployment`,
|
||||
details: { agentPreset, reason: 'it ships with the deployment' },
|
||||
})
|
||||
}
|
||||
return ok(request, { opened: true as const })
|
||||
},
|
||||
remove: (request) => {
|
||||
const { agentPreset } = request.payload
|
||||
const existing = fixturePresets.get(agentPreset)
|
||||
if (existing?.trust === 'system') {
|
||||
return err(request, {
|
||||
code: 'agent-preset-read-only',
|
||||
message: `agent preset "${agentPreset}" ships with the deployment`,
|
||||
details: { agentPreset, reason: 'it ships with the deployment' },
|
||||
})
|
||||
}
|
||||
fixturePresets.delete(agentPreset)
|
||||
return ok(request, {})
|
||||
},
|
||||
},
|
||||
|
||||
skills: {
|
||||
list: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
return ok(request, {
|
||||
skills: [
|
||||
{ name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收' },
|
||||
{ name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收', modelInvocable: true },
|
||||
{ name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false },
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
goals: {
|
||||
// Mutation-only mirror of the host handlers: each verb CAS-checks the
|
||||
// projected current goal, appends the whole-value change (the mux
|
||||
// stream and projection frame ride the shared append path), and
|
||||
// acknowledges with the new ref only.
|
||||
create: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current !== null && current.goal.phase !== 'complete') {
|
||||
return err(request, { code: 'internal', message: `goal "${current.goal.id}" already exists`, details: { goalCode: 'GOAL_ALREADY_EXISTS' } })
|
||||
}
|
||||
const projection = appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'create',
|
||||
goal: { id: `fx-goal-${logOf(id).length}`, revision: 1, objective: request.payload.objective, phase: 'active', maxGoalRounds: request.payload.maxGoalRounds ?? 256 },
|
||||
roundsStarted: 0, createdAt: Date.now(), updatedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { ref: { id: projection.goal.id as never, revision: projection.goal.revision } })
|
||||
},
|
||||
edit: request => fxMutateGoal(request, request.payload.ref, current => ({
|
||||
...current.goal,
|
||||
revision: current.goal.revision + 1,
|
||||
...request.payload.objective === undefined ? {} : { objective: request.payload.objective },
|
||||
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
|
||||
})),
|
||||
pause: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'paused' }
|
||||
: undefined
|
||||
)),
|
||||
resume: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'paused' || current.goal.phase === 'blocked' || current.goal.phase === 'active'
|
||||
? { ...current.goal, revision: current.goal.revision + 1, phase: 'active' }
|
||||
: undefined
|
||||
)),
|
||||
complete: request => fxMutateGoal(request, request.payload.ref, current => (
|
||||
current.goal.phase === 'complete'
|
||||
? undefined
|
||||
: { ...current.goal, revision: current.goal.revision + 1, phase: 'complete' }
|
||||
)),
|
||||
clear: (request) => {
|
||||
const missing = requireSession(request)
|
||||
if (missing !== undefined) return missing
|
||||
const id = request.payload.sessionId
|
||||
const current = backscanGoal(logOf(id))
|
||||
if (current === null || current.goal.id !== request.payload.ref.id || current.goal.revision !== request.payload.ref.revision) {
|
||||
return err(request, { code: 'internal', message: 'stale or missing goal revision', details: { goalCode: 'GOAL_STALE_REVISION' } })
|
||||
}
|
||||
appendGoalChange(id, {
|
||||
kind: 'goal/change', version: 1, operation: 'clear',
|
||||
cleared: { id: current.goal.id, revision: current.goal.revision + 1 }, clearedAt: Date.now(),
|
||||
})
|
||||
return ok(request, { cleared: true as const })
|
||||
},
|
||||
// Compatibility face only: old API Proxy payloads and acknowledgements
|
||||
// adapt to the canonical fixture Remote implementation above.
|
||||
create: request => legacyGoalResponse(
|
||||
request,
|
||||
mapGoalResult(
|
||||
goalRemotes.create(request.payload.sessionId, {
|
||||
objective: request.payload.objective,
|
||||
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
|
||||
}),
|
||||
value => ({ ref: { id: value.ref.id as never, revision: value.ref.revision } }),
|
||||
),
|
||||
),
|
||||
edit: request => legacyGoalResponse(
|
||||
request,
|
||||
goalRefResult(goalRemotes.edit(request.payload.sessionId, request.payload.ref, {
|
||||
...request.payload.objective === undefined ? {} : { objective: request.payload.objective },
|
||||
...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds },
|
||||
})),
|
||||
),
|
||||
pause: request => legacyGoalResponse(
|
||||
request,
|
||||
goalRefResult(goalRemotes.pause(request.payload.sessionId, request.payload.ref)),
|
||||
),
|
||||
resume: request => legacyGoalResponse(
|
||||
request,
|
||||
goalRefResult(goalRemotes.resume(request.payload.sessionId, request.payload.ref)),
|
||||
),
|
||||
complete: request => legacyGoalResponse(
|
||||
request,
|
||||
goalRefResult(goalRemotes.complete(request.payload.sessionId, request.payload.ref)),
|
||||
),
|
||||
clear: request => legacyGoalResponse(
|
||||
request,
|
||||
mapGoalResult(
|
||||
goalRemotes.clear(request.payload.sessionId, request.payload.ref),
|
||||
() => ({ cleared: true as const }),
|
||||
),
|
||||
),
|
||||
},
|
||||
events: {
|
||||
async *mux(_request, signal) {
|
||||
@@ -2499,8 +2706,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
providers: request => ok(request, {
|
||||
providers: [
|
||||
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
|
||||
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
|
||||
{ provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true, declared: false },
|
||||
{ provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false, declared: false },
|
||||
// One hand-declared route, so a surface reading this fixture meets
|
||||
// the tagged shape rather than only the shipped one.
|
||||
{ provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], active: true, declared: true },
|
||||
],
|
||||
}),
|
||||
models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }),
|
||||
@@ -2537,21 +2747,55 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
return Promise.resolve({ accepted: true })
|
||||
},
|
||||
}
|
||||
|
||||
const rpc: ClientConnectionRpc = {
|
||||
call(channel, endpoint, payload) {
|
||||
if (channel !== '/api') {
|
||||
return Promise.reject(new Error(`fixture connection RPC channel ${JSON.stringify(channel)} is unavailable`))
|
||||
}
|
||||
const args = (payload as {
|
||||
args: {
|
||||
agentId: SessionId
|
||||
ref?: { id: string; revision: number }
|
||||
request?: { objective?: string; maxGoalRounds?: number }
|
||||
}
|
||||
}).args
|
||||
const sessionId = args.agentId
|
||||
switch (endpoint) {
|
||||
case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, {
|
||||
objective: args.request?.objective as string,
|
||||
...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds },
|
||||
}))
|
||||
case 'goals/edit': return Promise.resolve(goalRemotes.edit(sessionId, args.ref as FxGoalRef, args.request ?? {}))
|
||||
case 'goals/pause': return Promise.resolve(goalRemotes.pause(sessionId, args.ref as FxGoalRef))
|
||||
case 'goals/resume': return Promise.resolve(goalRemotes.resume(sessionId, args.ref as FxGoalRef))
|
||||
case 'goals/complete': return Promise.resolve(goalRemotes.complete(sessionId, args.ref as FxGoalRef))
|
||||
case 'goals/clear': return Promise.resolve(goalRemotes.clear(sessionId, args.ref as FxGoalRef))
|
||||
default:
|
||||
return Promise.reject(new Error(`fixture connection RPC endpoint ${JSON.stringify(endpoint)} is unavailable`))
|
||||
}
|
||||
},
|
||||
}
|
||||
return { api, rpc }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixture platform subclass: there is no HTTP at all, so instead of a doFetch transport it
|
||||
* overrides the protocol-level virtuals (callUnary/openMux/openHost/respond) to dispatch
|
||||
* straight into the in-memory ApiProxy — while still minting rpcIds, fabricating the four
|
||||
* named full forms, and feeding the same tap as a real carrier. Delete when the fixture moves
|
||||
* to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)).
|
||||
* named full forms, and feeding the same tap as a real carrier. TODO: delete when the fixture
|
||||
* moves to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)).
|
||||
*/
|
||||
export class FixtureApiClient extends AbstractApiClient {
|
||||
private readonly api: ApiProxy
|
||||
/** Generic Remote caller backed by the same in-memory state as the legacy fixture API. */
|
||||
readonly rpc: ClientConnectionRpc
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.api = createFixtureApi(fixtureOptionsFromLocation())
|
||||
const world = createFixtureWorld(fixtureOptionsFromLocation())
|
||||
this.api = world.api
|
||||
this.rpc = world.rpc
|
||||
}
|
||||
|
||||
protected doFetch(): Promise<Response> {
|
||||
@@ -2597,6 +2841,7 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'subagent.list': return this.api.subagents.list(request)
|
||||
case 'subagent.history': return this.api.subagents.history(request)
|
||||
case 'subagent.prompt': return this.api.subagents.prompt(request, signal)
|
||||
case 'subagent.interrupt': return this.api.subagents.interrupt(request)
|
||||
case 'host.describe': return this.api.host.describe(request)
|
||||
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
|
||||
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)
|
||||
@@ -2611,6 +2856,12 @@ export class FixtureApiClient extends AbstractApiClient {
|
||||
case 'command.list': return this.api.commands.list(request)
|
||||
case 'command.execute': return this.api.commands.execute(request, signal)
|
||||
case 'skill.list': return this.api.skills.list(request)
|
||||
case 'agentPreset.list': return this.api.agentPresets.list(request)
|
||||
case 'agentPreset.select': return this.api.agentPresets.select(request)
|
||||
case 'agentPreset.read': return this.api.agentPresets.read(request)
|
||||
case 'agentPreset.copy': return this.api.agentPresets.copy(request)
|
||||
case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal)
|
||||
case 'agentPreset.remove': return this.api.agentPresets.remove(request)
|
||||
case 'goal.create': return this.api.goals.create(request)
|
||||
case 'goal.edit': return this.api.goals.edit(request)
|
||||
case 'goal.pause': return this.api.goals.pause(request)
|
||||
|
||||
@@ -8,7 +8,9 @@ import type { IApiClient } from './api.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
import { WebApiClient } from './web-api-client.ts'
|
||||
import { createWebConnectionRpc } from './rpc.ts'
|
||||
import { isLoopbackHostname } from '../loopback-hostname.ts'
|
||||
import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
|
||||
export type {
|
||||
@@ -18,7 +20,7 @@ export type {
|
||||
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
|
||||
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
|
||||
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
MessageId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
|
||||
MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels,
|
||||
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
|
||||
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
|
||||
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
|
||||
@@ -36,6 +38,7 @@ export {
|
||||
// Connection loop types are public through ConnectionHandle.start; the
|
||||
// controller remains package-internal.
|
||||
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
|
||||
export type { ClientConnectionRpc } from '../rpc.ts'
|
||||
|
||||
|
||||
/** Required services (none — this is the wire root). */
|
||||
@@ -51,6 +54,8 @@ export interface ConnectionHandle {
|
||||
readonly api: IApiClient
|
||||
/** Whether the current page authority is loopback; non-browser contexts default to true. */
|
||||
readonly isLoopback: boolean
|
||||
/** Generic logical RPC channels over the same Connection transport. */
|
||||
readonly rpc: ClientConnectionRpc
|
||||
/**
|
||||
* Start the connect/pump/reconnect loop with the consumer's frame sinks.
|
||||
* One consumer owns the streams (the runtime object layer); a second call
|
||||
@@ -69,11 +74,14 @@ export interface ConnectionHandle {
|
||||
export function apply(ctx: Context): void {
|
||||
const pageLocation = typeof location === 'undefined' ? undefined : location
|
||||
const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture')
|
||||
const api: IApiClient = fixture ? new FixtureApiClient() : new WebApiClient()
|
||||
const fixtureClient = fixture ? new FixtureApiClient() : undefined
|
||||
const api: IApiClient = fixtureClient ?? new WebApiClient()
|
||||
const rpc = fixtureClient?.rpc ?? createWebConnectionRpc()
|
||||
let started = false
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname),
|
||||
rpc,
|
||||
start(sinks, config) {
|
||||
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
|
||||
started = true
|
||||
|
||||
14
packages/client/connection/src/client/random-uuid.ts
Normal file
14
packages/client/connection/src/client/random-uuid.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/** Browser-safe UUID generation for client-side wire correlation. */
|
||||
|
||||
/**
|
||||
* Generate an RFC 4122 version 4 UUID without requiring a secure context.
|
||||
* @returns a UUID backed by `crypto.getRandomValues()`, which browsers expose on insecure origins.
|
||||
*/
|
||||
export function randomUuid(): string {
|
||||
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16))
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
view.setUint8(6, (view.getUint8(6) & 0x0f) | 0x40)
|
||||
view.setUint8(8, (view.getUint8(8) & 0x3f) | 0x80)
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
63
packages/client/connection/src/client/rpc.ts
Normal file
63
packages/client/connection/src/client/rpc.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/** Browser caller for generic Connection unary RPC channels. */
|
||||
|
||||
import {
|
||||
RpcId,
|
||||
serverResponseSchema,
|
||||
type ClientRequest,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ClientConnectionRpc } from '../rpc.ts'
|
||||
import { randomUuid } from './random-uuid.ts'
|
||||
|
||||
const INTERNAL_BASE = 'http://dsh.internal'
|
||||
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
|
||||
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
|
||||
|
||||
/**
|
||||
* Create the browser-backed generic RPC caller.
|
||||
* @returns caller that owns request correlation and response-envelope validation.
|
||||
*/
|
||||
export function createWebConnectionRpc(): ClientConnectionRpc {
|
||||
return {
|
||||
async call(channel, endpoint, payload, signal) {
|
||||
assertTarget(channel, endpoint)
|
||||
const rpcId = RpcId(randomUuid())
|
||||
const message: ClientRequest = {
|
||||
type: 'client-request',
|
||||
rpcId,
|
||||
method: endpoint,
|
||||
payload,
|
||||
}
|
||||
const response = await globalThis.fetch(
|
||||
new URL(`${channel}/${endpoint}`, resolveBase()),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(message),
|
||||
...signal === undefined ? {} : { signal },
|
||||
},
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`)
|
||||
}
|
||||
const full = serverResponseSchema.parse(await response.json())
|
||||
if (full.rpcId !== rpcId) {
|
||||
throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`)
|
||||
}
|
||||
return full.result
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBase(): string {
|
||||
const location = (globalThis as { location?: { origin?: string } }).location
|
||||
return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE
|
||||
}
|
||||
|
||||
function assertTarget(channel: string, endpoint: string): void {
|
||||
const segments = endpoint.split('/')
|
||||
if (!CHANNEL_PATTERN.test(channel)
|
||||
|| segments.some(segment =>
|
||||
segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
|
||||
throw new Error(`connection: invalid RPC target ${JSON.stringify(`${channel}/${endpoint}`)}`)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,16 @@
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
|
||||
/** Transport-independent request handler consumed by the Host HTTP bridge. */
|
||||
export interface FetchHandler {
|
||||
/**
|
||||
* Handle one standard Fetch request.
|
||||
* @param request - request produced by the active transport bridge.
|
||||
* @returns complete or streaming Fetch response.
|
||||
*/
|
||||
fetch(request: Request): Promise<Response>
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge one node:http request to the fetch-shaped handler (client close
|
||||
* aborts; SSE bodies stream out chunk by chunk).
|
||||
@@ -12,7 +22,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
* @param res - node:http response the bridge writes and owns to completion.
|
||||
* @param apiHandler - fetch-shaped API carrier the request is dispatched to.
|
||||
*/
|
||||
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: { fetch: typeof fetch }): Promise<void> {
|
||||
export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: FetchHandler): Promise<void> {
|
||||
const abort = new AbortController()
|
||||
// Client-disconnect detection MUST hang off the response, not the request:
|
||||
// since Node 16, IncomingMessage 'close' fires as soon as the request body is
|
||||
|
||||
@@ -7,15 +7,26 @@ import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
||||
import { bridge } from './http-bridge.ts'
|
||||
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
|
||||
import { HostConnectionService } from './rpc-host.ts'
|
||||
import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts'
|
||||
|
||||
export type {
|
||||
ConnectionRpcAuthority,
|
||||
ConnectionRpcEndpointMatcher,
|
||||
ConnectionRpcHandler,
|
||||
ConnectionRpcHandlerOptions,
|
||||
HostConnectionHandle,
|
||||
HostConnectionRpc,
|
||||
} from './rpc.ts'
|
||||
export { HostConnectionService } from './rpc-host.ts'
|
||||
|
||||
export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts'
|
||||
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'client-connection'
|
||||
|
||||
/** Services required before mounting the route. */
|
||||
export const inject = ['httpServer', 'apiProxy']
|
||||
/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */
|
||||
export const inject = ['httpServer']
|
||||
|
||||
/** Plugin config: the deployment's non-loopback serving authorities. */
|
||||
export interface ConnectionConfig {
|
||||
@@ -55,6 +66,24 @@ export const Config: z<ConnectionConfig> = z.object({
|
||||
* keys, or key state — and a LAN client's model picker legitimately needs it.
|
||||
*/
|
||||
const PRIVILEGED_METHODS = new Set([
|
||||
// A preset composition names the plugins a session runs, so reading one is
|
||||
// reconnaissance; copy and remove rearrange what the deployment offers, and
|
||||
// openDocument drives the host desktop — all more than the roster beside
|
||||
// them. (Authoring is copy-only, so no method here accepts composition text
|
||||
// or a path; the pin is about who may manage the roster at all.)
|
||||
//
|
||||
// CHOOSING one is not pinned, and `agentPreset.list` is not either. Picking a
|
||||
// preset looks like escalation — one of them mounts the toolset that edits the
|
||||
// live runtime — but `session.create` already takes an `agentPreset`, so
|
||||
// pinning only the switch would leave the same capability one method over.
|
||||
// The deeper reason is that the capability is not the preset's to grant: the
|
||||
// deployment's own default already carries `bash` and the filesystem tools, so
|
||||
// any caller that may start a session at all can already run commands as this
|
||||
// process. Pinning the switch would be a fence beside an open gate.
|
||||
'agentPreset.read',
|
||||
'agentPreset.copy',
|
||||
'agentPreset.openDocument',
|
||||
'agentPreset.remove',
|
||||
'host.pickDirectory',
|
||||
'host.openPath',
|
||||
'settings.describe',
|
||||
@@ -83,49 +112,61 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
// Config boundary: a malformed entry fails the load loudly here rather than
|
||||
// silently authorizing its hostname prefix at request time.
|
||||
for (const entry of trustedHosts) assertTrustedAuthority(entry)
|
||||
const apiHandler = toFetchHandler(ctx.apiProxy)
|
||||
const downlinks = new WebSocketDownlinks(ctx.apiProxy)
|
||||
const connection = new HostConnectionService(ctx, trustedHosts)
|
||||
const fetchHandler = connection.createSharedFetchHandler(API_PATH, {
|
||||
async fetch(request) {
|
||||
const pathname = new URL(request.url).pathname
|
||||
const method = pathname.startsWith(`${API_PATH}/`)
|
||||
? pathname.slice(API_PATH.length + 1)
|
||||
: undefined
|
||||
if (method !== undefined
|
||||
&& PRIVILEGED_METHODS.has(method)
|
||||
&& !isTrustedApiRequest(request, [])) {
|
||||
return new Response('forbidden', { status: 403 })
|
||||
}
|
||||
if (request.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
|
||||
return new Response('upgrade required', {
|
||||
status: 426,
|
||||
headers: { connection: 'Upgrade', upgrade: 'websocket' },
|
||||
})
|
||||
}
|
||||
const apiProxy = ctx.get('apiProxy')
|
||||
if (apiProxy === undefined) return new Response('not found', { status: 404 })
|
||||
return toFetchHandler(apiProxy).fetch(request)
|
||||
},
|
||||
})
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: API_PATH,
|
||||
handler: async (req, res) => {
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
const method = pathname.startsWith(`${API_PATH}/`)
|
||||
? pathname.slice(API_PATH.length + 1)
|
||||
: undefined
|
||||
const allowed = method !== undefined && PRIVILEGED_METHODS.has(method)
|
||||
? isTrustedApiRequest(req, [])
|
||||
: isTrustedApiRequest(req, trustedHosts)
|
||||
if (!allowed) {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
if (req.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) {
|
||||
res.writeHead(426, { connection: 'Upgrade', upgrade: 'websocket' })
|
||||
res.end('upgrade required')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, apiHandler)
|
||||
await bridge(req, res, fetchHandler)
|
||||
},
|
||||
}
|
||||
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
|
||||
const registerDownlink = (
|
||||
path: string,
|
||||
handle: WebUpgradeRoute['handler'],
|
||||
): void => {
|
||||
ctx.effect(() => ctx.httpServer.registerUpgrade({
|
||||
path,
|
||||
handler: (req, socket, head) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
rejectWebSocketUpgrade(socket)
|
||||
return
|
||||
}
|
||||
return handle(req, socket, head)
|
||||
},
|
||||
}), `client-connection: ${path} WebSocket`)
|
||||
}
|
||||
ctx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
|
||||
registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
|
||||
registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
|
||||
ctx.inject(['apiProxy'], (apiCtx) => {
|
||||
const downlinks = new WebSocketDownlinks(apiCtx.apiProxy)
|
||||
const registerDownlink = (
|
||||
path: string,
|
||||
handle: WebUpgradeRoute['handler'],
|
||||
): void => {
|
||||
apiCtx.effect(() => apiCtx.httpServer.registerUpgrade({
|
||||
path,
|
||||
handler: (req, socket, head) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
rejectWebSocketUpgrade(socket)
|
||||
return
|
||||
}
|
||||
return handle(req, socket, head)
|
||||
},
|
||||
}), `client-connection: ${path} WebSocket`)
|
||||
}
|
||||
apiCtx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks')
|
||||
registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) })
|
||||
registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) })
|
||||
})
|
||||
}
|
||||
|
||||
224
packages/client/connection/src/rpc-host.ts
Normal file
224
packages/client/connection/src/rpc-host.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/** Host registry and HTTP adapter for generic Connection RPC channels. */
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import {
|
||||
clientRequestSchema,
|
||||
RpcId,
|
||||
type ClientRequest,
|
||||
type RpcError,
|
||||
type RpcErrorDetailsMap,
|
||||
type RpcId as RpcIdType,
|
||||
type ServerResponse as RpcServerResponse,
|
||||
} from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { bridge, type FetchHandler } from './http-bridge.ts'
|
||||
import { isTrustedApiRequest } from './api-request-trust.ts'
|
||||
import { API_PATH } from './api-path.ts'
|
||||
import type {
|
||||
ConnectionRpcEndpointMatcher,
|
||||
ConnectionRpcHandler,
|
||||
ConnectionRpcHandlerOptions,
|
||||
HostConnectionHandle,
|
||||
HostConnectionRpc,
|
||||
} from './rpc.ts'
|
||||
|
||||
const INVALID_REQUEST_RPC_ID = RpcId('invalid-request')
|
||||
const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/
|
||||
const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/
|
||||
|
||||
interface ConnectionRpcInterceptor {
|
||||
readonly matches: ConnectionRpcEndpointMatcher
|
||||
readonly fetchHandler: FetchHandler
|
||||
readonly options: ConnectionRpcHandlerOptions
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
/** Host Connection transport and RPC registrations. */
|
||||
connection: HostConnectionHandle
|
||||
}
|
||||
}
|
||||
|
||||
/** Host Connection service whose channel registrations belong to the caller fiber. */
|
||||
export class HostConnectionService extends Service implements HostConnectionHandle {
|
||||
private readonly interceptors = new Map<string, ConnectionRpcInterceptor>()
|
||||
|
||||
/**
|
||||
* Provide the Host half over the active HTTP server.
|
||||
* @param ctx - owning Connection plugin context.
|
||||
* @param trustedHosts - deployment authorities accepted by trusted-host channels.
|
||||
*/
|
||||
constructor(ctx: Context, private readonly trustedHosts: readonly string[]) {
|
||||
super(ctx, 'connection')
|
||||
}
|
||||
|
||||
/** Generic channel registry scoped to the Context reading this service. */
|
||||
get rpc(): HostConnectionRpc {
|
||||
const owner = this.ctx
|
||||
return {
|
||||
handle: (channel, handler, options) => this.register(owner, channel, handler, options),
|
||||
intercept: (channel, matches, handler, options) =>
|
||||
this.registerInterceptor(owner, channel, matches, handler, options),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose one shared-channel Fetch handler from its interceptor and fallback.
|
||||
* @param channel - shared channel mounted by Connection.
|
||||
* @param fallback - handler for endpoints not claimed by the interceptor.
|
||||
* @returns Fetch handler that selects exactly one target for each request.
|
||||
*/
|
||||
createSharedFetchHandler(
|
||||
channel: '/api',
|
||||
fallback: FetchHandler,
|
||||
): FetchHandler {
|
||||
return {
|
||||
fetch: (request) => {
|
||||
const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
|
||||
const interceptor = this.interceptors.get(channel)
|
||||
if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) {
|
||||
return fallback.fetch(request)
|
||||
}
|
||||
if (interceptor.options.authority === 'loopback' && !isTrustedApiRequest(request, [])) {
|
||||
return Promise.resolve(new Response('forbidden', { status: 403 }))
|
||||
}
|
||||
return interceptor.fetchHandler.fetch(request)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private register(
|
||||
owner: Context,
|
||||
channel: string,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void> {
|
||||
assertChannel(channel)
|
||||
const trustedHosts = options.authority === 'loopback' ? [] : this.trustedHosts
|
||||
const fetchHandler = rpcFetchHandler(channel, handler)
|
||||
const route: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: channel,
|
||||
handler: async (req, res) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
await bridge(req, res, fetchHandler)
|
||||
},
|
||||
}
|
||||
return owner.effect(
|
||||
() => owner.httpServer.register(route),
|
||||
`client-connection: ${channel} rpc channel`,
|
||||
)
|
||||
}
|
||||
|
||||
private registerInterceptor(
|
||||
owner: Context,
|
||||
channel: string,
|
||||
matches: ConnectionRpcEndpointMatcher,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void> {
|
||||
if (channel !== API_PATH) {
|
||||
throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`)
|
||||
}
|
||||
const interceptor: ConnectionRpcInterceptor = {
|
||||
matches,
|
||||
fetchHandler: rpcFetchHandler(channel, handler),
|
||||
options,
|
||||
}
|
||||
return owner.effect(() => {
|
||||
if (this.interceptors.has(channel)) {
|
||||
throw new Error(`connection: shared RPC channel ${JSON.stringify(channel)} already has an interceptor`)
|
||||
}
|
||||
this.interceptors.set(channel, interceptor)
|
||||
return () => {
|
||||
this.interceptors.delete(channel)
|
||||
}
|
||||
}, `client-connection: ${channel} rpc interceptor`)
|
||||
}
|
||||
}
|
||||
|
||||
function rpcFetchHandler(
|
||||
channel: string,
|
||||
handler: ConnectionRpcHandler,
|
||||
): FetchHandler {
|
||||
return {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const endpoint = endpointFromPath(channel, new URL(request.url).pathname)
|
||||
if (request.method !== 'POST' || endpoint === undefined) {
|
||||
return new Response('not found', { status: 404 })
|
||||
}
|
||||
|
||||
const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
|
||||
if (mediaType !== 'application/json') {
|
||||
return new Response('content type must be application/json', { status: 415 })
|
||||
}
|
||||
|
||||
let body: unknown
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return new Response('body is not JSON', { status: 400 })
|
||||
}
|
||||
|
||||
const envelope = clientRequestSchema.safeParse(body)
|
||||
if (!envelope.success) {
|
||||
return invalidEnvelopeResponse(body, envelope.error.issues)
|
||||
}
|
||||
const message: ClientRequest = envelope.data
|
||||
if (message.method !== endpoint) {
|
||||
return errorResponse(message.rpcId, {
|
||||
code: 'bad-request',
|
||||
message: `method ${JSON.stringify(message.method)} does not match endpoint ${JSON.stringify(endpoint)}`,
|
||||
details: { issues: [] },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(endpoint, message.payload, request.signal)
|
||||
return fullResponse(message.rpcId, result)
|
||||
} catch (error) {
|
||||
return new Response(`handler failure: ${String(error)}`, { status: 500 })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function invalidEnvelopeResponse(body: unknown, issues: RpcErrorDetailsMap['bad-request']['issues']): Response {
|
||||
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 },
|
||||
})
|
||||
}
|
||||
|
||||
function endpointFromPath(channel: string, pathname: string): string | undefined {
|
||||
if (!pathname.startsWith(`${channel}/`)) return undefined
|
||||
const endpoint = pathname.slice(channel.length + 1)
|
||||
const segments = endpoint.split('/')
|
||||
if (segments.some(segment =>
|
||||
segment === '' || segment === '.' || segment === '..' || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
|
||||
return undefined
|
||||
}
|
||||
return endpoint
|
||||
}
|
||||
|
||||
function errorResponse(rpcId: RpcIdType, error: RpcError): Response {
|
||||
return fullResponse(rpcId, { ok: false, error })
|
||||
}
|
||||
|
||||
function fullResponse(rpcId: RpcIdType, result: RpcServerResponse['result']): Response {
|
||||
const body: RpcServerResponse = { type: 'server-response', rpcId, result }
|
||||
return Response.json(body)
|
||||
}
|
||||
|
||||
function assertChannel(channel: string): void {
|
||||
if (!CHANNEL_PATTERN.test(channel) || channel === '/api') {
|
||||
throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`)
|
||||
}
|
||||
}
|
||||
77
packages/client/connection/src/rpc.ts
Normal file
77
packages/client/connection/src/rpc.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/** Generic unary RPC contracts shared by the Host and Client Connection halves. */
|
||||
|
||||
import type { RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
|
||||
/** Trust fence applied before a Host RPC channel reaches its handler. */
|
||||
export type ConnectionRpcAuthority = 'trusted-host' | 'loopback'
|
||||
|
||||
/** Registration policy for one logical RPC channel. */
|
||||
export interface ConnectionRpcHandlerOptions {
|
||||
/** Browser authority accepted by every endpoint in this channel. */
|
||||
readonly authority: ConnectionRpcAuthority
|
||||
}
|
||||
|
||||
/** Handler invoked after Connection has decoded the transport envelope. */
|
||||
export type ConnectionRpcHandler = (
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal: AbortSignal,
|
||||
) => Promise<RpcResult<unknown>>
|
||||
|
||||
/** Synchronous ownership test for one endpoint on a shared RPC channel. */
|
||||
export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean
|
||||
|
||||
/** Host registry for logical RPC channels carried by the current transport. */
|
||||
export interface HostConnectionRpc {
|
||||
/**
|
||||
* Register one absolute channel prefix and its trust policy.
|
||||
* @param channel - absolute logical channel such as `/rpc`.
|
||||
* @param handler - decoded endpoint handler returning the existing RPC result shape.
|
||||
* @param options - channel trust policy.
|
||||
* @returns asynchronous disposer removing the channel and its physical route.
|
||||
*/
|
||||
handle(
|
||||
channel: string,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void>
|
||||
|
||||
/**
|
||||
* Intercept owned endpoints on the shared `/api` channel before its fallback.
|
||||
* @param channel - reserved shared channel; currently `/api`.
|
||||
* @param matches - synchronous endpoint ownership test.
|
||||
* @param handler - decoded endpoint handler returning the existing RPC result shape.
|
||||
* @param options - trust policy for every endpoint claimed by this interceptor.
|
||||
* @returns asynchronous disposer removing the interceptor.
|
||||
*/
|
||||
intercept(
|
||||
channel: '/api',
|
||||
matches: ConnectionRpcEndpointMatcher,
|
||||
handler: ConnectionRpcHandler,
|
||||
options: ConnectionRpcHandlerOptions,
|
||||
): () => Promise<void>
|
||||
}
|
||||
|
||||
/** Host `ctx.connection` shape consumed by transport-independent adapters. */
|
||||
export interface HostConnectionHandle {
|
||||
/** Generic RPC channel registry. */
|
||||
readonly rpc: HostConnectionRpc
|
||||
}
|
||||
|
||||
/** Client caller for logical RPC channels carried by the current transport. */
|
||||
export interface ClientConnectionRpc {
|
||||
/**
|
||||
* Call one endpoint through an already registered logical channel.
|
||||
* @param channel - absolute logical channel such as `/api`.
|
||||
* @param endpoint - channel-relative endpoint such as `goals/create`.
|
||||
* @param payload - channel-owned request payload.
|
||||
* @param signal - optional caller cancellation.
|
||||
* @returns the existing RPC success/error result; correlation stays inside Connection.
|
||||
*/
|
||||
call(
|
||||
channel: string,
|
||||
endpoint: string,
|
||||
payload: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcResult<unknown>>
|
||||
}
|
||||
@@ -203,4 +203,119 @@ describe('connection client apply', () => {
|
||||
expect(sockets).toHaveLength(1)
|
||||
expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED)
|
||||
})
|
||||
|
||||
it('carries RPC calls without requiring secure-context randomUUID', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '' }
|
||||
vi.stubGlobal('crypto', {
|
||||
getRandomValues(bytes: Uint8Array) {
|
||||
return bytes.fill(0)
|
||||
},
|
||||
})
|
||||
const handle = await mount()
|
||||
const original = globalThis.fetch
|
||||
const seen: { url: string; body: unknown }[] = []
|
||||
globalThis.fetch = async (input: URL | RequestInfo, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url
|
||||
if (typeof init?.body !== 'string') throw new TypeError('expected a JSON string request body')
|
||||
const body = JSON.parse(init.body) as { rpcId: string }
|
||||
seen.push({ url, body })
|
||||
return Response.json({
|
||||
type: 'server-response',
|
||||
rpcId: body.rpcId,
|
||||
result: { ok: true, value: { ref: 'goal-1' } },
|
||||
})
|
||||
}
|
||||
try {
|
||||
await expect(handle.rpc.call('/api', 'goals/create', { args: { agentId: 'agent-1' } }))
|
||||
.resolves.toEqual({ ok: true, value: { ref: 'goal-1' } })
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]?.url).toBe('http://dsh.internal/api/goals/create')
|
||||
expect(seen[0]?.body).toMatchObject({
|
||||
type: 'client-request',
|
||||
rpcId: '00000000-0000-4000-8000-000000000000',
|
||||
method: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('validates generic RPC transport failures, correlation, and targets', async () => {
|
||||
;(globalThis as Win).location = {
|
||||
hostname: 'harness.example', search: '', origin: 'https://harness.example',
|
||||
}
|
||||
const handle = await mount()
|
||||
const original = globalThis.fetch
|
||||
const abort = new AbortController()
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(new Response('unavailable', { status: 503 }))
|
||||
try {
|
||||
await expect(handle.rpc.call('/api', 'goals/create', {}, abort.signal))
|
||||
.rejects.toThrow('HTTP 503')
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
new URL('https://harness.example/api/goals/create'),
|
||||
expect.objectContaining({ signal: abort.signal }),
|
||||
)
|
||||
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '', origin: 'null' }
|
||||
globalThis.fetch = vi.fn().mockResolvedValue(Response.json({
|
||||
type: 'server-response',
|
||||
rpcId: 'different-rpc',
|
||||
result: { ok: true, value: null },
|
||||
}))
|
||||
await expect(handle.rpc.call('/api', 'goals/create', {})).rejects.toThrow('rpcId mismatch')
|
||||
const fetch = vi.mocked(globalThis.fetch)
|
||||
expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create'))
|
||||
expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal')
|
||||
} finally {
|
||||
globalThis.fetch = original
|
||||
}
|
||||
|
||||
for (const [channel, endpoint] of [
|
||||
['api2', 'goals/create'],
|
||||
['/api/path', 'goals/create'],
|
||||
['/api', ''],
|
||||
['/api', '.'],
|
||||
['/api', '..'],
|
||||
['/api', 'goals//create'],
|
||||
['/api', 'goals/create?unsafe'],
|
||||
] as const) {
|
||||
await expect(handle.rpc.call(channel, endpoint, {})).rejects.toThrow('invalid RPC target')
|
||||
}
|
||||
})
|
||||
|
||||
it('carries Goal Remotes over the same state as the client-only fixture API', async () => {
|
||||
;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' }
|
||||
const handle = await mount()
|
||||
const created = await handle.rpc.call('/api', 'goals/create', {
|
||||
args: { agentId: 'fx-alpha', request: { objective: 'fixture remote' } },
|
||||
})
|
||||
expect(created).toMatchObject({ ok: true, value: { ref: { revision: 1 } } })
|
||||
if (!created.ok) throw new Error('fixture Goal create failed')
|
||||
const ref = (created.value as { ref: { id: string; revision: number } }).ref
|
||||
const edited = await handle.rpc.call('/api', 'goals/edit', {
|
||||
args: { agentId: 'fx-alpha', ref, request: { objective: 'edited fixture remote' } },
|
||||
})
|
||||
expect(edited).toMatchObject({ ok: true, value: { objective: 'edited fixture remote', revision: 2 } })
|
||||
const editedRef = { id: ref.id, revision: 2 }
|
||||
const paused = await handle.rpc.call('/api', 'goals/pause', {
|
||||
args: { agentId: 'fx-alpha', ref: editedRef },
|
||||
})
|
||||
expect(paused).toMatchObject({ ok: true, value: { phase: 'paused', activation: 'disarmed', revision: 3 } })
|
||||
const resumed = await handle.rpc.call('/api', 'goals/resume', {
|
||||
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 3 } },
|
||||
})
|
||||
expect(resumed).toMatchObject({ ok: true, value: { phase: 'active', activation: 'armed', revision: 4 } })
|
||||
const completed = await handle.rpc.call('/api', 'goals/complete', {
|
||||
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 4 } },
|
||||
})
|
||||
expect(completed).toMatchObject({ ok: true, value: { phase: 'complete', activation: 'disarmed', revision: 5 } })
|
||||
await expect(handle.rpc.call('/api', 'goals/clear', {
|
||||
args: { agentId: 'fx-alpha', ref: { id: ref.id, revision: 5 } },
|
||||
})).resolves.toEqual({ ok: true, value: { id: ref.id, revision: 6 } })
|
||||
await expect(handle.rpc.call('/other', 'goals/create', {})).rejects.toThrow(/channel.*unavailable/)
|
||||
await expect(handle.rpc.call('/api', 'unknown/read', { args: { agentId: 'fx-alpha' } }))
|
||||
.rejects.toThrow(/endpoint.*unavailable/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost.
|
||||
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
|
||||
import type {
|
||||
CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
|
||||
CommandDescriptor, HostFrame, IApiClient, ModelSelection, MuxFrame,
|
||||
RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
|
||||
} from '../src/client/api.ts'
|
||||
import { RpcId } from '../src/client/api.ts'
|
||||
@@ -50,20 +50,21 @@ export class FakeApiClient implements IApiClient {
|
||||
onRename: (payload: unknown) => Promise<RpcResponse<{ title: string; seq: number }>> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
|
||||
onFork: (payload: unknown) => Promise<RpcResponse<{ sessionId: SessionId }>> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
|
||||
onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number })
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelTarget: ModelTarget }>> =
|
||||
=> Promise<RpcResponse<{ events: never[]; hasMore: boolean; modelSelection: ModelSelection }>> =
|
||||
() => Promise.resolve(ok({
|
||||
events: [],
|
||||
hasMore: false,
|
||||
modelTarget: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
}))
|
||||
|
||||
onModels: (payload: unknown) => Promise<RpcResponse<SessionModels>> = () => Promise.resolve(ok({
|
||||
current: { provider: 'deepseek-official', model: 'deepseek-chat' },
|
||||
routable: true,
|
||||
groups: [],
|
||||
failures: [],
|
||||
}))
|
||||
onSelectModel: (payload: ModelTarget & { sessionId: SessionId })
|
||||
=> Promise<RpcResponse<{ selected: ModelTarget }>> =
|
||||
onSelectModel: (payload: ModelSelection & { sessionId: SessionId })
|
||||
=> Promise<RpcResponse<{ selected: ModelSelection }>> =
|
||||
payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } }))
|
||||
onPrompt: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
onUpdateQueue: (payload: unknown) => Promise<RpcResponse<{ accepted: true }>> = () => Promise.resolve(ok({ accepted: true as const }))
|
||||
@@ -104,7 +105,7 @@ export class FakeApiClient implements IApiClient {
|
||||
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
|
||||
this.record('session.history', payload, this.onHistory(payload)),
|
||||
models: (payload: unknown) => this.record('session.models', payload, this.onModels(payload)),
|
||||
selectModel: (payload: ModelTarget & { sessionId: SessionId }) =>
|
||||
selectModel: (payload: ModelSelection & { sessionId: SessionId }) =>
|
||||
this.record('session.selectModel', payload, this.onSelectModel(payload)),
|
||||
rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)),
|
||||
fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)),
|
||||
@@ -125,6 +126,9 @@ export class FakeApiClient implements IApiClient {
|
||||
prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({
|
||||
messageId: 'fake-message' as never,
|
||||
}))),
|
||||
interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, Promise.resolve(ok({
|
||||
accepted: true as const,
|
||||
}))),
|
||||
}
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
@@ -162,11 +166,28 @@ export class FakeApiClient implements IApiClient {
|
||||
onSkillList: (payload: unknown) => Promise<RpcResponse<{ skills: SkillEntry[] }>>
|
||||
= () => Promise.resolve(ok({ skills: [] }))
|
||||
|
||||
|
||||
readonly commands: IApiClient['commands'] = {
|
||||
list: (payload: unknown) => this.record('command.list', payload, this.onCommandList(payload)),
|
||||
execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
|
||||
}
|
||||
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))),
|
||||
select: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
read: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.read', payload, Promise.resolve(ok({
|
||||
agentPreset: payload.agentPreset, trust: 'user' as const, content: '',
|
||||
}))),
|
||||
copy: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
openDocument: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))),
|
||||
remove: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.remove', payload, Promise.resolve(ok({}))),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
|
||||
}
|
||||
|
||||
@@ -168,7 +168,7 @@ describe('createFixtureApi', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('serves grouped models and keeps a selected target for later history and fixture requests', async () => {
|
||||
it('serves grouped models and keeps a selection for later history and fixture requests', async () => {
|
||||
const api = createFixtureApi()
|
||||
const sessionId = sid('fx-alpha')
|
||||
const catalog = await api.sessions.models(req({ sessionId }))
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('HTTP bridge abort', () => {
|
||||
let carrierSignal: AbortSignal | undefined
|
||||
const pending = bridge(request, response, {
|
||||
fetch: async (input) => {
|
||||
const fetchRequest = input as Request
|
||||
const fetchRequest = input
|
||||
carrierSignal = fetchRequest.signal
|
||||
resolveStarted()
|
||||
if (!fetchRequest.signal.aborted) {
|
||||
|
||||
@@ -7,8 +7,9 @@ import { describe, expect, it } from 'vitest'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH } from '../src/index.ts'
|
||||
import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts'
|
||||
|
||||
/** Structural httpServer fake recording both route registries. */
|
||||
function fakeHttpServer(
|
||||
@@ -17,6 +18,9 @@ function fakeHttpServer(
|
||||
): Pick<HttpServerService, 'register' | 'registerUpgrade' | 'tapIndex' | 'port'> {
|
||||
return {
|
||||
register(route) {
|
||||
if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
|
||||
throw new Error(`duplicate route ${route.path}`)
|
||||
}
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
@@ -36,15 +40,32 @@ function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session
|
||||
return request
|
||||
}
|
||||
|
||||
/** JSON POST carrying a complete client-request envelope. */
|
||||
function fakePost(headers: Record<string, string>, url: string, body: unknown): IncomingMessage {
|
||||
const request = Readable.from([Buffer.from(JSON.stringify(body))]) as unknown as IncomingMessage
|
||||
Object.assign(request, { url, method: 'POST', headers: { 'content-type': 'application/json', ...headers } })
|
||||
return request
|
||||
}
|
||||
|
||||
/** Raw POST for malformed-body and media-type boundary cases. */
|
||||
function fakeRawPost(headers: Record<string, string>, url: string, body: string): IncomingMessage {
|
||||
const request = Readable.from([Buffer.from(body)]) as unknown as IncomingMessage
|
||||
Object.assign(request, { url, method: 'POST', headers })
|
||||
return request
|
||||
}
|
||||
|
||||
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
|
||||
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
|
||||
const state: { status?: number; body?: unknown } = {}
|
||||
const chunks: Buffer[] = []
|
||||
const response = Object.assign(new EventEmitter(), {
|
||||
writableEnded: false,
|
||||
writeHead(value: number) { state.status = value; return this },
|
||||
write() { return true },
|
||||
write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true },
|
||||
end(this: { writableEnded: boolean }, value?: unknown) {
|
||||
if (value !== undefined) state.body = value
|
||||
if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value))
|
||||
else if (value !== undefined) throw new TypeError('fake response only accepts string or Uint8Array bodies')
|
||||
if (chunks.length > 0) state.body = Buffer.concat(chunks).toString()
|
||||
this.writableEnded = true
|
||||
return this
|
||||
},
|
||||
@@ -138,6 +159,10 @@ describe('connection node half', () => {
|
||||
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
|
||||
'credentials.describe', 'credentials.set', 'credentials.unset',
|
||||
'llm.discoverModels',
|
||||
// A composition names the plugins a session runs: reading one is
|
||||
// reconnaissance, and copy/remove/openDocument manage the roster and
|
||||
// drive the host desktop.
|
||||
'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove',
|
||||
]) {
|
||||
const denied = fakeResponse()
|
||||
await routes[0]!.handler(
|
||||
@@ -173,6 +198,211 @@ describe('connection node half', () => {
|
||||
expect(declared.state.status).toBe(404)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
|
||||
|
||||
const connection = ctx.get('connection') as HostConnectionHandle
|
||||
const calls: unknown[] = []
|
||||
const remove = connection.rpc.handle('/rpc', async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload })
|
||||
return { ok: true, value: { accepted: true } }
|
||||
}, { authority: 'trusted-host' })
|
||||
const route = routes.find(candidate => candidate.path === '/rpc')
|
||||
expect(route).toBeDefined()
|
||||
|
||||
const request: ClientRequest = {
|
||||
type: 'client-request',
|
||||
rpcId: RpcId('rpc-dedicated'),
|
||||
method: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}
|
||||
const result = fakeResponse()
|
||||
await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/rpc/goals/create', request), result.response)
|
||||
expect(result.state.status).toBe(200)
|
||||
expect(JSON.parse(String(result.state.body))).toEqual({
|
||||
type: 'server-response',
|
||||
rpcId: 'rpc-dedicated',
|
||||
result: { ok: true, value: { accepted: true } },
|
||||
})
|
||||
expect(calls).toEqual([{
|
||||
endpoint: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}])
|
||||
|
||||
expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null }), {
|
||||
authority: 'trusted-host',
|
||||
})).toThrow(/duplicate route/)
|
||||
await remove()
|
||||
expect(routes.map(candidate => candidate.path)).toEqual([API_PATH])
|
||||
await fiber.dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
|
||||
await fiber.await()
|
||||
const connection = ctx.get('connection') as HostConnectionHandle
|
||||
const calls: unknown[] = []
|
||||
const remove = connection.rpc.intercept(
|
||||
'/api',
|
||||
endpoint => endpoint === 'goals/create',
|
||||
async (endpoint, payload) => {
|
||||
calls.push({ endpoint, payload })
|
||||
return { ok: true, value: { accepted: true } }
|
||||
},
|
||||
{ authority: 'trusted-host' },
|
||||
)
|
||||
expect(() => connection.rpc.intercept(
|
||||
'/api',
|
||||
() => true,
|
||||
async () => ({ ok: true, value: null }),
|
||||
{ authority: 'trusted-host' },
|
||||
)).toThrow('already has an interceptor')
|
||||
expect(() => connection.rpc.intercept(
|
||||
'/rpc' as '/api',
|
||||
() => true,
|
||||
async () => ({ ok: true, value: null }),
|
||||
{ authority: 'trusted-host' },
|
||||
)).toThrow('invalid shared RPC channel')
|
||||
const route = routes.find(candidate => candidate.path === API_PATH)!
|
||||
const request: ClientRequest = {
|
||||
type: 'client-request',
|
||||
rpcId: RpcId('rpc-shared'),
|
||||
method: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}
|
||||
|
||||
const claimed = fakeResponse()
|
||||
await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), claimed.response)
|
||||
expect(JSON.parse(String(claimed.state.body))).toEqual({
|
||||
type: 'server-response',
|
||||
rpcId: 'rpc-shared',
|
||||
result: { ok: true, value: { accepted: true } },
|
||||
})
|
||||
expect(calls).toEqual([{
|
||||
endpoint: 'goals/create',
|
||||
payload: { args: { agentId: 'agent-1' } },
|
||||
}])
|
||||
|
||||
const denied = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'other.example' }, '/api/goals/create', request), denied.response)
|
||||
expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
const unclaimed = fakeResponse()
|
||||
await route.handler(fakeRequest({ host: '127.0.0.1:3080' }, '/api/session.list'), unclaimed.response)
|
||||
expect(unclaimed.state.status).toBe(404)
|
||||
|
||||
await remove()
|
||||
const withdrawn = fakeResponse()
|
||||
await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), withdrawn.response)
|
||||
expect(withdrawn.state.status).toBe(404)
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
const removeLoopback = connection.rpc.intercept(
|
||||
'/api',
|
||||
endpoint => endpoint === 'goals/create',
|
||||
async () => ({ ok: true, value: null }),
|
||||
{ authority: 'loopback' },
|
||||
)
|
||||
const loopbackOnly = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'harness.example' }, '/api/goals/create', request), loopbackOnly.response)
|
||||
expect(loopbackOnly.state.status).toBe(403)
|
||||
await removeLoopback()
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('applies the configured trust fence and JSON envelope checks to generic channels', async () => {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService)
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] })
|
||||
await fiber.await()
|
||||
const connection = ctx.get('connection') as HostConnectionHandle
|
||||
const remove = connection.rpc.handle('/rpc', async (endpoint) => {
|
||||
if (endpoint === 'fail') throw new Error('handler broke')
|
||||
return { ok: true, value: null }
|
||||
}, {
|
||||
authority: 'trusted-host',
|
||||
})
|
||||
const route = routes.find(candidate => candidate.path === '/rpc')!
|
||||
|
||||
const denied = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'other.example' }, '/rpc/goals/create', {}), denied.response)
|
||||
expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' })
|
||||
|
||||
const methodMismatch = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', {
|
||||
type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {},
|
||||
}), methodMismatch.response)
|
||||
expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({
|
||||
rpcId: 'rpc-bad',
|
||||
result: { ok: false, error: { code: 'bad-request' } },
|
||||
})
|
||||
|
||||
for (const [request, status] of [
|
||||
[fakeRequest({ host: 'harness.example' }, '/rpc/goals/create'), 404],
|
||||
[fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404],
|
||||
[fakePost({ host: 'harness.example' }, '/rpc/goals//create', {}), 404],
|
||||
[fakeRawPost({ host: 'harness.example' }, '/rpc/goals/create', '{}'), 415],
|
||||
[fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415],
|
||||
[fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400],
|
||||
] as const) {
|
||||
const response = fakeResponse()
|
||||
await route.handler(request, response.response)
|
||||
expect(response.state.status).toBe(status)
|
||||
}
|
||||
|
||||
for (const [body, rpcId] of [
|
||||
[{ rpcId: 'retained-id' }, 'retained-id'],
|
||||
[{ rpcId: 42 }, 'invalid-request'],
|
||||
[null, 'invalid-request'],
|
||||
] as const) {
|
||||
const response = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', body), response.response)
|
||||
expect(JSON.parse(String(response.state.body))).toMatchObject({
|
||||
rpcId,
|
||||
result: { ok: false, error: { code: 'bad-request' } },
|
||||
})
|
||||
}
|
||||
|
||||
const failed = fakeResponse()
|
||||
await route.handler(fakePost({ host: 'harness.example' }, '/rpc/fail', {
|
||||
type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {},
|
||||
}), failed.response)
|
||||
expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' })
|
||||
|
||||
expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null }), {
|
||||
authority: 'loopback',
|
||||
})).toThrow('invalid or reserved RPC channel')
|
||||
expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null }), {
|
||||
authority: 'loopback',
|
||||
})).toThrow('invalid or reserved RPC channel')
|
||||
|
||||
const removeLoopback = connection.rpc.handle('/loopback', async () => ({ ok: true, value: null }), {
|
||||
authority: 'loopback',
|
||||
})
|
||||
const loopbackRoute = routes.find(candidate => candidate.path === '/loopback')!
|
||||
const publicResponse = fakeResponse()
|
||||
await loopbackRoute.handler(fakePost({ host: 'harness.example' }, '/loopback/read', {
|
||||
type: 'client-request', rpcId: 'rpc-public', method: 'read', payload: {},
|
||||
}), publicResponse.response)
|
||||
expect(publicResponse.state.status).toBe(403)
|
||||
await removeLoopback()
|
||||
await remove()
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('connection node half over a real HTTP server', () => {
|
||||
@@ -226,13 +456,19 @@ describe('connection node half over a real HTTP server', () => {
|
||||
// Carries a draft credential and turns the host into a fetcher for a
|
||||
// URL the caller picked: an anonymous LAN caller must not reach it.
|
||||
'llm.discoverModels',
|
||||
'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove',
|
||||
]) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
|
||||
}
|
||||
// The model catalog stays reachable for the same authority: a LAN
|
||||
// client's model picker needs it, and it carries no key or endpoint
|
||||
// state (404 is the empty proxy's carrier answer — the fence passed).
|
||||
for (const method of ['llm.providers', 'llm.models']) {
|
||||
// `agentPreset.list` joins the model catalog for the same reason: ids and
|
||||
// trust only, and a LAN client's preset picker needs it. `select` is
|
||||
// reachable too: `session.create` already takes an `agentPreset`, and the
|
||||
// deployment's own default already carries bash, so pinning the switch
|
||||
// would be a fence beside an open gate.
|
||||
for (const method of ['llm.providers', 'llm.models', 'agentPreset.list', 'agentPreset.select']) {
|
||||
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404])
|
||||
}
|
||||
// Loopback reaches everything, configuration included.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
@@ -28,10 +28,10 @@
|
||||
"path": "../../host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-approval"
|
||||
"path": "../../interaction/user-approval"
|
||||
},
|
||||
{
|
||||
"path": "../../ui/user-interaction"
|
||||
"path": "../../interaction/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
|
||||
Reference in New Issue
Block a user