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 new file mode 100644 index 0000000000..c52f0a47a2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.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/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 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 new file mode 100644 index 0000000000..ed740acd0d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md @@ -0,0 +1,43 @@ +# Agent Note: Session prose local media paths display through a same-origin file route + +Status: implemented + +English | [中文](2026-09-07-session-prose-local-media-display.zh.md) + +## Problem + +Assistant prose can reference an image by its filesystem path, but browsers cannot read Host files. A renderer limited to absolute HTTP(S) destinations leaves those references as inert alt text. Issue #3662 records this display gap. + +## Decision + +Local media paths in Session prose render through a same-origin file route. This note owns the renderer vocabulary and its placement; [authenticated filesystem reads](2026-09-08-file-display-through-filesystem.md) owns the current serving policy and supersedes the workspace/media restrictions described below. + +`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. + +`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. + +## Alternatives considered + +**Typert gateway or workspace controller ownership.** The gateway owns Remote RPC dispatch, while the workspace controller owns registry lifecycle. Neither owns file-byte presentation; Session Controller is the consumer serving Session prose. + +**Session RPC followed by blob/data URLs.** Attachment images can use an asynchronous fetch, but this Markdown vocabulary must synchronously resolve a destination during a memoized render pass. + +**Image-only endpoints.** One file route can serve images, audio, and video without separate URL vocabularies. The current implementation returns complete bounded files; Markdown audio/video player nodes remain independent work. + +**Byte-signature validation in the route.** The model-facing `read_image` tool owns image admission checks. Display responses describe content by MIME lookup and let browser decoding reject corrupt payloads, avoiding a duplicate signature checker. + +**Workspace/media-only access (superseded).** The original policy restricted canonical paths to registered workspace roots and allowed image/video/audio MIME categories except SVG. Regular-file checks before opening rejected pipes and devices; an opened-handle identity comparison narrowed replacement races. These restrictions bounded authenticated access and avoided a per-request interactive authorization flow. They also excluded temporary screenshots and remote files; the successor note records the replacement policy and why those restrictions are not retained. + +## Consequences + +The Client vocabulary cannot bypass Host authentication or the filesystem provider. The original restricted route distinguished an existing outside-workspace path from an absent path, exposing existence even while refusing its bytes; the successor policy instead permits ordinary provider-readable files. + +Windows-style authored paths remain unsupported by the Client vocabulary. Trajectory and tool-card Markdown consumers do not supply this vocabulary, and audio/video Markdown nodes do not render players. These are renderer limitations, independent of the file route's readable MIME types. + +The archived [model-readable image paths](../../archived/feature/2026-08-21-model-readable-image-paths.md) note owns the model-facing behavior; this note owns user-facing display and does not supersede it. + +## Testing + +Renderer tests cover settled and streaming gates, reference-style images, protocol rechecks, failed-load fallback, and replacement sources. Chat tests cover the vocabulary and component wiring. The browser scenario in `apps/web/tests/markdown-images.e2e.ts` boots the shipped Web composition with a seeded Session and checks actual loading and fallback text. A model-driven recorded Session round trip remains separate from this UI expectation; the successor note names current route coverage. 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 new file mode 100644 index 0000000000..de8a7f99d7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 会话正文本地媒体路径通过同源文件路由显示 + +Status: implemented + +[English](2026-09-07-session-prose-local-media-display.md) | 中文 + +## Problem + +Assistant 正文可能通过文件系统路径引用图片,但浏览器无法读取 Host 文件。仅允许绝对 HTTP(S) 目标的渲染器会把这些引用保留为静态 alt 文本。Issue #3662 记录了这一展示缺口。 + +## Decision + +Session 正文中的本地媒体路径通过同源文件路由渲染。本记录拥有渲染器词表及其归属;[鉴权文件系统读取](2026-09-08-file-display-through-filesystem.zh.md)拥有当前文件服务策略,并取代下文的工作区与媒体限制。 + +`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 页面传输保持静态回退。 + +`session-controller` 在 `SessionFileReferences` 旁拥有 `SessionMediaReferences` 贡献。它通过 `connection.fetch` 注册;该通道执行与 `/api` RPC 相同的浏览器鉴权和信任检查。固定同源端点让同步渲染器获得稳定 URL,无需异步能力协商。 + +## Alternatives considered + +**由 Typert gateway 或 workspace controller 拥有。** gateway 拥有 Remote RPC 分发,workspace controller 拥有注册表生命周期。两者都不拥有文件字节展示;Session Controller 是服务 Session 正文的消费方。 + +**先经 Session RPC 获取,再使用 blob/data URL。** 附件图片可以异步获取,但此 Markdown 词表必须在记忆化渲染过程中同步解析目标。 + +**图片专用端点。** 单一文件路由即可服务图片、音频和视频,无需独立 URL 词表。当前实现返回有界完整文件;Markdown 音视频播放器节点仍是独立工作。 + +**路由中的字节签名校验。** 面向模型的 `read_image` 工具拥有图片准入检查。展示响应通过 MIME 查询描述内容,由浏览器解码拒绝损坏载荷,避免重复实现签名检查器。 + +**仅限工作区与媒体的访问(已取代)。** 原策略把规范路径限制在已注册工作区根目录内,并允许除 SVG 外的 image/video/audio MIME 类别。打开前的普通文件检查拒绝管道与设备;已打开句柄的身份比较收窄替换竞态。这些限制约束了鉴权后的访问范围,并避免每次请求的交互授权流程。它们也排除了临时截图与远程文件;后续记录说明替代策略及不保留这些限制的理由。 + +## Consequences + +客户端词表无法绕过 Host 鉴权或文件系统提供方。原受限路由区分了工作区外已存在路径与缺失路径,即使拒绝其字节仍暴露存在性;后续策略则允许提供方可读的普通文件。 + +客户端词表仍不支持作者提供的 Windows 风格路径。轨迹与工具卡片 Markdown 消费方不提供此词表,音视频 Markdown 节点也不渲染播放器。这些属于渲染器限制,与文件路由可读的 MIME 类型无关。 + +已归档的[模型可读图片路径](../../archived/feature/2026-08-21-model-readable-image-paths.md)记录拥有模型侧行为;本记录拥有用户侧展示,不取代它。 + +## Testing + +渲染器测试覆盖稳定与流式门禁、引用式图片、协议复查、加载失败回退和来源替换。聊天测试覆盖词表与组件连接。`apps/web/tests/markdown-images.e2e.ts` 浏览器场景使用已播种 Session 启动交付的 Web 组合,检查实际加载与回退文本。模型驱动的记录 Session 往返仍独立于此 UI 期望;后续记录说明当前路由覆盖。 diff --git a/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.i18n.yaml new file mode 100644 index 0000000000..0817dcd4f1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.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/feature/2026-09-08-file-display-through-filesystem.md +2026-09-08-file-display-through-filesystem.md: b1f93f8c9fbd245ad69bc732b2a599c6e0abeb0d +2026-09-08-file-display-through-filesystem.zh.md: faa3fdb757c9a3fc60cae37692a559158e02ceee diff --git a/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.md b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.md new file mode 100644 index 0000000000..b1f93f8c9f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.md @@ -0,0 +1,35 @@ +# Agent Note: Authenticated file display reuses filesystem byte reads + +Status: implemented + +English | [中文](2026-09-08-file-display-through-filesystem.zh.md) + +## Problem + +Session prose can reference screenshots in temporary directories or files stored by a remote filesystem provider. A Host-local workspace allowlist cannot serve those paths. An image response without a byte limit can also make the browser download a 1 GiB image before attempting to decode it. + +## Decision + +The authenticated `/api/file` route reads ordinary files through `ctx.fs`. Authentication and the composed provider's read policy govern access; directory and MIME allowlists do not. This supersedes the serving policy in [the local-media display note](2026-09-07-session-prose-local-media-display.md), which retains renderer ownership and its rationale. + +GET calls the existing `readBytes(target, signal, maxBytes)`: providers reject known oversized files before content I/O and enforce the limit while reading. HEAD uses metadata without reading content. `FS_TOO_LARGE` becomes 413. MIME lookup supplies response metadata without sniffing file contents; unknown extensions use `application/octet-stream`. A sandbox CSP prevents directly opened HTML/SVG from executing with the authenticated API origin. + +All files use the resolved `ctx.attachments.imageLimits.maxImageBytes` limit, normally 20 MiB. The attachment service owns this deployment setting. All responses contain complete files; Range is ignored and no range support is advertised. + +## Alternatives considered + +**Workspace and media allowlists.** They limit which authenticated bytes can be read, but exclude ordinary screenshot locations and remote files. The chosen policy permits every regular file the composed provider can read. + +**A new filesystem byte-stream API.** Efficient large-file delivery and audio/video seeking would require implementations in every provider, including remote range handling. Complete bounded reads satisfy the current display scope without widening that interface. Streaming and Range can be added when those use cases justify the provider work. + +**Duplicate size checks in the route.** GET needs no additional stat/read loop: `readBytes` already owns preflight limits, growth detection, and cancellation. HEAD checks size separately because it must not read the body. + +## Consequences + +Temporary and remote files use the same filesystem provider as `read_image`, without adding model-facing events. The local sandbox provider constrains mutations and permits reads; an authenticated client therefore has broader access than registered workspace roots. Files remain subject to the provider's permissions and the route's byte limits. + +Each GET buffers the complete file in Host memory. Audio/video work as complete responses without incremental transfer or guaranteed seeking. Encoded byte limits do not bound decoded pixel dimensions. Failed image loads show authored alt text or the original destination when alt is empty. + +## Testing + +Route tests cover sparse 1 GiB rejection before content I/O, post-stat growth, the shared attachment byte limit, ordinary MIME types, temporary paths and symlinks, opaque remote targets, provider failures, metadata-only HEAD, ignored Range, and disposal. Browser expectations cover rendered images, 413/404 and corrupt-image fallbacks, and an image outside the workspace. Remote byte transfer remains owned by the existing filesystem provider tests. diff --git a/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.zh.md b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.zh.md new file mode 100644 index 0000000000..faa3fdb757 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 鉴权文件展示复用文件系统字节读取 + +Status: implemented + +[English](2026-09-08-file-display-through-filesystem.md) | 中文 + +## Problem + +会话正文可能引用临时目录中的截图或远程文件系统提供方中的文件。Host 本地工作区白名单无法提供这些路径。没有字节上限的图片响应还可能让浏览器先下载一张 1 GiB 图片,再尝试解码。 + +## Decision + +鉴权 `/api/file` 路由通过 `ctx.fs` 读取普通文件。鉴权和所组合提供方的读取策略决定访问权限;目录与 MIME 白名单不参与准入。这取代了[本地媒体展示记录](2026-09-07-session-prose-local-media-display.zh.md)中的文件服务策略;该记录保留渲染器归属及其理由。 + +GET 调用现有 `readBytes(target, signal, maxBytes)`:提供方在内容 I/O 前拒绝已知超限文件,并在读取过程中执行上限。HEAD 使用元数据,不读取内容。`FS_TOO_LARGE` 转换为 413。MIME 查询提供响应元数据,不嗅探文件内容;未知扩展名使用 `application/octet-stream`。sandbox CSP 阻止直接打开的 HTML/SVG 以鉴权 API 源身份执行脚本。 + +所有文件均使用已解析的 `ctx.attachments.imageLimits.maxImageBytes` 上限,通常为 20 MiB。附件服务拥有此部署配置。所有响应均包含完整文件;忽略 Range,也不声明支持 Range。 + +## Alternatives considered + +**工作区和媒体白名单。** 它们限制鉴权后能读取哪些字节,却排除了常见截图位置和远程文件。所选策略允许读取所组合提供方可读的任意普通文件。 + +**新增文件系统字节流 API。** 高效的大文件传输和音视频跳转需要每个提供方实现,包括远端 Range 处理。有界完整读取满足当前展示范围,无需扩展该接口。相关用例足以支持这项提供方工作时,可以加入流式传输与 Range。 + +**在路由重复实现大小检查。** GET 无需额外的 stat/read 循环:`readBytes` 已经负责读取前上限、增长检测和取消。HEAD 单独检查大小,因为它不能读取正文。 + +## Consequences + +临时与远程文件使用与 `read_image` 相同的文件系统提供方,不增加模型可见事件。本地沙箱提供方约束变更操作并允许读取,因此鉴权客户端的访问范围大于已注册工作区根目录。文件仍受提供方权限和路由字节上限约束。 + +每个 GET 都会在 Host 内存中缓存完整文件。音视频使用完整响应,不支持增量传输,也不保证跳转播放。编码字节上限不限制解码后的像素尺寸。图片加载失败后展示作者提供的 alt 文本;alt 为空时展示原始目标路径。 + +## Testing + +路由测试覆盖内容 I/O 前拒绝稀疏 1 GiB 文件、stat 后增长、共用附件字节上限、普通 MIME 类型、临时路径与符号链接、不透明远程目标、提供方失败、仅元数据 HEAD、忽略 Range 和释放。浏览器期望覆盖图片渲染、413/404 及损坏图片回退,以及工作区之外的图片。远程字节传输仍由现有文件系统提供方测试负责。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index a43b428f74..49c2d8ba69 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -90,6 +90,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT | | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | +| [`mime-types`](https://github.com/jshttp/mime-types) | MIT | | [`negotiator`](https://github.com/jshttp/negotiator) | MIT | | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | @@ -156,6 +157,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`@types/fs-ext`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/mime-types`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 3e00a67cb0..c010943b60 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -16,6 +16,13 @@ - paragraph: - img "Remote test image" - paragraph: Local test image +- paragraph: + - img "Workspace test image" +- paragraph: Oversized image +- paragraph: + - img "Outside workspace image" +- paragraph: Missing image +- paragraph: {{cwd}}/corrupt.png - paragraph: REMOTE_IMAGE_DONE - button "Copy": - img diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts index 9e4a7be60e..e531e133a4 100644 --- a/apps/web/tests/feedback-command.e2e.ts +++ b/apps/web/tests/feedback-command.e2e.ts @@ -81,6 +81,8 @@ describe('web e2e: /feedback command acknowledgement', () => { await input.press('Enter') await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 }) expect(await page.getByText(/Anonymous user: [0-9a-f-]+\.$/i).count()).toBe(1) + await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('') + await expect.poll(() => page.getByRole('button', { name: 'Add attachment' }).isEnabled(), { timeout: 10_000 }).toBe(true) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE) const expanded = await captureExpandedTurnProcessAria( diff --git a/apps/web/tests/github-ready-review.e2e.ts b/apps/web/tests/github-ready-review.e2e.ts index 1530c35c80..9c44a90e04 100644 --- a/apps/web/tests/github-ready-review.e2e.ts +++ b/apps/web/tests/github-ready-review.e2e.ts @@ -140,8 +140,10 @@ describe.skipIf(MODE === 'record')('web e2e: GitHub ready-for-review', () => { }, } expect((await send(webhookOrigin, 'ready', payload)).status).toBe(202) - await vi.waitFor(() => { expect(scaffold.ctx.agents.list()).toHaveLength(before + 1) }) - await vi.waitFor(() => { expect(adapter.requests).toHaveLength(1) }) + // HTTP 202 acknowledges ingress; the model request follows durable + // Workspace and Session creation by the asynchronous webhook rule. + await expect.poll(() => adapter.requests.length, { timeout: 30_000 }).toBe(1) + expect(scaffold.ctx.agents.list()).toHaveLength(before + 1) const agent = scaffold.ctx.agents.list().find(candidate => candidate.session.header.cwd === scaffold.workspaceCwd) expect(agent).toBeDefined() diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index cac81f2bb0..c189c982a7 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -349,6 +349,14 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect(await connecting.innerText()).toMatch(/^Reconnecting\.{1,3}$/) const connectingGeometry = await connectionIndicatorGeometry(connecting) expect(await connectionIndicatorTextAlignment(connecting)).toBe('left') + // Animated dots must remain hidden with their state label during hover. + await connecting.evaluate((element) => { + for (const animation of element.getAnimations({ subtree: true })) { + if (!(animation instanceof CSSAnimation)) continue + animation.pause() + animation.currentTime = 1_250 + } + }) await connecting.hover() expect(await connecting.innerText()).toBe('Reconnect now') expect(await connectionIndicatorGeometry(connecting)).toEqual(connectingGeometry) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 3f6f192507..a72723de46 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -182,7 +182,10 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { await queuedRow.getByRole('button', { name: 'Remove queued message' }).click() await expect.poll(() => queuedRow.count(), { timeout: 10_000 }).toBe(0) - await page.getByRole('button', { name: 'Stop generating' }).click() + const stopButton = page.getByRole('button', { name: 'Stop generating' }) + await stopButton.hover() + await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor() + await stopButton.click() await settled expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted') // Composer recovered; no streaming node lingers. The host settled first @@ -190,6 +193,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // frozen-partial swap is eventually consistent, so poll rather than count. await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true) await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0) + await expect.poll(() => page.getByRole('tooltip').count()).toBe(0) // Golden of the aborted end-state: the prompt bubble plus the frozen // partial ('partial' is the hang entry's replayed prefix) and no more. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index 54ac8ddc9e..b8f18c6c67 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -1,8 +1,7 @@ -// Web e2e scenario: absolute HTTP(S) Markdown images. A validated session -// assembled through the Session API is seeded cold into the real web -// composition, then a separate image origin proves that the browser receives -// a real network image while local-path Markdown remains inert alt text. +// Real browser image loading and failure fallbacks through the shipped Web composition. +import { open, writeFile } from 'node:fs/promises' import { createServer, type Server } from 'node:http' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' @@ -32,6 +31,7 @@ const MODE = webSnapshotMode() const SEED_ID = 'markdown-images-web-e2e' const REMOTE_ALT = 'Remote test image' const LOCAL_ALT = 'Local test image' +const WORKSPACE_ALT = 'Workspace test image' const PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64', @@ -81,7 +81,7 @@ async function stopServer(server: Server): Promise { } /** Build one closed, invariant-checked session fixture with remote and local image Markdown. */ -function markdownImageFixture(remoteUrl: string): string { +function markdownImageFixture(remoteUrl: string, outsidePath: string): string { const session = Session.create(SessionId('markdown-image-source')) const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) session.append('turn/start', { turn: 1 }) @@ -110,6 +110,16 @@ function markdownImageFixture(remoteUrl: string): string { '', `![${LOCAL_ALT}](./local-image.png)`, '', + `![${WORKSPACE_ALT}]({{cwd}}/valid.png)`, + '', + '![Oversized image]({{cwd}}/oversized.png)', + '', + `![Outside workspace image](${outsidePath})`, + '', + '![Missing image]({{cwd}}/missing.png)', + '', + '![]({{cwd}}/corrupt.png)', + '', 'REMOTE_IMAGE_DONE', ].join('\n'), }], @@ -142,20 +152,38 @@ function markdownImageFixture(remoteUrl: string): string { ].join('\n') } -describe('web e2e: remote Markdown image rendering', () => { +describe('web e2e: Markdown image rendering', () => { let scaffold: WebScaffold let imageOrigin: ImageOrigin let browser: Browser let page: Page let tripwire: ReturnType + const mediaResponses = new Map() beforeAll(async () => { imageOrigin = await startImageOrigin() scaffold = await launchWebScaffold({}) - await seedSession(scaffold, markdownImageFixture(imageOrigin.url), SEED_ID) + await writeFile(join(scaffold.workspaceCwd, 'valid.png'), PNG) + await writeFile(join(scaffold.workspaceCwd, 'corrupt.png'), 'invalid image') + const oversized = await open(join(scaffold.workspaceCwd, 'oversized.png'), 'w') + try { + await oversized.truncate(20 * 1024 * 1024 + 1) + } finally { + await oversized.close() + } + const outsidePath = join(scaffold.persistenceRoot, 'outside.png') + await writeFile(outsidePath, PNG) + await writeFile(join(scaffold.workspaceCwd, 'active.html'), '

File preview

') + await seedSession(scaffold, markdownImageFixture(imageOrigin.url, outsidePath), SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) + page.on('response', (response) => { + const url = new URL(response.url()) + if (url.pathname !== '/api/file') return + const path = url.searchParams.get('path') + if (path !== null) mediaResponses.set(path, response.status()) + }) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) @@ -163,17 +191,30 @@ describe('web e2e: remote Markdown image rendering', () => { afterAll(async () => { await browser?.close() await scaffold?.close() - await stopServer(imageOrigin.server) + if (imageOrigin !== undefined) await stopServer(imageOrigin.server) }) - it.skipIf(MODE === 'record')('loads only the remote image and matches the conversation golden', async () => { + it('authenticates file requests and isolates directly opened active content', async () => { + const path = `/api/file?path=${encodeURIComponent(join(scaffold.workspaceCwd, 'active.html'))}` + const unauthenticated = await fetch(new URL(path, scaffold.baseUrl)) + expect(unauthenticated.status).toBe(401) + await unauthenticated.body?.cancel() + const preview = await newEnglishPage(browser) + await preview.context().addCookies(await page.context().cookies()) + try { + const response = await preview.goto(new URL(path, scaffold.baseUrl).href) + expect(response?.status()).toBe(200) + await preview.getByText('File preview', { exact: true }).waitFor() + expect(await preview.locator('body').getAttribute('data-script-ran')).toBeNull() + } finally { + await preview.close() + } + }) + + it.skipIf(MODE === 'record')('loads permitted images and shows authored text for failures', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-images')) - const groupRow = page.locator('[role="treeitem"]').first() - await groupRow.waitFor({ timeout: 15_000 }) - await groupRow.click() - const sessionRow = page.locator('[role="treeitem"]').nth(1) - await sessionRow.waitFor({ timeout: 10_000 }) - await sessionRow.click() + await page.getByRole('treeitem').first().click() + await page.getByRole('treeitem').nth(1).click() await expect.poll(() => page.getByText('REMOTE_IMAGE_DONE', { exact: true }).count(), { timeout: 15_000, }).toBe(1) @@ -203,6 +244,27 @@ describe('web e2e: remote Markdown image rendering', () => { expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1) expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }]) + const workspaceImage = page.getByRole('img', { name: WORKSPACE_ALT }) + await expect.poll(() => mediaResponses.get(join(scaffold.workspaceCwd, 'valid.png'))).toBe(200) + await expect.poll(() => workspaceImage.evaluate(element => (element as HTMLImageElement).naturalWidth, undefined, { + timeout: 1_000, + })) + .toBe(1) + const outsideImage = page.getByRole('img', { name: 'Outside workspace image' }) + await expect.poll(() => outsideImage.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1) + for (const alt of ['Oversized image', 'Missing image']) { + await page.getByText(alt, { exact: true }).waitFor() + expect(await page.getByRole('img', { name: alt }).count()).toBe(0) + } + await page.getByText(join(scaffold.workspaceCwd, 'corrupt.png'), { exact: true }).waitFor() + expect(mediaResponses).toEqual(new Map([ + [join(scaffold.workspaceCwd, 'valid.png'), 200], + [join(scaffold.workspaceCwd, 'oversized.png'), 413], + [join(scaffold.persistenceRoot, 'outside.png'), 200], + [join(scaffold.workspaceCwd, 'missing.png'), 404], + [join(scaffold.workspaceCwd, 'corrupt.png'), 200], + ])) + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index ebaec71502..97cf82cd19 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -212,6 +212,10 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await settings.getByLabel('上下文窗口 3').fill('131072') await settings.getByLabel('最大输出 token 数 3').fill('64K') + await expect.poll( + () => settings.getByLabel('API 密钥', { exact: true }).getAttribute('placeholder'), + { timeout: 10_000 }, + ).toBe('已配置——输入新值可替换') const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE) await settings.getByRole('button', { name: '保存', exact: true }).click() diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 086bbce416..65f646187a 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterEach, describe, expect, it, onTestFailed } from 'vitest' +import { afterEach, describe, expect, it, onTestFailed, vi } from 'vitest' import { deriveReplayScript, parseSessionLog, type ReplayEntry } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { @@ -147,29 +147,36 @@ describe('web e2e: queue row actions', () => { ).toBe(2) await page.setViewportSize({ width: 640, height: 1000 }) - const queueBox = await page.locator('[data-queue-dock]').boundingBox() - const composerBox = await page.locator('[data-composer-card]').boundingBox() - expect(queueBox).not.toBeNull() - expect(composerBox).not.toBeNull() - expect(queueBox!.x).toBeGreaterThanOrEqual(composerBox!.x) - expect(queueBox!.x + queueBox!.width) - .toBeLessThanOrEqual(composerBox!.x + composerBox!.width) - const queueLeftInset = queueBox!.x - composerBox!.x - const queueRightInset = composerBox!.x + composerBox!.width - queueBox!.x - queueBox!.width - const composerMetrics = await page.locator('[data-composer-card]').evaluate((element) => { - const style = getComputedStyle(element) - return { - dockInset: Number.parseFloat(style.getPropertyValue('--dsh-composer-dock-inset')), - } - }) - expect(queueLeftInset).toBeCloseTo(composerMetrics.dockInset, 1) - expect(queueRightInset).toBeCloseTo(composerMetrics.dockInset, 1) + await page.locator('[data-sidebar-collapsed="true"]').waitFor() + // The responsive sidebar and composer settle independently; sample both + // rectangles in one browser task so the comparison uses one layout. + await vi.waitFor(async () => { + const metrics = await page.evaluate(() => { + const queue = document.querySelector('[data-queue-dock]') + const composer = document.querySelector('[data-composer-card]') + if (queue === null || composer === null) return undefined + const queueBox = queue.getBoundingClientRect() + const composerBox = composer.getBoundingClientRect() + return { + leftInset: queueBox.left - composerBox.left, + rightInset: composerBox.right - queueBox.right, + dockInset: Number.parseFloat(getComputedStyle(composer).getPropertyValue('--dsh-composer-dock-inset')), + } + }) + expect(metrics).toBeDefined() + expect(metrics!.leftInset).toBeGreaterThanOrEqual(0) + expect(metrics!.rightInset).toBeGreaterThanOrEqual(0) + expect(metrics!.leftInset).toBeCloseTo(metrics!.dockInset, 1) + expect(metrics!.rightInset).toBeCloseTo(metrics!.dockInset, 1) + }, { timeout: 10_000 }) await page.setViewportSize({ width: 1680, height: 1000 }) const editRow = page.locator('[data-queue-dock] li', { hasText: EDIT }) await editRow.getByRole('button', { name: 'Edit queued message' }).click() const editor = page.getByRole('textbox', { name: 'Edit queued message' }) await editor.fill(EDITED) + await page.getByRole('button', { name: 'Save queued message' }).hover() + await page.getByRole('tooltip', { name: 'Save queued message', exact: true }).waitFor() const editingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(EDITING_EXPECTED, editingSnapshot, MODE) await page.getByRole('button', { name: 'Save queued message' }).click() @@ -178,6 +185,11 @@ describe('web e2e: queue row actions', () => { const removeRow = page.locator('[data-queue-dock] li', { hasText: REMOVE }) await removeRow.getByRole('button', { name: 'Remove queued message' }).click() await expect.poll(() => page.getByText(REMOVE, { exact: true }).count()).toBe(0) + // The queue stream can remove the row before the mutation reply clears busy. + const remainingEdit = page.getByRole('button', { name: 'Edit queued message', exact: true }) + await expect.poll(() => remainingEdit.isEnabled(), { timeout: 10_000 }).toBe(true) + await remainingEdit.hover() + await page.getByRole('tooltip', { name: 'Edit queued message', exact: true }).waitFor() const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) @@ -215,16 +227,18 @@ describe('web e2e: queue row actions', () => { { timeout: 10_000 }, ).toBe(2) - await page.getByRole('button', { name: 'Stop generating' }).click() + const stopButton = page.getByRole('button', { name: 'Stop generating' }) + await stopButton.hover() + await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor() + await stopButton.click() await firstSettled await expect.poll(() => page.getByRole('button', { name: 'Stop generating' }).count()) .toBe(0) await expect.poll(() => page.getByRole('button', { name: 'Remove queued message' }).count()) .toBe(2) - // Stop becomes Send under the pointer; dismiss its hover tooltip before capture. - await page.mouse.move(0, 0) - await expect.poll(() => page.getByRole('tooltip').filter({ hasText: 'Send message' }).count()).toBe(0) + // The disabled Send button must dismiss the active Stop tooltip without mouseleave. + await expect.poll(() => page.getByRole('tooltip').count()).toBe(0) const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE) const expanded = await captureExpandedTurnProcessAria( diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 2f59edc165..a6ab6adf15 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -501,6 +501,9 @@ describe('web e2e: seeded history renders through cold resume', () => { const userId = userLine?.match(/^Anonymous user: ([0-9a-f-]+)/i)?.[1] if (userId === undefined) throw new Error('feedback command omitted the user id') + // command/done can arrive before the submit reply releases the composer. + await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('') + await expect.poll(() => page.getByRole('button', { name: 'Add attachment' }).isEnabled(), { timeout: 10_000 }).toBe(true) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') .split(userId).join('{{userId}}') diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index 08f4c5e193..5001232549 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -7,7 +7,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -32,6 +32,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = let page: Page let tripwire: ReturnType let prompt: string + const releaseChild = Promise.withResolvers() const waitForParentSettlement = (): Promise => new Promise((resolve, reject) => { let dispose = (): void => {} @@ -53,9 +54,14 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = scaffold = await launchWebScaffold({ replayFixture: PARENT_FIXTURE, replayChildFixtures: [CHILD_FIXTURE], - paceMs: 50, compareReplaySession: false, }) + // Keep the live child available throughout disclosure, layout, and navigation checks. + scaffold.ctx.on('llm/stream', async function* (options, next) { + const session = options.sessionId === undefined ? undefined : scaffold.ctx.sessions.get(options.sessionId) + if (session?.header.origin === 'subagent') await releaseChild.promise + yield* next() + }, { prepend: true }) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) @@ -65,6 +71,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = }, 120_000) afterAll(async () => { + releaseChild.resolve(undefined) await browser?.close() await scaffold?.close() }) @@ -72,6 +79,9 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = it('shows the live member, opens its local child, then retains the settled record beside the tool row', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-live')) const settled = waitForParentSettlement() + onTestFinished(() => { + releaseChild.resolve(undefined) + }) const input = page.locator('[data-composer-input]').first() await input.fill(prompt) await input.press('Enter') @@ -160,6 +170,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = const sessions = page.getByRole('tree', { name: 'Sessions' }) await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click() + releaseChild.resolve(undefined) await settled await expandTurnProcesses(page) await page.locator('[data-workflow-run][data-run-status="completed"]').waitFor() diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index d591dbe3b0..99bce35b56 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -77,6 +77,11 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff () => scaffold.ctx.workspaceRegistry.resolveByPath(join(parent, name)), { timeout: 10_000 }, ).not.toBeUndefined() + // Adoption also opens a blank Session. Its selected row must reach the + // browser before a later workspace action can depend on the row positions. + const row = page.getByRole('treeitem').filter({ hasText: name }).first() + const section = row.locator('xpath=ancestor::*[contains(@class, "groupSection")][1]') + await section.locator('[role="treeitem"][aria-selected="true"]').waitFor({ timeout: 10_000 }) } /** diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 8c4e980c28..0b79f5f0a8 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.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 docs/config-catalog.md -config-catalog.md: 90390cab85be57485ad5446e1897f4f11be49ef5 +config-catalog.md: d3ea87e94ecb2de64c146c8647684785e537647c config-catalog.zh.md: 053ca6d1e357c07524b4b993447a59ed69761a88 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 90390cab85..d3ea87e94e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -213,7 +213,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:70`](../packages/api/session-controller/src/index.ts) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 7789f3c308..fa254b9d2a 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: ecb72f02bc7a27a75318e8abf56c20f9915831ea -module-graph.zh.md: 5c94c866d10e81948dd69089210cd4b08ded7531 +module-graph.md: f0ebb388318ed4a0aa8896a8040a5539f5477037 +module-graph.zh.md: 0cc8ff7b9a7601102f8d708b8ebb2d17c7bb0612 diff --git a/docs/module-graph.md b/docs/module-graph.md index ecb72f02bc..f0ebb38831 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1070,6 +1070,7 @@ flowchart TD pkg_api_session_controller --> pkg_client_file_upload pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference + pkg_api_session_controller --> pkg_fs pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm pkg_api_session_controller --> pkg_native_command @@ -1419,7 +1420,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 5c94c866d1..0cc8ff7b9a 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1072,6 +1072,7 @@ flowchart TD pkg_api_session_controller --> pkg_client_file_upload pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference + pkg_api_session_controller --> pkg_fs pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm pkg_api_session_controller --> pkg_native_command @@ -1421,7 +1422,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 47da2a25c0..156a15aa1a 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/api/session-controller/README.md -README.md: 8f7de90c2029117296bca02ce13dc5e769471434 -README.zh.md: b99589c4d7ed0a0105874bcbbd37dfd03927918d +README.md: 7d0785c77395f9eae55656708a15a842a27f079b +README.zh.md: b39a6a512b82cef45550d11660b7a87b2cd0b035 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 8f7de90c20..7d0785c773 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -13,6 +13,7 @@ English | [中文](README.zh.md) ## Table of Contents - [Use this package](#use-this-package) +- [Session media references](#session-media-references) - [Configuration](#configuration) - [Model Experience](#model-experience) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) @@ -31,6 +32,12 @@ The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. + + +## Session media references + +`SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. + ----- @@ -57,6 +64,7 @@ No direct effect; model requests remain owned by the Agent and LLM packages. +- The image byte cap does not validate decoded dimensions or pixel count. - Control baselines represent process-local state and therefore cannot reconstruct jobs after a Host restart. - A failed follow resumption remains visible to the caller instead of retrying indefinitely. - The raw browser upload is one streaming HTTP request without resumable offsets; a retry sends the file again from byte zero. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index b99589c4d7..b39a6a512b 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -13,6 +13,7 @@ kind: "package-reference" ## 目录 - [使用本包](#use-this-package) +- [会话媒体引用](#session-media-references) - [配置](#configuration) - [模型体验](#model-experience) - [已知限制与延期工作](#known-limitations-and-deferred-work) @@ -31,6 +32,12 @@ Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 + + +## 会话媒体引用 + +当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与 sandbox CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 + ----- @@ -57,6 +64,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 +- 图片字节上限不校验解码后的尺寸或像素数。 - Control baseline 表示进程本地状态,因此 Host 重启后无法重建 jobs。 - follow 恢复失败会对调用方可见,而不会无限重试。 - 浏览器原始字节上传使用一次不带断点续传偏移的流式 HTTP 请求;重试会从第一个字节重新传输整个文件。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 65b757e0e1..19b5339da6 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -71,6 +71,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-deque": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", + "mime-types": "^3.0.2", "zod": "^4.4.3" }, "peerDependencies": { @@ -84,6 +85,7 @@ "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", @@ -125,9 +127,11 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-file-upload": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", @@ -150,6 +154,7 @@ "@deepseek-ai/dsh-util-time": "workspace:^", "@deepseek-ai/dsh-util-values": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", - "@deepseek-ai/dsh-workspace": "workspace:^" + "@deepseek-ai/dsh-workspace": "workspace:^", + "@types/mime-types": "^3.0.1" } } diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index c058f25a08..9cc93be179 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -22,6 +22,7 @@ import { ApiSessionList } from './list.ts' import { buildModelCatalog } from './catalog.ts' import { installModelSelectionProjection } from './model-selection-projection.ts' import { SessionSkillCatalog } from './skill-catalog.ts' +import { SessionMediaReferences } from './media-references.ts' import type { ModelCatalog, SessionAttachmentRequest, @@ -134,6 +135,7 @@ export class SessionController extends TypertRemoteService { this.canOpenPath = internals.canOpenPath ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) ctx.plugin(SessionFileReferences) + ctx.plugin(SessionMediaReferences) ctx.plugin(SessionSkillCatalog) ctx.on('session/created', (session) => { diff --git a/packages/api/session-controller/src/media-references.ts b/packages/api/session-controller/src/media-references.ts new file mode 100644 index 0000000000..9da80029f5 --- /dev/null +++ b/packages/api/session-controller/src/media-references.ts @@ -0,0 +1,77 @@ +/** + * Authenticated GET/HEAD /api/file reads bounded file responses through + * the composed filesystem provider. Paths and MIME types do not restrict access; + * the connection service authenticates requests before this handler. + * @module @deepseek-ai/dsh-api-session-controller/media-references + */ + +import { isAbsolute } from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-connection' +import type {} from '@deepseek-ai/dsh-attachment' +import { FsError, type FileSystem } from '@deepseek-ai/dsh-fs' +import mime from 'mime-types' + +const BASE_HEADERS = { + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff', + // HTML and SVG files may be opened directly on the authenticated API origin. + 'Content-Security-Policy': "sandbox; default-src 'none'", +} + +async function serveFile(request: Request, fs: FileSystem, maxBytes: number): Promise { + const fail = (status: number, text: string): Response => + new Response(request.method === 'HEAD' ? null : text, { status, headers: BASE_HEADERS }) + const path = new URL(request.url).searchParams.get('path') + if (path === null || path.length === 0) return fail(400, 'missing path') + if (path.includes('\0') || !isAbsolute(path)) return fail(400, 'absolute path required') + try { + const target = await fs.resolve(path, { signal: request.signal }) + const mediaType = mime.lookup(target.displayPath) || 'application/octet-stream' + const headers: Record = { + ...BASE_HEADERS, + 'Content-Type': mediaType, + } + if (request.method === 'HEAD') { + const info = await fs.stat(target, request.signal) + if (info === undefined) return fail(404, 'not found') + if (info.type !== 'file') return fail(403, 'not a regular file') + if (info.size !== undefined) { + if (info.size > maxBytes) return fail(413, 'file exceeds byte limit') + headers['Content-Length'] = String(info.size) + } + return new Response(null, { headers }) + } + const bytes = await fs.readBytes(target, request.signal, maxBytes) + headers['Content-Length'] = String(bytes.byteLength) + return new Response(bytes.slice(), { headers }) + } catch (error: unknown) { + if (!(error instanceof FsError)) throw error + const statuses: Partial> = { + FS_NOT_FOUND: 404, + FS_NOT_REGULAR_FILE: 403, + FS_PERMISSION_DENIED: 403, + FS_SANDBOX_DENIED: 403, + FS_TOO_LARGE: 413, + FS_ABORTED: 499, + } + return fail(statuses[error.code] ?? 500, error.code) + } +} + +/** + * File-display contribution. The connection service supplies authentication; + * `ctx.fs` supplies the execution world's paths, reads, and access policy. + */ +export const SessionMediaReferences = { + inject: ['connection', 'fs', 'attachments'], + apply(ctx: Context): void { + const maxBytes = ctx.attachments.imageLimits.maxImageBytes + ctx.effect(() => ctx.connection.fetch.register({ + path: '/api/file', + methods: ['GET', 'HEAD'], + requestBody: 'buffered', + fetch: request => serveFile(request, ctx.fs, maxBytes), + }), 'session-controller: /api/file') + }, +} diff --git a/packages/api/session-controller/tests/media-references.host.spec.ts b/packages/api/session-controller/tests/media-references.host.spec.ts new file mode 100644 index 0000000000..61572ab0be --- /dev/null +++ b/packages/api/session-controller/tests/media-references.host.spec.ts @@ -0,0 +1,224 @@ +import { appendFile, mkdir, mkdtemp, open, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import { SessionMediaReferences } from '../src/media-references.ts' + +const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]) +const DEFAULT_LIMIT = 20 * 1024 * 1024 + +async function responseBytes(response: Response): Promise { + return new Uint8Array(await response.arrayBuffer()) +} + +describe('SessionMediaReferences /api/file', () => { + let root: string + const contexts: Context[] = [] + + beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-media-references-'))) + }) + + afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await rm(root, { recursive: true, force: true }) + }) + + async function mount(maxBytes = DEFAULT_LIMIT) { + const ctx = new Context() + contexts.push(ctx) + let handler: ((request: Request) => Promise) | undefined + const unregister = vi.fn(() => {}) + ctx.provide('connection', { + fetch: { + register: (registered: { fetch: (request: Request) => Promise }) => { + handler = registered.fetch + return unregister + }, + }, + } as never) + ctx.provide('attachments', { imageLimits: { maxImageBytes: maxBytes } } as never) + await ctx.plugin(LocalFileSystem, { cwd: root }).await() + await ctx.plugin(SessionMediaReferences).await() + const raw = (url: string, init?: RequestInit) => { + if (handler === undefined) throw new Error('route not registered') + return handler(new Request(url, init)) + } + return { + call: (path: string, init?: RequestInit) => raw(`http://127.0.0.1/api/file?path=${encodeURIComponent(path)}`, init), + raw, + fs: ctx.fs as LocalFileSystem, + unregister, + dispose: () => ctx.fiber.dispose(), + } + } + + it('serves the inclusive image cap and refuses larger images for GET, HEAD and Range', async () => { + const route = await mount(PNG_BYTES.length) + const path = join(root, 'bounded.png') + await writeFile(path, PNG_BYTES) + expect(await responseBytes(await route.call(path))).toEqual(PNG_BYTES) + await appendFile(path, new Uint8Array(1)) + expect((await route.call(path)).status).toBe(413) + expect((await route.call(path, { headers: { range: 'bytes=0-0' } })).status).toBe(413) + const head = await route.call(path, { method: 'HEAD' }) + expect(head.status).toBe(413) + expect(head.body).toBeNull() + }) + + it('rejects a sparse 1 GiB image before content I/O', async () => { + const route = await mount() + const inspect = vi.fn() + route.fs.internals.inspectReadBytesAfterStat = inspect + const path = join(root, 'huge.png') + const handle = await open(path, 'w') + try { + await handle.truncate(1024 * 1024 * 1024) + } finally { + await handle.close() + } + expect((await route.call(path)).status).toBe(413) + expect(inspect).not.toHaveBeenCalled() + }) + + it('uses the filesystem byte reader to reject post-stat image growth', async () => { + const route = await mount(PNG_BYTES.length) + const path = join(root, 'growing.png') + await writeFile(path, PNG_BYTES) + route.fs.internals.inspectReadBytesAfterStat = async () => { + await appendFile(path, new Uint8Array(1)) + } + expect((await route.call(path)).status).toBe(413) + }) + + it.each([ + ['png', 'image/png'], ['svg', 'image/svg+xml'], ['mp4', 'video/mp4'], ['mp3', 'audio/mpeg'], + ['txt', 'text/plain'], ['html', 'text/html'], ['bin', 'application/octet-stream'], ['', 'application/octet-stream'], + ])('serves .%s files with their MIME type and response protections', async (extension, mediaType) => { + const route = await mount() + const path = join(root, `file${extension === '' ? '' : `.${extension}`}`) + await writeFile(path, PNG_BYTES) + const response = await route.call(path) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe(mediaType) + expect(response.headers.get('content-length')).toBe(String(PNG_BYTES.length)) + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + expect(response.headers.get('content-security-policy')).toBe("sandbox; default-src 'none'") + expect(await responseBytes(response)).toEqual(PNG_BYTES) + }) + + it.each(['mp4', 'mp3', 'bin'])('applies the attachment byte cap to .%s files', async (extension) => { + const route = await mount(PNG_BYTES.length) + const path = join(root, `file.${extension}`) + await writeFile(path, PNG_BYTES) + expect(await responseBytes(await route.call(path))).toEqual(PNG_BYTES) + await appendFile(path, new Uint8Array(1)) + expect((await route.call(path)).status).toBe(413) + expect((await route.call(path, { method: 'HEAD' })).status).toBe(413) + }) + + it('ignores Range headers and returns complete bodies without advertising ranges', async () => { + const route = await mount() + const path = join(root, 'clip.mp4') + await writeFile(path, PNG_BYTES) + for (const range of ['bytes=0-3', 'bytes=-4', 'bytes=999-', 'bytes=abc', 'items=0-0', 'bytes=0-1,3-4']) { + const response = await route.call(path, { headers: { range } }) + expect(response.status).toBe(200) + expect(response.headers.get('accept-ranges')).toBeNull() + expect(response.headers.get('content-range')).toBeNull() + expect(await responseBytes(response)).toEqual(PNG_BYTES) + } + }) + + it('answers HEAD without reading content and reports missing and non-regular files', async () => { + const route = await mount() + const path = join(root, 'image.png') + await writeFile(path, PNG_BYTES) + const read = vi.spyOn(route.fs, 'readBytes') + const response = await route.call(path, { method: 'HEAD', headers: { range: 'bytes=0-3' } }) + expect(response.status).toBe(200) + expect(response.headers.get('content-length')).toBe(String(PNG_BYTES.length)) + expect(response.body).toBeNull() + expect(read).not.toHaveBeenCalled() + expect((await route.call(join(root, 'missing'), { method: 'HEAD' })).status).toBe(404) + expect((await route.call(root, { method: 'HEAD' })).status).toBe(403) + vi.spyOn(route.fs, 'stat').mockResolvedValue({ type: 'file', version: FsVersion('v1') }) + expect((await route.call(path, { method: 'HEAD' })).headers.get('content-length')).toBeNull() + }) + + it('rejects malformed paths, absent files, and directories', async () => { + const route = await mount() + expect((await route.raw('http://127.0.0.1/api/file')).status).toBe(400) + for (const path of ['', 'relative.png', '/a\0b.png']) { + expect((await route.call(path)).status).toBe(400) + } + const head = await route.call('', { method: 'HEAD' }) + expect(head.status).toBe(400) + expect(head.body).toBeNull() + expect((await route.call(join(root, 'missing.png'))).status).toBe(404) + await mkdir(join(root, 'frames.png')) + expect((await route.call(join(root, 'frames.png'))).status).toBe(403) + }) + + it('reads files and symlink targets outside the default cwd without a workspace registry', async () => { + const route = await mount() + const outside = await mkdtemp(join(tmpdir(), 'dsh-media-outside-')) + try { + const path = join(outside, 'image.png') + await writeFile(path, PNG_BYTES) + expect(await responseBytes(await route.call(path))).toEqual(PNG_BYTES) + const link = join(root, 'linked.png') + await symlink(path, link) + expect(await responseBytes(await route.call(link))).toEqual(PNG_BYTES) + } finally { + await rm(outside, { recursive: true, force: true }) + } + }) + + it.skipIf(process.platform === 'win32')('rejects a FIFO before opening it', async () => { + const route = await mount() + const path = join(root, 'stream.png') + const { execFile } = await import('node:child_process') + const { promisify } = await import('node:util') + await promisify(execFile)('mkfifo', [path]) + expect((await route.call(path)).status).toBe(403) + }) + + it('reads opaque remote targets through ctx.fs and preserves provider failures', async () => { + const route = await mount() + const target = { targetKey: FsTargetKey('opaque-remote-id'), displayPath: '/remote/photo.png' } + vi.spyOn(route.fs, 'resolve').mockResolvedValue(target) + const read = vi.spyOn(route.fs, 'readBytes').mockResolvedValue(PNG_BYTES) + expect(await responseBytes(await route.call('/remote/photo.png'))).toEqual(PNG_BYTES) + expect(read).toHaveBeenCalledWith(target, expect.any(AbortSignal), DEFAULT_LIMIT) + for (const [code, status] of [ + ['FS_PERMISSION_DENIED', 403], ['FS_SANDBOX_DENIED', 403], ['FS_NOT_FOUND', 404], + ['FS_NOT_REGULAR_FILE', 403], ['FS_TOO_LARGE', 413], ['FS_IO_ERROR', 500], + ] as const) { + read.mockRejectedValueOnce(new FsError('provider rejected read', code)) + expect((await route.call('/remote/photo.png')).status).toBe(status) + } + read.mockRejectedValueOnce(new Error('provider bug')) + await expect(route.call('/remote/photo.png')).rejects.toThrow('provider bug') + }) + + it('serves an empty file and respects an aborted request', async () => { + const route = await mount() + const path = join(root, 'empty.png') + await writeFile(path, '') + const response = await route.call(path) + expect(response.headers.get('content-length')).toBe('0') + expect(await response.text()).toBe('') + expect((await route.call(path, { signal: AbortSignal.abort() })).status).toBe(499) + }) + + it('unregisters the route on disposal', async () => { + const route = await mount() + await route.dispose() + expect(route.unregister).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index 85366bd632..45a22e93cb 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -17,6 +17,7 @@ "src/file-references.ts", "src/history.ts", "src/list.ts", + "src/media-references.ts", "src/model-selection-projection.ts", "src/skill-catalog.ts" ], @@ -30,8 +31,10 @@ { "path": "../../context/file-reference" }, { "path": "../../attachment/attachment" }, { "path": "../../client/file-upload/tsconfig.host.json" }, + { "path": "../../client/connection/tsconfig.host.json" }, { "path": "../../interaction/permission-presets" }, { "path": "../../jobs/jobs" }, + { "path": "../../fs/fs" }, { "path": "../../llm/llm" }, { "path": "../../util/deque" }, { "path": "../../util/native-command" }, diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index c517f9d0c2..3212801e00 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -1,7 +1,7 @@ import { Fragment, memo, useMemo } from 'react' import type { ReactNode } from 'react' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' -import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' +import type { MarkdownFileMentions, MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' import type { AssistantBlock } from '../contract/snapshot.ts' import { markdownLabels } from '../markdown-labels.ts' @@ -9,6 +9,22 @@ import { ReasoningRow } from './ReasoningRow.tsx' 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). + */ +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 interface AssistantMarkdownProps { blocks: readonly AssistantBlock[] streaming: boolean @@ -34,6 +50,13 @@ 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. + const pathImages = useMemo(() => { + const { protocol, origin } = window.location + return { resolve: value => localPathMediaUrl(protocol, origin, value) } + }, []) const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root @@ -55,6 +78,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ streaming={streaming} labels={labels} fileMentions={mentions} + pathImages={pathImages} />, ) break 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 new file mode 100644 index 0000000000..564f5a6a41 --- /dev/null +++ b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' +import { AssistantMarkdown, localPathMediaUrl } from '../src/client/chat/AssistantMarkdown.tsx' +import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../src/client/contract/slots.ts' +import type { AssistantBlock } from '../src/client/contract/snapshot.ts' + +afterEach(cleanup) + +const t = ((_key: string) => 'label') as unknown as ChatViewSlotProps['t'] +const renderMessageImages = (() => null) as unknown as ChatNodeOwnerProps['renderMessageImages'] + +function textBlock(text: string): AssistantBlock { + return { kind: 'text', text } +} + +const ORIGIN = 'http://127.0.0.1:3080' + +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')) + .toBe(`https://127.0.0.1:3080/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() + }) + + 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() + }) + + 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')}`) + }) +}) + +describe('AssistantMarkdown local-path images', () => { + it('renders a local image path in closing prose through the same-origin API', () => { + const { container } = render( + , + ) + const image = container.querySelector('img') + expect(image?.getAttribute('alt')).toBe('diagram') + const url = new URL(image?.getAttribute('src') ?? '') + expect(url.pathname).toBe('/api/file') + expect(url.searchParams.get('path')).toBe('/tmp/graph.png') + }) + + it('keeps non-absolute destinations inert', () => { + const { container } = render( + , + ) + expect(container.querySelector('img')).toBeNull() + expect(container.textContent).toContain('diagram') + }) +}) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index d93d7d7dc5..79765da08c 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: e06270a339a23fa17fb9d51642317fcf2aec6c0f -README.zh.md: 315fd3d7798145df7dbb6f504fe7b89c64827024 +README.md: 06667e15dd44c4bcaaea9212d52bb0eda8867c6e +README.zh.md: ce2b183e6fffcf00485856c8eed777368b446ac6 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index e06270a339..06667e15dd 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -48,7 +48,7 @@ Default sends commit optimistically: Enter clears the draft, occurrence table, a Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. -While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)). +Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)). ## Temporary composer entries diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 315fd3d779..ce2b183e6f 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -48,7 +48,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到 prompt 确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 -普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。 +Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。 ## 临时 composer entry diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index aa37b85ecf..3025bbe691 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -344,6 +344,8 @@ export const InputBar = memo(function InputBar({ // state keeps plain Send. A continuable child keeps Send primary and // exposes Stop independently. const primaryStops = running && subagent === null && (empty || blocked !== undefined) + // Disabled native buttons may omit mouseleave; their tooltip must close from state. + const primaryDisabled = primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending const interruptible = running && continuable const primarySubmitMode = resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable) const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/') @@ -526,7 +528,7 @@ export const InputBar = memo(function InputBar({ {sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })} {interruptible && ( - +