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:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md
|
||||
2026-07-31-web-workspace-file-links.md: b7fd5ca240db3ca885e89f4cf6dcc135e7c88de8
|
||||
2026-07-31-web-workspace-file-links.zh.md: 74949afe0260d2d9018691740573ff24a1bce820
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note: opening a produced file from the web UI
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-31-web-workspace-file-links.zh.md)
|
||||
|
||||
> Scope: the `/f` workspace-file route on the web transport, the `IWorkspaces.fileUrl` derivation behind it, and the conversation's file-open affordance switching to it. Not in scope: an artifact registry, versioning, live reload, or any model-facing declaration.
|
||||
|
||||
## Problem
|
||||
|
||||
A web session that produced a file had no way to look at it. The agent wrote `deepseek-homepage.html`, said so, and the user's only recourse was to copy an absolute path like `/private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html` into a terminal.
|
||||
|
||||
The parts were nearly all present, pointed at the wrong target. `ToolRow` already renders a mutation or read row's path as a real button, `ui-conversation` already routes its click through `openFile`, and `workspaces.openPath` already carries it to the Host's system opener. But that opener runs on the Host machine, and `host.openPath` is loopback-pinned by the `/api` trust fence, so the affordance answered nothing for a browser reached over the LAN and was invisible even locally (the path styled as plain text, underlined only on hover). Meanwhile `MarkdownText` strips every non-`http(s)` URL, so a path the model wrote into its closing message could never become a link at all, and `ToolCallView.locations` — the follow-along vocabulary the file tools already populate — had no consumer in the client.
|
||||
|
||||
## Decision
|
||||
|
||||
**One prefix route on the transport that already exists, not a new capability.** `client-connection` owns both browser-facing prefixes: `/api` for RPC and `/f/<sessionId>/<segments…>` for workspace-file reads. It was already the package holding `httpServer`, the `trustedHosts` config, and the browser-trust fence; a separate package would have duplicated the fence and the config, and forced `AppCLIEntry` to patch two rows for one `--trusted-host` flag. The webserver's own contract — every feature surface is a route some other plugin registers — makes the route the whole mechanism. Segments ride the path rather than a query parameter so a served document's relative references resolve to its siblings.
|
||||
|
||||
**The request names a Session; the gateway names the authority.** `ApiProxy.workspaceRootOf` answers where a Session's files live — a live agent's `session.header.cwd` first, then the persistence store, never a resume — as a second, non-envelope face of the `cwd` the session summaries already carry. The route reads that instead of `ctx.agents` directly, because `client-connection` is registered in the client program and importing the core service packages merges their host-side `sessions: SessionStore` declaration over the browser runtime's own `sessions: SessionsService` — the collision `tsconfig.host.json`/`tsconfig.client.json` exist to prevent. Both the cwd and the resolved target go through `realpath` before the prefix comparison, so a workspace-internal symlink pointing outward is refused by its target; traversal spellings are refused at parse time, before any filesystem call. Reads stream through `pipeline`, so a client that goes away destroys the descriptor and no request ever buffers a file.
|
||||
|
||||
**The URL shape lives in `dsh-host-apiproxy/api`, with the other browser-importable contract surfaces.** Both ends must agree on one encoding, but a client bundle may not value-import another plugin's package: the purity gate in `packages/client/tsdown.client.ts` allows only platform modules and the `INLINE_SAFE` wire layers, of which apiproxy is one. Putting `api/files.ts` there is what lets the browser half build a URL and the serving half parse it from a single source, and it needed no new package edge — both sides already depend on apiproxy.
|
||||
|
||||
**Model-authored documents are served into an opaque origin.** `.html`/`.htm`/`.xhtml`/`.svg` carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`. Serving generated markup same-origin with `/api` would put `/api/events.mux` — a readable `GET` stream — one `window.open` away from a page the model wrote. The sandbox costs the preview its `localStorage`, cookies, and same-origin `fetch`; `host.openPath` stays as the full-capability way to open the same file on the Host machine, so the trade is resolved by keeping both affordances rather than by weakening either.
|
||||
|
||||
**The client decides by derivation, not by probing.** `IWorkspaces.fileUrl(sessionId, cwd, path)` expresses a tool-reported path as segments below the session cwd and returns the origin-relative URL, or `undefined` when the path leaves the workspace. `undefined` is exactly the signal to fall back to `openPath`, so a file outside the workspace behaves as it did before and no capability negotiation is needed.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **The artifact capability family (RFC #268 / PR #272)** — a seam with ids, versions, snapshot storage, its own HTTP server, SSE live reload, and a browser auto-opener. Its review found seven critical issues, and every one of them came from that machinery: an unlistened opener spawn crashing the harness, the opener inheriting `DEEPSEEK_API_KEY`, in-flight publishes outliving disposal, `readFile` preceding the size cap, a snapshot TOCTOU, and retention leaking with undisposed agents. `dsh web` already runs an HTTP server and the user is already in a browser, so none of that machinery buys anything here. The RFC and its tests stay as the input for the day a real cross-session or versioned-artifact need appears; this route is that seam's natural mount point when it does.
|
||||
- **A dedicated `dsh-client-workspace-files` package** — the honest seam shape if file serving were an independent capability. It is not: it needs the same fence and the same `trustedHosts` value as `/api`, and splitting would have duplicated both against the repository's own "don't split preemptively" rule.
|
||||
- **Keeping the URL-shape module in `client-connection` and importing it from the runtime** — the first cut, and the build refused it: a cross-plugin value import into a client bundle either inlines a duplicate runtime instance or names a specifier the frozen module table cannot answer. The gate is the reason the shared module sits in the wire layer rather than in the package that happens to own the route.
|
||||
- **`/f/<absolute path>`, so `openPath` could stay the single call site** — drops the sessionId from the URL, but then the served authority becomes the union of every workspace the host knows. The tight authority costs exactly one call-site edit, because `openFile` already has both the sessionId and the cwd in scope.
|
||||
- **`connect-src 'none'` instead of `sandbox`, to keep `localStorage` working** — blocks `fetch`/`EventSource` but not `window.open('/api/events.mux')`, which is readable same-origin. The two GET SSE endpoints are what make the sandbox necessary rather than optional.
|
||||
- **Linkifying paths in the assistant's closing message** — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry `locations` as structured fact; consuming that is the reliable source and is left as the follow-up this route unblocks.
|
||||
|
||||
## Consequences
|
||||
|
||||
Every existing file affordance changed target at once: write, edit, read, and the generic single-file card all reach `openFile`, so one call-site edit made produced files openable in the browser, LAN clients included. Three tests asserting the old `openPath` destination were rewritten to the new one; the outside-workspace fallback keeps the old assertion. The route is covered against a real HTTP server and a real temporary workspace, because confinement, content typing, and the sandbox header are wire facts, and the assembled web lane (`apps/web/tests/workspace-file-open.e2e.ts`, keyless over a cold-seeded session) proves the product path: clicking a read row's path opens `/f/<sessionId>/a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. `localStorage` is unavailable inside a preview, which is visible on generated pages that persist a theme toggle — the Host opener remains for those. Still deferred: the end-of-turn deliverable row derived from `locations`, and any linkification inside assistant Markdown.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Agent Note:从 web UI 打开产出的文件
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-31-web-workspace-file-links.md) | 中文
|
||||
|
||||
> 范围:web 传输层上的 `/f` 工作区文件路由、其背后的 `IWorkspaces.fileUrl` 推导,以及会话中打开文件的交互改指向它。不在范围内:产物注册表、版本、实时重载,或任何面向模型的声明。
|
||||
|
||||
## 问题
|
||||
|
||||
一个产出了文件的 web 会话,没有办法看到那个文件。agent 写出了 `deepseek-homepage.html` 并如实告知,而用户唯一的办法是把 `/private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html` 这样的绝对路径复制进终端。
|
||||
|
||||
零件几乎都在,只是指错了目标。`ToolRow` 早已把改写行或读取行的路径渲染成一个真正的按钮,`ui-conversation` 早已把它的点击经由 `openFile` 转发,`workspaces.openPath` 也早已把它送到 Host 的系统打开器。但那个打开器运行在 Host 机器上,而 `host.openPath` 被 `/api` 信任 fence 钉在回环,所以这个交互对经 LAN 访问的浏览器什么都答不了,即便在本机也是隐形的(路径的样式就是普通文本,只有 hover 时才有下划线)。与此同时 `MarkdownText` 会剥掉每一个非 `http(s)` 的 URL,因此模型写进收尾消息里的路径根本不可能成为链接;而 `ToolCallView.locations`——文件工具早已填好的跟随文件词汇——在客户端没有任何消费方。
|
||||
|
||||
## 决定
|
||||
|
||||
**在已有的传输层上加一条前缀路由,而不是加一项能力。** `client-connection` 持有两条面向浏览器的前缀:`/api` 承载 RPC,`/f/<sessionId>/<segments…>` 承载工作区文件读取。它本来就是持有 `httpServer`、`trustedHosts` 配置和浏览器信任 fence 的那个包;单开一个包会把 fence 和配置各复制一份,并逼着 `AppCLIEntry` 为一个 `--trusted-host` 标志去 patch 两行。webserver 自己的契约——每个特性面都是别的插件注册的一条路由——让这条路由本身就是全部机制。段落走路径而非查询参数,是为了让所服务文档的相对引用能解析到它的同级文件。
|
||||
|
||||
**请求指名 Session,由网关指名权限边界。** `ApiProxy.workspaceRootOf` 回答某个 Session 的文件位于何处——先看活跃 agent 的 `session.header.cwd`,再看持久化存储,绝不恢复会话——它是会话摘要早已携带的那个 `cwd` 的第二副面孔,只是不带信封。路由读取它而不是直接够 `ctx.agents`,因为 `client-connection` 注册在 client 程序里,而引入核心服务包会把它们 host 侧的 `sessions: SessionStore` 声明盖到浏览器运行时自己的 `sessions: SessionsService` 之上——这正是 `tsconfig.host.json`/`tsconfig.client.json` 分立所要防的那种冲突。cwd 与解析出的目标在前缀比较前都要过 `realpath`,因此工作区内指向工作区外的符号链接会因其目标而被拒绝;穿越写法在解析期就被拒,早于任何文件系统调用。读取经 `pipeline` 流出,因此客户端离开即销毁描述符,任何请求都不会把文件缓冲起来。
|
||||
|
||||
**URL 形状落在 `dsh-host-apiproxy/api`,与其余浏览器可导入的契约面同处一地。** 两端必须就同一套编码达成一致,但客户端 bundle 不允许值导入另一个插件的包:`packages/client/tsdown.client.ts` 里的纯度 gate 只放行平台模块与 `INLINE_SAFE` 协议层,而 apiproxy 正是其中之一。把 `api/files.ts` 放在那里,才使构造 URL 的浏览器半侧与解析它的服务半侧共用单一来源,而且没有新增任何包依赖边——两侧本来就依赖 apiproxy。
|
||||
|
||||
**模型撰写的文档被送进不透明源。** `.html`/`.htm`/`.xhtml`/`.svg` 会带上 `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`。若把生成的标记与 `/api` 同源提供,`/api/events.mux`——一条可读的 `GET` 流——离模型写的页面就只有一次 `window.open` 之遥。sandbox 让预览失去 `localStorage`、cookie 与同源 `fetch`;`host.openPath` 作为在 Host 机器上以完整能力打开同一文件的方式保留下来,因此这个取舍是靠同时保留两个交互解决的,而不是靠削弱其中之一。
|
||||
|
||||
**客户端靠推导决定,而不是靠探测。** `IWorkspaces.fileUrl(sessionId, cwd, path)` 把工具报告的路径表达为 session cwd 之下的段落并返回相对于源的 URL,路径离开工作区时返回 `undefined`。`undefined` 恰好就是回退到 `openPath` 的信号,因此工作区外的文件行为与以往一致,也不需要任何能力协商。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **产物能力族(RFC #268 / PR #272)**——一条带 id、版本、快照存储、自有 HTTP 服务器、SSE 实时重载与浏览器自动打开器的 seam。它的评审给出了七个 critical,而每一个都来自那套机械结构:未监听的打开器 spawn 会让 harness 崩溃、打开器继承 `DEEPSEEK_API_KEY`、进行中的 publish 活过 dispose、`readFile` 先于大小上限、快照的 TOCTOU,以及未 dispose 的 agent 导致保留期泄漏。`dsh web` 本来就跑着一个 HTTP 服务器,用户本来就在浏览器里,那套机械结构在这里买不到任何东西。RFC 与其测试保留下来,作为真正出现跨会话或版本化产物需求那天的输入;届时这条路由就是那条 seam 的天然挂载点。
|
||||
- **单开一个 `dsh-client-workspace-files` 包**——如果文件服务是一项独立能力,这才是诚实的 seam 形状。它不是:它需要与 `/api` 相同的 fence 和相同的 `trustedHosts` 值,拆分会把两者都复制一份,违背仓库自己的“不要预先拆分”。
|
||||
- **把 URL 形状模块留在 `client-connection` 里、由 runtime 去导入**——最初就是这么写的,构建直接拒绝:向客户端 bundle 做跨插件值导入,要么内联出一份重复的运行时实例,要么落到冻结模块表答不出的说明符上。这道 gate 正是共享模块落在协议层、而非落在恰好持有该路由的那个包里的原因。
|
||||
- **`/f/<绝对路径>`,好让 `openPath` 保持为唯一调用点**——这会把 sessionId 从 URL 里去掉,但所服务的权限边界随之变成 host 已知的全部工作区之并集。紧的权限边界只花掉一处调用点的改动,因为 `openFile` 本来就同时持有 sessionId 与 cwd。
|
||||
- **用 `connect-src 'none'` 代替 `sandbox`,以保住 `localStorage`**——它挡得住 `fetch`/`EventSource`,挡不住 `window.open('/api/events.mux')`,而后者是同源可读的。正是那两个 GET SSE 端点让 sandbox 成为必需而非可选。
|
||||
- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带;消费它才是可靠来源,作为这条路由解锁的后续留下。
|
||||
|
||||
## 影响
|
||||
|
||||
现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f/<sessionId>/a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览中无法使用 `localStorage`,这在会持久化主题切换的生成页面上是看得见的——那些场景仍有 Host 打开器。仍然暂缓:由 `locations` 推导的回合末交付物行,以及助手 Markdown 内部的任何链接化。
|
||||
92
apps/web/tests/workspace-file-open.e2e.ts
Normal file
92
apps/web/tests/workspace-file-open.e2e.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// Web e2e scenario: clicking a tool row's file path opens that file in a new
|
||||
// browser tab, served by the web transport's own /f route. Cold-seeds the
|
||||
// seeded-history fixture (zero model calls). The surface package tests can
|
||||
// assert which opener the click reaches, but only the assembled application
|
||||
// proves the opened URL actually serves the workspace file — the whole point
|
||||
// of the route (docs/testing.md snapshot rule).
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
// Borrowed read-only: this scenario needs any settled turn whose tool rows
|
||||
// carry a workspace file path, not a new recording (message-actions pattern).
|
||||
const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url))
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'workspace-file-open-web-e2e'
|
||||
|
||||
const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.'
|
||||
|
||||
describe('web e2e: opening a workspace file from a tool row', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
// The seeded Session's cwd is the scaffold workspace itself; the recording's
|
||||
// own nested directory is written too, so the seed's paths stay resolvable.
|
||||
await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true })
|
||||
for (const dir of [scaffold.workspaceCwd, join(scaffold.workspaceCwd, 'workspace')]) {
|
||||
await writeFile(join(dir, 'a.txt'), 'alpha\n')
|
||||
await writeFile(join(dir, 'b.txt'), 'beta\n')
|
||||
}
|
||||
const raw = await readFile(SEED, 'utf8')
|
||||
expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT])
|
||||
await seedSession(scaffold, raw, SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it.skipIf(MODE === 'record')('opens the read row’s file in a new tab, served from the session workspace', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-file-open'))
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
await groupRow.click()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
|
||||
// The row summary IS the link: a button whose label is the tool's path.
|
||||
const fileLink = page.getByRole('button', { name: 'a.txt', exact: true }).first()
|
||||
await fileLink.waitFor({ timeout: 10_000 })
|
||||
const [opened] = await Promise.all([
|
||||
page.context().waitForEvent('page', { timeout: 15_000 }),
|
||||
fileLink.click(),
|
||||
])
|
||||
await opened.waitForLoadState('domcontentloaded')
|
||||
expect(new URL(opened.url()).pathname).toBe(`/f/${SEED_ID}/a.txt`)
|
||||
expect(await opened.locator('body').innerText()).toContain('alpha')
|
||||
|
||||
// The served response is a workspace read, not a download, and never cached
|
||||
// past the turn that produced it.
|
||||
const served = await page.request.get(opened.url())
|
||||
expect(served.status()).toBe(200)
|
||||
expect(served.headers()['x-content-type-options']).toBe('nosniff')
|
||||
expect(served.headers()['cache-control']).toBe('no-store')
|
||||
|
||||
// Nothing outside the Session's workspace is reachable through the route.
|
||||
const escape = await page.request.get(`${scaffold.baseUrl}/f/${SEED_ID}/..%2Fetc%2Fhosts`)
|
||||
expect(escape.status()).toBe(404)
|
||||
|
||||
await opened.close()
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 90_000)
|
||||
})
|
||||
@@ -50,7 +50,8 @@
|
||||
"tests/permission-policy-context.e2e.ts",
|
||||
"tests/access-confirmation.e2e.ts",
|
||||
"tests/shipped-composition.e2e.ts",
|
||||
"tests/startup-auto-selection.e2e.ts"
|
||||
"tests/startup-auto-selection.e2e.ts",
|
||||
"tests/workspace-file-open.e2e.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
|
||||
@@ -296,7 +296,7 @@ export interface ConnectionConfig {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/client/connection/src/index.ts:20`](../packages/client/connection/src/index.ts)
|
||||
Source: [`packages/client/connection/src/index.ts:26`](../packages/client/connection/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-client-hmr`
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,7 @@
|
||||
"apps/web/tests/access-confirmation.e2e.ts",
|
||||
"apps/web/tests/shipped-composition.e2e.ts",
|
||||
"apps/web/tests/startup-auto-selection.e2e.ts",
|
||||
"apps/web/tests/workspace-file-open.e2e.ts",
|
||||
"apps/cli/tests/**/*.ts",
|
||||
"examples/*/src/**/*.ts",
|
||||
"examples/*/start.ts",
|
||||
|
||||
Reference in New Issue
Block a user