feat(web): serve workspace files from their own origin
A sandbox header bought isolation by taking the document's origin away, and measuring that cost decided against it: the reported artifact throws SecurityError on load, and because an uncaught exception aborts the rest of its <script>, every listener declared after that line — theme toggle, mobile menu, model tabs — never binds. Two of the four artifacts in the reporting user's workspace were dead pages under it, and they still looked right. A second listener on the API's host, answering /f and nothing else, is the same boundary without the amputation: cross-origin to /api (refused by the Origin fence and by CORS), same-origin with itself (localStorage, cookies and fetch all work). Its port is published into the index page; the browser half reads it to address previews, and its absence — the keyless fixture lane — is what makes a file row fall back to the Host opener instead of a dead tab. fileUrl moves from IWorkspaces to ConnectionHandle: the transport owns both the listener that serves the bytes and the port that addresses it.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
|
||||
README.md: cc7070500645f407b46f0326bf74479a81c579d8
|
||||
README.zh.md: 600b4f19d3262cb2381ec9368bc92b93db183603
|
||||
README.md: 5001da2458ea3470659f5983dffc8de832aadeac
|
||||
README.zh.md: 47e745964e4087c6ccc59aae5bbfba69f96480e4
|
||||
|
||||
@@ -12,11 +12,11 @@ The node half guards every request under `/api` before bridging (`src/api-reques
|
||||
|
||||
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`, which runs them in an opaque origin. A workspace file is not necessarily agent-authored: a read row makes every file in a cloned repository openable, so an active document served same-origin with `/api` would have its script pass the browser-trust fence into every method, the loopback-pinned settings and credential plane included. The cost is borne by the preview — `localStorage`, cookies, and same-origin `fetch` are unavailable inside it, so a generated page that remembers a theme will not — and `host.openPath` remains the full-capability way to open the same file on the Host machine. Restoring those capabilities without reopening the hole needs a separate origin, not a weaker header. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads.
|
||||
Workspace files are served from their own port, and therefore their own origin. That port is the isolation: a workspace file is not necessarily agent-authored — a read row makes every file in a cloned repository openable — so an active document served beside `/api` would have its script pass the browser-trust fence into every method, the loopback-pinned settings and credential plane included. A different origin closes that without touching the document: a preview keeps `localStorage`, cookies, and its own `fetch`, while a call to the API is cross-origin and refused twice over — by the fence's Origin check and by CORS. The alternative, `Content-Security-Policy: sandbox`, buys the same boundary by taking the document's origin away entirely, which measurably breaks the pages this route exists to show (a page that reads `localStorage` throws on load, and every listener declared after that line in the same script never binds). The listener binds the same host as the API, so a client that can reach the app can reach its previews; it answers the `/f` prefix and nothing else — no index, no SPA fallback, no API — and its port is published into the index page as `window.__DSH_FILES_PORT__`, which the browser half reads to address it. The same trust fence gates it, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads.
|
||||
|
||||
## Keyless fixture
|
||||
|
||||
The fixture carrier has no `/f` route, and `IWorkspaces.fileUrl` derives its URL in the browser regardless of carrier, so a file-path row clicked under `fixture=` opens a tab that 404s where the Host opener used to be a silent no-op. Fixture pages carry no file rows today; a fixture scenario that adds them should stub the derivation rather than teach the in-memory carrier to serve bytes.
|
||||
A fixture page is served by no host, so no workspace-file port is published into it and `ConnectionHandle.fileUrl` answers `undefined` — a file-path row falls back to the Host opener rather than opening a dead tab.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust
|
||||
|
||||
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`,让它们运行在不透明源中。工作区文件未必由 agent 撰写:一条 read 行就能让 clone 下来的仓库里任何文件变得可打开,因此与 `/api` 同源提供的活动文档,其脚本会带着浏览器信任 fence 通行到每一个方法,包括那些正因会改动设置与凭据而被钉在回环的方法。代价由预览承担——其中无法使用 `localStorage`、cookie 与同源 `fetch`,因此一个会记住主题的生成页面在预览里记不住——而 `host.openPath` 仍是在 Host 机器上以完整能力打开同一文件的方式。要在不重新打开这个洞的前提下取回那些能力,需要的是一个独立的源,而不是一个更弱的头。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。
|
||||
工作区文件由它自己的端口提供,因而拥有自己的源。那个端口就是隔离:工作区文件未必由 agent 撰写——一条 read 行就能让 clone 下来的仓库里任何文件变得可打开——因此与 `/api` 并排提供的活动文档,其脚本会带着浏览器信任 fence 通行到每一个方法,包括那些正因会改动设置与凭据而被钉在回环的方法。换一个源即可堵死这条,且不必动文档本身:预览保有 `localStorage`、cookie 与自己的 `fetch`,而对 API 的调用属于跨源,会被两道独立的关卡拒绝——fence 的 Origin 校验,以及 CORS。另一种做法 `Content-Security-Policy: sandbox` 用"干脆剥夺文档的源"换来同一条边界,而这经实测会破坏本路由存在的意义所在的那类页面(读 `localStorage` 的页面在加载时抛异常,同一 script 块中该行之后声明的所有监听器都不会绑定)。该监听器绑定与 API 相同的 host,因此能访问应用的客户端也能访问它的预览;它只应答 `/f` 前缀,别无其他——没有首页、没有 SPA 兜底、没有 API——其端口以 `window.__DSH_FILES_PORT__` 注入首页,由浏览器半侧读取来寻址。它由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。
|
||||
|
||||
## 无密钥 fixture
|
||||
|
||||
fixture 载体没有 `/f` 路由,而 `IWorkspaces.fileUrl` 无论载体为何都在浏览器侧推导 URL,因此在 `fixture=` 下点击文件路径行会打开一个 404 的标签页,而此处从前是 Host 打开器的静默空操作。今天的 fixture 页面并不含文件行;若某个 fixture 场景要加上它们,应当把这段推导打桩,而不是教这个内存载体去提供字节。
|
||||
fixture 页面不由任何 host 提供,因此没有工作区文件端口注入其中,`ConnectionHandle.fileUrl` 应答 `undefined`——文件路径行会回退到 Host 打开器,而不是打开一个空标签页。
|
||||
|
||||
任何 `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。
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
* controller with its sinks.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { FILES_PORT_GLOBAL } from '../files-server.ts'
|
||||
import type { IApiClient } from './api.ts'
|
||||
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
|
||||
import { FixtureApiClient } from './fixture.ts'
|
||||
@@ -56,6 +59,19 @@ export interface ConnectionHandle {
|
||||
* @returns stop handle for the loop.
|
||||
*/
|
||||
start(sinks: ConnectionSinks, config?: ConnectionConfig): { stop(): void }
|
||||
/**
|
||||
* Absolute URL serving one file out of a Session's workspace, on the
|
||||
* transport's own workspace-file origin — the same hostname the page is
|
||||
* reached by, a different port, so a served document is isolated from this
|
||||
* API without being stripped of its own capabilities.
|
||||
* @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 URL, or `undefined` when the path lies outside the workspace
|
||||
* (which this transport never serves) or when this page was not served by a
|
||||
* host that published a workspace-file port (the fixture carrier).
|
||||
*/
|
||||
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +84,15 @@ export function apply(ctx: Context): void {
|
||||
let started = false
|
||||
const handle: ConnectionHandle = {
|
||||
api,
|
||||
fileUrl(sessionId, cwd, path) {
|
||||
// Published by the node half's index tap; absent means no host is
|
||||
// serving workspace files to this page (the keyless fixture lane).
|
||||
const port = (globalThis as unknown as Record<string, unknown>)[FILES_PORT_GLOBAL]
|
||||
if (typeof port !== 'number') return undefined
|
||||
const segments = workspaceFileSegments(cwd, path)
|
||||
if (segments === undefined) return undefined
|
||||
return `${location.protocol}//${location.hostname}:${String(port)}${workspaceFileUrl(sessionId, segments)}`
|
||||
},
|
||||
start(sinks, config) {
|
||||
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
|
||||
started = true
|
||||
|
||||
127
packages/client/connection/src/files-server.ts
Normal file
127
packages/client/connection/src/files-server.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* The workspace-file listener: a second loopback/LAN socket on the same host
|
||||
* as the API, serving nothing but `/f`.
|
||||
*
|
||||
* The port is the isolation. A workspace file is not necessarily
|
||||
* agent-authored — a read row makes every file in a cloned repository
|
||||
* openable — so an active document must not be same-origin with `/api`, where
|
||||
* its script would pass the browser-trust fence into every method, the
|
||||
* loopback-pinned settings and credential plane included. A different port is
|
||||
* a different origin, which the browser enforces for free: the document keeps
|
||||
* `localStorage`, cookies, and its own `fetch`, while a call to the API is
|
||||
* cross-origin and refused twice over — by the fence's Origin check and by
|
||||
* CORS. The alternative, `Content-Security-Policy: sandbox`, buys the same
|
||||
* boundary by taking the document's origin away entirely, which measurably
|
||||
* breaks the pages this route exists to show.
|
||||
*/
|
||||
|
||||
import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { isTrustedApiRequest } from './api-request-trust.ts'
|
||||
import { handleWorkspaceFile, type WorkspaceFileDeps } from './workspace-files.ts'
|
||||
|
||||
/** A listening workspace-file server: its port, and the teardown that reaches quiescence. */
|
||||
export interface FilesServer {
|
||||
/** The bound port (OS-assigned), which the browser half needs to address this origin. */
|
||||
port: number
|
||||
/** Close the socket and destroy held connections; resolves once quiet. */
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the workspace-file listener.
|
||||
* @param host - the same bind host the API uses, so a client that can reach
|
||||
* the app can reach its previews (a LAN deployment included).
|
||||
* @param trustedHosts - the deployment's non-loopback serving authorities,
|
||||
* applied through the same fence as `/api`.
|
||||
* @param deps - the session-to-directory lookup reads are confined by.
|
||||
* @param onSocketError - reports a post-listen socket error; without a
|
||||
* listener node would raise it as an unhandled 'error' event.
|
||||
* @returns the bound port and its disposer.
|
||||
*/
|
||||
export async function listenForWorkspaceFiles(
|
||||
host: string,
|
||||
trustedHosts: readonly string[],
|
||||
deps: WorkspaceFileDeps,
|
||||
onSocketError: (error: Error) => void,
|
||||
): Promise<FilesServer> {
|
||||
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
if (!isTrustedApiRequest(req, trustedHosts)) {
|
||||
res.writeHead(403)
|
||||
res.end('forbidden')
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
|
||||
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
||||
// This origin serves one prefix and nothing else: no index, no SPA
|
||||
// fallback, no API. Anything else is not here — answered before the method
|
||||
// check, because a 405 would claim the resource exists.
|
||||
if (pathname !== FILES_PATH && !pathname.startsWith(`${FILES_PATH}/`)) {
|
||||
res.writeHead(404)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
// RFC 9110 §15.5.6: a 405 names the methods the resource does support.
|
||||
res.writeHead(405, { allow: 'GET, HEAD' })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
await handleWorkspaceFile(req, res, deps)
|
||||
}
|
||||
|
||||
const server: Server = createServer((req, res) => {
|
||||
handle(req, res).catch((error: unknown) => {
|
||||
// A malformed request must not become an unhandled rejection that takes
|
||||
// the process down; the API carrier guards its own handler the same way.
|
||||
if (res.headersSent) {
|
||||
res.destroy()
|
||||
return
|
||||
}
|
||||
onSocketError(error instanceof Error ? error : new Error(String(error)))
|
||||
res.writeHead(400)
|
||||
res.end()
|
||||
})
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, host, () => {
|
||||
server.off('error', reject)
|
||||
server.on('error', onSocketError)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
port: (server.address() as AddressInfo).port,
|
||||
// close + closeAllConnections: a held-open response would otherwise keep
|
||||
// teardown waiting forever.
|
||||
close: () => new Promise<void>((resolve) => {
|
||||
server.close(() => { resolve() })
|
||||
server.closeAllConnections()
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/** The global the node half hands its port to the browser half through. */
|
||||
export const FILES_PORT_GLOBAL = '__DSH_FILES_PORT__'
|
||||
|
||||
/**
|
||||
* Inject the workspace-file port into index.html, ahead of the shell bundle
|
||||
* that reads it. A boot-time fact of the serving host, delivered the way the
|
||||
* module graph is: synchronously on the page, so the first click on a produced
|
||||
* file does not race a round trip.
|
||||
* @param html - the index.html source.
|
||||
* @param port - the bound workspace-file port.
|
||||
* @returns the html with the port script injected.
|
||||
*/
|
||||
export function injectFilesPort(html: string, port: number): string {
|
||||
const script = `<script>window.${FILES_PORT_GLOBAL} = ${String(port)}</script>`
|
||||
const head = html.indexOf('<head>')
|
||||
if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
|
||||
/* v8 ignore next -- headless fixture pages may lack <head>; prepending keeps read-before-shell ordering. */
|
||||
return `${script}${html}`
|
||||
}
|
||||
@@ -4,14 +4,13 @@ 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 { injectFilesPort, listenForWorkspaceFiles } from './files-server.ts'
|
||||
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
|
||||
|
||||
export { API_PATH } from './api-path.ts'
|
||||
@@ -74,8 +73,10 @@ const PRIVILEGED_METHODS = new Set([
|
||||
* 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).
|
||||
* @returns a promise settling once the workspace-file listener is bound and
|
||||
* its port published — the page must never render before it can address one.
|
||||
*/
|
||||
export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
export async function apply(ctx: Context, config?: ConnectionConfig): Promise<void> {
|
||||
// The Loader resolves schema defaults; hand-built test contexts may pass none.
|
||||
const trustedHosts = config?.trustedHosts ?? []
|
||||
// Config boundary: a malformed entry fails the load loudly here rather than
|
||||
@@ -108,23 +109,19 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
|
||||
// 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') {
|
||||
// RFC 9110 §15.5.6: a 405 names the methods the resource does support.
|
||||
res.writeHead(405, { allow: 'GET, HEAD' })
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
await handleWorkspaceFile(req, res, { cwdFor })
|
||||
},
|
||||
}
|
||||
ctx.effect(() => ctx.httpServer.register(filesRoute), 'client-connection: /f route')
|
||||
// Workspace files get their own port, and therefore their own origin: an
|
||||
// active document served beside `/api` would reach every method through the
|
||||
// fence below. The listen is awaited inside the effect so the port is known
|
||||
// before the index tap that publishes it can run.
|
||||
await ctx.effect(async () => {
|
||||
const files = await listenForWorkspaceFiles(
|
||||
ctx.httpServer.host, trustedHosts, { cwdFor },
|
||||
(error) => { ctx.logger.error(error) },
|
||||
)
|
||||
const untap = ctx.httpServer.tapIndex(html => injectFilesPort(html, files.port))
|
||||
return async () => {
|
||||
untap()
|
||||
await files.close()
|
||||
}
|
||||
}, 'client-connection: /f listener')
|
||||
}
|
||||
|
||||
@@ -10,13 +10,11 @@
|
||||
* owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) —
|
||||
* this module is reached only by requests that already passed it.
|
||||
*
|
||||
* Script-capable documents are served into an opaque origin. A workspace file
|
||||
* is not necessarily agent-authored — a read row makes every file in a cloned
|
||||
* repository openable — so an active document served same-origin with `/api`
|
||||
* reaches the whole RPC surface, the loopback-pinned settings and credential
|
||||
* methods included. The sandbox costs a preview its `localStorage` and
|
||||
* cookies; restoring those without reopening that hole needs a separate
|
||||
* origin, not a weaker header.
|
||||
* Isolation is the listener's, not this module's: these responses carry no
|
||||
* sandbox header because they are served from their own port, and therefore
|
||||
* their own origin ([files-server](./files-server.ts)). A served document
|
||||
* keeps `localStorage`, cookies, and its own `fetch`, while the API stays
|
||||
* cross-origin to it.
|
||||
*/
|
||||
|
||||
import { createReadStream } from 'node:fs'
|
||||
@@ -59,17 +57,6 @@ const MIME: Record<string, string> = {
|
||||
|
||||
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'])
|
||||
|
||||
/**
|
||||
* The opaque origin an active workspace document runs in. Without it the
|
||||
* document is same-origin with `/api` and its script passes the browser-trust
|
||||
* fence, which admits every method — including the ones pinned to loopback
|
||||
* precisely because they mutate settings and credentials.
|
||||
*/
|
||||
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 {
|
||||
/**
|
||||
@@ -160,7 +147,6 @@ export async function handleWorkspaceFile(
|
||||
// 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()
|
||||
|
||||
@@ -8,10 +8,11 @@ import { apply, type ConnectionHandle } from '../src/client/index.ts'
|
||||
import { FixtureApiClient } from '../src/client/fixture.ts'
|
||||
import { WebApiClient } from '../src/client/web-api-client.ts'
|
||||
|
||||
type Win = { location?: { search: string } }
|
||||
type Win = { location?: { search: string; protocol?: string; hostname?: string }; __DSH_FILES_PORT__?: number }
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as Win).location
|
||||
delete (globalThis as Win).__DSH_FILES_PORT__
|
||||
})
|
||||
|
||||
async function mount(): Promise<ConnectionHandle> {
|
||||
@@ -62,4 +63,28 @@ describe('connection client apply', () => {
|
||||
}
|
||||
expect(seen.some(u => u.includes('/api/'))).toBe(true)
|
||||
})
|
||||
|
||||
it('addresses a workspace file on the port the host published, and only inside the workspace', async () => {
|
||||
const win = globalThis as Win
|
||||
win.location = { search: '', protocol: 'http:', hostname: '192.168.1.5' }
|
||||
win.__DSH_FILES_PORT__ = 4321
|
||||
const handle = await mount()
|
||||
const session = 's-1' as never
|
||||
// Same hostname the page was reached by — a LAN client must reach previews
|
||||
// too — and the published port, which is what makes it another origin.
|
||||
expect(handle.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html'))
|
||||
.toBe('http://192.168.1.5:4321/f/s-1/out/a%20b.html')
|
||||
// Outside the workspace there is nothing this transport may serve, which
|
||||
// is the signal a caller falls back to openPath on.
|
||||
expect(handle.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('serves no file URL on a page no host published a port into', async () => {
|
||||
const win = globalThis as Win
|
||||
win.location = { search: '?fixture', protocol: 'http:', hostname: '127.0.0.1' }
|
||||
const handle = await mount()
|
||||
// The keyless fixture lane: no workspace-file origin exists, so the row
|
||||
// falls back to the Host opener instead of opening a dead tab.
|
||||
expect(handle.fileUrl('s-1' as never, '/w', 'a.txt')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
44
packages/client/connection/tests/files-server.spec.ts
Normal file
44
packages/client/connection/tests/files-server.spec.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/** The workspace-file listener's own failure and publication paths. */
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { injectFilesPort, listenForWorkspaceFiles } from '../src/files-server.ts'
|
||||
|
||||
describe('workspace-file listener', () => {
|
||||
it('answers 400 and reports the failure when the directory lookup throws', async () => {
|
||||
const seen: Error[] = []
|
||||
const files = await listenForWorkspaceFiles(
|
||||
'127.0.0.1', [],
|
||||
{ cwdFor: () => Promise.reject(new Error('store unavailable')) },
|
||||
(error) => { seen.push(error) },
|
||||
)
|
||||
try {
|
||||
// A lookup failure is the host's problem, not a miss: it must not become
|
||||
// an unhandled rejection, and it must not be reported as "not found".
|
||||
const response = await fetch(`http://127.0.0.1:${String(files.port)}${FILES_PATH}/s-1/a.txt`)
|
||||
expect(response.status).toBe(400)
|
||||
expect(seen.map(error => error.message)).toEqual(['store unavailable'])
|
||||
} finally {
|
||||
await files.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('closes idempotently and stops answering', async () => {
|
||||
const files = await listenForWorkspaceFiles(
|
||||
'127.0.0.1', [], { cwdFor: async () => undefined }, () => {},
|
||||
)
|
||||
const origin = `http://127.0.0.1:${String(files.port)}`
|
||||
expect((await fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).status).toBe(404)
|
||||
await files.close()
|
||||
await files.close()
|
||||
await expect(fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('injectFilesPort', () => {
|
||||
it('publishes the port as the first script in head', () => {
|
||||
const html = injectFilesPort('<html><head><title>x</title></head></html>', 4321)
|
||||
expect(html).toContain('<head><script>window.__DSH_FILES_PORT__ = 4321</script>')
|
||||
// Ahead of anything the shell might read it from.
|
||||
expect(html.indexOf('__DSH_FILES_PORT__')).toBeLessThan(html.indexOf('<title>'))
|
||||
})
|
||||
})
|
||||
@@ -15,14 +15,21 @@ 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(). */
|
||||
function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
|
||||
function fakeHttpServer(
|
||||
routes: WebRoute[],
|
||||
taps: ((html: string) => string)[] = [],
|
||||
): Pick<HttpServerService, 'register' | 'tapIndex' | 'port' | 'host'> {
|
||||
return {
|
||||
register(route) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
tapIndex(transform) {
|
||||
taps.push(transform)
|
||||
return () => { taps.splice(taps.indexOf(transform), 1) }
|
||||
},
|
||||
port: 0,
|
||||
host: '127.0.0.1',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,21 +68,39 @@ function fakeApiProxy(workspaces: Record<string, string> = {}): ApiProxy {
|
||||
async function mounted(
|
||||
config?: { trustedHosts?: string[] },
|
||||
workspaces: Record<string, string> = {},
|
||||
): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
|
||||
): Promise<{ routes: WebRoute[]; taps: ((html: string) => string)[]; dispose: () => Promise<void> }> {
|
||||
const ctx = new Context()
|
||||
const routes: WebRoute[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
|
||||
const taps: ((html: string) => string)[] = []
|
||||
ctx.provide('httpServer', fakeHttpServer(routes, taps) as HttpServerService)
|
||||
ctx.provide('apiProxy', fakeApiProxy(workspaces))
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
|
||||
await fiber.await()
|
||||
return { routes, dispose: () => fiber.dispose() }
|
||||
return { routes, taps, 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
|
||||
/** One raw GET whose Host header is spoofed (fetch forbids setting it). */
|
||||
function statusWithHost(origin: string, path: string, host: string): Promise<number> {
|
||||
const url = new URL(origin)
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = httpRequest(
|
||||
{ host: url.hostname, port: url.port, path, method: 'GET', headers: { host } },
|
||||
(response) => {
|
||||
response.resume()
|
||||
response.on('end', () => { resolve(response.statusCode ?? 0) })
|
||||
},
|
||||
)
|
||||
request.on('error', reject)
|
||||
request.end()
|
||||
})
|
||||
}
|
||||
|
||||
/** The workspace-file origin the node half published into the index page. */
|
||||
function filesOrigin(taps: ((html: string) => string)[]): string {
|
||||
const html = taps.reduce((acc, tap) => tap(acc), '<head></head>')
|
||||
const port = /__DSH_FILES_PORT__ = (\d+)/.exec(html)?.[1]
|
||||
if (port === undefined) throw new Error(`no workspace-file port was published: ${html}`)
|
||||
return `http://127.0.0.1:${port}`
|
||||
}
|
||||
|
||||
describe('connection node half', () => {
|
||||
@@ -89,11 +114,19 @@ describe('connection node half', () => {
|
||||
expect(routes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('registers both transport prefix routes and removes them with the fiber', async () => {
|
||||
const { routes, dispose } = await mounted()
|
||||
expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }, { kind: 'prefix', path: FILES_PATH }])
|
||||
it('registers the /api route and publishes a separate workspace-file origin, both removed with the fiber', async () => {
|
||||
const { routes, taps, dispose } = await mounted()
|
||||
// The API keeps one prefix on the shared server; workspace files get a
|
||||
// port of their own, which is the origin boundary between them.
|
||||
expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }])
|
||||
const origin = filesOrigin(taps)
|
||||
expect(new URL(origin).port).not.toBe('')
|
||||
expect((await fetch(`${origin}${FILES_PATH}/absent/x.txt`)).status).toBe(404)
|
||||
await dispose()
|
||||
expect(routes).toHaveLength(0)
|
||||
expect(taps).toHaveLength(0)
|
||||
// Disposal reaches quiescence: the socket is gone, not merely unrouted.
|
||||
await expect(fetch(`${origin}${FILES_PATH}/absent/x.txt`)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
|
||||
@@ -154,7 +187,7 @@ describe('connection node half', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('connection node half: the /f workspace-file route', () => {
|
||||
describe('connection node half: the workspace-file origin', () => {
|
||||
/** 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-'))
|
||||
@@ -162,40 +195,35 @@ describe('connection node half: the /f workspace-file route', () => {
|
||||
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)
|
||||
expect(written.state.headers).toMatchObject({ allow: 'GET, HEAD' })
|
||||
it('applies the same browser-trust fence as /api, refuses writes, and serves nothing else', async () => {
|
||||
const { taps, dispose } = await mounted()
|
||||
const origin = filesOrigin(taps)
|
||||
// Rebound Host: refused before any filesystem work, exactly as on /api.
|
||||
// node's fetch refuses to set Host (a forbidden header), so the spoof goes
|
||||
// through the raw client — the same parse the server really performs.
|
||||
expect(await statusWithHost(origin, `${FILES_PATH}/s-1/index.html`, 'harness.example')).toBe(403)
|
||||
const written = await fetch(`${origin}${FILES_PATH}/s-1/index.html`, { method: 'POST' })
|
||||
expect(written.status).toBe(405)
|
||||
expect(written.headers.get('allow')).toBe('GET, HEAD')
|
||||
// This origin is one route wide: no index, no SPA fallback, no API.
|
||||
expect((await fetch(`${origin}/`)).status).toBe(404)
|
||||
expect((await fetch(`${origin}${API_PATH}/session.list`, { method: 'POST' })).status).toBe(404)
|
||||
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)
|
||||
const { taps, dispose } = await mounted(undefined, { 's-1': cwd })
|
||||
const origin = filesOrigin(taps)
|
||||
const served = await fetch(`${origin}${FILES_PATH}/s-1/index.html`)
|
||||
expect(served.status).toBe(200)
|
||||
expect(await served.text()).toBe('<h1>ok</h1>')
|
||||
// A served document keeps its own capabilities: the port is the boundary,
|
||||
// so nothing here strips the document of its origin.
|
||||
expect(served.headers.get('content-security-policy')).toBeNull()
|
||||
// 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)
|
||||
expect((await fetch(`${origin}${FILES_PATH}/s-absent/index.html`)).status).toBe(404)
|
||||
await dispose()
|
||||
await remove()
|
||||
})
|
||||
|
||||
@@ -59,27 +59,25 @@ function get(path: string, init?: RequestInit): Promise<Response> {
|
||||
}
|
||||
|
||||
describe('workspace file reads', () => {
|
||||
it('serves an active document into an opaque origin', async () => {
|
||||
it('serves a produced document with its own capabilities intact', 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')
|
||||
// A workspace file is not necessarily agent-authored, and same-origin
|
||||
// script here would pass the browser-trust fence into every RPC method.
|
||||
expect(response.headers.get('content-security-policy')).toContain('sandbox')
|
||||
expect(response.headers.get('content-security-policy')).not.toContain('allow-same-origin')
|
||||
// No isolation header: the listener's own port is the origin boundary, so
|
||||
// a preview keeps localStorage and cookies (see files-server).
|
||||
expect(response.headers.get('content-security-policy')).toBeNull()
|
||||
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 inert types unrestricted', async () => {
|
||||
it('types SVG as a standalone document rather than sniffable bytes', 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')
|
||||
expect(svg.headers.get('x-content-type-options')).toBe('nosniff')
|
||||
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('serves a workspace rooted at a filesystem root, whose realpath already ends in a separator', async () => {
|
||||
|
||||
Reference in New Issue
Block a user