feat(web): open a produced file from the conversation
Serve one file at a time out of a Session's workspace under /f on the web transport, and point the conversation's existing file-open affordance at it. Clicking a write/edit/read row's path now opens that file in a browser tab — including from a LAN client, where the Host's system opener is fenced to loopback and answered nothing. - /f/<sessionId>/<segments> in client-connection, behind the same browser-trust fence as /api; realpath confinement, streamed reads, GET/HEAD only, nosniff + no-store. - Script-capable documents carry CSP sandbox: model-authored markup must not be same-origin with /api, where events.mux is a readable GET stream. - ApiProxy.workspaceRootOf answers where a Session's files live without resuming an agent; the client program cannot reach the core services. - The /f URL shape lives in dsh-host-apiproxy/api so both ends share one encoding (client bundles may not value-import another plugin).
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: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d
|
||||
README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45
|
||||
README.md: 9a08cb4de5531b044bd411ea08595c22f88e5f8a
|
||||
README.zh.md: cfc427945f42b61288f57f5ca1db9af74dbcfb31
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration 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 subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
|
||||
Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half owns both browser-facing prefixes — `/api` for RPC and `/f` for workspace-file reads — behind one trust fence. The `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration 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 subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3.
|
||||
|
||||
## /api browser-trust fence
|
||||
|
||||
The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md).
|
||||
|
||||
## /f workspace-file reads
|
||||
|
||||
The node half also serves one file at a time out of a Session's workspace under `/f/<sessionId>/<segments…>`, so a produced deliverable is reachable from the page that reported it — an `http` page cannot follow a `file://` link, and a browser that is not on the Host machine has no such path anyway. The segments ride the URL rather than a query parameter so a served document's relative references resolve to its siblings. The request names a Session and the gateway names that Session's directory (`ApiProxy.workspaceRootOf`, which answers from a live agent's header or the persistence store and never resumes an agent to serve a file); this package reads the authority rather than the core services, because holding their host-side Context declarations would merge them over the browser runtime's own. The URL shape itself lives with the other browser-importable contract surfaces, in [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md), so the browser half that builds a URL and this half that parses one share a single encoding decision. Both the cwd and the resolved target go through `realpath` before comparison, so a symlink inside the workspace pointing out of it is refused by its target rather than its name; traversal spellings are refused earlier still, at parse time, before any filesystem call. Reads stream (no request buffers a file), answer `GET`/`HEAD` only, and carry `nosniff` with `no-store`. Extensions outside the served content-type table are typed `text/plain` rather than offered as a download, because a workspace read is a request to see a file.
|
||||
|
||||
Documents that can execute script — `.html`, `.htm`, `.xhtml`, `.svg` — additionally carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`. Model-authored markup is served from the same origin as `/api`, where `/api/events.mux` is a readable `GET` stream, so an opaque origin is what keeps a generated page from reading the session event stream one `window.open` away. The cost is borne by the preview: `localStorage`, cookies, and same-origin `fetch` are unavailable inside it, and `host.openPath` remains the full-capability way to open the same file on the Host machine. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads.
|
||||
|
||||
## Keyless fixture
|
||||
|
||||
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
|
||||
协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧持有两条面向浏览器的前缀——`/api` 承载 RPC,`/f` 承载工作区文件读取——共用同一道信任 fence。`/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。
|
||||
|
||||
## /api 浏览器信任栅栏
|
||||
|
||||
node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。
|
||||
|
||||
## /f 工作区文件读取
|
||||
|
||||
node 半侧还会在 `/f/<sessionId>/<segments…>` 下逐个提供某个 Session 工作区里的文件,让产出的交付物能从报告它的那个页面直接抵达——`http` 页面无法跟随 `file://` 链接,而不在 Host 机器上的浏览器本来也没有那条路径。段落走 URL 而非查询参数,是为了让所服务文档的相对引用能解析到它的同级文件。请求指名一个 Session,由网关指名该 Session 的目录(`ApiProxy.workspaceRootOf`,它从活跃 agent 的 header 或持久化存储作答,绝不会为了提供一个文件而恢复 agent);本包读取这个权威来源而不去够核心服务,因为持有它们的 host 侧 Context 声明会把它们盖到浏览器运行时自己的声明之上。URL 形状本身与其余浏览器可导入的契约面放在一起,位于 [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md),因此构造 URL 的浏览器半侧与解析 URL 的这一半共享同一个编码决定。cwd 与解析出的目标在比较前都要过 `realpath`,因此工作区内指向工作区外的符号链接会因其目标而被拒绝,而不是因其名字;穿越写法拒得更早,在解析期、任何文件系统调用之前。读取是流式的(没有请求会把文件缓冲起来),只应答 `GET`/`HEAD`,并带上 `nosniff` 与 `no-store`。所服务的内容类型表之外的扩展名一律按 `text/plain` 定型而非作为下载给出,因为工作区读取本就是一个“让我看看这个文件”的请求。
|
||||
|
||||
能执行脚本的文档——`.html`、`.htm`、`.xhtml`、`.svg`——还会额外带上 `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`。模型撰写的标记与 `/api` 同源提供,而 `/api/events.mux` 是一条可读的 `GET` 流,因此正是不透明源阻止了一个生成页面通过一次 `window.open` 读走会话事件流。代价由预览承担:其中无法使用 `localStorage`、cookie 与同源 `fetch`,而 `host.openPath` 仍是在 Host 机器上以完整能力打开同一文件的方式。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。
|
||||
|
||||
## 无密钥 fixture
|
||||
|
||||
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token/短语行为,并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。
|
||||
|
||||
@@ -2362,6 +2362,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
|
||||
})
|
||||
return Promise.resolve({ accepted: true })
|
||||
},
|
||||
|
||||
// The fixture has no filesystem behind its Sessions, so it names no
|
||||
// directory for any of them; the /f route belongs to the node half, which
|
||||
// a fixture page never reaches.
|
||||
workspaceRootOf: () => Promise.resolve(undefined),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
/** Host HTTP bridge for browser-client RPC. */
|
||||
/** Host HTTP bridge for browser-client RPC and workspace-file reads. */
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
// Activates the httpServer Context merge used below.
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
// The merge-free types subpath: pulling the session package's root into this
|
||||
// client-registered program would merge the host `sessions` service over the
|
||||
// browser runtime's own.
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { API_PATH } from './api-path.ts'
|
||||
import { bridge } from './http-bridge.ts'
|
||||
import { handleWorkspaceFile } from './workspace-files.ts'
|
||||
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
|
||||
|
||||
export { API_PATH } from './api-path.ts'
|
||||
@@ -13,7 +19,7 @@ export { API_PATH } from './api-path.ts'
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'client-connection'
|
||||
|
||||
/** Services required before mounting the route. */
|
||||
/** Services required before mounting the routes. */
|
||||
export const inject = ['httpServer', 'apiProxy']
|
||||
|
||||
/** Plugin config: the deployment's non-loopback serving authorities. */
|
||||
@@ -61,11 +67,11 @@ const PRIVILEGED_METHODS = new Set([
|
||||
])
|
||||
|
||||
/**
|
||||
* Mounts the API gateway under the browser transport prefix. Every request on
|
||||
* the prefix passes the browser-trust fence first (DNS-rebinding and
|
||||
* cross-site defense — [api-request-trust](./api-request-trust.ts));
|
||||
* privileged methods additionally pass it with an empty trust list, which
|
||||
* pins them to loopback.
|
||||
* Mounts the API gateway and the workspace-file reads under the browser
|
||||
* transport prefixes. Every request on either prefix passes the browser-trust
|
||||
* fence first (DNS-rebinding and cross-site defense —
|
||||
* [api-request-trust](./api-request-trust.ts)); privileged methods
|
||||
* additionally pass it with an empty trust list, which pins them to loopback.
|
||||
* @param ctx - Host plugin context.
|
||||
* @param config - resolved plugin config (schema defaults applied).
|
||||
*/
|
||||
@@ -96,4 +102,28 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
},
|
||||
}
|
||||
ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route')
|
||||
|
||||
// The gateway is the host's session authority: it answers where a Session's
|
||||
// files live without this package reaching into the core services, which
|
||||
// would merge their host-side Context declarations into the browser lane.
|
||||
const cwdFor = (sessionId: string): Promise<string | undefined> =>
|
||||
ctx.apiProxy.workspaceRootOf(sessionId as SessionId)
|
||||
const filesRoute: WebRoute = {
|
||||
kind: 'prefix',
|
||||
path: FILES_PATH,
|
||||
handler: async (req, res) => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
await handleWorkspaceFile(req, res, { cwdFor })
|
||||
},
|
||||
}
|
||||
ctx.effect(() => ctx.httpServer.register(filesRoute), 'client-connection: /f route')
|
||||
}
|
||||
|
||||
169
packages/client/connection/src/workspace-files.ts
Normal file
169
packages/client/connection/src/workspace-files.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* The read half of the web transport: streams one file out of a session's
|
||||
* workspace so the browser can open what the agent just produced. The RPC
|
||||
* gateway carries structured session state; this route carries bytes, which a
|
||||
* JSON-RPC envelope cannot stream and a `file://` link cannot reach from an
|
||||
* http page.
|
||||
*
|
||||
* Confinement is the whole contract: a request names a session, the session
|
||||
* names its cwd, and nothing outside that realpath is ever served. The caller
|
||||
* owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) —
|
||||
* this module is reached only by requests that already passed it.
|
||||
*/
|
||||
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { realpath, stat } from 'node:fs/promises'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { extname, resolve, sep } from 'node:path'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { parseWorkspaceFilePath } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
|
||||
/**
|
||||
* Content types served verbatim. Everything absent is `text/plain`, not
|
||||
* `application/octet-stream`: a workspace read is a "show me what you made"
|
||||
* gesture, and an unknown extension is far more often a source file to read
|
||||
* than a binary to download. `nosniff` keeps that choice binding, so a
|
||||
* mislabelled document can never be re-interpreted as HTML.
|
||||
*/
|
||||
const MIME: Record<string, string> = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.htm': 'text/html; charset=utf-8',
|
||||
'.xhtml': 'application/xhtml+xml',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json',
|
||||
'.pdf': 'application/pdf',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.avif': 'image/avif',
|
||||
'.ico': 'image/x-icon',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.wasm': 'application/wasm',
|
||||
}
|
||||
|
||||
const DEFAULT_MIME = 'text/plain; charset=utf-8'
|
||||
|
||||
/** Extensions whose top-level navigation can execute script, and so need the sandbox. */
|
||||
const SCRIPTABLE = new Set(['.html', '.htm', '.xhtml', '.svg'])
|
||||
|
||||
/**
|
||||
* Model-authored documents run in an opaque origin. Without it a generated page
|
||||
* is same-origin with the RPC gateway, where `/api/events.mux` is a readable
|
||||
* GET stream — one `window.open` away from every session's events. The cost is
|
||||
* that `localStorage`, cookies, and same-origin `fetch` are unavailable inside
|
||||
* a preview; the native-open path (`host.openPath`) remains the full-capability
|
||||
* way to view a file.
|
||||
*/
|
||||
const SANDBOX_CSP = 'sandbox allow-scripts allow-popups allow-modals allow-forms'
|
||||
|
||||
/** How the route learns which directory a session may serve from. */
|
||||
export interface WorkspaceFileDeps {
|
||||
/**
|
||||
* The session's absolute working directory.
|
||||
* @param sessionId - the session named by the request path.
|
||||
* @returns its cwd, or `undefined` when the id names no session this host serves.
|
||||
*/
|
||||
cwdFor: (sessionId: string) => Promise<string | undefined>
|
||||
}
|
||||
|
||||
function fail(res: ServerResponse, status: number): void {
|
||||
res.writeHead(status)
|
||||
res.end()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one request's segments against a session cwd, refusing anything that
|
||||
* leaves it. Both sides go through `realpath`, so a symlink inside the
|
||||
* workspace pointing out of it is refused by its resolved target rather than
|
||||
* its name. A component swapped between this resolution and the open below
|
||||
* would still be followed; closing that window needs privileges that already
|
||||
* imply workspace write access, which is strictly stronger than reading a
|
||||
* workspace file, so the check stops here.
|
||||
*/
|
||||
async function confine(cwd: string, segments: readonly string[]): Promise<string | undefined> {
|
||||
const root = await realpath(cwd)
|
||||
const real = await realpath(resolve(root, ...segments))
|
||||
return real.startsWith(root + sep) ? real : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve one workspace-file request. The caller has already applied the
|
||||
* browser-trust fence and rejected non-read methods.
|
||||
* @param req - the request, read for its url and method only (no body).
|
||||
* @param res - the response this function owns to completion.
|
||||
* @param deps - the session-to-cwd lookup this host answers with.
|
||||
*/
|
||||
export async function handleWorkspaceFile(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
deps: WorkspaceFileDeps,
|
||||
): Promise<void> {
|
||||
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
const target = parseWorkspaceFilePath(pathname)
|
||||
if (target === undefined) {
|
||||
fail(res, 404)
|
||||
return
|
||||
}
|
||||
const cwd = await deps.cwdFor(target.sessionId)
|
||||
if (cwd === undefined) {
|
||||
fail(res, 404)
|
||||
return
|
||||
}
|
||||
|
||||
let file: string | undefined
|
||||
let size: number
|
||||
try {
|
||||
file = await confine(cwd, target.segments)
|
||||
if (file === undefined) {
|
||||
fail(res, 403)
|
||||
return
|
||||
}
|
||||
const info = await stat(file)
|
||||
// A directory read has no answer here: the route serves files, and listing
|
||||
// is the directory-picker capability's job, behind its own fence.
|
||||
if (!info.isFile()) {
|
||||
fail(res, 404)
|
||||
return
|
||||
}
|
||||
size = info.size
|
||||
} catch {
|
||||
// Missing, unreadable, or a path whose ancestor is not a directory: all
|
||||
// report as absent, so a probe cannot distinguish them.
|
||||
fail(res, 404)
|
||||
return
|
||||
}
|
||||
|
||||
const ext = extname(file).toLowerCase()
|
||||
res.writeHead(200, {
|
||||
'content-type': MIME[ext] ?? DEFAULT_MIME,
|
||||
'content-length': String(size),
|
||||
'content-disposition': 'inline',
|
||||
'x-content-type-options': 'nosniff',
|
||||
// Workspace files change under the agent's hands; a cached preview would
|
||||
// show the previous turn's output after the next edit.
|
||||
'cache-control': 'no-store',
|
||||
...SCRIPTABLE.has(ext) ? { 'content-security-policy': SANDBOX_CSP } : {},
|
||||
})
|
||||
if (req.method === 'HEAD') {
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
try {
|
||||
// pipeline (not pipe) so a client disconnect destroys the read stream:
|
||||
// an abandoned preview must not leave a descriptor open.
|
||||
await pipeline(createReadStream(file), res)
|
||||
} catch {
|
||||
// The status line is already out, so a mid-stream read failure or client
|
||||
// disconnect can only end the response abruptly.
|
||||
res.destroy()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
/** Node half: registers the /api prefix route bridging to the api gateway. */
|
||||
/** Node half: registers the /api and /f prefix routes over the api gateway and the session workspaces. */
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createServer, request as httpRequest } from 'node:http'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -8,6 +11,7 @@ import type { AddressInfo } from 'node:net'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { API_PATH, apply, inject } from '../src/index.ts'
|
||||
|
||||
/** Structural httpServer fake: the plugin only touches register(). */
|
||||
@@ -45,31 +49,45 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b
|
||||
return { response, state }
|
||||
}
|
||||
|
||||
async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
|
||||
/** The gateway stub: only the session-directory authority the /f route reads. */
|
||||
function fakeApiProxy(workspaces: Record<string, string> = {}): ApiProxy {
|
||||
return { workspaceRootOf: async (id: string) => workspaces[id] } as unknown as ApiProxy
|
||||
}
|
||||
|
||||
async function mounted(
|
||||
config?: { trustedHosts?: string[] },
|
||||
workspaces: Record<string, string> = {},
|
||||
): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
ctx.provide('apiProxy', fakeApiProxy(workspaces))
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
|
||||
await fiber.await()
|
||||
return { routes, dispose: () => fiber.dispose() }
|
||||
}
|
||||
|
||||
/** The /f route is registered after /api; both are prefix routes on the same server. */
|
||||
function filesRoute(routes: WebRoute[]): WebRoute {
|
||||
const route = routes.find(candidate => candidate.path === FILES_PATH)
|
||||
if (route === undefined) throw new Error('the /f route was not registered')
|
||||
return route
|
||||
}
|
||||
|
||||
describe('connection node half', () => {
|
||||
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
|
||||
const routes: WebRoute[] = []
|
||||
const ctx = new Context()
|
||||
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
|
||||
ctx.provide('apiProxy', {} as unknown as ApiProxy)
|
||||
ctx.provide('apiProxy', fakeApiProxy())
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
|
||||
await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/)
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('registers the /api prefix route and removes it with the fiber', async () => {
|
||||
it('registers both transport prefix routes and removes them with the fiber', async () => {
|
||||
const { routes, dispose } = await mounted()
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
|
||||
expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }, { kind: 'prefix', path: FILES_PATH }])
|
||||
await dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
@@ -132,6 +150,52 @@ describe('connection node half', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('connection node half: the /f workspace-file route', () => {
|
||||
/** A workspace holding one file, torn down with the returned disposer. */
|
||||
async function workspace(): Promise<{ cwd: string; remove: () => Promise<void> }> {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'dsh-node-half-'))
|
||||
await writeFile(join(cwd, 'index.html'), '<h1>ok</h1>')
|
||||
return { cwd, remove: () => rm(cwd, { recursive: true, force: true }) }
|
||||
}
|
||||
|
||||
/** HEAD keeps the assertion on the route's decision, not on the byte stream. */
|
||||
function head(url: string, headers: Record<string, string> = { host: '127.0.0.1:3080' }): IncomingMessage {
|
||||
const request = fakeRequest(headers, url)
|
||||
Object.assign(request, { method: 'HEAD' })
|
||||
return request
|
||||
}
|
||||
|
||||
it('applies the same browser-trust fence as /api, and refuses writes', async () => {
|
||||
const { routes, dispose } = await mounted()
|
||||
const untrusted = fakeResponse()
|
||||
await filesRoute(routes).handler(head(`${FILES_PATH}/s-1/index.html`, { host: 'harness.example' }), untrusted.response)
|
||||
expect(untrusted.state.status).toBe(403)
|
||||
expect(untrusted.state.body).toBe('forbidden')
|
||||
|
||||
const written = fakeResponse()
|
||||
const post = fakeRequest({ host: '127.0.0.1:3080' }, `${FILES_PATH}/s-1/index.html`)
|
||||
Object.assign(post, { method: 'POST' })
|
||||
await filesRoute(routes).handler(post, written.response)
|
||||
expect(written.state.status).toBe(405)
|
||||
await dispose()
|
||||
})
|
||||
|
||||
it('confines reads to the directory the gateway names for that session', async () => {
|
||||
const { cwd, remove } = await workspace()
|
||||
const { routes, dispose } = await mounted(undefined, { 's-1': cwd })
|
||||
const served = fakeResponse()
|
||||
await filesRoute(routes).handler(head(`${FILES_PATH}/s-1/index.html`), served.response)
|
||||
expect(served.state.status).toBe(200)
|
||||
// A session the gateway names no directory for has no workspace to confine
|
||||
// against, so there is nothing to serve.
|
||||
const unknown = fakeResponse()
|
||||
await filesRoute(routes).handler(head(`${FILES_PATH}/s-absent/index.html`), unknown.response)
|
||||
expect(unknown.state.status).toBe(404)
|
||||
await dispose()
|
||||
await remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('connection node half over a real HTTP server', () => {
|
||||
/** Serve the registered prefix route from a real server and return its port. */
|
||||
async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise<void> }> {
|
||||
|
||||
134
packages/client/connection/tests/workspace-files.spec.ts
Normal file
134
packages/client/connection/tests/workspace-files.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Workspace-file reads over a real HTTP server and a real temporary
|
||||
* workspace: confinement, content typing, and the sandbox header are wire
|
||||
* facts, so they are asserted against responses Node actually produced.
|
||||
*/
|
||||
import { createServer } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Writable } from 'node:stream'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { handleWorkspaceFile } from '../src/workspace-files.ts'
|
||||
|
||||
const SESSION = 's-1'
|
||||
|
||||
let workspace: string
|
||||
let outside: string
|
||||
let origin: string
|
||||
let close: () => Promise<void>
|
||||
|
||||
beforeAll(async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-files-'))
|
||||
workspace = join(root, 'workspace')
|
||||
outside = join(root, 'outside')
|
||||
await mkdir(join(workspace, 'out'), { recursive: true })
|
||||
await mkdir(outside, { recursive: true })
|
||||
await writeFile(join(workspace, 'index.html'), '<h1>产物</h1>')
|
||||
await writeFile(join(workspace, 'notes.txt'), 'plain')
|
||||
await writeFile(join(workspace, 'chart.svg'), '<svg xmlns="http://www.w3.org/2000/svg"/>')
|
||||
await writeFile(join(workspace, 'model.safetensors'), 'unknown extension')
|
||||
await writeFile(join(workspace, 'out', 'page.html'), '<p>nested</p>')
|
||||
await writeFile(join(outside, 'secret.html'), 'SECRET')
|
||||
await symlink(join(outside, 'secret.html'), join(workspace, 'escape.html'))
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
void handleWorkspaceFile(req, res, {
|
||||
cwdFor: async sessionId => sessionId === SESSION ? workspace : undefined,
|
||||
})
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
origin = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}`
|
||||
close = () => new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error === undefined || error === null) resolve()
|
||||
else reject(error)
|
||||
})
|
||||
})
|
||||
return async () => { await rm(root, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
afterAll(async () => { await close() })
|
||||
|
||||
function get(path: string, init?: RequestInit): Promise<Response> {
|
||||
return fetch(`${origin}${path}`, init)
|
||||
}
|
||||
|
||||
describe('workspace file reads', () => {
|
||||
it('serves a produced document with the sandbox that keeps it off this origin', async () => {
|
||||
const response = await get(`${FILES_PATH}/${SESSION}/index.html`)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toBe('<h1>产物</h1>')
|
||||
expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8')
|
||||
// The whole reason a model-authored page may be served from the RPC
|
||||
// origin: an opaque origin cannot read /api/events.mux.
|
||||
expect(response.headers.get('content-security-policy')).toContain('sandbox')
|
||||
expect(response.headers.get('x-content-type-options')).toBe('nosniff')
|
||||
expect(response.headers.get('cache-control')).toBe('no-store')
|
||||
expect(response.headers.get('content-disposition')).toBe('inline')
|
||||
})
|
||||
|
||||
it('sandboxes SVG too, and leaves non-scriptable types alone', async () => {
|
||||
const svg = await get(`${FILES_PATH}/${SESSION}/chart.svg`)
|
||||
expect(svg.headers.get('content-type')).toBe('image/svg+xml')
|
||||
expect(svg.headers.get('content-security-policy')).toContain('sandbox')
|
||||
const text = await get(`${FILES_PATH}/${SESSION}/notes.txt`)
|
||||
expect(text.headers.get('content-type')).toBe('text/plain; charset=utf-8')
|
||||
expect(text.headers.get('content-security-policy')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows an unknown extension as text rather than downloading it', async () => {
|
||||
const response = await get(`${FILES_PATH}/${SESSION}/model.safetensors`)
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-type')).toBe('text/plain; charset=utf-8')
|
||||
})
|
||||
|
||||
it('serves a nested path, so a document reaches its own siblings', async () => {
|
||||
const response = await get(`${FILES_PATH}/${SESSION}/out/page.html`)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toBe('<p>nested</p>')
|
||||
})
|
||||
|
||||
it('answers HEAD with the length and no body', async () => {
|
||||
const response = await get(`${FILES_PATH}/${SESSION}/notes.txt`, { method: 'HEAD' })
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('content-length')).toBe('5')
|
||||
expect(await response.text()).toBe('')
|
||||
})
|
||||
|
||||
it('refuses a symlink whose target leaves the workspace', async () => {
|
||||
const response = await get(`${FILES_PATH}/${SESSION}/escape.html`)
|
||||
expect(response.status).toBe(403)
|
||||
expect(await response.text()).not.toContain('SECRET')
|
||||
})
|
||||
|
||||
it('reports missing files, directories, and unknown sessions as absent', async () => {
|
||||
expect((await get(`${FILES_PATH}/${SESSION}/nope.html`)).status).toBe(404)
|
||||
expect((await get(`${FILES_PATH}/${SESSION}/out`)).status).toBe(404)
|
||||
// A path whose ancestor is a file, not a directory.
|
||||
expect((await get(`${FILES_PATH}/${SESSION}/notes.txt/child`)).status).toBe(404)
|
||||
expect((await get(`${FILES_PATH}/s-other/index.html`)).status).toBe(404)
|
||||
expect((await get(`${FILES_PATH}/${SESSION}`)).status).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace file streaming failures', () => {
|
||||
it('tears the response down instead of rejecting when the body cannot be written', async () => {
|
||||
// A client that goes away mid-stream must not surface as a handler
|
||||
// rejection: the webserver's last-resort guard would log it and try to
|
||||
// answer 400 on a response whose status line is already out.
|
||||
const sink = new Writable({
|
||||
write(_chunk, _encoding, callback) { callback(new Error('socket gone')) },
|
||||
})
|
||||
const response = Object.assign(sink, { writeHead: () => response }) as unknown as ServerResponse
|
||||
await expect(handleWorkspaceFile(
|
||||
{ url: `${FILES_PATH}/${SESSION}/index.html`, method: 'GET', headers: {} } as never,
|
||||
response,
|
||||
{ cwdFor: async () => workspace },
|
||||
)).resolves.toBeUndefined()
|
||||
expect(sink.destroyed).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -56,6 +56,17 @@ export interface IWorkspaces {
|
||||
* @param path - absolute or host-resolvable path.
|
||||
*/
|
||||
openPath(path: string): Promise<void>
|
||||
/**
|
||||
* URL serving one file out of a session's workspace, for a UI that opens a
|
||||
* produced file in the browser instead of on the Host machine.
|
||||
* @param sessionId - the session whose cwd anchors the path.
|
||||
* @param cwd - that session's working directory, or `undefined` when unknown.
|
||||
* @param path - the path a tool reported (absolute, or relative to `cwd`).
|
||||
* @returns the origin-relative URL, or `undefined` when the path lies
|
||||
* outside the workspace — which this transport never serves, leaving
|
||||
* {@link IWorkspaces.openPath} as the only way to reach it.
|
||||
*/
|
||||
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined
|
||||
/**
|
||||
* Rename a Workspace.
|
||||
* @param workspaceId - target workspace.
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
DirectoryListing, IApiClient, RpcError,
|
||||
SessionId, WorkspaceId, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { SnapshotStore } from '../contract/store.ts'
|
||||
import { createSnapshotStore } from '../contract/store.ts'
|
||||
import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts'
|
||||
@@ -239,6 +240,19 @@ export class WorkspacesService implements IWorkspaces {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* URL serving one file out of a session's workspace.
|
||||
* @param sessionId - the session whose cwd anchors the path.
|
||||
* @param cwd - that session's working directory, or `undefined` when unknown.
|
||||
* @param path - the path a tool reported (absolute, or relative to `cwd`).
|
||||
* @returns the origin-relative URL, or `undefined` for a path outside the workspace.
|
||||
*/
|
||||
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined {
|
||||
const segments = workspaceFileSegments(cwd, path)
|
||||
if (segments === undefined) return undefined
|
||||
return workspaceFileUrl(sessionId, segments)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a Workspace.
|
||||
* @param workspaceId - target workspace.
|
||||
|
||||
@@ -276,6 +276,21 @@ describe('WorkspacesService', () => {
|
||||
await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/)
|
||||
})
|
||||
|
||||
it('addresses a workspace file by URL, and only inside the workspace', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
const sessions = new SessionsService(ctx, api)
|
||||
const workspaces = new WorkspacesService(ctx, api, sessions)
|
||||
const session = 's-1' as SessionId
|
||||
// The URL is derived, not fetched: no wire call answers a link.
|
||||
expect(workspaces.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html')).toBe('/f/s-1/out/a%20b.html')
|
||||
expect(workspaces.fileUrl(session, '/w/alpha', 'out/index.html')).toBe('/f/s-1/out/index.html')
|
||||
// Outside the workspace there is nothing this transport may serve, which
|
||||
// is the signal a caller falls back to openPath on.
|
||||
expect(workspaces.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined()
|
||||
expect(api.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
|
||||
const ctx = new Context()
|
||||
const api = new FakeApiClient()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type {
|
||||
DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -98,6 +99,22 @@ export class TestWorkspaces implements IWorkspaces {
|
||||
await (this.stubs.get('openPath')?.(path) as Promise<void> | undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace-file URL (recorded). Runs the production path derivation so a
|
||||
* feature test sees the real in/outside-workspace split; stub to force either.
|
||||
* @param sessionId - the session whose cwd anchors the path.
|
||||
* @param cwd - that session's working directory.
|
||||
* @param path - the path a tool reported.
|
||||
* @returns the origin-relative URL, or undefined outside the workspace.
|
||||
*/
|
||||
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined {
|
||||
this.calls.push({ method: 'fileUrl', args: [sessionId, cwd, path] })
|
||||
const stub = this.stubs.get('fileUrl')
|
||||
if (stub !== undefined) return stub(sessionId, cwd, path) as string | undefined
|
||||
const segments = workspaceFileSegments(cwd, path)
|
||||
return segments === undefined ? undefined : workspaceFileUrl(sessionId, segments)
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory picker (recorded). The default cancels (null); stub to select.
|
||||
* @returns the picked path, or null.
|
||||
|
||||
@@ -549,6 +549,10 @@ describe('workspaces action face', () => {
|
||||
expect(renamed.title).toBe('Renamed')
|
||||
await ws.delete('w1' as WorkspaceId)
|
||||
await ws.openPath('/proj/file.ts')
|
||||
// fileUrl runs the production derivation, so a feature test sees the same
|
||||
// inside/outside-workspace split the browser half decides on.
|
||||
expect(ws.fileUrl('s1' as SessionId, '/proj', 'out/a.html')).toBe('/f/s1/out/a.html')
|
||||
expect(ws.fileUrl('s1' as SessionId, '/proj', '/etc/hosts')).toBeUndefined()
|
||||
const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId)
|
||||
expect(moved.sessionIds).toEqual(['s1'])
|
||||
// Default archive mirrors the production effect: the id joins the list
|
||||
@@ -556,13 +560,15 @@ describe('workspaces action face', () => {
|
||||
await ws.archiveSession('s1' as SessionId)
|
||||
expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
|
||||
expect(ws.calls.map(c => c.method)).toEqual(
|
||||
['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore', 'archiveSession'])
|
||||
['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'fileUrl', 'fileUrl',
|
||||
'insertSessionBefore', 'archiveSession'])
|
||||
|
||||
ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never))
|
||||
ws.stub('pickDirectory', () => Promise.resolve('/picked'))
|
||||
ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never))
|
||||
ws.stub('delete', () => Promise.resolve())
|
||||
ws.stub('openPath', () => Promise.resolve())
|
||||
ws.stub('fileUrl', () => '/f/forced/a.html')
|
||||
ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
|
||||
ws.stub('archiveSession', () => Promise.resolve())
|
||||
expect((await ws.create({ name: 'y' })).title).toBe('X')
|
||||
@@ -570,6 +576,7 @@ describe('workspaces action face', () => {
|
||||
expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
|
||||
await ws.delete('w1' as WorkspaceId)
|
||||
await ws.openPath('/other')
|
||||
expect(ws.fileUrl('s1' as SessionId, '/proj', '/etc/hosts')).toBe('/f/forced/a.html')
|
||||
expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([])
|
||||
// The stub replaces the default set mutation: the set stays as-is.
|
||||
await ws.archiveSession('s2' as SessionId)
|
||||
|
||||
@@ -275,6 +275,15 @@ export function apply(ctx: Context): void {
|
||||
},
|
||||
openFile: (path) => {
|
||||
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
|
||||
// A file inside the workspace opens in a new tab, so a browser that
|
||||
// is not on the Host machine can still see what the agent produced.
|
||||
// Anything outside it has no served URL and falls back to the Host's
|
||||
// own opener, which is loopback-only by the /api trust fence.
|
||||
const url = workspaces.fileUrl(sessionId, cwd, path)
|
||||
if (url !== undefined) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
return
|
||||
}
|
||||
void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => {
|
||||
// Host/OS open failures stay silent in the chat row; the native
|
||||
// app surfaces its own error dialog when the path is unusable.
|
||||
|
||||
@@ -218,13 +218,22 @@ describe('conversation slot inject surface', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
|
||||
it('openFile (chat view face) opens a workspace file in a tab and falls back to the host opener outside it', async () => {
|
||||
const b = await bench()
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const { injected } = b.chatViewSurface(ROOT)
|
||||
// Inside the session cwd: served by this origin, so a browser anywhere on
|
||||
// the network sees the file the agent produced.
|
||||
injected.openFile('src/a.ts')
|
||||
expect(open).toHaveBeenCalledWith(`/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer')
|
||||
expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false)
|
||||
// Outside it there is no served URL, so the Host's own opener answers —
|
||||
// resolved against the session cwd exactly as before.
|
||||
injected.openFile('/etc/hosts')
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] })
|
||||
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/etc/hosts'] })
|
||||
})
|
||||
open.mockRestore()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -134,6 +134,7 @@ async function bench(snapshot: ConversationSnapshot) {
|
||||
startSession: vi.fn(),
|
||||
sendSession: vi.fn(),
|
||||
openPath: vi.fn(async () => {}),
|
||||
fileUrl: vi.fn((_sessionId: unknown, _cwd: string | undefined, path: string) => `/f/s-1/${path}`),
|
||||
}
|
||||
ctx.provide('workspaces', workspaces)
|
||||
ctx.provide('layout', layout)
|
||||
@@ -243,12 +244,14 @@ describe('run_code sub-calls through the real chat machinery', () => {
|
||||
subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
|
||||
]]])
|
||||
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const view = mountApp(b.slots)
|
||||
view.getByText('notes/demo.txt').click()
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(b.workspaces.openPath).toHaveBeenCalledWith('notes/demo.txt')
|
||||
expect(open).toHaveBeenCalledWith('/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer')
|
||||
})
|
||||
open.mockRestore()
|
||||
view.getByText('List notes').click()
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -119,14 +119,16 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => {
|
||||
it('file-path clicks travel owner openFile → chat inject → the served workspace URL', async () => {
|
||||
const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
const view = b.runtime.renderRoot()
|
||||
view.getByText('src/a.ts').click()
|
||||
expect(b.layout.openDetails).not.toHaveBeenCalled()
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['src/a.ts'] })
|
||||
expect(open).toHaveBeenCalledWith(expect.stringContaining('/src/a.ts'), '_blank', 'noopener,noreferrer')
|
||||
})
|
||||
open.mockRestore()
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -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/host/apiproxy/README.md
|
||||
README.md: 3c5a83a468b0cf9e596b8b13fafe40c409576fc5
|
||||
README.zh.md: f8533564575bf6b716f3fa7241ce47b8d4dd435f
|
||||
README.md: ee8e758a68f6efa3e363a36fcc9e8444e589ea40
|
||||
README.zh.md: 4ec3817e65543d6e248be9d902d0b74674f56e5a
|
||||
|
||||
@@ -36,6 +36,8 @@ The `command.*` and `skill.*` domains expose the host command registry and skill
|
||||
|
||||
The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin.
|
||||
|
||||
Two members of `ApiProxy` are deliberately not wire methods. `respond` is the client-response entry (four-quadrant model), and `workspaceRootOf` answers where a Session's files live for an in-process reader — a live agent's header first, then the persistence store, never a resume. It has no wire face: a browser learns a Session's cwd from `sessions.view`, and reaches a file through the web transport's own `/f` route, never by asking for a host path. That route's URL shape (`api/files.ts`: `FILES_PATH`, `workspaceFileSegments`, `workspaceFileUrl`, `parseWorkspaceFilePath`) lives here with the other browser-importable contract surfaces, so the browser half that builds a `/f` URL and the serving half that parses one cannot drift apart; the route itself belongs to [`dsh-client-connection`](../../client/connection/README.md).
|
||||
|
||||
## Carrier layer (`/client` + root)
|
||||
|
||||
`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless.
|
||||
|
||||
@@ -36,6 +36,8 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
|
||||
`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。
|
||||
|
||||
`ApiProxy` 上有两个成员刻意不是协议方法。`respond` 是客户端响应入口(四象限模型),`workspaceRootOf` 则为进程内读取方回答某个 Session 的文件位于何处——先看活跃 agent 的 header,再看持久化存储,绝不恢复会话。它没有协议面:浏览器从 `sessions.view` 得知 Session 的 cwd,并经由 web 传输自己的 `/f` 路由抵达文件,而不是靠索要一条宿主路径。该路由的 URL 形状(`api/files.ts`:`FILES_PATH`、`workspaceFileSegments`、`workspaceFileUrl`、`parseWorkspaceFilePath`)与其余浏览器可导入的契约面一同放在这里,因此构造 `/f` URL 的浏览器半侧与解析它的服务半侧不会彼此漂移;路由本身则属于 [`dsh-client-connection`](../../client/connection/README.md)。
|
||||
|
||||
## 载体层(`/client` + 根路径)
|
||||
|
||||
`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。
|
||||
|
||||
@@ -2290,5 +2290,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
pending.resolve(payload.answer)
|
||||
return Promise.resolve({ accepted: true })
|
||||
},
|
||||
|
||||
async workspaceRootOf(sessionId: SessionId): Promise<string | undefined> {
|
||||
// A live agent answers from its own header; otherwise the store answers,
|
||||
// deliberately without resuming — reading a session's directory must not
|
||||
// pull an agent up the way the cold RPC path does.
|
||||
const live = ctx.agents.get(sessionId)
|
||||
if (live !== undefined) return live.session.header.cwd
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) return undefined
|
||||
return (await persistence.list()).find(meta => meta.id === sessionId)?.cwd
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
98
packages/host/apiproxy/src/api/files.ts
Normal file
98
packages/host/apiproxy/src/api/files.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The `/f` workspace-file URL shape: the contract half of the web transport
|
||||
* that carries bytes rather than RPC. The browser turns a tool's file path
|
||||
* into a URL, the serving side turns that URL back into the segments below a
|
||||
* session's cwd, and both read this one encoding decision so neither can drift
|
||||
* into serving a path the other never meant. Pure string work with no Node and
|
||||
* no DOM, like the rest of `api/` — the browser bundle inlines it.
|
||||
* @module @deepseek-ai/dsh-host-apiproxy/api/files
|
||||
*/
|
||||
|
||||
/**
|
||||
* Route prefix owning every workspace-file read (`/f/<sessionId>/<segments…>`).
|
||||
* The path carries the segments verbatim rather than a query parameter so a
|
||||
* served document's relative references (`./logo.png`) resolve to their
|
||||
* siblings in the same workspace directory.
|
||||
*/
|
||||
export const FILES_PATH = '/f'
|
||||
|
||||
/** One parsed workspace-file request: whose workspace, and where inside it. */
|
||||
export interface WorkspaceFileTarget {
|
||||
/** The owning session, still an opaque string — the caller resolves it to a cwd. */
|
||||
sessionId: string
|
||||
/** Decoded path segments below that session's cwd; never empty, never `.` or `..`. */
|
||||
segments: string[]
|
||||
}
|
||||
|
||||
/** A segment that survived decoding but would re-enter path resolution as more than one name. */
|
||||
function isPlainSegment(segment: string): boolean {
|
||||
return segment !== '' && segment !== '.' && segment !== '..'
|
||||
&& !segment.includes('/') && !segment.includes('\\') && !segment.includes('\0')
|
||||
}
|
||||
|
||||
function decode(raw: string): string | undefined {
|
||||
try {
|
||||
return decodeURIComponent(raw)
|
||||
} catch {
|
||||
// A malformed %-escape is a request we cannot interpret, not a miss.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Express one tool-reported file path as segments below the session cwd.
|
||||
* @param cwd - the session's working directory, or `undefined` when unknown.
|
||||
* @param path - the path the tool reported (absolute, or relative to `cwd`).
|
||||
* @returns the segments below `cwd`, or `undefined` when the path names
|
||||
* something outside the workspace (which this route never serves) or resolves
|
||||
* to the workspace directory itself.
|
||||
*/
|
||||
export function workspaceFileSegments(cwd: string | undefined, path: string): string[] | undefined {
|
||||
const slashed = path.replace(/\\/g, '/')
|
||||
const absolute = /^\/|^[A-Za-z]:\//.test(slashed)
|
||||
let relative: string
|
||||
if (absolute) {
|
||||
if (cwd === undefined || cwd === '') return undefined
|
||||
const root = cwd.replace(/\\/g, '/').replace(/\/+$/, '')
|
||||
if (!slashed.startsWith(`${root}/`)) return undefined
|
||||
relative = slashed.slice(root.length + 1)
|
||||
} else {
|
||||
relative = slashed
|
||||
}
|
||||
const segments = relative.split('/').filter(segment => segment !== '' && segment !== '.')
|
||||
if (segments.length === 0 || segments.some(segment => !isPlainSegment(segment))) return undefined
|
||||
return segments
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the origin-relative URL serving one workspace file.
|
||||
* @param sessionId - the session whose cwd anchors the path.
|
||||
* @param segments - segments below that cwd, as {@link workspaceFileSegments} returns them.
|
||||
* @returns the `/f/…` URL, resolved by the browser against the serving origin.
|
||||
*/
|
||||
export function workspaceFileUrl(sessionId: string, segments: readonly string[]): string {
|
||||
const encoded = segments.map(segment => encodeURIComponent(segment)).join('/')
|
||||
return `${FILES_PATH}/${encodeURIComponent(sessionId)}/${encoded}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a request pathname back into the session and segments it names.
|
||||
* @param pathname - the request's raw (still percent-encoded) pathname.
|
||||
* @returns the target, or `undefined` when the pathname is not a well-formed
|
||||
* workspace-file read — including every traversal shape, which is refused here
|
||||
* before any filesystem call rather than being resolved and then judged.
|
||||
*/
|
||||
export function parseWorkspaceFilePath(pathname: string): WorkspaceFileTarget | undefined {
|
||||
if (!pathname.startsWith(`${FILES_PATH}/`)) return undefined
|
||||
const [rawSession, ...rawSegments] = pathname.slice(FILES_PATH.length + 1).split('/')
|
||||
if (rawSession === undefined || rawSegments.length === 0) return undefined
|
||||
const sessionId = decode(rawSession)
|
||||
if (sessionId === undefined || sessionId === '') return undefined
|
||||
const segments: string[] = []
|
||||
for (const raw of rawSegments) {
|
||||
const segment = decode(raw)
|
||||
if (segment === undefined || !isPlainSegment(segment)) return undefined
|
||||
segments.push(segment)
|
||||
}
|
||||
return { sessionId, segments }
|
||||
}
|
||||
@@ -15,6 +15,9 @@ import type { SettingsApi } from './settings.ts'
|
||||
import type { CredentialsApi } from './credentials.ts'
|
||||
import type { LlmApi } from './llm.ts'
|
||||
import type { ClientResponse, RpcReceipt } from './rpc.ts'
|
||||
// The merge-free types subpath: api/ is imported from the browser lane, where
|
||||
// the host session service must not merge over the client runtime's own.
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
/** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */
|
||||
export interface ApiProxy {
|
||||
@@ -30,6 +33,17 @@ export interface ApiProxy {
|
||||
llm: LlmApi
|
||||
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
|
||||
respond(message: ClientResponse): Promise<RpcReceipt>
|
||||
/**
|
||||
* The directory a Session's files may be read from — the same `cwd` the
|
||||
* session summaries carry, in non-envelope form for an in-process reader.
|
||||
* Not a domain method: it has no wire face, because a browser learns a
|
||||
* Session's cwd from `sessions.view` and a file it may read from the web
|
||||
* transport's own `/f` route, never by asking for a host path.
|
||||
* @param sessionId - the Session to locate.
|
||||
* @returns its absolute working directory, or `undefined` when this host
|
||||
* serves no such Session. Resolving one never resumes an agent.
|
||||
*/
|
||||
workspaceRootOf(sessionId: SessionId): Promise<string | undefined>
|
||||
}
|
||||
|
||||
// ---- Domain interfaces and payload entities ----
|
||||
@@ -48,6 +62,10 @@ export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSe
|
||||
export type { CredentialsApi, CredentialView } from './credentials.ts'
|
||||
export type { ConfigurableProviderView, LlmApi } from './llm.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
|
||||
// ---- Workspace-file URL shape (the transport's byte-carrying half) ----
|
||||
export { FILES_PATH, workspaceFileSegments, workspaceFileUrl, parseWorkspaceFilePath } from './files.ts'
|
||||
export type { WorkspaceFileTarget } from './files.ts'
|
||||
export type { QuestionResponsePayload } from './questions.ts'
|
||||
|
||||
// ---- Message layer: narrow forms (domain-signature view) ----
|
||||
|
||||
@@ -64,6 +64,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
readonly llm: ApiProxy['llm']
|
||||
readonly events: ApiProxy['events']
|
||||
readonly respond: ApiProxy['respond']
|
||||
readonly workspaceRootOf: ApiProxy['workspaceRootOf']
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'apiProxy')
|
||||
@@ -87,6 +88,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
// createApiProxy returns closures (no `this` capture); bind only satisfies
|
||||
// the unbound-method lint without changing behavior.
|
||||
this.respond = api.respond.bind(api)
|
||||
this.workspaceRootOf = api.workspaceRootOf.bind(api)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,11 @@ function stubAgent(session: Session): Agent {
|
||||
async function harness(
|
||||
workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
|
||||
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
|
||||
extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
|
||||
extras: {
|
||||
openPath?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
/** Store contents behind the gateway, or 'absent' for a composition with no persistence at all. */
|
||||
persisted?: { id: SessionId; cwd?: string }[] | 'absent'
|
||||
} = {},
|
||||
) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
@@ -73,7 +77,10 @@ async function harness(
|
||||
const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} })
|
||||
ctx.storage.mount('domain', storageDomain)
|
||||
ctx.provide('storageDomain', storageDomain)
|
||||
ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
|
||||
if (extras.persisted !== 'absent') {
|
||||
const persisted = extras.persisted ?? []
|
||||
ctx.provide('sessionPersistence', { list: () => Promise.resolve(persisted) } as never)
|
||||
}
|
||||
await ctx.plugin(WorkspaceRegistry)
|
||||
|
||||
const factory: AgentFactory = {
|
||||
@@ -244,6 +251,27 @@ describe('host.openPath', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspaceRootOf', () => {
|
||||
it('answers from the live agent, then the store, and names nothing for an unknown session', async () => {
|
||||
const { api, workspaceRoot } = await harness(undefined, undefined, {
|
||||
persisted: [{ id: 's-cold' as SessionId, cwd: '/w/cold' }],
|
||||
})
|
||||
const created = await api.sessions.create(request({ cwd: workspaceRoot }))
|
||||
const sessionId = (created.result as { ok: true; value: { sessionId: SessionId } }).value.sessionId
|
||||
// Live: the agent's own header, no store read involved.
|
||||
await expect(api.workspaceRootOf(sessionId)).resolves.toBe(workspaceRoot)
|
||||
// Not live: the store answers, and the lookup never resumes an agent —
|
||||
// this harness's factory throws on resume, so a resuming lookup would fail.
|
||||
await expect(api.workspaceRootOf('s-cold' as SessionId)).resolves.toBe('/w/cold')
|
||||
await expect(api.workspaceRootOf('s-absent' as SessionId)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('names nothing at all when the host keeps no session store', async () => {
|
||||
const { api } = await harness(undefined, undefined, { persisted: 'absent' })
|
||||
await expect(api.workspaceRootOf('s-any' as SessionId)).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace.create', () => {
|
||||
it('serializes concurrent names and rejects the duplicate', async () => {
|
||||
const { api, workspaceRoot } = await harness()
|
||||
|
||||
@@ -108,6 +108,8 @@ function scriptedApi(overrides: {
|
||||
},
|
||||
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
|
||||
respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
|
||||
// No wire face, so the handler map never reaches it.
|
||||
workspaceRootOf: () => Promise.resolve(undefined),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -233,6 +233,8 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
|
||||
async respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||
return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' }
|
||||
},
|
||||
// No wire face, so the carrier never reaches it.
|
||||
workspaceRootOf: () => Promise.resolve(undefined),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
74
packages/host/apiproxy/tests/files-path.spec.ts
Normal file
74
packages/host/apiproxy/tests/files-path.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/** The /f URL shape: one encoding decision, asserted from both ends. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
FILES_PATH, parseWorkspaceFilePath, workspaceFileSegments, workspaceFileUrl,
|
||||
} from '../src/api/files.ts'
|
||||
|
||||
describe('workspaceFileSegments', () => {
|
||||
it('keeps a relative path as its own segments', () => {
|
||||
expect(workspaceFileSegments('/w', 'out/index.html')).toEqual(['out', 'index.html'])
|
||||
expect(workspaceFileSegments(undefined, 'index.html')).toEqual(['index.html'])
|
||||
expect(workspaceFileSegments('/w', './a/./b.txt')).toEqual(['a', 'b.txt'])
|
||||
})
|
||||
|
||||
it('strips the cwd prefix from an absolute path inside the workspace', () => {
|
||||
expect(workspaceFileSegments('/w', '/w/a/b.html')).toEqual(['a', 'b.html'])
|
||||
// A trailing separator on the cwd must not shift the split.
|
||||
expect(workspaceFileSegments('/w/', '/w/a.html')).toEqual(['a.html'])
|
||||
})
|
||||
|
||||
it('reads Windows paths on either separator', () => {
|
||||
expect(workspaceFileSegments('C:\\w', 'C:\\w\\a\\b.html')).toEqual(['a', 'b.html'])
|
||||
expect(workspaceFileSegments('C:/w', 'C:\\w\\a.html')).toEqual(['a.html'])
|
||||
})
|
||||
|
||||
it('refuses everything the route would not serve', () => {
|
||||
// Absolute, but not under this workspace.
|
||||
expect(workspaceFileSegments('/w', '/etc/hosts')).toBeUndefined()
|
||||
// A sibling directory sharing the cwd's name prefix is not inside it.
|
||||
expect(workspaceFileSegments('/w', '/workspace-other/a')).toBeUndefined()
|
||||
// Absolute with no cwd to anchor against.
|
||||
expect(workspaceFileSegments(undefined, '/w/a.html')).toBeUndefined()
|
||||
expect(workspaceFileSegments('', '/w/a.html')).toBeUndefined()
|
||||
// Traversal, in either spelling.
|
||||
expect(workspaceFileSegments('/w', '../secret')).toBeUndefined()
|
||||
expect(workspaceFileSegments('/w', 'a/../../secret')).toBeUndefined()
|
||||
// The workspace directory itself is not a file.
|
||||
expect(workspaceFileSegments('/w', '/w')).toBeUndefined()
|
||||
expect(workspaceFileSegments('/w', '.')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspaceFileUrl', () => {
|
||||
it('percent-encodes each segment but keeps the separators structural', () => {
|
||||
expect(workspaceFileUrl('s-1', ['out', 'a b.html'])).toBe(`${FILES_PATH}/s-1/out/a%20b.html`)
|
||||
expect(workspaceFileUrl('s/1', ['a#b.html'])).toBe(`${FILES_PATH}/s%2F1/a%23b.html`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseWorkspaceFilePath', () => {
|
||||
it('round-trips what the browser half builds', () => {
|
||||
const url = workspaceFileUrl('s-1', ['out', 'a b.html'])
|
||||
expect(parseWorkspaceFilePath(url)).toEqual({ sessionId: 's-1', segments: ['out', 'a b.html'] })
|
||||
})
|
||||
|
||||
it('refuses malformed, prefix-foreign, and traversal pathnames', () => {
|
||||
expect(parseWorkspaceFilePath('/api/session.list')).toBeUndefined()
|
||||
expect(parseWorkspaceFilePath(FILES_PATH)).toBeUndefined()
|
||||
// Session named but no file below it.
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1`)).toBeUndefined()
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}//a.html`)).toBeUndefined()
|
||||
// Traversal is refused at parse time, before any filesystem call.
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/../etc/hosts`)).toBeUndefined()
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a/./b`)).toBeUndefined()
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a//b`)).toBeUndefined()
|
||||
// A separator smuggled through percent-encoding stays one segment's problem.
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%2F..%2Fb`)).toBeUndefined()
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%5Cb`)).toBeUndefined()
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%00b`)).toBeUndefined()
|
||||
// Malformed percent-escapes are uninterpretable, not a miss to resolve.
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%zz`)).toBeUndefined()
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}/%zz/a.html`)).toBeUndefined()
|
||||
expect(parseWorkspaceFilePath(`${FILES_PATH}//`)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user