feat(web-shell): add web previews with saved delivery history (#11276)

* feat(web-shell): add web previews with saved delivery history

* fix(serve): register saved preview telemetry route

* test(cli): update telemetry route drift count

* fix(web-shell): reclaim snapshot files and address preview findings (#11276)

* fix(web-shell): Replace undefined mock in split rerender tests (#11404) (#11406)

* fix(web-shell): Replace undefined mock in split rerender tests (#11404)

The two "does not rerender App for other split sessions" tests referenced
mockUseDaemonActivePromptBridge, which is not defined anywhere in
App.test.tsx — a deterministic ReferenceError that failed the main-branch
CI Test job at 70cf363395. The intended render spy is
mockUseDaemonSessionActivityBridge: App calls useDaemonSessionActivityBridge
on every render, and this file already uses that mock as the render-call
witness elsewhere.

Co-authored-by: Qwen-Coder <[email protected]>

* test(web-shell): Clear spy before rerender so the guard can fail (#11406)

The "does not rerender App for other split sessions" tests asserted
mockUseDaemonSessionActivityBridge toHaveBeenCalled() after rerender(),
but nothing cleared the spy since the top-level beforeEach mockReset(),
so it already held setup-time calls from renderApp()/flush()/the split
click and the assertion passed whether or not the rerender rendered App.
Clearing it immediately before rerender() makes the guard measure that
render only; the trailing mockClear() stays to isolate the loop below.

Mutation-verified: with the clear in place, deleting rerender() or the
useDaemonSessionActivityBridge() call in App.tsx turns both
parameterized cases red at the guard (both previously stayed green).

Co-authored-by: Qwen-Coder <[email protected]>

---------

Co-authored-by: Qwen-Coder <[email protected]>

* test(cli): complete worktree ACP config fixture

* fix(acp-bridge): keep restored saved-webpage records when snapshot retain fails (#11276)

* test(cli): make dangling-include screen test host-independent (#11276)

The test asserts runOneMutant's detail names vitest, which only holds
when findVitestBin throws "not found" from the bare fixture — but Node
resolution walks up from the probe tree, so a node_modules above the
runner's TMPDIR (observed on self-hosted CI, where jobs share one /tmp)
resolves a vitest, the probe then runs for real, and the detail carries
no 'vitest'. Plant a shadow vitest that declares no bin in the fixture:
the innermost node_modules wins resolution on every host, so the throw
is deterministic. Same pattern as the runControlMutant shadow in this
file.

* test(web-shell): align branding fixtures with workspace and sidebar behavior

* fix(acp): preserve non-strict ingestion on snapshot reference errors

---------

Co-authored-by: qwen-code-ci-bot <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: qwen-code-dev-bot <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-11 10:01:34 +00:00
1 parent 782c22bff5
commit 20ecdaf6b2
58 files changed
+5319 -148

No files matched your search

@@ -0,0 +1,119 @@
# Web Shell live development preview
[English](web-shell-live-preview.md) | [简体中文](web-shell-live-preview.zh-CN.md)
## Problem and current behavior
The right panel renders HTML file contents in a sandboxed `srcDoc`, but cannot
open a running development server. Users must leave the conversation to inspect
an application, and the shipped shell's frame policy blocks other ports.
This first increment adds direct, browser-reachable development URLs to the
existing panel. It provides an address field, refresh, responsive width controls,
and external opening. The server continues to run through the existing terminal
or shell tool.
Preview history belongs to the conversation, independently of the viewing
panel. Opening an Artifact-published HTTP/HTTPS webpage from a turn opens its URL
in the preview when the feature is enabled. Ordinary `record_artifact` links
open metadata with an external action. Closing the panel or its tab does not
remove the artifact card; users can reopen it from the original message.
Manually entering an address in the panel does not create a transcript record.
These are historical entry points to a live URL, not frozen website versions.
The preview explicitly labels this distinction. Reusing a development URL
shows the current application even when opened from an older turn. Existing
artifact metadata also updates by identity, so it is not a version archive.
Replaying a previous website requires an immutable copy of that turn's HTML
and dependent assets, or an independently versioned deployment. That storage
is implemented for self-contained Artifact deliveries in
`web-shell-preview-snapshots.md`; panel localStorage is only viewing state.
## Design
| Layer | Change |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Panel | Add a `web_preview` tab and a `webPreview` action alongside review and terminal actions. Enable it in the standalone shell; embedded consumers opt in through `rightPanel.items`. |
| Preview | Add an internal `WebPreviewPanel` using shared input and button primitives. Desktop fills the panel; mobile uses a 390 CSS pixel viewport. |
| State | Store the entry URL and viewport with the tab in the existing workspace/session-keyed panel state. Keep inactive preview frames mounted while the panel stays open. |
| Navigation | The address is the requested entry URL. Without a page bridge, navigation within a cross-origin application cannot update this field. Refresh returns to the entry URL. |
| Server | Permit HTTP/HTTPS frames in the shell document policy while preserving the other CSP directives, frame-ancestor policy, and permissions policy. No new daemon route is introduced. |
The panel accepts absolute HTTP/HTTPS URLs without embedded credentials. It
rejects the shell and configured daemon origins. IPv6 literal URLs are excluded
from this increment because CSP host sources do not reliably support them; use
a hostname instead. URLs are validated again when rendering persisted state.
No daemon credentials are forwarded and preview requests omit the referrer.
Creation and restoration use the same workspace-context and host-opt-in gate.
Cards in Live or standalone sessions retain the existing artifact-panel behavior,
including its workspace availability restrictions.
The shell's frame policy also permits blob URLs for existing PDF attachments;
the preview wrappers continue to enforce their own narrower child policies.
A trusted, script-free `srcDoc` wrapper contains the application iframe. Its own
CSP pins `frame-src` to the validated development origin, blocking application
direct child redirects and navigations into the shell or a different origin.
Validation also rejects HTTP sources whose CSP-permitted HTTPS upgrade reaches
a protected origin. The inner frame
allows scripts, forms, and its own origin so ordinary modules and web storage
work, but cannot navigate ancestors or open popups. All interpolated markup is
escaped. Browser tests must verify both redirect and script-navigation blocking
before this design is accepted. The wrapper is not a recursive origin firewall:
the application's own descendant frames have their own policies. Shell HTML
must retain its existing `frame-ancestors` protection and file endpoints must
retain attachment/nosniff protection. Embedded consumers must prevent their own
app documents from being framed by preview origins. Vite development serves a
`frame-ancestors 'self'` policy for the same boundary.
Offline HTML artifact previews use their own trusted parent with
`frame-src 'none'` and an opaque content iframe. This parent restriction blocks
self-navigation from offline content even when the shell permits live URLs.
An iframe load event is not evidence of a successful response. The UI provides
an external-open fallback and a short explanation for blocked/unreachable pages,
without claiming that the development server is healthy. Applications with
frame restrictions may require external opening. Embedded Web Shell consumers
must also allow the desired HTTP/HTTPS frame origins in their own CSP.
The existing panel persistence key owns previews, including in multi-workspace
and session-switch flows. Existing turn-output callbacks route recorded webpage
requests into the viewing panel, with tab IDs distinguishing source sessions
and turns. No new filesystem request or annotation feedback route is added.
Closing a preview removes its iframe and does not terminate the user's server.
Closing/reopening the entire panel or changing sessions recreates browser state
from the saved entry URL; switching tabs within the open panel preserves it.
## Files affected
- `packages/web-shell/client/components/preview/WebPreviewPanel.tsx` and
its URL/document helper and focused tests.
- `packages/web-shell/client/components/artifacts/ArtifactPanel.tsx` and tests.
- `packages/web-shell/client/App.tsx` and tests for creation and persistence.
- `packages/web-shell/client/customization.tsx`, `main.tsx`, and `i18n.tsx`.
- `packages/web-shell/README.md` and a focused browser E2E spec.
- `packages/cli/src/serve/web-shell-static.ts` and its policy tests. This is a
process-global shell-document policy change; all static and deep-link shell
consumers use the same policy builder. Workspace runtime routing is unchanged.
## Follow-up increments
Element/region annotations need a controlled page bridge and correct routing to
the owning session's composer. Browser verification needs a shared page binding
to CUA/CDP. Remote development needs a separate-origin authenticated proxy with
HTTP and HMR WebSocket forwarding. None is implied by opening an entry URL in
this increment: the user's browser must already be able to reach it, through a
direct connection or an existing tunnel.
## Validation and open questions
Use the global `qwen` binary for the baseline, then the locally built bundle
for production CSP verification. Exercise a real development server, modules,
storage, WebSocket updates, viewport size, tab switches, refresh, external
opening, persistence, and invalid origins. Verify the trusted-wrapper policy
against hostile redirects and scripts. The detailed plan and observed results
live in `.qwen/e2e-tests/web-shell-live-preview.md`.
The pre-implementation browser spike passed modules, storage, Vite HMR and direct
redirect/script-navigation blocking on Chromium and Firefox. A descendant-frame
probe confirmed the host-policy requirement described above. WebKit was not
validated because the installed browser/runtime versions did not match.
@@ -0,0 +1,88 @@
# Web Shell 实时开发预览
[English](web-shell-live-preview.md) | [简体中文](web-shell-live-preview.zh-CN.md)
## 问题与现有行为
右侧面板可以在沙箱 `srcDoc` 中渲染 HTML 文件内容,但不能打开正在运行的开发服务器。
用户必须离开对话才能查看应用,已发布 Web Shell 的框架策略也会阻止其他端口。
本次增量在现有面板中加入浏览器可直接访问的开发 URL,提供地址输入、刷新、响应式宽度
切换和外部打开。服务器仍通过现有终端或 shell 工具运行。
预览历史属于对话,与查看面板相互独立。启用功能后,从某一轮打开 Artifact 发布的
HTTP/HTTPS 网页,会在预览中打开其 URL。普通 `record_artifact` 链接仍展示元数据和
外部打开操作。关闭面板或标签不会删除产物卡片,用户可以从原消息重新打开。
在面板中手动输入地址不会创建对话记录。
这些是实时 URL 的历史入口,不是冻结的网站版本。预览会明确标注这个区别。
复用开发 URL 时,即使从旧轮次打开,也会显示当前应用。现有产物元数据也会按身份更新,
因此不是版本档案。重现过去的网站需要不可变的当轮 HTML 与依赖资源副本,或独立的
版本化部署。自包含 Artifact 交付的存储已在 `web-shell-preview-snapshots.md` 中实现;
面板 localStorage 只保存查看状态。
## 设计
| 层次 | 改动 |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------ |
| 面板 | 在代码审查和终端操作旁新增 `web_preview` 标签和 `webPreview` 操作。独立 Web Shell 默认开启;嵌入式接入方通过 `rightPanel.items` 选择开启。 |
| 预览 | 新增内部 `WebPreviewPanel`,复用输入框和按钮原语。桌面模式填满面板,手机模式使用 390 CSS 像素视口。 |
| 状态 | 在现有按工作区和会话区分的面板状态中保存入口 URL 和视口。面板打开期间,非活动预览框架保持挂载。 |
| 导航 | 地址代表请求的入口 URL。没有页面桥接时,跨域应用内部导航不能更新该字段。刷新返回入口 URL。 |
| 服务器 | 在 Web Shell 文档策略中允许 HTTP/HTTPS 框架,同时保留其他 CSP 指令、frame-ancestor 策略和权限策略。不新增 daemon 路由。 |
面板接受不含登录凭据的绝对 HTTP/HTTPS URL,拒绝 Web Shell 和配置的 daemon 来源。
本次不支持 IPv6 字面量 URL,因为 CSP 主机来源不能可靠支持它们;请改用主机名。
渲染已持久化的状态时会再次验证 URL。预览不会转发 daemon 凭据,请求也不携带 referrer。
创建与恢复使用相同的工作区上下文和宿主选择开启条件。Live 或独立会话中的卡片保留
现有产物面板行为,包括工作区可用性限制。Web Shell 的框架策略同时允许现有 PDF 附件
所需的 blob URL;预览包装层继续执行各自更严格的子框架策略。
可信且不执行脚本的 `srcDoc` 包装层包含应用 iframe。包装层 CSP 把 `frame-src` 固定
为已验证的开发来源,阻止直接子应用框架重定向或导航到 Web Shell 或其他来源。
验证也会拒绝其 CSP 允许的 HTTPS 升级可到达受保护来源的 HTTP 地址。内层框架允许
脚本、表单及其自身来源,使普通模块和 web storage 可用,但不能导航祖先或打开弹窗。
所有插入标记的值均转义。验收前,浏览器测试必须验证重定向和脚本导航都被阻止。
包装层不是递归的来源防火墙:应用自身的后代框架使用各自的策略。Web Shell HTML
必须保留现有 `frame-ancestors` 保护,文件端点必须保留 attachment/nosniff 保护。
嵌入式接入方必须防止预览来源嵌入宿主应用文档。Vite 开发服务器通过
`frame-ancestors 'self'` 策略维持同一边界。
离线 HTML 产物预览使用单独的可信父框架,其 `frame-src 'none'` 策略包围具有不透明
来源的内容 iframe。即使 Web Shell 允许实时 URL,父框架限制也会阻止离线内容自身导航。
iframe 的 load 事件不能证明响应成功。界面提供外部打开的备用操作,并简要说明页面
被阻止或不可访问的情况,不声称开发服务器健康。限制嵌入的应用可能需要外部打开。
嵌入式 Web Shell 接入方还必须在自己的 CSP 中允许所需 HTTP/HTTPS 框架来源。
预览由现有面板持久化键管理,包括多工作区和会话切换流程。现有轮次产物回调把记录的
网页请求路由到查看面板,标签 ID 区分来源会话和轮次。不增加文件系统请求或标注反馈
路由。关闭预览会移除 iframe,不会终止用户的服务器。关闭后重开整个面板或切换会话
会从已保存入口 URL 重建浏览器状态;在打开的面板内切换标签则保留状态。
## 受影响文件
- `packages/web-shell/client/components/preview/WebPreviewPanel.tsx`、URL/文档辅助函数和定向测试。
- `packages/web-shell/client/components/artifacts/ArtifactPanel.tsx` 及测试。
- `packages/web-shell/client/App.tsx` 及创建和持久化测试。
- `packages/web-shell/client/customization.tsx`、`main.tsx` 和 `i18n.tsx`。
- `packages/web-shell/README.md` 和定向浏览器 E2E 用例。
- `packages/cli/src/serve/web-shell-static.ts` 及策略测试。这是进程全局的 Web Shell 文档策略变更;所有静态与深层链接入口共用同一个策略构建器。工作区运行时路由不变。
## 后续增量
元素或区域标注需要受控的页面桥接,并正确路由到所属会话的输入框。浏览器验证需要
与 CUA/CDP 共享页面绑定。远程开发需要独立来源的认证代理,转发 HTTP 和 HMR
WebSocket。本次打开入口 URL 不包含这些能力:用户浏览器必须已经能够通过直连或
现有隧道访问目标。
## 验证与开放问题
使用全局 `qwen` 二进制验证基线,再用本地构建的 bundle 验证生产 CSP。
覆盖真实开发服务器、模块、存储、WebSocket 更新、视口尺寸、标签切换、刷新、外部
打开、持久化和无效来源。针对恶意重定向和脚本验证可信包装层策略。详细计划及观察
结果位于 `.qwen/e2e-tests/web-shell-live-preview.md`。
实现前的浏览器试验已在 Chromium 和 Firefox 上通过模块、存储、Vite HMR 以及直接
重定向/脚本导航拦截验证。后代框架探针确认了上述宿主策略要求。由于已安装浏览器与
运行时版本不匹配,未验证 WebKit。
@@ -0,0 +1,143 @@
# Saved webpage versions in Web Shell
[English](web-shell-preview-snapshots.md) | [简体中文](web-shell-preview-snapshots.zh-CN.md)
## Problem
A historical link to a development server shows the current page. The user
needs the page delivered in that conversation turn to remain available after
later edits, closing the preview, reloading the shell, and restarting the
daemon.
## Design
In managed ACP sessions with chat recording enabled, each successful `Artifact`
publication also attempts to write a separate, immutable local HTML snapshot. Ordinary CLI
publication and sessions without recording do not create historical files. The
existing publisher continues to update its stable latest URL. The snapshot contains the exact wrapped HTML published in that invocation,
including inline styles, scripts, data, and embedded assets. Each invocation
gets a new ID, even when the source path or content is unchanged.
If snapshot storage fails, the published URL remains valid and the tool reports
that the historical version could not be saved. If cancellation arrives after
publication while the snapshot is being saved, the tool releases that new
snapshot and returns no artifact descriptors for the cancelled request. It
still reports the successful publication truthfully; other saved versions are
unaffected.
Snapshot bytes live under the producing runtime's storage directory in
`artifacts/snapshots/<UUID>/index.html`. Exclusive creation prevents overwrites.
The tool emits a separate published HTML artifact with a unique managed ID,
file URL, `artifactType: web_preview_snapshot`, the latest publication URL,
and the existing trusted `qwen.published.sha256` checksum. Existing tool-result
artifact ingestion and persistence associate that descriptor with its tool
call and original turn; HTML bytes do not enter model context or SSE messages.
Session restore and same-runtime forks retain these descriptors, including
their original timestamps and deletion markers. Only Artifact-produced snapshot
descriptors with the expected UUID path and checksum qualify for local file URL
restoration; ordinary local file links remain untrusted. Restore does not read
the HTML, so missing bytes do not prevent restoring the conversation record.
New snapshots also contain a `references/` directory with hashed session IDs,
identified by `qwen.snapshot.references: 1` in their metadata. Publication creates
the producing session's reference. A fork acquires its own reference before
committing the transcript. Missing snapshot storage does not block the fork:
it preserves the historical descriptor and warns that the content may be
unavailable. Other reference errors still abort the fork; a failed fork releases
only its operation's reference.
The owning runtime is captured explicitly. Session deletion releases only the
snapshot UUIDs found in the exact transcripts being removed, including old side
events; it does not scan other workspaces by session ID. Removal, eviction,
successful rewind, and session deletion release that session's references. Files are removed only
when the final reference is released. Failed removal persistence preserves the
bytes needed by the durable history. Old snapshots without reference metadata
are kept conservatively because their other fork owners cannot be established.
Files left by a process crash before descriptor persistence or a failed cleanup
are also kept; this change does not add an orphan-file collector.
An artifact batch that rolls back releases its discarded snapshots' session
references, including candidates dropped from the live change list during
eviction. It preserves snapshots still owned by the restored live records or
by other sessions. Snapshots whose removal failed to persist remain protected
even when absent from the live list. An incomplete restore also protects prior
snapshots that it skips instead of treating them as a completed rewind. This
protection is part of rollback state
and is cleared by a subsequent successful durable change or complete snapshot
or restore. This keeps failed batches from leaking references without deleting
bytes still needed by recorded history.
Non-strict ingestion keeps accepted records and their change notifications
aligned when snapshot reference bookkeeping fails: it reports an artifact-specific
warning and continues through normal persistence and notification. Strict
validation or persistence still aborts and rolls back the batch. Existing
references and missing snapshot storage remain tolerated without a new warning.
The new `GET /session/:id/artifacts/:artifactId/content` route is
**live-session-owner scoped**, with the same owner resolution, client filtering,
trust and cwd-bound read behavior as the artifact listing. It looks up the
registered artifact in that owner session and reads only the fixed snapshot
path beneath that runtime's storage directory. It validates the descriptor,
file URL, regular file, containment, 16 MiB bound and checksum. Missing or
altered snapshots return an error; there is no fallback to source files, latest
URLs or the primary runtime. Responses use attachment disposition and nosniff.
The browser reads this route through the authenticated daemon SDK using the
source session ID. The artifact panel renders the HTML in an opaque-origin,
script-enabled sandbox with the existing no-network artifact CSP. Saved and
ordinary HTML previews both use a fixed parent document whose `frame-src 'none'`
blocks the content frame's own navigations. The content frame has a separate
opaque origin, so its scripts cannot modify that parent policy. This preserves
the offline preview boundary while the shell allows live development URLs. The panel
shows that this is a saved version and its creation time. Closing the panel
removes only viewing state. Reopening from the original message fetches that
same version. Local file publication cards are omitted from a turn when that same publication
has its saved-version card. HTTP/HTTPS publication cards remain available for
opening the latest hosted page.
Refresh rebuilds an already loaded snapshot from its in-memory HTML, so it can
recover a blank navigated frame without another daemon request. An initial load
failure still retries the content route. Closing and reopening fetches the
version again; no snapshot bytes are persisted in browser storage.
## Boundaries and retention
This saves self-contained Artifact deliveries, not arbitrary live websites or
the transient state of a user's browser. Live URL preview remains available
and labeled as live. The Artifact tool already requires inline dependencies;
its existing best-effort validator is unchanged. The offline viewer blocks network
resources at runtime; this feature does not bundle external dependencies. Interactive state
starts from the delivered HTML when reopening a saved version.
Snapshot descriptors use existing session artifact retention and its 200-record
default limit. This increment does not implement an unlimited archive or change
session deletion/retention policy. A new publication never overwrites an earlier
snapshot; snapshots remain until their final retained record is removed. Older live-link records cannot be
retroactively reconstructed. Missing local snapshot bytes are reported as
unavailable, including when moving a transcript without its runtime storage.
## Affected components and validation
- Core Artifact tool, snapshot storage and persistence helpers, and focused tests.
- ACP artifact restore and deletion-marker validation.
- CLI owner-scoped content route and route tests.
- Daemon SDK authenticated content read method and tests.
- Web Shell turn selectors, artifact panel, translations and browser tests.
- Web Shell README and the live-preview design's version-history boundary.
Test publishing v1 then v2 from one source, retaining the stable latest URL
while both snapshot files retain their own bytes. Restart/read from persisted
session data, open each original message, exercise inline interaction, close
and reload, and make the source/latest page unavailable. Test owner isolation,
forged descriptors, symlinks, truncation and checksum mismatch. Fork with missing
snapshot bytes or reference storage and verify the conversation and descriptor
survive; other reference errors must not commit a partial fork. Inject batch
persistence, capacity and reference-sync failures and verify that rollback
releases new ownership while preserving existing and pending durable owners.
Cancel after publication and during snapshot writing; verify that the new
snapshot is reclaimed, earlier versions survive, and the published URL remains
readable. Run the global
CLI baseline first, then build/typecheck/bundle, focused unit tests and the
browser scenario on the local daemon. See
`.qwen/e2e-tests/web-shell-preview-snapshots.md` for commands and results.
There are no open design questions for this bounded increment.
@@ -0,0 +1,99 @@
# Web Shell 历史网页版本
[English](web-shell-preview-snapshots.md) | [简体中文](web-shell-preview-snapshots.zh-CN.md)
## 问题
开发服务器的历史链接显示的是当前页面。用户需要当轮交付的页面在后续编辑、关闭预览、
刷新 Web Shell 和重启 daemon 后仍然可用。
## 设计
开启会话记录的受管 ACP 会话中,每次成功的 `Artifact` 发布都会尝试额外写入独立、
不可变的本地 HTML 快照。普通 CLI 发布和未开启记录的会话不创建历史文件。
现有发布器继续更新稳定的最新 URL。快照包含该次调用发布的完整包装后 HTML,包括
内联样式、脚本、数据和内嵌资源。每次调用生成新 ID,即使源路径或内容相同。
快照存储失败时,已发布 URL 仍然有效,工具会报告历史版本未能保存。如果取消操作在
发布完成后、快照保存期间到达,工具会释放该次新快照,并且不为已取消的请求返回产物
描述符。返回说明仍会如实承认发布成功,其他历史版本不受影响。
快照字节位于生产该快照的运行时存储目录下的 `artifacts/snapshots/<UUID>/index.html`。
排他创建防止覆盖。工具额外发出一个已发布 HTML 产物,包含唯一 managed ID、文件 URL、
`artifactType: web_preview_snapshot`、最新发布 URL 和现有受信任的
`qwen.published.sha256` 校验和。现有工具结果产物接收与持久化流程将描述符关联到工具
调用和原始轮次;HTML 字节不会进入模型上下文或 SSE 消息。会话恢复和同运行时分叉
保留这些描述符,包括原时间戳和删除标记。只有来自 Artifact、符合预期 UUID 路径及
校验和的快照描述符可以恢复本地文件 URL;普通本地文件链接仍不受信任。恢复不读取
HTML,因此字节缺失不会阻止恢复对话记录。
新快照还包含保存会话 ID 哈希的 `references/` 目录,由元数据
`qwen.snapshot.references: 1` 标识。发布时创建所属会话的引用。分叉在提交对话文件
之前取得自己的引用。快照存储缺失不会阻止分叉:保留历史描述符,并警告内容可能
不可用。其他引用错误仍会终止分叉;失败分叉只释放该次操作的引用。所属运行时会被显式捕获。
删除会话仅释放实际删除的对话文件中出现的快照 UUID,包括旧侧通道事件,不会按会话
ID 扫描其他工作区。删除记录、淘汰、成功回退历史和删除会话都会释放该会话的引用。
只有最后一个引用被释放后才删除文件。删除持久化失败时,保留持久化历史所需的字节。
没有引用元数据的旧快照会保守保留,因为无法确定它们的其他分叉所有者。描述符持久化
前进程崩溃或清理失败留下的文件也会保留;本次不增加孤立文件回收器。
产物批次回滚时,会释放被丢弃快照的本会话引用,包括淘汰过程中从活动变更列表移除的
候选。恢复后的活动记录及其他会话仍持有的快照会保留。删除未能持久化的快照即使已不在
活动列表中,也会继续受到保护。不完整恢复跳过的已有快照也会保留,不会被当作成功
回退历史而删除。该保护状态随回滚恢复,并在后续成功的持久化变更、完整
快照提交或完整恢复后清除。这既避免失败批次泄漏引用,也不会误删已记录历史所需的字节。
非严格写入遇到快照引用记账失败时,会报告对应产物的警告,继续正常的持久化和通知流程,
使接受的记录与变更通知保持一致。严格校验或严格持久化仍会中止并回滚整个批次。
已有引用和缺失快照存储仍按原有规则容忍,不会新增警告。
新增 `GET /session/:id/artifacts/:artifactId/content` 路由属于
**live-session-owner(活动会话所有者)作用域**,使用与产物列表相同的所有者解析、
客户端过滤、信任检查和 cwd 绑定读取行为。它在所属会话中查找已注册产物,只读取该
运行时存储目录下的固定快照路径,验证描述符、文件 URL、普通文件类型、路径包含关系、
16 MiB 大小限制和校验和。快照缺失或被修改时返回错误,不回退到源文件、最新 URL
或主运行时。响应使用 attachment disposition 和 nosniff。
浏览器通过已认证的 daemon SDK,以来源会话 ID 读取此路由。产物面板在具有不透明
来源、允许脚本的沙箱中渲染 HTML,并使用现有禁止联网的产物 CSP。历史与普通 HTML
预览都使用固定父文档,其 `frame-src 'none'` 阻止内容框架自身导航。内容框架具有
独立的不透明来源,因此其脚本不能修改父层策略。这使 Web Shell 允许实时开发 URL
时仍保留离线预览边界。面板显示历史版本标记及创建时间。关闭面板只移除查看状态;
从原消息重新打开会获取同一版本。同一次发布具有历史版本卡片时,会隐藏轮次中的本地
文件发布链接卡片。HTTP/HTTPS 发布卡片仍保留,用于打开最新托管页面。
刷新会从内存中的 HTML 重建已加载快照,因此导航造成空白框架后,可以不再次请求
daemon 就恢复。初次加载失败仍会重试内容路由。关闭后重新打开会再次获取该版本;
不会把快照字节持久化到浏览器存储。
## 边界与保留策略
本功能保存自包含的 Artifact 交付,不保存任意实时网站或用户浏览器的临时状态。
实时 URL 预览仍可使用,并明确标为实时页面。Artifact 工具已经要求内联依赖,现有
尽力验证器保持不变。离线查看器在运行时阻止网络资源,本功能不打包外部依赖。
重新打开历史版本时,交互状态从交付 HTML 的初始状态开始。
快照描述符沿用会话产物的保留策略和默认 200 条记录上限。本次不实现无限存档,也不
改变会话删除或保留策略。新发布不会覆盖旧快照;快照保留到最后一个保留记录被删除。
过去的实时链接记录不能追溯重建。快照字节缺失时显示不可用,包括移动对话文件却没有
携带运行时存储的情况。
## 受影响组件与验证
- Core Artifact 工具、快照存储与持久化辅助函数及定向测试。
- ACP 产物恢复和删除标记验证。
- CLI 所有者作用域内容路由及路由测试。
- Daemon SDK 已认证的内容读取方法及测试。
- Web Shell 轮次选择器、产物面板、翻译和浏览器测试。
- Web Shell README 和实时预览设计中的版本历史边界。
测试从同一源文件发布 v1 再发布 v2,在保持稳定最新 URL 的同时,两份快照保留各自的
字节。重启并读取持久化会话数据,打开各原始消息,操作内联交互,关闭再刷新,并使源
文件和最新页面不可用。覆盖所有者隔离、伪造描述符、符号链接、截断和校验和不匹配。
在快照字节或引用存储缺失时分叉,验证对话和描述符均保留;其他引用错误不得提交不完整
分叉。注入批次持久化失败、容量超限和引用同步失败,验证回滚释放新引用,同时保留已有
所有者及删除尚未持久化的所有者。在发布后和快照写入期间取消,验证新快照被回收、早先
版本保留且已发布 URL 仍可读。先运行全局 CLI 基线,再执行 build/typecheck/bundle、定向单元测试和本地 daemon
上的浏览器场景。命令及结果见 `.qwen/e2e-tests/web-shell-preview-snapshots.md`。
本次有限增量没有开放设计问题。
+2
View File
@@ -7191,6 +7191,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
artifacts: new SessionArtifactStore({
sessionId,
workspaceCwd,
runtimeBaseDir: opts.artifactSnapshotRuntimeBaseDir,
persistence: createSessionArtifactPersistence(ci.connection, sessionId),
}),
artifactWorkspaceCwd: workspaceCwd,
@@ -11777,6 +11778,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
const artifacts = new SessionArtifactStore({
sessionId,
workspaceCwd: expectation.child.canonicalPath,
runtimeBaseDir: opts.artifactSnapshotRuntimeBaseDir,
persistence: createSessionArtifactPersistence(
entry.connection,
sessionId,
+2
View File
@@ -229,6 +229,8 @@ export interface BridgeTelemetry {
* strictly-required field. See per-field JSDoc for caller contract.
*/
export interface BridgeOptions {
/** Captured owner runtime for saved webpage bytes and references. */
artifactSnapshotRuntimeBaseDir?: string;
/**
* Runtime-owned directory for persistent session attachment bytes. Daemon
* callers provide a workspace-scoped directory under the Qwen runtime temp
@@ -5,17 +5,21 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createHash } from 'node:crypto';
import { createHash, randomUUID } from 'node:crypto';
import { promises as fs, type BigIntStats, type Stats } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import {
SessionArtifactAuthorizationError,
SessionArtifactStore,
SessionArtifactValidationError,
} from './sessionArtifacts.js';
import {
rebuildSessionArtifactSnapshot,
readArtifactSnapshot,
retainArtifactSnapshot,
deleteArtifactSnapshot,
stableSessionArtifactId,
type RebuiltSessionArtifactSnapshot,
type SessionArtifactEventRecordPayload,
@@ -6193,6 +6197,887 @@ describe('SessionArtifactStore', () => {
});
});
it('restores saved webpage records and deletion markers without reading their files', async () => {
const sessionId = 'saved-webpages';
const events: SessionArtifactEventRecordPayload[] = [];
const source = new SessionArtifactStore({
sessionId,
workspaceCwd: workspace,
persistence: {
recordEvent: async (payload) => {
events.push(payload);
},
recordSnapshot: async () => {},
},
});
await source.upsertMany(
[
'8c5e8dc7-4d9c-4a52-a703-7391e9b42dad',
'9c5e8dc7-4d9c-4a52-a703-7391e9b42dad',
].map((uuid) => ({
kind: 'html' as const,
storage: 'published' as const,
source: 'tool' as const,
toolName: 'artifact',
toolCallId: `call-${uuid}`,
title: 'Saved page',
managedId: `preview-${uuid}`,
url: pathToFileURL(
path.join(workspace, 'artifacts', 'snapshots', uuid, 'index.html'),
).href,
metadata: {
artifactType: 'web_preview_snapshot',
publishedUrl: 'https://example.com/latest',
'qwen.published.sha256': 'a'.repeat(64),
},
})),
{ strict: true, trustedPublisher: true },
);
const rebuilt = rebuildSessionArtifactSnapshot(
events.map((systemPayload) => ({
type: 'system',
subtype: 'session_artifact_event',
systemPayload,
})),
)!;
expect(rebuilt.artifacts).toHaveLength(2);
expect(rebuilt.artifacts[0]?.metadata?.['publishedUrl']).toBe(
'https://example.com/latest',
);
const snapshots: SessionArtifactSnapshotRecordPayload[] = [];
const restored = new SessionArtifactStore({
sessionId,
workspaceCwd: workspace,
persistence: {
recordEvent: async () => {},
recordSnapshot: async (payload) => {
snapshots.push(payload);
},
},
});
await expect(restored.restore(rebuilt)).resolves.toEqual([]);
const listed = await restored.list();
for (const saved of rebuilt.artifacts) {
expect(listed.artifacts).toContainEqual(
expect.objectContaining({ ...saved, restoreState: 'restored' }),
);
}
const first = rebuilt.artifacts[0]!;
await expect(
restored.restore({
...rebuilt,
artifacts: [rebuilt.artifacts[1]!],
tombstonedIds: [first.id],
markerArtifacts: [first],
}),
).resolves.toEqual([]);
await restored.recordSnapshot();
expect(snapshots.at(-1)?.markerArtifacts).toEqual([
expect.objectContaining(first),
]);
expect((await restored.list()).artifacts.map((entry) => entry.id)).toEqual([
rebuilt.artifacts[1]!.id,
]);
for (const override of [
{ source: 'client' as const },
{ source: 'hook' as const },
{ toolName: 'record_artifact' },
]) {
const rejected = new SessionArtifactStore({
sessionId,
workspaceCwd: workspace,
});
const warnings = await rejected.restore({
...rebuilt,
artifacts: [{ ...first, ...override }],
});
expect(warnings).toContain(
'artifact snapshot restore failed; kept existing live artifacts',
);
expect((await rejected.list()).artifacts).toEqual([]);
}
});
it('reclaims saved webpage snapshot files when their records leave the store', async () => {
const runtime = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-snapshot-runtime-'),
);
vi.stubEnv('QWEN_RUNTIME_DIR', runtime);
try {
const firstUuid = '8c5e8dc7-4d9c-4a52-a703-7391e9b42dad';
const secondUuid = '9c5e8dc7-4d9c-4a52-a703-7391e9b42dad';
const snapshotArtifact = async (uuid: string, html: string) => {
const dir = path.join(runtime, 'artifacts', 'snapshots', uuid);
await fs.mkdir(path.join(dir, 'references'), { recursive: true });
await fs.writeFile(path.join(dir, 'index.html'), html);
return {
kind: 'html' as const,
storage: 'published' as const,
source: 'tool' as const,
toolName: 'artifact',
toolCallId: `call-${uuid}`,
title: 'Saved page',
managedId: `preview-${uuid}`,
url: pathToFileURL(path.join(dir, 'index.html')).href,
metadata: {
artifactType: 'web_preview_snapshot',
'qwen.snapshot.references': 1,
publishedUrl: 'https://example.com/latest',
'qwen.published.sha256': createHash('sha256')
.update(html)
.digest('hex'),
},
};
};
const first = await snapshotArtifact(firstUuid, 'first');
const second = await snapshotArtifact(secondUuid, 'second');
const dirFor = (uuid: string) =>
path.join(runtime, 'artifacts', 'snapshots', uuid);
const store = new SessionArtifactStore({
sessionId: 'snapshot-reclaim',
workspaceCwd: workspace,
maxArtifacts: 1,
persistence: {
recordEvent: async () => {},
recordSnapshot: async () => {},
},
});
await store.upsertMany([first], {
strict: true,
trustedPublisher: true,
});
await store.upsertMany([second], {
strict: true,
trustedPublisher: true,
});
const listed = await store.list();
expect(listed.artifacts).toHaveLength(1);
// The evicted record's snapshot bytes are reclaimed; the retained
// record's snapshot stays in place.
await expect(fs.stat(dirFor(firstUuid))).rejects.toThrow();
await expect(
fs.readFile(path.join(dirFor(secondUuid), 'index.html'), 'utf8'),
).resolves.toBe('second');
await store.remove(listed.artifacts[0]!.id);
await expect(fs.stat(dirFor(secondUuid))).rejects.toThrow();
} finally {
vi.unstubAllEnvs();
await fs.rm(runtime, { recursive: true, force: true });
}
});
it('reclaims snapshot files for restored records pruned to the live limit', async () => {
const runtime = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-snapshot-runtime-'),
);
vi.stubEnv('QWEN_RUNTIME_DIR', runtime);
try {
const firstUuid = '8c5e8dc7-4d9c-4a52-a703-7391e9b42dad';
const secondUuid = '9c5e8dc7-4d9c-4a52-a703-7391e9b42dad';
const persistedSnapshot = async (
uuid: string,
html: string,
createdAt: string,
) => {
const dir = path.join(runtime, 'artifacts', 'snapshots', uuid);
await fs.mkdir(path.join(dir, 'references'), { recursive: true });
await fs.writeFile(path.join(dir, 'index.html'), html);
return {
id: stableSessionArtifactId(
'snapshot-restore-reclaim',
`managed:preview-${uuid}`,
),
kind: 'html' as const,
storage: 'published' as const,
source: 'tool' as const,
status: 'available' as const,
toolName: 'artifact',
title: 'Saved page',
managedId: `preview-${uuid}`,
url: pathToFileURL(path.join(dir, 'index.html')).href,
metadata: {
artifactType: 'web_preview_snapshot',
'qwen.snapshot.references': 1,
publishedUrl: 'https://example.com/latest',
'qwen.published.sha256': createHash('sha256')
.update(html)
.digest('hex'),
},
retention: 'restorable' as const,
clientRetained: false,
createdAt,
updatedAt: createdAt,
};
};
const first = await persistedSnapshot(
firstUuid,
'first',
'2026-09-07T00:00:00.000Z',
);
const second = await persistedSnapshot(
secondUuid,
'second',
'2026-09-07T00:01:00.000Z',
);
const dirFor = (uuid: string) =>
path.join(runtime, 'artifacts', 'snapshots', uuid);
const store = new SessionArtifactStore({
sessionId: 'snapshot-restore-reclaim',
workspaceCwd: workspace,
maxArtifacts: 1,
persistence: {
recordEvent: async () => {},
recordSnapshot: async () => {},
},
});
const warnings = await store.restore({
v: 2,
sessionId: 'snapshot-restore-reclaim',
sequence: 1,
artifacts: [first, second],
tombstonedIds: [],
stickyEphemeralIds: [],
warnings: [],
});
expect(warnings).toContain('restored artifact list pruned to live limit');
expect((await store.list()).artifacts).toHaveLength(1);
await expect(fs.stat(dirFor(firstUuid))).rejects.toThrow();
await expect(
fs.readFile(path.join(dirFor(secondUuid), 'index.html'), 'utf8'),
).resolves.toBe('second');
} finally {
vi.unstubAllEnvs();
await fs.rm(runtime, { recursive: true, force: true });
}
});
describe('saved webpage ownership', () => {
const persistence = {
recordEvent: async () => {},
recordSnapshot: async () => {},
};
async function saved(sessionId: string, html: string) {
const uuid = randomUUID();
const dir = path.join(workspace, 'artifacts', 'snapshots', uuid);
await fs.mkdir(path.join(dir, 'references'), { recursive: true });
await fs.writeFile(path.join(dir, 'index.html'), html);
return {
id: stableSessionArtifactId(sessionId, `managed:preview-${uuid}`),
kind: 'html' as const,
storage: 'published' as const,
source: 'tool' as const,
toolName: 'artifact',
title: 'Saved page',
managedId: `preview-${uuid}`,
url: pathToFileURL(path.join(dir, 'index.html')).href,
status: 'available' as const,
retention: 'restorable' as const,
clientRetained: false,
createdAt: '2026-09-07T00:00:00.000Z',
updatedAt: '2026-09-07T00:00:00.000Z',
metadata: {
artifactType: 'web_preview_snapshot',
'qwen.snapshot.references': 1,
'qwen.published.sha256': createHash('sha256')
.update(html)
.digest('hex'),
},
};
}
function snapshot(
sessionId: string,
artifacts: Array<Awaited<ReturnType<typeof saved>>>,
): RebuiltSessionArtifactSnapshot {
return {
v: 2,
sessionId,
sequence: 1,
artifacts,
tombstonedIds: [],
stickyEphemeralIds: [],
warnings: [],
};
}
function store(sessionId: string, maxArtifacts = 200) {
return new SessionArtifactStore({
sessionId,
workspaceCwd: workspace,
runtimeBaseDir: workspace,
maxArtifacts,
persistence,
});
}
it.each(['remove', 'evict'] as const)(
'keeps the parent readable after a fork %s',
async (action) => {
const page = await saved('parent', 'original');
const parent = store('parent');
await parent.restore(snapshot('parent', [page]));
const forked = {
...page,
id: stableSessionArtifactId('fork', `managed:${page.managedId}`),
};
const fork = store('fork', 1);
await fork.restore(snapshot('fork', [forked]));
if (action === 'remove') await fork.remove(forked.id);
else
await fork.upsertMany([await saved('fork', 'new')], {
strict: true,
trustedPublisher: true,
});
expect((await parent.list()).artifacts[0]!.status).toBe('available');
await expect(readArtifactSnapshot(page, workspace)).resolves.toBe(
'original',
);
await parent.remove(page.id);
await expect(readArtifactSnapshot(page, workspace)).rejects.toThrow();
},
);
it.each(['reject', 'unavailable'] as const)(
'keeps durable bytes when restore pruning persistence is %s',
async (failure) => {
const first = await saved('owner', 'first');
const second = await saved('owner', 'second');
const original = snapshot('owner', [first, second]);
const pruned = new SessionArtifactStore({
sessionId: 'owner',
workspaceCwd: workspace,
runtimeBaseDir: workspace,
maxArtifacts: 1,
persistence:
failure === 'reject'
? {
...persistence,
recordEvent: async () => {
throw new Error('disk full');
},
}
: undefined,
});
const warnings = await pruned.restore(original);
expect(warnings).toContain(
'artifact removal not persisted; live removal kept',
);
expect((await pruned.list()).artifacts).toHaveLength(1);
await expect(readArtifactSnapshot(first, workspace)).resolves.toBe(
'first',
);
const reloaded = store('owner');
await reloaded.restore(original);
expect((await reloaded.list()).artifacts).toHaveLength(2);
await expect(readArtifactSnapshot(second, workspace)).resolves.toBe(
'second',
);
},
);
it.each(['strict persistence', 'durable transition', 'capacity'])(
'releases new ownership after %s rollback and preserves existing owners',
async (failure) => {
let failWrites = false;
const live = new SessionArtifactStore({
sessionId: 'owner',
workspaceCwd: workspace,
runtimeBaseDir: workspace,
maxArtifacts: failure === 'strict persistence' ? 200 : 1,
persistence: {
...persistence,
recordEvent: async () => {
if (failWrites) throw new Error('disk full');
},
},
});
const prior = await saved('owner', 'prior');
if (failure !== 'capacity') {
await live.upsertMany([prior], {
strict: true,
trustedPublisher: true,
});
}
const before = (await live.list()).artifacts;
const pages = [await saved('owner', 'new')];
if (failure === 'capacity') pages.push(await saved('owner', 'second'));
for (const page of pages) {
await retainArtifactSnapshot(page, workspace, 'producer');
}
failWrites = true;
const result = live.upsertMany(
[
...(failure === 'capacity'
? []
: [{ ...prior, title: 'Attempted update' }]),
...pages,
],
{ strict: failure !== 'durable transition', trustedPublisher: true },
);
if (failure === 'durable transition') {
await expect(result).resolves.toMatchObject({
changes: [],
warnings: [
'artifact durable removal not persisted; live changes rolled back',
],
});
} else {
await expect(result).rejects.toThrow(
failure === 'capacity' ? 'artifact store is full' : 'disk full',
);
}
expect((await live.list()).artifacts).toEqual(before);
if (failure !== 'capacity') {
await expect(readArtifactSnapshot(prior, workspace)).resolves.toBe(
'prior',
);
expect(
await fs.readdir(
path.join(path.dirname(fileURLToPath(prior.url)), 'references'),
),
).toEqual([createHash('sha256').update('owner').digest('hex')]);
}
for (const page of pages) {
expect(
await fs.readdir(
path.join(path.dirname(fileURLToPath(page.url)), 'references'),
),
).toEqual([createHash('sha256').update('producer').digest('hex')]);
await deleteArtifactSnapshot(page, workspace, 'producer');
await expect(
fs.stat(path.dirname(fileURLToPath(page.url))),
).rejects.toMatchObject({ code: 'ENOENT' });
}
},
);
it.each(['before', 'after'] as const)(
'returns the complete non-strict batch when retaining a snapshot fails %s its reference write',
async (failure) => {
const first = await saved('owner', 'first');
const second = await saved('owner', 'second');
const snapshots: SessionArtifactSnapshotRecordPayload[] = [];
const live = new SessionArtifactStore({
sessionId: 'owner',
workspaceCwd: workspace,
runtimeBaseDir: workspace,
persistence: {
...persistence,
recordSnapshot: async (payload) => {
snapshots.push(payload);
},
},
});
const references = path.join(
path.dirname(fileURLToPath(first.url)),
'references',
);
const writeFile = fs.writeFile;
const spy = vi
.spyOn(fs, 'writeFile')
.mockImplementation(async (...args) => {
if (path.dirname(String(args[0])) === references) {
if (failure === 'after') await writeFile(...args);
throw Object.assign(new Error('ENOSPC: reference write failed'), {
code: 'ENOSPC',
});
}
await writeFile(...args);
});
try {
const result = await live.upsertMany([first, second], {
trustedPublisher: true,
});
expect(result.warnings).toEqual([
`artifact ${first.id} kept without retaining its snapshot: ENOSPC: reference write failed`,
]);
expect(result.changes.map((change) => change.action)).toEqual([
'created',
'created',
]);
expect(result.changes.map((change) => change.artifact)).toEqual(
(await live.list()).artifacts,
);
await expect(live.recordSnapshot()).resolves.toEqual([]);
expect(snapshots.at(-1)?.artifacts.map((page) => page.id)).toEqual(
result.changes.map((change) => change.artifactId),
);
await expect(readArtifactSnapshot(first, workspace)).resolves.toBe(
'first',
);
await expect(readArtifactSnapshot(second, workspace)).resolves.toBe(
'second',
);
expect(
await fs.readdir(
path.join(path.dirname(fileURLToPath(second.url)), 'references'),
),
).toEqual([createHash('sha256').update('owner').digest('hex')]);
} finally {
spy.mockRestore();
}
},
);
it.each(['EEXIST', 'ENOENT'] as const)(
'does not warn when snapshot retention tolerates %s',
async (code) => {
const page = await saved('owner', 'original');
const references = path.join(
path.dirname(fileURLToPath(page.url)),
'references',
);
if (code === 'ENOENT') await fs.unlink(fileURLToPath(page.url));
const writeFile = fs.writeFile;
const spy = vi
.spyOn(fs, 'writeFile')
.mockImplementation(async (...args) => {
if (path.dirname(String(args[0])) === references) {
await writeFile(...args);
}
await writeFile(...args);
});
try {
const live = store('owner');
const result = await live.upsertMany([page], {
trustedPublisher: true,
});
expect(result.warnings).toBeUndefined();
expect(result.changes).toHaveLength(1);
expect(result.changes[0]!.artifact).toEqual(
(await live.list()).artifacts[0],
);
if (code === 'EEXIST') {
expect(spy).toHaveBeenCalledTimes(1);
await expect(readArtifactSnapshot(page, workspace)).resolves.toBe(
'original',
);
}
} finally {
spy.mockRestore();
}
},
);
it.each([
{ strict: true },
{ validationStrict: true },
{ persistenceStrict: true },
])(
'cleans the whole batch after a reference write fails with %j',
async (strictOptions) => {
const first = await saved('owner', 'first');
const second = await saved('owner', 'second');
await retainArtifactSnapshot(second, workspace, 'owner');
const live = store('owner');
const references = path.join(
path.dirname(fileURLToPath(first.url)),
'references',
);
const writeFile = fs.writeFile;
const spy = vi
.spyOn(fs, 'writeFile')
.mockImplementation(async (...args) => {
await writeFile(...args);
if (path.dirname(String(args[0])) === references) {
throw new Error('reference write failed');
}
});
try {
await expect(
live.upsertMany([first, second], {
...strictOptions,
trustedPublisher: true,
}),
).rejects.toThrow('reference write failed');
expect((await live.list()).artifacts).toEqual([]);
for (const page of [first, second]) {
await expect(
fs.stat(path.dirname(fileURLToPath(page.url))),
).rejects.toMatchObject({ code: 'ENOENT' });
}
} finally {
spy.mockRestore();
}
},
);
it.each([false, true])(
'protects failed durable pruning through rollback after partial restore: %s',
async (partialRestore) => {
const first = await saved('owner', 'first');
const second = await saved('owner', 'second');
const live = new SessionArtifactStore({
sessionId: 'owner',
workspaceCwd: workspace,
runtimeBaseDir: workspace,
maxArtifacts: 1,
persistence: {
...persistence,
recordEvent: async () => {
throw new Error('disk full');
},
},
});
await expect(
live.restore(snapshot('owner', [first, second])),
).resolves.toContain(
'artifact removal not persisted; live removal kept',
);
if (partialRestore) {
await expect(
live.restore(
snapshot('owner', [{ ...first, id: 'abcdef1234567890' }, second]),
),
).resolves.toContain(
'skipped artifact with mismatched id abcdef1234567890',
);
}
await expect(
live.upsertMany([first], { strict: true, trustedPublisher: true }),
).rejects.toThrow('disk full');
expect((await live.list()).artifacts.map((page) => page.id)).toEqual([
second.id,
]);
await expect(readArtifactSnapshot(first, workspace)).resolves.toBe(
'first',
);
await expect(readArtifactSnapshot(second, workspace)).resolves.toBe(
'second',
);
},
);
it.each(['event', 'snapshot', 'restore'])(
'stops protecting an old failed removal after a complete %s commit',
async (commit) => {
const first = await saved('owner', 'first');
const second = await saved('owner', 'second');
await retainArtifactSnapshot(first, workspace, 'fork');
let failWrites = true;
const live = new SessionArtifactStore({
sessionId: 'owner',
workspaceCwd: workspace,
runtimeBaseDir: workspace,
maxArtifacts: 1,
persistence: {
...persistence,
recordEvent: async () => {
if (failWrites) throw new Error('disk full');
},
},
});
await live.restore(snapshot('owner', [first, second]));
failWrites = false;
if (commit === 'event') {
await live.upsertMany([first], {
strict: true,
trustedPublisher: true,
});
await live.remove(first.id);
} else if (commit === 'snapshot') {
await expect(live.recordSnapshot()).resolves.toEqual([]);
} else {
await expect(
live.restore(snapshot('owner', [second])),
).resolves.toEqual([]);
}
failWrites = true;
await expect(
live.upsertMany([first], { strict: true, trustedPublisher: true }),
).rejects.toThrow('disk full');
expect(
await fs.readdir(
path.join(path.dirname(fileURLToPath(first.url)), 'references'),
),
).toEqual([createHash('sha256').update('fork').digest('hex')]);
await expect(readArtifactSnapshot(first, workspace)).resolves.toBe(
'first',
);
},
);
it('releases a failed reintroduction after successful durable removal', async () => {
const page = await saved('owner', 'shared');
let failWrites = false;
const live = new SessionArtifactStore({
sessionId: 'owner',
workspaceCwd: workspace,
runtimeBaseDir: workspace,
persistence: {
...persistence,
recordEvent: async () => {
if (failWrites) throw new Error('disk full');
},
},
});
await live.restore(snapshot('owner', [page]));
await retainArtifactSnapshot(page, workspace, 'fork');
await live.remove(page.id);
failWrites = true;
await expect(
live.upsertMany([page], { strict: true, trustedPublisher: true }),
).rejects.toThrow('disk full');
expect((await live.list()).artifacts).toEqual([]);
expect(
await fs.readdir(
path.join(path.dirname(fileURLToPath(page.url)), 'references'),
),
).toEqual([createHash('sha256').update('fork').digest('hex')]);
await deleteArtifactSnapshot(page, workspace, 'fork');
await expect(
fs.stat(path.dirname(fileURLToPath(page.url))),
).rejects.toMatchObject({ code: 'ENOENT' });
});
it.each(['id', 'title'])(
'preserves history skipped due to %s through partial restore and later rollback',
async (invalidField) => {
const first = await saved('owner', 'first');
const second = await saved('owner', 'second');
const live = new SessionArtifactStore({
sessionId: 'owner',
workspaceCwd: workspace,
runtimeBaseDir: workspace,
persistence: {
...persistence,
recordEvent: async () => {
throw new Error('disk full');
},
},
});
await live.restore(snapshot('owner', [first, second]));
await expect(
live.restore(
snapshot('owner', [
invalidField === 'id'
? { ...first, id: 'abcdef1234567890' }
: { ...first, title: '' },
second,
]),
),
).resolves.toContain(
invalidField === 'id'
? 'skipped artifact with mismatched id abcdef1234567890'
: 'skipped artifact restore: title is required',
);
expect((await live.list()).artifacts.map((page) => page.id)).toEqual([
second.id,
]);
await expect(readArtifactSnapshot(first, workspace)).resolves.toBe(
'first',
);
await expect(
live.upsertMany([first], { strict: true, trustedPublisher: true }),
).rejects.toThrow('disk full');
await expect(readArtifactSnapshot(first, workspace)).resolves.toBe(
'first',
);
await expect(readArtifactSnapshot(second, workspace)).resolves.toBe(
'second',
);
},
);
it('preserves pending durable history dropped from a later successful batch', async () => {
const first = await saved('owner', 'first');
const second = await saved('owner', 'second');
const third = await saved('owner', 'third');
let failWrites = true;
const live = new SessionArtifactStore({
sessionId: 'owner',
workspaceCwd: workspace,
runtimeBaseDir: workspace,
maxArtifacts: 1,
persistence: {
...persistence,
recordEvent: async () => {
if (failWrites) throw new Error('disk full');
},
},
});
await live.restore(snapshot('owner', [first, second]));
failWrites = false;
const result = await live.upsertMany([third, first], {
trustedPublisher: true,
});
expect(result.warnings).toContain(
'dropped 1 newly created artifacts because the store is full',
);
expect((await live.list()).artifacts.map((page) => page.id)).toEqual([
third.id,
]);
await expect(readArtifactSnapshot(first, workspace)).resolves.toBe(
'first',
);
await expect(
readArtifactSnapshot(second, workspace),
).rejects.toMatchObject({ code: 'ENOENT' });
await expect(readArtifactSnapshot(third, workspace)).resolves.toBe(
'third',
);
});
it('reclaims discarded rewind history only after a complete restore', async () => {
const first = await saved('owner', 'first');
const second = await saved('owner', 'second');
const live = store('owner');
await live.restore(snapshot('owner', [first, second]));
await live.restore(snapshot('owner', [{ ...second, id: 'invalid-id' }]));
expect((await live.list()).artifacts).toHaveLength(2);
await expect(readArtifactSnapshot(first, workspace)).resolves.toBe(
'first',
);
await live.restore(snapshot('owner', [second]));
await expect(readArtifactSnapshot(first, workspace)).rejects.toThrow();
await expect(readArtifactSnapshot(second, workspace)).resolves.toBe(
'second',
);
});
it('uses the runtime captured before the ambient environment changes', async () => {
const page = await saved('owner', 'secondary');
const live = store('owner');
await live.restore(snapshot('owner', [page]));
vi.stubEnv('QWEN_RUNTIME_DIR', path.join(workspace, 'primary'));
try {
await live.remove(page.id);
await expect(readArtifactSnapshot(page, workspace)).rejects.toThrow();
} finally {
vi.unstubAllEnvs();
}
});
it('keeps the restored record when snapshot retain bookkeeping fails', async () => {
const page = await saved('owner', 'original');
const references = path.join(
workspace,
'artifacts',
'snapshots',
page.managedId.replace('preview-', ''),
'references',
);
await fs.rm(references, { recursive: true });
await fs.writeFile(references, 'not a directory');
const restored = store('owner');
const warnings = await restored.restore(snapshot('owner', [page]));
expect(warnings).toEqual([
expect.stringContaining(
`restored artifact ${page.id} without retaining its snapshot`,
),
]);
await expect(restored.list()).resolves.toMatchObject({
artifacts: [expect.objectContaining({ id: page.id })],
});
await expect(readArtifactSnapshot(page, workspace)).resolves.toBe(
'original',
);
});
});
it('does not trust persisted published file urls during restore', async () => {
const store = new SessionArtifactStore({
sessionId: 's11-restore-published-file',
+147 -8
View File
@@ -10,11 +10,15 @@ import type { FileHandle } from 'node:fs/promises';
import path from 'node:path';
import {
collectRecordableWorkspaceFiles,
deleteArtifactSnapshot,
retainArtifactSnapshot,
Storage,
isOfficeDocumentExtension,
isPrototypeMetadataKey,
isRecordableDerivedChild,
isAdoptableContentFingerprintKey,
isReservedWorkspaceMetadataKey,
getWebPreviewSnapshotId,
MAX_DIRECTORY_ARTIFACT_DEPTH,
MAX_DIRECTORY_ARTIFACT_FILES,
PUBLISHED_CONTENT_SHA256_METADATA_KEY,
@@ -231,6 +235,7 @@ export class SessionArtifactAuthorizationError extends Error {
interface SessionArtifactStoreOptions {
sessionId: string;
workspaceCwd: string;
runtimeBaseDir?: string;
maxArtifacts?: number;
persistence?: SessionArtifactPersistence;
}
@@ -263,6 +268,7 @@ interface WorkspaceStatusExpected {
export class SessionArtifactStore {
private readonly sessionId: string;
private readonly workspaceCwd: string;
private readonly runtimeBaseDir: string;
private readonly maxArtifacts: number;
private readonly persistence?: SessionArtifactPersistence;
private readonly artifacts = new Map<string, StoredArtifact>();
@@ -280,12 +286,15 @@ export class SessionArtifactStore {
string,
PersistedSessionArtifact
>();
// Failed removals or partial restores can leave ownership without a live record.
private readonly pendingSnapshotRemovals = new Set<string>();
private lastRestoreWarnings: string[] = [];
private lastRestoreWarningDetails: SessionArtifactWarningDetail[] = [];
constructor(options: SessionArtifactStoreOptions) {
this.sessionId = options.sessionId;
this.workspaceCwd = options.workspaceCwd;
this.runtimeBaseDir = options.runtimeBaseDir ?? Storage.getRuntimeBaseDir();
this.maxArtifacts = options.maxArtifacts ?? 200;
this.persistence = options.persistence;
}
@@ -397,6 +406,11 @@ export class SessionArtifactStore {
}
}
const changes: SessionArtifactChange[] = [];
let rollbackArtifacts: DaemonSessionArtifact[] = [];
const rollback = async () => {
this.restoreState(before);
await this.reclaimSnapshotFiles(rollbackArtifacts);
};
try {
for (const normalized of coalesceByIdentity(normalizedResults)) {
const artifact = this.applyStickyEphemeralOverride(normalized);
@@ -476,6 +490,27 @@ export class SessionArtifactStore {
.filter((change) => change.action === 'created')
.map((change) => change.artifactId),
);
rollbackArtifacts = changes.flatMap((change) =>
change.action !== 'removed' && change.artifact
? [change.artifact]
: [],
);
for (const artifact of rollbackArtifacts) {
try {
await retainArtifactSnapshot(
artifact,
this.runtimeBaseDir,
this.sessionId,
);
} catch (error) {
if (validationStrict || persistenceStrict) throw error;
warnings.push(
`artifact ${artifact.id} kept without retaining its snapshot: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
const overflowRemoved = await this.evictOverflow(
createdIds,
changes,
@@ -496,7 +531,7 @@ export class SessionArtifactStore {
try {
persistenceWarnings = await this.persistChanges(changes, true);
} catch (error) {
this.restoreState(before);
await rollback();
const artifactIds = changes
.filter(shouldCommitBeforeDurablePersistence)
.map((change) => change.artifactId);
@@ -539,13 +574,22 @@ export class SessionArtifactStore {
),
);
stripDurableTombstoneMarkers(changes);
await this.reclaimSnapshotFiles([
...changes
.filter(
(change) =>
change.action === 'removed' && change.reason === 'eviction',
)
.map((change) => change.artifact),
...overflowRemoved.droppedArtifacts,
]);
} catch (error) {
if (
validationStrict ||
persistenceStrict ||
error instanceof SessionArtifactAuthorizationError
) {
this.restoreState(before);
await rollback();
}
throw error;
}
@@ -681,6 +725,7 @@ export class SessionArtifactStore {
? []
: await this.persistChanges(changes, false);
stripDurableTombstoneMarkers(changes);
await this.reclaimSnapshotFiles([removeChange.artifact]);
const warningDetails = detailsForPersistenceWarnings(
warnings,
changes,
@@ -757,7 +802,8 @@ export class SessionArtifactStore {
input,
++this.receivedSeq,
artifact.storage === 'published' &&
!isFileArtifactUrl(artifact.url),
(!isFileArtifactUrl(artifact.url) ||
Boolean(getWebPreviewSnapshotId(artifact))),
{
metadataBudget: 'persisted',
workspaceExpected: workspaceExpectedFromArtifact(artifact),
@@ -807,6 +853,23 @@ export class SessionArtifactStore {
retention !== 'ephemeral' ? true : undefined,
insertSeq: ++this.insertSeq,
};
// retainArtifactSnapshot already tolerates missing bytes (ENOENT)
// and existing references (EEXIST); anything it still throws is a
// bookkeeping failure, so keep the restored record and let the read
// path report the snapshot as unavailable instead of dropping it.
try {
await retainArtifactSnapshot(
stored,
this.runtimeBaseDir,
this.sessionId,
);
} catch (error) {
warnings.push(
`restored artifact ${stored.id} without retaining its snapshot: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
this.artifacts.set(stored.id, stored);
restoredCount++;
} catch (error) {
@@ -852,10 +915,37 @@ export class SessionArtifactStore {
insertSeq: ++this.insertSeq,
});
}
if (!warnings.some(isArtifactSnapshotCompletenessWarning)) {
this.pendingSnapshotRemovals.clear();
}
const evicted = await this.evictOverflow(new Set(), []);
if (evicted.removed.length > 0) {
warnings.push('restored artifact list pruned to live limit');
warnings.push(...(await this.persistChanges(evicted.removed, false)));
const persistenceWarnings = await this.persistChanges(
evicted.removed,
false,
);
warnings.push(...persistenceWarnings);
if (persistenceWarnings.length === 0) {
await this.reclaimSnapshotFiles(
evicted.removed.map((change) => change.artifact),
);
}
}
const restoredIds = new Set([
...snapshot.artifacts.map((artifact) => artifact.id),
...preservedLiveEphemeralArtifacts.map((artifact) => artifact.id),
]);
const discarded = [...previousState.artifacts.values()].filter(
(artifact) => !restoredIds.has(artifact.id),
);
if (warnings.some(isArtifactSnapshotCompletenessWarning)) {
for (const artifact of previousState.artifacts.values()) {
const id = getWebPreviewSnapshotId(artifact);
if (id) this.pendingSnapshotRemovals.add(id);
}
} else {
await this.reclaimSnapshotFiles(discarded);
}
this.setLastRestoreWarnings(warnings);
return warnings;
@@ -879,6 +969,7 @@ export class SessionArtifactStore {
this.persistenceSeq = sequence;
this.durableEventsSinceSnapshot = 0;
this.consecutiveSnapshotFailures = 0;
this.pendingSnapshotRemovals.clear();
return [];
} catch (error) {
writeStderrLine(
@@ -907,7 +998,9 @@ export class SessionArtifactStore {
const normalized = await this.normalizeInput(
input,
++this.receivedSeq,
artifact.storage === 'published' && !isFileArtifactUrl(artifact.url),
artifact.storage === 'published' &&
(!isFileArtifactUrl(artifact.url) ||
Boolean(getWebPreviewSnapshotId(artifact))),
{
metadataBudget: 'persisted',
workspaceExpected: workspaceExpectedFromArtifact(artifact),
@@ -957,6 +1050,7 @@ export class SessionArtifactStore {
tombstonedClientIds: Map<string, string | undefined>;
stickyEphemeralIds: Set<string>;
markerArtifacts: Map<string, PersistedSessionArtifact>;
pendingSnapshotRemovals: Set<string>;
lastRestoreWarnings: string[];
lastRestoreWarningDetails: SessionArtifactWarningDetail[];
} {
@@ -976,6 +1070,7 @@ export class SessionArtifactStore {
tombstonedClientIds: new Map(this.tombstonedClientIds),
stickyEphemeralIds: new Set(this.stickyEphemeralIds),
markerArtifacts: new Map(this.markerArtifacts),
pendingSnapshotRemovals: new Set(this.pendingSnapshotRemovals),
lastRestoreWarnings: [...this.lastRestoreWarnings],
lastRestoreWarningDetails: [...this.lastRestoreWarningDetails],
};
@@ -992,6 +1087,7 @@ export class SessionArtifactStore {
tombstonedClientIds: Map<string, string | undefined>;
stickyEphemeralIds: Set<string>;
markerArtifacts: Map<string, PersistedSessionArtifact>;
pendingSnapshotRemovals: Set<string>;
lastRestoreWarnings: string[];
lastRestoreWarningDetails: SessionArtifactWarningDetail[];
}): void {
@@ -1020,6 +1116,10 @@ export class SessionArtifactStore {
for (const [id, artifact] of state.markerArtifacts) {
this.markerArtifacts.set(id, artifact);
}
this.pendingSnapshotRemovals.clear();
for (const id of state.pendingSnapshotRemovals) {
this.pendingSnapshotRemovals.add(id);
}
this.setLastRestoreWarnings(state.lastRestoreWarnings);
this.lastRestoreWarningDetails = [...state.lastRestoreWarningDetails];
}
@@ -1071,6 +1171,9 @@ export class SessionArtifactStore {
await this.persistence.recordEvent(payload);
this.persistenceSeq = sequence;
for (const change of durableChanges) {
const snapshotId =
change.artifact && getWebPreviewSnapshotId(change.artifact);
if (snapshotId) this.pendingSnapshotRemovals.delete(snapshotId);
if (change.action === 'removed') continue;
const stored = this.artifacts.get(change.artifactId);
if (!stored) continue;
@@ -1118,6 +1221,7 @@ export class SessionArtifactStore {
this.persistenceSeq = sequence;
this.durableEventsSinceSnapshot = 0;
this.consecutiveSnapshotFailures = 0;
this.pendingSnapshotRemovals.clear();
} catch (error) {
this.consecutiveSnapshotFailures = Math.min(
this.consecutiveSnapshotFailures + 1,
@@ -1187,6 +1291,9 @@ export class SessionArtifactStore {
for (const change of changes) {
if (change.action === 'removed') {
removalNotPersisted = true;
const snapshotId =
change.artifact && getWebPreviewSnapshotId(change.artifact);
if (snapshotId) this.pendingSnapshotRemovals.add(snapshotId);
if (change.reason === 'explicit') {
this.rememberTombstone(change);
this.stickyEphemeralIds.delete(change.artifactId);
@@ -1901,14 +2008,45 @@ export class SessionArtifactStore {
}
}
private async reclaimSnapshotFiles(
artifacts: ReadonlyArray<DaemonSessionArtifact | undefined>,
): Promise<void> {
for (const artifact of artifacts) {
if (!artifact) continue;
const id = getWebPreviewSnapshotId(artifact);
if (
!id ||
this.pendingSnapshotRemovals.has(id) ||
[...this.artifacts.values()].some(
(kept) => getWebPreviewSnapshotId(kept) === id,
)
)
continue;
try {
await deleteArtifactSnapshot(
artifact,
this.runtimeBaseDir,
this.sessionId,
);
} catch {
// Reclamation must never break the store's removal paths.
}
}
}
private async evictOverflow(
createdIds: Set<string>,
changes: SessionArtifactChange[],
strict = false,
): Promise<{ removed: SessionArtifactChange[]; droppedCreated: number }> {
): Promise<{
removed: SessionArtifactChange[];
droppedCreated: number;
droppedArtifacts: DaemonSessionArtifact[];
}> {
const removed: SessionArtifactChange[] = [];
const droppedArtifacts: DaemonSessionArtifact[] = [];
if (this.artifacts.size <= this.maxArtifacts) {
return { removed, droppedCreated: 0 };
return { removed, droppedCreated: 0, droppedArtifacts };
}
const createdInThisBatch = new Set(createdIds);
@@ -1963,13 +2101,14 @@ export class SessionArtifactStore {
}
this.artifacts.delete(artifact.id);
droppedCreated++;
droppedArtifacts.push(toPublicArtifact(artifact));
writeStderrLine(
`[artifacts] session=${this.sessionId} action=dropped reason="max artifacts exceeded" artifactId=${artifact.id}`,
);
removePriorChange(changes, artifact.id);
}
return { removed, droppedCreated };
return { removed, droppedCreated, droppedArtifacts };
}
}
@@ -4220,6 +4220,26 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
await agentPromise;
});
it.each([false, true])(
'enables snapshot storage only for a trusted managed parent (%s)',
async (managed) => {
const innerConfig = await setupSessionMocks('snapshot-session');
const { agent, agentPromise } = await bootInitializedAcpAgent(
makeSessionSettings(),
managed ? 'snapshot-parent' : undefined,
);
try {
await agent.newSession({ cwd: '/tmp', mcpServers: [] });
expect(innerConfig.setArtifactSnapshotsEnabled).toHaveBeenCalledWith(
managed,
);
} finally {
mockConnectionState.resolve();
await agentPromise;
}
},
);
it('generates and binds a sessionId before loading a new Config', async () => {
const innerConfig = await setupSessionMocks('generated-session');
let configLoadSessionContext: string | undefined;
@@ -4568,6 +4588,7 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
setSessionWriterReclaimPolicy: vi.fn(),
setSessionWriterTakeoverPolicy: vi.fn(),
setSessionSource: vi.fn(),
setArtifactSnapshotsEnabled: vi.fn(),
setGoalProposalHostSupported: vi.fn(),
setSessionSourceServiceFactory: vi.fn(),
registerSessionSourceTool: vi.fn().mockResolvedValue(undefined),
@@ -22637,6 +22658,7 @@ describe('QwenAgent session-management routing (rename / delete / list / branch
recording: ReturnType<typeof makeRecordingService> | null,
) {
return {
setArtifactSnapshotsEnabled: vi.fn(),
initialize: vi.fn().mockResolvedValue(undefined),
shutdown: vi.fn().mockResolvedValue(undefined),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
@@ -24513,6 +24535,7 @@ describe('QwenAgent loadSession / unstable_resumeSession', () => {
setSessionWriterReclaimPolicy: vi.fn(),
setSessionWriterTakeoverPolicy: vi.fn(),
setSessionSource: vi.fn(),
setArtifactSnapshotsEnabled: vi.fn(),
setGoalProposalHostSupported: vi.fn(),
setSessionSourceServiceFactory: vi.fn(),
registerSessionSourceTool: vi.fn().mockResolvedValue(undefined),
@@ -28836,6 +28859,7 @@ describe('sessionLanguage multi-session propagation', () => {
function makeConfig(overrides: Record<string, unknown> = {}) {
return {
setArtifactSnapshotsEnabled: vi.fn(),
initialize: vi.fn().mockResolvedValue(undefined),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getModel: vi.fn().mockReturnValue('m'),
@@ -14339,6 +14339,7 @@ class QwenAgent implements Agent {
if (sessionSource) {
config.setSessionSource(sessionSource.sourceType, sessionSource.sourceId);
}
config.setArtifactSnapshotsEnabled(this.isTrustedManagedParent());
if (this.clientCapabilities?._meta?.['qwen.goalProposals'] === true) {
config.setGoalProposalHostSupported(true);
}
@@ -385,6 +385,7 @@ describe('QwenAgent loadSession — Phase C worktree context restore', () => {
);
return {
setArtifactSnapshotsEnabled: vi.fn(),
initialize: vi.fn().mockResolvedValue(undefined),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getModelsConfig: vi.fn().mockReturnValue({
@@ -788,6 +788,16 @@ describe('restoreProbeTreeTracked, through runOneMutant', () => {
const isolation = isolateHostGitConfig();
try {
writeFileSync(join(dir, 'a.ts'), 'gone.clear();\n');
// The runner-stage observation below is `findVitestBin`'s throw, and a
// bare tmpdir only throws "not found" when nothing up-tree provides
// vitest — a node_modules above the runner's TMPDIR (observed on
// self-hosted CI, where jobs share one /tmp) resolves one, the probe
// then runs for real, and the detail names no vitest. Plant a shadow
// vitest that declares no bin: the innermost node_modules wins
// resolution on every host, so the throw is deterministic.
const vitestDir = join(dir, 'node_modules', 'vitest');
mkdirSync(vitestDir, { recursive: true });
writeFileSync(join(vitestDir, 'package.json'), '{}');
asCheckout(dir);
appendFileSync(
join(dir, '.git', 'config'),
@@ -808,9 +818,10 @@ describe('restoreProbeTreeTracked, through runOneMutant', () => {
expect(detail).not.toContain('could not read to the bottom');
expect(detail).not.toContain('content filter');
// And it reached the runner, which this bare fixture does not have: the
// screen PASSED, rather than the run failing for some other reason — the
// difference between "did not refuse" and "proceeded".
// And it reached the runner stage, where the shadow vitest above makes
// `findVitestBin` throw: the screen PASSED, rather than the run failing
// for some other reason — the difference between "did not refuse" and
// "proceeded".
expect(detail).toContain('vitest');
} finally {
isolation.dispose();
@@ -5,6 +5,8 @@
*/
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import { promises as fsp } from 'node:fs';
import * as os from 'node:os';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -3496,6 +3498,115 @@ describe('multi-workspace session dispatch', () => {
}
});
it('reads a saved HTML version only from its registered session and owner runtime', async () => {
const root = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-snapshot-route-'),
);
const primaryRoot = path.join(root, 'primary');
const secondaryRoot = path.join(root, 'secondary');
const id = 'fce7cbe1-15de-422d-9b9a-2e2ab3370ca4';
const relativeFile = path.join('artifacts', 'snapshots', id, 'index.html');
const file = path.join(secondaryRoot, relativeFile);
const html = '<h1>Saved secondary version</h1>';
try {
for (const runtimeRoot of [primaryRoot, secondaryRoot]) {
await fsp.mkdir(path.dirname(path.join(runtimeRoot, relativeFile)), {
recursive: true,
});
await fsp.writeFile(path.join(runtimeRoot, relativeFile), html);
}
const { app, primaryBridge, secondaryBridge } = makeHarness({
token: TEST_TOKEN,
primaryRuntimeBaseDir: primaryRoot,
secondaryRuntimeBaseDir: secondaryRoot,
});
const sessionId = '22222222-2222-4222-a222-222222222222';
const artifact = {
id: 'saved-version',
title: 'Saved version',
kind: 'html' as const,
storage: 'published' as const,
source: 'tool' as const,
toolName: 'artifact',
status: 'available' as const,
retention: 'restorable' as const,
clientRetained: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
url: pathToFileURL(file).href,
managedId: `preview-${id}`,
metadata: {
artifactType: 'web_preview_snapshot',
'qwen.published.sha256': createHash('sha256')
.update(html)
.digest('hex'),
},
};
const listSecondary = vi.fn(async () => ({
v: 1 as const,
sessionId,
artifacts: [artifact],
generatedAt: new Date().toISOString(),
limits: { maxArtifacts: 200 },
}));
const listPrimary = vi.fn(async () => ({
v: 1 as const,
sessionId: '11111111-1111-4111-a111-111111111111',
artifacts: [],
generatedAt: new Date().toISOString(),
limits: { maxArtifacts: 200 },
}));
secondaryBridge.getSessionArtifacts = listSecondary;
primaryBridge.getSessionArtifacts = listPrimary;
const read = (ownerId = sessionId, artifactId = artifact.id) =>
request(app)
.get(`/session/${ownerId}/artifacts/${artifactId}/content`)
.set('Host', host())
.set('Authorization', TEST_AUTHORIZATION)
.set('X-Qwen-Client-Id', 'secondary-client');
const response = await read();
expect(response.status).toBe(200);
expect(response.text).toBe(html);
expect(response.headers['content-disposition']).toContain('attachment');
expect(response.headers['x-content-type-options']).toBe('nosniff');
expect(response.headers['cache-control']).toBe('private, no-store');
expect(listSecondary).toHaveBeenCalledWith(sessionId, {
clientId: 'secondary-client',
});
expect(listPrimary).not.toHaveBeenCalled();
expect((await read(sessionId, 'unregistered')).status).toBe(404);
expect((await read('11111111-1111-4111-a111-111111111111')).status).toBe(
404,
);
await fsp.writeFile(file, '<h1>Modified</h1>');
expect((await read()).status).toBe(404);
await fsp.unlink(file);
expect((await read()).status).toBe(404);
// An identical version in the primary runtime must never be a fallback.
expect(
await fsp.readFile(path.join(primaryRoot, relativeFile), 'utf8'),
).toBe(html);
} finally {
await fsp.rm(root, { recursive: true, force: true });
}
});
it('rejects saved HTML reads for an untrusted owner before listing its artifacts', async () => {
const { app, primaryBridge, secondaryBridge } = makeHarness({
secondaryTrusted: false,
});
const listArtifacts = vi.fn();
primaryBridge.getSessionArtifacts = listArtifacts;
secondaryBridge.getSessionArtifacts = listArtifacts;
const response = await request(app)
.get(
'/session/22222222-2222-4222-a222-222222222222/artifacts/saved/content',
)
.set('Host', host());
expect(response.status).toBe(403);
expect(listArtifacts).not.toHaveBeenCalled();
});
it('routes continue, language, and artifact mutations to the owning non-primary bridge', async () => {
const { app, primaryBridge, secondaryBridge } = makeHarness({
token: TEST_TOKEN,
+43
View File
@@ -45,6 +45,7 @@ import {
type SessionArchiveState,
type WorktreeSession,
parseGoalControlRequest,
readArtifactSnapshot,
} from '@qwen-code/qwen-code-core';
import type { SessionArtifactInput } from '@qwen-code/acp-bridge/sessionArtifacts';
import {
@@ -6393,6 +6394,48 @@ export function registerSessionRoutes(
),
);
app.get(
'/session/:id/artifacts/:artifactId/content',
withOwnerReadSession(
'GET /session/:id/artifacts/:artifactId/content',
async (req, res, sessionId, runtime) => {
const clientId = parseClientIdHeader(req, res);
if (clientId === null) return;
const { artifacts } = await runtime.bridge.getSessionArtifacts(
sessionId,
clientId !== undefined ? { clientId } : undefined,
);
const artifact = artifacts.find(
(item) => item.id === req.params['artifactId'],
);
let content: string;
try {
if (!artifact) throw new Error('Snapshot not registered');
content = await readArtifactSnapshot(
artifact,
runtime.sessionRuntimeBaseDir,
);
} catch {
res.status(404).json({
error: 'artifact_snapshot_unavailable',
message: 'Saved webpage version is missing or has changed.',
});
return;
}
res
.status(200)
.set({
'Content-Type': 'text/html; charset=utf-8',
'Content-Disposition': 'attachment; filename="saved-webpage.html"',
'X-Content-Type-Options': 'nosniff',
'Cache-Control': 'private, no-store',
})
.send(content);
},
{ cwdBound: true },
),
);
app.post(
'/session/:id/artifacts',
mutate({ strict: true }),
+3
View File
@@ -5647,6 +5647,7 @@ async function runQwenServeImpl(
const bridge =
deps.bridge ??
runtime.createAcpSessionBridge({
artifactSnapshotRuntimeBaseDir: primarySessionRuntimeBaseDir,
sessionAttachmentsRoot: attachmentsRoots.root,
sessionAttachmentsFallbackRoot: attachmentsRoots.fallback,
// Reverse tool channel: let `BridgeClient.extMethod` reach the WS
@@ -6221,6 +6222,7 @@ async function runQwenServeImpl(
secondaryEnv.sessionRuntimeBaseDir,
);
const secondaryBridge = runtime.createAcpSessionBridge({
artifactSnapshotRuntimeBaseDir: secondaryEnv.sessionRuntimeBaseDir,
sessionAttachmentsRoot: secondaryAttachmentsRoots.root,
sessionAttachmentsFallbackRoot: secondaryAttachmentsRoots.fallback,
clientMcpSender: secondaryClientMcpSenderRegistry.lookup,
@@ -6898,6 +6900,7 @@ async function runQwenServeImpl(
wsEnv.sessionRuntimeBaseDir,
);
wsBridge = runtime.createAcpSessionBridge({
artifactSnapshotRuntimeBaseDir: wsEnv.sessionRuntimeBaseDir,
sessionAttachmentsRoot: wsAttachmentsRoots.root,
sessionAttachmentsFallbackRoot: wsAttachmentsRoots.fallback,
clientMcpSender: wsClientMcpRegistry.lookup,
@@ -16,6 +16,7 @@ import {
} from './acp-session-bridge.js';
import type { WorkspaceRegistry } from './workspace-registry.js';
import type { WorkspaceFileSystemFactory } from './fs/workspace-file-system.js';
import { Storage } from '@qwen-code/qwen-code-core';
import { MAX_SESSION_RESTORE_TIMEOUT_MS } from '@qwen-code/acp-bridge/sessionRestoreTimeout';
const WS_BOUND = path.resolve('/work/bound');
@@ -98,6 +99,7 @@ describe('createServeApp default bridge wiring', () => {
expect(sessionLifecycle).toBeDefined();
expect(bridgeOptions).toMatchObject({
delegateReadTextFileToClient: false,
artifactSnapshotRuntimeBaseDir: Storage.getRuntimeBaseDir(),
});
await expect(
bridgeOptions!.fileSystem!.writeText({
+1
View File
@@ -1171,6 +1171,7 @@ export function createServeApp(
injectedWorkspaceRegistry?.primary.bridge ??
deps.bridge ??
createAcpSessionBridge({
artifactSnapshotRuntimeBaseDir: Storage.getRuntimeBaseDir(),
sessionAttachmentsRoot: attachmentsRoots.root,
sessionAttachmentsFallbackRoot: attachmentsRoots.fallback,
maxSessions: opts.maxSessions,
@@ -98,7 +98,7 @@ describe('legacy session telemetry route drift guard', () => {
.map(({ method, path }) => `${method} ${path}`)
.sort();
expect(registered).toHaveLength(72);
expect(registered).toHaveLength(73);
expect(registered).toEqual(catalog);
});
});
@@ -567,6 +567,11 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => {
const mw = daemonTelemetryMiddleware(() => '/workspace/primary');
for (const [method, path, route] of [
[
'GET',
'/session/secondary-session/artifacts/saved-version/content',
'GET /session/:id/artifacts/:artifactId/content',
],
[
'GET',
'/session/secondary-session/rewind/snapshots',
@@ -1090,17 +1095,17 @@ describe('daemonTelemetryMiddleware — recordRequest seam', () => {
});
describe('legacy session telemetry route catalog', () => {
it('contains 72 unique routes with the audited 70/2 attribution split', () => {
it('contains 73 unique routes with the audited 71/2 attribution split', () => {
const keys = legacySessionTelemetryRoutes.map(
({ method, path }) => `${method} ${path}`,
);
expect(keys).toHaveLength(72);
expect(new Set(keys).size).toBe(72);
expect(keys).toHaveLength(73);
expect(new Set(keys).size).toBe(73);
expect(
legacySessionTelemetryRoutes.filter(
({ attribution }) => attribution === 'handler_resolved',
),
).toHaveLength(70);
).toHaveLength(71);
expect(
legacySessionTelemetryRoutes.filter(
({ attribution }) => attribution === 'pre_resolved',
@@ -193,6 +193,12 @@ export const legacySessionTelemetryRoutes = [
attribution: 'handler_resolved',
route: 'GET /session/:id/artifacts',
},
{
method: 'GET',
path: '/session/:id/artifacts/:artifactId/content',
attribution: 'handler_resolved',
route: 'GET /session/:id/artifacts/:artifactId/content',
},
{
method: 'POST',
path: '/session/:id/artifacts',
+14 -38
View File
@@ -8,50 +8,26 @@ import { describe, expect, it } from 'vitest';
import {
buildWebShellCsp,
buildWebShellPermissionsPolicy,
loopbackSandboxOrigins,
portFromHostHeader,
} from './web-shell-static.js';
describe('Web Shell sandbox framing', () => {
it('pins loopback sandbox origins to the request Host port', () => {
expect(portFromHostHeader('localhost:4170')).toBe('4170');
expect(portFromHostHeader('[::1]:4170')).toBe('4170');
expect(portFromHostHeader('127.0.0.1')).toBeUndefined();
expect(loopbackSandboxOrigins('127.0.0.1:4170')).toEqual([
'http://localhost:4170',
'http://127.0.0.1:4170',
'https://localhost:4170',
'https://127.0.0.1:4170',
]);
expect(loopbackSandboxOrigins('127.0.0.1:4170').join(' ')).not.toContain(
'[::1]',
);
expect(loopbackSandboxOrigins('127.0.0.2:4170')).toContain(
'http://127.0.0.2:4170',
);
expect(loopbackSandboxOrigins('127.0.0.2:4170')).not.toContain(
'http://127.0.0.2:*',
);
expect(loopbackSandboxOrigins('example.com:4170')).not.toContain(
'example.com',
it('allows live previews and PDF blobs while retaining shell isolation', () => {
const csp = buildWebShellCsp();
expect(csp).toContain('frame-src http: https: blob:;');
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).toContain("connect-src 'self'");
expect(csp).toContain("media-src 'self' data:");
expect(csp).toContain("base-uri 'none'");
expect(csp).toContain(
"script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'",
);
expect(csp).not.toContain('frame-src *');
});
it('allows local Blob previews and only the daemon loopback port in frame-src', () => {
const csp = buildWebShellCsp([], loopbackSandboxOrigins('localhost:4170'));
expect(csp).toContain(
'frame-src blob: http://localhost:4170 http://127.0.0.1:4170 https://localhost:4170 https://127.0.0.1:4170',
);
expect(
csp
.split('; ')
.find((directive) => directive.startsWith('frame-src '))
?.split(' ')
.slice(1),
).toEqual(['blob:', ...loopbackSandboxOrigins('localhost:4170')]);
expect(csp).not.toContain('[::1]');
expect(csp).not.toContain('http://localhost:*');
expect(csp).not.toContain('http://127.0.0.1:*');
it('retains the explicit embedding ancestor allowlist', () => {
const csp = buildWebShellCsp(['chrome-extension://test-extension']);
expect(csp).toContain('frame-ancestors chrome-extension://test-extension');
expect(csp).toContain('frame-src http: https: blob:;');
});
it('keeps camera, microphone, and geolocation host-blocked', () => {
+5 -54
View File
@@ -9,7 +9,6 @@ import express from 'express';
import type { Application, NextFunction, Request, Response } from 'express';
import { writeStderrLine } from '../utils/stdioHelpers.js';
import { isServeDebugMode } from './debug-mode.js';
import { isLoopbackAddress } from './loopback-binds.js';
import { isDocumentNavigation } from './web-shell-preauth.js';
export { resolveWebShellDir } from './web-shell-resolver.js';
@@ -31,6 +30,7 @@ const WEB_SHELL_CSP_DIRECTIVES = [
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
"img-src 'self' data: blob:",
"media-src 'self' data:",
"connect-src 'self'",
"worker-src 'self' blob:",
// base-uri does NOT fall back to default-src; lock it so an injected <base>
@@ -39,54 +39,6 @@ const WEB_SHELL_CSP_DIRECTIVES = [
"base-uri 'none'",
];
/**
* Loopback origins the Web Shell may frame for the MCP App sandbox, pinned to
* the request's Host port. Wildcard ports would let a compromised shell embed
* any loopback listener.
*/
export function loopbackSandboxOrigins(
hostHeader: string | undefined,
): string[] {
const port = portFromHostHeader(hostHeader);
const suffix = port ? `:${port}` : '';
// CSP host-sources reject bracketed IPv6 (`http://[::1]:<port>`). The
// sandbox iframe aliases `[::1]` to `localhost`, so these hosts are enough.
const hosts = ['localhost', '127.0.0.1'];
try {
const requestHostname = new URL(`http://${hostHeader}`).hostname;
if (
isLoopbackAddress(requestHostname) &&
!requestHostname.includes(':') &&
!hosts.includes(requestHostname)
) {
hosts.push(requestHostname);
}
} catch {
// Ignore malformed Host headers; the fixed loopback aliases remain safe.
}
return (['http', 'https'] as const).flatMap((scheme) =>
hosts.map((host) => `${scheme}://${host}${suffix}`),
);
}
export function portFromHostHeader(
hostHeader: string | undefined,
): string | undefined {
if (!hostHeader) return undefined;
if (hostHeader.startsWith('[')) {
const end = hostHeader.indexOf(']');
if (end === -1) return undefined;
const rest = hostHeader.slice(end + 1);
return rest.startsWith(':') && /^\d+$/u.test(rest.slice(1))
? rest.slice(1)
: undefined;
}
const colon = hostHeader.lastIndexOf(':');
if (colon === -1) return undefined;
const port = hostHeader.slice(colon + 1);
return /^\d+$/u.test(port) ? port : undefined;
}
export function buildWebShellPermissionsPolicy(): string {
return [
'camera=()',
@@ -107,12 +59,12 @@ export function buildWebShellPermissionsPolicy(): string {
*/
export function buildWebShellCsp(
frameAncestors: readonly string[] = [],
frameSrcOrigins: readonly string[] = loopbackSandboxOrigins(undefined),
): string {
const fa = frameAncestors.length
? `frame-ancestors ${frameAncestors.join(' ')}`
: "frame-ancestors 'none'";
const frameSrc = `frame-src blob: ${frameSrcOrigins.join(' ')}`;
// PDF attachments use blob URLs; live previews pin their own child source.
const frameSrc = 'frame-src http: https: blob:';
return [...WEB_SHELL_CSP_DIRECTIVES, frameSrc, fa].join('; ');
}
@@ -140,9 +92,8 @@ function createSendIndex(
frameAncestors: readonly string[] = [],
): (req: Request, res: Response) => void {
const indexPath = path.join(webShellDir, 'index.html');
return (req: Request, res: Response): void => {
const sandboxOrigins = loopbackSandboxOrigins(req.get('host'));
const csp = buildWebShellCsp(frameAncestors, sandboxOrigins);
return (_req: Request, res: Response): void => {
const csp = buildWebShellCsp(frameAncestors);
res
.status(200)
.set('Content-Security-Policy', csp)
+31
View File
@@ -6238,6 +6238,27 @@ describe('Server Config (config.ts)', () => {
expect(registeredNames).toContain(ToolNames.RECORD_ARTIFACT);
});
it.each([true, false])(
'registers saved-page publishing only for recorded managed sessions (%s)',
async (chatRecording) => {
const config = new Config({
...baseParams,
interactive: false,
sdkMode: false,
chatRecording,
});
config.setArtifactSnapshotsEnabled(true);
await config.initialize();
const registeredNames = (
ToolRegistry.prototype.registerFactory as Mock
).mock.calls.map((call) => call[0]);
expect(registeredNames.includes(ToolNames.ARTIFACT)).toBe(
chatRecording,
);
if (chatRecording) expect(config.shouldAutoOpenArtifact()).toBe(false);
},
);
it('registers display_image only for the main interactive TUI', async () => {
const interactive = new Config({
...baseParams,
@@ -6578,6 +6599,16 @@ describe('Server Config (config.ts)', () => {
expect(registeredNames).toContain(ToolNames.REPORT_FINDINGS);
});
it('enables historical artifact snapshots only when a managed caller opts in', () => {
const config = new Config({ ...baseParams, chatRecording: true });
expect(config.isArtifactSnapshotsEnabled()).toBe(false);
config.setArtifactSnapshotsEnabled(true);
expect(config.isArtifactSnapshotsEnabled()).toBe(true);
const unrecorded = new Config({ ...baseParams, chatRecording: false });
unrecorded.setArtifactSnapshotsEnabled(true);
expect(unrecorded.isArtifactSnapshotsEnabled()).toBe(false);
});
describe('isArtifactEnabled', () => {
const originalForceEnable = process.env['QWEN_CODE_ENABLE_ARTIFACT'];
const originalDisable = process.env['QWEN_CODE_DISABLE_ARTIFACT'];
+14 -2
View File
@@ -2551,6 +2551,7 @@ export class Config {
private readonly todoWriteEnabled: boolean = false;
private readonly agentTeamEnabled: boolean = false;
private readonly artifactEnabled: boolean = true;
private artifactSnapshotsEnabled = false;
private readonly artifactAutoOpen: boolean = true;
private readonly artifactPublisher: 'local' | 'host' | 'oss' = 'local';
private readonly artifactHost?: ArtifactHostConfig;
@@ -8054,12 +8055,14 @@ export class Config {
isArtifactEnabled(): boolean {
// Publishing writes outside the project and opens a browser, so it is
// limited to interactive, non-SDK sessions. QWEN_CODE_DISABLE_ARTIFACT
// limited to interactive or managed preview sessions, excluding SDK use.
// Managed previews render in Web Shell instead of opening a host browser.
// QWEN_CODE_DISABLE_ARTIFACT
// hard-disables both artifact tools; QWEN_CODE_ENABLE_ARTIFACT remains as
// a compatibility override for old configs that explicitly disabled them.
if (process.env['QWEN_CODE_DISABLE_ARTIFACT'] === '1') return false;
if (this.sdkMode) return false;
if (!this.interactive) return false;
if (!this.interactive && !this.isArtifactSnapshotsEnabled()) return false;
if (process.env['QWEN_CODE_ENABLE_ARTIFACT'] === '1') return true;
return this.artifactEnabled;
}
@@ -8089,6 +8092,14 @@ export class Config {
return this.artifactPublisher;
}
isArtifactSnapshotsEnabled(): boolean {
return this.artifactSnapshotsEnabled && this.chatRecordingEnabled;
}
setArtifactSnapshotsEnabled(enabled: boolean): void {
this.artifactSnapshotsEnabled = enabled;
}
getArtifactHostConfig(): ArtifactHostConfig | undefined {
return this.artifactHost;
}
@@ -8149,6 +8160,7 @@ export class Config {
}
shouldAutoOpenArtifact(): boolean {
if (this.isArtifactSnapshotsEnabled()) return false;
if (process.env['QWEN_ARTIFACT_NO_AUTO_OPEN'] === '1') return false;
return this.artifactAutoOpen && !this.isBrowserLaunchSuppressed();
}
+5
View File
@@ -243,6 +243,11 @@ export type {
ArtifactTool,
ArtifactToolParams,
} from './tools/artifact/artifact-tool.js';
export {
deleteArtifactSnapshot,
readArtifactSnapshot,
retainArtifactSnapshot,
} from './tools/artifact/artifact-snapshots.js';
export {
RecordArtifactTool,
isRecordableDerivedChild,
@@ -7,6 +7,7 @@
import { describe, expect, it } from 'vitest';
import {
SESSION_ARTIFACT_PERSISTENCE_VERSION,
getWebPreviewSnapshotId,
normalizeEventPayload,
normalizeSnapshotPayload,
rebuildSessionArtifactSnapshot,
@@ -77,6 +78,90 @@ describe('source snapshots beside artifact history', () => {
});
describe('session artifact persistence records', () => {
it('keeps saved webpage versions when forking events and snapshots', () => {
const uuid = '8c5e8dc7-4d9c-4a52-a703-7391e9b42dad';
const saved = artifact(
'source-session',
`file:///tmp/runtime/artifacts/snapshots/${uuid}/index.html`,
{
id: stableSessionArtifactId(
'source-session',
`managed:preview-${uuid}`,
),
kind: 'html',
storage: 'published',
source: 'tool',
toolName: 'artifact',
toolCallId: 'publish-v1',
managedId: `preview-${uuid}`,
metadata: {
artifactType: 'web_preview_snapshot',
'qwen.published.sha256': 'a'.repeat(64),
},
},
);
const expected = {
...saved,
id: stableSessionArtifactId('forked-session', `managed:preview-${uuid}`),
};
const base = {
v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
sessionId: 'source-session',
sequence: 1,
recordedAt: saved.createdAt,
};
const forkEvent = remapSessionArtifactPayloadForFork(
{
...base,
changes: [{ action: 'created', artifactId: saved.id, artifact: saved }],
},
'source-session',
'forked-session',
) as SessionArtifactEventRecordPayload;
expect(forkEvent.changes[0]?.artifact).toMatchObject(expected);
const forkSnapshot = remapSessionArtifactPayloadForFork(
{
...base,
artifacts: [saved],
tombstonedIds: [],
stickyEphemeralIds: [],
},
'source-session',
'forked-session',
) as SessionArtifactSnapshotRecordPayload;
expect(forkSnapshot.artifacts).toEqual([expect.objectContaining(expected)]);
expect(getWebPreviewSnapshotId(saved)).toBe(uuid);
const invalidOverrides: Array<Partial<PersistedSessionArtifact>> = [
{ source: 'client' },
{ source: 'hook' },
{ toolName: 'record_artifact' },
{ metadata: { artifactType: 'web_preview_snapshot' } },
{ metadata: { ...saved.metadata, 'qwen.published.sha256': 'bad' } },
{ managedId: `preview-${uuid.replace('-4a52-', '-5a52-')}` },
{ url: saved.url?.replace(uuid, '9c5e8dc7-4d9c-4a52-a703-7391e9b42dad') },
{ url: saved.url?.replace('file:///', 'file://remote/') },
{ url: `${saved.url}?q=1` },
{ url: `${saved.url}#fragment` },
{ url: 'file:///tmp/secret.html' },
];
for (const override of invalidOverrides) {
const invalid = { ...saved, ...override };
expect(getWebPreviewSnapshotId(invalid)).toBeUndefined();
const rejected = remapSessionArtifactPayloadForFork(
{
...base,
artifacts: [invalid],
tombstonedIds: [],
stickyEphemeralIds: [],
},
'source-session',
'forked-session',
) as SessionArtifactSnapshotRecordPayload;
expect(rejected.artifacts).toEqual([]);
}
});
it('roundtrips persisted document artifacts', () => {
const document = artifact('s1', 'https://example.com/unused', {
kind: 'document',
@@ -103,6 +103,44 @@ export interface PersistedSessionArtifact {
clientId?: string;
}
export function getWebPreviewSnapshotId(
artifact: Partial<PersistedSessionArtifact>,
): string | undefined {
const id =
/^preview-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/.exec(
artifact.managedId ?? '',
)?.[1];
const sha256 = artifact.metadata?.[PUBLISHED_CONTENT_SHA256_METADATA_KEY];
if (
!id ||
artifact.kind !== 'html' ||
artifact.storage !== 'published' ||
artifact.metadata?.['artifactType'] !== 'web_preview_snapshot' ||
typeof sha256 !== 'string' ||
!/^[0-9a-f]{64}$/.test(sha256) ||
(artifact.source !== undefined &&
(artifact.source !== 'tool' ||
artifact.toolName?.toLowerCase() !== 'artifact')) ||
(artifact.toolName !== undefined &&
artifact.toolName.toLowerCase() !== 'artifact')
)
return undefined;
try {
const url = new URL(artifact.url ?? '');
if (
url.protocol === 'file:' &&
!url.host &&
!url.search &&
!url.hash &&
url.pathname.endsWith(`/artifacts/snapshots/${id}/index.html`)
)
return id;
} catch {
return undefined;
}
return undefined;
}
export type SessionArtifactPersistedChangeAction =
| 'created'
| 'updated'
@@ -606,7 +644,11 @@ function isForkSafeArtifact(artifact: PersistedSessionArtifact): boolean {
if (artifact.metadata && hasRestoreUnsafeMetadata(artifact.metadata)) {
return false;
}
if (artifact.url && hasRestoreUnsafeUrl(artifact.url)) {
if (
artifact.url &&
hasRestoreUnsafeUrl(artifact.url) &&
!getWebPreviewSnapshotId(artifact)
) {
return false;
}
return true;
@@ -5,6 +5,12 @@
*/
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
saveArtifactSnapshot,
readArtifactSnapshot,
deleteArtifactSnapshot,
} from '../tools/artifact/artifact-snapshots.js';
import {
commitUsageBeforeTranscriptDeletion,
prepareUsageBeforeTranscriptDeletion,
@@ -5107,6 +5113,9 @@ describe('SessionService', () => {
mockedPaths.sanitizeCwd = actualPaths.sanitizeCwd;
vi.mocked(jsonl.read).mockImplementation(actualJsonl.read);
vi.mocked(jsonl.readLines).mockImplementation(actualJsonl.readLines);
vi.mocked(jsonl.parseLineTolerant).mockImplementation(
actualJsonl.parseLineTolerant,
);
// Restore any fs spies installed by the outer beforeEach.
vi.mocked(readdirSyncSpy).mockRestore?.();
@@ -5781,6 +5790,235 @@ describe('SessionService', () => {
).toBe(false);
});
async function seedSavedPage(sessionId: string) {
const { file } = seedSession(sessionId);
const page = await saveArtifactSnapshot(
'original page',
'Page',
'https://example.com',
sessionId,
realTmpDir,
);
const id = stableSessionArtifactId(
sessionId,
`managed:${page.managedId}`,
);
const recordedAt = '2026-04-22T00:00:02.000Z';
fs.appendFileSync(
file,
JSON.stringify({
uuid: 'saved-page',
parentUuid: 'u2',
sessionId,
type: 'system',
subtype: 'session_artifact_event',
timestamp: recordedAt,
cwd,
version: 'test',
systemPayload: {
v: 2,
sessionId,
sequence: 1,
recordedAt,
changes: [
{
action: 'created',
artifactId: id,
artifact: {
...page,
id,
source: 'tool',
toolName: 'artifact',
status: 'available',
retention: 'restorable',
clientRetained: false,
createdAt: recordedAt,
updatedAt: recordedAt,
},
},
],
},
}) + '\n',
);
return page;
}
it('retains an unloaded fork snapshot until its final session is deleted', async () => {
const oldId = '11111111-1111-4111-8111-111111111119';
const newId = '22222222-2222-4222-8222-222222222229';
const page = await seedSavedPage(oldId);
await service.forkSession(oldId, newId);
process.env['QWEN_RUNTIME_DIR'] = realPath.join(
realTmpDir,
'other-runtime',
);
await expect(service.removeSession(oldId)).resolves.toBe(true);
await expect(readArtifactSnapshot(page, realTmpDir)).resolves.toBe(
'original page',
);
const loaded = await service.loadSession(newId);
expect(loaded?.artifactSnapshot?.artifacts).toHaveLength(1);
expect(loaded?.artifactSnapshot?.artifacts[0]).toMatchObject({
url: page.url,
managedId: page.managedId,
});
await expect(service.removeSession(newId)).resolves.toBe(true);
await expect(
fs.promises.stat(realPath.dirname(fileURLToPath(page.url!))),
).rejects.toMatchObject({ code: 'ENOENT' });
});
it.each([false, true])(
'isolates relocated and replacement same-ID sessions, destination deleted first: %s',
async (destinationFirst) => {
const sessionId = '11111111-1111-4111-8111-111111111119';
const movedPage = await seedSavedPage(sessionId);
const movedCwd = realPath.join(realTmpDir, 'moved-workspace');
fs.mkdirSync(movedCwd);
const destination = new SessionService(movedCwd, {
runtimeBaseDir: realTmpDir,
});
const sourceFile = realPath.join(
service['storage'].getProjectDir(),
'chats',
`${sessionId}.jsonl`,
);
const destinationDir = realPath.join(
destination['storage'].getProjectDir(),
'chats',
);
fs.mkdirSync(destinationDir, { recursive: true });
const movedRecords = fs
.readFileSync(sourceFile, 'utf8')
.trim()
.split('\n')
.map((line) => ({ ...JSON.parse(line), cwd: movedCwd }));
fs.writeFileSync(
realPath.join(destinationDir, `${sessionId}.jsonl`),
movedRecords.map((record) => JSON.stringify(record)).join('\n') +
'\n',
);
fs.unlinkSync(sourceFile);
const replacementPage = await seedSavedPage(sessionId);
const firstService = destinationFirst ? destination : service;
const firstPage = destinationFirst ? movedPage : replacementPage;
const secondService = destinationFirst ? service : destination;
const secondPage = destinationFirst ? replacementPage : movedPage;
await expect(firstService.removeSession(sessionId)).resolves.toBe(true);
await expect(
readArtifactSnapshot(firstPage, realTmpDir),
).rejects.toThrow();
await expect(
readArtifactSnapshot(secondPage, realTmpDir),
).resolves.toBe('original page');
await expect(secondService.removeSession(sessionId)).resolves.toBe(
true,
);
await expect(
readArtifactSnapshot(secondPage, realTmpDir),
).rejects.toThrow();
},
);
it.each(['file', 'directory', 'references'])(
'forks the conversation while preserving a saved-page record with missing %s',
async (missing) => {
const oldId = '11111111-1111-4111-8111-111111111119';
const newId = '22222222-2222-4222-8222-222222222229';
const page = await seedSavedPage(oldId);
const file = fileURLToPath(page.url!);
await fs.promises.rm(
missing === 'file'
? file
: missing === 'directory'
? realPath.dirname(file)
: realPath.join(realPath.dirname(file), 'references'),
{ recursive: true },
);
const warnings: string[] = [];
const forkService = new SessionService(cwd, {
runtimeBaseDir: realTmpDir,
onWarning: (message) => warnings.push(message),
});
await forkService.forkSession(oldId, newId);
const forked = await forkService.loadSession(newId);
expect(
forked?.conversation.messages.map((record) => record.uuid),
).toEqual(['u1', 'u2']);
expect(forked?.artifactSnapshot?.artifacts).toEqual([
expect.objectContaining({
id: stableSessionArtifactId(newId, `managed:${page.managedId}`),
url: page.url,
managedId: page.managedId,
metadata: page.metadata,
createdAt: '2026-04-22T00:00:02.000Z',
}),
]);
expect(warnings).toEqual([
expect.stringContaining('missing snapshot storage'),
]);
expect(
(await forkService.loadSession(oldId))?.conversation.messages,
).toHaveLength(2);
},
);
it('does not commit a fork when snapshot ownership bookkeeping fails', async () => {
const oldId = '11111111-1111-4111-8111-111111111119';
const newId = '22222222-2222-4222-8222-222222222229';
const page = await seedSavedPage(oldId);
const references = realPath.join(
realPath.dirname(fileURLToPath(page.url!)),
'references',
);
await fs.promises.rm(references, { recursive: true });
await fs.promises.writeFile(references, 'not a directory');
await expect(service.forkSession(oldId, newId)).rejects.toMatchObject({
code: 'ENOTDIR',
});
await expect(
fs.promises.stat(
realPath.join(
service['storage'].getProjectDir(),
'chats',
`${newId}.jsonl`,
),
),
).rejects.toMatchObject({ code: 'ENOENT' });
});
it('rolls back only the failed fork operation snapshot reference', async () => {
const oldId = '11111111-1111-4111-8111-111111111119';
const newId = '22222222-2222-4222-8222-222222222229';
const page = await seedSavedPage(oldId);
const link = vi.spyOn(fs.promises, 'link').mockRejectedValue(
Object.assign(new Error('target won by another fork'), {
code: 'EEXIST',
}),
);
try {
await expect(service.forkSession(oldId, newId)).rejects.toThrow(
'Target session already exists',
);
} finally {
link.mockRestore();
}
const references = realPath.join(
realPath.dirname(fileURLToPath(page.url!)),
'references',
);
expect(await fs.promises.readdir(references)).toHaveLength(1);
await expect(readArtifactSnapshot(page, realTmpDir)).resolves.toBe(
'original page',
);
await deleteArtifactSnapshot(page, realTmpDir, oldId);
await expect(fs.promises.stat(references)).rejects.toMatchObject({
code: 'ENOENT',
});
});
it('does not resurrect artifacts removed by later side records when forking', async () => {
const oldId = '72727272-7272-7272-7272-727272727272';
const newId = '82828282-8282-8282-8282-828282828282';
@@ -53,6 +53,8 @@ import {
} from '../utils/sessionStorageUtils.js';
import {
isSessionArtifactRecord,
getWebPreviewSnapshotId,
type PersistedSessionArtifact,
rebuildSessionArtifactSnapshot,
remapSessionArtifactPayloadForFork,
selectActiveSideArtifactRecordUuids,
@@ -60,6 +62,10 @@ import {
} from './session-artifact-persistence.js';
import { SessionOrganizationService } from './session-organization-service.js';
import { moveSessionPrSidecar } from './session-pr-service.js';
import {
deleteArtifactSnapshot,
retainArtifactSnapshot,
} from '../tools/artifact/artifact-snapshots.js';
import {
SessionTranscriptReader,
SessionTranscriptTooLargeError,
@@ -3233,6 +3239,46 @@ export class SessionService {
}
}
private async collectSessionArtifactSnapshots(
filePath: string,
sessionId: string,
): Promise<PersistedSessionArtifact[]> {
const artifacts = new Map<string, PersistedSessionArtifact>();
const stream = fs.createReadStream(filePath);
const lines = readline.createInterface({
input: stream,
crlfDelay: Infinity,
});
try {
for await (const line of lines) {
for (const record of jsonl.parseLineTolerant<ChatRecord>(
line,
filePath,
)) {
if (
record.sessionId !== sessionId ||
!isSessionArtifactRecord(record)
)
continue;
for (const artifact of rebuildSessionArtifactSnapshot(
[record],
sessionId,
)?.artifacts ?? []) {
const id = getWebPreviewSnapshotId(artifact);
if (id) artifacts.set(id, artifact);
}
}
}
} catch {
// Unreadable history must not prevent deleting the session. Keep files
// whose ownership cannot be established instead of scanning other projects.
} finally {
lines.close();
stream.destroy();
}
return [...artifacts.values()];
}
private async removeSessionTranscripts(
sessionId: string,
options: RemoveSessionOptions = {},
@@ -3261,7 +3307,14 @@ export class SessionService {
PreparedUsageBeforeTranscriptDeletion
>();
const preparedSessionIds = new Set<string>();
const artifactSnapshots: PersistedSessionArtifact[] = [];
for (const identity of physicalSnapshot.identities) {
artifactSnapshots.push(
...(await this.collectSessionArtifactSnapshots(
identity.filePath,
sessionId,
)),
);
const prepared = await this.prepareUsageSalvageBestEffort(
identity.filePath,
);
@@ -3302,6 +3355,16 @@ export class SessionService {
if (durableParent) {
await syncDurableDirectory(durableParent);
}
for (const artifact of artifactSnapshots) {
(options.assertCleanupOwned ?? options.assertCanMutate)?.();
await deleteArtifactSnapshot(
artifact,
this.storage.getRuntimeBaseDir(),
sessionId,
undefined,
options.assertCleanupOwned ?? options.assertCanMutate,
);
}
return true;
} catch (error) {
if (expectedLocation !== undefined) {
@@ -4044,6 +4107,8 @@ export class SessionService {
const body = forked.map((r) => JSON.stringify(r)).join('\n') + '\n';
const operationId = randomUUID();
const artifactSnapshots =
rebuildSessionArtifactSnapshot(forked, newSessionId)?.artifacts ?? [];
const stagedTranscriptPath = path.join(
chatsDir,
`.${newSessionId}.${operationId}.tmp`,
@@ -4103,6 +4168,21 @@ export class SessionService {
}
}
for (const artifact of artifactSnapshots) {
try {
await retainArtifactSnapshot(
artifact,
this.storage.getRuntimeBaseDir(),
newSessionId,
operationId,
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
this.warn(
`branch ${newSessionId} retained saved webpage record ${artifact.id} with missing snapshot storage; its content may be unavailable`,
);
}
}
try {
await fs.promises.link(stagedTranscriptPath, targetPath);
} catch (error) {
@@ -4125,6 +4205,16 @@ export class SessionService {
}
} finally {
const cleanupFailures: string[] = [];
if (!committed) {
for (const artifact of artifactSnapshots) {
await deleteArtifactSnapshot(
artifact,
this.storage.getRuntimeBaseDir(),
newSessionId,
operationId,
);
}
}
await fs.promises.unlink(stagedTranscriptPath).catch((error) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
cleanupFailures.push(`transcript staging: ${String(error)}`);
@@ -0,0 +1,314 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
deleteArtifactSnapshot,
retainArtifactSnapshot,
readArtifactSnapshot,
saveArtifactSnapshot,
} from './artifact-snapshots.js';
import { MAX_ARTIFACT_BYTES } from './html.js';
describe('saved Artifact versions', () => {
let runtime: string;
beforeEach(async () => {
runtime = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-snapshot-'));
vi.stubEnv('QWEN_RUNTIME_DIR', runtime);
});
afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(runtime, { recursive: true, force: true });
});
it('saves distinct invocations, preserving exact UTF-8 bytes after later saves', async () => {
const html = '<h1>第一版</h1><script>let count=0</script>';
const first = await saveArtifactSnapshot(
html,
'Page',
'https://example.com/latest',
'owner',
runtime,
);
const repeated = await saveArtifactSnapshot(
html,
'Page',
'https://example.com/latest',
'owner',
runtime,
);
const second = await saveArtifactSnapshot(
'<h1>第二版</h1>',
'Page',
'https://example.com/latest',
'owner',
runtime,
);
expect(
new Set([first.managedId, repeated.managedId, second.managedId]).size,
).toBe(3);
await expect(readArtifactSnapshot(first, runtime)).resolves.toBe(html);
await expect(readArtifactSnapshot(second, runtime)).resolves.toBe(
'<h1>第二版</h1>',
);
await expect(
readArtifactSnapshot(first, path.join(runtime, 'other')),
).rejects.toThrow();
});
it('rejects missing, changed and oversized files without reading latest', async () => {
const snapshot = await saveArtifactSnapshot(
'original',
'Page',
'https://example.com/latest',
'owner',
runtime,
);
const file = fileURLToPath(snapshot.url!);
await fs.writeFile(file, 'changed');
await expect(readArtifactSnapshot(snapshot, runtime)).rejects.toThrow();
const handle = await fs.open(file, 'r+');
await handle.truncate(MAX_ARTIFACT_BYTES + 1);
await handle.close();
await expect(readArtifactSnapshot(snapshot, runtime)).rejects.toThrow();
await fs.unlink(file);
await expect(readArtifactSnapshot(snapshot, runtime)).rejects.toThrow();
});
it('rejects forged descriptors and symlink files or directories even with matching bytes', async () => {
const snapshot = await saveArtifactSnapshot(
'original',
'Page',
'https://example.com/latest',
'owner',
runtime,
);
for (const override of [
{ source: 'client' },
{ toolName: 'record_artifact' },
{ source: 'tool', toolName: 'record_artifact' },
{ managedId: '../outside' },
{ metadata: { artifactType: 'web_preview_snapshot' } },
{ storage: 'external_url' as const },
{ url: 'file:///tmp/elsewhere.html' },
]) {
await expect(
readArtifactSnapshot({ ...snapshot, ...override }, runtime),
).rejects.toThrow();
}
const file = fileURLToPath(snapshot.url!);
const outside = path.join(runtime, 'outside.html');
await fs.writeFile(outside, 'original');
await fs.unlink(file);
await fs.symlink(outside, file);
await expect(readArtifactSnapshot(snapshot, runtime)).rejects.toThrow();
const dir = path.dirname(file);
await fs.rm(dir, { recursive: true });
const otherDir = path.join(runtime, 'other');
await fs.mkdir(otherDir);
await fs.writeFile(path.join(otherDir, 'index.html'), 'original');
await fs.symlink(otherDir, dir, 'dir');
await expect(readArtifactSnapshot(snapshot, runtime)).rejects.toThrow();
});
it('reclaims an evicted snapshot while retained versions stay readable', async () => {
const first = await saveArtifactSnapshot(
'<h1>v1</h1>',
'Page',
'https://example.com/latest',
'owner',
runtime,
);
const second = await saveArtifactSnapshot(
'<h1>v2</h1>',
'Page',
'https://example.com/latest',
'owner',
runtime,
);
await deleteArtifactSnapshot(first, runtime, 'owner');
await expect(readArtifactSnapshot(first, runtime)).rejects.toThrow();
await expect(readArtifactSnapshot(second, runtime)).resolves.toBe(
'<h1>v2</h1>',
);
// Reclaiming an already-reclaimed snapshot is a quiet no-op.
await expect(
deleteArtifactSnapshot(first, runtime, 'owner'),
).resolves.toBeUndefined();
});
it('preserves unloaded fork ownership and removes bytes only for the final owner', async () => {
const snapshot = await saveArtifactSnapshot(
'shared',
'Page',
'https://example.com',
'owner',
runtime,
);
await retainArtifactSnapshot(snapshot, runtime, 'fork', 'committed-fork');
await retainArtifactSnapshot(snapshot, runtime, 'fork', 'failed-fork');
await deleteArtifactSnapshot(snapshot, runtime, 'fork', 'failed-fork');
await deleteArtifactSnapshot(snapshot, runtime, 'owner');
await expect(readArtifactSnapshot(snapshot, runtime)).resolves.toBe(
'shared',
);
await deleteArtifactSnapshot(snapshot, runtime, 'fork');
await expect(
fs.stat(path.dirname(fileURLToPath(snapshot.url!))),
).rejects.toMatchObject({ code: 'ENOENT' });
});
it('uses the captured runtime and never collects an untracked legacy snapshot', async () => {
const snapshot = await saveArtifactSnapshot(
'history',
'Page',
'https://example.com',
'owner',
runtime,
);
vi.stubEnv('QWEN_RUNTIME_DIR', path.join(runtime, 'other'));
await deleteArtifactSnapshot(
snapshot,
path.join(runtime, 'other'),
'owner',
);
await expect(readArtifactSnapshot(snapshot, runtime)).resolves.toBe(
'history',
);
const legacy = { ...snapshot, metadata: { ...snapshot.metadata } };
delete legacy.metadata['qwen.snapshot.references'];
await deleteArtifactSnapshot(legacy, runtime, 'owner');
await expect(readArtifactSnapshot(snapshot, runtime)).resolves.toBe(
'history',
);
await deleteArtifactSnapshot(snapshot, runtime, 'owner');
await expect(readArtifactSnapshot(snapshot, runtime)).rejects.toThrow();
});
it('fails a retain after reclamation claims the reference directory', async () => {
const snapshot = await saveArtifactSnapshot(
'history',
'Page',
'https://example.com',
'owner',
runtime,
);
await fs.rm(
path.join(path.dirname(fileURLToPath(snapshot.url!)), 'references'),
{ recursive: true },
);
await expect(
retainArtifactSnapshot(snapshot, runtime, 'fork', 'operation'),
).rejects.toMatchObject({ code: 'ENOENT' });
await fs.rm(path.dirname(fileURLToPath(snapshot.url!)), {
recursive: true,
});
await expect(
retainArtifactSnapshot(snapshot, runtime, 'fork', 'operation'),
).rejects.toMatchObject({ code: 'ENOENT' });
});
it('cleans partial files when writing the snapshot fails', async () => {
const write = fs.writeFile.bind(fs);
const spy = vi
.spyOn(fs, 'writeFile')
.mockImplementation(async (...args) => {
if (String(args[0]).endsWith('index.html'))
throw new Error('disk full');
return write(...args);
});
try {
await expect(
saveArtifactSnapshot(
'history',
'Page',
'https://example.com',
'owner',
runtime,
),
).rejects.toThrow('disk full');
} finally {
spy.mockRestore();
}
expect(
await fs.readdir(path.join(runtime, 'artifacts', 'snapshots')),
).toEqual([]);
});
it('rechecks deletion ownership after asynchronous reference lookup', async () => {
const snapshot = await saveArtifactSnapshot(
'history',
'Page',
'https://example.com',
'owner',
runtime,
);
let lost = false;
const readdir = fs.readdir.bind(fs);
const spy = vi.spyOn(fs, 'readdir').mockImplementation(async (...args) => {
const result = await readdir(...args);
lost = true;
return result;
});
try {
await expect(
deleteArtifactSnapshot(snapshot, runtime, 'owner', undefined, () => {
if (lost) throw new Error('lease lost');
}),
).rejects.toThrow('lease lost');
} finally {
spy.mockRestore();
}
await expect(readArtifactSnapshot(snapshot, runtime)).resolves.toBe(
'history',
);
});
it('never deletes outside the exact file the descriptor points at', async () => {
const snapshot = await saveArtifactSnapshot(
'original',
'Page',
'https://example.com/latest',
'owner',
runtime,
);
const id = snapshot.managedId!.slice('preview-'.length);
for (const override of [
// Classifier rejects: wrong suffix, wrong metadata, wrong source.
{ url: 'file:///tmp/elsewhere.html' },
{ managedId: '../outside' },
{ metadata: { artifactType: 'web_preview_snapshot' } },
{ source: 'client' as const },
// Classifier passes, but the url root is another runtime's tree:
// reclamation must not follow it anywhere.
{
url: pathToFileURL(
path.join(
runtime,
'foreign',
'artifacts',
'snapshots',
id,
'index.html',
),
).href,
},
]) {
await deleteArtifactSnapshot(
{ ...snapshot, ...override },
runtime,
'owner',
);
await expect(readArtifactSnapshot(snapshot, runtime)).resolves.toBe(
'original',
);
}
});
});
@@ -0,0 +1,264 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash, randomUUID } from 'node:crypto';
import { constants, promises as fs } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
getWebPreviewSnapshotId,
PUBLISHED_CONTENT_SHA256_METADATA_KEY,
} from '../../services/session-artifact-persistence.js';
import type { ToolArtifact } from '../tools.js';
import { MAX_ARTIFACT_BYTES } from './html.js';
const SNAPSHOT_REFERENCES_METADATA_KEY = 'qwen.snapshot.references';
export async function saveArtifactSnapshot(
html: string,
title: string,
publishedUrl: string,
sessionId: string,
runtimeBaseDir: string,
): Promise<ToolArtifact> {
const id = randomUUID();
const root = path.join(runtimeBaseDir, 'artifacts', 'snapshots');
await fs.mkdir(root, { recursive: true });
const dir = path.join(root, id);
await fs.mkdir(dir);
const file = path.join(dir, 'index.html');
try {
const references = path.join(dir, 'references');
await fs.mkdir(references);
await fs.writeFile(
path.join(references, snapshotReference(sessionId)),
'',
{
flag: 'wx',
mode: 0o600,
flush: true,
},
);
await fs.writeFile(file, html, {
encoding: 'utf8',
flag: 'wx',
mode: 0o600,
flush: true,
});
await syncSnapshotDirectory(references);
await syncSnapshotDirectory(dir);
await syncSnapshotDirectory(root);
} catch (error) {
await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
return {
kind: 'html',
storage: 'published',
title,
url: pathToFileURL(file).href,
managedId: `preview-${id}`,
mimeType: 'text/html',
sizeBytes: Buffer.byteLength(html, 'utf8'),
metadata: {
artifactType: 'web_preview_snapshot',
[SNAPSHOT_REFERENCES_METADATA_KEY]: 1,
publishedUrl,
[PUBLISHED_CONTENT_SHA256_METADATA_KEY]: createHash('sha256')
.update(html)
.digest('hex'),
},
};
}
export async function readArtifactSnapshot(
artifact: ToolArtifact,
runtimeBaseDir: string,
): Promise<string> {
const id = getWebPreviewSnapshotId(artifact);
const sha256 = artifact.metadata?.[PUBLISHED_CONTENT_SHA256_METADATA_KEY];
const unavailable = () => new Error('Saved webpage version is unavailable.');
if (!id) throw unavailable();
const root = path.join(runtimeBaseDir, 'artifacts', 'snapshots');
const file = path.join(root, id, 'index.html');
if (artifact.url !== pathToFileURL(file).href) throw unavailable();
const realRoot = await fs.realpath(root);
if ((await fs.realpath(file)) !== path.join(realRoot, id, 'index.html')) {
throw unavailable();
}
const handle = await fs.open(
file,
// O_NOFOLLOW refuses a symlink swapped in after the realpath check, and
// O_NONBLOCK keeps a FIFO swapped into the same window from blocking the
// open: the path is only proven to be a regular file by the fstat below.
constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
);
try {
const stat = await handle.stat();
if (!stat.isFile() || stat.size > MAX_ARTIFACT_BYTES) throw unavailable();
const bytes = Buffer.alloc(stat.size + 1);
let length = 0;
while (length < bytes.length) {
const { bytesRead } = await handle.read(
bytes,
length,
bytes.length - length,
);
if (bytesRead === 0) break;
length += bytesRead;
}
const content = bytes.subarray(0, length);
if (
length !== stat.size ||
createHash('sha256').update(content).digest('hex') !== sha256
) {
throw unavailable();
}
return content.toString('utf8');
} finally {
await handle.close();
}
}
function snapshotReference(sessionId: string, operationId?: string): string {
const owner = createHash('sha256').update(sessionId).digest('hex');
return operationId
? `${owner}-${createHash('sha256').update(operationId).digest('hex')}`
: owner;
}
async function syncSnapshotDirectory(directory: string): Promise<void> {
try {
const handle = await fs.open(directory, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
} catch (error) {
if (process.platform !== 'win32') throw error;
}
}
async function snapshotDirectory(
artifact: ToolArtifact,
runtimeBaseDir: string,
): Promise<string | undefined> {
const id = getWebPreviewSnapshotId(artifact);
if (!id) return undefined;
const root = path.join(runtimeBaseDir, 'artifacts', 'snapshots');
const dir = path.join(root, id);
if (artifact.url !== pathToFileURL(path.join(dir, 'index.html')).href) {
return undefined;
}
// Untracked snapshots predate reference-based reclamation and stay intact.
if (artifact.metadata?.[SNAPSHOT_REFERENCES_METADATA_KEY] !== 1)
return undefined;
const realRoot = await fs.realpath(root);
if ((await fs.realpath(dir)) !== path.join(realRoot, id)) return undefined;
if (
(await fs.realpath(path.join(dir, 'index.html'))) !==
path.join(realRoot, id, 'index.html')
)
return undefined;
if (
(await fs.realpath(path.join(dir, 'references'))) !==
path.join(realRoot, id, 'references')
) {
return undefined;
}
return dir;
}
export async function retainArtifactSnapshot(
artifact: ToolArtifact,
runtimeBaseDir: string,
sessionId: string,
operationId?: string,
): Promise<void> {
try {
const dir = await snapshotDirectory(artifact, runtimeBaseDir);
if (!dir) return;
const references = path.join(dir, 'references');
const owner = snapshotReference(sessionId);
if (
!operationId &&
(await fs.readdir(references)).some(
(entry) => entry === owner || entry.startsWith(`${owner}-`),
)
)
return;
await fs.writeFile(
path.join(references, snapshotReference(sessionId, operationId)),
'',
{ flag: 'wx', mode: 0o600, flush: true },
);
await syncSnapshotDirectory(references);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
// A fork must acquire ownership before committing. An ordinary restore can
// still show an unavailable historical descriptor when its file is missing.
if (code !== 'EEXIST' && !(code === 'ENOENT' && !operationId)) throw error;
}
}
export async function deleteArtifactSnapshot(
artifact: ToolArtifact,
runtimeBaseDir: string,
sessionId: string,
operationId?: string,
assertCleanupOwned?: () => void,
): Promise<void> {
try {
const dir = await snapshotDirectory(artifact, runtimeBaseDir);
if (!dir) return;
await releaseSnapshotDirectory(
dir,
sessionId,
operationId,
assertCleanupOwned,
);
} catch {
assertCleanupOwned?.();
// Reclamation must never break record removal.
}
}
async function releaseSnapshotDirectory(
dir: string,
sessionId: string,
operationId?: string,
assertCleanupOwned?: () => void,
): Promise<void> {
const references = path.join(dir, 'references');
const owner = snapshotReference(sessionId, operationId);
let released = false;
for (const reference of await fs.readdir(references)) {
if (
reference === owner ||
(!operationId && reference.startsWith(`${owner}-`))
) {
assertCleanupOwned?.();
await fs.unlink(path.join(references, reference));
released = true;
}
}
if (!released) return;
// rmdir is the atomic last-owner claim. A concurrent retain either prevents
// it or fails before the new fork can commit its transcript.
try {
assertCleanupOwned?.();
await fs.rmdir(references);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOTEMPTY') return;
throw error;
}
const file = path.join(dir, 'index.html');
assertCleanupOwned?.();
await fs.unlink(file);
assertCleanupOwned?.();
await fs.rmdir(dir);
}
@@ -14,6 +14,7 @@ import { ToolErrorType } from '../tool-error.js';
import { ArtifactTool, type UrlOpener } from './artifact-tool.js';
import { LocalPublisher } from './local-publisher.js';
import { MAX_ARTIFACT_BYTES } from './html.js';
import { readArtifactSnapshot } from './artifact-snapshots.js';
const signal = new AbortController().signal;
@@ -23,10 +24,15 @@ describe('ArtifactTool', () => {
let openSpy: ReturnType<typeof vi.fn>;
let tool: ArtifactTool;
const makeConfig = (): Config =>
const makeConfig = (snapshots = true): Config =>
({
getFileSystemService: () => new StandardFileSystemService(),
getTargetDir: () => workdir,
isArtifactSnapshotsEnabled: () => snapshots,
getSessionId: () => 'artifact-session',
storage: {
getRuntimeBaseDir: () => path.join(outDir, 'runtime'),
},
shouldAutoOpenArtifact: () =>
process.env['QWEN_ARTIFACT_NO_AUTO_OPEN'] !== '1',
}) as unknown as Config;
@@ -40,6 +46,7 @@ describe('ArtifactTool', () => {
beforeEach(async () => {
workdir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-art-src-'));
outDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-art-out-'));
vi.stubEnv('QWEN_RUNTIME_DIR', path.join(outDir, 'runtime'));
openSpy = vi.fn(async () => {});
tool = new ArtifactTool(
makeConfig(),
@@ -53,6 +60,7 @@ describe('ArtifactTool', () => {
await fs.rm(outDir, { recursive: true, force: true });
delete process.env['QWEN_ARTIFACT_NO_AUTO_OPEN'];
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it('describes browser opening as settings-dependent', () => {
@@ -77,6 +85,12 @@ describe('ArtifactTool', () => {
title: 'My Report',
mimeType: 'text/html',
},
{
kind: 'html',
storage: 'published',
title: 'My Report',
metadata: { artifactType: 'web_preview_snapshot' },
},
]);
expect(res.artifacts?.[0]?.url).toMatch(/^file:\/\//);
expect(res.artifacts?.[0]?.managedId).toBeTruthy();
@@ -84,6 +98,9 @@ describe('ArtifactTool', () => {
/^[0-9a-f]{64}$/,
);
expect(res.artifacts?.[1]?.metadata?.['publishedUrl']).toBe(
res.artifacts?.[0]?.url,
);
const published = res.resultFilePaths?.[0];
expect(published).toBeTruthy();
const html = await fs.readFile(published!, 'utf8');
@@ -108,6 +125,106 @@ describe('ArtifactTool', () => {
expect(second.artifacts?.[0]?.metadata?.['qwen.published.sha256']).not.toBe(
first.artifacts?.[0]?.metadata?.['qwen.published.sha256'],
);
const firstSnapshot = first.artifacts![1]!;
const secondSnapshot = second.artifacts![1]!;
expect(firstSnapshot.managedId).not.toBe(secondSnapshot.managedId);
await fs.unlink(file);
await fs.unlink(second.resultFilePaths![0]);
const runtime = path.join(outDir, 'runtime');
await expect(
readArtifactSnapshot(firstSnapshot, runtime),
).resolves.toContain('<p>v1</p>');
await expect(
readArtifactSnapshot(secondSnapshot, runtime),
).resolves.toContain('<p>v2</p>');
});
it('does not accumulate historical files without a managed artifact store', async () => {
tool = new ArtifactTool(
makeConfig(false),
new LocalPublisher(outDir),
openSpy,
);
const file = await writeFragment('page.html', '<h1>Report</h1>');
const urls = new Set<string | undefined>();
for (let i = 0; i < 5; i++) {
const result = await tool.build({ file_path: file }).execute(signal);
expect(result.error).toBeUndefined();
expect(result.artifacts).toHaveLength(1);
expect(result.llmContent).not.toContain('saved');
urls.add(result.artifacts![0]!.url);
}
expect(urls.size).toBe(1);
await expect(
fs.stat(path.join(outDir, 'runtime', 'artifacts', 'snapshots')),
).rejects.toMatchObject({ code: 'ENOENT' });
});
it.each(['after publication', 'during snapshot write'])(
'reclaims only the new snapshot when cancelled %s',
async (abortPoint) => {
const earlierFile = await writeFragment('earlier.html', '<p>Earlier</p>');
const earlier = await tool
.build({ file_path: earlierFile })
.execute(signal);
const priorSnapshot = earlier.artifacts![1]!;
const runtime = path.join(outDir, 'runtime');
const root = path.join(runtime, 'artifacts', 'snapshots');
const beforeDirectories = await fs.readdir(root);
const beforeHtml = await readArtifactSnapshot(priorSnapshot, runtime);
const file = await writeFragment('cancel.html', '<p>Published</p>');
const controller = new AbortController();
if (abortPoint === 'after publication') {
openSpy.mockImplementationOnce(async () => controller.abort());
} else {
const writeFile = fs.writeFile;
vi.spyOn(fs, 'writeFile').mockImplementation(
async (file, data, options) => {
await writeFile(file, data, options);
if (
String(file).startsWith(root + path.sep) &&
path.basename(String(file)) === 'index.html'
) {
controller.abort();
}
},
);
}
const result = await tool
.build({ file_path: file })
.execute(controller.signal);
expect(controller.signal.aborted).toBe(true);
expect(result.artifacts).toBeUndefined();
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain('Published artifact');
expect(result.llmContent).not.toContain(
'Artifact publishing was cancelled',
);
expect(await fs.readFile(result.resultFilePaths![0], 'utf8')).toContain(
'<p>Published</p>',
);
expect(await fs.readdir(root)).toEqual(beforeDirectories);
await expect(readArtifactSnapshot(priorSnapshot, runtime)).resolves.toBe(
beforeHtml,
);
},
);
it('reports a saved-version failure even when latest publication succeeded', async () => {
const file = await writeFragment('page.html', '<p>Published</p>');
await fs.writeFile(path.join(outDir, 'runtime'), 'not a directory');
const result = await tool.build({ file_path: file }).execute(signal);
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain(
'historical version could not be saved',
);
expect(result.artifacts).toHaveLength(1);
expect(await fs.readFile(result.resultFilePaths![0], 'utf8')).toContain(
'<p>Published</p>',
);
expect(result.llmContent).toContain('Published artifact');
});
it('rejects a fragment with external references and does not publish', async () => {
@@ -9,6 +9,7 @@ import path from 'node:path';
import { PUBLISHED_CONTENT_SHA256_METADATA_KEY } from '../../services/session-artifact-persistence.js';
import type { Config } from '../../config/config.js';
import type {
ToolArtifact,
ToolCallConfirmationDetails,
ToolInfoConfirmationDetails,
ToolInvocation,
@@ -35,6 +36,10 @@ import {
} from './html.js';
import { artifactIdFromPath, type ArtifactPublisher } from './publisher.js';
import { createArtifactPublisher } from './create-publisher.js';
import {
deleteArtifactSnapshot,
saveArtifactSnapshot,
} from './artifact-snapshots.js';
/** Opens a URL in the browser. Injectable so tests don't launch a browser. */
export type UrlOpener = (
@@ -58,7 +63,7 @@ Workflow:
- Responsive: relative units, flex/grid, max-width:100% on media; wide content (tables, diagrams, code) scrolls inside its own overflow-x:auto container.
- Set a concise \`title\` — it names the browser tab.
To update an artifact, call Artifact again with the SAME file path: it redeploys to the same URL. A different path creates a separate Artifact.
To update an artifact, call Artifact again with the SAME file path: it redeploys to the same URL. In Web Shell, each publication also saves a separate local HTML version for the conversation's historical preview. Use Artifact to deliver a saved webpage version; recording a live development URL alone cannot preserve its content. A different path creates a separate Artifact.
Set artifact.autoOpen=false in settings.json, or QWEN_ARTIFACT_NO_AUTO_OPEN=1, to publish without launching a browser.`;
@@ -243,27 +248,60 @@ class ArtifactToolInvocation extends BaseToolInvocation<
}
}
const llmContent = `Published artifact "${title}" to ${url}. Share or open this URL to view the interactive page. Re-run Artifact with the same file path to update it.`;
const artifacts: ToolArtifact[] = [
{
kind: 'html',
storage: 'published',
title,
url,
managedId,
mimeType: 'text/html',
sizeBytes: bytes,
metadata: {
[PUBLISHED_CONTENT_SHA256_METADATA_KEY]: createHash('sha256')
.update(html)
.digest('hex'),
},
},
];
const saveVersion = this.config.isArtifactSnapshotsEnabled();
if (saveVersion) {
try {
const sessionId = this.config.getSessionId();
const runtimeBaseDir = this.config.storage.getRuntimeBaseDir();
const snapshot = await saveArtifactSnapshot(
html,
title,
url,
sessionId,
runtimeBaseDir,
);
if (signal.aborted) {
await deleteArtifactSnapshot(snapshot, runtimeBaseDir, sessionId);
const message = `Published artifact "${title}" to ${url}, but its historical version was discarded because the request was cancelled.`;
return {
llmContent: message,
returnDisplay: message,
resultFilePaths: filePath ? [filePath] : undefined,
};
}
artifacts.push(snapshot);
} catch (err) {
const message = `Published artifact "${title}" to ${url}, but its historical version could not be saved: ${getErrorMessage(err)}`;
return {
llmContent: message,
returnDisplay: message,
artifacts,
resultFilePaths: filePath ? [filePath] : undefined,
};
}
}
const llmContent = `Published artifact "${title}" to ${url}${saveVersion ? ' and saved a separate local HTML version for this turn' : ''}. Share or open this URL to view the latest interactive page. Re-run Artifact with the same file path to update it${saveVersion ? '; earlier saved versions remain unchanged' : ''}.`;
return {
llmContent,
returnDisplay: `Published artifact **${title}**\n\n${url}`,
resultFilePaths: filePath ? [filePath] : undefined,
artifacts: [
{
kind: 'html',
storage: 'published',
title,
url,
managedId,
mimeType: 'text/html',
sizeBytes: bytes,
metadata: {
[PUBLISHED_CONTENT_SHA256_METADATA_KEY]: createHash('sha256')
.update(html)
.digest('hex'),
},
},
],
artifacts,
};
}
}
@@ -46,6 +46,29 @@ describe('artifact html helpers', () => {
expect(validateSelfContained(ok)).toBeNull();
});
it('accepts scripts that generate image data locally', () => {
expect(
validateSelfContained(
'<canvas id="c"></canvas><script>const img = new Image(); img.src = document.querySelector("#c").toDataURL();</script>',
),
).toBeNull();
});
it.each([
'<script>const u = URL.createObjectURL(blob)</script>',
'<script>canvas.toDataURL("image/png")</script>',
'<script>new URL("./data", document.baseURI)</script>',
'<script>URL.revokeObjectURL(u)</script>',
"<script>o.innerHTML = '<img src=\"' + dataUrl + '\">'</script>",
'<img alt="Set src=x in the config" src="data:image/png;base64,iVBOR=">',
'<script>fetch("data:application/json,{}")</script>',
'<pre><code>fetch(&quot;/api/users&quot;)</code></pre>',
'<style>@import url(data:text/css;base64,Ym9keXt9);</style>',
'<img src="" alt="placeholder">',
])('preserves existing self-contained input support: %s', (html) => {
expect(validateSelfContained(html)).toBeNull();
});
it('accepts external hyperlinks', () => {
const ok = `<a href="https://github.com/QwenLM/qwen-code/pull/1">PR</a>`;
expect(validateSelfContained(ok)).toBeNull();
@@ -4082,6 +4082,30 @@ export class DaemonClient {
);
}
async readSessionArtifactContent(
sessionId: string,
artifactId: string,
opts?: { signal?: AbortSignal; clientId?: string },
): Promise<string> {
return await this.fetchWithTimeout(
`${this.baseUrl}/session/${urlEncode(sessionId)}/artifacts/${urlEncode(artifactId)}/content`,
{
method: 'GET',
headers: this.headers({}, opts?.clientId),
signal: opts?.signal,
},
async (res) => {
if (!res.ok) {
throw await this.failOnError(
res,
'GET /session/:id/artifacts/:artifactId/content',
);
}
return await res.text();
},
);
}
async readSessionAttachment(
sessionId: string,
attachmentId: string,
@@ -498,6 +498,41 @@ describe('DaemonClient', () => {
});
describe('session artifacts', () => {
it('reads saved HTML with encoded identities and daemon authentication', async () => {
const { fetch, calls } = recordingFetch(() =>
textResponse(200, '<h1>Saved</h1>'),
);
const client = new DaemonClient({
baseUrl: 'http://daemon',
token: 'secret',
fetch,
});
await expect(
client.readSessionArtifactContent('session/1', 'artifact/1', {
clientId: 'client-1',
}),
).resolves.toBe('<h1>Saved</h1>');
expect(calls[0]).toMatchObject({
url: 'http://daemon/session/session%2F1/artifacts/artifact%2F1/content',
method: 'GET',
headers: {
authorization: 'Bearer secret',
'x-qwen-client-id': 'client-1',
},
});
});
it('surfaces missing saved HTML without a fallback request', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(404, { error: 'artifact_snapshot_unavailable' }),
);
const client = new DaemonClient({ baseUrl: 'http://daemon', fetch });
await expect(
client.readSessionArtifactContent('s', 'a'),
).rejects.toBeInstanceOf(DaemonHttpError);
expect(calls).toHaveLength(1);
});
it('lists session artifacts with an encoded session id', async () => {
const envelope = {
v: 1 as const,
+43
View File
@@ -3,6 +3,49 @@
Qwen Code Web Shell 是面向浏览器的 daemon 会话终端 UI,可以作为 React
组件嵌入到其他项目中。
## 开发网页预览
独立 Web Shell 的右侧面板提供「网页预览」。先通过终端启动开发服务器,
再打开面板并输入当前浏览器可访问的 HTTP/HTTPS 地址。预览支持热更新、
刷新、390px 手机宽度与桌面宽度切换,以及外部打开。
面板内切换标签会保留页面运行状态。URL 和宽度模式按工作区与会话保存;
重新打开整个面板、切换会话或刷新 Web Shell 后会从保存的入口 URL 加载。
预览中的跨域页面导航不会同步到地址栏,刷新也会返回入口 URL。
关闭预览不会停止开发服务器。
Artifact 发布的网页链接会在对应轮次留下产物卡片;开启网页预览后,
点击卡片「打开」可在右栏查看。普通 `record_artifact` 链接显示详情,
可从详情外部打开。关闭页签不删除卡片,之后仍能从历史消息重新打开。手动输入的地址只保存为面板状态,不会新增对话记录。
实时链接始终显示当前内容。
需要保留当时的网页时,使用 `Artifact` 发布自包含 HTML:每次发布会额外
保存一份独立的历史版本,包含内联样式、脚本、数据和内嵌资源。原消息的
卡片打开当次保存的内容,关闭面板、刷新或重启 daemon 后仍可回看和交互,
不依赖源文件、最新发布地址或开发服务器。重新打开会从交付时的初始状态
开始,不保存用户在预览中的临时操作状态。
历史版本文件保存在所属运行目录的 `artifacts/snapshots/` 中;原发布 URL
仍更新为最新版。记录沿用会话产物的保留策略(默认最多 200 条,超过后
可能淘汰旧记录),并非无限存档。淘汰、删除记录、回退历史或删除会话时,
会释放该会话的快照引用;分支会话仍在使用的内容会保留,最后一个引用
释放后才回收文件。持久化失败时不会提前删除历史文件。旧版没有引用信息
的快照,以及落盘前崩溃或清理失败留下的文件会保守保留。仅开启会话记录的受管 ACP 会话保存快照,普通 CLI 发布
不额外写入历史文件。删除运行目录、移动会话而未复制快照,
或修改快照文件后会提示不可用,不会退回最新版。普通实时链接和过去未保存
内容的记录不能自动还原;任意开发网站也不会自动打包成离线网页。
嵌入式接入方通过 `rightPanel={{ items: ['review', 'sideTask', 'webPreview'] }}`
开启入口。宿主 CSP 需要允许预览来源的 `frame-src`,同时用
`frame-ancestors` 限制宿主页面只能被受信任的宿主嵌入,不能允许开发页面
反过来嵌入宿主。预览容器会限制直接子页面跳转,但开发应用自己的后代
iframe 仍需宿主的防嵌入策略保护。
地址必须使用主机名或 IPv4,不能包含登录凭据,也不能指向 Web Shell 或
daemon 自身。页面的防嵌入策略可能要求使用外部打开。远程开发目前需要
浏览器可访问的地址或已有端口转发;预览不会自动把浏览器的 `localhost`
转成远程 daemon 地址,也不会转发 daemon 凭据。
## 环境要求
- React:`^18.0.0 || ^19.0.0`
+298
View File
@@ -35,6 +35,7 @@ import {
import type { WebShellApi } from './App';
import { DEFAULT_SESSION_ACTION_ITEMS } from './components/sidebar/WebShellSidebar';
import type { Message } from './adapters/types';
import type { TurnOutputOpenRequest } from './components/artifacts/TurnOutputs';
import type {
VoiceStatusRevision,
VoiceWorkspaceTarget,
@@ -357,6 +358,9 @@ const {
generatedAt: '2026-08-26T00:00:00.000Z',
limits: { maxArtifacts: 100 },
}),
readSessionArtifactContent: vi
.fn()
.mockResolvedValue('<h1>Saved version</h1>'),
sessionStats: vi.fn().mockResolvedValue({}),
sessionContextUsage: vi.fn().mockResolvedValue({}),
sessionTaskCancel: vi.fn().mockResolvedValue({ cancelled: true }),
@@ -586,6 +590,7 @@ const {
latestStatusBarHideSettings: false,
latestStatusBarOnSelectModel: null as (() => void) | null,
latestMessageListProps: null as {
onTurnOutputOpen?: (request: TurnOutputOpenRequest) => void;
messages?: Array<{
role?: string;
content?: string;
@@ -3638,6 +3643,295 @@ describe('task activity key', () => {
).not.toHaveBeenCalled();
});
it.each(['workspace', 'standalone', 'live'] as const)(
'uses a restorable panel kind for published webpages in %s sessions',
async (kind) => {
mockConnection.sessionContext =
kind === 'workspace' ? { kind, cwd: '/tmp/project' } : { kind };
mockConnection.workspaceCwd = kind === 'workspace' ? '/tmp/project' : '';
mockConnection.capabilities.features = ['session_artifacts'];
const artifact: DaemonSessionArtifact = {
id: 'published-page',
kind: 'html',
storage: 'published',
source: 'tool',
toolName: 'Artifact',
status: 'available',
title: 'Published webpage',
url: 'https://preview.example/report.html',
retention: 'restorable',
clientRetained: false,
createdAt: '2026-09-07T00:00:00.000Z',
updatedAt: '2026-09-07T00:00:00.000Z',
};
mockSessionActions.loadArtifacts.mockResolvedValue({
artifacts: [artifact],
});
const props = { rightPanel: { items: ['webPreview'] as const } };
const first = renderApp(props);
await flush();
expect(testState.latestMessageListProps?.onTurnOutputOpen).toBeTypeOf(
'function',
);
await act(async () => {
testState.latestMessageListProps!.onTurnOutputOpen!({
kind: 'artifact',
id: 'artifact:published-page',
artifactId: artifact.id,
title: artifact.title,
artifact,
sourceSessionId: 'session-1',
turnId: 'turn-1',
});
});
await flush();
expect(
first.container.querySelector('[data-web-shell-web-preview]') !== null,
).toBe(kind === 'workspace');
expect(
first.container.querySelector('[role="tab"]')?.textContent,
).toContain(artifact.title);
expect(
window.localStorage.getItem('qwen-code-web-shell-right-panel-state'),
).toContain(
`"kind":"${kind === 'workspace' ? 'web_preview' : 'artifact'}"`,
);
act(() => first.unmount());
const restored = renderApp(props);
await flush();
await flush();
expect(
restored.container.querySelector('[data-web-shell-web-preview]') !==
null,
).toBe(kind === 'workspace');
expect(
restored.container.querySelector('[role="tab"]')?.textContent,
).toContain(artifact.title);
if (kind !== 'workspace') {
expect(restored.container.textContent).toContain(
'This workspace may have been removed or the link is no longer valid.',
);
expect(
window.localStorage.getItem('qwen-code-web-shell-right-panel-state'),
).not.toContain('"kind":"web_preview"');
}
},
);
it.each(['workspace', 'standalone', 'live'] as const)(
'opens a saved webpage version from its card in %s sessions',
async (kind) => {
mockConnection.sessionContext =
kind === 'workspace' ? { kind, cwd: '/tmp/project' } : { kind };
mockConnection.workspaceCwd = kind === 'workspace' ? '/tmp/project' : '';
mockConnection.capabilities.features = ['session_artifacts'];
const artifact: DaemonSessionArtifact = {
id: 'saved-page-v1',
kind: 'html',
storage: 'published',
source: 'tool',
toolName: 'Artifact',
status: 'available',
title: 'Saved page v1',
url: 'file:///runtime/artifacts/snapshots/8c5e8dc7-4d9c-4a52-a703-7391e9b42dad/index.html',
retention: 'restorable',
clientRetained: false,
createdAt: '2026-09-07T00:00:00.000Z',
updatedAt: '2026-09-07T00:00:00.000Z',
metadata: {
artifactType: 'web_preview_snapshot',
publishedUrl: 'https://preview.example/report.html',
},
};
mockSessionActions.loadArtifacts.mockResolvedValue({
artifacts: [artifact],
});
const props = { rightPanel: { items: ['webPreview'] as const } };
const first = renderApp(props);
await flush();
expect(testState.latestMessageListProps?.onTurnOutputOpen).toBeTypeOf(
'function',
);
await act(async () => {
testState.latestMessageListProps!.onTurnOutputOpen!({
kind: 'artifact',
id: `artifact:${artifact.id}`,
artifactId: artifact.id,
title: artifact.title,
artifact,
sourceSessionId: 'session-1',
turnId: 'turn-1',
});
});
await flush();
expect(
first.container.querySelector('[data-web-shell-saved-preview]'),
).not.toBeNull();
expect(first.container.textContent).not.toContain(
'This workspace may have been removed',
);
expect(
first.container.querySelector('iframe[title="Saved webpage version"]'),
).not.toBeNull();
expect(
window.localStorage.getItem('qwen-code-web-shell-right-panel-state'),
).toContain('"kind":"artifact"');
act(() => first.unmount());
},
);
it('restores preview settings under their owning session', async () => {
window.localStorage.setItem(
'qwen-code-web-shell-right-panel-state',
JSON.stringify({
v: 1,
'/tmp/project\0session-2': {
open: true,
activeTabId: 'web-preview:second',
tabs: [
{
id: 'web-preview:second',
kind: 'web_preview',
title: 'Second page',
url: 'http://localhost:6544/second',
viewport: 'desktop',
},
],
},
'/tmp/project\0session-1': {
open: true,
activeTabId: 'web-preview:stored',
tabs: [
{
id: 'web-preview:stored',
kind: 'web_preview',
title: 'Web preview',
url: 'http://localhost:6543/settings',
viewport: 'mobile',
html: 'do not persist page content',
},
],
},
}),
);
const { container, rerender } = renderApp({
rightPanel: { items: ['webPreview'] },
});
await flush();
await flush();
expect(
container.querySelector<HTMLInputElement>(
'input[aria-label="Development URL"]',
)?.value,
).toBe('http://localhost:6543/settings');
expect(
container.querySelector<HTMLIFrameElement>(
'iframe[title="Web preview frame"]',
)?.style.width,
).toBe('390px');
act(() => {
container
.querySelector<HTMLButtonElement>('button[aria-label="Desktop width"]')
?.click();
});
await flush();
expect(
JSON.parse(
window.localStorage.getItem('qwen-code-web-shell-right-panel-state') ??
'{}',
)['/tmp/project\0session-1'].tabs,
).toEqual([
{
id: 'web-preview:stored',
kind: 'web_preview',
title: 'Web preview',
url: 'http://localhost:6543/settings',
viewport: 'desktop',
},
]);
mockConnection.sessionId = 'session-2';
rerender();
await flush();
expect(
container.querySelector<HTMLInputElement>(
'input[aria-label="Development URL"]',
)?.value,
).toBe('http://localhost:6544/second');
expect(container.innerHTML).not.toContain('http://localhost:6543/settings');
mockConnection.sessionId = 'session-1';
rerender();
await flush();
await flush();
expect(
container.querySelector<HTMLInputElement>(
'input[aria-label="Development URL"]',
)?.value,
).toBe('http://localhost:6543/settings');
});
it('defers persisted previews until the embedded host opts in', async () => {
window.localStorage.setItem(
'qwen-code-web-shell-right-panel-state',
JSON.stringify({
'/tmp/project\0session-1': {
open: true,
activeTabId: 'web-preview:stored',
tabs: [
{
id: 'web-preview:stored',
kind: 'web_preview',
title: 'Stored page',
url: 'http://localhost:6543/settings',
viewport: 'desktop',
},
],
},
}),
);
const { container, rerender } = renderApp();
await flush();
await flush();
expect(container.querySelector('[data-web-shell-web-preview]')).toBeNull();
rerender({ rightPanel: { items: ['webPreview'] } });
await flush();
await flush();
expect(
container.querySelector<HTMLInputElement>(
'input[aria-label="Development URL"]',
)?.value,
).toBe('http://localhost:6543/settings');
});
it('rejects a persisted preview URL targeting the shell', async () => {
window.localStorage.setItem(
'qwen-code-web-shell-right-panel-state',
JSON.stringify({
'/tmp/project\0session-1': {
open: true,
activeTabId: 'web-preview:unsafe',
tabs: [
{
id: 'web-preview:unsafe',
kind: 'web_preview',
title: 'Web preview',
url: window.location.href,
viewport: 'desktop',
},
],
},
}),
);
const { container } = renderApp({ rightPanel: { items: ['webPreview'] } });
await flush();
await flush();
expect(
container.querySelector('iframe[title="Web preview frame"]'),
).toBeNull();
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
'development address',
);
});
it('restores terminal tabs without persisting terminal output', async () => {
mockConnection.capabilities.features = ['web_terminal'];
window.localStorage.setItem(
@@ -10104,6 +10398,10 @@ beforeEach(() => {
generatedAt: '2026-08-26T00:00:00.000Z',
limits: { maxArtifacts: 100 },
});
mockWorkspace.client.readSessionArtifactContent.mockReset();
mockWorkspace.client.readSessionArtifactContent.mockResolvedValue(
'<h1>Saved version</h1>',
);
mockWorkspace.client.sessionStats.mockReset();
mockWorkspace.client.sessionStats.mockResolvedValue({});
mockWorkspace.client.sessionContextUsage.mockReset();
+108 -2
View File
@@ -2,6 +2,10 @@ import './styles/globals.css';
import { isSessionWriterBlockedCode } from './daemon/session/session-context';
import { TurnNotificationNavigationContext } from './daemon/session/turn-notification-context';
import { useBrowserNotificationSettings } from './browser-turn-notifications';
import {
parseWebPreviewUrl,
type WebPreviewState,
} from './components/preview/web-preview';
import {
forwardRef,
memo,
@@ -1614,6 +1618,7 @@ interface ArtifactPanelPersistedState {
}
type PersistedArtifactPanelTab =
| Extract<ArtifactPanelTab, { kind: 'web_preview' }>
| Pick<
Extract<ArtifactPanelTab, { kind: 'review' }>,
| 'id'
@@ -1844,6 +1849,19 @@ function parsePersistedArtifactPanelTab(
parentSessionId: tab['parentSessionId'],
workspaceCwd: tab['workspaceCwd'],
} as PersistedArtifactPanelTab;
case 'web_preview':
if (
typeof tab['url'] !== 'string' ||
(tab['viewport'] !== 'desktop' && tab['viewport'] !== 'mobile')
) {
return;
}
return {
...common,
kind: 'web_preview',
url: tab['url'],
viewport: tab['viewport'],
};
case 'terminal':
return {
...common,
@@ -1878,6 +1896,10 @@ function serializeArtifactPanelTabs(
return tabs.flatMap((tab): PersistedArtifactPanelTab[] => {
const { id, title } = tab;
switch (tab.kind) {
case 'web_preview':
return [
{ id, title, kind: tab.kind, url: tab.url, viewport: tab.viewport },
];
case 'source':
return [];
case 'review':
@@ -4633,6 +4655,8 @@ export function App({
Boolean(connection.sessionId && connection.workspaceCwd) &&
connection.capabilities?.features.includes(SESSION_SIDE_TASK_FEATURE) ===
true;
const webPreviewAvailable =
workspaceContextActive && rightPanelItems.includes('webPreview');
const webTerminalAvailable =
workspaceContextActive &&
rightPanelItems.includes('terminal') &&
@@ -4698,6 +4722,39 @@ export function App({
if (createSideTask()) return;
pushToast('error', t('sideTask.createFailed'));
}, [createSideTask, pushToast, t]);
const openWebPreviewTab = useCallback(() => {
const id = `web-preview:${crypto.randomUUID()}`;
setArtifactPanelTabs((tabs) => [
...tabs,
{
id,
kind: 'web_preview',
title: t('webPreview.title'),
url: '',
viewport: 'desktop',
},
]);
setActiveArtifactPanelTabId(id);
setArtifactPanelOpen(true);
}, [t]);
const updateWebPreviewTab = useCallback(
(tabId: string, state: WebPreviewState) => {
setArtifactPanelTabs((tabs) =>
tabs.map((tab) =>
tab.id === tabId && tab.kind === 'web_preview'
? {
...tab,
title:
state.url === tab.url ? tab.title : state.url || tab.title,
url: state.url,
viewport: state.viewport,
}
: tab,
),
);
},
[],
);
const openTerminalTab = useCallback(() => {
const id = `terminal:${crypto.randomUUID()}`;
const count = artifactPanelTabsRef.current.filter(
@@ -5875,6 +5932,7 @@ export function App({
).some(
(tab) =>
(tab.kind === 'terminal' && webTerminalAvailable) ||
(tab.kind === 'web_preview' && webPreviewAvailable) ||
(tab.kind === 'workflow' &&
tab.sessionId === connection.sessionId &&
sessionAgentTraceSupported),
@@ -5969,6 +6027,7 @@ export function App({
const deferredPersistedTabs = (persisted?.tabs ?? []).filter(
(tab) =>
(tab.kind === 'terminal' && !webTerminalAvailable) ||
(tab.kind === 'web_preview' && !webPreviewAvailable) ||
(tab.kind === 'workflow' &&
tab.sessionId === connection.sessionId &&
!sessionAgentTraceSupported),
@@ -6050,6 +6109,8 @@ export function App({
}
: undefined;
}
case 'web_preview':
return webPreviewAvailable ? tab : undefined;
case 'file': {
return tab;
}
@@ -6290,6 +6351,7 @@ export function App({
sessionAgentTraceSupported,
sessionActions,
webTerminalAvailable,
webPreviewAvailable,
workspace.baseUrl,
workspace.client,
]);
@@ -6505,6 +6567,44 @@ export function App({
);
return;
}
const previewUrl =
webPreviewAvailable &&
request.artifact.metadata?.['artifactType'] !==
'web_preview_snapshot' &&
request.artifact.storage === 'published' &&
request.artifact.source === 'tool' &&
request.artifact.toolName?.toLowerCase() === 'artifact' &&
request.artifact.status === 'available' &&
(request.artifact.kind === 'html' ||
request.artifact.kind === 'link') &&
request.artifact.url
? parseWebPreviewUrl(
request.artifact.url,
window.location.href,
workspace.baseUrl,
)
: undefined;
if (previewUrl) {
const tab: ArtifactPanelTab = {
id: `web-preview:${request.sourceSessionId ?? connection.sessionId}:${request.turnId}:${request.artifactId}`,
kind: 'web_preview',
title: request.title,
url: previewUrl.href,
viewport: 'desktop',
};
rememberArtifactPanelTrigger();
setArtifactPanelTabs((tabs) =>
tabs.some((item) => item.id === tab.id)
? tabs.map((item) => (item.id === tab.id ? tab : item))
: [...tabs, tab],
);
setActiveArtifactPanelTabId(tab.id);
setArtifactPanelWidth((width) =>
artifactPanelOpenRef.current ? width : getDefaultReviewPanelWidth(),
);
setArtifactPanelOpen(true);
return;
}
// Cache the opened row so the tab keeps rendering through transient
// gaps in the live artifact lists (an SSE reconnect, or the source
// pane closing); the snapshot/live-list reconciles drop the copy once
@@ -6527,8 +6627,8 @@ export function App({
artifactId: request.artifactId,
...(request.workspaceCwd ? { workspaceCwd: request.workspaceCwd } : {}),
...(request.workspaceId ? { workspaceId: request.workspaceId } : {}),
...(request.sourceSessionId
? { sourceSessionId: request.sourceSessionId }
...((request.sourceSessionId ?? connection.sessionId)
? { sourceSessionId: request.sourceSessionId ?? connection.sessionId }
: {}),
...(request.previewContent !== undefined
? { previewContent: request.previewContent }
@@ -6556,6 +6656,10 @@ export function App({
openImagePanel,
openAttachmentPanel,
openSubagentPanelForSession,
webPreviewAvailable,
workspace.baseUrl,
connection.sessionId,
rememberArtifactPanelTrigger,
],
);
const openFilePreview = useCallback(
@@ -16648,6 +16752,8 @@ export function App({
onSelectTab: selectArtifactPanelTab,
onCloseTab: closeArtifactPanelTab,
onOpenFilePreview: openFilePreview,
onOpenWebPreview: workspaceContextActive ? openWebPreviewTab : undefined,
onWebPreviewChange: updateWebPreviewTab,
latestReviewAvailable: latestReviewChanges.length > 0,
onOpenLatestReview: openLatestReviewPanel,
items: rightPanelItems,
@@ -70,6 +70,7 @@ const {
},
client: {
workspaceByCwd: vi.fn(() => mockSecondaryWorkspaceActions),
readSessionArtifactContent: vi.fn(),
},
},
};
@@ -81,6 +82,10 @@ vi.mock(
...(await importOriginal()),
useActions: () => mockActions,
useWorkspace: () => mockWorkspace,
useConnection: () => ({
sessionId: 'active-session',
clientId: 'active-viewer',
}),
useWorkspaceActions: () => mockWorkspaceActions,
}),
);
@@ -227,7 +232,11 @@ function linkArtifact(): DaemonSessionArtifact {
function artifactPanel(
artifact: DaemonSessionArtifact,
owner: { workspaceCwd: string; workspaceId: string } | null = {
owner: {
workspaceCwd: string;
workspaceId: string;
sourceSessionId?: string;
} | null = {
workspaceCwd: '/primary',
workspaceId: 'primary-id',
},
@@ -489,6 +498,52 @@ describe('ArtifactPanel terminal tabs', () => {
});
});
describe('ArtifactPanel web previews', () => {
it('keeps the application frame mounted across tab and viewport changes', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
const renderPanel = (
activeTabId: string,
viewport: 'desktop' | 'mobile' = 'desktop',
) => (
<I18nProvider language="en">
<ArtifactPanel
artifacts={[]}
tabs={[
{
id: 'preview',
kind: 'web_preview',
title: 'Web preview',
url: 'http://localhost:6543',
viewport,
},
{ id: 'terminal', kind: 'terminal', title: 'Terminal' },
]}
activeTabId={activeTabId}
reviewChanges={[]}
selectedReviewPath={null}
onSelectTab={() => {}}
onCloseTab={() => {}}
onOpenFilePreview={() => {}}
onClose={() => {}}
/>
</I18nProvider>
);
act(() => root.render(renderPanel('preview')));
const frame = container.querySelector('iframe');
expect(frame).not.toBeNull();
act(() => root.render(renderPanel('terminal')));
expect(container.querySelector('iframe')).toBe(frame);
expect(frame?.closest('[hidden]')).not.toBeNull();
act(() => root.render(renderPanel('preview', 'mobile')));
expect(container.querySelector('iframe')).toBe(frame);
expect(frame?.closest('[hidden]')).toBeNull();
expect(frame?.style.width).toBe('390px');
});
});
describe('artifact workspace authority', () => {
it('keeps an in-flight read across an equivalent capabilities refresh', async () => {
let resolveRead:
@@ -662,6 +717,43 @@ async function flush() {
}
describe('ArtifactPanel code review artifacts', () => {
it('reads a saved artifact from the panel tab source session', async () => {
const container = document.createElement('div');
const root = createRoot(container);
mounted.push({ root, container });
mockWorkspace.client.readSessionArtifactContent.mockResolvedValue(
'<h1>Source session</h1>',
);
await act(async () =>
root.render(
artifactPanel(
{
...linkArtifact(),
kind: 'html',
storage: 'published',
metadata: { artifactType: 'web_preview_snapshot' },
},
{
workspaceCwd: '/primary',
workspaceId: 'primary-id',
sourceSessionId: 'original-session',
},
),
),
);
expect(
mockWorkspace.client.readSessionArtifactContent,
).toHaveBeenCalledWith('original-session', linkArtifact().id, {
clientId: undefined,
signal: expect.any(AbortSignal),
});
expect(
container
.querySelector('iframe[title="Saved webpage version"]')
?.getAttribute('srcdoc'),
).toContain('Source session');
});
it('uses the artifact format icon in the panel tab', () => {
const container = document.createElement('div');
document.body.appendChild(container);
@@ -722,6 +814,38 @@ describe('ArtifactPanel code review artifacts', () => {
expect(container.textContent).not.toContain('PRIMARY_WORKSPACE_SECRET');
});
it('renders a saved webpage version without a workspace owner', async () => {
mockWorkspace.client.readSessionArtifactContent.mockResolvedValue(
'<h1>Saved version</h1>',
);
const artifact = {
...linkArtifact(),
kind: 'html',
storage: 'published',
metadata: { artifactType: 'web_preview_snapshot' },
} as DaemonSessionArtifact;
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });
await act(async () => root.render(artifactPanel(artifact, null)));
await flush();
expect(
container.querySelector('[data-web-shell-saved-preview]'),
).not.toBeNull();
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(container.textContent).not.toContain(
'This workspace may have been removed',
);
expect(
container
.querySelector('iframe[title="Saved webpage version"]')
?.getAttribute('srcdoc'),
).toContain('Saved version');
});
it('fails closed when a file tab has no workspace owner', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
@@ -870,9 +994,11 @@ describe('ArtifactPanel code review artifacts', () => {
).click();
});
await flush();
expect(container.querySelector('iframe')?.getAttribute('srcdoc')).toContain(
'<h1>Attachment page</h1>',
);
expect(
new DOMParser()
.parseFromString(container.querySelector('iframe')!.srcdoc, 'text/html')
.querySelector('iframe')!.srcdoc,
).toContain('<h1>Attachment page</h1>');
expect(mockWorkspaceActions.readWorkspaceFile).not.toHaveBeenCalled();
});
@@ -2969,7 +3095,12 @@ describe('ArtifactPanel workspace artifact previews', () => {
);
} else {
expect(
container.querySelector('iframe')?.getAttribute('srcdoc'),
new DOMParser()
.parseFromString(
container.querySelector('iframe')!.srcdoc,
'text/html',
)
.querySelector('iframe')!.srcdoc,
).toContain(testCase.content);
}
});
@@ -23,6 +23,7 @@ import {
EyeIcon,
ExpandIcon,
GaugeIcon,
GlobeIcon,
ImageIcon,
LayersIcon,
MessageCirclePlusIcon,
@@ -82,7 +83,7 @@ import {
normalizeArtifactMimeType,
normalizePath,
readWorkspaceFileAsBlob,
withArtifactPreviewCsp,
artifactPreviewDocument,
} from './artifactUtils';
import {
displayPath,
@@ -104,6 +105,9 @@ import type { EnvironmentAgentTask } from '../panels/EnvironmentPanel';
import { SideTaskPanel } from './SideTaskPanel';
import { SessionWorkflowInspector } from '../workflow/SessionWorkflowInspector';
import { TerminalPanel } from '../terminal/TerminalPanel';
import { WebPreviewPanel } from '../preview/WebPreviewPanel';
import { SavedWebPreview } from '../preview/SavedWebPreview';
import type { WebPreviewState } from '../preview/web-preview';
import { TokenUsagePanel } from './TokenUsagePanel';
import { ContextUsagePanel } from './ContextUsagePanel';
import {
@@ -141,6 +145,11 @@ export type ImageTabSource = {
};
export type ArtifactPanelTab =
| (WebPreviewState & {
id: string;
kind: 'web_preview';
title: string;
})
| {
id: string;
kind: 'source';
@@ -366,6 +375,8 @@ interface ArtifactPanelProps {
onOpenLatestReview?: () => void;
/** Open an interactive terminal tab in this panel (shown as an empty-state action). */
onOpenTerminal?: () => void;
onOpenWebPreview?: () => void;
onWebPreviewChange?: (tabId: string, state: WebPreviewState) => void;
items?: readonly WebShellRightPanelItem[];
sideTaskAvailable?: boolean;
sideTasks?: readonly SideTaskListItem[];
@@ -436,6 +447,8 @@ export function ArtifactPanel({
latestReviewAvailable = false,
onOpenLatestReview,
onOpenTerminal,
onOpenWebPreview,
onWebPreviewChange,
items = DEFAULT_RIGHT_PANEL_ITEMS,
sideTaskAvailable = false,
sideTasks = [],
@@ -522,9 +535,14 @@ export function ArtifactPanel({
sideTaskAvailable &&
Boolean(onCreateSideTask);
const showTerminalMenuItem = Boolean(onOpenTerminal);
const showWebPreviewMenuItem =
items.includes('webPreview') && Boolean(onOpenWebPreview);
const showAddMenu =
Boolean(activeTab) &&
(showReviewMenuItem || showSideTaskMenuItems || showTerminalMenuItem);
(showReviewMenuItem ||
showSideTaskMenuItems ||
showTerminalMenuItem ||
showWebPreviewMenuItem);
const activeWorkspaceIdentity =
activeTab && isWorkspaceScopedTab(activeTab)
? {
@@ -539,6 +557,10 @@ export function ArtifactPanel({
activeWorkspaceTarget?.workspaceId === activeWorkspaceIdentity?.workspaceId
? activeWorkspaceTarget?.actions
: undefined;
const activeTabArtifact =
activeTab?.kind === 'artifact'
? artifacts.find((item) => item.id === activeTab.artifactId)
: undefined;
return (
<aside
@@ -620,6 +642,8 @@ export function ArtifactPanel({
className={styles.tabIconSvg}
strokeWidth={1.6}
/>
) : tab.kind === 'web_preview' ? (
<GlobeIcon className={styles.tabIconSvg} />
) : tab.kind === 'terminal' ? (
<SquareTerminalIcon
className={styles.tabIconSvg}
@@ -723,6 +747,14 @@ export function ArtifactPanel({
</span>
</DropdownMenuItem>
)}
{showWebPreviewMenuItem && (
<DropdownMenuItem onSelect={onOpenWebPreview}>
<GlobeIcon className={styles.sideTaskNewIcon} />
<span className={styles.sideTaskListTitle}>
{t('webPreview.title')}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
@@ -763,6 +795,20 @@ export function ArtifactPanel({
activeTab?.kind === 'side_task' ? styles.bodySideTask : ''
}`.trim()}
>
{tabs
.filter((tab) => tab.kind === 'web_preview')
.map((tab) => (
<div
key={tab.id}
className="h-full"
hidden={tab.id !== activeTab?.id}
>
<WebPreviewPanel
state={tab}
onChange={(state) => onWebPreviewChange?.(tab.id, state)}
/>
</div>
))}
{tabs
.filter((tab) => tab.kind === 'terminal')
.map((tab) => (
@@ -821,7 +867,8 @@ export function ArtifactPanel({
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-4 w-3/5" />
</div>
) : activeTab?.kind === 'terminal' ? null : !activeTab ? (
) : activeTab?.kind === 'terminal' ||
activeTab?.kind === 'web_preview' ? null : !activeTab ? (
<div
className={styles.emptyActions}
data-testid="right-panel-empty-actions"
@@ -943,6 +990,27 @@ export function ArtifactPanel({
</DropdownMenuContent>
</DropdownMenu>
))}
{showWebPreviewMenuItem && (
<button
type="button"
className={styles.emptyAction}
onClick={onOpenWebPreview}
>
<span className={styles.emptyActionIcon} aria-hidden="true">
<GlobeIcon strokeWidth={1.6} />
</span>
<span className={styles.emptyActionTitle}>
{t('webPreview.title')}
</span>
<span className={styles.emptyActionHint}>
{t('webPreview.openHint')}
</span>
<ChevronRightIcon
className={styles.emptyActionChevron}
aria-hidden="true"
/>
</button>
)}
{onOpenTerminal && (
<button
type="button"
@@ -989,6 +1057,9 @@ export function ArtifactPanel({
) : isWorkspaceScopedTab(activeTab) &&
(activeTab.kind !== 'scheduled_task' || activeTab.task.durable) &&
(activeTab.kind !== 'file' || !activeTab.previewOnly) &&
(activeTab.kind !== 'artifact' ||
activeTabArtifact?.metadata?.['artifactType'] !==
'web_preview_snapshot') &&
!activeWorkspaceActions ? (
<div className={styles.empty} role="alert">
{t('workspace.notFoundDescription')}
@@ -1075,6 +1146,7 @@ export function ArtifactPanel({
key={activeTab.id}
artifacts={artifacts}
artifactId={activeTab.artifactId}
sourceSessionId={activeTab.sourceSessionId}
workspaceActions={activeWorkspaceActions!}
previewContent={activeTab.previewContent}
loading={loading}
@@ -1279,6 +1351,7 @@ function TabScheduledTaskIcon() {
function ArtifactDetailTab({
artifacts,
artifactId,
sourceSessionId,
workspaceActions,
previewContent,
loading,
@@ -1286,6 +1359,7 @@ function ArtifactDetailTab({
}: {
artifacts: readonly DaemonSessionArtifact[];
artifactId: string;
sourceSessionId?: string;
workspaceActions: ArtifactWorkspaceActions;
previewContent?: string;
loading?: boolean;
@@ -1296,6 +1370,7 @@ function ArtifactDetailTab({
return (
<ArtifactDetail
artifact={artifact}
sourceSessionId={sourceSessionId}
workspaceActions={workspaceActions}
previewContent={previewContent}
/>
@@ -2710,10 +2785,12 @@ function fileExtensionLabel(value: string) {
function ArtifactDetail({
artifact,
sourceSessionId,
workspaceActions,
previewContent,
}: {
artifact: DaemonSessionArtifact;
sourceSessionId?: string;
workspaceActions: ArtifactWorkspaceActions;
previewContent?: string;
}) {
@@ -2728,6 +2805,12 @@ function ArtifactDetail({
artifact.storage === 'workspace' && Boolean(artifact.workspacePath);
const imageMimeType = getArtifactImageMimeType(artifact);
if (artifact.metadata?.['artifactType'] === 'web_preview_snapshot') {
return (
<SavedWebPreview artifact={artifact} sourceSessionId={sourceSessionId} />
);
}
if (isCodeReview) {
if (artifact.status !== 'available') {
return <CodeReviewUnavailable status={artifact.status} />;
@@ -3588,7 +3671,7 @@ function HtmlArtifactPreview({
className={styles.htmlPreview}
referrerPolicy="no-referrer"
sandbox="allow-scripts"
srcDoc={withArtifactPreviewCsp(content)}
srcDoc={artifactPreviewDocument(content, `Preview ${workspacePath}`)}
title={`Preview ${workspacePath}`}
/>
)}
@@ -460,7 +460,14 @@ function ArtifactCard({
<div className={styles.artifactInfo}>
<div className={styles.title}>{artifact.title}</div>
<div className={styles.artifactMeta}>
{[getArtifactTypeLabel(artifact), size].filter(Boolean).join(' · ')}
{[
artifact.metadata?.['artifactType'] === 'web_preview_snapshot'
? t('webPreview.saved')
: getArtifactTypeLabel(artifact),
size,
]
.filter(Boolean)
.join(' · ')}
</div>
</div>
<div className={styles.actions}>
@@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
import {
artifactKindLabel,
artifactPreviewDocument,
downloadWorkspaceFile,
getArtifactFreshnessKey,
getArtifactImageMimeType,
@@ -324,6 +325,22 @@ describe('artifactUtils', () => {
expect(statFile).toHaveBeenCalledTimes(2);
});
it('keeps user HTML inside an opaque child with a restrictive parent policy', () => {
const title = 'Page "quoted" <title>';
const output = artifactPreviewDocument(
'<p>Hello</p><script>let n=0</script>',
title,
);
const parent = new DOMParser().parseFromString(output, 'text/html');
const child = parent.querySelector('iframe')!;
expect(parent.querySelector('script')).toBeNull();
expect(parent.querySelector('meta')?.content).toContain("frame-src 'none'");
expect(child.title).toBe(title);
expect(child.getAttribute('sandbox')).toBe('allow-scripts');
expect(child.srcdoc).toContain('<p>Hello</p><script>let n=0</script>');
expect(child.srcdoc).toContain("default-src 'none'");
});
it('injects preview CSP and strips unsafe metadata', () => {
const output = withArtifactPreviewCsp(`
<html>
@@ -3,6 +3,7 @@ import type {
DaemonWorkspaceFileBytes,
} from '@qwen-code/sdk/daemon';
import type { DaemonWorkspaceActions } from '@qwen-code/web-shell/daemon-react-sdk';
import { escapeAttribute } from '../preview/web-preview';
export function artifactKindLabel(
kind: string,
@@ -387,7 +388,16 @@ export function isSamePath(
}
const ARTIFACT_PREVIEW_CSP =
"default-src 'none'; base-uri 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:;";
"default-src 'none'; base-uri 'none'; form-action 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; media-src data:;";
export function artifactPreviewDocument(html: string, title: string): string {
// A frame's own CSP cannot block its self-navigation. Keep a trusted parent
// policy around the opaque content frame, including when the shell allows live URLs.
return `<!doctype html><html><head>
<meta http-equiv="Content-Security-Policy" content="${ARTIFACT_PREVIEW_CSP} frame-src 'none';">
<style>html,body,iframe{width:100%;height:100%;margin:0;border:0;display:block;overflow:hidden}</style>
</head><body><iframe title="${escapeAttribute(title)}" sandbox="allow-scripts" referrerpolicy="no-referrer" srcdoc="${escapeAttribute(withArtifactPreviewCsp(html))}"></iframe></body></html>`;
}
export function withArtifactPreviewCsp(html: string) {
if (typeof DOMParser === 'undefined') {
@@ -22,6 +22,86 @@ function toolGroup(id: string, tools: ACPToolCall[]): ToolGroupMessage {
}
describe('turnOutputSelectors', () => {
it('keeps each saved version in its original turn and omits the matching local latest card', () => {
const latestUrl = 'file:///tmp/publications/latest/index.html';
const messages = [
userMessage('u1', 'build'),
toolGroup('t1', [
{ callId: 'publish-1', toolName: 'Artifact', status: 'completed' },
]),
userMessage('u2', 'update'),
toolGroup('t2', [
{ callId: 'publish-2', toolName: 'Artifact', status: 'completed' },
]),
userMessage('u3', 'record latest link'),
toolGroup('t3', [
{
callId: 'record',
toolName: 'record_artifact',
status: 'completed',
args: { url: latestUrl },
},
]),
];
const first = {
id: 'first',
toolCallId: 'publish-1',
storage: 'published',
metadata: {
artifactType: 'web_preview_snapshot',
publishedUrl: latestUrl,
},
} as DaemonSessionArtifact;
const second = { ...first, id: 'second', toolCallId: 'publish-2' };
const latest = {
id: 'latest',
toolCallId: 'publish-2',
storage: 'published',
url: latestUrl,
} as DaemonSessionArtifact;
expect(
getArtifactsByTurn(messages.slice(0, 4), [first, second, latest]).get(
'u2',
),
).toEqual([second]);
const grouped = getArtifactsByTurn(messages, [first, second, latest]);
expect(grouped.get('u1')).toEqual([first]);
expect(grouped.get('u2')).toEqual([second]);
expect(grouped.get('u3')).toEqual([latest]);
});
it('keeps the matching latest card when its published page is browser-openable', () => {
const messages = [
userMessage('u1', 'build'),
toolGroup('t1', [
{ callId: 'publish-1', toolName: 'Artifact', status: 'completed' },
]),
userMessage('u2', 'update'),
toolGroup('t2', [
{ callId: 'publish-2', toolName: 'Artifact', status: 'completed' },
]),
];
const first = {
id: 'first',
toolCallId: 'publish-1',
storage: 'published',
metadata: {
artifactType: 'web_preview_snapshot',
publishedUrl: 'https://example.com/latest',
},
} as DaemonSessionArtifact;
const second = { ...first, id: 'second', toolCallId: 'publish-2' };
const latest = {
id: 'latest',
toolCallId: 'publish-2',
storage: 'published',
url: 'https://example.com/latest',
} as DaemonSessionArtifact;
const grouped = getArtifactsByTurn(messages, [first, second, latest]);
expect(grouped.get('u1')).toEqual([first]);
expect(grouped.get('u2')).toEqual([second, latest]);
});
it('attaches expanded directory files to the recorded folder turn', () => {
const messages = [
userMessage('u1', 'export excel'),
@@ -72,6 +72,30 @@ export function getArtifactsByTurn(
}
}
}
for (const [turnId, list] of byTurn) {
const savedUrls = new Set(
list
.filter(
(artifact) =>
artifact.metadata?.['artifactType'] === 'web_preview_snapshot',
)
.map((artifact) => artifact.metadata?.['publishedUrl']),
);
byTurn.set(
turnId,
list.filter(
(artifact) =>
artifact.metadata?.['artifactType'] === 'web_preview_snapshot' ||
artifact.storage !== 'published' ||
!artifact.url ||
// Only a file:// publication is indistinguishable from its saved
// twin: the preview cannot frame it either way. A browser-openable
// live card opens the hosted page, so it is not a duplicate.
/^https?:/i.test(artifact.url) ||
!savedUrls.has(artifact.url),
),
);
}
return byTurn;
}
@@ -0,0 +1,178 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import {
DaemonHttpError,
type DaemonSessionArtifact,
} from '@qwen-code/sdk/daemon';
import { I18nProvider } from '../../i18n';
import { SavedWebPreview } from './SavedWebPreview';
const { readContent, client } = vi.hoisted(() => {
const readContent = vi.fn();
return { readContent, client: { readSessionArtifactContent: readContent } };
});
vi.mock('@qwen-code/web-shell/daemon-react-sdk', () => ({
useWorkspace: () => ({ client }),
useConnection: () => ({ sessionId: 'active-session', clientId: 'viewer' }),
}));
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root;
let container: HTMLDivElement;
const artifact = (id: string): DaemonSessionArtifact => ({
id,
title: 'Page',
kind: 'html',
storage: 'published',
source: 'tool',
status: 'available',
retention: 'restorable',
clientRetained: false,
createdAt: '2026-09-07T00:00:00.000Z',
updatedAt: '2026-09-07T00:00:00.000Z',
metadata: { artifactType: 'web_preview_snapshot' },
});
function view(id: string, sourceSessionId = 'original-session') {
return (
<I18nProvider language="en">
<SavedWebPreview
artifact={artifact(id)}
sourceSessionId={sourceSessionId}
/>
</I18nProvider>
);
}
afterEach(() => {
act(() => root?.unmount());
container?.remove();
readContent.mockReset();
vi.restoreAllMocks();
});
describe('SavedWebPreview', () => {
it('reads the original session and cannot display a stale response in another version', async () => {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
let finishFirst!: (value: string) => void;
readContent.mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
finishFirst = resolve;
}),
);
readContent.mockResolvedValueOnce(
'<h1>Version two</h1><script>let count=0</script>',
);
await act(async () => {
root.render(view('one'));
});
const firstSignal = readContent.mock.calls[0]![2].signal as AbortSignal;
expect(readContent).toHaveBeenCalledWith('original-session', 'one', {
clientId: undefined,
signal: firstSignal,
});
await act(async () => {
root.render(view('two'));
});
expect(firstSignal.aborted).toBe(true);
await act(async () => {
finishFirst('<h1>Version one</h1>');
});
const iframe = container.querySelector('iframe')!;
const contentFrame = new DOMParser()
.parseFromString(iframe.srcdoc, 'text/html')
.querySelector('iframe')!;
expect(contentFrame.srcdoc).toContain('Version two');
expect(contentFrame.srcdoc).not.toContain('Version one');
expect(contentFrame.srcdoc).toContain('<script>let count=0</script>');
expect(contentFrame.getAttribute('sandbox')).toBe('allow-scripts');
expect(iframe.srcdoc).toContain("frame-src 'none'");
expect(iframe.sandbox?.value ?? iframe.getAttribute('sandbox')).toBe(
'allow-scripts',
);
expect(iframe.srcdoc).toContain("default-src 'none'");
expect(iframe.srcdoc).toContain('font-src data:');
expect(container.textContent).toContain('Saved version');
});
it('shows an unavailable version without loading a current URL', async () => {
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
readContent.mockRejectedValue(
new DaemonHttpError(
404,
{ error: 'artifact_snapshot_unavailable' },
'missing',
),
);
await act(async () => {
root.render(view('missing'));
});
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
'missing or has changed',
);
expect(container.querySelector('iframe')).toBeNull();
expect(readContent).toHaveBeenCalledTimes(1);
// A terminal unavailable verdict must not offer another attempt.
expect(container.querySelector('button')).toBeNull();
});
it('uses the active session identity locally and memoizes unchanged HTML', async () => {
container = document.createElement('div');
root = createRoot(container);
readContent.mockResolvedValue('<h1>Local</h1>');
const parse = vi.spyOn(DOMParser.prototype, 'parseFromString');
await act(async () => root.render(view('local', 'active-session')));
expect(readContent.mock.calls[0]![2].clientId).toBe('viewer');
expect(parse).toHaveBeenCalledTimes(1);
await act(async () => root.render(view('local', 'active-session')));
expect(readContent).toHaveBeenCalledTimes(1);
expect(parse).toHaveBeenCalledTimes(1);
});
it('refreshes loaded HTML from memory without another server read', async () => {
container = document.createElement('div');
root = createRoot(container);
readContent
.mockResolvedValueOnce('<h1>Version one</h1>')
.mockRejectedValue(new DaemonHttpError(503, {}, 'busy'));
await act(async () => root.render(view('one')));
const originalFrame = container.querySelector('iframe')!;
const originalDocument = originalFrame.srcdoc;
await act(async () => container.querySelector('button')!.click());
const refreshedFrame = container.querySelector('iframe')!;
expect(refreshedFrame).not.toBe(originalFrame);
expect(refreshedFrame.srcdoc).toBe(originalDocument);
expect(refreshedFrame.srcdoc).toContain('Version one');
expect(readContent).toHaveBeenCalledTimes(1);
expect(container.querySelector('[role="alert"]')).toBeNull();
});
it.each([
new DaemonHttpError(503, {}, 'busy'),
new DaemonHttpError(
404,
{ code: 'session_not_found' },
'session unavailable',
),
new Error('timeout'),
])('retries transient failures: %s', async (error) => {
container = document.createElement('div');
root = createRoot(container);
readContent
.mockRejectedValueOnce(error)
.mockResolvedValueOnce('<h1>Recovered</h1>');
await act(async () => root.render(view('recover')));
expect(container.textContent).toContain('Could not load');
expect(container.textContent).not.toContain('missing or has changed');
const retry = container.querySelector('button')!;
expect(retry.textContent).toBe('Try again');
await act(async () => retry.click());
expect(readContent).toHaveBeenCalledTimes(2);
expect(container.querySelector('iframe')!.srcdoc).toContain('Recovered');
});
});
@@ -0,0 +1,130 @@
import { useEffect, useMemo, useState } from 'react';
import {
DaemonHttpError,
type DaemonSessionArtifact,
} from '@qwen-code/sdk/daemon';
import {
useConnection,
useWorkspace,
} from '@qwen-code/web-shell/daemon-react-sdk';
import { useI18n } from '../../i18n';
import { artifactPreviewDocument } from '../artifacts/artifactUtils';
import { Button } from '../ui/button';
export function SavedWebPreview({
artifact,
sourceSessionId,
}: {
artifact: DaemonSessionArtifact;
sourceSessionId?: string;
}) {
const { t } = useI18n();
const { client } = useWorkspace();
const connection = useConnection();
const sessionId = sourceSessionId ?? connection.sessionId;
const clientId =
sessionId === connection.sessionId ? connection.clientId : undefined;
const [attempt, setAttempt] = useState(0);
const [frameRevision, setFrameRevision] = useState(0);
const [result, setResult] = useState<{
key: string;
html?: string;
failed?: 'unavailable' | 'request';
}>();
const key = `${sessionId}:${artifact.id}:${attempt}`;
useEffect(() => {
const controller = new AbortController();
if (!sessionId) {
setResult({ key, failed: 'request' });
return;
}
void client
.readSessionArtifactContent(sessionId, artifact.id, {
clientId,
signal: controller.signal,
})
.then(
(html) => {
if (!controller.signal.aborted) setResult({ key, html });
},
(error: unknown) => {
if (!controller.signal.aborted)
setResult({
key,
failed:
error instanceof DaemonHttpError &&
error.status === 404 &&
typeof error.body === 'object' &&
error.body !== null &&
'error' in error.body &&
error.body.error === 'artifact_snapshot_unavailable'
? 'unavailable'
: 'request',
});
},
);
return () => controller.abort();
}, [artifact.id, client, clientId, key, sessionId]);
const current = result?.key === key ? result : undefined;
const title = t('webPreview.savedFrame');
const previewDocument = useMemo(
() =>
current?.html === undefined
? undefined
: artifactPreviewDocument(current.html, title),
[current?.html, title],
);
return (
<section
className="flex h-full min-h-0 flex-col gap-3"
data-web-shell-saved-preview
>
<div className="flex shrink-0 items-center justify-between gap-2 text-xs text-muted-foreground">
<p>
{t('webPreview.saved')} ·{' '}
<time dateTime={artifact.createdAt}>
{new Date(artifact.createdAt).toLocaleString()}
</time>
</p>
{current?.failed !== 'unavailable' && (
<Button
variant="outline"
size="sm"
onClick={() => {
if (current?.html !== undefined) {
setFrameRevision((value) => value + 1);
} else {
setAttempt((value) => value + 1);
}
}}
>
{t(current?.failed ? 'common.retry' : 'webPreview.refresh')}
</Button>
)}
</div>
{current?.failed ? (
<p role="alert" className="text-sm text-destructive">
{t(
current.failed === 'unavailable'
? 'webPreview.savedUnavailable'
: 'webPreview.savedLoadFailed',
)}
</p>
) : current?.html !== undefined ? (
<iframe
key={frameRevision}
className="min-h-0 w-full flex-1 rounded-lg border border-border bg-white"
title={t('webPreview.savedFrame')}
referrerPolicy="no-referrer"
sandbox="allow-scripts"
srcDoc={previewDocument}
/>
) : (
<p role="status" className="text-sm text-muted-foreground">
{t('common.loading')}
</p>
)}
</section>
);
}
@@ -0,0 +1,147 @@
import { useEffect, useState } from 'react';
import {
ArrowUpRightIcon,
GlobeIcon,
MonitorIcon,
RotateCwIcon,
SmartphoneIcon,
} from 'lucide-react';
import { useWorkspace } from '@qwen-code/web-shell/daemon-react-sdk';
import { useI18n } from '../../i18n';
import { useExternalLinkOpener } from '../../hooks/useExternalLinkOpener';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import {
parseWebPreviewUrl,
webPreviewDocument,
type WebPreviewState,
} from './web-preview';
export function WebPreviewPanel({
state,
onChange,
}: {
state: WebPreviewState;
onChange: (state: WebPreviewState) => void;
}) {
const { t } = useI18n();
const { baseUrl } = useWorkspace();
const openExternal = useExternalLinkOpener();
const [address, setAddress] = useState(state.url);
const [invalid, setInvalid] = useState(false);
const [revision, setRevision] = useState(0);
const url = parseWebPreviewUrl(state.url, window.location.href, baseUrl);
useEffect(() => {
setAddress(state.url);
setInvalid(false);
}, [state.url]);
return (
<section
className="flex h-full min-h-0 flex-col gap-3 bg-background text-foreground"
data-web-shell-web-preview
aria-label={t('webPreview.title')}
>
<form
className="flex shrink-0 gap-2"
onSubmit={(event) => {
event.preventDefault();
const next = parseWebPreviewUrl(
address,
window.location.href,
baseUrl,
);
setInvalid(!next);
if (!next) return;
setAddress(next.href);
setRevision((value) => value + 1);
onChange({ ...state, url: next.href });
}}
>
<Input
aria-label={t('webPreview.address')}
aria-invalid={invalid || (!!state.url && !url)}
placeholder="http://localhost:3000"
value={address}
onChange={(event) => {
setAddress(event.target.value);
setInvalid(false);
}}
autoComplete="off"
spellCheck={false}
/>
<Button type="submit">{t('webPreview.open')}</Button>
</form>
{invalid || (state.url && !url) ? (
<p role="alert" className="text-sm text-destructive">
{t('webPreview.invalidUrl')}
</p>
) : null}
<div className="flex shrink-0 flex-wrap items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
disabled={!url}
title={t('webPreview.refresh')}
aria-label={t('webPreview.refresh')}
onClick={() => setRevision((value) => value + 1)}
>
<RotateCwIcon />
</Button>
{(['desktop', 'mobile'] as const).map((viewport) => (
<Button
key={viewport}
type="button"
variant={state.viewport === viewport ? 'secondary' : 'ghost'}
size="icon"
aria-pressed={state.viewport === viewport}
title={t(`webPreview.${viewport}`)}
aria-label={t(`webPreview.${viewport}`)}
onClick={() => onChange({ ...state, viewport })}
>
{viewport === 'desktop' ? <MonitorIcon /> : <SmartphoneIcon />}
</Button>
))}
{url && (
<Button asChild variant="ghost" size="sm" className="ml-auto">
<a
href={url.href}
target="_blank"
rel="noopener noreferrer"
onClick={(event) => openExternal(event, url.href)}
>
<ArrowUpRightIcon />
{t('webPreview.external')}
</a>
</Button>
)}
</div>
{url ? (
<div className="min-h-0 flex-1 overflow-auto rounded-lg border border-border bg-muted">
<iframe
key={`${url.href}:${revision}`}
title={t('webPreview.frame')}
className="mx-auto block h-full border-0 bg-white"
style={{ width: state.viewport === 'mobile' ? 390 : '100%' }}
referrerPolicy="no-referrer"
srcDoc={webPreviewDocument(url, t('webPreview.page'))}
/>
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
<GlobeIcon className="size-8" />
<p>{t('webPreview.empty')}</p>
</div>
)}
{url && (
<p className="shrink-0 text-xs text-muted-foreground">
{t('webPreview.live')}
</p>
)}
<p className="shrink-0 text-xs text-muted-foreground">
{t(url ? 'webPreview.fallback' : 'webPreview.reachable')}
</p>
</section>
);
}
@@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest';
import { parseWebPreviewUrl, webPreviewDocument } from './web-preview';
describe('web preview URLs', () => {
const parse = (value: string) =>
parseWebPreviewUrl(value, 'https://shell.example', 'http://localhost:4170');
it('preserves the development route, search, and fragment', () => {
expect(
parse(' http://localhost:3000/settings?theme=dark#card ')?.href,
).toBe('http://localhost:3000/settings?theme=dark#card');
expect(parse('https://preview.example/app')?.origin).toBe(
'https://preview.example',
);
});
it.each([
'javascript:alert(1)',
'data:text/html,hello',
'file:///tmp/index.html',
'/relative',
'localhost:3000',
'https://user:[email protected]',
'https://shell.example/path',
'http://shell.example/path',
'http://localhost:4170/path',
'http://127.0.0.1:4170/path',
'http://127.12.0.3:4170/path',
'http://0.0.0.0:4170/path',
'http://host.docker.internal:4170/path',
'http://x.localhost:4170/path',
'http://localhost.:4170/path',
'https://shell.example./path',
'http://[::1]:3000',
'http://*.example',
"http://example;script-src'unsafe-inline'",
])('rejects unsafe or unsupported URL %s', (value) => {
expect(parse(value)).toBeUndefined();
});
it.each([
'127.0.0.1',
'0.0.0.0',
'[::1]',
'host.docker.internal',
'x.localhost',
])('protects a daemon configured as %s', (host) => {
expect(
parseWebPreviewUrl(
'http://localhost:4170',
'https://shell.example',
`http://${host}:4170`,
),
).toBeUndefined();
expect(parse('http://127.0.0.1:3000')?.port).toBe('3000');
});
it('preserves other ports and unrelated LAN origins for loopback aliases', () => {
for (const host of ['host.docker.internal', 'x.localhost']) {
expect(parse(`http://${host}:3000`)?.port).toBe('3000');
expect(
parseWebPreviewUrl(
`http://${host}:4170`,
'https://shell.example',
'http://192.168.1.2:4170',
)?.hostname,
).toBe(host);
}
});
it('protects shell and daemon hosts written with trailing dots', () => {
expect(
parseWebPreviewUrl(
'https://shell.example/path',
'https://shell.example./',
'http://localhost:4170',
),
).toBeUndefined();
expect(
parseWebPreviewUrl(
'http://daemon.example:4170/path',
'https://shell.example',
'http://daemon.example.:4170',
),
).toBeUndefined();
});
it('rejects a non-default-port HTTPS upgrade into the daemon', () => {
expect(
parseWebPreviewUrl(
'http://daemon.example:4170',
'https://shell.example',
'https://daemon.example:4170',
),
).toBeUndefined();
});
it.each(['host.docker.internal', 'x.localhost'])(
'protects an HTTPS daemon from %s aliases and HTTP upgrades',
(host) => {
for (const protocol of ['http', 'https']) {
expect(
parseWebPreviewUrl(
`${protocol}://${host}:4170/path`,
'https://shell.example',
'https://localhost:4170',
),
).toBeUndefined();
}
},
);
it('pins the child origin before loading escaped application markup', () => {
const document = webPreviewDocument(
new URL('https://preview.example/?a=1&b=2'),
'Preview "app" <script>',
);
expect(document).toContain('frame-src https://preview.example;');
expect(document).toContain("script-src 'none'");
expect(document).toContain('src="https://preview.example/?a=1&amp;b=2"');
expect(document).toContain('Preview &quot;app&quot; &lt;script&gt;');
expect(document.indexOf('Content-Security-Policy')).toBeLessThan(
document.indexOf('<iframe'),
);
expect(document).toContain(
'sandbox="allow-scripts allow-same-origin allow-forms"',
);
expect(document).toContain('referrerpolicy="no-referrer"');
expect(document).toContain('<meta name="referrer" content="no-referrer">');
expect(document).not.toContain('allow-popups');
expect(document).not.toContain('allow-top-navigation');
expect(document).not.toContain('<script>');
});
});
@@ -0,0 +1,74 @@
export interface WebPreviewState {
url: string;
viewport: 'desktop' | 'mobile';
}
function protectedOrigin(url: URL): string {
const normalized = new URL(url);
normalized.hostname = normalized.hostname.replace(/\.$/, '');
if (
normalized.hostname === 'localhost' ||
normalized.hostname === '[::1]' ||
normalized.hostname === '0.0.0.0' ||
normalized.hostname === 'host.docker.internal' ||
normalized.hostname.endsWith('.localhost') ||
/^127\./.test(normalized.hostname)
) {
normalized.hostname = 'localhost';
}
return normalized.origin;
}
export function parseWebPreviewUrl(
input: string,
shellUrl: string,
daemonUrl: string,
): URL | undefined {
try {
const url = new URL(input.trim());
if (
!['http:', 'https:'].includes(url.protocol) ||
url.username ||
url.password ||
url.hostname.endsWith('.') ||
!/^[a-z0-9._-]+$/i.test(url.hostname)
) {
return;
}
const protectedOrigins = [
protectedOrigin(new URL(shellUrl)),
protectedOrigin(new URL(daemonUrl, shellUrl)),
];
const upgraded = new URL(url);
upgraded.protocol = 'https:';
// CSP HTTP sources also permit HTTPS upgrades.
if (
protectedOrigins.includes(protectedOrigin(url)) ||
protectedOrigins.includes(protectedOrigin(upgraded))
) {
return;
}
return url;
} catch {
return;
}
}
export function escapeAttribute(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
export function webPreviewDocument(url: URL, title: string): string {
// Pin direct child navigation; host frame-ancestors also protects against
// descendant frames created by the application itself.
const policy = `default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; frame-src ${url.origin}; base-uri 'none'; form-action 'none'`;
return `<!doctype html><html><head>
<meta http-equiv="Content-Security-Policy" content="${escapeAttribute(policy)}">
<meta name="referrer" content="no-referrer">
<style>html,body,iframe{width:100%;height:100%;margin:0;border:0;display:block;overflow:hidden}</style>
</head><body><iframe title="${escapeAttribute(title)}" src="${escapeAttribute(url.href)}" sandbox="allow-scripts allow-same-origin allow-forms" referrerpolicy="no-referrer"></iframe></body></html>`;
}
+6 -2
View File
@@ -131,10 +131,14 @@ export interface WebShellChatHeaderOptions {
items?: readonly WebShellChatHeaderItem[];
}
export type WebShellRightPanelItem = 'review' | 'sideTask' | 'terminal';
export type WebShellRightPanelItem =
| 'review'
| 'sideTask'
| 'terminal'
| 'webPreview';
export interface WebShellRightPanelOptions {
/** Empty-state actions to show. Defaults to all actions. */
/** Empty-state actions to show. Defaults to review and sideTask. */
items?: readonly WebShellRightPanelItem[];
}
@@ -0,0 +1,395 @@
import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createServer, type ViteDevServer } from 'vite';
import { expect, test, type Page } from '@playwright/test';
import {
assistantTextEvent,
createWebShellDaemonScenario,
installMockDaemon,
toolCallEvent,
turnCompleteEvent,
userTextEvent,
} from './utils/mockDaemon';
let fixture: ViteDevServer;
let fixtureDir: string;
let fixtureUrl: string;
const fixtureReferrers: Array<string | undefined> = [];
test.beforeAll(async () => {
fixtureDir = await realpath(
await mkdtemp(join(tmpdir(), 'qwen-preview-e2e-')),
);
await writeFile(
join(fixtureDir, 'index.html'),
'<!doctype html><input aria-label="App input"><p id="message"></p><p id="storage"></p><p id="referrer"></p><button id="popup">Probe popup</button><script type="module" src="/app.js"></script>',
);
await writeFile(
join(fixtureDir, 'app.js'),
`import { message } from './message.js';
document.querySelector('#message').textContent = message;
document.querySelector('#referrer').textContent = document.referrer || 'empty';
document.querySelector('#popup').onclick = () => document.querySelector('#popup').textContent = window.open('/popup') === null ? 'Blocked popup' : 'Opened popup';
localStorage.setItem('preview-module', 'working');
document.querySelector('#storage').textContent = localStorage.getItem('preview-module');
if (import.meta.hot) import.meta.hot.accept('./message.js', (module) => {
document.querySelector('#message').textContent = module.message;
});`,
);
await writeFile(
join(fixtureDir, 'message.js'),
"export const message = 'Before update';",
);
fixture = await createServer({
configFile: false,
root: fixtureDir,
logLevel: 'error',
server: { host: '127.0.0.1', port: 0 },
plugins: [
{
name: 'preview-navigation-fixture',
configureServer(server) {
server.middlewares.use((req, res, next) => {
const url = new URL(req.url ?? '/', 'http://localhost');
if (url.pathname === '/')
fixtureReferrers.push(req.headers.referer);
const target = url.searchParams.get('target') ?? '';
if (url.pathname === '/redirect') {
res.writeHead(302, { Location: target });
res.end();
} else if (url.pathname === '/navigate') {
res.setHeader('Content-Type', 'text/html');
res.end(
`<script>location.href=${JSON.stringify(target)}</script>`,
);
} else if (url.pathname === '/nested') {
res.setHeader('Content-Type', 'text/html');
res.end(
`<!doctype html><body><script>const child=document.createElement('iframe');child.src=${JSON.stringify(target)};document.body.append(child)</script>`,
);
} else if (url.pathname === '/unframeable') {
res.setHeader(
'Content-Security-Policy',
"frame-ancestors 'none'",
);
res.end('This page requires external opening');
} else {
next();
}
});
},
},
],
});
await fixture.listen();
const address = fixture.httpServer!.address();
if (!address || typeof address === 'string')
throw new Error('No fixture port');
fixtureUrl = `http://127.0.0.1:${address.port}`;
});
test.afterAll(async () => {
await fixture?.close();
if (fixtureDir) await rm(fixtureDir, { recursive: true, force: true });
});
test.beforeEach(async ({ page }, testInfo) => {
// Fulfilled document responses lose their loopback address-space metadata.
await page.context().grantPermissions(['local-network-access']);
fixtureReferrers.length = 0;
await page.setViewportSize({ width: 1440, height: 1000 });
const scenario = createWebShellDaemonScenario({
capabilities: { features: ['session_events', 'session_artifacts'] },
events: [
userTextEvent('Build a webpage.', { id: 1 }),
toolCallEvent(
'record-preview',
'Artifact',
{ title: 'Historical webpage', url: fixtureUrl },
{ id: 2, rawOutput: { recorded: true } },
),
assistantTextEvent('The webpage is ready.', { id: 3 }),
turnCompleteEvent('build-webpage', { id: 4 }),
userTextEvent('Change the heading.', { id: 5 }),
toolCallEvent(
'record-link',
'record_artifact',
{ title: 'Recorded link', url: fixtureUrl },
{ id: 6, rawOutput: { recorded: true } },
),
assistantTextEvent('The heading has changed.', { id: 7 }),
turnCompleteEvent('update-webpage', { id: 8 }),
],
artifacts: [
{
id: 'recorded-link',
kind: 'link',
storage: 'external_url',
source: 'tool',
status: 'available',
title: 'Recorded link',
url: fixtureUrl,
retention: 'restorable',
clientRetained: false,
createdAt: '2026-09-07T00:00:00.000Z',
updatedAt: '2026-09-07T00:00:00.000Z',
toolCallId: 'record-link',
toolName: 'record_artifact',
},
{
id: 'historical-webpage',
kind: 'html',
storage: 'published',
source: 'tool',
status: 'available',
title: 'Historical webpage',
url: fixtureUrl,
retention: 'restorable',
clientRetained: false,
createdAt: '2026-09-07T00:00:00.000Z',
updatedAt: '2026-09-07T00:00:00.000Z',
toolCallId: 'record-preview',
toolName: 'Artifact',
},
],
});
await installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
await page.route(`**/session/${scenario.sessionId}?*`, async (route) => {
if (route.request().resourceType() !== 'document') {
await route.fallback();
return;
}
const response = await route.fetch();
await route.fulfill({
response,
headers: { ...response.headers(), 'referrer-policy': 'unsafe-url' },
});
});
await page.goto(`/session/${scenario.sessionId}?language=en`);
await expect(
page
.locator('[data-web-shell-message-list]')
.getByText('Historical webpage', { exact: true }),
).toBeVisible();
await page
.getByRole('button', { name: 'Toggle right panel', exact: true })
.click();
await page
.getByRole('button', { name: /Web preview.*Preview a running/ })
.click();
});
async function openPreview(page: Page, url: string) {
await page.getByRole('textbox', { name: 'Development URL' }).fill(url);
await page
.locator('[data-web-shell-web-preview]:visible')
.getByRole('button', { name: 'Open', exact: true })
.click();
}
function appFrame(page: Page) {
return page
.frameLocator('iframe[title="Web preview frame"]')
.frameLocator('iframe[title="Application preview"]');
}
test('runs modules, storage and HMR, preserves tabs, and restores settings', async ({
page,
}) => {
await openPreview(page, fixtureUrl);
const app = appFrame(page);
await expect(app.locator('#message')).toHaveText('Before update');
await expect(app.locator('#storage')).toHaveText('working');
await expect(app.locator('#referrer')).toHaveText('empty');
expect(fixtureReferrers.length).toBeGreaterThan(0);
expect(fixtureReferrers.every((value) => value === undefined)).toBe(true);
await app.getByRole('button', { name: 'Probe popup', exact: true }).click();
await expect(
app.getByRole('button', { name: 'Blocked popup', exact: true }),
).toBeVisible();
await app.getByRole('textbox', { name: 'App input' }).fill('Keep this state');
await writeFile(
join(fixtureDir, 'message.js'),
"export const message = 'After update';",
);
await expect(app.locator('#message')).toHaveText('After update');
await expect(app.getByRole('textbox')).toHaveValue('Keep this state');
await page
.getByRole('button', { name: 'Mobile width (390 px)', exact: true })
.click();
expect(await app.locator('html').evaluate(() => window.innerWidth)).toBe(390);
await expect(app.getByRole('textbox')).toHaveValue('Keep this state');
await page.getByRole('button', { name: 'Add panel', exact: true }).click();
await page
.getByRole('menuitem', { name: 'Web preview', exact: true })
.click();
await page
.getByRole('tab', { name: `${fixtureUrl}/`, exact: true })
.first()
.click();
await expect(appFrame(page).getByRole('textbox')).toHaveValue(
'Keep this state',
);
await page
.getByRole('button', { name: 'Refresh preview', exact: true })
.first()
.click();
await expect(appFrame(page).getByRole('textbox')).toHaveValue('');
await expect(
page.getByRole('link', { name: 'Open externally' }).first(),
).toHaveAttribute('href', `${fixtureUrl}/`);
await expect(
page.getByRole('link', { name: 'Open externally' }).first(),
).toHaveAttribute('rel', 'noopener noreferrer');
const [external] = await Promise.all([
page.waitForEvent('popup'),
page.getByRole('link', { name: 'Open externally' }).first().click(),
]);
await external.waitForLoadState();
expect(external.url()).toBe(`${fixtureUrl}/`);
expect(await external.evaluate(() => window.opener)).toBeNull();
await external.close();
await page.reload();
await expect(
page.getByRole('textbox', { name: 'Development URL' }).first(),
).toHaveValue(`${fixtureUrl}/`);
await expect(appFrame(page).locator('#message')).toHaveText('After update');
expect(
await appFrame(page)
.locator('html')
.evaluate(() => window.innerWidth),
).toBe(390);
await page
.getByRole('button', { name: `Close ${fixtureUrl}/`, exact: true })
.click();
await expect(page.locator('iframe[title="Web preview frame"]')).toHaveCount(
0,
);
});
test('rejects unsafe URLs and preserves the working preview after invalid input', async ({
page,
}) => {
await openPreview(page, fixtureUrl);
await expect(appFrame(page).locator('#storage')).toHaveText('working');
for (const url of [
'javascript:alert(1)',
'https://user:[email protected]',
page.url(),
]) {
await openPreview(page, url);
await expect(page.getByRole('alert')).toContainText('development address');
await expect(appFrame(page).locator('#storage')).toHaveText('working');
}
});
test('@smoke blocks direct redirects and script navigation, and protects host descendants', async ({
page,
}, testInfo) => {
const violations: string[] = [];
const target = `${String(testInfo.project.use.baseURL)}/e2e/composer-layout-harness.html?preview-probe=1`;
const targetRequests: string[] = [];
page.on('console', (message) => {
if (message.text().includes('Content Security Policy'))
violations.push(message.text());
});
page.on('request', (request) => {
if (request.url() === target) targetRequests.push(request.url());
});
for (const path of ['redirect', 'navigate']) {
violations.length = 0;
await openPreview(
page,
`${fixtureUrl}/${path}?target=${encodeURIComponent(target)}`,
);
await expect
.poll(() => violations.some((message) => message.includes('frame-src')))
.toBe(true);
expect(targetRequests).toHaveLength(0);
}
violations.length = 0;
await openPreview(
page,
`${fixtureUrl}/nested?target=${encodeURIComponent(target)}`,
);
await expect
.poll(() =>
violations.some((message) => message.includes('frame-ancestors')),
)
.toBe(true);
expect(targetRequests).toContain(target);
expect(page.frames().some((frame) => frame.url() === target)).toBe(false);
});
test('keeps the external fallback available for frame-blocked applications', async ({
page,
}) => {
const violations: string[] = [];
page.on('console', (message) => violations.push(message.text()));
await openPreview(page, `${fixtureUrl}/unframeable`);
await expect
.poll(() =>
violations.some((message) => message.includes('frame-ancestors')),
)
.toBe(true);
await expect(page.getByText('Blank page?', { exact: false })).toBeVisible();
await expect(
page.getByRole('link', { name: 'Open externally' }),
).toHaveAttribute('href', `${fixtureUrl}/unframeable`);
});
test('reopens a live webpage from its historical message after closing the tab and reloading', async ({
page,
}) => {
await page
.getByRole('button', { name: 'Close Web preview', exact: true })
.click();
const transcript = page.locator('[data-web-shell-message-list]');
for (let attempt = 0; attempt < 2; attempt++) {
await expect(
page.getByText('The heading has changed.', { exact: true }),
).toBeVisible();
await expect(
transcript.getByText('Historical webpage', { exact: true }),
).toBeVisible();
await transcript.locator('[title="Historical webpage"] > button').click();
await expect(appFrame(page).locator('#storage')).toHaveText('working');
await expect(page.getByText('Live page.', { exact: false })).toBeVisible();
await page
.getByRole('button', { name: 'Close Historical webpage', exact: true })
.click();
await expect(page.locator('iframe[title="Web preview frame"]')).toHaveCount(
0,
);
await expect(
transcript.getByText('Historical webpage', { exact: true }),
).toBeVisible();
await page.reload();
}
});
test('recorded links open metadata without automatically loading a live frame', async ({
page,
}) => {
await page
.getByRole('button', { name: 'Close Web preview', exact: true })
.click();
await page
.locator('[data-web-shell-message-list] [title="Recorded link"] > button')
.click();
await expect(
page.getByRole('tab', { name: 'Recorded link', exact: true }),
).toBeVisible();
await expect(page.locator('iframe[title="Web preview frame"]')).toHaveCount(
0,
);
await expect(
page.getByRole('link', { name: 'Open link', exact: true }),
).toHaveAttribute('href', fixtureUrl);
});
@@ -0,0 +1,40 @@
import { expect, test } from '@playwright/test';
// Full Chromium includes the PDF viewer; headless-shell does not.
test.use({ channel: 'chromium' });
test.describe('PDF attachment framing', () => {
test('@smoke permits PDF blob frames under the development CSP', async ({
page,
}) => {
const violations: string[] = [];
page.on('console', (message) => {
if (message.text().includes('Content Security Policy')) {
violations.push(message.text());
}
});
const response = await page.goto('/e2e/composer-layout-harness.html');
expect(response?.headers()['content-security-policy']).toContain(
'frame-src http: https: blob:;',
);
await page.setContent('<iframe title="PDF attachment"></iframe>');
const url = await page.locator('iframe').evaluate((frame, base64) => {
const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
const url = URL.createObjectURL(
new Blob([bytes], { type: 'application/pdf' }),
);
(frame as HTMLIFrameElement).src = url;
return url;
}, 'JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCAzMDAgMjAwXSAvQ29udGVudHMgNCAwIFIgL1Jlc291cmNlcyA8PCAvRm9udCA8PCAvRjEgNSAwIFIgPj4gPj4gPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA0NCA+PgpzdHJlYW0KQlQgL0YxIDE4IFRmIDIwIDEwMCBUZCAoUERGIHByb2JlKSBUaiBFVAplbmRzdHJlYW0KZW5kb2JqCjUgMCBvYmoKPDwgL1R5cGUgL0ZvbnQgL1N1YnR5cGUgL1R5cGUxIC9CYXNlRm9udCAvSGVsdmV0aWNhID4+CmVuZG9iagp4cmVmCjAgNgowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCjAwMDAwMDAyNDEgMDAwMDAgbiAKMDAwMDAwMDMzMSAwMDAwMCBuIAp0cmFpbGVyCjw8IC9TaXplIDYgL1Jvb3QgMSAwIFIgPj4Kc3RhcnR4cmVmCjQwMQolJUVPRgo=');
try {
await expect
.poll(() => page.frames().some((frame) => frame.url() === url))
.toBe(true);
expect(
violations.filter((message) => message.includes('frame-src')),
).toEqual([]);
} finally {
await page.evaluate((value) => URL.revokeObjectURL(value), url);
}
});
});
@@ -0,0 +1,274 @@
import {
expect,
test,
type ConsoleMessage,
type Request,
} from '@playwright/test';
import { createServer } from 'node:http';
import type { DaemonSessionArtifact } from '@qwen-code/sdk/daemon';
import { artifactPreviewDocument } from '../components/artifacts/artifactUtils';
import {
assistantTextEvent,
createWebShellDaemonScenario,
installMockDaemon,
toolCallEvent,
turnCompleteEvent,
userTextEvent,
} from './utils/mockDaemon';
function savedArtifact(version: number): DaemonSessionArtifact {
return {
id: `saved-${version}`,
title: `Page version ${version}`,
kind: 'html',
storage: 'published',
source: 'tool',
status: 'available',
retention: 'restorable',
clientRetained: false,
createdAt: `2026-09-07T00:0${version}:00.000Z`,
updatedAt: `2026-09-07T00:0${version}:00.000Z`,
toolCallId: `publish-${version}`,
toolName: 'Artifact',
url: `file:///runtime/snapshots/${version}/index.html`,
metadata: {
artifactType: 'web_preview_snapshot',
publishedUrl: 'http://127.0.0.1:1/stopped-server',
},
};
}
test('@smoke offline HTML remains interactive but cannot navigate out of its parent', async ({
page,
}) => {
const requests: string[] = [];
const target = createServer((req, res) => {
requests.push(req.url ?? '');
res.end('Navigation escaped');
});
await new Promise<void>((resolve) => target.listen(0, '127.0.0.1', resolve));
try {
const address = target.address();
if (!address || typeof address === 'string')
throw new Error('No target port');
const destination = `http://127.0.0.1:${address.port}/leak?content=private`;
const html = `<style>body{background:rgb(12, 34, 56)}</style><img alt="Embedded" src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"><audio preload="auto" src="data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YSADAACAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgA=="></audio><button id="count">Count: 0</button><button id="parent">Probe parent</button><button id="navigate">Navigate</button><script>let n=0;document.querySelector('#count').onclick=()=>document.querySelector('#count').textContent='Count: '+(++n);document.querySelector('#parent').onclick=()=>{try{parent.document.body.textContent='Escaped'}catch{document.querySelector('#parent').textContent='Blocked'}};document.querySelector('#navigate').onclick=()=>window['location']['href']=${JSON.stringify(destination)}</script>`;
const violations: string[] = [];
page.on('console', (message) => {
if (message.text().includes('Content Security Policy'))
violations.push(message.text());
});
const response = await page.goto('/e2e/composer-layout-harness.html');
expect(response?.headers()['content-security-policy']).toContain(
"default-src 'self'",
);
expect(response?.headers()['content-security-policy']).toContain(
"media-src 'self' data:",
);
expect(response?.headers()['content-security-policy']).toContain(
'frame-src http: https: blob:;',
);
await page.setContent(
'<iframe title="Offline preview" sandbox="allow-scripts"></iframe>',
);
await page.locator('iframe').evaluate(
(frame, document) => {
(frame as HTMLIFrameElement).srcdoc = document;
},
artifactPreviewDocument(html, 'Document'),
);
const content = page
.frameLocator('iframe[title="Offline preview"]')
.frameLocator('iframe');
await content.getByRole('button', { name: 'Count: 0' }).click();
await expect(
content.getByRole('button', { name: 'Count: 1' }),
).toBeVisible();
await expect(content.locator('body')).toHaveCSS(
'background-color',
'rgb(12, 34, 56)',
);
await expect
.poll(() =>
content
.getByAltText('Embedded')
.evaluate((img) => (img as HTMLImageElement).naturalWidth),
)
.toBe(1);
await expect
.poll(() =>
content
.locator('audio')
.evaluate((audio) => (audio as HTMLAudioElement).readyState),
)
.toBeGreaterThan(0);
await content.getByRole('button', { name: 'Probe parent' }).click();
await expect(
content.getByRole('button', { name: 'Blocked' }),
).toBeVisible();
await content.getByRole('button', { name: 'Navigate' }).click();
await expect
.poll(() => violations.some((message) => message.includes('frame-src')))
.toBe(true);
expect(requests).toEqual([]);
expect(
page.frames().some((frame) => frame.url().startsWith(destination)),
).toBe(false);
} finally {
await new Promise<void>((resolve, reject) =>
target.close((error) => (error ? reject(error) : resolve())),
);
}
});
test('opens each saved delivery after closing and reloading, with inline interaction', async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 1500, height: 1000 });
const scenario = createWebShellDaemonScenario({
capabilities: { features: ['session_events', 'session_artifacts'] },
events: [
userTextEvent('Build the first page.', { id: 1 }),
toolCallEvent(
'publish-1',
'Artifact',
{ file_path: '/tmp/page.html' },
{ id: 2 },
),
assistantTextEvent('First version saved.', { id: 3 }),
turnCompleteEvent('first', { id: 4 }),
userTextEvent('Change it to version two.', { id: 5 }),
toolCallEvent(
'publish-2',
'Artifact',
{ file_path: '/tmp/page.html' },
{ id: 6 },
),
assistantTextEvent('Second version saved.', { id: 7 }),
turnCompleteEvent('second', { id: 8 }),
],
artifacts: [savedArtifact(1), savedArtifact(2)],
});
await installMockDaemon(page, scenario, {
baseURL: String(testInfo.project.use.baseURL),
});
const reads: string[] = [];
let missing = false;
await page.route('**/artifacts/saved-*/content', async (route) => {
const match = /\/saved-(\d)\/content$/.exec(
new URL(route.request().url()).pathname,
);
expect(route.request().url()).toContain(`/session/${scenario.sessionId}/`);
reads.push(match![1]!);
if (missing) {
await route.fulfill({
status: 404,
contentType: 'application/json',
body: '{"error":"artifact_snapshot_unavailable"}',
});
return;
}
await route.fulfill({
contentType: 'text/html',
body: `<!doctype html><style>body{font:20px system-ui;padding:30px;background:#f5f5f5}</style><h1>Version ${match![1]}</h1><button id="count">Count: 0</button><button id="navigate">Navigate</button><script>let n=0;document.querySelector('#count').onclick=()=>document.querySelector('#count').textContent='Count: '+(++n);document.querySelector('#navigate').onclick=()=>location.href='https://example.invalid/blocked'</script>`,
});
});
const liveRequests: string[] = [];
page.on('request', (req) => {
if (req.url().includes('stopped-server')) liveRequests.push(req.url());
});
await page.goto(`/session/${scenario.sessionId}?language=en`);
const transcript = page.locator('[data-web-shell-message-list]');
const frame = page
.frameLocator('iframe[title="Saved webpage version"]')
.frameLocator('iframe');
for (const version of [1, 2, 1]) {
await transcript
.locator(`[title="Page version ${version}"] > button`)
.click();
await expect(frame.getByRole('heading')).toHaveText(`Version ${version}`);
await frame.getByRole('button', { name: 'Count: 0' }).click();
await expect(frame.getByRole('button', { name: 'Count: 1' })).toBeVisible();
await expect(page.locator('[data-web-shell-saved-preview]')).toContainText(
'Saved version',
);
if (version === 2) {
const readCount = reads.length;
const violations: string[] = [];
const navigations: string[] = [];
const recordViolation = (message: ConsoleMessage) => {
if (message.text().includes('Content Security Policy')) {
violations.push(message.text());
}
};
const recordNavigation = (request: Request) => {
if (request.url().startsWith('https://example.invalid/')) {
navigations.push(request.url());
}
};
page.on('console', recordViolation);
page.on('request', recordNavigation);
await frame
.getByRole('button', { name: 'Navigate', exact: true })
.click();
try {
// The wrapper's frame-src 'none' is the containment: the attempt is
// reported and blocked before any request or frame commit, not
// inferred from the document disappearing into an error page.
await expect
.poll(() => violations.some((text) => text.includes('frame-src')))
.toBe(true);
expect(navigations).toEqual([]);
expect(
page
.frames()
.some((candidate) =>
candidate.url().startsWith('https://example.invalid/'),
),
).toBe(false);
} finally {
page.off('console', recordViolation);
page.off('request', recordNavigation);
}
missing = true;
await page
.getByRole('button', { name: 'Refresh preview', exact: true })
.click();
await expect(frame.getByRole('heading')).toHaveText('Version 2');
await expect(
frame.getByRole('button', { name: 'Count: 0' }),
).toBeVisible();
expect(reads).toHaveLength(readCount);
missing = false;
await page.reload();
await expect(frame.getByRole('heading')).toHaveText('Version 2');
await expect(
frame.getByRole('button', { name: 'Count: 0' }),
).toBeVisible();
}
await page
.getByRole('button', {
name: `Close Page version ${version}`,
exact: true,
})
.click();
await expect(
page.locator('iframe[title="Saved webpage version"]'),
).toHaveCount(0);
await page.reload();
}
expect(
reads.filter((version, index) => reads[index - 1] !== version),
).toEqual(['1', '2', '1']);
expect(reads.length).toBeLessThanOrEqual(8);
expect(liveRequests).toEqual([]);
missing = true;
await transcript.locator('[title="Page version 1"] > button').click();
await expect(
page.locator('[data-web-shell-saved-preview] [role="alert"]'),
).toContainText('missing or has changed');
await expect(
page.locator('iframe[title="Saved webpage version"]'),
).toHaveCount(0);
expect(liveRequests).toEqual([]);
});
+48
View File
@@ -1428,6 +1428,31 @@ const EN: Messages = {
'sideTask.description': 'View or create side tasks',
'sideTask.new': 'New',
'sideTask.create': 'New side task',
'webPreview.title': 'Web preview',
'webPreview.openHint': 'Preview a running web app',
'webPreview.address': 'Development URL',
'webPreview.open': 'Open',
'webPreview.refresh': 'Refresh preview',
'webPreview.desktop': 'Desktop width',
'webPreview.mobile': 'Mobile width (390 px)',
'webPreview.external': 'Open externally',
'webPreview.frame': 'Web preview frame',
'webPreview.saved': 'Saved version',
'webPreview.savedFrame': 'Saved webpage version',
'webPreview.savedUnavailable':
'This saved version is missing or has changed. It cannot be opened.',
'webPreview.savedLoadFailed': 'Could not load this saved version. Try again.',
'webPreview.page': 'Application preview',
'webPreview.empty':
'Start your development server, then enter its URL above.',
'webPreview.reachable':
'Use an address this browser can reach. For a remote server, use its accessible URL or an existing port forward.',
'webPreview.fallback':
'Blank page? Check the server or open externally. The address and refresh use the entry URL.',
'webPreview.live':
'Live page. Opening a link from an earlier message shows its current content, not a saved version.',
'webPreview.invalidUrl':
'Use a separate HTTP/HTTPS development address with a hostname or IPv4 address, without login credentials. Web Shell and daemon addresses cannot be previewed.',
'terminal.title': 'Terminal',
'terminal.open': 'Open a terminal',
'terminal.notice.exited': (v) =>
@@ -5029,6 +5054,29 @@ const ZH: Messages = {
'sideTask.description': '查看或新增侧边任务',
'sideTask.new': '新增',
'sideTask.create': '新建侧边任务',
'webPreview.title': '网页预览',
'webPreview.openHint': '预览正在运行的网页应用',
'webPreview.address': '开发地址',
'webPreview.open': '打开',
'webPreview.refresh': '刷新预览',
'webPreview.desktop': '桌面宽度',
'webPreview.mobile': '手机宽度(390 px)',
'webPreview.external': '外部打开',
'webPreview.frame': '网页预览容器',
'webPreview.saved': '当时保存的版本',
'webPreview.savedFrame': '历史网页版本',
'webPreview.savedUnavailable': '这份历史版本已丢失或被修改,无法打开。',
'webPreview.savedLoadFailed': '暂时无法加载这份历史版本,请重试。',
'webPreview.page': '应用预览',
'webPreview.empty': '启动开发服务器,然后在上方输入地址。',
'webPreview.reachable':
'使用当前浏览器可访问的地址。远程服务器请使用可访问的 URL 或已有的端口转发。',
'webPreview.fallback':
'页面空白?请检查服务器或外部打开。地址栏和刷新使用最初打开的地址。',
'webPreview.live':
'实时页面:从历史消息打开链接,也会显示当前内容,不是当时保存的版本。',
'webPreview.invalidUrl':
'请使用独立开发服务器的 HTTP/HTTPS 地址,主机名或 IPv4 均可,且不含登录凭据。不能预览 Web Shell 或 daemon 地址。',
'terminal.title': '终端',
'terminal.open': '打开终端',
'terminal.notice.exited': (v) => `进程已退出,退出码 ${v?.exitCode ?? '?'}`,
+1 -1
View File
@@ -330,7 +330,7 @@ export function StandaloneApp({ daemonToken }: { daemonToken?: string }) {
],
},
rightPanel: {
items: ['review', 'sideTask', 'terminal'],
items: ['review', 'sideTask', 'terminal', 'webPreview'],
},
environmentPanel: {
items: [
+18
View File
@@ -96,6 +96,24 @@ export default defineConfig(({ command }) => ({
},
server: {
cors: false,
// Mirrors buildWebShellCsp() in packages/cli/src/serve/web-shell-static.ts;
// dev intentionally permits same-origin ancestors instead of denying all.
headers: {
'Content-Security-Policy': [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
"img-src 'self' data: blob:",
"media-src 'self' data:",
"connect-src 'self'",
"worker-src 'self' blob:",
"base-uri 'none'",
'frame-src http: https: blob:',
"frame-ancestors 'self'",
].join('; '),
'Referrer-Policy': 'no-referrer',
},
port: 5173,
proxy: {
'/health': daemonProxy,