Skip to content

feat(web-shell): add PWA installability and Android development shell - #11722

Merged
wenshao merged 22 commits into
QwenLM:mainfrom
jabrailkhalil:feat/web-shell-pwa-installability
Sep 17, 2026
Merged

wenshao merged 22 commits into
QwenLM:mainfrom
jabrailkhalil:feat/web-shell-pwa-installability

Conversation

@jabrailkhalil

@jabrailkhalil jabrailkhalil commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

Adds installability metadata and a production service worker to the daemon-served Web Shell, including the published CLI package. Public installation resources revalidate on each request; content-addressed assets can be cached, while application HTML, authenticated traffic and event streams remain network-only. An unavailable daemon displays a retry page instead of a stale session.

Aligns the early browser guard and compatibility styles with Chrome/Edge/WebView 111+, Firefox 128+ and Safari/iOS 16.4+. Unsupported browsers use the existing themed startup-error surface. The Android development shell wraps the same Web Shell with validated origin navigation, native bootstrap and connection-error screens, and a pinned, buildable Android toolchain.

Why it's needed

The Web Shell should install from the actual released CLI, retain its authentication boundary during startup, and provide a readable failure when the browser or daemon cannot support a session. The Android portion establishes a small, testable host for the existing interface while the daemon's per-device credential design remains a prerequisite for a production mobile client.

Reviewer Test Plan

How to verify

  1. Start a packaged, token-protected daemon. Installation metadata and the worker must load without a bearer token, including GET/HEAD and case/trailing-slash variants, before and after runtime initialization. Protected API requests must still require authentication. Missing installation resources must return plain-text 404, never application HTML.
  2. Load the production Web Shell and open Add Workspace. Its dialog stays within the viewport. Unsupported engines should show one readable startup error rather than a blank or competing screen. A disconnected navigation should offer Retry; session HTML and authenticated responses must never be served from the worker cache.
  3. Change installation metadata or icons without changing the package version. They must revalidate. Content-hashed chunks can be reused, failed responses must not be cached, and activation must remove only obsolete Qwen shell caches.
  4. Build and launch the Android development shell using its README. Missing/invalid profiles and missing/outdated providers must show native guidance. With a provisioned local profile, same-origin links stay embedded, safe external links open another application, untrusted schemes are blocked, and a stopped daemon can recover through Retry.

Evidence (Before & After)

The baseline reproduced missing release assets, an undefined offline navigation response, stale cached installation metadata and an early guard that failed ES5 parsing. All five replay checks now pass. Independent testing of the actual bundled CLI passed 87 HTTP assertions, including explicitly instrumented cold/warm startup and deliberately missing-resource cases.

Local validation passed: root build, typecheck, bundle, package preparation and npm packing; 148 targeted unit tests with one Windows-only skip; lockfile parity and a fresh pinned-pnpm worktree installation. Both package-directory and root-directory Vite builds produced the required output paths. The shipped inline scripts parse as ES5 and the worker parses as a classic script. Android debug/release assembly, unit tests and lint passed; lint reports only four dependency-update warnings. A separate E2E comment records the commands, emulator scenarios and limits.

In the actual browser UI at a 720px viewport, Add Workspace rendered at 437.75px with a computed maximum height of 576px and the new viewport class attached.

Tested on

OS Status
macOS Not tested locally; CI required
Windows Build, package, targeted tests, bundled HTTP and browser UI passed
Linux Not tested locally; CI required
Android Android 14/API 34 x86_64 emulator, WebView 113; exercised scenarios passed

Environment

Windows, Node 22.21.1, npm 10.9.4, pinned pnpm 11.24.0, JDK 17, Android SDK 34 and Gradle 8.2.1. Tests used the bundled CLI and an isolated Android HTTP fixture; no request was sent to an external model.

Risk & Scope

  • Worker updates activate immediately. Cached assets improve loading, but an offline application/session is not provided.
  • Minimum browser versions describe the implementation contract; physical devices, the exact minimum engines/Android OS, Safari/Firefox runtime behavior, TLS chains and backup/restore were not locally validated.
  • Android is a development scaffold with one manually provisioned profile. Multiple profiles, Keystore-backed per-device credentials, native background streams/notifications, file chooser/download/permission/new-window callbacks and complete accessibility acceptance are Phase 2. The placeholder foreground service has been removed.
  • No daemon authentication protocol or core runtime architecture changes. Android is excluded from npm/pnpm workspace traversal and built separately.

The paired designs are synchronized: PWA English, PWA 中文, Android English, Android 中文.

Linked Issues

Related to #11704. This PR does not complete the production mobile-client requirements in that issue.

中文说明

此 PR 的改动

为 daemon 提供的 Web Shell 添加可安装元数据和生产环境 service worker,并将它们包含在发布的 CLI 包中。公开的安装资源每次请求都会重新验证;内容寻址的资源可以缓存,应用 HTML、认证流量和事件流则始终只通过网络获取。daemon 不可用时显示重试页面,不会显示过期会话。

将早期浏览器检查和兼容样式统一为 Chrome/Edge/WebView 111+、Firefox 128+ 和 Safari/iOS 16.4+。不受支持的浏览器使用现有的带主题启动错误界面。Android 开发外壳复用相同的 Web Shell,提供经过校验的同源导航、原生初始化和连接错误界面,以及固定版本、可构建的 Android 工具链。

为什么需要

Web Shell 应能够从实际发布的 CLI 安装,在启动期间保持认证边界,并在浏览器或 daemon 无法支持会话时提供可读的错误。Android 部分为现有界面建立一个小型、可测试的宿主;daemon 的设备级凭据设计仍是生产移动客户端的前置条件。

审阅者测试计划

如何验证

  1. 启动打包后的、受 token 保护的 daemon。在运行时初始化前后,安装元数据和 worker 都应无需 bearer token 即可获取,包括 GET/HEAD、大小写和尾部斜杠变体。受保护的 API 仍须认证。缺失的安装资源必须返回纯文本 404,不能返回应用 HTML。
  2. 加载生产 Web Shell 并打开 Add Workspace。对话框应保持在视口内。不支持的引擎应显示一个可读启动错误,而非空白或重复错误界面。断网导航应提供 Retry;worker 缓存绝不能返回会话 HTML 或认证响应。
  3. 在不更改包版本的情况下更改安装元数据或图标。它们必须重新验证。带内容哈希的资源可以复用,失败响应不能缓存,激活过程只能删除旧的 Qwen shell 缓存。
  4. 按 README 构建并启动 Android 开发外壳。缺失或无效的配置、缺失或过旧的 WebView provider 都应显示原生说明。配置本地 profile 后,同源链接留在 WebView 中,安全的外部链接交给其他应用,不可信协议被阻止,daemon 停止后可通过 Retry 恢复。

修复前后证据

基线复现了发布资源缺失、离线导航返回 undefined、安装元数据缓存过期,以及早期检查无法通过 ES5 解析的问题。五项回放检查现已全部通过。独立测试实际打包 CLI 的 87 项 HTTP 断言全部通过,包括明确记录时序的冷/热启动,以及故意移除资源的场景。

本地验证通过:根目录构建、类型检查、bundle、发布包准备和 npm 打包;148 项针对性单元测试,另有一项因 Windows 跳过;锁文件一致性,以及使用固定 pnpm 版本安装的全新 worktree。分别从包目录和根目录运行 Vite 都生成了所需路径。发布 HTML 的内联脚本可按 ES5 解析,worker 可按经典脚本解析。Android debug/release 构建、单元测试和 lint 通过;lint 仅有四项依赖版本更新提示。另附独立 E2E 评论记录命令、模拟器场景和验证边界。

实际浏览器界面中,720px 视口下的 Add Workspace 高度为 437.75px,计算得到的最大高度为 576px,且已应用新的视口样式类。

测试系统

系统 状态
macOS 未在本地测试,需 CI 验证
Windows 构建、打包、针对性测试、打包 HTTP 和浏览器界面验证通过
Linux 未在本地测试,需 CI 验证
Android Android 14/API 34 x86_64 模拟器、WebView 113;已执行场景通过

环境

Windows、Node 22.21.1、npm 10.9.4、固定 pnpm 11.24.0、JDK 17、Android SDK 34 和 Gradle 8.2.1。测试使用实际打包 CLI 和独立 Android HTTP fixture,没有向外部模型发送请求。

风险与范围

  • Worker 更新立即激活。资源缓存改善加载,但不提供离线应用或离线会话。
  • 浏览器最低版本是实现契约;真实设备、精确的最低引擎及 Android 系统版本、Safari/Firefox 运行行为、TLS 证书链和备份恢复没有进行本地验证。
  • Android 是只有一个手动配置 profile 的开发骨架。多 profile、基于 Keystore 的设备级凭据、原生后台事件流和通知、文件选择/下载/权限/新窗口回调,以及完整无障碍验收都属于第二阶段。占位前台服务已删除。
  • 不修改 daemon 认证协议或核心运行时架构。Android 已排除在 npm/pnpm workspace 遍历之外,单独构建。

双语设计文档保持同步:PWA 英文、PWA 中文、Android 英文、Android 中文。

关联 Issue

关联 #11704。本 PR 不代表该 issue 中的生产移动客户端要求已经完成。

@jabrailkhalil

jabrailkhalil commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor Author

E2E verification report

I tested this PWA contribution on two Windows PCs and on my physical Pixel 7. The focused automated runs below completed successfully: 492 test/check executions, including repeat validation on the second PC and a final rerun after rebasing. This is a count of successful executions, not unique tests or a claim that the full repository suite passed.

Focused check First Windows PC Second Windows PC Final rerun on submitted commit
Web Shell: worker, registration and boot behavior 62 passed 40 passed 40 passed
Daemon: public assets, cold start and authentication 61 passed 20 passed 20 passed
Package assets 39 passed, 1 existing skip 39 passed, 1 existing skip 39 passed, 1 existing skip
HTTP against the built daemon 36 passed 36 passed 36 passed
Desktop Chromium and Pixel 7 touch emulation 8 passed 8 passed 8 passed
Total successful executions 206 143 143

The unit-test selections differed between the two PCs. The totals above exclude the full repository preflight, package smoke checks and physical-device acceptance. The PWA source files were verified byte-for-byte after transfer. Fresh dependency installation, full build, CLI bundle, workspace/integration typecheck and changed-file formatting/lint passed on the second PC.

For the final rerun, I rebased onto current main and verified that all 21 PWA files remained identical. On submitted commit ccdd699, the full build, CLI bundle and repeated 99 unit tests, 36 HTTP checks and 8 browser cases all passed on the second PC. The HTTP and browser checks used the freshly rebuilt CLI; the browser run took 25.8 seconds with no retries. Lockfile and Playwright version-parity checks passed too.

Physical Pixel 7 / Android 16 / Chrome 144

I installed the Web Shell on my Pixel and verified the installed Chrome WebAPK through Android. I opened it from the launcher and confirmed standalone mode with no browser address bar. The installed context initially had no token, so I entered a synthetic test token through the Connect form and confirmed authenticated API access.

I then stopped the test daemon and reloaded the installed app. The service worker displayed the connection-unavailable page and Retry button. The app stayed in standalone mode, preserved the current session token and left Cache Storage empty. After restarting the daemon, I tapped Retry on the phone: the Web Shell returned without another login, the stored token authorized API access with HTTP 200, and a request without a token still returned HTTP 401.

This was physical-device acceptance with a real server interruption. Wi-Fi and wireless ADB remained enabled. I used a loopback origin through ADB reverse; this does not claim testing over cellular, a public HTTPS deployment, Wi-Fi loss or Android process death.

Built-daemon HTTP and browser behavior

  • I checked two independent cold starts, with the manifest or worker as the first HTTP request. Both returned the actual bundled asset. MIME, revalidation, nosniff, HEAD behavior, icon bytes and dimensions all matched expectations.
  • I verified that manifest identity, launch URL and scope remained / without credentials. Unauthenticated APIs returned JSON 401; valid synthetic credentials returned JSON 200. Foreign Origin and disallowed Host requests returned 403. Mutating and adjacent paths remained protected.
  • Chromium reported no manifest/installability errors. The worker activated and controlled the page after reload. Root and session navigation received the connection fallback on network failure; API requests and real HTTP errors retained their own behavior.
  • In both desktop and Pixel touch emulation, Retry restored the app and authentication. A fresh tab opened offline with a token fragment preserved that fragment until recovery, when the app consumed and removed it. Cache Storage remained empty throughout.

Independent npm archive smoke

I extracted the same npm archive into a fresh directory outside the repository, with no node_modules directories in its ancestor paths. It started successfully and served all four PWA assets with HTTP 200 and matching bytes. API requests with missing, incorrect and valid synthetic credentials returned 401, 401 and 200 respectively. The archive and asset hashes matched the earlier verification.

Full-suite and platform limits

The full Windows preflight failed with 152 assertions and five runner/unhandled errors. A separate full scripts run had six assertion failures. I reproduced the six scripts failures, four Web Shell locale failures and three CLI failures on unchanged baseline sources; I have not attributed every other failure. The baseline source reproductions shared dependencies/build outputs and were not a separately rebuilt baseline release. No failing assertion in the inspected logs targeted the new PWA behavior.

I used Node 22.21.1, isolated profiles and synthetic credentials. No model calls were made. Safari/iOS, macOS, native Android WebView, background notifications, process-death persistence and the complete release matrix are outside this verification. Upstream CI is the next gate.

中文说明

E2E 验证报告

我在两台 Windows 电脑和自己的真实 Pixel 7 上测试了这项 PWA 改动。以下定向自动化检查全部成功:共 492 次测试/检查执行,包括第二台电脑上的重复验证和 rebase 后的最终重跑。这统计的是成功执行次数,并非不同测试的数量,也不表示整个仓库测试全部通过。

定向检查 第一台 Windows 电脑 第二台 Windows 电脑 已提交版本的最终重跑
Web Shell:worker、注册和启动行为 62 通过 40 通过 40 通过
Daemon:公开资源、冷启动和认证 61 通过 20 通过 20 通过
打包资源 39 通过,1 个现有跳过 39 通过,1 个现有跳过 39 通过,1 个现有跳过
针对构建后 daemon 的 HTTP 检查 36 通过 36 通过 36 通过
桌面 Chromium 和 Pixel 7 触屏模拟 8 通过 8 通过 8 通过
成功执行总数 206 143 143

两台电脑选取的单元测试范围不同。上表不包含完整仓库 preflight、归档 smoke 检查或真实设备验收。转移后对 PWA 源文件进行了逐字节核对。第二台电脑上的全新依赖安装、完整构建、CLI bundle、workspace/集成测试类型检查以及改动文件的格式和 lint 检查均通过。

最终重跑前,我 rebase 到当前 main,并确认全部 21 个 PWA 文件保持一致。在已提交版本 ccdd699 上,第二台电脑的完整构建、CLI bundle、重复执行的 99 个单元测试、36 次 HTTP 检查和 8 个浏览器场景均通过。HTTP 和浏览器检查使用了刚重新构建的 CLI;浏览器运行耗时 25.8 秒,没有重试。lockfile 和 Playwright 版本一致性检查也通过。

真实 Pixel 7 / Android 16 / Chrome 144

我在自己的 Pixel 上安装了 Web Shell,并通过 Android 确认已安装的 Chrome WebAPK。我从 launcher 打开应用,确认其为无浏览器地址栏的独立模式。安装上下文最初没有 token,因此我通过 Connect 表单输入合成测试 token,并确认了认证 API 访问。

随后我停止测试 daemon 并重新加载已安装应用。service worker 显示连接不可用页面和重试按钮。应用保持独立模式,保留当前会话 token,Cache Storage 为空。重启 daemon 后,我在手机上点击重试:Web Shell 恢复且无需再次登录,保存的 token 访问 API 返回 HTTP 200,无 token 请求仍返回 HTTP 401。

这是真实设备上的实际服务器中断验收。Wi-Fi 和无线 ADB 一直开启。我通过 ADB reverse 使用 loopback origin;这并不表示测试了蜂窝网络、公网 HTTPS 部署、Wi-Fi 断开或 Android 进程终止。

构建后 daemon 的 HTTP 和浏览器行为

  • 我检查了两次独立冷启动,分别以 manifest 和 worker 作为首个 HTTP 请求。两者都返回真实打包资源。MIME、重新验证、nosniff、HEAD 行为、图标字节和尺寸均符合预期。
  • 我确认 manifest 的身份、启动 URL 和作用域始终为 /,不含凭据。未认证 API 返回 JSON 401;有效合成凭据返回 JSON 200。外部 Origin 和不允许的 Host 请求返回 403。修改类请求和相邻路径仍受保护。
  • Chromium 未报告 manifest/可安装性错误。worker 激活并在重新加载后控制页面。网络故障时,根页面和会话导航显示连接回退页面;API 请求和真实 HTTP 错误保持各自原有行为。
  • 在桌面和 Pixel 触屏模拟中,重试恢复了应用及认证。离线时带 token fragment 打开的新标签页一直保留 fragment,直到恢复后由应用读取并移除。整个过程中 Cache Storage 始终为空。

独立 npm 归档 smoke

我将同一个 npm 归档解压到仓库外的全新目录,其祖先路径中没有 node_modules。归档成功启动,四个 PWA 资源均返回 HTTP 200,字节内容一致。缺失、错误和有效合成凭据访问 API 分别返回 401、401 和 200。归档及资源哈希与先前验证一致。

完整测试和平台限制

完整 Windows preflight 有 152 个断言失败和五个运行器/未处理错误。另一次完整 scripts 测试有六个断言失败。我在未修改基线源码上复现了这六个 scripts 失败、四个 Web Shell locale 失败和三个 CLI 失败;尚未归因其余所有失败。基线源码复现共享依赖/构建输出,并非单独重新构建的基线发行包。检查到的日志中,没有失败断言针对新增 PWA 行为。

我使用 Node 22.21.1、隔离配置和合成凭据,没有调用模型。Safari/iOS、macOS、原生 Android WebView、后台通知、进程终止后的持久化和完整发布矩阵不在此次验证范围内。上游 CI 是下一步检查。

@jabrailkhalil
jabrailkhalil marked this pull request as ready for review September 12, 2026 14:32
@jabrailkhalil

Copy link
Copy Markdown
Contributor Author

@wenshao, I've marked the focused PWA first step from #11704 ready for review. My physical Pixel 7 checks passed, including installed launch, authentication, daemon interruption and Retry recovery. The detailed test report covers 492 successful focused executions, including reruns.

Could you approve the pending workflow runs for this fork so upstream CI can run? The full Windows preflight limitations are documented in the PR.

中文说明

我已将 #11704 中约定的独立 PWA 首步标记为可评审。真实 Pixel 7 检查均通过,包括已安装应用的启动、认证、daemon 中断及重试恢复。详细测试报告 记录了 492 次成功的定向执行,包括重复运行。

能否批准此 fork 待运行的 workflow,以便上游 CI 执行?完整 Windows preflight 的限制已在 PR 中说明。

@jabrailkhalil

Copy link
Copy Markdown
Contributor Author

Additional physical-device acceptance: OnePlus 15

I tested the same published commit ccdd699 on my physical OnePlus 15 (CPH2745), running Android 16 (build CPH2745_16.0.10.500(EX01)) and Chrome 153.0.8010.36. The complete installed-PWA acceptance passed.

Check Observed result
Installation Installed through Chrome's normal Install flow; Android confirmed the new WebAPK with the Qwen Code name and icon.
Launcher and standalone launch Opened the newly installed app from the launcher; standalone mode was true, with no browser address bar.
Authentication The installed context started without a token. I entered a synthetic token through the Connect form; API requests returned 200 with it and 401 without it.
Actual server interruption I stopped only the isolated test daemon. API requests failed with network errors, and reloading the installed app showed Connection unavailable and the Retry button.
Recovery After restarting the daemon, I tapped Retry on the phone. The app recovered without another login; API requests again returned 200 with the stored token and 401 without it.
Session state The synthetic token survived the server interruption. The app stayed in standalone mode and Cache Storage remained empty.

This adds a second physical Android device alongside the Pixel 7 / Chrome 144 acceptance already reported. The 492 automated executions remain unchanged; I am reporting these physical-device checks separately.

I used an isolated loopback origin through ADB reverse, kept Wi-Fi enabled and made no model calls. This verifies a real server outage and recovery; it does not cover cellular/public HTTPS deployment, Wi-Fi loss, Android process death or background notifications. Product source and the submitted commit are unchanged.

中文说明

补充真实设备验收:OnePlus 15

我在自己的真实 OnePlus 15(CPH2745)上测试了同一个已发布提交 ccdd699,系统为 Android 16(构建版本 CPH2745_16.0.10.500(EX01)),Chrome 为 153.0.8010.36。已安装 PWA 的完整验收通过。

检查 实际结果
安装 通过 Chrome 正常安装流程完成;Android 确认了新的 WebAPK,名称和图标为 Qwen Code。
Launcher 和独立模式启动 从 launcher 打开新安装的应用;独立模式为 true,没有浏览器地址栏。
认证 安装上下文最初没有 token。我通过 Connect 表单输入合成 token;带 token 的 API 请求返回 200,不带则返回 401。
实际服务器中断 我只停止了隔离的测试 daemon。API 请求出现网络错误,重新加载已安装应用后显示 Connection unavailable 和重试按钮。
恢复 重启 daemon 后,我在手机上点击重试。应用恢复且无需再次登录;使用保存 token 的 API 请求再次返回 200,无 token 则返回 401。
会话状态 服务器中断期间合成 token 保持不变。应用始终处于独立模式,Cache Storage 保持为空。

这是继已报告的 Pixel 7 / Chrome 144 验收之后,第二台真实 Android 设备。492 次自动化执行的数量保持不变;这些真实设备检查单独报告。

我通过 ADB reverse 使用隔离的 loopback origin,保持 Wi-Fi 开启,没有调用模型。这验证了真实服务器中断及恢复;不涵盖蜂窝网络/公网 HTTPS 部署、Wi-Fi 断开、Android 进程终止或后台通知。产品源码和已提交的 commit 均未改变。

@jabrailkhalil

Copy link
Copy Markdown
Contributor Author

I fixed item 1 from the code review in bf29291. An old or partial Web Shell build now follows the existing warning-and-skip path before any UI files are copied, instead of throwing ENOENT and leaving a partial destination. Complete builds still copy all PWA assets, and release preparation still rejects bundles missing required files.

I reproduced the failure before changing the guard. All three new regression cases failed on the previous code and now pass: missing manifest, missing worker, and missing both. The full packaging suite passed 42 tests, with one existing skip. The original isolated reproduction also passed all four cases after the fix, including absent-output and complete-output controls. Full build, CLI bundle, workspace/integration typecheck, changed-file formatting and lint all passed on Windows / Node 22.21.1.

For item 2, I viewed both PNGs and confirmed their dimensions are 192×192 and 512×512. They are byte-for-byte copies of the existing desktop-shell Android mipmap-xxxhdpi/ic_launcher.png and desktop icon.png, respectively. For item 3, dynamic installation branding remains deferred as documented.

The earlier 492 automated executions and physical Pixel 7 / OnePlus 15 acceptance remain reported separately; I have not added this follow-up's checks to that total or claimed another device run. This commit changes only the packaging guard and its tests; the PWA runtime and assets are unchanged. Upstream CI and the remaining product decisions still need maintainer review.

中文说明

我在 bf29291 中修复了代码评审的第 1 项。旧版或不完整的 Web Shell 构建现在会在复制任何 UI 文件之前进入现有的警告并跳过分支,不再抛出 ENOENT 或留下部分目标文件。完整构建仍会复制所有 PWA 资源,发布准备步骤仍会拒绝缺少必需资源的包。

我先复现了问题再修改守卫条件。三个新增回归场景在旧代码上全部失败,修复后全部通过:缺少 manifest、缺少 worker,以及两者都缺少。完整打包测试套件 42 项通过,1 项为既有跳过。原始隔离复现的四个场景也全部通过,包括无构建产物和完整构建产物的对照。Windows / Node 22.21.1 上的完整构建、CLI bundle、workspace/集成测试类型检查,以及改动文件的格式和 lint 检查均通过。

关于第 2 项,我查看了两个 PNG,确认尺寸分别为 192×192 和 512×512。它们分别与现有 desktop-shell Android mipmap-xxxhdpi/ic_launcher.png 和桌面 icon.png 逐字节一致。关于第 3 项,动态安装品牌仍按设计文档推迟处理。

此前的 492 次自动化执行和真实 Pixel 7 / OnePlus 15 验收仍单独报告;我没有把本次检查加入该总数,也没有声称重新进行了设备验收。本提交只修改打包守卫及其测试,PWA 运行时和资源保持不变。上游 CI 和其余产品决策仍需维护者评审。

@wenshao

wenshao commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification: local real-stack run on macOS

I built this PR locally and drove it end to end against a real qwen serve daemon and real Google Chrome. macOS is listed as "Not tested" in the PR, so that is the gap I closed. Everything the PR claims about installability, routing, recovery and packaging reproduced. I found no correctness or security blocker. Four observations are noted at the end — three test-coverage gaps and one cosmetic nit; none of them block merge.

What was under test

PR head bf29291
Merged with main @ 54aa668 (13 commits ahead of the PR base; merge is clean)
Tree actually exercised merge commit 5fc094e — so the report describes what lands, not the branch in isolation
Artifact npm run build && npm run bundle → dist/cli.js, started as node dist/cli.js serve --port … --token … --workspace …. The daemon banner confirms Web Shell UI served from …/dist/web-shell, i.e. the bundled UI, not a dev server.
Control arm main @ 54aa668, same pipeline, same port, same browser profile shape
Environment macOS 26.6.2 (arm64), Node 24.18.1, Google Chrome 152.0.7977.83, loopback HTTP (secure context), synthetic daemon token, a local OpenAI-compatible stub for the one real turn. No model calls needed for the PWA behaviour itself.

1. Installability in a real browser

Chrome fires beforeinstallprompt with platforms: ["web"] on first load, and Page.getAppManifest returns errors: []. The two icons are genuinely 192×192 and 512×512 (RGBA PNG, read from the PNG IHDR, not from the manifest's own claim), and their bytes in dist/web-shell/assets/ are identical to the committed sources.

Chrome install dialog

Chrome's own install dialog, reached from the page's beforeinstallprompt. Name and icon come from the manifest; the dialog chrome is Chinese because that is this machine's system language. I dismissed it rather than installing, so nothing was left behind.

Same daemon port, same URL, main vs merged — the affordance appears only after this PR:

Omnibox before/after

On main, the same probe records beforeinstallprompt: [], getRegistrations(): [], and no <link rel="manifest"> in the document.

2. HTTP surface and the auth boundary

Live probe against the bundled daemon. sha is the sha256 prefix of the response body.

Request merged main
GET /manifest.webmanifest 200 application/manifest+json, Cache-Control: no-cache, nosniff, sha f52aa4f0… (byte-identical to the repo file) 401
GET /service-worker.js 200 text/javascript, no-cache, nosniff, sha ccd9432c… (byte-identical) 401
HEAD on both 200 401
GET /MANIFEST.WEBMANIFEST/ 200 (Express case-insensitivity mirrored by the pre-auth predicate's toLowerCase()) 401
GET /service-worker.js/extra, …/manifest.webmanifest.bak 401 401
POST on either path 401 401
either path with Origin: https://evil.example 403 403
GET /capabilities (no token) 401 — unchanged 401
GET /capabilities (with token) 200 200
GET /assets/pwa-icon-{192,512}-v1.png 200 image/png, public, max-age=31536000, immutable 404

DNS-rebinding wall still applies to the new routes (this is not covered by the PR's tests, so I checked it explicitly with raw requests carrying a forged Host):

Host: header /manifest.webmanifest /service-worker.js /capabilities /
127.0.0.1:41722 200 200 401 200
localhost:41722 200 200 401 200
evil.example 403 403 403 403
attacker.test:41722 403 403 403 403

The pre-auth widening exempts these two paths from bearer auth only; Host and Origin walls are untouched.

3. Worker behaviour on a live session

With the worker controlling the page (navigator.serviceWorker.controller set after one reload):

  • A real streaming turn works. Typed into the composer, the reply streamed back through the controlled page. GET /session/<id>/events shows fromServiceWorker: false — the worker falls through and does not touch SSE.
  • Session deep links still authenticate. GET /session/<id> as a top-level navigation returned 200 with the response served through the worker (fromServiceWorker: true), and the daemon log confirms it server-side: route=GET /session/1716d5a5-… status=200. This is the load-bearing check for the worker's pass-through: the daemon only serves a deep link pre-auth when isDocumentNavigation() is true, so a worker that re-issued the request as a plain fetch would have turned every session refresh into a 401. It does not.
  • No Cache Storage. caches.keys() is [] at every phase, including after the offline page and after recovery. navigator.storage.estimate() attributes the entire delta to serviceWorkerRegistrations: 2238 bytes (the script itself) plus the pre-existing IndexedDB.
  • Server errors are preserved. fetch('/capabilities') with a wrong bearer returns a real 401 with the server's body, not a worker-synthesised page.
  • Navigation cost is not measurable on loopback. Paired samples (12 navigations each) gave median responseEnd of 3.4–4.1 ms with and without a controlling worker across three runs, with the sign of the difference flipping between runs.

4. Connection loss and recovery

I stopped the daemon for real (SIGKILL, then polled until the port refused connections) rather than emulating offline.

Connection-loss page, light and dark

  • / and /session/<id> → 503 from the worker, bilingual retry page. On main, the same navigations give Chrome's ERR_CONNECTION_REFUSED page.
  • /capabilities and /assets/pwa-icon-192-v1.png → untouched, real connection error. The worker claims only root and exact session navigations.
  • Fresh tab, token only in the fragment (the sharpest version of the claim, because a new tab starts with empty sessionStorage): opened /#token=… while the daemon was down → retry page, URL keeps #token=…, sessionStorage empty. Restarted the daemon, clicked Try again / 重试 → app came back authenticated, sessionStorage now holds qwen-daemon-token, and no token prompt appeared.

Recovered after retry

5. What happens to someone who rolls qwen back

Not in the PR's test plan, and worth knowing before this ships, so I measured it: register the worker against the PR daemon, then serve the same origin and port from a pre-PR daemon.

  • A pre-PR daemon answers GET /service-worker.js with 401 JSON, not 404 and not index.html. Because it is not a 404, the browser keeps the registration rather than dropping it.
  • The rolled-back app still loads normally (200, passed through the stale worker); manifestLink is null, confirming the old index.html.
  • With the rolled-back daemon stopped, the stale worker still shows the Qwen retry page.

Benign in every direction — the worker only substitutes on network failure — and the docs this PR adds already point users at browser site settings to remove it. Recording it here so the behaviour is not a surprise later.

6. Packaging, against the real build rather than fixtures

  • dist/web-shell/ from npm run bundle contains index.html, manifest.webmanifest, service-worker.js and both icons; the manifest and worker are byte-identical to packages/web-shell/client/public/.
  • Deleting manifest.webmanifest from packages/web-shell/dist and re-running the real scripts/copy_bundle_assets.js: warns Warning: Web Shell assets not found … and leaves no dist/web-shell/ at all — the bf29291 fix behaves as described on a real tree, not just on the fixture root.
  • Real scripts/prepare-package.js with the manifest missing from the bundle: Error: Required package artifact not found: …/dist/web-shell/manifest.webmanifest. With the complete bundle it succeeds.

7. Test suites on macOS

Suite Result
service-worker + register-service-worker + boot-watchdog 40/40 passed
Full packages/web-shell suite 312 files, 7667/7667 passed
run-qwen-serve.test.ts 426/426 passed
scripts/tests/package-assets.test.js 43/43 passed
server.test.ts + self-origin.test.ts + web-shell-static.test.ts 1305/1306 on the first run; re-runs green — 1303/1303 (server + self-origin) and 1279/1279 (server.test.ts alone)

The single first-run failure was 400 when branch name is empty (expected 400, got 404, server.test.ts:14629) — unrelated to this PR. Control: the same full-file run on main also fails one test in that file, a different one (expected 403 to be 200, server.test.ts:5917), and both pass in isolation. That file has pre-existing order-dependent flakiness on macOS on both arms; it is not a regression from this PR.

8. Mutation matrix — do the new tests bite?

I flipped one thing at a time in the PR's own production code and re-ran the suite that owns it.

Mutation Suite Outcome
drop Cache-Control: no-cache from the two routes cli serve killed (2 tests)
break X-Content-Type-Options cli serve killed (2)
widen the worker's session pattern to /^\/session\//i web-shell killed
drop request.destination !== 'document' web-shell killed
drop the window.self !== window.top frame gate web-shell killed
drop the isSecureContext gate web-shell killed
drop the manifest/worker existence check in copy_bundle_assets.js scripts killed (3)
drop the two release-gate entries in prepare-package.js scripts killed (2)
remove registerServiceWorker() from main.tsx full web-shell (7667) survived
remove <link rel="manifest"> from index.html full web-shell (7667) survived
drop the $ anchor from the new pre-auth pattern cli serve + run-qwen-serve survived
drop request.mode !== 'navigate' web-shell survived

9. Non-blocking notes

N1 — the wiring is the untested part. Deleting the registerServiceWorker() call from main.tsx, or the <link rel="manifest"> from index.html, passes all 7,667 web-shell tests. Either deletion silently removes the feature: no worker, or no installability. The unit tests cover both pieces thoroughly in isolation; nothing asserts they are connected. The CI visual-preview bot reports the same blind spot from the other side ("this PR edits 1 render-shaping file … no scenario renders this UI"). One assertion over the built dist/index.html (contains the manifest link) plus one over the built bundle (references service-worker.js) would close it. Same class: the manifest's own contents have no test — I changed "sizes" to 191x191 and the full suite stayed green (real Chrome did not mind either: still errors: [], still installable, since it reads the actual image).

N2 — two assertions that look like they pin the pre-auth anchor do not. server.test.ts's /service-worker.js/extra → 401 and self-origin.test.ts's ['get','/service-worker.js/extra',401] both stay green when the $ is deleted from /^\/(?:manifest\.webmanifest|service-worker\.js)\/?$/u. I chased this down rather than leaving it as "untested": in both apps the 401 comes from a route miss falling through to bearerAuth, not from the predicate. I also wrote a probe in the cold-start window — the one place isPreAuthWebShellRequest actually decides auth — and it returns 401 with the anchor deleted too, because the delegating app has nothing to serve at that path. So the anchor is correct and currently has no reachable behavioural consequence; there is nothing to fix. Worth knowing only because those two rows will not warn you if the route surface later changes.

N3 — mode !== 'navigate' and destination !== 'document' are redundant in every test row. Deleting the destination gate is caught; deleting the mode gate is not, because no row varies one without the other. Defence in depth is fine here; a single row with { mode: 'cors', destination: 'document' } would pin both independently.

N4 — cosmetic. The connection-loss page's own default-src 'none' blocks its implicit /favicon.ico request, so the retry page logs one CSP violation in the console. No functional effect. (The page itself renders correctly in both colour schemes — the screenshot above shows light and dark.)

What I did not verify

Android/iOS, Safari, real HTTPS with a certificate (I used loopback, which is a secure context), Web Push, background execution, and the --allow-origin chrome-extension://… framing path. Those stay as the PR scopes them.


中文说明

维护者验证:macOS 本地真实环境实测

我在本地构建了这个 PR,并用真实的 qwen serve daemon + 真实 Google Chrome 做了端到端验证。PR 里 macOS 标的是「未测试」,我补的就是这一块。PR 关于可安装性、路由、恢复和打包的声明全部复现,没有发现正确性或安全阻断问题。 末尾列了四条说明 —— 三个测试覆盖缺口和一条观感问题,都不阻塞合并。

被测对象

PR head bf29291
与之合并 main @ 54aa668(PR 落后 13 个提交;合并干净)
实际验证的树 合并提交 5fc094e —— 所以本报告描述的是「合并后会发生什么」,不是孤立分支
产物 npm run build && npm run bundle → dist/cli.js,以 node dist/cli.js serve … 启动。daemon 启动横幅确认 Web Shell UI served from …/dist/web-shell,即打包后的 UI,不是 dev server。
对照臂 main @ 54aa668,同一套流水线、同一端口、同样的浏览器配置
环境 macOS 26.6.2 (arm64)、Node 24.18.1、Chrome 152.0.7977.83、loopback HTTP(安全上下文)、合成 daemon token、一个本地 OpenAI 兼容假服务用于跑一轮真实对话。PWA 行为本身不需要模型调用。

1. 真实浏览器中的可安装性

Chrome 首次加载即触发 beforeinstallprompt(platforms: ["web"]),Page.getAppManifest 返回 errors: []。两个图标确实是 192×192 和 512×512(直接读 PNG IHDR,不看 manifest 自己的声明),且 dist/web-shell/assets/ 里的字节与提交的源文件完全一致。截图为 Chrome 自己的安装对话框(对话框是中文因为本机系统语言是中文);我只截图未安装,没有残留。

同一端口、同一 URL,main 对比合并树:安装入口只在本 PR 之后出现。main 上探针记录为 beforeinstallprompt: []、getRegistrations(): [],文档里也没有 <link rel="manifest">。

2. HTTP 面与认证边界

  • GET /manifest.webmanifest → 200,application/manifest+json、no-cache、nosniff,字节与仓库文件完全一致(sha f52aa4f0…);main 上是 401。
  • GET /service-worker.js → 200,text/javascript,字节一致(sha ccd9432c…);main 上是 401。
  • /service-worker.js/extra、/manifest.webmanifest.bak、两者的 POST → 401;带 Origin: https://evil.example → 403。
  • GET /capabilities 无 token 仍是 401,带 token 200 —— 未被放宽。
  • 两个图标 200,public, max-age=31536000, immutable;main 上 404。

DNS rebinding 防护对新路由仍然生效(PR 的测试没有覆盖这点,我用裸请求伪造 Host 专门验证):伪造 Host: evil.example / attacker.test:41722 时,/manifest.webmanifest 与 /service-worker.js 都是 403,和其它路由一样。pre-auth 放宽只豁免 bearer 认证,Host 和 Origin 两道墙原样保留。

3. 真实会话下的 worker 行为

  • 真实流式对话正常。 在受 worker 控制的页面里输入并提交,回复流式返回;GET /session/<id>/events 的 fromServiceWorker: false,worker 完全不碰 SSE。
  • 会话深链接仍能通过认证。 顶层导航 GET /session/<id> 返回 200(经 worker 提供,fromServiceWorker: true),daemon 侧日志同样确认:route=GET /session/1716d5a5-… status=200。这是 worker 透传最关键的一项:daemon 只在 isDocumentNavigation() 为真时才 pre-auth 提供深链接,如果 worker 把请求重发成普通 fetch,每次会话刷新都会变成 401。实测没有。
  • 没有 Cache Storage。 所有阶段 caches.keys() 均为 [],包括断线页面之后和恢复之后。navigator.storage.estimate() 的增量全部归于 serviceWorkerRegistrations: 2238 字节(脚本本身)。
  • 服务器错误被保留。 用错误 token 请求 /capabilities 返回真实 401 和服务器响应体,不是 worker 合成的页面。
  • loopback 上测不出导航开销。 三次配对测量(每次各 12 轮导航),有/无 worker 控制的 responseEnd 中位数都在 3.4–4.1 ms,且差值符号在不同轮次间来回翻转。

4. 断线与恢复

我是真的杀掉 daemon(SIGKILL 后轮询到端口拒绝连接),不是模拟断网。

  • / 和 /session/<id> → worker 返回 503 双语重试页;main 上同样的导航是 Chrome 的 ERR_CONNECTION_REFUSED 页。
  • /capabilities 和 /assets/pwa-icon-192-v1.png → 不受影响,真实连接错误。worker 只接管根路径和精确会话导航。
  • 新标签页、token 只存在于 fragment 中(这是该声明最尖锐的版本,因为新标签页的 sessionStorage 是空的):daemon 停止时打开 /#token=… → 重试页,URL 保留 #token=…,sessionStorage 为空;重启 daemon 后点「Try again / 重试」→ 应用恢复且已认证,sessionStorage 出现 qwen-daemon-token,全程没有再次要求输入 token。

5. 回滚 qwen 的用户会遇到什么

PR 的测试计划里没有这一项,但上线前值得知道,所以我实测了:先在 PR daemon 上注册 worker,再用同一 origin 同一端口跑改动前的 daemon。

  • 改动前的 daemon 对 GET /service-worker.js 返回 401 JSON,不是 404,也不是 index.html。因为不是 404,浏览器会保留注册而不是注销。
  • 回滚后的应用照常加载(200,经陈旧 worker 透传);manifestLink 为 null,确认用的是旧 index.html。
  • 把回滚后的 daemon 也停掉,陈旧 worker 仍会显示 Qwen 的重试页。

各个方向都是良性的 —— worker 只在网络失败时才替换响应 —— 而且本 PR 新增的文档已经指引用户用浏览器站点设置移除它。记录在此只是为了避免以后成为意外。

6. 打包(对真实构建产物,不是 fixture)

  • npm run bundle 产出的 dist/web-shell/ 含 index.html、manifest.webmanifest、service-worker.js 和两个图标;manifest 与 worker 和 packages/web-shell/client/public/ 逐字节一致。
  • 从 packages/web-shell/dist 删掉 manifest.webmanifest 后跑真实的 scripts/copy_bundle_assets.js:告警 Warning: Web Shell assets not found …,且完全不生成 dist/web-shell/ —— bf29291 的修复在真实树上也是这个行为,不只是 fixture root。
  • 真实 scripts/prepare-package.js 在 bundle 缺 manifest 时报 Error: Required package artifact not found: …/dist/web-shell/manifest.webmanifest;完整 bundle 则成功。

7. macOS 上的测试套件

套件 结果
service-worker + register-service-worker + boot-watchdog 40/40 通过
packages/web-shell 全量 312 个文件,7667/7667 通过
run-qwen-serve.test.ts 426/426 通过
scripts/tests/package-assets.test.js 43/43 通过
server.test.ts + self-origin.test.ts + web-shell-static.test.ts 首轮 1305/1306;重跑全绿 —— 1303/1303(server + self-origin)、1279/1279(单跑 server.test.ts)

首轮唯一失败是 400 when branch name is empty(期望 400 实得 404,server.test.ts:14629),与本 PR 无关。对照:在 main 上跑同一个文件的全量也会失败一条,但是另一条(expected 403 to be 200,server.test.ts:5917),且两条单独跑都通过。该文件在 macOS 上两臂都有既有的顺序相关抖动,不是本 PR 的回归。

8. 变异矩阵 —— 新增测试有没有辨别力

每次只改一处生产代码,再跑归属它的套件:

变异 结果
去掉两个新路由的 Cache-Control: no-cache 被杀(2 条)
破坏 X-Content-Type-Options 被杀(2 条)
把 worker 的会话匹配放宽为 /^\/session\//i 被杀
去掉 request.destination !== 'document' 被杀
去掉 window.self !== window.top 框架门 被杀
去掉 isSecureContext 门 被杀
去掉 copy_bundle_assets.js 的 manifest/worker 存在性检查 被杀(3 条)
去掉 prepare-package.js 的两条发布门条目 被杀(2 条)
从 main.tsx 删掉 registerServiceWorker() 调用 存活(7667 条全绿)
从 index.html 删掉 <link rel="manifest"> 存活(7667 条全绿)
去掉新 pre-auth 正则的 $ 锚 存活
去掉 request.mode !== 'navigate' 存活

9. 不阻塞的说明

N1 —— 没被测到的恰恰是「接线」。 删掉 main.tsx 里的 registerServiceWorker() 调用,或删掉 index.html 里的 <link rel="manifest">,7667 条 web-shell 测试全绿。任何一处删除都会静默移除该功能:要么没有 worker,要么不可安装。单测把这两块孤立地覆盖得很充分,但没有任何断言确认它们被接上了。CI 的视觉预览 bot 从另一侧报告了同一个盲区(「本 PR 修改了 1 个影响渲染的文件……没有场景渲染这块 UI」)。在构建产物 dist/index.html 上加一条断言(包含 manifest 链接)+ 在构建 bundle 上加一条(引用 service-worker.js)即可闭合。同类:manifest 自身内容零测试 —— 我把 "sizes" 改成 191x191,全量套件仍然全绿(真实 Chrome 也不在意:仍然 errors: []、仍可安装,因为它读的是图片实际尺寸)。

N2 —— 两条看似钉住 pre-auth 锚点的断言其实没有。 把 /^\/(?:manifest\.webmanifest|service-worker\.js)\/?$/u 的 $ 删掉后,server.test.ts 的 /service-worker.js/extra → 401 和 self-origin.test.ts 的 ['get','/service-worker.js/extra',401] 都仍然绿。我没有停在「未测试」这个结论上,而是追到底:这两个 app 里的 401 来自路由未命中后落到 bearerAuth,不是来自这个判定式。我还在冷启动窗口(isPreAuthWebShellRequest 唯一真正决定认证的地方)写了探针,删掉锚点后同样返回 401,因为委派 app 在那个路径上根本没有可服务的东西。所以 锚点是对的,且目前没有可达的行为后果,没有需要修的东西。记录在此只是提醒:若将来路由面变化,这两行不会替你报警。

N3 —— mode !== 'navigate' 与 destination !== 'document' 在每一条测试行里都是冗余的。 删掉 destination 门会被抓,删掉 mode 门不会,因为没有任何一行只改其中一个。这里做纵深防御没问题;补一行 { mode: 'cors', destination: 'document' } 就能把两道门各自钉住。

N4 —— 观感问题。 断线页自己的 default-src 'none' 挡掉了它隐式发起的 /favicon.ico 请求,所以重试页会在 console 打一条 CSP 违规。无功能影响。(页面本身在浅色和深色下都渲染正确,见上面的截图。)

未验证的部分

Android/iOS、Safari、带证书的真实 HTTPS(我用的是 loopback,本身就是安全上下文)、Web Push、后台执行,以及 --allow-origin chrome-extension://… 的框架路径。这些仍按 PR 声明的范围留在范围外。

@wenshao

wenshao commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

CI is red — but not because of anything in this diff

One lane is failing: Lint & Static (ubuntu-latest, Node 22.x). It died after 15 s at step 8, Check lint gate freshness, before any linter ran — ESLint, Prettier, actionlint, shellcheck, yamllint, the schema checks and the helper tests all report skipped, so nothing in your code has been judged yet.

The step's own message:

The lint gate changed on 'main' after this branch last incorporated it:

  - eslint.config.js: 1e768979c5a6 chore(pnpm): gate pnpm-lock on package-lock
    and declare hoisted imports (#11625) (2026-09-12)

This lane checks out the branch head alone, so its green proves the branch
passes the gate AS THE BRANCH DEFINES IT — with the files above, that gate
is stale. Merge or rebase 'main' into this branch and push to
re-validate under the current gate.

This gate exists because a branch carrying an old eslint.config.js / .prettierrc.json / scripts/lint.js / ci.yml can go green against its own stale rules and then redden main after merge — exactly what happened when #11117 flipped Prettier --write → --check. Your branch is 13 commits behind main, and #11625 touched eslint.config.js in that window, so the gate fired.

Fix

Merge main into feat/web-shell-pwa-installability and push. Please merge rather than rebase — a force-push detaches the existing inline review threads, and the bot squashes at merge time anyway, so a merge commit costs nothing.

Pre-flight: the merge should clear it

I already had the prospective merged tree built locally for the verification report above — 5fc094e = this PR's bf29291 merged into main@54aa668, which contains 1e76897. I ran the two gate commands the lane runs, against that tree:

Command Result
node scripts/lint.js --eslint (eslint . --ext .ts,.tsx --max-warnings 0 + eslint integration-tests) exit 0
node scripts/lint.js --prettier (prettier --experimental-cli --check .) exit 0 — "All matched files use Prettier code style!"

The merge is clean and the new lint gate has nothing to say about this PR's files, so the lane should go green once the branch takes main.

Everything else is already green

Test (ubuntu-latest), Integration Tests (no-AK, No Sandbox), web-shell E2E Smoke, Serve A/B, Capture web-shell visuals, Desktop Shell (ubuntu + windows), Real daemon E2E, the whole Java matrix, OpenTUI no-flicker gate and TUI parity snapshots all passed. Note that Test (macos-latest) and Test (windows-latest) are skipped by the CI profile on this PR — which is part of why I ran the suites on macOS locally.

Pushing the merge will kick off a fresh review-pr run; that is expected, the previous one already completed.

中文说明

CI 红了,但不是这个 diff 的问题

只有一个 lane 失败:Lint & Static (ubuntu-latest, Node 22.x)。它在第 8 步 Check lint gate freshness 上 15 秒就挂了,在任何 linter 运行之前 —— ESLint、Prettier、actionlint、shellcheck、yamllint、schema 检查和 helper 测试全部显示 skipped,也就是说你的代码根本还没被评判过。

该步骤自己的报错:

The lint gate changed on 'main' after this branch last incorporated it:

  - eslint.config.js: 1e768979c5a6 chore(pnpm): gate pnpm-lock on package-lock
    and declare hoisted imports (#11625) (2026-09-12)

This lane checks out the branch head alone, so its green proves the branch
passes the gate AS THE BRANCH DEFINES IT — with the files above, that gate
is stale. Merge or rebase 'main' into this branch and push to
re-validate under the current gate.

这道门存在的原因是:带着旧 eslint.config.js / .prettierrc.json / scripts/lint.js / ci.yml 的分支,可能按自己那套过时规则跑绿,合入后却把 main 打红 —— #11117 把 Prettier 从 --write 改成 --check 时就发生过这种事。你的分支落后 main 13 个提交,而 #11625 正好在这个窗口里改了 eslint.config.js,所以门触发了。

修复方式

把 main 合并进 feat/web-shell-pwa-installability 然后推送。请用 merge 而不是 rebase —— force-push 会让已有的行内评审线程失效,而机器人合并时本来就是 squash,所以多一个 merge commit 没有代价。

预检:合并之后应该能过

我为上面那份验证报告本来就在本地构建了「合并后」的树 —— 5fc094e = 本 PR 的 bf29291 合并 main@54aa668(其中包含 1e76897)。我在那棵树上跑了这个 lane 使用的两条门禁命令:

命令 结果
node scripts/lint.js --eslint(eslint . --ext .ts,.tsx --max-warnings 0 + eslint integration-tests) exit 0
node scripts/lint.js --prettier(prettier --experimental-cli --check .) exit 0 —— "All matched files use Prettier code style!"

合并是干净的,新的 lint 门对本 PR 的文件也没有意见,所以分支取了 main 之后这个 lane 应该会变绿。

其余全绿

Test (ubuntu-latest)、Integration Tests (no-AK, No Sandbox)、web-shell E2E Smoke、Serve A/B、Capture web-shell visuals、Desktop Shell(ubuntu + windows)、Real daemon E2E、整个 Java 矩阵、OpenTUI no-flicker gate 和 TUI parity snapshots 全部通过。注意 Test (macos-latest) 和 Test (windows-latest) 在本 PR 上被 CI profile skipped —— 这也是我在本地跑 macOS 套件的原因之一。

推送合并会触发一次新的 review-pr 运行,这是正常的,上一次已经跑完了。

@wenshao

wenshao commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /review

@github-actions

Copy link
Copy Markdown
Contributor

Qwen Code review request accepted. Review is running in workflow run. A command-triggered review is not listed under the checks of this PR; the result is posted here as a review when it finishes.

Serves /manifest.webmanifest and /sw.js pre-auth, registers the worker
deferred to load, and replaces the earlier service-worker.js draft with
sw.js as a Vite entry (PNG icons included).
WebView shell around the daemon-served Web Shell (no second UI), profile
tuples of URL/token/display name with #token= fragment delivery, native
WebView version check at the Chrome 107 floor, and a foreground-service
skeleton for SSE/notifications.
@jabrailkhalil jabrailkhalil changed the title feat(web-shell): add installable PWA support feat(web-shell, mobile-shell): browser compat, PWA installability and Android shell skeleton Sep 13, 2026
@jabrailkhalil

Copy link
Copy Markdown
Contributor Author

Thanks for the macOS verification and the CI notes — both were acted on.

The branch now carries the agreed three steps from #11704: browser compatibility (browserslist + README matrix, @supports fallbacks for every :has()/@container, 100vh→dvh pairs, ES5 engine-floor screen), the reworked PWA (Vite-built sw.js entry replacing the earlier service-worker.js draft, cache-first /assets/* only, network-only daemon routes, PNG/SVG icons), and the Android WebView shell skeleton (packages/mobile-shell, no second UI, #token= fragment delivery, native WebView floor check). main was merged into the branch per your merge-not-rebase note, so the lint-gate lane should re-validate cleanly.

On your four non-blocking notes, applied to the new code:

  • N1 (wiring is untested): addressed for the new worker — added client/service-worker.test.ts with 17 sandboxed tests covering bypass rules, cache-first shell assets, navigation fallback, lifecycle cache eviction and push notifications. The pre-auth route surface keeps its CLI tests; manifest content itself is still not asserted (same gap you flagged — it now also lives behind the Vite public-dir copy; open to a packaging assertion in a follow-up if you want it).
  • N2 (pre-auth anchor has no reachable consequence): the new predicate uses exact-path equality (/manifest.webmanifest, /sw.js), so there is no regex anchor left to drift; negatives (/sw.js/extra, .bak suffixes) are covered in the unit tests.
  • N3 (mode/destination row redundancy): the reworked worker no longer has those two gates — navigation handling is a single request.mode === 'navigate' branch, so the redundant-row class is gone rather than pinned.
  • N4 (favicon CSP console noise on the retry page): the reworked worker deliberately has no offline/retry page (no pre-cache, offline is out of scope and documented in the design doc), so the favicon violation cannot occur; the tradeoff is that a stopped daemon yields the browser's native connection error instead of a branded page.

The earlier physical-device acceptance (Pixel 7, OnePlus 15) covered the old worker draft; the reworked sw.js head is ready for a fresh CI/verification pass.

中文说明

感谢 macOS 验证和 CI 说明,两点都已处理。

分支现在承载 #11704 中商定的三步:浏览器兼容性(browserslist + README 矩阵、全部 :has()/@container 的 @supports 回退、100vh→dvh 成对、ES5 引擎下限页面)、重构后的 PWA(Vite 构建的 sw.js 入口取代旧 service-worker.js 草稿,仅对 /assets/* 缓存优先,daemon 路由纯网络,PNG/SVG 图标),以及 Android WebView 壳骨架(packages/mobile-shell,无第二 UI、#token= fragment 传 token、原生 WebView 下限检查)。已按你的「用 merge 不用 rebase」说明把 main 合入分支,lint-gate lane 应能干净重验。

四条不阻塞说明在新代码上的落实情况:N1 接线未测——已为新的 worker 增加 client/service-worker.test.ts(17 个沙箱测试:绕行、缓存优先、导航回退、生命周期缓存清理、推送通知);pre-auth 路由保留 CLI 测试;manifest 自身内容仍无断言(与你指出的缺口一致,现在它走 Vite public-dir 拷贝,如需打包断言可作后续)。N2 pre-auth 锚点——新判定用精确路径相等(/manifest.webmanifest、/sw.js),已无正则锚点可漂移;负例(/sw.js/extra、.bak 后缀)已进单元测试。N3 mode/destination 冗余——重构后的 worker 已没有这两道门,导航处理只有单个 request.mode === 'navigate' 分支,因此该类冗余行不复存在。N4 重试页 favicon CSP 噪音——重构后的 worker 刻意没有离线/重试页(不预缓存、离线在范围外,已写入设计文档),因此 favicon 违例不会发生;代价是 daemon 停止时显示浏览器原生连接错误页而非品牌页。

此前的真机验收(Pixel 7、OnePlus 15)覆盖的是旧 worker 草稿;重构后的 sw.js head 已准备好接受新一轮 CI/验证。

@jabrailkhalil

jabrailkhalil commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor Author

Merged main into feat/web-shell-pwa-installability and pushed as requested! Thank you @wenshao for the thorough verification report.

@jabrailkhalil
jabrailkhalil force-pushed the feat/web-shell-pwa-installability branch from d67d07c to 384a244 Compare September 13, 2026 14:37
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@jabrailkhalil

Copy link
Copy Markdown
Contributor Author

Quick status: the branch now contains only the PWA/compat/shell changes from the agreed #11704 direction on top of current main — an unrelated upstream commit that had been pushed into the branch was removed, and it is not part of this PR anymore. Local checks on the clean head are green: ESLint/Prettier on all changed files, CLI pre-auth tests (5/5) and the new service-worker suite (17/17).

The heavy CI lanes (Install, Test, Lint & Static, E2E) are still waiting for workflow approval for this fork on the new head, same as before — could you approve the runs when you get a moment? Happy to fix anything CI or review finds.

中文说明

快速状态更新:分支现在只包含 #11704 商定方向下的 PWA/兼容性/壳改动,并位于当前 main 之上;之前被推入分支的一个无关 upstream 提交已被移除,不再属于本 PR。干净 head 上的本地检查全部通过:改动文件的 ESLint/Prettier、CLI pre-auth 测试(5/5)以及新的 service worker 套件(17/17)。

与新 head 相关的重型 CI 泳道(Install、Test、Lint & Static、E2E)仍在此 fork 的工作流批准页面等待批准,和之前一样——方便时能否批准这些运行?CI 或评审发现任何问题,我都会立即修复。

@jabrailkhalil

Copy link
Copy Markdown
Contributor Author

Addressed the complete round-4 review in 86f6e292fc.

  • Fixed all three critical paths: Android window.confirm() now has a lifecycle-safe native dialog, Android WebView UAs no longer fall into the Safari 4 branch, and URL bearer tokens are scrubbed before an unsupported-browser early return.
  • Closed the 26 suggestions across the pre-auth route contract, cache policy, service-worker behavior and tests, Android CI/integrity, docs, CSS cleanup, and build artifacts.
  • Added a shared PWA route table consumed by the daemon and standalone registration; the committed manifest, registration boundaries, classic worker output, remote self-origin behavior, error logging, and stale-dist copy path are now pinned by tests.

Validation: Web Shell/CLI/SDK builds pass; 77/77 focused Web Shell tests, 155/155 focused CLI tests, 90/90 script tests, and 1955/1955 SDK tests pass. The full Web Shell run completed with 8026 passing tests; five unrelated number-format assertions use this Windows host's ru-RU separators instead of the suite's expected en-US separators. Android SDK is unavailable on this host, so the new pinned Android workflow runs wrapper verification, assembleDebug, testDebugUnitTest, and lintDebug in CI.

@jabrailkhalil

Copy link
Copy Markdown
Contributor Author

Addressed the two outstanding Critical findings in 63f841b8ba:

  • R4-9: Back now reattaches the existing WebView only when the native connection-error screen detached it, before navigating back. This restores the visible page without restoring the prior unconditional reattachment.
  • R5-1: Added the 1,343-byte mobile-shell.yml entry to the workflow-size ratchet, so both enforcers track the new workflow.

Validation: the workflow-size gate passes, its focused Vitest suite passes (188 passed, 23 fixture skips), and npm run check:desktop-isolation passes. Android Gradle verification cannot run on this Windows host because no Android SDK is installed.

This PR has now reached five review rounds. In line with the repository contribution guidance, I am deferring the remaining R5 Suggestions (R5-2, R5-3, R5-4, R5-9 through R5-13) to focused follow-ups rather than expanding this already broad PWA/Android PR further. They cover import hygiene, extra artifact and route pins, workflow pinning/cache, and checksum documentation/normalization; none is marked Critical. The previously addressed R1/R4 Suggestions remain documented in their threads.

The current fork workflows are still awaiting maintainer approval; once approved, I will address any actual CI failure.

@jabrailkhalil

Copy link
Copy Markdown
Contributor Author

Resolved the branch divergence with the current main in a070fde312; the only conflict kept the upstream configuration table and retained the Web Shell PWA routes (/manifest.webmanifest, /sw.js).

Post-merge checks passed:

  • npm run test:scripts -- workflow-size.test.js — 194 passed, 23 skipped
  • node scripts/check-desktop-isolation.js
  • .github/scripts/check-workflow-size.sh
  • repository pre-commit (Prettier/lint-staged)

@jabrailkhalil

Copy link
Copy Markdown
Contributor Author

Addressed critical R6-1 and merged current upstream main in 0d91521.

The Android README and both English/Chinese mobile-shell design records now state that adb reverse must never forward a token-less loopback daemon to a physical device. They require --require-auth with a bearer token stored as daemon_token, while keeping the loopback URL required by the existing cleartext policy.

The merge also retains the PWA root worker entry and registration alongside upstream’s remote-daemon boot flow. Validation completed on this head:

  • npm run build -- --cli-only
  • 48 focused Web Shell/PWA tests: worker, registration, assets, build artifact, and boot
  • node scripts/check-desktop-isolation.js
  • .github/scripts/check-workflow-size.sh

@wenshao

wenshao commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — round 2: local real-stack run on Linux

I rebuilt both arms from source, packed and installed the CLI the way npm would, and drove the result through a real qwen serve daemon and real Chromium. Everything this PR claims about pre-auth routing, cache policy, worker lifecycle, offline recovery, the engine floor and packaging reproduced. No correctness or security blocker. Seven non-blocking observations are at the end; one of them (N1) is the same test-coverage gap I reported in round 1 and is still open.

My round 1 was against bf29291, when the branch still carried the earlier service-worker.js draft. The implementation has been replaced since (Vite-built /sw.js, shared route table, engine floor, Android shell), so this is a fresh full pass rather than a delta.

What was under test

PR head 6b5ecad828d908f85ac252d32b934a30b0c3e42a
Merge base main @ c53287da5f (v0.24.0, 2026-09-16)
Diff 86 files, +2782 −534 → production +946/−493, tests +838/−17, docs +382/−22, config/build +572/−2
Host Linux x86-64, Node 22.22.2, Chromium 1228 (Playwright), real daemon with --require-auth
Arms head and base built identically (core → acp-bridge → channel-base → sdk → web-shell ×3 vite → web-templates → channels → esbuild → copy_bundle_assets)

Both arms also went through npm pack ./dist + npm i <tarball> into a clean prefix, so the HTTP surface below was re-measured against the published package layout, not just the dev bundle.

1. Public PWA routes on a real, token-protected daemon

pre-auth route matrix

30 probes per arm, then the same set again against the installed tarball (identical results).

  • Served without a bearer token: GET/HEAD /manifest.webmanifest and /sw.js, plus the case variants (/SW.JS, /Sw.Js, /MANIFEST.WEBMANIFEST), the trailing-slash forms and a query string. On base every one of them is 401.
  • Still authenticated: /sw.js.bak, /sw.js.map, /sw.js/extra, /manifest.webmanifest.bak, /manifest.webmanifest/extra, //sw.js, POST /sw.js, PUT /manifest.webmanifest, /capabilities, /favicon.ico, /robots.txt — all 401.
  • Headers are what the design doc promises: application/manifest+json / application/javascript, Cache-Control: no-cache, X-Content-Type-Options: nosniff, and Service-Worker-Allowed: / on the worker only.
  • Missing file → plain text, never HTML. Renaming dist/web-shell/manifest.webmanifest and sw.js away under a live daemon gives 404 text/plain "Not found" for both, and restoring them returns 200 again.
  • Cold daemon. The very first request the daemon ever received was an unauthenticated GET /sw.js: 200 in 8 ms, while the log still read deferred runtime: scheduling fallback start in 1000ms, and /capabilities in the same window answered 401. The deferred-runtime pre-auth gate therefore does cover the two new routes.
  • With --no-web the routes are simply absent (401 unauthenticated, Express's default 404 with a token) — the same shape / has had all along, not a new surface.

2. Cache policy — measured A/B, not read from the diff

Base serves every file under /assets with public, max-age=31536000, immutable. Head narrows that to content-addressed names: of the 395 files in the real build, 392 are hashed and immutable, and the three manifest icons revalidate (no-cache). Planting files with chosen names confirms the rule end to end (lower table in the figure above).

3. Real Chromium against the real daemon

Persistent profile, counting reverse proxy in front of the daemon so every request that actually left the browser is attributable.

  • Registration: scope http://127.0.0.1:<port>/, script /sw.js, state activated.
  • After a reload the worker cache holds 3 entries, all /assets/*; caches.match('/') misses. The page issued 4 asset requests, 3 with transferSize === 0, and exactly one asset request reached the daemon — /assets/icon.svg, the file the new policy deliberately revalidates. HTML went to the network every time.
  • An authenticated fetch('/capabilities', {Authorization}) returns 200 and never enters the cache.
  • A 404 asset response is not cached.
  • Manifest revalidation without a version bump: editing dist/web-shell/manifest.webmanifest on disk and re-fetching from the live page returns the edited bytes immediately; the manifest is never in the worker cache.
  • Activation removes only obsolete Qwen caches: seeded qwen-code-shell-v1-0.0.1-old + some-other-app-cache, changed the worker bytes, forced an update → after activation only qwen-code-shell-v1-0.24.0 and some-other-app-cache remain.

4. Offline behaviour and recovery

offline before/after

Killing the daemon and navigating gives the worker's bilingual 503 retry page — both for / and for a /session/<id> deep link, so no stale session HTML is replayed. Restarting the daemon and reloading mounts the app again.

5. Engine floor — 11/11 boundary cases in a real browser

engine floor before/after

Chrome 111/110, Edge 120, Firefox 128/127, Safari 16.4/16.3, iOS 17.2/15.6 and — the case the round-4 fix was about — Android System WebView 113 (which carries the Version/4.0 … Safari/ token) is correctly treated as supported, while WebView 110 is not. In every rejected case: exactly one fallback box, React never mounts, ?token= is scrubbed from the address bar, and zero service-worker registrations. The five inline classic scripts in the shipped index.html all parse at ecmaVersion: 5; the built sw.js has no import/export, passes node --check, and parses as a classic script (ES2019+, from the optional catch binding).

6. Add Workspace dialog

dialog before/after

At a 1100×720 viewport I measure height 437.75 px, computed max-height 576 px, fully inside the viewport, with the new viewportPanel class attached — the same numbers the PR reports from Windows. The base measures identically, so the CSS-module rewrite is not a regression; the 100dvh pair only changes behaviour on a browser with a dynamic toolbar, which I cannot reproduce headless.

7. Packaging

  • scripts/prepare-package.js passes; removing dist/web-shell/sw.js makes it fail with Required package artifact not found: …/web-shell/sw.js (exit 1). The release gate is real.
  • npm pack ./dist → 29.9 MB, 1127 entries, containing web-shell/{index.html,sw.js,manifest.webmanifest,assets/icon-192.png,icon-512.png,icon.svg}.
  • The icons are genuine 192×192 and 512×512 PNGs; the manifest carries name, short_name, start_url, scope, display.

8. Suites and negative controls

Suite (at head) Result
packages/cli — web-shell-preauth, web-shell-static, self-origin, fast-path 158/158
packages/web-shell — 6 PWA suites + DialogShell + sidebar CSS 78/78
scripts/tests — package-assets, package-scripts 96 passed, 2 skipped
packages/web-shell full client suite 7153/7252; the 99 failures are 10 markdown/chart files that fail identically before and after any change I made (local dependency farm, not this PR)
check-serve-fast-path-bundle passes on both arms

Negative controls, each reverting exactly one production hunk at head:

Control Result
web-shell-preauth.ts → base version 13 tests fail
web-shell-static.ts → base version 7 tests fail
copy_bundle_assets.js → base version 2 tests fail
sw.js: drop the Authorization bypass killed by leaves /assets/... to the browser network stack
sw.js: drop the status === 200 store guard killed by does not cache failed responses
Startup time, 6 cold starts per arm median 1333 ms (head) vs 1379 ms (base) — indistinguishable

9. Gradle wrapper provenance

The only binary the PR adds is checked in CI against a .sha256 file committed in the same PR, which proves nothing about origin. I checked it against Gradle's published checksums instead: gradle-wrapper.jar = 81a82aae… is byte-identical to the official Gradle 8.13 wrapper jar, and distributionSha256Sum matches the official gradle-8.2.1-bin.zip checksum. The jar is legitimate. (Consider gradle/actions/wrapper-validation so the lane checks provenance rather than self-consistency; also note the wrapper jar is from 8.13 while the distribution is pinned to 8.2.1 — valid, but worth being deliberate about.)

10. Android navigation policy, executed

OriginPolicy.kt is plain JVM Kotlin, so I compiled the file as it stands in the PR (kotlinc 2.1.0, JDK 17) and ran 51 adversarial cases against it — beyond the 20-odd in OriginPolicyTest.kt. 51/51 behave as the design doc describes. The ones worth naming: http://127.0.0.1:4170@evil.example/ and http://127.0.0.1:4170%2f@evil.example/ are not same-origin (user-info is rejected outright), http://127.0.0.1:4170.evil.example/ is not, http://127.0.0.1:4170\@evil.example/ is not, default-port equivalence works both ways (http://h ≡ http://h:80, https://h ≡ https://h:443) and does not leak across schemes, and intent:, javascript:, file:, content:, data:, market:, android-app: are all refused by isExternalLink, so they are neither loaded nor handed to another app. tel: and sms: are refused too — deliberate given the http/https/mailto allowlist, just worth knowing that a phone-number link in a transcript does nothing in the shell.

Open bot suggestions that I found already closed at this head

  • R4-20 "nothing pins that the build emits dist/sw.js as a classic script" — build-artifact.test.ts:318 does exactly that, and I reproduced it on the real artifact.
  • R4-39 "the status === 200 half of the store guard is unpinned" — my mutation is caught.
  • R4-41 "the offline page is English-only" — the shipped page is bilingual (screenshot above).
  • R4-10 / R5-10 "the Android test is collected by nothing" — mobile-shell.yml exists and its lane is green on this PR.
  • R4-33 pnpm-smoke exclusion, R4-19 inert browserslist — both resolved in the current diff.
  • Still accurate: R4-40, the text/event-stream bypass is unreachable given the routing below it (harmless defensive code).

Non-blocking observations

N1 — the wiring is still the untested part (same as round 1). Deleting <link rel="manifest"> from index.html and the scheduleServiceWorkerRegistration({...}) call from main.tsx, then rebuilding, produces a shipped dist/index.html with no manifest link and an app chunk with no serviceWorker reference — and the full client suite (7,252 tests) plus all six PWA suites stay green. pwa-registration.test.ts covers the helper in isolation and build-artifact.test.ts pins the built worker, but nothing asserts the two call sites survive into the artifact. Two assertions in the existing build artifact — standalone PWA block would close it.

N2 — daemon and worker disagree on what a content hash looks like. The daemon uses /-[a-zA-Z0-9_-]{8,}\.[^.]+$/ (single extension); sw.js uses /-[a-zA-Z0-9_-]{8,}\.[^/]+$/ (any tail). A file named chunk-ABCDEFGH12.min.js or chunk-ABCDEFGH12.js.map therefore gets Cache-Control: no-cache from the daemon and is cached first by the worker — verified in the browser. No file in today's build hits it (392 hashed files and the 3 icons are classified the same way by both sides), so this is latent, not live.

N3 — the "content-addressed" test has false positives. zz-vendor-lib.js — no hash anywhere in the name — is served immutable, max-age=31536000 and permanently cached by the worker, because vendor-lib is ≥ 8 characters of [A-Za-z0-9_-] after a dash. The comment above the rule says Vite content hashes are "the only safe basis for immutable caching" and that future unhashed assets "revalidate by default"; a realistic future name such as polyfills-legacy.js would not. Deriving the set from the Vite manifest, or requiring the hash to be the last dash-separated token and a single extension, would make the comment true.

N4 — the retry page is the one unthemed surface. It renders in the browser's default serif with a blue link, while every other dead end this PR touches is themed. It is readable and bilingual, so purely cosmetic.

N5 — the SDK barrel import costs more than the constant it reads (matches open R5-2). web-shell-preauth.ts documents itself as the dependency-light home of the pre-auth predicates, then statically imports @qwen-code/sdk/daemon for a two-element array. Measured on the esbuild metafile: the input closure of serve/server/self-origin.ts goes from 52 modules / 0.11 MiB to 80 modules / 1.08 MiB (+DaemonClient.ts, types.ts, events.ts, the transcript UI renderer…). The good news: it costs nothing at runtime — the emitted self-origin chunk does not import the daemon chunk, check-serve-fast-path-bundle passes on both arms, and startup time is unchanged. So this is a contract/maintainability point, not a performance one.

N6 — the worker outlives the feature on a loopback origin. After downgrading the same port to a build without /sw.js, the worker stays registered and in control, because /sw.js then answers 401 rather than 404 and the browser never treats it as removed. With a different local app bound to that port afterwards, its HTML still loads from the network, but a request for a path the Qwen worker had cached is answered from that cache (I got 4.6 MB of Qwen's chunk in place of the other server's file). This is inherent to service workers on a shared loopback origin, and the filename would have to collide exactly — but answering /sw.js with a pre-auth 404 when the shell is disabled would give browsers the unregister signal.

N7 — packages/mobile-shell/package.json pins "version": "0.23.3". The repo is on 0.24.0 and this package is excluded from the release bump, so the number will drift. The two existing exclusions use independent 0.0.x versions instead, which reads more honestly.

Not verified here

No Android SDK on this box, so the Gradle build, lintDebug and the emulator scenarios were not run locally — that lane is green in CI, and the origin policy itself was executed standalone as described above. Also out of scope for this run: real Safari and Firefox runtimes (the UA matrix exercises the guard's parsing in Chromium, not those engines), physical devices, HTTPS with a real certificate chain, and the install prompt itself (headless Chromium does not surface it).

中文版

维护者验证 —— 第 2 轮:本地真实环境(Linux)

我把两个分支都从源码重新构建,按 npm 的方式打包并安装,再用真实的 qwen serve daemon 和真实 Chromium 驱动。本 PR 关于 pre-auth 路由、缓存策略、worker 生命周期、离线恢复、浏览器引擎下限和打包的全部声明都复现成功,没有发现正确性或安全阻塞项。 末尾列出 7 条非阻塞观察,其中 N1 与我第 1 轮报告的测试覆盖缺口相同,至今仍然存在。

我的第 1 轮针对 bf29291,当时分支上还是早期的 service-worker.js 草案。实现此后已被整体替换(Vite 构建的 /sw.js、共享路由表、引擎下限、Android 外壳),因此本轮是完整重新验证,而非增量。

验证对象

PR head 6b5ecad828d908f85ac252d32b934a30b0c3e42a
合并基点 main @ c53287da5f(v0.24.0,2026-09-16)
差异 86 个文件,+2782 −534 → 生产代码 +946/−493,测试 +838/−17,文档 +382/−22,配置/构建 +572/−2
环境 Linux x86-64、Node 22.22.2、Chromium 1228(Playwright)、真实 daemon 开启 --require-auth
两臂 head 与 base 以完全相同的顺序构建(core → acp-bridge → channel-base → sdk → web-shell 三次 vite → web-templates → channels → esbuild → copy_bundle_assets)

两臂还都执行了 npm pack ./dist + npm i <tarball> 安装到干净目录,因此下面的 HTTP 结论是在发布包布局上复测过的,而不仅是开发 bundle。

1. 真实、带 token 保护的 daemon 上的公开 PWA 路由

pre-auth 路由矩阵

每臂 30 项探测,随后对安装后的 tarball 再跑一遍(结果一致)。

  • 无 bearer token 即可获取:GET/HEAD /manifest.webmanifest 与 /sw.js,以及大小写变体(/SW.JS、/Sw.Js、/MANIFEST.WEBMANIFEST)、尾部斜杠形式和带查询串的形式。base 上这些全部是 401。
  • 仍需认证:/sw.js.bak、/sw.js.map、/sw.js/extra、/manifest.webmanifest.bak、/manifest.webmanifest/extra、//sw.js、POST /sw.js、PUT /manifest.webmanifest、/capabilities、/favicon.ico、/robots.txt —— 全部 401。
  • 响应头与设计文档一致:application/manifest+json / application/javascript、Cache-Control: no-cache、X-Content-Type-Options: nosniff,且只有 worker 带 Service-Worker-Allowed: /。
  • 资源缺失返回纯文本,绝不返回 HTML。 在 daemon 运行期间把 dist/web-shell/manifest.webmanifest 和 sw.js 改名移走,两者都返回 404 text/plain "Not found";恢复后重新返回 200。
  • 冷启动。 daemon 收到的第一个请求就是未认证的 GET /sw.js:8 ms 返回 200,此时日志仍是 deferred runtime: scheduling fallback start in 1000ms,同一窗口内 /capabilities 返回 401。说明延迟运行时的 pre-auth 门确实覆盖了这两条新路由。
  • 使用 --no-web 时这两条路由根本不存在(未认证 401,带 token 时是 Express 默认 404)—— 与 / 一直以来的表现相同,不是新增面。

2. 缓存策略 —— 实测 A/B,而非读 diff

base 对 /assets 下每一个文件都返回 public, max-age=31536000, immutable。head 将其收窄到内容寻址的文件名:真实构建产物的 395 个文件中,392 个带哈希、返回 immutable,3 个 manifest 图标改为重新验证(no-cache)。我用自选文件名植入文件,端到端确认了该规则(见上图下半部分表格)。

3. 真实 Chromium 对真实 daemon

使用持久化 profile,并在 daemon 前放置计数反向代理,使每个真正离开浏览器的请求都可归因。

  • 注册结果:scope http://127.0.0.1:<port>/、脚本 /sw.js、状态 activated。
  • 重新加载后 worker 缓存里只有 3 条,全部是 /assets/*;caches.match('/') 未命中。页面发起 4 个资源请求,其中 3 个 transferSize === 0,只有 1 个资源请求到达 daemon —— 正是新策略刻意要求重新验证的 /assets/icon.svg。HTML 每次都走网络。
  • 带认证的 fetch('/capabilities', {Authorization}) 返回 200,且从不进入缓存。
  • 404 响应不会被缓存。
  • 不改版本号的 manifest 重新验证: 在磁盘上修改 dist/web-shell/manifest.webmanifest 后从页面重新 fetch,立即拿到修改后的内容;manifest 从不进入 worker 缓存。
  • 激活只清理过期的 Qwen 缓存: 预置 qwen-code-shell-v1-0.0.1-old 与 some-other-app-cache,修改 worker 字节后强制更新 → 激活后只剩 qwen-code-shell-v1-0.24.0 和 some-other-app-cache。

4. 离线行为与恢复

离线前后对比

杀掉 daemon 后再导航,得到 worker 的双语 503 重试页 —— / 与 /session/<id> 深链都是如此,因此不会回放过期的会话 HTML。重启 daemon 并重新加载后应用重新挂载。

5. 引擎下限 —— 真实浏览器中 11/11 边界用例

引擎下限前后对比

Chrome 111/110、Edge 120、Firefox 128/127、Safari 16.4/16.3、iOS 17.2/15.6,以及第 4 轮修复所针对的那个用例:Android System WebView 113(UA 中带 Version/4.0 … Safari/)被正确判定为受支持,而 WebView 110 不受支持。 在每个被拒绝的用例中:只出现一个 fallback 框、React 从未挂载、地址栏中的 ?token= 被清除、service worker 注册数为 0。发布 index.html 中的 5 段内联经典脚本全部能以 ecmaVersion: 5 解析;构建出的 sw.js 没有 import/export,通过 node --check,并可按经典脚本解析(因可选 catch 绑定,需 ES2019+)。

6. Add Workspace 对话框

对话框前后对比

在 1100×720 视口下我测得高度 437.75 px、计算后的 max-height 576 px、完整位于视口内,并带有新的 viewportPanel 类 —— 与 PR 在 Windows 上报告的数字相同。base 的测量完全一致,说明这次 CSS module 重写没有回归;100dvh 那一对只在带动态工具栏的浏览器上改变行为,而这在无头环境中无法复现。

7. 打包

  • scripts/prepare-package.js 通过;删除 dist/web-shell/sw.js 后它以 Required package artifact not found: …/web-shell/sw.js 失败(exit 1)。发布门是真实有效的。
  • npm pack ./dist → 29.9 MB、1127 个条目,包含 web-shell/{index.html,sw.js,manifest.webmanifest,assets/icon-192.png,icon-512.png,icon.svg}。
  • 图标确实是 192×192 与 512×512 的真实 PNG;manifest 带有 name、short_name、start_url、scope、display。

8. 测试套件与反向对照

套件(head) 结果
packages/cli —— web-shell-preauth、web-shell-static、self-origin、fast-path 158/158
packages/web-shell —— 6 个 PWA 套件 + DialogShell + 侧栏 CSS 78/78
scripts/tests —— package-assets、package-scripts 96 通过,2 跳过
packages/web-shell 全量 client 套件 7153/7252;99 条失败集中在 10 个 markdown/图表文件,在我做任何改动前后完全相同(本地依赖拼装所致,与本 PR 无关)
check-serve-fast-path-bundle 两臂均通过

反向对照,每次只在 head 上回退一处生产代码:

对照 结果
web-shell-preauth.ts → base 版本 13 条测试失败
web-shell-static.ts → base 版本 7 条测试失败
copy_bundle_assets.js → base 版本 2 条测试失败
sw.js:去掉 Authorization 旁路 被 leaves /assets/... to the browser network stack 杀死
sw.js:去掉 status === 200 存储守卫 被 does not cache failed responses 杀死
启动耗时,每臂 6 次冷启动 中位数 1333 ms(head)对 1379 ms(base)—— 无法区分

9. Gradle wrapper 来源核验

本 PR 新增的唯一二进制文件,在 CI 中只与同一个 PR 里提交的 .sha256 比对,这无法证明来源。我改为与 Gradle 官方公布的校验和比对:gradle-wrapper.jar = 81a82aae… 与官方 Gradle 8.13 的 wrapper jar 逐字节相同,distributionSha256Sum 与官方 gradle-8.2.1-bin.zip 校验和一致。该 jar 是正规产物。(建议改用 gradle/actions/wrapper-validation,让这道门校验来源而非自洽;另外注意 wrapper jar 来自 8.13 而分发版固定在 8.2.1 —— 合法,但值得有意识地选择。)

10. Android 导航策略:已实际执行

OriginPolicy.kt 是纯 JVM Kotlin,因此我按 PR 中的原样编译该文件(kotlinc 2.1.0、JDK 17),并针对它跑了 51 个对抗性用例 —— 超出 OriginPolicyTest.kt 里的 20 余个。51/51 的行为与设计文档一致。 值得点名的几条:http://127.0.0.1:4170@evil.example/ 与 http://127.0.0.1:4170%2f@evil.example/ 不算同源(user-info 直接被拒),http://127.0.0.1:4170.evil.example/ 不算,http://127.0.0.1:4170\@evil.example/ 不算;默认端口等价双向成立(http://h ≡ http://h:80,https://h ≡ https://h:443)且不跨协议泄漏;intent:、javascript:、file:、content:、data:、market:、android-app: 都被 isExternalLink 拒绝,既不会加载也不会交给其他应用。tel: 与 sms: 同样被拒 —— 在 http/https/mailto 白名单下这是有意为之,只是需要知道:会话中的电话号码链接在外壳里点击无反应。

已在当前 head 关闭、但机器人线程仍开着的建议

  • R4-20「没有任何测试钉住构建产物中 dist/sw.js 是经典脚本」—— build-artifact.test.ts:318 正是如此断言,我也在真实产物上复现了。
  • R4-39「status === 200 这一半守卫没有被钉住」—— 我的变异被杀死。
  • R4-41「离线页只有英文」—— 发布版本是双语的(见上方截图)。
  • R4-10 / R5-10「Android 测试没有任何执行者」—— mobile-shell.yml 已存在,本 PR 上该 lane 为绿。
  • R4-33 pnpm smoke 排除项、R4-19 无人读取的 browserslist —— 当前 diff 中均已解决。
  • 仍然成立:R4-40,text/event-stream 旁路在其下方的路由结构下不可达(属无害的防御性代码)。

非阻塞观察

N1 —— 没被测到的仍然是「接线」(与第 1 轮相同)。 从 index.html 删掉 <link rel="manifest">,并从 main.tsx 删掉 scheduleServiceWorkerRegistration({...}) 调用后重新构建,发布出的 dist/index.html 没有 manifest 链接、应用 chunk 中没有任何 serviceWorker 引用 —— 而全量 client 套件(7252 条)以及 6 个 PWA 套件依旧全绿。pwa-registration.test.ts 孤立地覆盖了该 helper,build-artifact.test.ts 钉住了构建出的 worker,但没有任何断言确认这两个调用点进入了产物。在已有的 build artifact — standalone PWA 块里加两条断言即可闭合。

N2 —— daemon 与 worker 对「什么是内容哈希」判断不一致。 daemon 用 /-[a-zA-Z0-9_-]{8,}\.[^.]+$/(单一扩展名),sw.js 用 /-[a-zA-Z0-9_-]{8,}\.[^/]+$/(任意尾部)。于是名为 chunk-ABCDEFGH12.min.js 或 chunk-ABCDEFGH12.js.map 的文件会被 daemon 判为 Cache-Control: no-cache,同时被 worker 缓存优先 —— 这一点已在浏览器中实测。今天的构建产物中没有文件命中(392 个哈希文件与 3 个图标在两侧判断一致),因此是潜在问题而非现网问题。

N3 —— 「内容寻址」判据存在误判。 zz-vendor-lib.js(文件名中根本没有哈希)会得到 immutable, max-age=31536000 并被 worker 永久缓存,因为 vendor-lib 是短横线之后 ≥ 8 个 [A-Za-z0-9_-] 字符。规则上方的注释写着 Vite 内容哈希是「唯一安全的 immutable 依据」、未来未加哈希的资源「默认重新验证」;但像 polyfills-legacy.js 这样现实的未来文件名并不会。改为从 Vite manifest 推导,或要求哈希必须是最后一个短横线分段且只有单一扩展名,才能让注释成立。

N4 —— 重试页是唯一没有主题化的界面。 它以浏览器默认衬线字体和蓝色链接渲染,而本 PR 触及的其他终止界面都是主题化的。内容可读且双语,纯观感问题。

N5 —— SDK barrel 导入的代价超过它读取的常量(对应仍开着的 R5-2)。 web-shell-preauth.ts 自述是 pre-auth 判定式的「依赖轻量」归宿,却为了一个两元素数组静态导入了 @qwen-code/sdk/daemon。在 esbuild metafile 上实测:serve/server/self-origin.ts 的输入闭包从 52 个模块 / 0.11 MiB 变为 80 个模块 / 1.08 MiB(新增 DaemonClient.ts、types.ts、events.ts、transcript UI 渲染器等)。好消息是运行时没有代价 —— 产出的 self-origin chunk 并不 import daemon chunk,check-serve-fast-path-bundle 两臂都通过,启动耗时无变化。所以这是契约/可维护性问题,不是性能问题。

N6 —— 在 loopback origin 上 worker 的生命周期长于功能本身。 把同一端口降级到没有 /sw.js 的构建后,worker 仍保持注册并处于控制状态,因为此时 /sw.js 返回 401 而不是 404,浏览器不会认为它已被移除。随后若另一个本地应用绑定该端口,它的 HTML 仍走网络,但只要请求路径恰好是 Qwen worker 缓存过的,就会由该缓存应答(我实测拿到了 4.6 MB 的 Qwen chunk,而不是对方服务器的文件)。这是 service worker 在共享 loopback origin 上的固有性质,且文件名必须完全相同才会命中;但在 Web Shell 关闭时用 pre-auth 的 404 应答 /sw.js,可以给浏览器一个注销信号。

N7 —— packages/mobile-shell/package.json 固定为 "version": "0.23.3"。 仓库已经到 0.24.0,而该包被排除在发版 bump 之外,这个数字只会越漂越远。已有的两个排除包使用独立的 0.0.x 版本号,表达上更诚实。

本轮未验证的部分

本机没有 Android SDK,因此 Gradle 构建、lintDebug 和模拟器场景没有在本地执行 —— 该 lane 在 CI 上为绿,而 origin 策略本身已按上文方式独立执行。本轮同样不在范围内的还有:真实 Safari 与 Firefox 运行时(UA 矩阵验证的是该守卫在 Chromium 中的解析逻辑,不是这些引擎本身)、真机、带真实证书链的 HTTPS,以及安装提示本身(无头 Chromium 不会弹出)。


🤖 Generated with Claude Code — Claude Opus 5 (1M context)

@wenshao

wenshao commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao
wenshao enabled auto-merge September 17, 2026 08:23
@jabrailkhalil

Copy link
Copy Markdown
Contributor Author

@wenshao, thank you for the detailed round-2 verification and for approving this revision.

Could you help clarify the remaining path to merge? At 6b5ecad, GitHub shows passing checks, your approval, and a remaining changes-requested review. The latest stage-3 assessment reports no blocking findings, identifies that review as belonging to the superseded a070fde3, and defers the second approval to maintainers under the review policy. My understanding is that this PR is still waiting on review disposition and the second required approval. Could a maintainer review the old verdict for dismissal if appropriate and help arrange the remaining review? If there is a specific blocking code change or additional verification you need from me, please point me to it and I will address it.

I also want to answer the maintenance question: I am willing to continue developing and maintaining packages/mobile-shell, including regressions, tests, Android toolchain updates and compatibility work, with maintainer review for protocol/authentication changes. I am ready to keep working on this beyond the initial scaffold.

For Phase 2, I propose small follow-up PRs, keeping the architecture agreed in #11704: a native shell around the daemon-served Web Shell, using the documented REST+SSE surface.

  1. Credential storage: Android Keystore-backed storage and a recoverable migration from the development profile, removing the old plaintext credential after successful migration.
  2. Connection profiles: stable profile IDs, add/edit/switch/remove UI, profile/workspace-scoped state, capability checks per connection, and explicit isolation of credentials and WebView state when switching.
  3. Per-device credentials: integrate the maintainer-owned primary-listener issuance/revocation contract once available. Keystore storage alone does not satisfy server-side revocation; until that dependency and acceptance testing are complete, the app remains a development client.
  4. Background events and notifications: lifecycle-aware SSE, explicit foreground-service controls, reconnect handling and completion/permission notifications, with Android permission and background-limit testing.
  5. Native integrations and acceptance: separate PRs for file selection/downloads, microphone permissions, window/renderer recovery and accessibility, with emulator/device evidence for each delivered behavior.

I will also track the round-2 N1–N7 observations as focused follow-ups: built-artifact wiring coverage, consistent asset-cache classification, offline-page styling, the narrow SDK import, worker retirement and mobile-package versioning. These remain proposals for subsequent work; I am not claiming Phase 2 is implemented or adding it to the scope of this PR.

Does this sequence fit the team's priorities, and is there a maintainer who can coordinate the credential prerequisite and mobile-shell review? I can start with the storage/profile slice and provide a bilingual design, focused tests and screenshots in a separate PR.

@jabrailkhalil

Copy link
Copy Markdown
Contributor Author

Phase 2 follow-ups are now implemented and submitted as focused draft PRs:

Each PR includes synchronized EN/zh-CN design documentation, its incremental commit link, reviewer steps and a separate report with actual device results. Builds and 18 JVM regressions pass; the native suites pass on API 26 (unsupported profile isolation skipped) and API 35. Actual HTML acceptance also covers file bytes/cancellation and microphone consent/audio processing/lifecycle. Remote workflows still need maintainer authorization; local success is not upstream CI success.

The head of this original PR remains unchanged. The follow-ups declare their dependency on it and will need rebasing after the parent changes land. They do not complete the maintainer-owned device credential prerequisite or constitute an official mobile release. Native downloads need a coordinated H5 export design because existing exports use short-lived blob URLs; background events/notifications and broader accessibility acceptance remain separate work.

I am ready to keep maintaining the mobile shell, address review findings and work through the next agreed slices. The earlier review-disposition question on this PR remains for maintainers; I will address any concrete blocking code or validation request.

中文进展

Phase 2 后续实现已拆分提交为草稿 PR:#12121 提供 Keystore 加密凭据、可恢复迁移及隔离连接;#12126 提供受限 URI 授权的系统文件单选/多选及迟到结果处理;#12127 提供按 origin 限定的原生麦克风同意、Android 权限处理及进入后台关闭连接,提示明确说明未发送状态可能丢失的权衡。

每项均附同步中英文设计、增量提交链接、评审步骤和独立设备测试报告。构建、18 个 JVM 回归以及 API 26/35 原生测试通过(API 26 跳过不支持的 profile 隔离),真实 HTML 验收覆盖文件内容/取消及麦克风授权、音频处理、生命周期。远程工作流仍需维护者授权,本地通过不等于上游 CI 通过。

本原始 PR 的 head 未变,后续 PR 明确依赖它,合入后需变基。这些实现不替代维护者负责的设备凭据前置条件,也不代表正式移动发布。现有导出使用短生命周期 blob URL,原生下载需另行协调 H5 导出设计;后台事件/通知及更全面的无障碍验收仍为独立工作。我愿继续维护、处理评审意见并推进约定的后续切片。本 PR 先前的评审状态问题仍需维护者处理;任何明确的阻塞代码或验证要求都会继续跟进。

@jabrailkhalil

Copy link
Copy Markdown
Contributor Author

Two further Phase 2 slices are implemented and submitted:

I also found and fixed a profile-initialization regression during repeated acceptance, rather than relying on the earlier isolated passing run. On unchanged 95ff656/WebView124, a fresh named profile could receive stale localStorage after rapid process termination. #12121 now includes acac9a3b3d: official complete profile-scoped clearing before any page/token load, a durable initialization marker and current provider-name checks, with cancellation/stale-Activity protection. Unsupported providers fail closed; migration preserves native credentials and resets browser sessions/cache once. The updated verification report records modern/old-provider results and limits. The fix is also merged into all four dependent branches, including #12126 and #12127.

Current integrated E2E reports: file selection, microphone, downloads, accessibility. Each records the exact pushed commit/APK, full API 26 and API 36 suite results and feature-specific UI evidence. All five remote branch heads were verified after push.

All five follow-ups remain drafts with their dependency on this unchanged PR explicit. Bilingual designs, reviewer instructions and actual local verification are attached to each; local results are not upstream CI results. The automated precheck requires maintainer inspection/authorization before review/workflows. Could a maintainer review the pending original verdict/remaining approval here and authorize the follow-up checks when appropriate? I will address concrete findings and continue maintaining the mobile shell.

Full Phase 2 is not complete. Primary-listener per-device issuance/revocation remains a maintainer-owned prerequisite; current Local Control pairing is a separate contract. Background notifications, integration of all native capabilities together, real-device/TLS and complete TalkBack acceptance remain explicit follow-up work. These PRs are development-client increments, not an official signed mobile release.

中文进展

又完成并提交两个 Phase 2 切片:#12129 改善原生配置无障碍,包括带上下文的操作名称、输入标签及 200% 字体下可用的恢复控件,附前后截图;#12130 通过 Android 文档选择器保存真实 Blob 导出字节,覆盖精确二进制、空文件、16MiB、取消及迟到结果,保持现有 CSP 不变。

反复验收还发现并修复了配置初始化回归,不能仅依赖早先单次通过结果:未修改的 95ff656/WebView124 在快速终止进程后可能给新命名配置分配旧 localStorage。#12121 已包含 acac9a3b3d,在加载页面或令牌前通过官方 API 完整清除配置数据,结合持久化初始化标志、当前提供程序名称检查及取消/过期 Activity 保护。不支持时拒绝不安全连接;迁移保留原生凭据并清除一次浏览器会话/缓存。上方更新验收链接记录新旧提供程序结果及限制。该修复也已合入另外四个依赖分支,包括 #12126、#12127。

最新集成验收分别见上方文件选择、麦克风、下载及无障碍报告链接,记录精确推送提交/APK、API 26/36 完整套件结果及功能界面证据。推送后已核对五个远程分支头。

五个后续 PR 均保留草稿状态,明确依赖本 PR,且本 PR 的代码未改变。每个 PR 附英中设计、审阅步骤和真实本地验证;本地结果不等于上游 CI。自动预检要求维护者检查并授权审查/工作流。请维护者在适当时处理此处待定的原有评审及剩余批准,并授权后续检查。我会处理具体发现并继续维护移动外壳。

完整 Phase 2 尚未完成。主监听器的每设备凭据签发/撤销仍是维护者负责的前置契约,现有 Local Control 配对不是同一契约。后台通知、全部原生能力共同集成、实体设备/TLS 及完整 TalkBack 验收仍需后续工作。这些 PR 是开发客户端增量,不是官方签名移动版本。

@qqqys qqqys left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical-only review pass at head 6b5ecad828d908f85ac252d32b934a30b0c3e42a. No merge-blocking defect found. The most recent automated round is bound to this exact commit and posted zero findings, all 20 Critical threads from earlier rounds are resolved, and none of the 38 still-open threads carries a Critical marker. I read the security-bearing surfaces in full; the scan found nothing provable.

Service worker: the cache cannot hold a credential or a session

packages/web-shell/client/sw.js, all 102 lines. The fetch handler declines to intercept at all when any of these hold: a non-GET method, a request carrying an Authorization header, an Accept that includes text/event-stream, or a URL whose origin differs from the worker's own. Authenticated API traffic and event streams therefore never reach the caching logic.

What is cached is narrow and content-addressed: url.pathname.startsWith('/assets/') plus /-[a-zA-Z0-9_-]{8,}\.[^/]+$/, so an unhashed file under /assets stays on the network by default. readAsset stores only response.status === 200 && response.type === 'basic', which excludes error responses and opaque cross-origin bodies, and its cache.put runs under waitUntil with a swallowing catch so a failed write cannot reject the response. A cache that is disabled or full falls through to fetch rather than failing the load.

HTML is never cached. A navigation is always fetch(request), and only its rejection produces the synthetic 503 retry page, sent with Cache-Control: no-store. That is the property the design claims - an unavailable daemon shows a retry page rather than a stale session - and the code does not have a path that puts a document into a cache. Activation deletes only keys starting with qwen-code-shell- that are not the current version, so obsolete shell caches are reclaimed without touching anything else on the origin.

Daemon side: the unauthenticated surface is exactly two public files

  • packages/sdk-typescript/src/daemon/pwa-assets.ts defines WEB_SHELL_PWA_ASSETS as a fixed two-entry as const table - /manifest.webmanifest and /sw.js - with their content types and the worker's Service-Worker-Allowed flag. No session, workspace or authenticated route appears in it, and both the daemon and the browser client consume the same table so the contracts cannot drift.
  • packages/cli/src/serve/web-shell-preauth.ts widens isPreAuthWebShellRequest by that table only, matching the exact route or its trailing-slash form, and the GET/HEAD restriction at the top of the function still applies.
  • packages/cli/src/serve/web-shell-static.ts registers each route with a path derived from the constant (route.slice(1)), not from request input, so there is no traversal surface. Responses set an explicit Content-Type, Cache-Control: no-cache so installation metadata revalidates on every request, and X-Content-Type-Options: nosniff; Service-Worker-Allowed: / is set only on the worker route. The send callback distinguishes 404 from 500, logs only the 500, guards on res.headersSent before writing, and answers text/plain - "Not found" or "Failed to load Web Shell asset" - so a missing installation resource can never return application HTML.
  • The /assets static mount tightened rather than widened: blanket immutable with a one-year maxAge became a per-file decision that grants public, max-age=31536000, immutable only to content-addressed names and no-cache to everything else.

Android development shell

OriginPolicy.kt fails closed throughout: parseHttp accepts only http and https, requires a non-blank host, rejects any URL carrying rawUserInfo, and validates the port range, returning null on a syntax error so every predicate answers false. isSameOrigin compares scheme, host case-insensitively and a port normalized to 443/80, so a differing port is not same-origin. isExternalLink admits only parseable http(s) and mailto, leaving javascript:, file: and custom schemes outside both the embed and the open-externally paths. network_security_config.xml blocks cleartext globally against system trust anchors and permits it only for localhost, 127.0.0.1 and ::1 with includeSubdomains="false", documenting that an operator must add any plain-HTTP LAN host explicitly.

Scope of this pass

Not audited to depth: MainActivity.kt, the roughly twenty CSS-module changes and the DialogShell reduction, the index.html boot inline scripts, main.tsx and pwa-registration.ts, scripts/check-desktop-isolation.js, the packaging scripts and the new mobile-shell.yml workflow. None is a credential, cache-scope or origin-policy surface, and the Android shell is a development scaffold excluded from the npm and pnpm workspace traversal. The 38 open threads are Suggestion-level and were not treated as gates.

CI

Every non-skipped check on this head concluded success except cancelled runs of route, which are not attributable to this change. No failure exposes a defect introduced here.

@wenshao
wenshao dismissed a stale review September 17, 2026 22:23

fixed

@wenshao
wenshao added this pull request to the merge queue Sep 17, 2026
Merged via the queue into QwenLM:main with commit 535dfd9 Sep 17, 2026
112 of 113 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants