mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
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`。
|
||||
|
||||
本次有限增量没有开放设计问题。
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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("/api/users")</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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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&b=2"');
|
||||
expect(document).toContain('Preview "app" <script>');
|
||||
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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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 ?? '?'}`,
|
||||
|
||||
@@ -330,7 +330,7 @@ export function StandaloneApp({ daemonToken }: { daemonToken?: string }) {
|
||||
],
|
||||
},
|
||||
rightPanel: {
|
||||
items: ['review', 'sideTask', 'terminal'],
|
||||
items: ['review', 'sideTask', 'terminal', 'webPreview'],
|
||||
},
|
||||
environmentPanel: {
|
||||
items: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user