diff --git a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.i18n.yaml new file mode 100644 index 0000000000..d1515fb973 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.i18n.yaml @@ -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/architecture/2026-09-17-web-feature-routes-and-route-gate.md +2026-09-17-web-feature-routes-and-route-gate.md: 5c18e23e827c4556c0012147f857f214ecc5641a +2026-09-17-web-feature-routes-and-route-gate.zh.md: 2ab045e288e61eb9ba8dc6df222bf3048a11363a diff --git a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md new file mode 100644 index 0000000000..5c18e23e82 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md @@ -0,0 +1,31 @@ +# Agent Note: Feature routes follow the document base, and a gate keeps them there + +Status: implemented + +English | [中文](2026-09-17-web-feature-routes-and-route-gate.zh.md) + +## Problem + +[Web app-owned routes are document-relative](2026-09-14-web-document-relative-app-routes.md) makes the shell, its plugin bundles, and its streams reach one listener under any mount. Routes owned by feature packages still addressed the origin root: `/open-in-app/...`, the deliverables `present.*` and `changes.*` routes, the session-log export download, the upload worker's `/api/session/uploadFileBinary` post, and the `/api/file` route behind local markdown image paths. Under a prefix-stripping proxy each of those requests missed. Nothing stopped a new browser-face reference from binding the bundle to one mount again. + +## Decision + +Every feature producer keeps its absolute registration key and derives the browser form beside it: `OPEN_IN_APP_*_PATH`/`*_ROUTE`, `PRESENT_*_PATH`/`*_ROUTE`, `CHANGED_FILES_PATH` and `CHANGES_*_PATH`/`*_ROUTE`, `SESSION_LOG_EXPORT_PATH`/`_ROUTE`, `FILE_UPLOAD_PATH`/`_ROUTE`. Browser code addresses only the `*_ROUTE` form, and a Fetch-shaped carrier a page installs (`FileUploadFetch`, `RpcFetch`) receives that relative route and resolves it against its own base. + +Two consumers need an absolute URL and resolve against `document.baseURI`: the upload worker, whose own base is a `blob:` URL, and the markdown image vocabulary, which emits only absolute `http(s)`, `blob`, or `data` destinations. + +`verify-client-route-resolution` runs in `hygiene` and CI static checks over every browser source the client compiler face compiles. It governs request targets — the first argument of a request constructor, a `fetch`-shaped call, or a dynamic import; an assigned resource property; or a JSX `src`/`href` attribute — and rejects a root-absolute, protocol-relative, or absolute app route (`api`, `plugins`, `open-in-app` prefixes), and a relative app route resolved against a `location` read. A shared `*_PATH`/`*_ENDPOINT` key used as a request target must be stripped. The same pass checks the Host producers of browser references: a `url`, `src`, or `href` field stamped with a root-absolute app route is rejected, while route keys and response-table keys stay absolute and out of scope. + +## Alternatives considered + +**Banning every `location` read in browser code.** Rejected: identity comparisons, the preview's own query read, and the worker tunnel's host-relative mapping are all legitimate. The gate governs request targets, which is where a mount-bound route appears. + +**Rewriting feature responses in the proxy.** Rejected: it duplicates the mount in deployment configuration for each route the proxy does not know about, and the document already resolves relative references. + +## Consequences + +The gate reads inline literal fragments only, so a composed reference (`comboReference(...)`, `artifact.url.slice(1)`) is not traced; `packages/client/modules/tests/node-half.client.spec.ts` owns the regression signal for composed graph rows, batch descriptors, and the source-map trailer. Preview pages do not expose their worker's file API at the document URL, so markdown local-file image URLs cannot reach that Host. [Session prose local media display](../feature/2026-09-07-session-prose-local-media-display.md) owns the image vocabulary. + +## Testing + +The gate's own spec pins a negative control per rule: interpolated targets (`${origin}/api/file`), protocol-relative and absolute forms, an unstripped shared key, a relative route resolved against a `location` read, and root-absolute producer fields. Each feature package's specs assert the relative route its browser half sends; `markdown-images.e2e.ts` keeps the local-image path covered end to end. diff --git a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.zh.md b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.zh.md new file mode 100644 index 0000000000..2ab045e288 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 功能路由遵循文档 base,并由门禁维持 + +Status: implemented + +[English](2026-09-17-web-feature-routes-and-route-gate.md) | 中文 + +## 问题 + +[Web 应用自有路由相对文档寻址](2026-09-14-web-document-relative-app-routes.zh.md)让外壳、插件 bundle 与流在任一挂载下到达同一监听端口。功能包自有的路由仍寻址源站根:`/open-in-app/...`、deliverables 的 `present.*` 与 `changes.*` 路由、会话日志导出下载、上传 worker 的 `/api/session/uploadFileBinary` 提交,以及本地 markdown 图片路径背后的 `/api/file` 路由。在剥离前缀的代理之后,这些请求全部落空。也没有任何机制阻止新的浏览器侧引用再次把 bundle 绑定到单一挂载。 + +## 决策 + +每个功能生产者保留其绝对注册键,并在旁边派生浏览器形式:`OPEN_IN_APP_*_PATH`/`*_ROUTE`、`PRESENT_*_PATH`/`*_ROUTE`、`CHANGED_FILES_PATH` 与 `CHANGES_*_PATH`/`*_ROUTE`、`SESSION_LOG_EXPORT_PATH`/`_ROUTE`、`FILE_UPLOAD_PATH`/`_ROUTE`。浏览器代码只寻址 `*_ROUTE` 形式;页面安装的 Fetch 形载体(`FileUploadFetch`、`RpcFetch`)收到该相对路由,并相对自身基准解析。 + +两个消费方需要绝对 URL,并相对 `document.baseURI` 解析:上传 worker(其自身基准是 `blob:` URL)与 markdown 图片词汇表(只输出绝对的 `http(s)`、`blob` 或 `data` 目标)。 + +`verify-client-route-resolution` 在 `hygiene` 与 CI 静态检查中运行,覆盖 client 编译面编译的每个浏览器源文件。它治理请求目标——请求构造器、`fetch` 形调用或动态 import 的第一个参数;赋值的资源属性;JSX 的 `src`/`href` 属性——拒绝根绝对、协议相对或绝对的应用路由(`api`、`plugins`、`open-in-app` 前缀),以及相对 `location` 读取解析的相对应用路由。作为请求目标使用的共享 `*_PATH`/`*_ENDPOINT` 键必须先剥去前导斜杠。同一遍检查还覆盖宿主侧浏览器引用的生产者:以根绝对应用路由写入的 `url`、`src` 或 `href` 字段被拒绝,而路由键与响应表键保持绝对,不在范围内。 + +## 曾考虑的替代方案 + +**在浏览器代码中禁止一切 `location` 读取。** 否决:身份比较、preview 自身查询读取、worker tunnel 的宿主相对映射都属正当。门禁治理请求目标——绑定挂载点的路由真正出现的位置。 + +**在代理中改写功能响应。** 否决:这会为代理并不知晓的每条路由在部署配置里重复挂载点,而文档本就能解析相对引用。 + +## 后果 + +门禁只读取内联字面片段,因此组合出来的引用(`comboReference(...)`、`artifact.url.slice(1)`)不被追踪;组合而成的启动图 row、批次描述符与 source-map trailer 的回归信号由 `packages/client/modules/tests/node-half.client.spec.ts` 持有。Preview 页面不会在文档 URL 下暴露其 worker 的文件 API,因此 markdown 本地文件图片 URL 无法到达该 Host。[会话正文本地媒体展示](../feature/2026-09-07-session-prose-local-media-display.zh.md)持有图片词汇表。 + +## 测试 + +门禁自身的 spec 为每条规则固定了负向对照:插值目标(`${origin}/api/file`)、协议相对与绝对形式、未剥前导斜杠的共享键、相对路由相对 `location` 读取解析,以及生产者中根绝对的引用字段。各功能包的 spec 断言其浏览器半侧发出的相对路由;`markdown-images.e2e.ts` 端到端维持本地图片路径的覆盖。 diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml index c52f0a47a2..daa947a20c 100644 --- a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md -2026-09-07-session-prose-local-media-display.md: ed740acd0d0d6eaf7f8834dc8d6280a33305aecd -2026-09-07-session-prose-local-media-display.zh.md: de8a7f99d7aabc4474f525f9f37f50a465a80840 +2026-09-07-session-prose-local-media-display.md: fe769d1cbe0b54eed355a7e699bf853a497820cb +2026-09-07-session-prose-local-media-display.zh.md: 2cfff4016f42a13d94a1097602ff2dadbd28d2a7 diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md index ed740acd0d..fe769d1cbe 100644 --- a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md @@ -14,7 +14,7 @@ Local media paths in Session prose render through a same-origin file route. This `ui-primitives` owns the `MarkdownPathImages` vocabulary on `MarkdownText`. Like `fileMentions`, it applies only after a message settles so frozen streaming blocks cannot cache a vocabulary handler. The settled pass rewrites image destinations outside the remote-URL allowlist and emits only absolute `http(s)`, `blob`, or `data` results. Without a vocabulary, local destinations retain inert alt text. Failed loads replace the image with authored alt text, or its original destination when alt is empty; a different source can load again. -`ui-chat` supplies a page-stable `localPathMediaUrl` vocabulary through `AssistantMarkdown`. It maps absolute POSIX paths to `/api/file?path=…` on the page's origin. Relative and protocol-relative paths, Windows-style paths, and non-HTTP page transports such as Electron `file://` remain inert. +`ui-chat` supplies a page-stable `localPathMediaUrl` vocabulary through `AssistantMarkdown`. It maps absolute POSIX paths to `api/file?path=…` resolved against `document.baseURI`, so the request stays under whatever mount the served document came from. Relative and protocol-relative paths, Windows-style paths, and non-HTTP page transports such as Electron `file://` remain inert. `session-controller` owns the `SessionMediaReferences` contribution beside `SessionFileReferences`. It registers through `connection.fetch`, which applies the same browser authentication and trust checks as `/api` RPC. The fixed same-origin endpoint gives the synchronous renderer a stable URL without an asynchronous capability negotiation. diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md index de8a7f99d7..2cfff4016f 100644 --- a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md @@ -14,7 +14,7 @@ Session 正文中的本地媒体路径通过同源文件路由渲染。本记录 `ui-primitives` 拥有 `MarkdownText` 上的 `MarkdownPathImages` 词表。与 `fileMentions` 一样,它只在消息稳定后生效,使冻结的流式块无法缓存词表处理函数。稳定渲染过程重写远程 URL 白名单之外的图片目标,并只输出绝对 `http(s)`、`blob` 或 `data` 结果。没有词表时,本地目标保留静态 alt 文本。加载失败会把图片替换为作者提供的 alt 文本;alt 为空时显示原始目标路径;不同来源仍可重新加载。 -`ui-chat` 通过 `AssistantMarkdown` 提供页面稳定的 `localPathMediaUrl` 词表。它把绝对 POSIX 路径映射到页面同源的 `/api/file?path=…`。相对路径、协议相对路径、Windows 风格路径,以及 Electron `file://` 等非 HTTP 页面传输保持静态回退。 +`ui-chat` 通过 `AssistantMarkdown` 提供页面稳定的 `localPathMediaUrl` 词表。它把绝对 POSIX 路径映射到相对 `document.baseURI` 解析的 `api/file?path=…`,因此请求始终停留在所服务文档来源的那个挂载之下。相对路径、协议相对路径、Windows 风格路径,以及 Electron `file://` 等非 HTTP 页面传输保持静态回退。 `session-controller` 在 `SessionFileReferences` 旁拥有 `SessionMediaReferences` 贡献。它通过 `connection.fetch` 注册;该通道执行与 `/api` RPC 相同的浏览器鉴权和信任检查。固定同源端点让同步渲染器获得稳定 URL,无需异步能力协商。 diff --git a/package.json b/package.json index 7eb8fb2782..4cb2f0e828 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "verify-npm-install-layout": "tsx scripts/verify-npm-install-layout.ts", "verify-client-packages": "tsx scripts/verify-client-packages.ts", "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts", + "verify-client-route-resolution": "tsx scripts/verify-client-route-resolution.ts", "verify-no-bare-dispatcher": "tsx scripts/verify-no-bare-dispatcher.ts", "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", "rescope-vendor": "tsx scripts/rescope-vendor.ts", diff --git a/packages/client/file-upload/src/client/runtime.ts b/packages/client/file-upload/src/client/runtime.ts index 6601da4878..b7866edfa6 100644 --- a/packages/client/file-upload/src/client/runtime.ts +++ b/packages/client/file-upload/src/client/runtime.ts @@ -5,13 +5,14 @@ import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { FILE_UPLOAD_PATH } from '../protocol.ts' +import { FILE_UPLOAD_ROUTE } from '../protocol.ts' import type { ClientFileUploadHooks, EncodedFileUploadRequest, FileUploadFetch, FileUploadValue, } from '../types.ts' import type { FileUploadBody, FileUploadService } from './contract.ts' interface FileUploadRequest { + /** Document-relative app-owned upload route, query string included. */ readonly path: string readonly body: FileUploadBody readonly headers?: Readonly> @@ -198,7 +199,7 @@ export class FileUploadRuntime extends Service implements FileUploadService { const query = new URLSearchParams({ sessionId }) if (name !== undefined) query.set('name', name) const response = await this.post({ - path: `${FILE_UPLOAD_PATH}?${query.toString()}`, + path: `${FILE_UPLOAD_ROUTE}?${query.toString()}`, body: data, headers: { 'content-type': 'application/octet-stream' }, ...(signal === undefined ? {} : { signal }), @@ -230,7 +231,7 @@ function customTransport(customFetch: FileUploadFetch): FileUploadTransport { ...(request.body instanceof ReadableStream ? { duplex: 'half' as const } : {}), ...(request.signal === undefined ? {} : { signal: request.signal }), } - const response = await customFetch(resolveUrl(request.path), init) + const response = await customFetch(request.path, init) return { status: response.status, body: await response.text() } }, } @@ -284,7 +285,8 @@ function workerTransport(): FileUploadTransport { } request.signal?.addEventListener('abort', abort, { once: true }) const message: UploadWorkerStart = { - url: resolveUrl(request.path).href, + // The Worker's own base is `blob:`, so its request URL must be absolute. + url: new URL(request.path, document.baseURI).href, body: request.body, headers: request.headers ?? {}, } @@ -295,15 +297,6 @@ function workerTransport(): FileUploadTransport { } } -function resolveUrl(path: string): URL { - const pageLocation = Reflect.get(globalThis, 'location') as unknown - const origin = typeof pageLocation === 'object' && pageLocation !== null - && 'origin' in pageLocation && typeof pageLocation.origin === 'string' - ? pageLocation.origin - : undefined - return new URL(path, origin === undefined || origin === 'null' ? 'http://dsh.internal' : origin) -} - function parseFileUploadResult(body: string): RemoteResult { const value = JSON.parse(body) as unknown if (!isRecord(value) || typeof value.ok !== 'boolean') { diff --git a/packages/client/file-upload/src/protocol.ts b/packages/client/file-upload/src/protocol.ts index 67ec32811a..17edde2a70 100644 --- a/packages/client/file-upload/src/protocol.ts +++ b/packages/client/file-upload/src/protocol.ts @@ -1,2 +1,8 @@ /** Authenticated raw-byte route owned by the file-upload service. */ export const FILE_UPLOAD_PATH = '/api/session/uploadFileBinary' + +/** + * Browser-relative form of {@link FILE_UPLOAD_PATH}; see + * .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md. + */ +export const FILE_UPLOAD_ROUTE = FILE_UPLOAD_PATH.slice(1) diff --git a/packages/client/file-upload/src/types.ts b/packages/client/file-upload/src/types.ts index b63b2452bf..ff153925af 100644 --- a/packages/client/file-upload/src/types.ts +++ b/packages/client/file-upload/src/types.ts @@ -23,11 +23,12 @@ export type FileUploadReceiptId = Branded<'file-upload-receipt-id'> /** * Fetch-shaped carrier installed by a page that owns its Host transport. - * @param input - absolute same-origin upload URL. + * @param input - document-relative upload route; a carrier that needs an + * absolute URL resolves it against its own base. * @param init - raw request body, headers, and cancellation signal. * @returns the Host response. */ -export type FileUploadFetch = (input: URL, init: RequestInit) => Promise +export type FileUploadFetch = (input: string | URL, init: RequestInit) => Promise /** Pre-Cordis hook supplied by a page whose Host runs in another execution context. */ export interface ClientFileUploadHooks { diff --git a/packages/client/file-upload/tests/file-upload.client.spec.ts b/packages/client/file-upload/tests/file-upload.client.spec.ts index ac9e63f499..2392feed55 100644 --- a/packages/client/file-upload/tests/file-upload.client.spec.ts +++ b/packages/client/file-upload/tests/file-upload.client.spec.ts @@ -213,8 +213,7 @@ describe('file upload worker body', () => { describe('file upload service', () => { it('uses a page-owned Host fetch for Blob and ReadableStream bodies', async () => { - vi.stubGlobal('location', { origin: 'https://preview.test' }) - const fetch = vi.fn((_url: URL, _init?: RequestInit) => + const fetch = vi.fn((_url: string | URL, _init?: RequestInit) => Promise.resolve(new Response('accepted', { status: 202 }))) ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch } const ctx = new Context() @@ -223,30 +222,29 @@ describe('file upload service', () => { const blob = new Blob(['opaque']) const signal = new AbortController().signal await expect((ctx.fileUpload as FileUploadRuntime).post({ - path: '/api/upload', body: blob, headers: { 'x-test': 'yes' }, signal, + path: 'api/upload', body: blob, headers: { 'x-test': 'yes' }, signal, })).resolves.toEqual({ status: 202, body: 'accepted' }) - expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/api/upload'), { + expect(fetch).toHaveBeenLastCalledWith('api/upload', { method: 'POST', headers: { 'x-test': 'yes' }, body: blob, signal, }) const stream = new ReadableStream({ start(controller) { controller.close() } }) - await (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream }) - expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/stream'), { + await (ctx.fileUpload as FileUploadRuntime).post({ path: 'stream', body: stream }) + expect(fetch).toHaveBeenLastCalledWith('stream', { method: 'POST', body: stream, duplex: 'half', }) await fiber.dispose() }) - it('mounts through the plugin entry and resolves non-browser URLs', async () => { - vi.stubGlobal('location', { origin: 'null' }) + it('mounts through the plugin entry with a document-relative route', async () => { const fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 204 }))) ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch } const ctx = new Context() const fiber = ctx.plugin({ apply }) await fiber const body = new Blob() - await (ctx.fileUpload as FileUploadRuntime).post({ path: '/fallback', body }) - expect(fetch).toHaveBeenCalledWith(new URL('http://dsh.internal/fallback'), { + await (ctx.fileUpload as FileUploadRuntime).post({ path: 'api/session/uploadFileBinary', body }) + expect(fetch).toHaveBeenCalledWith('api/session/uploadFileBinary', { method: 'POST', body, }) await fiber.dispose() @@ -257,7 +255,7 @@ describe('file upload service', () => { const ctx = new Context() const fiber = ctx.plugin(FileUploadRuntime) await fiber - await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })) + await expect((ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob() })) .rejects.toThrow('background upload requires Web Worker support') await fiber.dispose() }) @@ -274,19 +272,19 @@ describe('file upload service', () => { constructor(readonly url: string, readonly options: WorkerOptions) { FakeWorker.last = this } } vi.stubGlobal('Worker', FakeWorker) - vi.stubGlobal('location', { origin: 'https://harness.test' }) + vi.stubGlobal('document', { baseURI: 'https://harness.test/mount/' }) const ctx = new Context() const fiber = ctx.plugin(FileUploadRuntime) await fiber const progress = vi.fn() const blob = new Blob(['bytes']) - const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/api/upload', body: blob, onProgress: progress }) + const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: 'api/upload', body: blob, onProgress: progress }) const worker = FakeWorker.last if (worker === undefined) throw new Error('worker missing') expect(created).toHaveBeenCalledOnce() expect(revoked).toHaveBeenCalledWith('blob:worker') expect(worker.postMessage).toHaveBeenCalledWith({ - url: 'https://harness.test/api/upload', body: blob, headers: {}, + url: 'https://harness.test/mount/api/upload', body: blob, headers: {}, }) worker.onmessage?.({ data: { kind: 'progress', loaded: 4, total: 5 } } as MessageEvent) worker.onmessage?.({ data: { kind: 'progress', loaded: 6 } } as MessageEvent) @@ -313,11 +311,12 @@ describe('file upload service', () => { constructor() { FakeWorker.last = this } } vi.stubGlobal('Worker', FakeWorker) + vi.stubGlobal('document', { baseURI: 'https://harness.test/mount/' }) const ctx = new Context() const fiber = ctx.plugin(FileUploadRuntime) await fiber const stream = new ReadableStream({ start(controller) { controller.close() } }) - const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream }) + const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: 'stream', body: stream }) const worker = FakeWorker.last if (worker === undefined) throw new Error('worker missing') expect(worker.postMessage).toHaveBeenCalledWith(expect.objectContaining({ body: stream }), [stream]) @@ -338,31 +337,32 @@ describe('file upload service', () => { constructor() { FakeWorker.all.push(this) } } vi.stubGlobal('Worker', FakeWorker) + vi.stubGlobal('document', { baseURI: 'https://harness.test/mount/' }) const ctx = new Context() const fiber = ctx.plugin(FileUploadRuntime) await fiber - const reported = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }) + const reported = (ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob() }) FakeWorker.all[0]?.onmessage?.({ data: { kind: 'error', message: 'network failed' } } as MessageEvent) await expect(reported).rejects.toThrow('network failed') - const errored = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }) + const errored = (ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob() }) FakeWorker.all[1]?.onerror?.({ message: 'worker crashed' } as ErrorEvent) await expect(errored).rejects.toThrow('worker crashed') - const unnamed = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }) + const unnamed = (ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob() }) FakeWorker.all[2]?.onerror?.({ message: '' } as ErrorEvent) await expect(unnamed).rejects.toThrow('background upload worker failed') const controller = new AbortController() - const aborted = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: controller.signal }) + const aborted = (ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob(), signal: controller.signal }) controller.abort() await expect(aborted).rejects.toMatchObject({ name: 'AbortError' }) expect(FakeWorker.all[3]?.terminate).toHaveBeenCalledOnce() const already = new AbortController() already.abort() - await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: already.signal })) + await expect((ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob(), signal: already.signal })) .rejects.toMatchObject({ name: 'AbortError' }) expect(FakeWorker.all[4]?.postMessage).not.toHaveBeenCalled() await fiber.dispose() @@ -390,9 +390,8 @@ describe('Session-addressed file upload', () => { } it('assembles the scoped streaming request and parses progress and receipt fields', async () => { - vi.stubGlobal('location', { origin: 'https://preview.test' }) const progress = vi.fn() - const fetch = vi.fn((_url: URL, init: RequestInit) => { + const fetch = vi.fn((_url: string | URL, init: RequestInit) => { expect(init.body).toBeInstanceOf(Blob) progress({ loaded: 2, total: 4 }) return Promise.resolve(new Response(JSON.stringify({ @@ -416,7 +415,7 @@ describe('Session-addressed file upload', () => { }, }) expect(fetch).toHaveBeenCalledWith( - new URL('https://preview.test/api/session/uploadFileBinary?sessionId=s1&name=notes+%26+refs.pdf'), + 'api/session/uploadFileBinary?sessionId=s1&name=notes+%26+refs.pdf', expect.objectContaining({ method: 'POST', headers: { 'content-type': 'application/octet-stream' }, @@ -448,7 +447,6 @@ describe('Session-addressed file upload', () => { }) it('rejects malformed background results', async () => { - vi.stubGlobal('location', { origin: 'https://preview.test' }) ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch: () => Promise.resolve(new Response(null, { status: 413 })), } diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 3212801e00..13ff7fae4b 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -10,19 +10,16 @@ import { useSearchableHidden } from './searchable-hidden.ts' import css from './AssistantMarkdown.module.css' /** - * Map one authored media destination to the same-origin workspace-file URL. - * @param protocol - `window.location.protocol` at render time. - * @param origin - `window.location.origin` at render time. - * @param value - The authored markdown destination, exactly as written. - * @returns The API URL for an absolute POSIX path on an HTTP(S) page, or - * undefined when the destination cannot be a Host-served local file - * (non-HTTP transport such as Electron `file://`, protocol-relative or - * relative destinations). + * Resolve an authored POSIX image path against the document's file API. + * @param base - canonical `document.baseURI` at render time. + * @param value - authored markdown destination. + * @returns an absolute HTTP(S) file-API URL, or undefined for unsupported + * protocols and non-local paths. */ -export function localPathMediaUrl(protocol: string, origin: string, value: string): string | undefined { - if (protocol !== 'http:' && protocol !== 'https:') return undefined - if (value.length === 0 || !value.startsWith('/') || value.startsWith('//')) return undefined - return `${origin}/api/file?path=${encodeURIComponent(value)}` +export function localPathMediaUrl(base: string, value: string): string | undefined { + if (!value.startsWith('/') || value.startsWith('//')) return undefined + if (!base.startsWith('http:') && !base.startsWith('https:')) return undefined + return new URL(`api/file?path=${encodeURIComponent(value)}`, base).href } export interface AssistantMarkdownProps { @@ -50,12 +47,9 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. const labels = useMemo(() => markdownLabels(t), [t]) - // Local media paths in the closing prose rewrite to the same-origin file - // API (policy re-validation lives host-side). The vocabulary identity is - // stable per page load because MarkdownText memoizes on it. + // MarkdownText memoizes its vocabulary; keep its identity stable across renders. const pathImages = useMemo(() => { - const { protocol, origin } = window.location - return { resolve: value => localPathMediaUrl(protocol, origin, value) } + return { resolve: value => localPathMediaUrl(document.baseURI, value) } }, []) const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so diff --git a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx index 564f5a6a41..7af96d9cd5 100644 --- a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx +++ b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx @@ -14,31 +14,39 @@ function textBlock(text: string): AssistantBlock { return { kind: 'text', text } } -const ORIGIN = 'http://127.0.0.1:3080' +const BASE = 'http://127.0.0.1:3080/' +const MOUNTED_BASE = 'http://127.0.0.1:3080/tools/dsh/' describe('localPathMediaUrl', () => { - it('maps an absolute POSIX path on an HTTP page to the file API', () => { - expect(localPathMediaUrl('http:', ORIGIN, '/tmp/graph.png')) - .toBe(`${ORIGIN}/api/file?path=${encodeURIComponent('/tmp/graph.png')}`) - expect(localPathMediaUrl('https:', 'https://127.0.0.1:3080', '/tmp/graph.png')) + it('maps an absolute POSIX path on an HTTP page to the file route of its document', () => { + expect(localPathMediaUrl(BASE, '/tmp/graph.png')) + .toBe(`${BASE}api/file?path=${encodeURIComponent('/tmp/graph.png')}`) + expect(localPathMediaUrl('https://127.0.0.1:3080/', '/tmp/graph.png')) .toBe(`https://127.0.0.1:3080/api/file?path=${encodeURIComponent('/tmp/graph.png')}`) }) + it('keeps the route beneath a mount the document is served from', () => { + expect(localPathMediaUrl(MOUNTED_BASE, '/tmp/graph.png')) + .toBe(`${MOUNTED_BASE}api/file?path=${encodeURIComponent('/tmp/graph.png')}`) + expect(localPathMediaUrl('http://127.0.0.1:3080/tools/dsh/index.html', '/tmp/graph.png')) + .toBe(`${MOUNTED_BASE}api/file?path=${encodeURIComponent('/tmp/graph.png')}`) + }) + it('keeps non-HTTP transports inert', () => { - expect(localPathMediaUrl('file:', 'file:///app', '/tmp/graph.png')).toBeUndefined() - expect(localPathMediaUrl('ws:', ORIGIN, '/tmp/graph.png')).toBeUndefined() + expect(localPathMediaUrl('about:blank', '/tmp/graph.png')).toBeUndefined() + expect(localPathMediaUrl('dsh-app://app/', '/tmp/graph.png')).toBeUndefined() }) it('keeps destinations that cannot be Host-served local files inert', () => { - expect(localPathMediaUrl('http:', ORIGIN, '')).toBeUndefined() - expect(localPathMediaUrl('http:', ORIGIN, '//cdn.example.com/x.png')).toBeUndefined() - expect(localPathMediaUrl('http:', ORIGIN, 'relative.png')).toBeUndefined() - expect(localPathMediaUrl('http:', ORIGIN, 'C:\\tmp\\x.png')).toBeUndefined() + expect(localPathMediaUrl(BASE, '')).toBeUndefined() + expect(localPathMediaUrl(BASE, '//cdn.example.com/x.png')).toBeUndefined() + expect(localPathMediaUrl(BASE, 'relative.png')).toBeUndefined() + expect(localPathMediaUrl(BASE, 'C:\\tmp\\x.png')).toBeUndefined() }) it('encodes the full path including spaces', () => { - expect(localPathMediaUrl('http:', ORIGIN, '/tmp/my graph.png')) - .toBe(`${ORIGIN}/api/file?path=${encodeURIComponent('/tmp/my graph.png')}`) + expect(localPathMediaUrl(BASE, '/tmp/my graph.png')) + .toBe(`${BASE}api/file?path=${encodeURIComponent('/tmp/my graph.png')}`) }) }) diff --git a/packages/client/ui-deliverables/src/changes.ts b/packages/client/ui-deliverables/src/changes.ts index 1e4ec7dddd..2124b41ff1 100644 --- a/packages/client/ui-deliverables/src/changes.ts +++ b/packages/client/ui-deliverables/src/changes.ts @@ -11,6 +11,18 @@ export const CHANGES_DIFF_PATH = '/api/changes.diff' /** Authenticated POST route for opening a changed file on the Host desktop. */ export const CHANGES_OPEN_PATH = '/api/changes.open' +/** + * Browser-relative form of {@link CHANGED_FILES_PATH}; see + * .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md. + */ +export const CHANGED_FILES_ROUTE = CHANGED_FILES_PATH.slice(1) + +/** Browser-relative form of {@link CHANGES_DIFF_PATH}. */ +export const CHANGES_DIFF_ROUTE = CHANGES_DIFF_PATH.slice(1) + +/** Browser-relative form of {@link CHANGES_OPEN_PATH}. */ +export const CHANGES_OPEN_ROUTE = CHANGES_OPEN_PATH.slice(1) + /** Resource-address prefix of a turn's review tab in the right Sidebar. */ export const CHANGES_REVIEW_ADDRESS = 'dsh-resource://changes-review/session/' @@ -94,10 +106,10 @@ export function isChangesEvent(value: unknown): value is { turn: number } { * Build authenticated coordinates for the summary one `workspace/changes` event announced. * @param sessionId - owning Session. * @param seq - event sequence. - * @returns same-origin summary URL. + * @returns document-relative summary route. */ export function changesSummaryUrl(sessionId: SessionId, seq: number): string { - return `${CHANGED_FILES_PATH}?${new URLSearchParams({ sessionId, seq: String(seq) })}` + return `${CHANGED_FILES_ROUTE}?${new URLSearchParams({ sessionId, seq: String(seq) })}` } /** @@ -105,10 +117,10 @@ export function changesSummaryUrl(sessionId: SessionId, seq: number): string { * @param sessionId - owning Session. * @param seq - workspace/changes event sequence. * @param index - original index in the summary's files array. - * @returns same-origin comparison URL. + * @returns document-relative comparison route. */ export function changesDiffUrl(sessionId: SessionId, seq: number, index: number): string { - return `${CHANGES_DIFF_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` + return `${CHANGES_DIFF_ROUTE}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` } /** @@ -116,10 +128,10 @@ export function changesDiffUrl(sessionId: SessionId, seq: number, index: number) * @param sessionId - owning Session. * @param seq - workspace/changes event sequence. * @param index - original index in the summary's files array. - * @returns same-origin action URL. + * @returns document-relative action route. */ export function changedFileUrl(sessionId: SessionId, seq: number, index: number): string { - return `${CHANGES_OPEN_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` + return `${CHANGES_OPEN_ROUTE}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` } /** diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index aef36604aa..d724816d19 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -2,7 +2,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { changedFileUrl } from '../changes.ts' -import { presentedFileUrl, PRESENT_HOST_PATH, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts' +import { presentedFileUrl, PRESENT_HOST_ROUTE, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts' /** State of the latest explicit open gesture for one saved file. */ export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' | 'nativeUnavailable' @@ -86,7 +86,7 @@ export class PresentedOpenController { private async readHost(signal: AbortSignal): Promise { let host: PresentedHost | 'error' = 'error' try { - const response = await fetch(PRESENT_HOST_PATH, { signal }) + const response = await fetch(PRESENT_HOST_ROUTE, { signal }) if (response.ok) { const value: unknown = await response.json() if (isPresentedHost(value)) host = value diff --git a/packages/client/ui-deliverables/src/presented.ts b/packages/client/ui-deliverables/src/presented.ts index f4f2ed92d4..f9274f067a 100644 --- a/packages/client/ui-deliverables/src/presented.ts +++ b/packages/client/ui-deliverables/src/presented.ts @@ -9,6 +9,15 @@ export const PRESENT_OPEN_PATH = '/api/present.open' /** Authenticated desktop availability and destination metadata. */ export const PRESENT_HOST_PATH = '/api/present.host' +/** + * Browser-relative form of {@link PRESENT_OPEN_PATH}; see + * .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md. + */ +export const PRESENT_OPEN_ROUTE = PRESENT_OPEN_PATH.slice(1) + +/** Browser-relative form of {@link PRESENT_HOST_PATH}. */ +export const PRESENT_HOST_ROUTE = PRESENT_HOST_PATH.slice(1) + /** Native file action selected by an explicit user gesture. */ export type PresentedAction = 'open' | 'reveal' @@ -49,10 +58,10 @@ export function isPresentedFile(value: unknown): value is PresentedFile { * @param sessionId - owning Session. * @param seq - deliverables/presented event sequence. * @param index - original index in the event's files array. - * @returns same-origin file action URL. + * @returns document-relative file action route. */ export function presentedFileUrl(sessionId: SessionId, seq: number, index: number): string { - return `${PRESENT_OPEN_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` + return `${PRESENT_OPEN_ROUTE}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` } /** diff --git a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts index 78078c7a80..b391263de5 100644 --- a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts @@ -77,7 +77,7 @@ async function fixture() { describe('change summary route', () => { it('serves the Host-held summary without its working directory, and 404 once it is gone', async () => { const { read, data, summary } = await fixture() - expect(changesSummaryUrl(SessionId('owner'), 9)).toBe(`${CHANGED_FILES_PATH}?sessionId=owner&seq=9`) + expect(changesSummaryUrl(SessionId('owner'), 9)).toBe('api/changes.summary?sessionId=owner&seq=9') const response = await read() expect(response.status).toBe(200) expect(response.headers.get('cache-control')).toBe('no-store') @@ -95,7 +95,7 @@ describe('change summary route', () => { describe('change comparison route', () => { it('serves the Host-computed comparison, 404 once it is gone, and 500 when the read fails', async () => { const { compare, comparison, diff } = await fixture() - expect(changesDiffUrl(SessionId('owner'), 9, 0)).toBe(`${CHANGES_DIFF_PATH}?sessionId=owner&seq=9&index=0`) + expect(changesDiffUrl(SessionId('owner'), 9, 0)).toBe('api/changes.diff?sessionId=owner&seq=9&index=0') const response = await compare() expect(response.status).toBe(200) expect(response.headers.get('cache-control')).toBe('no-store') @@ -132,7 +132,7 @@ describe('change comparison route', () => { describe('changed files native open route', () => { it('opens a listed file inside or outside the workspace with its verified Host path', async () => { const { cwd, open, opener, outside } = await fixture() - expect(changedFileUrl(SessionId('owner'), 9, 0)).toBe(`${CHANGES_OPEN_PATH}?sessionId=owner&seq=9&index=0`) + expect(changedFileUrl(SessionId('owner'), 9, 0)).toBe('api/changes.open?sessionId=owner&seq=9&index=0') const response = await open() expect(response.status).toBe(204) expect(response.headers.get('cache-control')).toBe('no-store') diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index cedb8a2d04..83e898212b 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -621,8 +621,8 @@ describe('ChangedFiles card', () => { it('keeps every count in place whatever the native-open gestures of the review tab are doing', () => { const controller = new PresentedOpenController() controller.state.set({ - '/api/changes.open?sessionId=child-session&seq=5&index=0': 'opening', - '/api/changes.open?sessionId=child-session&seq=5&index=1': 'error', + 'api/changes.open?sessionId=child-session&seq=5&index=0': 'opening', + 'api/changes.open?sessionId=child-session&seq=5&index=1': 'error', }) const { view } = renderCard(controller) // Native-open gestures belong to the review tab; the card shows counts only. @@ -747,31 +747,31 @@ describe('plugin registration', () => { expect(face.hooks.presentedHost.getSnapshot()).toMatchObject({ name: 'desktop' }) fetcher.mockResolvedValueOnce(Response.json({ turn: 1, files: [], total: 0, added: 0, deleted: 0 })) await face.loadChangesSummary(SessionId('child-session'), 5) - expect(face.hooks.changesSummary.getSnapshot()['/api/changes.summary?sessionId=child-session&seq=5']).toEqual({ turn: 1, files: [], total: 0, added: 0, deleted: 0 }) + expect(face.hooks.changesSummary.getSnapshot()['api/changes.summary?sessionId=child-session&seq=5']).toEqual({ turn: 1, files: [], total: 0, added: 0, deleted: 0 }) ctx.emit('connection/reset') expect(face.hooks.presentedHost.getSnapshot()).toBeNull() // The replaced connection may reach a Host that no longer serves the summaries read so far. expect(face.hooks.changesSummary.getSnapshot()).toEqual({}) await face.openPresented(SessionId('child-session'), 2, 0) - expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') + expect(face.hooks.presentedOpen.getSnapshot()['api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') await face.openChanged(SessionId('child-session'), 5, 0) - expect(face.hooks.presentedOpen.getSnapshot()['/api/changes.open?sessionId=child-session&seq=5&index=0']).toBe('opened') + expect(face.hooks.presentedOpen.getSnapshot()['api/changes.open?sessionId=child-session&seq=5&index=0']).toBe('opened') face.openChangesReview({ sessionId: SessionId('child-session'), seq: 5, turn: 3 }, 1) expect(openResource).toHaveBeenCalledWith('dsh-resource://changes-review/session/child-session/5/3', { params: { index: 1 } }) expect((registered as { title(address: string): string }).title('dsh-resource://changes-review/session/child-session/5/3')).toBe('Review · turn 3') const tabFace = tabEntry!.inject!(SessionId('child-session') as never) as unknown as ReviewInjected fetcher.mockResolvedValueOnce(Response.json({ turn: 3, files: [], total: 0, added: 0, deleted: 0 })) await tabFace.loadChangesSummary(SessionId('child-session'), 6) - expect(tabFace.hooks.changesSummary.getSnapshot()['/api/changes.summary?sessionId=child-session&seq=6']).toEqual({ turn: 3, files: [], total: 0, added: 0, deleted: 0 }) + expect(tabFace.hooks.changesSummary.getSnapshot()['api/changes.summary?sessionId=child-session&seq=6']).toEqual({ turn: 3, files: [], total: 0, added: 0, deleted: 0 }) fetcher.mockResolvedValueOnce(Response.json({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' })) await tabFace.loadChangesDiff(SessionId('child-session'), 5, 1) - expect(tabFace.hooks.changesDiff.getSnapshot()['/api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' }) + expect(tabFace.hooks.changesDiff.getSnapshot()['api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' }) expect(tabFace.hooks.presentedHost).toBe(face.hooks.presentedHost) fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' })) await tabFace.reloadPresentedHost() fetcher.mockResolvedValueOnce(new Response(null, { status: 204 })) await tabFace.openChanged(SessionId('child-session'), 5, 1) - expect(tabFace.hooks.presentedOpen.getSnapshot()['/api/changes.open?sessionId=child-session&seq=5&index=1']).toBe('opened') + expect(tabFace.hooks.presentedOpen.getSnapshot()['api/changes.open?sessionId=child-session&seq=5&index=1']).toBe('opened') ctx.emit('connection/reset') expect(tabFace.hooks.changesDiff.getSnapshot()).toEqual({}) // A turn that produced nothing yields no vocabulary at all. @@ -905,7 +905,7 @@ it('lets one delivered file span the complete row without an expansion control', it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits retries after failure', (phase) => { const controller = new PresentedOpenController() - controller.state.set({ '/api/present.open?sessionId=session&seq=2&index=0': phase }) + controller.state.set({ 'api/present.open?sessionId=session&seq=2&index=0': phase }) const props = openProps(controller) const view = render( { vi.unstubAllGlobals() }) const id = SessionId('fork') -const url = '/api/present.open?sessionId=fork&seq=2&index=1' +const url = 'api/present.open?sessionId=fork&seq=2&index=1' it('coalesces concurrent card and mention gestures, then allows another open', async () => { const reply = Promise.withResolvers() @@ -31,11 +31,11 @@ it('opens changed files through their own coordinates', async () => { vi.stubGlobal('fetch', fetcher) const controller = new PresentedOpenController() await controller.openChanged(id, 9, 1) - expect(fetcher.mock.calls.map(call => call[0])).toEqual(['/api/changes.open?sessionId=fork&seq=9&index=1']) - expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9&index=1']).toBe('opened') + expect(fetcher.mock.calls.map(call => call[0])).toEqual(['api/changes.open?sessionId=fork&seq=9&index=1']) + expect(controller.state.getSnapshot()['api/changes.open?sessionId=fork&seq=9&index=1']).toBe('opened') fetcher.mockResolvedValueOnce(new Response(null, { status: 422 })) await controller.openChanged(id, 9, 1) - expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9&index=1']).toBe('nativeUnavailable') + expect(controller.state.getSnapshot()['api/changes.open?sessionId=fork&seq=9&index=1']).toBe('nativeUnavailable') await controller.dispose() }) diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts index 1282a47b85..072529cdde 100644 --- a/packages/client/ui-deliverables/tests/present-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -61,7 +61,7 @@ describe('Presented workspace file native open route', () => { it('opens the source itself with current bytes and leaves it intact at disposal', async () => { const { cwd, open, file, fiber, opener, handler, ctx } = await fixture() const source = await realpath(join(cwd, file.path)) - expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe(`${PRESENT_OPEN_PATH}?sessionId=owner&seq=7&index=0`) + expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe('api/present.open?sessionId=owner&seq=7&index=0') expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(404) expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'))).status).toBe(404) for (const contents of ['current source', 'edited source']) { diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx index 84e76ddc3a..cd94ff1010 100644 --- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -270,11 +270,11 @@ describe('ReviewTab', () => { expect(tabActions.openResource).toHaveBeenCalledWith(fileAddressFor(SESSION, '/work/app', '/tmp/out/big.bin')) fireEvent.click(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' })) expect(injected.openChanged).toHaveBeenCalledWith('viewed', 5, 1) - act(() => { controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'opening' }) }) + act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'opening' }) }) expect((view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }) as HTMLButtonElement).disabled).toBe(true) - act(() => { controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'error' }) }) + act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'error' }) }) expect(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }).hasAttribute('data-error')).toBe(true) - act(() => { controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'nativeUnavailable' }) }) + act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'nativeUnavailable' }) }) expect(view.queryByRole('button', { name: 'Open ~/out/big.bin in default app' })).toBeNull() act(() => { controller.state.set({}) diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index bab8f0bd17..158485bb7c 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -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/ui-open-in-app/README.md -README.md: d242d5a84dffbf0895485f7c79eafd5e332fe446 -README.zh.md: e954b7c71e8c4dbd1b19a041aafb7fe154631fbb +README.md: d3de7f60390b0a1b7065bcb71025c3f3e4db19cd +README.zh.md: b547fd144c464c01692d7a2cd47e8dda684976ac diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index d242d5a84d..d3de7f6039 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -39,7 +39,7 @@ The main button shows the remembered application's icon — the real application
Implementation internals — click to expand -The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Route paths and wire payload types are inlined from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. The node half is an empty `apply` that keeps the plugin on the host roster. +The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. The node half is an empty `apply` that keeps the plugin on the host roster.
diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index e954b7c71e..b547fd144c 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -39,7 +39,7 @@ kind: "package-reference"
实现内幕——点击展开 -插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。路由路径与 wire 载荷类型从主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared` 内联。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。节点半边是一个空 `apply`,让插件出现在主机侧的插件名册上。 +插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。节点半边是一个空 `apply`,让插件出现在主机侧的插件名册上。
diff --git a/packages/client/ui-open-in-app/src/client/controller.ts b/packages/client/ui-open-in-app/src/client/controller.ts index bb9e146e49..53da6a8fc8 100644 --- a/packages/client/ui-open-in-app/src/client/controller.ts +++ b/packages/client/ui-open-in-app/src/client/controller.ts @@ -8,12 +8,6 @@ import { type Fetch = (input: string | URL, init?: RequestInit) => Promise -/** Resolve the browser's Host base with the connection carrier's null-origin fallback. */ -function hostBase(): string { - const origin = (globalThis as { location?: { origin?: string } }).location?.origin - return origin !== undefined && origin !== 'null' ? origin : 'http://dsh.internal' -} - /** * Owns the once-per-page availability read, the persisted last choice, and * the launch POST. Availability and choice publish through uSES-safe sources @@ -60,7 +54,7 @@ export class OpenInAppController { */ async launch(appId: string, path: string): Promise { const body: OpenInAppOpenPayload = { app: appId, path } - const response = await this.fetcher(new URL(OPEN_IN_APP_OPEN_ROUTE, hostBase()), { + const response = await this.fetcher(OPEN_IN_APP_OPEN_ROUTE, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), @@ -71,7 +65,7 @@ export class OpenInAppController { private async run(): Promise { let apps: readonly string[] = [] try { - const response = await this.fetcher(new URL(OPEN_IN_APP_APPS_ROUTE, hostBase()), { + const response = await this.fetcher(OPEN_IN_APP_APPS_ROUTE, { headers: { accept: 'application/json' }, }) if (response.ok) { diff --git a/packages/client/ui-open-in-app/src/client/index.ts b/packages/client/ui-open-in-app/src/client/index.ts index c3e4d88f98..039e0a9f50 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -10,7 +10,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' -import { OPEN_IN_APP_ICON_PREFIX } from '@deepseek-ai/dsh-host-open-in-app/shared' +import { OPEN_IN_APP_ICON_PREFIX_ROUTE } from '@deepseek-ai/dsh-host-open-in-app/shared' import { OpenInAppController } from './controller.ts' import { OpenInAppAction, type OpenInAppActionInjected } from './OpenInAppAction.tsx' import { en, NS, zh, type OpenInAppKey } from './locales.ts' @@ -47,7 +47,7 @@ export function apply(ctx: ClientContext): void { }, launch: (appId, path) => controller.launch(appId, path), choose: (appId) => { controller.choose(appId) }, - iconUrl: appId => `${OPEN_IN_APP_ICON_PREFIX}/${appId}`, + iconUrl: appId => `${OPEN_IN_APP_ICON_PREFIX_ROUTE}/${appId}`, }), }, OpenInAppAction)) } diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index eefdae245d..07a4fdcfce 100644 --- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts @@ -58,7 +58,7 @@ describe('open-in-app browser half', () => { const fetcher = vi.fn(async (input: string | URL, init?: RequestInit) => { void init const url = String(input) - if (url.includes('/open-in-app/apps')) { + if (url.includes('open-in-app/apps')) { return new Response(JSON.stringify({ apps: ['finder', 'cursor', 7] }), { status: 200 }) } return new Response(JSON.stringify({ ok: true }), { status: 200 }) @@ -71,13 +71,13 @@ describe('open-in-app browser half', () => { await vi.waitFor(() => { expect(injected.hooks.openInAppApps.getSnapshot()).toEqual(['finder', 'cursor']) }) - expect(injected.iconUrl('cursor')).toBe('/open-in-app/icon/cursor') + expect(injected.iconUrl('cursor')).toBe('open-in-app/icon/cursor') injected.choose('cursor') expect(injected.hooks.openInAppChoice.getSnapshot()).toBe('cursor') await injected.launch('cursor', '/w/dir') - const openCall = fetcher.mock.calls.find(call => String(call[0]).includes('/open-in-app/open')) + const openCall = fetcher.mock.calls.find(call => String(call[0]).includes('open-in-app/open')) expect(openCall?.[1]).toMatchObject({ method: 'POST', headers: { 'content-type': 'application/json' }, @@ -88,7 +88,7 @@ describe('open-in-app browser half', () => { it('publishes an empty availability list when the host read fails, and launches reject on HTTP errors', async () => { vi.stubGlobal('fetch', vi.fn(async (input: string | URL) => { - if (String(input).includes('/open-in-app/apps')) throw new Error('down') + if (String(input).includes('open-in-app/apps')) throw new Error('down') return new Response('', { status: 502 }) })) const { ctx, fiber } = await bench() diff --git a/packages/client/ui-open-in-app/tests/controller.client.spec.ts b/packages/client/ui-open-in-app/tests/controller.client.spec.ts index dc60321271..ff8c9d9f62 100644 --- a/packages/client/ui-open-in-app/tests/controller.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/controller.client.spec.ts @@ -1,4 +1,4 @@ -/** Controller wire behavior: host-base resolution, availability filtering, and launch errors. */ +/** Controller wire behavior: document-relative routes, availability filtering, and launch errors. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { OpenInAppController } from '../src/client/controller.ts' @@ -36,20 +36,11 @@ describe('OpenInAppController availability', () => { expect(malformed.apps.getSnapshot()).toEqual([]) }) - it('resolves routes against the page origin when the page has one', async () => { - vi.stubGlobal('location', { origin: 'http://dsh.example:8080' }) + it('requests the document-relative availability route', async () => { const fetcher = vi.fn(async (input: string | URL) => { void input; return jsonResponse({ apps: [] }) }) const controller = new OpenInAppController(fetcher) await controller.load() - expect(String(fetcher.mock.calls[0]?.[0])).toBe('http://dsh.example:8080/open-in-app/apps') - }) - - it('falls back to the internal host base under a null origin', async () => { - vi.stubGlobal('location', { origin: 'null' }) - const fetcher = vi.fn(async (input: string | URL) => { void input; return jsonResponse({ apps: [] }) }) - const controller = new OpenInAppController(fetcher) - await controller.load() - expect(String(fetcher.mock.calls[0]?.[0])).toBe('http://dsh.internal/open-in-app/apps') + expect(fetcher.mock.calls[0]?.[0]).toBe('open-in-app/apps') }) }) @@ -72,6 +63,7 @@ describe('OpenInAppController launching', () => { const fetcher = vi.fn(async (input: string | URL, init?: RequestInit) => { void input; void init; return jsonResponse({ ok: true }) }) const controller = new OpenInAppController(fetcher) await controller.launch('cursor', '/w/dir') + expect(fetcher.mock.calls[0]?.[0]).toBe('open-in-app/open') expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'POST', headers: { 'content-type': 'application/json' }, diff --git a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx index 42d6ee0a02..540a918a77 100644 --- a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx @@ -55,7 +55,7 @@ function bench(over: { useOpenInAppChoice: useSelector(choice), launch, choose, - iconUrl: (appId: string) => `/open-in-app/icon/${appId}`, + iconUrl: (appId: string) => `open-in-app/icon/${appId}`, t, } as unknown as OpenInAppActionProps return { props, launch, choose } @@ -234,7 +234,7 @@ describe('OpenInAppAction launching', () => { const b = bench({ apps: ['terminal'], cwd: '/w/dir' }) const { container } = render() const img = container.querySelector('img') - expect(img?.getAttribute('src')).toBe('/open-in-app/icon/terminal') + expect(img?.getAttribute('src')).toBe('open-in-app/icon/terminal') if (img !== null) fireEvent.error(img) await waitFor(() => { expect(container.querySelector('img')).toBeNull() diff --git a/packages/host/open-in-app/src/index.ts b/packages/host/open-in-app/src/index.ts index 0c065e0111..8e8da91ec4 100644 --- a/packages/host/open-in-app/src/index.ts +++ b/packages/host/open-in-app/src/index.ts @@ -36,7 +36,7 @@ import { import { extractAppIcon, type OpenInAppIcon } from './icons.ts' import { internals } from './internals.ts' import { - OPEN_IN_APP_APPS_ROUTE, OPEN_IN_APP_ICON_PREFIX, OPEN_IN_APP_OPEN_ROUTE, + OPEN_IN_APP_APPS_PATH, OPEN_IN_APP_ICON_PREFIX_PATH, OPEN_IN_APP_OPEN_PATH, } from './shared.ts' export type * from './shared.ts' @@ -192,7 +192,7 @@ export function apply(ctx: Context, config: Config): void { ctx.effect(() => ctx.webServer.register({ kind: 'exact', - path: OPEN_IN_APP_APPS_ROUTE, + path: OPEN_IN_APP_APPS_PATH, handler: async (req, res) => { if (rejected(req, res)) return if (req.method !== 'GET') { @@ -201,11 +201,11 @@ export function apply(ctx: Context, config: Config): void { } sendJson(res, 200, { apps: [...(await availability()).keys()] }) }, - }), `open-in-app: GET ${OPEN_IN_APP_APPS_ROUTE}`) + }), `open-in-app: GET ${OPEN_IN_APP_APPS_PATH}`) ctx.effect(() => ctx.webServer.register({ kind: 'prefix', - path: OPEN_IN_APP_ICON_PREFIX, + path: OPEN_IN_APP_ICON_PREFIX_PATH, handler: async (req, res) => { if (rejected(req, res)) return if (req.method !== 'GET') { @@ -214,7 +214,7 @@ export function apply(ctx: Context, config: Config): void { } // Node always sets url on server requests; String keeps that fact local. const pathname = new URL(String(req.url), 'http://localhost').pathname - const id = pathname.slice(OPEN_IN_APP_ICON_PREFIX.length).replace(/^\//, '') + const id = pathname.slice(OPEN_IN_APP_ICON_PREFIX_PATH.length).replace(/^\//, '') const noIcon = (): void => { sendJson(res, 404, { code: 'not-found', message: `no icon for ${id}` }) } const app = OPEN_IN_APP_CATALOG.find(entry => entry.id === id) if (app === undefined) { @@ -236,11 +236,11 @@ export function apply(ctx: Context, config: Config): void { res.setHeader('cache-control', 'public, max-age=3600') res.end(icon.bytes) }, - }), `open-in-app: GET ${OPEN_IN_APP_ICON_PREFIX}/`) + }), `open-in-app: GET ${OPEN_IN_APP_ICON_PREFIX_PATH}/`) ctx.effect(() => ctx.webServer.register({ kind: 'exact', - path: OPEN_IN_APP_OPEN_ROUTE, + path: OPEN_IN_APP_OPEN_PATH, handler: async (req, res) => { if (rejected(req, res)) return if (req.method !== 'POST') { @@ -307,5 +307,5 @@ export function apply(ctx: Context, config: Config): void { sendJson(res, 502, { code: 'launch-failed', message: `failed to launch ${app.id}` }) } }, - }), `open-in-app: POST ${OPEN_IN_APP_OPEN_ROUTE}`) + }), `open-in-app: POST ${OPEN_IN_APP_OPEN_PATH}`) } diff --git a/packages/host/open-in-app/src/shared.ts b/packages/host/open-in-app/src/shared.ts index 8b9ff47c6d..9e703c1703 100644 --- a/packages/host/open-in-app/src/shared.ts +++ b/packages/host/open-in-app/src/shared.ts @@ -1,17 +1,29 @@ /** * Route paths and wire payloads shared verbatim by the host routes and the * browser package (`@deepseek-ai/dsh-client-ui-open-in-app`), published as - * the `./shared` subpath. Browser-safe: constants and types only. + * the `./shared` subpath. Browser-safe: constants and types only. Each route + * carries the absolute pathname the Host registers beside the + * document-relative form the browser addresses; see + * .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md. */ -/** GET route serving the probed application ids. */ -export const OPEN_IN_APP_APPS_ROUTE = '/open-in-app/apps' +/** GET route path serving the probed application ids. */ +export const OPEN_IN_APP_APPS_PATH = '/open-in-app/apps' -/** GET prefix serving one PNG bundle icon per application id. */ -export const OPEN_IN_APP_ICON_PREFIX = '/open-in-app/icon' +/** Browser-relative form of {@link OPEN_IN_APP_APPS_PATH}. */ +export const OPEN_IN_APP_APPS_ROUTE = OPEN_IN_APP_APPS_PATH.slice(1) -/** POST route launching one application on one workspace directory. */ -export const OPEN_IN_APP_OPEN_ROUTE = '/open-in-app/open' +/** GET prefix path serving one PNG bundle icon per application id. */ +export const OPEN_IN_APP_ICON_PREFIX_PATH = '/open-in-app/icon' + +/** Browser-relative form of {@link OPEN_IN_APP_ICON_PREFIX_PATH}. */ +export const OPEN_IN_APP_ICON_PREFIX_ROUTE = OPEN_IN_APP_ICON_PREFIX_PATH.slice(1) + +/** POST route path launching one application on one workspace directory. */ +export const OPEN_IN_APP_OPEN_PATH = '/open-in-app/open' + +/** Browser-relative form of {@link OPEN_IN_APP_OPEN_PATH}. */ +export const OPEN_IN_APP_OPEN_ROUTE = OPEN_IN_APP_OPEN_PATH.slice(1) /** Apps-route response: catalog ids probed as installed, in menu order. */ export interface OpenInAppAppsPayload { diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index ae845c1725..12136da5b7 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -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/session-query/session-log-export/README.md -README.md: 8697b0c2c105c928f5325f953b7fb663ae68e18e -README.zh.md: fe60c6ab8cc8d2d5e7515416bb72624584c11b9d +README.md: 165f0a8ecdd268d33a3cb1b8c0d506e12791f6d6 +README.zh.md: 3602f9aa1b4cb403498132fb1247200b3bc7e341 diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 8697b0c2c1..165f0a8ecd 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -50,7 +50,7 @@ The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-u | Input | Result | |---|---| -| `/export` | Records a human-command lifecycle; the submitting browser downloads `GET /api/session.export?sessionId=&includeDescendants=true` | +| `/export` | Records a human-command lifecycle; the submitting browser downloads the document-relative `api/session.export?sessionId=&includeDescendants=true` (Host route `/api/session.export`) | | `/export ` | An error; browser downloads choose their destination through the browser's ordinary download behavior | ### What to expect @@ -77,7 +77,7 @@ The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) regis ### Download flow -Both entry paths issue a `HEAD` preflight to `/api/session.export?...`, then hand the GET URL to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. Modal state lives in a snapshot store keyed by session, so the button and the command share one dialog per session. +Both entry paths issue a `HEAD` preflight to the document-relative `api/session.export?...`, then hand the GET route to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. Modal state lives in a snapshot store keyed by session, so the button and the command share one dialog per session. The Host route is a feature-owned exact Fetch contribution. Connection applies its Host/Origin and browser-session checks and bridges the streaming `Response`; this package owns query validation, live-session flushes, handle-based log reads and attachment reads, ZIP generation, and HTTP status semantics. diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index fe60c6ab8c..3602f9aa1b 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -50,7 +50,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` | 输入 | 结果 | |---|---| -| `/export` | 记录用户命令的生命周期;提交命令的浏览器下载 `GET /api/session.export?sessionId=&includeDescendants=true` | +| `/export` | 记录用户命令的生命周期;提交命令的浏览器下载文档相对的 `api/session.export?sessionId=&includeDescendants=true`(Host 路由 `/api/session.export`) | | `/export ` | 错误;浏览器下载通过浏览器的普通下载行为选择目标位置 | ### 预期行为 @@ -77,7 +77,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` ### 下载流程 -两条入口都会先向 `/api/session.export?...` 发出 `HEAD` 预检请求,然后把 GET URL 交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。弹窗状态存放在按会话键控的快照存储中,因此按钮与命令按会话共享一个弹窗。 +两条入口都会先向文档相对的 `api/session.export?...` 发出 `HEAD` 预检请求,然后把 GET 路由交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。弹窗状态存放在按会话键控的快照存储中,因此按钮与命令按会话共享一个弹窗。 Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、基于句柄的日志读取与附件读取、ZIP 生成和 HTTP 状态语义。 diff --git a/packages/session-query/session-log-export/src/client/controller.ts b/packages/session-query/session-log-export/src/client/controller.ts index f7f01cceb9..071b99f55e 100644 --- a/packages/session-query/session-log-export/src/client/controller.ts +++ b/packages/session-query/session-log-export/src/client/controller.ts @@ -2,6 +2,7 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { SESSION_LOG_EXPORT_ROUTE } from '../routes.ts' /** Download phases presented by the shared modal. */ export type SessionLogDownloadStatus = 'downloading' | 'success' | 'error' @@ -18,6 +19,7 @@ export interface SessionLogDownloadState { bySession: Record } +/** HTTP carrier for the export route. */ type Fetch = (input: string | URL, init?: RequestInit) => Promise type Save = (url: string, filename: string) => void @@ -33,8 +35,9 @@ export function sessionLogZipFilename(sessionId: SessionId): string { } /** - * Hand a Host download URL to the browser download manager. - * @param url - same-origin Host download URL. + * Hand a Host download route to the browser download manager, which resolves it + * against the document's own base. + * @param url - document-relative Host download route. * @param filename - browser download filename. */ export function downloadUrl(url: string, filename: string): void { @@ -44,12 +47,6 @@ export function downloadUrl(url: string, filename: string): void { anchor.click() } -/** Resolve the browser's Host base with the connection carrier's null-origin fallback. */ -function hostBase(): string { - const origin = (globalThis as { location?: { origin?: string } }).location?.origin - return origin !== undefined && origin !== 'null' ? origin : 'http://dsh.internal' -} - function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) } @@ -112,15 +109,14 @@ export class SessionLogDownloadController { private async run(sessionId: SessionId, signal: AbortSignal): Promise { this.publish(sessionId, { open: true, status: 'downloading', error: null }) try { - const url = new URL('/api/session.export', hostBase()) - url.searchParams.set('sessionId', sessionId) - url.searchParams.set('includeDescendants', 'true') - const response = await this.fetcher(url, { method: 'HEAD', signal }) + const query = new URLSearchParams({ sessionId, includeDescendants: 'true' }) + const route = `${SESSION_LOG_EXPORT_ROUTE}?${query.toString()}` + const response = await this.fetcher(route, { method: 'HEAD', signal }) if (!response.ok) { const detail = await response.text().catch(() => '') throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`) } - this.save(url.toString(), sessionLogZipFilename(sessionId)) + this.save(route, sessionLogZipFilename(sessionId)) const open = this.store.getSnapshot().bySession[String(sessionId)]?.open ?? true this.publish(sessionId, { open, status: 'success', error: null }) } catch (error: unknown) { diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index f907054d1a..98870d0618 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -17,6 +17,7 @@ import { type SessionLogCompressionLevel, type SessionLogExportReady, } from './archive.ts' +import { SESSION_LOG_EXPORT_PATH } from './routes.ts' export { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, @@ -39,8 +40,7 @@ export type { export const name = 'session-log-download' export const inject = ['commands', 'connection'] -/** Stable browser download path retained across the transport migration. */ -export const SESSION_LOG_EXPORT_PATH = '/api/session.export' +export { SESSION_LOG_EXPORT_PATH } from './routes.ts' /** Session-log archive policy. */ export interface Config { diff --git a/packages/session-query/session-log-export/src/routes.ts b/packages/session-query/session-log-export/src/routes.ts new file mode 100644 index 0000000000..e603351995 --- /dev/null +++ b/packages/session-query/session-log-export/src/routes.ts @@ -0,0 +1,11 @@ +/** + * The absolute pathname the Host registers the export under and the + * document-relative form the browser addresses; see + * .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md. + */ + +/** Absolute registration path for the ZIP download route. */ +export const SESSION_LOG_EXPORT_PATH = '/api/session.export' + +/** Browser-relative form of {@link SESSION_LOG_EXPORT_PATH}. */ +export const SESSION_LOG_EXPORT_ROUTE = SESSION_LOG_EXPORT_PATH.slice(1) diff --git a/packages/session-query/session-log-export/tests/controller.client.spec.ts b/packages/session-query/session-log-export/tests/controller.client.spec.ts index a5fc95852f..b12afab861 100644 --- a/packages/session-query/session-log-export/tests/controller.client.spec.ts +++ b/packages/session-query/session-log-export/tests/controller.client.spec.ts @@ -21,14 +21,12 @@ describe('SessionLogDownloadController', () => { await controller.download(SID) expect(fetcher).toHaveBeenCalledOnce() - const [url, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit] - expect(url.pathname).toBe('/api/session.export') - expect(url.searchParams.get('sessionId')).toBe(SID) - expect(url.searchParams.get('includeDescendants')).toBe('true') + const [route, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit] + expect(route).toBe(`api/session.export?sessionId=${SID}&includeDescendants=true`) expect(init.method).toBe('HEAD') expect(init.signal).toBeInstanceOf(AbortSignal) expect(save).toHaveBeenCalledWith( - url.toString(), + route, 'dsh-session-session-export-controller.zip', ) expect(controller.store.getSnapshot().bySession[SID]).toEqual({ @@ -99,8 +97,7 @@ describe('SessionLogDownloadController', () => { await controller.dispose() }) - it('uses the null-origin fallback and default browser operations', async () => { - vi.stubGlobal('location', { origin: 'null' }) + it('requests the document-relative route through the default carrier', async () => { const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('zip')) vi.stubGlobal('fetch', fetcher) const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) @@ -108,7 +105,7 @@ describe('SessionLogDownloadController', () => { await controller.download(SID) - expect((fetcher.mock.calls[0]?.[0] as URL).origin).toBe('http://dsh.internal') + expect(fetcher.mock.calls[0]?.[0]).toBe(`api/session.export?sessionId=${SID}&includeDescendants=true`) expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'HEAD' }) expect(click).toHaveBeenCalledOnce() }) @@ -133,14 +130,14 @@ describe('SessionLogDownloadController', () => { }) describe('browser download helpers', () => { - it('sanitizes the archive filename and hands the URL to a download anchor', () => { + it('sanitizes the archive filename and hands the relative route to a download anchor', () => { const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) expect(sessionLogZipFilename('a/b' as SessionId)).toBe('dsh-session-a_b.zip') - downloadUrl('http://host/api/session.export?sessionId=a', 'archive.zip') + downloadUrl('api/session.export?sessionId=a', 'archive.zip') expect(click).toHaveBeenCalledOnce() const anchor = click.mock.instances[0] as HTMLAnchorElement - expect(anchor.href).toBe('http://host/api/session.export?sessionId=a') + expect(anchor.getAttribute('href')).toBe('api/session.export?sessionId=a') expect(anchor.download).toBe('archive.zip') }) }) diff --git a/packages/session-query/session-log-export/tsconfig.client.json b/packages/session-query/session-log-export/tsconfig.client.json index 6e3e746c4c..8bcfe79da6 100644 --- a/packages/session-query/session-log-export/tsconfig.client.json +++ b/packages/session-query/session-log-export/tsconfig.client.json @@ -11,6 +11,7 @@ "src/client/controller.ts", "src/client/index.ts", "src/client/locales.ts", + "src/routes.ts", "src/css-modules.d.ts" ], "references": [ diff --git a/packages/session-query/session-log-export/tsconfig.host.json b/packages/session-query/session-log-export/tsconfig.host.json index 78ad234797..0832fbd1ac 100644 --- a/packages/session-query/session-log-export/tsconfig.host.json +++ b/packages/session-query/session-log-export/tsconfig.host.json @@ -7,7 +7,8 @@ }, "files": [ "src/archive.ts", - "src/index.ts" + "src/index.ts", + "src/routes.ts" ], "references": [ { "path": "../../../vendor/cordis" }, diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index fb61abe8b5..fd43c9549d 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -260,9 +260,14 @@ describe('gate graph validation', () => { expect(ids).toEqual([ 'rescope-vendor', 'publint', 'constraints', 'default-product-isolation', 'package-dependencies', 'application-entrypoints', 'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types', - 'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'no-bare-dispatcher', 'cordis-config', - 'runtime-closure', + 'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'client-route-resolution', 'no-bare-dispatcher', + 'cordis-config', 'runtime-closure', ]) + }) + + it('caps the local hygiene aggregate at four workers', () => { + const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id)) + expect(defaultConcurrency('hygiene', ids.length, 8)).toEqual({ workers: 4, source: '8 available CPU(s), hygiene cap 4', @@ -344,6 +349,15 @@ describe('gate graph validation', () => { }, ) + it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)( + 'keeps browser app-route resolution enforcement in %s', + (mode) => { + const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id)) + + expect(ids).toContain('client-route-resolution') + }, + ) + it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)( 'keeps application entrypoint enforcement in %s', (mode) => { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 025723ccbf..4e14031827 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -304,14 +304,21 @@ function ciSharedStaticGates(): Gate[] { pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), + ...sharedHygieneGates(), + pnpmScript('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }), + pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), + ] +} + +function sharedHygieneGates(): Gate[] { + return [ pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', { label: 'optional dependency imports', }), pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }), pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }), + pnpmScript('client-route-resolution', 'verify-client-route-resolution', { label: 'client route resolution' }), pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }), - pnpmScript('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }), - pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), ] } @@ -711,12 +718,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { label: 'node-next types', ...artifactOptions, }), - pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', { - label: 'optional dependency imports', - }), - pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }), - pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }), - pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }), + ...sharedHygieneGates(), ] } diff --git a/scripts/ts-project.ts b/scripts/ts-project.ts index 53b100ceb0..669285b1ed 100644 --- a/scripts/ts-project.ts +++ b/scripts/ts-project.ts @@ -29,31 +29,54 @@ export const repositoryConfigHost: ts.ParseConfigFileHost = { }, } -/** - * Parse one face aggregate tsconfig and flatten all referenced projects into one - * semantic graph. Never seed the root solution: flattening host+client into one - * program collides the cordis Context merges. - */ -function loadProjectGraph(projectRoot: string, face: CompilerFace): ProjectGraph { - const rootConfigPath = resolve(projectRoot, `tsconfig.${face}.json`) - const rootConfig = parseConfig(rootConfigPath) - const rootNames = new Set() - const visited = new Set() +/** One compiler face aggregate: its own config and every project it compiles. */ +export interface FaceConfigs { + /** The aggregate's `tsconfig..json`. */ + readonly root: ts.ParsedCommandLine + /** Every config the face compiles, its own aggregate included, keyed by resolved config path. */ + readonly byPath: ReadonlyMap +} - const collect = (configPath: string, parsed: ts.ParsedCommandLine): void => { - if (visited.has(configPath)) return - visited.add(configPath) - for (const fileName of parsed.fileNames) rootNames.add(fileName) +/** + * Parse one compiler face aggregate and every project it references. Never seed + * the root solution: the two faces are separate graphs, and flattening + * host+client into one program collides the cordis Context merges. + * @param projectRoot - repository root holding `tsconfig..json`. + * @param face - which aggregate to parse. + * @returns the aggregate's root config and every config it compiles. + */ +export function faceConfigs(projectRoot: string, face: CompilerFace): FaceConfigs { + const rootPath = resolve(projectRoot, `tsconfig.${face}.json`) + const root = parseConfig(rootPath) + const byPath = new Map([[rootPath, root]]) + const collect = (parsed: ts.ParsedCommandLine): void => { for (const reference of parsed.projectReferences ?? []) { const referencePath = ts.resolveProjectReferencePath(reference) - collect(referencePath, parseConfig(referencePath)) + if (byPath.has(referencePath)) continue + const referenced = parseConfig(referencePath) + byPath.set(referencePath, referenced) + collect(referenced) } } - collect(rootConfigPath, rootConfig) + collect(root) + return { root, byPath } +} +/** + * Flatten one compiler face aggregate into one semantic graph. + * @param projectRoot - repository root holding `tsconfig..json`. + * @param face - which aggregate to flatten. + * @returns every root name the face compiles, with the aggregate's options. + */ +function loadProjectGraph(projectRoot: string, face: CompilerFace): ProjectGraph { + const { root, byPath } = faceConfigs(projectRoot, face) + const rootNames = new Set() + for (const parsed of byPath.values()) { + for (const fileName of parsed.fileNames) rootNames.add(fileName) + } return { rootNames: [...rootNames], - options: rootConfig.options, + options: root.options, } } diff --git a/scripts/verify-client-route-resolution.spec.ts b/scripts/verify-client-route-resolution.spec.ts new file mode 100644 index 0000000000..6182a16523 --- /dev/null +++ b/scripts/verify-client-route-resolution.spec.ts @@ -0,0 +1,192 @@ +/** Regression coverage for the browser app-route guard. */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { describe, expect, it } from 'vitest' +import { browserFaceSources, findRouteResolutionViolations, type RouteGateFace } from './verify-client-route-resolution.ts' + +function patterns(file: string, source: string, face?: RouteGateFace): string[] { + return findRouteResolutionViolations(file, source, face).map(violation => violation.pattern) +} + +function rules(file: string, source: string, face?: RouteGateFace): string[] { + return findRouteResolutionViolations(file, source, face).map(violation => violation.rule) +} + +const CLIENT_FILE = 'packages/client/ui-example/src/client/View.tsx' +const PRODUCER_FILE = 'packages/client/modules/src/index.ts' + +describe('browser app-route guard', () => { + it('accepts document-relative request targets and routes that are not targets', () => { + expect(patterns(CLIENT_FILE, ` + export const PRESENT_OPEN_PATH = '/api/present.open' + const CHANNEL = '/api' + export async function load(id: string) { + const bundle = 'plugins/local.js' + const icon = \`open-in-app/icon/\${id}\` + const file = new URL(\`api/file?path=\${id}\`, document.baseURI) + await customFetch('api/session.export?sessionId=s1') + await connection.rpc.call('/api', 'goals/create', {}) + webserver.register({ path: '/plugins' }) + const identity = window.location.hostname + const foreign = 'https://cdn.example/app.js' + const asset = '/assets/app.js' + return [bundle, icon, file.href, CHANNEL, identity, foreign, asset] + } + `)).toEqual([]) + }) + + it('rejects every request target that binds a route to one mount', () => { + expect(findRouteResolutionViolations(CLIENT_FILE, ` + export async function load(id: string) { + const origin = window.location.origin + await fetch('/api/remote.mux') + await fetch(\`/plugins/\${id}/client.js\`) + await fetch(\`\${origin}/api/file?path=\${id}\`) + await this.fetcher('/api/custom') + new EventSource('/plugins/events') + new WebSocket('wss://harness.example/api/remote.mux') + document.querySelector('script').src = '//harness.example/api/file' + return id + } + `).map(violation => violation.line)).toEqual([4, 5, 6, 7, 8, 9, 10]) + expect(rules(CLIENT_FILE, ` + await fetch('/api/remote.mux') + await fetch(\`\${window.location.origin}/api/file\`) + `)).toEqual(['request-target', 'request-target']) + }) + + it('rejects a root-absolute app route in a JSX resource attribute', () => { + expect(findRouteResolutionViolations(CLIENT_FILE, ` + export function View({ id }: { id: string }) { + return
+ + + + apps + cdn +
+ } + `).map(violation => violation.line)).toEqual([4, 5, 6]) + }) + + it('rejects a shared host route key used without stripping its leading slash', () => { + const source = ` + import { PRESENT_OPEN_PATH, EVENTS_ENDPOINT } from '../presented.ts' + await fetch(PRESENT_OPEN_PATH) + await fetch(\`\${EVENTS_ENDPOINT}?since=1\`) + await fetch(PRESENT_OPEN_PATH.slice(1)) + ` + expect(findRouteResolutionViolations(CLIENT_FILE, source).map(violation => violation.line)).toEqual([3, 4]) + expect(rules(CLIENT_FILE, source)).toEqual(['host-route-key', 'host-route-key']) + }) + + it('rejects a relative app route resolved against a location read', () => { + expect(findRouteResolutionViolations(CLIENT_FILE, ` + const url = new URL('api/session.export?sessionId=s1', window.location.origin) + const ok = new URL('api/session.export?sessionId=s1', document.baseURI) + const mapped = new URL(input, globalThis.location.origin) + return [url, ok, mapped] + `)).toEqual([ + expect.objectContaining({ line: 2, rule: 'location-base' }), + ]) + }) + + it('leaves identity and transport reads of location alone', () => { + expect(patterns(CLIENT_FILE, ` + const trusted = window.location.hostname === 'localhost' + const requested = new URL(location.href).searchParams.get('source') + const { origin } = document.location + return [trusted, requested, origin] + `)).toEqual([]) + }) +}) + +describe('browser-reference producers', () => { + it('rejects a root-absolute app route stamped into a field the browser resolves', () => { + expect(findRouteResolutionViolations(PRODUCER_FILE, ` + const bootstrap = { phase: 'bootstrap', url: \`/plugins/??\${resources}&rev=\${rev}\`, rev, entries } + const row = { id, url: 'plugins/boot.js', initialUrl: '/plugins/all.js', rev } + const map = { src: 'https://harness.example/plugins/??a/client.js.map&rev=r' } + const link = { href: '/open-in-app/apps' } + const script = { src: '//harness.example/plugins/a.js' } + const key = \`/plugins/\${comboSearch(ids, rev)}\` + return [bootstrap, row, map, link, script, key] + `, 'reference-producer').map(violation => violation.rule)).toEqual(Array(5).fill('reference-producer')) + }) + + it('leaves internal route keys and generated source names alone', () => { + expect(patterns(PRODUCER_FILE, ` + const PLUGIN_ROUTE = '/plugins' + const key = \`\${PLUGIN_ROUTE}/\${comboSearch(ids, rev)}\` + const section = { map: { sources: [\`/plugins/\${record.entry.id}/client.js\`] } } + return [key, section] + `, 'reference-producer')).toEqual([]) + }) + +}) + +/** + * Write one fixture project: its config, its source files, and its declared + * libraries. The `src` tree the discovery anchors on always exists. + */ +function writeProject(root: string, name: string, options: { + lib: readonly string[] + /** Project-relative source paths to write. */ + sources: readonly string[] + /** Config `include` patterns; defaults to the `src` tree. */ + include?: readonly string[] +}): void { + const dir = join(root, name) + mkdirSync(join(dir, 'src'), { recursive: true }) + writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { lib: options.lib, noEmit: true }, + include: [...options.include ?? ['src']], + })) + for (const source of options.sources) { + mkdirSync(dirname(join(dir, source)), { recursive: true }) + writeFileSync(join(dir, source), 'export {}\n') + } +} + +/** Write the two face aggregates of one fixture root over the named projects. */ +function writeFaceAggregates(root: string, client: readonly string[], host: readonly string[]): void { + const references = (names: readonly string[]): { path: string }[] => names.map(name => ({ path: `./${name}` })) + writeFileSync(join(root, 'tsconfig.client.json'), JSON.stringify({ files: [], references: references(client) })) + writeFileSync(join(root, 'tsconfig.host.json'), JSON.stringify({ files: [], references: references(host) })) +} + +describe('browser face discovery', () => { + it('takes sources from the Client aggregate alone', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-route-face-')) + try { + writeProject(root, 'browser-package', { lib: ['ES2024', 'DOM'], sources: ['src/view.ts', 'src/globals.d.ts'] }) + writeProject(root, 'shared-package', { lib: ['ES2024', 'DOM'], sources: ['src/both-faces.ts'] }) + writeProject(root, 'host-package', { lib: ['ES2024'], sources: ['src/host.ts'] }) + writeFaceAggregates(root, ['browser-package', 'shared-package', 'host-package'], ['shared-package', 'host-package']) + + // DOM libraries alone do not make a browser face: a project the Host + // aggregate also compiles has a host half, and a project without them is + // host-side. Declarations carry no request target and stay out. + expect(browserFaceSources(root)).toEqual(['browser-package/src/view.ts']) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('fails loud when a DOM client project compiles nothing from its source tree', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-route-face-moved-')) + try { + // The DOM face still has a `src` directory, but its config now compiles a + // tree the discovery does not know: scanning less must not pass quietly. + writeProject(root, 'browser-package', { lib: ['DOM'], sources: ['lib/moved.ts'], include: ['lib'] }) + writeProject(root, 'host-package', { lib: ['ES2024'], sources: ['src/host.ts'] }) + writeFaceAggregates(root, ['browser-package'], ['host-package']) + + expect(() => browserFaceSources(root)).toThrow(/contributes no source file/u) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/scripts/verify-client-route-resolution.ts b/scripts/verify-client-route-resolution.ts new file mode 100644 index 0000000000..89ac2cf829 --- /dev/null +++ b/scripts/verify-client-route-resolution.ts @@ -0,0 +1,332 @@ +/** + * Browser-face route gate: an app-owned route reachable from browser code is + * document-relative. Only request targets are governed: a root-absolute + * (`/api/x`), protocol-relative (`//host/api/x`) or absolute + * (`https://host/api/x`) app route used to address a resource binds the bundle + * to one mount, and a relative app route resolved against a `location` read + * rebuilds the base the served document already provides. A request target is + * the first argument of a request constructor, of a `fetch`/`fetcher` callee, + * or of a dynamic import; an assigned resource property (`script.src = ...`); + * or a JSX `src`/`href` attribute. + * + * Route keys stay absolute pathnames — the RPC channel key, the path a route is + * registered under, and the shared `*_PATH`/`*_ENDPOINT` constants carrying + * them — so a literal that is not a request target is out of scope. A browser + * half addresses such a key by stripping its leading slash, which the shared-key + * rule below requires. + * + * The static producers of browser references are checked in the same pass: a + * field the browser resolves against its document (`url`, `src`, `href`) + * must not be stamped with a root-absolute app route. Internal route keys and + * response-table keys stay absolute and are not references. This rule reads + * inline literal fragments only, so a composed reference + * (`comboReference(...)`, `artifact.url.slice(1)`) is not traced: + * `packages/client/modules/tests/node-half.client.spec.ts` owns the regression + * signal for composed graph rows, batch descriptors, and the source-map trailer. + * + * Rationale: .agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md. + */ + +import { existsSync, globSync, readFileSync } from 'node:fs' +import { dirname, relative, resolve, sep } from 'node:path' +import ts from 'typescript' +import { faceConfigs } from './ts-project.ts' + +const root = resolve(import.meta.dirname, '..') +const GATE = 'verify-client-route-resolution' +/** App-owned route prefixes; a foreign path (`/assets/x`) is not governed here. */ +const APP_ROUTE_PREFIX = '(?:api|plugins|open-in-app)(?:/|$)' +/** A root-absolute or protocol-relative app route literal. */ +const ROOT_ABSOLUTE_ROUTE = new RegExp(`^/{1,2}${APP_ROUTE_PREFIX}`) +/** An absolute or protocol-relative app route literal on any origin or scheme. */ +const ABSOLUTE_ROUTE = new RegExp(`^(?:[A-Za-z][A-Za-z\\d+.-]*:)?//[^/]+/${APP_ROUTE_PREFIX}`) +/** A relative app route reference (`api/x`, `./plugins/x`). */ +const RELATIVE_APP_ROUTE = new RegExp(`^(?:\\./)*${APP_ROUTE_PREFIX}`) +/** A shared host route key: the absolute pathname a route is registered under. */ +const HOST_ROUTE_KEY = /(?:^|_)(?:PATH|ENDPOINT)$/ + +/** Request constructors whose first argument addresses a resource. */ +const REQUEST_CONSTRUCTORS: Record = { Request: true, EventSource: true, WebSocket: true, URL: true } +/** Assigned properties and JSX attributes that address a resource. */ +const REQUEST_PROPERTIES: Record = { href: true, src: true } +/** Fields a browser resolves against its own document. */ +const REFERENCE_FIELDS: Record = { url: true, src: true, href: true, initialUrl: true } + +/** + * Static producer of browser references: the node half composes the boot graph + * the browser resolves against its document. No request-target position in + * browser code sees those values, so its reference fields are checked directly. + */ +const REFERENCE_PRODUCERS = [ + 'packages/client/modules/src/index.ts', +] + +/** Rule that produced a violation, for diagnostics and for the gate's own coverage. */ +export type RouteResolutionRule = 'request-target' | 'location-base' | 'host-route-key' | 'reference-producer' + +/** What a scanned file is: browser face, or a static producer of browser references. */ +export type RouteGateFace = 'browser' | 'reference-producer' + +const HINT: Record = { + 'request-target': 'write the route document-relative (api/x)', + 'location-base': 'resolve the route against document.baseURI, not against a location read', + 'host-route-key': 'strip the leading slash before the browser uses this key (KEY.slice(1))', + 'reference-producer': 'emit the browser reference document-relative (plugins/x)', +} + +/** One app-owned route reached from a place that binds it to one mount. */ +export interface RouteResolutionViolation { + /** One-based source column. */ + column: number + /** Repository-relative source path. */ + file: string + /** One-based source line. */ + line: number + /** The offending expression, as written. */ + pattern: string + /** Rule that recognized the route. */ + rule: RouteResolutionRule +} + +/** A location expression: `location`, optionally behind its owning global. */ +const LOCATION_EXPRESSION = /^(?:(?:window|self|globalThis|document|top|parent|frames)\.)?(?:defaultView\.)?location$/ + +/** Source text of a location expression, without whitespace. */ +function locationText(node: ts.Expression): string | undefined { + const text = node.getText().replace(/\s+/g, '') + return LOCATION_EXPRESSION.test(text) ? text : undefined +} + +/** Whether an expression rebuilds a base from the page location anywhere inside it. */ +function readsLocation(node: ts.Node): boolean { + if (ts.isExpression(node) && locationText(node) !== undefined) return true + return node.forEachChild(readsLocation) === true +} + +/** Host route keys an expression references without stripping their leading slash. */ +function unstrippedHostRouteKeys(node: ts.Node): ts.Identifier[] { + if (ts.isIdentifier(node)) { + if (!HOST_ROUTE_KEY.test(node.text)) return [] + const derived = ts.isPropertyAccessExpression(node.parent) && node.parent.name.text === 'slice' + return derived ? [] : [node] + } + const keys: ts.Identifier[] = [] + node.forEachChild((child) => { keys.push(...unstrippedHostRouteKeys(child)) }) + return keys +} + +/** Callee name of a call or construction, for constructors and `fetch`-shaped carriers. */ +function calleeName(expression: ts.Expression): string | undefined { + if (ts.isIdentifier(expression)) return expression.text + return ts.isPropertyAccessExpression(expression) ? expression.name.text : undefined +} + +/** + * The request target one node carries, when it addresses a resource. + * @param node - any node visited in the tree. + * @returns the target expression, or undefined when the node is not a target. + */ +function requestTargetOf(node: ts.Node): ts.Expression | undefined { + if (ts.isCallExpression(node)) { + const name = calleeName(node.expression) + if ((name !== undefined && /fetch(er)?$/iu.test(name)) || node.expression.kind === ts.SyntaxKind.ImportKeyword) return node.arguments[0] + return undefined + } + if (ts.isNewExpression(node)) { + const name = calleeName(node.expression) + return name !== undefined && REQUEST_CONSTRUCTORS[name] === true ? node.arguments?.[0] : undefined + } + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken + && ts.isPropertyAccessExpression(node.left) && REQUEST_PROPERTIES[node.left.name.text] === true) { + return node.right + } + if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && REQUEST_PROPERTIES[node.name.text] === true) { + const { initializer } = node + if (initializer === undefined) return undefined + return ts.isJsxExpression(initializer) ? initializer.expression : initializer + } + return undefined +} + +/** One literal fragment of a composed value, with the node that carries it. */ +interface LiteralFragment { node: ts.Node; text: string } + +/** + * Literal fragments of a value composed of literals, `+` concatenation, + * templates, and conditionals. Template fragments are the spans between + * substitutions, so an interpolated prefix (`${origin}/api/x`) is visible. + * @param node - the expression to decompose. + * @returns fragments in source order. + */ +function literalFragments(node: ts.Expression): LiteralFragment[] { + if (ts.isParenthesizedExpression(node)) return literalFragments(node.expression) + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) { + return [...literalFragments(node.left), ...literalFragments(node.right)] + } + if (ts.isConditionalExpression(node)) { + return [...literalFragments(node.whenTrue), ...literalFragments(node.whenFalse)] + } + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return [{ node, text: node.text }] + if (ts.isTemplateExpression(node)) { + // Every fragment reports the template itself: an interpolated prefix makes + // the offending span start mid-expression, and one template is one route. + return [ + { node, text: node.head.text }, + ...node.templateSpans.map(span => ({ node, text: span.literal.text })), + ] + } + return [] +} + +/** + * Find app-owned routes that bind one source file to one mount. + * @param file - repository-relative path used in diagnostics. + * @param sourceText - TypeScript or TSX source. + * @param face - `browser` for request targets, `reference-producer` for the + * fields a static host emits into the browser. + * @returns violations in source order. + */ +export function findRouteResolutionViolations( + file: string, + sourceText: string, + face: RouteGateFace = 'browser', +): RouteResolutionViolation[] { + const source = ts.createSourceFile( + file, + sourceText, + ts.ScriptTarget.Latest, + true, + file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ) + const violations = new Map() + + const report = (node: ts.Node, rule: RouteResolutionRule): void => { + const start = node.getStart(source) + if (violations.has(start)) return + const { character, line } = source.getLineAndCharacterOfPosition(start) + violations.set(start, { column: character + 1, file, line: line + 1, pattern: node.getText(source), rule }) + } + + /** A request target's own literals must be document-relative. */ + const checkTarget = (target: ts.Expression): void => { + for (const fragment of literalFragments(target)) { + if (ROOT_ABSOLUTE_ROUTE.test(fragment.text) || ABSOLUTE_ROUTE.test(fragment.text)) { + report(fragment.node, 'request-target') + } + } + // A shared route key used as-is: the browser half strips the leading slash. + for (const key of unstrippedHostRouteKeys(target)) report(key, 'host-route-key') + } + + /** A relative app route belongs to the document base, not to a rebuilt origin. */ + const checkUrlBase = (node: ts.NewExpression): void => { + const [first, second] = node.arguments ?? [] + if (first === undefined || second === undefined || !readsLocation(second)) return + const literal = literalFragments(first) + if (literal.some(fragment => RELATIVE_APP_ROUTE.test(fragment.text))) report(first, 'location-base') + } + + /** A field the browser resolves against its document carries a reference. */ + const checkReferenceField = (value: ts.Expression): void => { + for (const fragment of literalFragments(value)) { + if (ROOT_ABSOLUTE_ROUTE.test(fragment.text) || ABSOLUTE_ROUTE.test(fragment.text)) { + report(fragment.node, 'reference-producer') + } + } + } + + const visit = (node: ts.Node): void => { + if (face === 'browser') { + const target = requestTargetOf(node) + if (target !== undefined) checkTarget(target) + if (ts.isNewExpression(node) && calleeName(node.expression) === 'URL') checkUrlBase(node) + } else if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) + && REFERENCE_FIELDS[node.name.text] === true && ts.isExpression(node.initializer)) { + checkReferenceField(node.initializer) + } + ts.forEachChild(node, visit) + } + visit(source) + + return [...violations.values()].sort((left, right) => left.line - right.line || left.column - right.column) +} + +/** + * Source files of every browser-side TypeScript project in the Client aggregate. + * + * A browser half belongs to `src/client`; a package that keeps its whole UI in + * plain `src` is found through the project itself, which carries the Client + * compiler shape (DOM libraries). A project the Host aggregate also compiles + * has a host half rather than a browser-only surface, so only its `src/client` + * tree is browser face. Every such face must contribute at least one source + * file: a face that contributes none means the aggregate's layout moved out + * from under this discovery, which must fail loud rather than scan less. + * @param projectRoot - repository root to discover from. + * @returns sorted repository-relative source paths. + */ +export function browserFaceSources(projectRoot: string = root): string[] { + const sources = new Set() + const add = (file: string): void => { + const normalized = file.replaceAll('\\', '/') + if (normalized.endsWith('.d.ts')) return + sources.add(normalized) + } + const hostConfigs = faceConfigs(projectRoot, 'host').byPath + + for (const [configPath, parsed] of faceConfigs(projectRoot, 'client').byPath) { + if (parsed.options.lib?.includes('lib.dom.d.ts') !== true || hostConfigs.has(configPath)) continue + const sourceRoot = `${dirname(configPath).replaceAll('\\', '/')}/src/` + // An aggregate or app shell (the root Client program) carries the compiler + // shape but no `src/` tree of its own; its browser sources arrive through + // the references and `src/client` discovery below. + if (!existsSync(sourceRoot)) continue + const faceSources = parsed.fileNames.filter((file) => { + const normalized = file.replaceAll('\\', '/') + return normalized.startsWith(sourceRoot) && /\.tsx?$/.test(normalized) + }) + if (faceSources.length === 0) { + throw new Error( + `${GATE}: browser face ${relative(projectRoot, configPath).split(sep).join('/')} has DOM libraries but contributes no source file; discovery is broken.`, + ) + } + for (const file of faceSources) add(relative(projectRoot, file).split(sep).join('/')) + } + + for (const file of globSync('packages/*/*/src/client/**/*.{ts,tsx}', { cwd: projectRoot })) { + add(relative(projectRoot, resolve(projectRoot, file)).split(sep).join('/')) + } + + return [...sources].sort() +} + +/** Scan one file for one face; a producer that disappeared must fail loud. */ +function scan(file: string, face: RouteGateFace): RouteResolutionViolation[] { + const path = resolve(root, file) + if (face === 'reference-producer' && !existsSync(path)) { + throw new Error(`${GATE}: browser-reference producer ${file} is gone; discovery is broken.`) + } + return findRouteResolutionViolations(file, readFileSync(path, 'utf8'), face) +} + +function main(): void { + const files = browserFaceSources() + const violations = [ + ...files.flatMap(file => scan(file, 'browser')), + ...REFERENCE_PRODUCERS.flatMap(file => scan(file, 'reference-producer')), + ] + if (violations.length > 0) { + console.error(`${GATE}: ${violations.length} app-owned route(s) that are not document-relative:`) + for (const violation of violations) { + console.error( + ` ${violation.file}:${violation.line}:${violation.column} ${violation.pattern} — ${HINT[violation.rule]}`, + ) + } + process.exitCode = 1 + return + } + console.log( + `${GATE}: ${files.length} browser source file(s) and ${REFERENCE_PRODUCERS.length} browser-reference producer(s) keep app-owned routes document-relative.`, + ) +} + +if (import.meta.filename === resolve(process.argv[1] ?? '')) main()