Compare commits

17 Commits
Author SHA1 Message Date
m1saka 169e1ffdc2 chore(runtime): upgrade DSH and bundled plugins for runtime-7.11 2026-10-30 17:08:16 +08:00
m1saka 0389555d76 chore(runtime): upgrade DSH and bundled plugins for runtime-7.10 2026-10-26 11:22:36 +08:00
m1saka 9961d42a2f fix(runtime): remove obsolete PDF browser compatibility patch 2026-10-18 19:24:17 +08:00
m1saka e36f3c1fdd fix(runtime): support PDF preview on Chrome 143 2026-10-18 15:51:26 +08:00
m1saka 605a48adb0 Revert "fix(runtime): polyfill PDF.js Map APIs for older WebViews"
This reverts commit e84906652e.
2026-10-18 14:22:40 +08:00
m1saka e84906652e fix(runtime): polyfill PDF.js Map APIs for older WebViews 2026-10-18 13:16:32 +08:00
m1saka b13b71b27c fix(runtime): hide successful plugin install output 2026-10-18 12:43:21 +08:00
m1saka b2d97dfda2 fix(runtime): synchronize lockfile after builtin plugin migration 2026-10-18 11:44:15 +08:00
m1saka 2e31e74972 feat(runtime): upgrade DSH and bundled plugins with startup migration 2026-10-18 02:14:27 +08:00
m1saka eb7e809999 fix(preview-download): declare remote service dependency 2026-10-17 03:31:32 +08:00
m1saka bbdee632f6 feat(runtime): add native office preview and download plugin 2026-10-17 03:03:32 +08:00
m1saka 65d118a145 fix(runtime): download pinned MiSans fonts individually for 7.5 2026-10-16 16:32:58 +08:00
m1saka 39cd470f92 chore(release): bump runtime to 7.4 2026-10-16 16:16:42 +08:00
m1saka 1fbb2a6fed feat(runtime): add document tools, MiSans and patched chat manager 2026-10-16 16:13:35 +08:00
m1saka a6bf83f037 chore: ignore Android build leftovers and signing files 2026-10-15 16:51:27 +08:00
m1saka ed6467d132 fix: enable host settings persistence in runtime-7.3 2026-10-15 10:35:22 +08:00
m1saka 1fda59fb09 feat: add ARM64 runtime release configuration 2026-10-14 19:42:34 +08:00
33 changed files with 1493 additions and 67 deletions
+29
View File
@@ -17,3 +17,32 @@ output/
!.env.example
*.sqlite*
*.log
# Local IDE files
.idea/
*.iml
# Generated files left when switching from android-app
/.dart_tool/
/.flutter-plugins-dependencies
/build/
/android/.gradle/
/android/.kotlin/
/android/build/
/android/app/build/
/android/local.properties
/android/gradlew
/android/gradlew.bat
/android/gradle/wrapper/gradle-wrapper.jar
/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java
# Android packages and signing credentials
*.apk
*.aab
*.jks
*.keystore
*.p12
*.pfx
keystore.properties
signing.properties
/android/key.properties
+127 -4
View File
@@ -1,27 +1,150 @@
# DSH Runtime Release
## runtime-7.11:DSH rc.5 与内置插件更新(待运行验收)
DSH 从 `0.2.1-rc.3` 更新为 `0.2.1-rc.5`(查询时 npm `latest` 和 `next` 均为此版本,不切换 alpha 渠道)。free-search 从 `0.9.15` 更新为 `0.9.21`,Chat Manager 从 `1.5.18-dsh-mgr.1` 更新为 `1.5.20-dsh-mgr.1`。mobile `3.2.1` 已是最新;自有 workspace `1.0.0`、preview-download `1.0.1` 及运行布局 7 不变。
同步安装归档 SHA-512、新 profile、已有内置归档迁移目标及精确宿主版本门禁,保留自定义来源、启用选择和备份机制。真实发布包的 Chat Manager 客户端补丁、原生设置接口及 Office converter 导入检查通过。48 项单元测试通过,新增 runtime-7.10 插件引用迁移、备份和幂等回归。完整镜像与运行验收结果另行记录;未提交、推送或修改线上实例。
AMD64 镜像 `dsh-web:runtime-7.11-amd64` 已成功构建;新版本真实依赖、插件 SHA-512 和安装期兼容性检查全部通过。禁网 UID 1000 新 profile 离线安装、重复执行启动入口(`--version`)及锁文件 SHA-256 不变通过;默认 Web 服务启动后,3080/8080 未认证请求均为 HTTP 401。测试容器已清理,未挂载现有工作区。尚未验收浏览器交互、真实搜索、设置保存、Office 实际转换、终端原生模块及 ARM64;本机没有 runtime-7.10 镜像,旧镜像锁文件迁移集成测试未执行(配置迁移单元回归已通过)。
## runtime-7.10:DSH rc.3 与内置插件更新(待运行验收)
沿用此前的 npm `next` 渠道,DSH 精确固定为 `0.2.1-rc.3`;查询时 `latest` 为 `0.2.1-rc.2`。mobile 更新为 `3.2.1`,free-search 为 `0.9.15`,Chat Manager 为 `1.5.18-dsh-mgr.1`(基于上游 `1.5.18`)。workspace `1.0.0`、preview-download `1.0.1` 和运行布局 7 保持不变。三个上游插件归档的 SHA-512 已核对并更新。
同步新 profile 和已有内置归档引用的迁移目标,保留自定义来源、插件启用选择、备份及锁文件同步机制。设置和 Office 补丁版本门禁对齐 rc.3,真实发布包的原生设置接口与 Office converter 导入入口检查通过。Chat Manager 的三处客户端适配仍精确匹配,新版真实源码补丁及语法检查通过;没有修改认证机制。mobile/free-search 的预发布 peer 例外仍只允许精确 rc.3 且满足 includePrerelease 范围。
本次 `node --test image/*.test.mjs` 共 46 项通过,包含新增的 runtime-7.9 实际插件版本引用迁移、备份及幂等回归。单元测试和源码核查不等同于完整运行兼容性证明;浏览器交互、真实搜索、设置保存、Office 实际转换、终端原生模块、容器离线启动及 ARM64 仍需本版本验收。未提交、推送或修改线上实例。
锁文件集成脚本默认验证 `dsh-web:runtime-7.9-amd64` → `dsh-web:runtime-7.10-amd64`,也可使用 `DSH_OLD_IMAGE` / `DSH_NEW_IMAGE` 指定已构建的镜像:`node image/lockfile-upgrade-smoke.mjs`。须先准备两个镜像;本次尚未完成该集成检查,不沿用旧发布的通过结论。
后续运行验收发现 preview-download 安装器仍限定 rc.2,已核对 rc.3 的操作插槽及 readBytes 接口并修正精确宿主门禁。新增安装器回归直接校验发布清单中的宿主版本可以完成打包调用、未经验证版本在写入前被拒绝;目前全部 47 项回归通过。
AMD64 镜像 `dsh-web:runtime-7.10-amd64` 已成功构建(ID `sha256:57d99cad32380dfc837fd1365814943b53f96abc07e84df154d02b7baa95f0bf`)。MiSans 下载首次超时,原来源重试后校验通过。禁网 UID 1000 新 profile 离线依赖安装、默认启动、未认证 HTTP 401、重启及锁文件哈希不变均通过。Office 实际 DOCX/PPTX/DOC/PPT 并发转换、已安装 DSH convertBytes 集成、输出/ZIP 限制、超时和清理通过。以上结果更新此前待验收状态;浏览器完整交互、真实搜索、设置保存、终端原生模块、旧镜像锁文件迁移和 ARM64 尚未验收,不代表可直接发布。
## runtime-7.9:DSH next 与启动配置迁移
锁文件同步修复:内置依赖迁移或补建 workspace 配置前,先持久化 `.dsh-lockfile-pending` 标记。仅带标记的启动安装显式传入 `--no-frozen-lockfile`,同步 `pnpm-lock.yaml`;成功后清除标记,后续启动恢复 CI 默认冻结检查。安装失败或超时保留标记并阻止应用启动,下次启动继续同步,不自动恢复旧 manifest。已有 manifest 备份机制保持不变。若旧版失败迁移已经留下“新 manifest+旧锁文件”且没有标记,应先恢复匹配的旧 profile 备份再升级;不要直接删除锁文件。
本次修复验证:45 项自动化测试通过;`node image/lockfile-upgrade-smoke.mjs` 使用本地 `dsh-upgrade-test:runtime-7.8` 与 `dsh-web:runtime-7.9-amd64` 镜像、独立临时卷、禁网非 root 容器,挂载当前修复脚本,验证真实旧锁文件迁移、随后冻结安装成功及重启锁文件哈希不变。脚本结束删除测试卷。本次未重跑面板 HTTP 升级接口或首页验收,未发布新镜像。
DSH 固定为 `0.2.1-rc.2`(本次明确选择 npm `next`,查询时 `latest` 仍为 `0.2.0-rc.2`),mobile 升级为 `3.1.0`,Chat Manager 升级为明确声明支持新宿主的 `1.5.16-dsh-mgr.1`。free-search 升级为 `0.9.2`,workspace `1.0.0`、preview-download `1.0.1` 保持不变。
现有 runtime 每次启动时检查 `profiles/web/package.json`,不另加容器、不联网追踪最新版。仅将 dependencies/devDependencies/optionalDependencies 中同名 `file:/usr/local/lib/dsh-mgr/<包名>-<版本>.tgz` 引用对齐镜像内置清单,并补齐相关缺失的 pnpm overrides。保留自定义安装来源、自定义 overrides、bundles、已删除的插件和其他配置。目标归档缺失、无效配置或不安全的符号链接会阻止启动。
只有配置变化才写入,原文保存在同目录 `package.json.backup-<UUID>`,新文件通过原子替换写入,权限 `0600`;重复启动不会反复改写。恢复时先停止实例并备份现状,再恢复对应备份;要回退旧内置版本必须同时使用匹配的旧镜像,否则下一次启动会再次对齐当前镜像。迁移不提供针对同 UID 恶意并发修改目录的沙箱保护。
新版设置 UI 已原生使用 host settings describe/mutate,构建只验证接口、不再应用旧 persistence 替换。Office 转换补丁仍限定精确宿主及插件版本;下载 slot/readBytes 和 workspaceRegistry 接口已核对上游源码。mobile/free-search 的预发布 peer 例外仅允许精确 `0.2.1-rc.2` 的 DSH 包且仍要求满足原版本范围(includePrerelease),其他依赖照常严格校验。
当前自动化回归 40 项通过;AMD64 镜像已构建,禁网 UID 1000 新 profile 安装、HTTP 未认证 401、旧 mobile/free-search/chat-manager 引用迁移、备份与幂等检查通过。Office 的已安装 DSH convertBytes 集成及 DOCX/PPTX/DOC/PPT 并发、限制、超时、清理测试通过。pnpm 会提示由宿主提供的 peer 缺失/范围冲突;这不是浏览器交互兼容性证明。浏览器完整交互、真实搜索结果、设置保存、终端原生模块和 ARM64 尚未验收。以下旧发布记录中的“已有 profile 不自动改写”仅适用于旧版本。
## runtime-7.8:修复下载按钮服务依赖
下载插件升级为 dsh-preview-download@1.0.1,补齐 remote 服务声明,修复点击时在发送请求前抛出 cannot get property "remote" without inject 的问题。同步更新内置包和新 profile 引用,新增注册回调依赖回归测试。此修复不处理面板 401、Office 504 或 plugins/events HTTP/2 连接错误。
已有实例升级镜像时,必须把 profile 中 dsh-preview-download 依赖的归档路径从 dsh-preview-download-1.0.0.tgz 改为 dsh-preview-download-1.0.1.tgz,保留其他配置和 bundles,重启后刷新页面;已有 profile 不自动改写。尚未完成线上浏览器下载验收。
## runtime-7.7:预览下载插件
内置独立插件 dsh-preview-download@1.0.0,在文档预览工具栏提供「下载原文件」。使用正式 sidebar.right.tab.document.actions 插槽,不修改上游 UI、不扫描 DOM、不增加服务端下载接口。通过当前会话 workspaceFiles.readBytes 获取原始字节,沿用服务端权限和完整文件大小上限;支持中文文件名、下载中状态、失败提示及切换文件/卸载时取消读取。下载的是原始文件,不是 Office 转换出的 PDF。文件元数据无法读取时不会显示工具栏按钮。
新建空 profile 默认启用。已有实例升级镜像后,在 /workspace/profiles/web/package.json 的 dependencies 中添加以下条目,并在 dsh.profile.bundles 中追加一次 dsh-preview-download,保留其他配置,然后重启:
~~~json
"dsh-preview-download": "file:/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.1.tgz"
~~~
插件源码位于 image/preview-download,也可单独打包安装。无第三方运行时依赖,React 由 DSH 浏览器提供。当前仅针对 DSH 0.2.0-rc.2 验证。25 项离线回归测试通过,已在现有 AMD64 镜像中验证插件打包、新 profile 离线安装及服务启动;完整 runtime-7.7 构建在 MiSans 下载阶段超时,尚未完成浏览器实际点击下载、移动端和 ARM64 验收。不应把单元测试中的拒绝响应模拟当作真实权限端到端验收。
## runtime-7.6:原生 Office 文档预览
Linux 文档预览通过构建期补丁将 Office-to-PDF 转换入口接到系统 LibreOffice,不再使用该入口原先默认的 WASM 后端。补丁限定 DSH 和 dsh-office-to-pdf 为 0.2.0-rc.2,目标 import 不匹配或重复应用时构建失败。保留上游授权、队列、缓存和 PDF 验证;不修改独立 dsoffice CLI 或其他插件。
每次转换使用私有临时目录、独立 profile、最高宏安全级别和独立进程组;保留默认 60 秒超时,取消时终止进程组。检查输入大小、OOXML 条目数、解压总量和 CRC、输出大小及完整性。子进程采用最小环境变量;校验器地址空间上限 512 MiB,LibreOffice 为 2 GiB,单文件写入上限为 max(maxOutputBytes, 16 MiB),最终 PDF 仍严格检查 maxOutputBytes。进程资源限制不是进程树总量或临时目录总量限制,部署时仍须容器内存、CPU、PID 和临时磁盘限制。
安全边界:原生 LibreOffice 与 DSH 使用同一 UID,独立 profile 不是文件系统或网络沙箱;不应将此功能视为隔离恶意文档的服务。容器内敏感文件及网络访问仍需部署侧隔离,尤其是处理不可信外部文档时。
字体由系统解析;显式 fontDirectories/fontFallbacks 配置会被拒绝,应移除并改用系统字体配置。kit 的 maxFontFiles/maxFontFileBytes/maxLoadedFontBytes 不再约束系统字体。missingFonts 为空仅表示不提供缺失字体诊断,不代表没有字体替换。
已完成 AMD64 构建、22 项回归测试,以及非 root/禁网容器中 DOCX/PPTX/DOC/PPT 并发转换、已安装 DSH convertBytes 集成、输出/压缩包限制、超时及清理验证。小型单页样本约 3 秒/文件,不代表实际文档性能保证。ARM64、真实失败样本、浏览器端到端、XLS/XLSX 和视觉版式尚未验收。
构建后在仓库根目录运行:
~~~sh
docker run --rm --network none --init \
--mount type=bind,src="$PWD/image",dst=/test,readonly \
--entrypoint node dsh-web:runtime-7.6-amd64 /test/office-preview-smoke.mjs
~~~
现有实例需升级新镜像才生效,不需要清空工作区。不自动提交、推送或升级线上实例。
## runtime-7.5:精简 MiSans 字重
runtime-7.5 修订仅保留 MiSans Regular 和 Bold,从第三方 GitHub 仓库 boyan01/mi_sans_font 的固定提交下载独立 TTF 并逐个校验 SHA-256,合计约 15.73 MB,不再下载官方完整 ZIP。构建环境需要能够访问 raw.githubusercontent.com。
## runtime-7.4:文档工具、中文字体与 Chat Manager 修复
runtime-7.4 修订内容:新增 Python、LibreOffice、Noto CJK 与 MiSans,并内置修复版 Chat Manager。DSH 版本仍为 0.2.0-rc.2,运行布局仍为 7,继续保留 runtime-7.3 的域名设置适配。已完成本地 AMD64 构建、19 项测试、离线启动及插件接口认证拒绝检查;ARM64 与线上面板完整删除流程尚未验收。
## 内置修复版 Chat Manager
镜像内置 `[email protected]`(基于官方 1.5.12,保留 MIT 许可及服务端安全校验)。补丁仅针对客户端:删除、恢复、归档搜索在面板内使用当前 `/dsh/<实例ID>/` 前缀,独立 DSH 根路径部署保持原行为;非 JSON 响应显示 HTTP 状态和代理排查提示,不再直接暴露 JSON 解析异常。不会修改全局 fetch、绕过 Cookie/Origin/可信主机校验或自动重试删除请求。
新建空 web profile 默认启用此插件,内置包和 clsx 依赖均支持离线安装。已有 profile 不会被覆盖,也不会自动替换用户已安装的官方版本。升级镜像后,如需迁移已有实例,请保留其他设置,将 profile 中同名依赖改为 `file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-dsh-mgr.1.tgz`,并设置 pnpm override `dsh-chat-manager>clsx` 为 `file:/usr/local/lib/dsh-mgr/clsx-2.1.1.tgz`,确保 bundles 中只有一次 `dsh-chat-manager`,再重启实例。不要同时启用重复的手动 patch 插件条目。
域名访问仍要求 DSH 配置相应 trusted host,且 runtime 必须经过面板认证与实例归属校验。此修复不开放面板根路径的插件接口。
## 内置 Python 与 LibreOffice
Runtime 镜像包含 Debian Bookworm 的 Python 3、pip、venv(`python` 指向 `python3`),以及 LibreOffice 和 Noto CJK 中文字体。LibreOffice 可通过 `libreoffice --headless` 无界面运行。Python 项目依赖请安装到工作区的虚拟环境中,不要覆盖系统 Python 包。
这些依赖在镜像构建时安装;已有实例需要重建镜像并升级后才生效。
另包含小米 MiSans 简体中文字体的 Regular 和 Bold 两个静态 TTF 字重,保留 Noto CJK,不修改默认字体优先级。构建时从第三方镜像 boyan01/mi_sans_font 的固定提交 68f3ec3fee5830ce8a1d955d462fa0df401a5341 下载两个 TTF 并校验各自 SHA-256;镜像文件与当前官网 ZIP 不同,不保证为最新版本,GitHub 仓库许可不替代小米字体许可。字体及声明位于 `/usr/local/share/fonts/misans`。MiSans 使用小米专有免费商用许可,并非开源字体许可;安装使用及对外分发镜像前应阅读[官方许可协议](https://hyperos.mi.com/font/zh/download/),不得单独分发或售卖字体。
## runtime-7.3:域名访问设置适配
自 runtime-7.3 起,构建时对 DSH 0.2.0-rc.2 的设置 UI 应用精确补丁,使设置视图与表单使用 host 持久化;不修改 connection.isLoopback、可信主机、Origin 或认证逻辑。版本或目标语句不匹配时构建失败,升级上游版本必须重新审核补丁。
这是构建期适配,不是普通插件。补丁本身不识别面板登录状态,该镜像的设置 UI 会对所有访问地址尝试远程设置操作,因此只能部署在受认证、校验实例归属的面板入口之后,不得绕过认证直接公开 runtime 端口。权限由服务端保证。
发布前须在非 localhost 域名验证提供商目录、配置与凭据保存、刷新和重启持久化,以及未登录、跨用户和非法 Origin 的拒绝行为。单元测试不代替这些验收;现有实例需升级镜像才生效,不覆盖用户 profile 或创建空 patch。
`dockerfile` 分支只维护发布清单和 runtime 镜像构建文件,不包含管理面板源码或部署配置。
## 发布清单
`release.json` 是发布入口:`schema_version` 为清单格式版本,`release_id` 标识一次发布,`version` 为精确 DSH 版本,`deployment_schema` 为运行布局版本,`platforms` 为支持的平台。当前为 `runtime-7.1`、DSH `0.2.0-rc.2`、布局 7、`linux/amd64`。
`release.json` 是发布入口:`schema_version` 为清单格式版本,`release_id` 标识一次发布,`version` 为精确 DSH 版本,`deployment_schema` 为运行布局版本,`platforms` 为支持的平台。当前待验收配置为 `runtime-7.11`、DSH `0.2.1-rc.5`、布局 7、`linux/amd64` 和 `linux/arm64`(aarch64,不含 32 位 ARM)。
ARM64 验收状态:已确认 Node/Caddy 基础镜像提供 ARM64 版本,但尚未完成 ARM64 runtime 构建及运行验收。清单声明会开放面板的对应平台构建入口,不等于兼容性证明;验收完成前不要推送发布。
此分支是可信代码来源,不是仅供读取的数据源:Dockerfile 和安装脚本会在构建时执行。只使用经过审核的提交,不要将不可信分支、PR 或任意远端内容直接用于构建。清单本身不代表镜像已经完成运行验收。
## 本地构建与检查
需要 Docker、Node.js,以及拉取基础镜像和构建依赖的网络。从仓库根目录执行,版本和平台直接取自清单,不跟踪 latest:
需要 Docker、Node.js,以及拉取基础镜像和构建依赖的网络。从仓库根目录执行,DSH 版本取自清单,平台按 Docker daemon 架构选择,不跟踪 latest:
~~~sh
set -eu
DSH_VERSION="$(node -p "require('./release.json').version")"
PLATFORM="$(node -p "require('./release.json').platforms[0]")"
case "$(docker info --format '{{.Architecture}}')" in
aarch64|arm64) PLATFORM=linux/arm64 ;;
x86_64|amd64) PLATFORM=linux/amd64 ;;
*) echo 'Unsupported Docker daemon architecture' >&2; exit 1 ;;
esac
RELEASE_ID="$(node -p "require('./release.json').release_id")"
node --test image/*.test.mjs
docker build --platform "$PLATFORM" \
--build-arg DSH_VERSION="$DSH_VERSION" \
-t "dsh-web:$RELEASE_ID" ./image
-t "dsh-web:$RELEASE_ID-${PLATFORM#linux/}" ./image
~~~
默认按 Docker daemon 架构原生构建,不再固定取清单第一个平台。在交叉构建环境可手动将 PLATFORM 设置为 linux/arm64 或 linux/amd64;需要事先配置对应原生 builder 或 QEMU/binfmt,仅设置 --platform 不会自动提供模拟能力。
Dockerfile 两个阶段均继承目标平台,无需写死架构;不要将 Caddy 阶段固定为构建主机平台,也不要复用另一架构的 node_modules。
发布前分别验证两个架构的镜像构建、非 root 启动、代理认证、终端及原生模块、内置插件离线协调、工作区写入和重启持久化,以及升级/恢复路径。迁移工作区时需重新安装其中的架构相关依赖。
`image/` 是构建上下文。构建只生成本地镜像,不推送镜像,也不自动发布分支。保留 `image/init-profile.test.mjs` 作为独立 runtime 回归测试,覆盖初始化幂等性、保留已有配置和拒绝符号链接目录;它不依赖面板,且被 `image/.dockerignore` 排除在构建上下文之外。此测试不能替代真实容器启动、插件和持久化验收。
## 布局 7 合约
+19 -2
View File
@@ -3,16 +3,25 @@ FROM node:24-bookworm-slim
ARG DSH_VERSION
# Keep the historical dependency-layer cache key; the template revision is set below.
ARG RECIPE_REV=3
RUN apt-get update && apt-get install -y --no-install-recommends tini ca-certificates git procps && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends \
tini ca-certificates git procps \
python3 python3-pip python3-venv python-is-python3 \
libreoffice fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
RUN python --version && python3 -m pip --version && libreoffice --headless --version
RUN test -n "$DSH_VERSION" && npm install --global --foreground-scripts --loglevel=verbose --registry=https://registry.npmjs.org "@deepseek-ai/dsh@${DSH_VERSION}"
RUN npm install --global --ignore-scripts --registry=https://registry.npmjs.org [email protected] && test "$(pnpm --version)" = "10.11.0"
COPY install-mobile.mjs /usr/local/lib/dsh-mgr/install-mobile.mjs
RUN node /usr/local/lib/dsh-mgr/install-mobile.mjs
COPY install-free-search.mjs /usr/local/lib/dsh-mgr/install-free-search.mjs
RUN node /usr/local/lib/dsh-mgr/install-free-search.mjs
COPY install-chat-manager.mjs patch-chat-manager.mjs /usr/local/lib/dsh-mgr/
RUN node /usr/local/lib/dsh-mgr/install-chat-manager.mjs
COPY install-workspace.mjs /usr/local/lib/dsh-mgr/install-workspace.mjs
RUN node /usr/local/lib/dsh-mgr/install-workspace.mjs
COPY init-profile.mjs /usr/local/lib/dsh-mgr/init-profile.mjs
COPY init-profile.mjs reconcile-profile.mjs /usr/local/lib/dsh-mgr/
COPY install-host-settings.mjs /usr/local/lib/dsh-mgr/install-host-settings.mjs
RUN node /usr/local/lib/dsh-mgr/install-host-settings.mjs
COPY --from=proxy /usr/bin/caddy /usr/bin/caddy
# Strip upstream file capabilities without granting any runtime capabilities.
# A fresh file copy omits security.capability xattrs; port 8080 needs none.
@@ -20,6 +29,14 @@ RUN cp /usr/bin/caddy /usr/bin/caddy.clean && mv /usr/bin/caddy.clean /usr/bin/c
COPY Caddyfile /etc/caddy/Caddyfile
COPY entrypoint.sh /usr/local/bin/dsh-entrypoint
RUN chmod 755 /usr/local/bin/dsh-entrypoint && mkdir -p /data /workspace /shared && ln -s /workspace /data/dsh-home && chown -R node:node /data /workspace /shared
COPY install-misans.mjs /usr/local/lib/dsh-mgr/install-misans.mjs
RUN node /usr/local/lib/dsh-mgr/install-misans.mjs
COPY MiSans-NOTICE.txt /usr/local/share/fonts/misans/NOTICE.txt
COPY install-office-preview.mjs native-office.mjs validate-office.py office-worker.py /usr/local/lib/dsh-mgr/
RUN node /usr/local/lib/dsh-mgr/install-office-preview.mjs
COPY preview-download /usr/local/lib/dsh-mgr/preview-download
COPY install-preview-download.mjs /usr/local/lib/dsh-mgr/install-preview-download.mjs
RUN node /usr/local/lib/dsh-mgr/install-preview-download.mjs
ARG RECIPE_REV=7
LABEL dsh-mgr.role=runtime dsh-mgr.version=$DSH_VERSION dsh-mgr.recipe=$RECIPE_REV
ENV DSH_HOME=/workspace HOME=/workspace XDG_DATA_HOME=/workspace/.local/share XDG_CONFIG_HOME=/workspace/.config
+14
View File
@@ -0,0 +1,14 @@
This runtime uses MiSans fonts by Xiaomi.
Official source: https://hyperos.mi.com/font/zh/download/
Download mirror (third-party, not an official Xiaomi repository):
https://github.com/boyan01/mi_sans_font/tree/68f3ec3fee5830ce8a1d955d462fa0df401a5341/lib/fonts
Files: MiSans-Regular.ttf and MiSans-Bold.ttf
These mirrored files differ from the current official ZIP; they are not claimed
to be the latest release. The mirror repository's license does not replace the
Xiaomi font license.
MiSans is subject to the MiSans Fonts Intellectual Property License Agreement
published on the official download page. It is not an open-source font license.
Installation and use indicate acceptance of that agreement. Do not modify the
fonts or distribute/sell them separately. Review the complete agreement before
redistributing a runtime image containing these fonts.
+13 -2
View File
@@ -8,16 +8,27 @@ if [[ -f "${DSH_HOME}/profiles/web/package.json" ]]; then
# pnpm otherwise probes registry metadata for the overlapping optional peer.
# Restrict offline mode to profiles containing only our fixed archives.
install_env=()
if node -e 'const m=require(process.env.DSH_HOME+"/profiles/web/package.json"); const pins={"dsh-mgr-workspace":"file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz","dsh-web-mobile":"file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz","dsh-free-search":"file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz"}; process.exit(Object.entries({...m.dependencies,...m.devDependencies,...m.optionalDependencies}).every(([n,v])=>pins[n]===v)?0:1)'; then
if node -e 'import("/usr/local/lib/dsh-mgr/reconcile-profile.mjs").then(({BUILTIN_DEPENDENCIES:pins,BUILTIN_OVERRIDES:overrides})=>{const m=require(process.env.DSH_HOME+"/profiles/web/package.json"); process.exit([m.dependencies,m.devDependencies,m.optionalDependencies].every(section=>Object.entries(section||{}).every(([n,v])=>pins[n]===v))&&Object.entries(m.pnpm?.overrides||{}).every(([n,v])=>overrides[n]===v)?0:1)})'; then
install_env=(npm_config_offline=true)
fi
if timeout --signal=TERM --kill-after=5s 120s env CI=true "${install_env[@]}" dsh plugin --profile web install --ignore-scripts; then
# Only migration installs may refresh the lockfile. Keep the marker on failure
# so a restart retries even when package.json already contains the new pins.
pending_lockfile="${DSH_HOME}/profiles/web/.dsh-lockfile-pending"
install_args=()
if [[ -f "$pending_lockfile" ]]; then
install_args+=(--no-frozen-lockfile)
fi
# Hide routine progress/peer warnings, but replay diagnostics on any failure.
if install_output=$(timeout --signal=TERM --kill-after=5s 120s env CI=true "${install_env[@]}" dsh plugin --profile web install --ignore-scripts "${install_args[@]}" 2>&1); then
rm -f -- "$pending_lockfile"
echo "dsh-mgr: plugin prestart reconciliation complete" >&2
else
status=$?
printf '%s\n' "$install_output" >&2
echo "dsh-mgr: plugin prestart reconciliation failed (exit $status); application not started" >&2
exit "$status"
fi
unset install_output
else
echo "dsh-mgr: no web plugin manifest; using bundled plugins" >&2
fi
+13 -4
View File
@@ -1,18 +1,20 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
for (const status of ['0', '23', '124', '137', 'empty']) {
test('runtime prestart status ' + status, () => {
for (const pending of [false, true]) {
test('runtime prestart status ' + status + ', pending migration ' + pending, () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-prestart-'));
try {
if (status !== 'empty') {
mkdirSync(join(dir, 'profiles/web'), { recursive: true, mode: 0o700 });
writeFileSync(join(dir, 'profiles/web/package.json'), '{}', { mode: 0o600 });
if (pending) writeFileSync(join(dir, 'profiles/web/.dsh-lockfile-pending'), 'pending');
}
const scripts = {
node: `#!/bin/bash
@@ -28,7 +30,9 @@ exec "$@"
printf 'dsh %s\n' "$*" >> "$TRACE"
test "$npm_config_ignore_scripts:$npm_config_ignore_pnpmfile" = true:true || exit 91
if [ "$1" = plugin ]; then
test "$*" = "plugin --profile web install --ignore-scripts" || exit 92
echo 'install progress and peer warnings'
echo 'install stderr diagnostics' >&2
test "$*" = "plugin --profile web install --ignore-scripts$EXPECTED_ARGS" || exit 92
exit "$PLUGIN_STATUS"
fi
exit 0
@@ -42,7 +46,7 @@ sleep 0.1
writeFileSync(join(dir, name), script, { mode: 0o700 });
}
const result = spawnSync('bash', [fileURLToPath(new URL('./entrypoint.sh', import.meta.url)), 'web', '--no-open', '--port', '3080'], {
env: { ...process.env, PATH: dir + ':' + process.env.PATH, DSH_HOME: dir, TRACE: join(dir, 'trace'), PLUGIN_STATUS: status },
env: { ...process.env, PATH: dir + ':' + process.env.PATH, DSH_HOME: dir, TRACE: join(dir, 'trace'), PLUGIN_STATUS: status, EXPECTED_ARGS: pending ? ' --no-frozen-lockfile' : '' },
encoding: 'utf8', timeout: 5000,
});
assert.ifError(result.error);
@@ -55,10 +59,14 @@ sleep 0.1
return;
}
assert.match(trace, /dsh plugin --profile web install --ignore-scripts/);
assert.equal(existsSync(join(dir, 'profiles/web/.dsh-lockfile-pending')), pending && status !== '0');
if (status === '0') {
assert.doesNotMatch(output, /install progress|install stderr/);
assert.match(output, /reconciliation complete/);
assert.match(trace, /dsh web/);
} else {
assert.match(result.stderr, /install progress and peer warnings/);
assert.match(result.stderr, /install stderr diagnostics/);
assert.ok(output.includes('failed (exit ' + status + ')'), output);
assert.doesNotMatch(trace, /caddy|dsh web/);
}
@@ -66,6 +74,7 @@ sleep 0.1
rmSync(dir, { recursive: true, force: true });
}
});
}
}
test('runtime pins pnpm and uses nonroot with tini process-group forwarding', () => {
+36 -33
View File
@@ -1,35 +1,38 @@
import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync, lstatSync } from 'node:fs';
import { join } from 'node:path';
const home = process.env.DSH_HOME;
if (!home) throw new Error('DSH_HOME required');
const profile = join(home, 'profiles/web');
for (const dir of [home, join(home, 'profiles'), profile]) {
try {
const stat = lstatSync(dir);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('unsafe profile directory');
} catch (error) { if (error.code !== 'ENOENT') throw error; }
}
// Never rewrite an existing user manifest or patch.
if (!existsSync(profile) || readdirSync(profile).length === 0) {
mkdirSync(profile, { recursive: true });
writeFileSync(join(profile, 'package.json'), JSON.stringify({
name: 'dsh-profile-web', private: true,
dependencies: { 'dsh-mgr-workspace': 'file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz', 'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz', 'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz' },
pnpm: { overrides: {
'dsh-free-search>@deepseek-ai/schemastery': 'file:/usr/local/lib/dsh-mgr/schemastery-3.18.4.tgz',
'@deepseek-ai/schemastery>@deepseek-ai/cosmokit': 'file:/usr/local/lib/dsh-mgr/cosmokit-1.8.5.tgz',
'@deepseek-ai/schemastery>@standard-schema/spec': 'file:/usr/local/lib/dsh-mgr/spec-1.1.0.tgz',
} },
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'dsh-web-mobile', 'dsh-free-search', 'dsh-mgr-workspace'] } },
}, null, 2), { flag: 'wx', mode: 0o600 });
console.log('dsh-mgr: new web profile includes [email protected] and [email protected]');
}
const manifestPath = join(profile, 'package.json');
const workspacePath = join(profile, 'pnpm-workspace.yaml');
// Backups omit workspace settings; restore host-provided peers before pnpm runs.
if (existsSync(manifestPath) && !existsSync(workspacePath)) {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
if (manifest.dependencies?.['dsh-web-mobile'] === 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz' || manifest.dependencies?.['dsh-free-search'] === 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz') {
writeFileSync(workspacePath, 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n', { flag: 'wx', mode: 0o600 });
import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { BUILTIN_DEPENDENCIES, BUILTIN_OVERRIDES, assertSafeProfile, reconcileProfile, markLockfilePending } from './reconcile-profile.mjs';
export function initProfile(home, options) {
if (!home) throw new Error('DSH_HOME required');
const profile = join(home, 'profiles/web');
assertSafeProfile(profile);
if (!existsSync(profile) || readdirSync(profile).length === 0) {
mkdirSync(profile, { recursive: true });
writeFileSync(join(profile, 'package.json'), JSON.stringify({
name: 'dsh-profile-web', private: true,
dependencies: BUILTIN_DEPENDENCIES,
pnpm: { overrides: BUILTIN_OVERRIDES },
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'dsh-web-mobile', 'dsh-free-search', 'dsh-mgr-workspace', 'dsh-chat-manager', 'dsh-preview-download'] } },
}, null, 2), { flag: 'wx', mode: 0o600 });
console.log('dsh-mgr: new web profile includes dsh-web-mobile@3.2.1 and [email protected] and [email protected]');
}
const migration = reconcileProfile(profile, options);
console.log(migration.changed
? "dsh-mgr: builtin profile updated; original saved to " + migration.backupPath
: "dsh-mgr: builtin profile check complete; no changes");
const manifestPath = join(profile, 'package.json');
const workspacePath = join(profile, 'pnpm-workspace.yaml');
// Backups omit workspace settings; restore host-provided peers before pnpm runs.
if (existsSync(manifestPath) && !existsSync(workspacePath)) {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
if (['dependencies', 'devDependencies', 'optionalDependencies'].some(section =>
['dsh-chat-manager', 'dsh-web-mobile', 'dsh-free-search'].some(name => manifest[section]?.[name] === BUILTIN_DEPENDENCIES[name]))) {
markLockfilePending(profile);
writeFileSync(workspacePath, 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n', { flag: 'wx', mode: 0o600 });
}
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
initProfile(process.env.DSH_HOME);
}
+49 -5
View File
@@ -2,33 +2,53 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { join, basename } from 'node:path';
import { execFileSync } from 'node:child_process';
import { initProfile } from './init-profile.mjs';
import { BUILTIN_DEPENDENCIES, BUILTIN_OVERRIDES } from './reconcile-profile.mjs';
function archives(home) {
const archiveDirectory = join(home, 'archives');
mkdirSync(archiveDirectory, { recursive: true });
for (const value of [...Object.values(BUILTIN_DEPENDENCIES), ...Object.values(BUILTIN_OVERRIDES)]) {
writeFileSync(join(archiveDirectory, basename(value)), 'fixture');
}
return { archiveDirectory };
}
test('new profile enables bundle once and preserves existing configuration', () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-init-'));
try {
const run = () => execFileSync(process.execPath, [new URL('./init-profile.mjs', import.meta.url).pathname], {env:{...process.env, DSH_HOME:home}});
const options = archives(home);
const run = () => initProfile(home, options);
run();
const file=join(home,'profiles/web/package.json');
const original=readFileSync(file,'utf8');
assert.equal(JSON.parse(original).dependencies['dsh-preview-download'], 'file:/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.1.tgz');
assert.equal(JSON.parse(original).dsh.profile.bundles.filter(name => name === 'dsh-preview-download').length, 1);
assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-chat-manager'));
assert.equal(JSON.parse(original).dependencies['dsh-chat-manager'], 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.20-dsh-mgr.1.tgz');
assert.equal(JSON.parse(original).pnpm.overrides['dsh-chat-manager>clsx'], 'file:/usr/local/lib/dsh-mgr/clsx-2.1.1.tgz');
assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-web-mobile'));
assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-free-search'));
assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-mgr-workspace'));
assert.equal(JSON.parse(original).dependencies['dsh-mgr-workspace'], 'file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz');
assert.equal(JSON.parse(original).dependencies['dsh-free-search'], 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz');
assert.equal(JSON.parse(original).dependencies['dsh-free-search'], 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.9.21.tgz');
assert.equal(JSON.parse(original).pnpm.overrides['dsh-free-search>@deepseek-ai/schemastery'], 'file:/usr/local/lib/dsh-mgr/schemastery-3.18.4.tgz');
assert.equal(JSON.parse(original).dependencies['dsh-web-mobile'], 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz');
assert.equal(JSON.parse(original).dependencies['dsh-web-mobile'], 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.2.1.tgz');
const workspace = join(home, 'profiles/web/pnpm-workspace.yaml');
assert.match(readFileSync(workspace, 'utf8'), /autoInstallPeers: false/);
assert.match(readFileSync(workspace, 'utf8'), /nodeLinker: hoisted/);
run();assert.equal(readFileSync(file,'utf8'),original);
const legacy = JSON.parse(original);
delete legacy.dependencies['dsh-mgr-workspace'];
delete legacy.dependencies['dsh-chat-manager'];
delete legacy.pnpm.overrides['dsh-chat-manager>clsx'];
legacy.dsh.profile.bundles = legacy.dsh.profile.bundles.filter(name => name !== 'dsh-chat-manager');
legacy.dsh.profile.bundles = legacy.dsh.profile.bundles.filter(name => name !== 'dsh-mgr-workspace');
writeFileSync(file, JSON.stringify(legacy));
run();
assert.deepEqual(JSON.parse(readFileSync(file)), legacy);
const disabled = JSON.parse(original);
disabled.dsh.profile.bundles = disabled.dsh.profile.bundles.filter(name => name !== 'dsh-chat-manager');
disabled.dsh.profile.bundles = disabled.dsh.profile.bundles.filter(name => name !== 'dsh-web-mobile');
writeFileSync(file, JSON.stringify(disabled));
writeFileSync(workspace, 'custom workspace');
@@ -42,13 +62,37 @@ test('new profile enables bundle once and preserves existing configuration', ()
writeFileSync(file,custom);run();assert.equal(readFileSync(file,'utf8'),custom);
} finally { rmSync(home,{recursive:true,force:true}); }
});
test('chat-manager-only restored profile gets overrides and offline peer settings', () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-chat-restore-'));
try {
const profile = join(home, 'profiles/web');
mkdirSync(profile, { recursive: true });
const manifest = JSON.stringify({ dependencies: { 'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.20-dsh-mgr.1.tgz' } });
writeFileSync(join(profile, 'package.json'), manifest);
initProfile(home, archives(home));
assert.deepEqual(JSON.parse(readFileSync(join(profile, 'package.json'))), { ...JSON.parse(manifest), pnpm: { overrides: { 'dsh-chat-manager>clsx': BUILTIN_OVERRIDES['dsh-chat-manager>clsx'] } } });
assert.match(readFileSync(join(profile, 'pnpm-workspace.yaml'), 'utf8'), /autoInstallPeers: false/);
const entrypoint = readFileSync(new URL('./entrypoint.sh', import.meta.url), 'utf8');
const predicate = entrypoint.match(/if node -e '([^']+)'/)[1].replace('/usr/local/lib/dsh-mgr/reconcile-profile.mjs', new URL('./reconcile-profile.mjs', import.meta.url).href);
execFileSync(process.execPath, ['-e', predicate], { env: { ...process.env, DSH_HOME: home } });
for (const custom of [
{ dependencies: { 'dsh-chat-manager': BUILTIN_DEPENDENCIES['dsh-chat-manager'] }, pnpm: { overrides: { 'dsh-chat-manager>clsx': '^2.0.0' } } },
{ dependencies: { 'dsh-chat-manager': '^1.5.16' }, optionalDependencies: { 'dsh-chat-manager': BUILTIN_DEPENDENCIES['dsh-chat-manager'] } },
]) {
writeFileSync(join(profile, 'package.json'), JSON.stringify(custom));
assert.throws(() => execFileSync(process.execPath, ['-e', predicate], { env: { ...process.env, DSH_HOME: home }, stdio: 'pipe' }));
}
writeFileSync(join(profile, 'package.json'), JSON.stringify({ dependencies: { 'dsh-chat-manager': '^1.5.16' } }));
assert.throws(() => execFileSync(process.execPath, ['-e', predicate], { env: { ...process.env, DSH_HOME: home }, stdio: 'pipe' }));
} finally { rmSync(home, { recursive: true, force: true }); }
});
test('nonempty legacy profiles are not automatically injected', () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-legacy-'));
try {
const profile = join(home, 'profiles/web');
mkdirSync(profile, { recursive: true });
writeFileSync(join(profile, 'cordis.patch.yml'), '[]');
execFileSync(process.execPath, [new URL('./init-profile.mjs', import.meta.url).pathname], { env: { ...process.env, DSH_HOME: home } });
initProfile(home, archives(home));
assert.equal(existsSync(join(profile, 'package.json')), false);
assert.equal(existsSync(join(profile, 'pnpm-workspace.yaml')), false);
} finally { rmSync(home, { recursive: true, force: true }); }
+68
View File
@@ -0,0 +1,68 @@
import { createHash } from 'node:crypto';
import { mkdirSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
import { patchClient } from './patch-chat-manager.mjs';
const root = '/usr/local/lib/dsh-mgr';
const version = '1.5.20';
const patchedVersion = '1.5.20-dsh-mgr.1';
const hostManifest = '/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json';
const destination = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/dsh-chat-manager';
const packages = [
['dsh-chat-manager', version, 'https://registry.npmjs.org/dsh-chat-manager/-/dsh-chat-manager-1.5.20.tgz', 'V/7N48lJioxBr2h+hmVnNoZB5jjoT/EGC6Pk8pSbNJveQoHye+zBj3tUX1Xf520YyopVk5fSVpt+aeCkBBVxdA=='],
['clsx', '2.1.1', 'https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz', 'eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=='],
];
mkdirSync(root, { recursive: true });
for (const [name, release, url, integrity] of packages) {
const response = await fetch(url, { signal: AbortSignal.timeout(120000) });
if (!response.ok) throw new Error('download failed: ' + name);
const data = Buffer.from(await response.arrayBuffer());
if (createHash('sha512').update(data).digest('base64') !== integrity) throw new Error('integrity mismatch: ' + name);
writeFileSync(`${root}/${name}-${release}.tgz`, data, { mode: 0o644 });
}
mkdirSync(destination, { recursive: true });
execFileSync('tar', ['-xzf', `${root}/dsh-chat-manager-${version}.tgz`, '--strip-components=1', '--no-same-owner', '-C', destination]);
const pkg = JSON.parse(readFileSync(destination + '/package.json'));
if (pkg.name !== 'dsh-chat-manager' || pkg.version !== version || pkg.license !== 'MIT' || JSON.stringify(pkg.dependencies) !== JSON.stringify({ clsx: '^2.0.0' })) throw new Error('unexpected chat-manager metadata');
if (!readFileSync(destination + '/LICENSE', 'utf8').includes('MIT License')) throw new Error('missing chat-manager MIT license');
const require = createRequire(hostManifest);
const host = JSON.parse(readFileSync(hostManifest));
const semver = createRequire('/usr/local/lib/node_modules/npm/package.json')('semver');
for (const [name, range] of Object.entries(pkg.peerDependencies || {})) {
// 1.5.20 retains this optional legacy peer but its runtime no longer imports it.
if (name === '@deepseek-ai/dsh-invariants' && pkg.peerDependenciesMeta?.[name]?.optional) {
for (const file of ['src/index.js', 'src/host/archive-manager.mjs', 'src/host/delete-session.mjs', 'lib/client.js']) {
if (readFileSync(destination + '/' + file, 'utf8').includes(name)) throw new Error('legacy invariants peer is used again');
}
continue;
}
if (name === 'react') {
if (host.version !== '0.2.1-rc.5') throw new Error('unverified browser React host: ' + host.version);
continue;
}
// New web hosts bundle this pure UI module in the browser bootstrap.
if (['@deepseek-ai/dsh-client-ui-primitives', '@deepseek-ai/dsh-client-ui-slots'].includes(name) && host.version === '0.2.1-rc.5') {
const frontend = require.resolve('@deepseek-ai/dsh-web-frontend/package.json');
const assets = frontend.slice(0, -'package.json'.length) + 'dist/assets';
const frontendVersion = JSON.parse(readFileSync(frontend)).version;
if (frontendVersion !== host.version || !semver.satisfies(frontendVersion, range) || !readdirSync(assets).filter(file => /^index-.*\.js$/.test(file)).some(file => readFileSync(assets + '/' + file, 'utf8').includes(JSON.stringify(name) + ':'))) throw new Error('missing browser UI provider');
continue;
}
const peer = require(name + '/package.json');
if (!semver.satisfies(peer.version, range)) throw new Error('incompatible chat-manager host dependency: ' + name + '@' + peer.version);
}
const client = destination + '/lib/client.js';
writeFileSync(client, patchClient(readFileSync(client, 'utf8'), version));
pkg.version = patchedVersion;
writeFileSync(destination + '/package.json', JSON.stringify(pkg, null, 2));
execFileSync('chmod', ['-R', 'a+rX', destination]);
execFileSync('tar', ['-czf', `${root}/dsh-chat-manager-${patchedVersion}.tgz`, '--transform=s,^dsh-chat-manager,package,', '-C', destination + '/..', 'dsh-chat-manager']);
execFileSync('chmod', ['644', `${root}/dsh-chat-manager-${patchedVersion}.tgz`]);
const clsx = destination + '/node_modules/clsx';
mkdirSync(clsx, { recursive: true });
execFileSync('tar', ['-xzf', `${root}/clsx-2.1.1.tgz`, '--strip-components=1', '--no-same-owner', '-C', clsx]);
execFileSync('chmod', ['-R', 'a+rX', clsx]);
host.dependencies = { ...host.dependencies, 'dsh-chat-manager': patchedVersion };
writeFileSync(hostManifest, JSON.stringify(host, null, 2));
console.log('Installed dsh-chat-manager@' + patchedVersion + ' and [email protected] (SHA-512 verified; no lifecycle scripts; MIT retained)');
+7 -7
View File
@@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
const root = '/usr/local/lib/dsh-mgr';
const packages = [
['dsh-free-search', '0.8.5', 'https://registry.npmjs.org/dsh-free-search/-/dsh-free-search-0.8.5.tgz', 'MT00Xtypw9uk9QsqaiRmtVbYwnPON+HJY1lZjYritEvuwSe9ahgG9NVO43lUI+Eje2umrqUapaeqLRYf34DrZw=='],
['dsh-free-search', '0.9.21', 'https://registry.npmjs.org/dsh-free-search/-/dsh-free-search-0.9.21.tgz', '3RtN+hphVn5WcqjbwPBkG+QGgz7YO6KtkAKQFmWmsVl3lmDEhLENuno95JheKahllt0IXCLFCTHsI2HSzttg4g=='],
['@deepseek-ai/schemastery', '3.18.4', 'https://registry.npmjs.org/@deepseek-ai/schemastery/-/schemastery-3.18.4.tgz', 'SSXO6tYuyrIqKVbmOnIq0s+riUywYzouFMcnBluHF9n4KMo2G8HvEzhCYj6pj2617/glOjbJuVInJ9hfONsjkg=='],
['@deepseek-ai/cosmokit', '1.8.5', 'https://registry.npmjs.org/@deepseek-ai/cosmokit/-/cosmokit-1.8.5.tgz', 'LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A=='],
['@standard-schema/spec', '1.1.0', 'https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz', 'l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=='],
@@ -20,21 +20,21 @@ for (const [name, version, url, integrity] of packages) {
const hostManifest = '/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json';
const destination = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/dsh-free-search';
mkdirSync(destination, { recursive: true });
execFileSync('tar', ['-xzf', `${root}/dsh-free-search-0.8.5.tgz`, '--strip-components=1', '--no-same-owner', '-C', destination]);
execFileSync('tar', ['-xzf', `${root}/dsh-free-search-0.9.21.tgz`, '--strip-components=1', '--no-same-owner', '-C', destination]);
execFileSync('chmod', ['-R', 'a+rX', destination]);
const pkg = JSON.parse(readFileSync(destination + '/package.json'));
if (pkg.name !== 'dsh-free-search' || pkg.version !== '0.8.5' || JSON.stringify(pkg.dependencies) !== JSON.stringify({ '@deepseek-ai/schemastery': '^3.18.4' })) throw new Error('unexpected free-search metadata');
if (pkg.name !== 'dsh-free-search' || pkg.version !== '0.9.21' || JSON.stringify(pkg.dependencies) !== JSON.stringify({ '@deepseek-ai/schemastery': '^3.18.4' })) throw new Error('unexpected free-search metadata');
const require = createRequire(hostManifest);
const semver = createRequire('/usr/local/lib/node_modules/npm/package.json')('semver');
for (const [name, range] of Object.entries({ ...pkg.peerDependencies, ...pkg.dependencies })) {
if (name === 'react') {
if (!['0.2.0-rc.1', '0.2.0-rc.2'].includes(require('./package.json').version)) throw new Error('unverified browser React host');
if (require('./package.json').version !== '0.2.1-rc.5') throw new Error('unverified browser React host');
continue;
}
const peer = require(name + '/package.json');
if (!semver.satisfies(peer.version, range)) throw new Error('incompatible free-search host dependency: ' + name);
if (!(semver.satisfies(peer.version, range) || (peer.version === "0.2.1-rc.5" && name.startsWith("@deepseek-ai/dsh-") && semver.satisfies(peer.version, range, { includePrerelease: true })))) throw new Error('incompatible free-search host dependency: ' + name);
}
const host = JSON.parse(readFileSync(hostManifest));
host.dependencies = { ...host.dependencies, 'dsh-free-search': '0.8.5' };
host.dependencies = { ...host.dependencies, 'dsh-free-search': '0.9.21' };
writeFileSync(hostManifest, JSON.stringify(host, null, 2));
console.log('Installed dsh-free-search@0.8.5 and pinned offline dependency archives (SHA-512 verified; no lifecycle scripts)');
console.log('Installed dsh-free-search@0.9.21 and pinned offline dependency archives (SHA-512 verified; no lifecycle scripts)');
+32
View File
@@ -0,0 +1,32 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
export const target = 'const persistence = ctx.remote.$host.isLoopback ? "host" : "memory";';
const replacement = 'const persistence = "host"; /* dsh-mgr: authenticated remote settings */';
// Deliberately scoped to the settings UI, not connection.isLoopback or auth.
// Fail closed on upstream changes instead of silently shipping a broken UI.
export function patchSettings(source, version) {
// Native host settings supersede the old persistence patch.
if (version === "0.2.1-rc.5") {
if (source.includes(target) || !source.includes("this.ctx.remote.settings.describe()") || !source.includes("this.ctx.remote.settings.mutate(")) throw new Error("expected native host settings API");
return source;
}
if (version !== '0.2.0-rc.2') throw new Error('unsupported DSH version for host settings patch');
if (source.split(target).length !== 2 || source.includes(replacement)) {
throw new Error('expected exactly one unpatched settings persistence selector');
}
return source.replace(target, replacement);
}
export function install(root = '/usr/local/lib/node_modules/@deepseek-ai/dsh') {
const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
const file = join(root, 'node_modules/@deepseek-ai/dsh-client-ui-settings/lib/client.js');
const source = readFileSync(file, 'utf8');
writeFileSync(file, patchSettings(source, version));
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
install();
}
+39
View File
@@ -0,0 +1,39 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { runInNewContext } from 'node:vm';
import { readFileSync } from 'node:fs';
import { patchSettings, target } from './install-host-settings.mjs';
test("next host settings API remains unchanged", () => {
const source = "this.ctx.remote.settings.describe(); this.ctx.remote.settings.mutate(namespace, ops, revision);";
assert.equal(patchSettings(source, "0.2.1-rc.5"), source);
assert.throws(() => patchSettings(target, "0.2.1-rc.5"), /native host settings/);
assert.throws(() => patchSettings("", "0.2.1-rc.5"), /native host settings/);
});
test('settings use host persistence on domains without changing loopback facts', () => {
for (const isLoopback of [false, true]) {
const ctx = { remote: { $host: { isLoopback } } };
const source = '(function(ctx) { ' + target + ' return persistence; })(ctx)';
assert.equal(runInNewContext(patchSettings(source, '0.2.0-rc.2'), { ctx }), 'host');
assert.equal(ctx.remote.$host.isLoopback, isLoopback);
}
});
test('reject unsupported versions, changed selectors, duplicates and repeat installation', () => {
assert.throws(() => patchSettings(target, '0.2.0-rc.3'), /unsupported DSH version/);
for (const source of ['', target.replace('isLoopback', 'isLocal'), target + target,
patchSettings(target, '0.2.0-rc.2')]) {
assert.throws(() => patchSettings(source, '0.2.0-rc.2'), /exactly one/);
}
});
test('patch leaves surrounding code unchanged and is wired into the image', () => {
const source = 'before; ' + target + ' after;';
const result = patchSettings(source, '0.2.0-rc.2');
assert.ok(result.startsWith('before; '));
assert.ok(result.endsWith(' after;'));
const dockerfile = readFileSync(new URL('./Dockerfile', import.meta.url), 'utf8');
assert.ok(dockerfile.includes('COPY install-host-settings.mjs /usr/local/lib/dsh-mgr/install-host-settings.mjs'));
assert.ok(dockerfile.indexOf('RUN node /usr/local/lib/dsh-mgr/install-host-settings.mjs') > dockerfile.indexOf('npm install --global --foreground-scripts'));
});
+29
View File
@@ -0,0 +1,29 @@
import { createHash } from 'node:crypto';
import { writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
// Third-party mirror, pinned to a reviewed commit; Xiaomi's font license applies.
const base = 'https://raw.githubusercontent.com/boyan01/mi_sans_font/68f3ec3fee5830ce8a1d955d462fa0df401a5341/lib/fonts/';
const fonts = [
['MiSans-Regular.ttf', '7172aa1b5c703780ee12df3fe4c50481dde01f3b125a045a398000a88d032ae5', 'Regular'],
['MiSans-Bold.ttf', '250fb5c8d4657f103e332b0b948a995e45f2c159beb8d0528424e2d32ae421be', 'Bold'],
];
const destination = '/usr/local/share/fonts/misans';
mkdirSync(destination, { recursive: true });
for (const [name, sha256] of fonts) {
console.log('Downloading ' + name);
const response = await fetch(base + name, { signal: AbortSignal.timeout(120000) });
if (!response.ok) throw new Error('MiSans download failed: ' + name + ' HTTP ' + response.status);
const font = Buffer.from(await response.arrayBuffer());
if (createHash('sha256').update(font).digest('hex') !== sha256) {
throw new Error('MiSans checksum mismatch: ' + name);
}
writeFileSync(join(destination, name), font, { mode: 0o644 });
}
execFileSync('fc-cache', ['-f', destination], { stdio: 'inherit' });
for (const [name, , style] of fonts) {
const file = execFileSync('fc-match', ['-f', '%{file}', 'MiSans:style=' + style], { encoding: 'utf8' });
if (file !== join(destination, name)) throw new Error('MiSans font lookup failed: ' + style + ': ' + file);
}
console.log('Installed MiSans Regular and Bold (SHA-256 verified)');
+7 -7
View File
@@ -2,15 +2,15 @@ import { createHash } from 'node:crypto';
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module';
const version = '3.0.5';
const integrity = 'N3wmegFHSd23J0+xjotKyBq0ZbLXUbkWZ65E+aSw5pzB/nudhaNHWT2V3CSNaW/LX3s6AxAWB2APpfA0dLXJZQ==';
const response = await fetch('https://registry.npmjs.org/dsh-web-mobile/-/dsh-web-mobile-3.0.5.tgz', { signal: AbortSignal.timeout(120000) });
const version = '3.2.1';
const integrity = '2XHbebwr78ujeJVt3wqffSp7/LEnLqUajawnL0TWNGG6LcAimAm8q8GJdh28c7gdRs6BYAGxfF/TatA5E6xZ+A==';
const response = await fetch('https://registry.npmjs.org/dsh-web-mobile/-/dsh-web-mobile-3.2.1.tgz', { signal: AbortSignal.timeout(120000) });
if (!response.ok) throw new Error('mobile plugin download failed');
const data = Buffer.from(await response.arrayBuffer());
if (createHash('sha512').update(data).digest('base64') !== integrity) throw new Error('mobile plugin integrity mismatch');
const destination = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/dsh-web-mobile';
mkdirSync(destination, { recursive: true });
const archive = '/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz';
const archive = '/usr/local/lib/dsh-mgr/dsh-web-mobile-3.2.1.tgz';
writeFileSync(archive, data, { mode: 0o644 });
execFileSync('tar', ['-xzf', archive, '--strip-components=1', '--no-same-owner', '-C', destination]);
// Published tar modes can be owner-only; runtime is UID 1000, not build root.
@@ -24,7 +24,7 @@ for (const [name, range] of Object.entries(pkg.peerDependencies || {})) {
// Limit that exception to the upstream-declared, tested RC hosts.
if (name === 'react') {
const host = require('./package.json').version;
if (!['0.2.0-rc.1', '0.2.0-rc.2'].includes(host)) throw new Error('unverified browser React host: ' + host);
if (host !== '0.2.1-rc.5') throw new Error('unverified browser React host: ' + host);
continue;
}
let peer;
@@ -33,7 +33,7 @@ for (const [name, range] of Object.entries(pkg.peerDependencies || {})) {
if (pkg.peerDependenciesMeta?.[name]?.optional) continue;
throw new Error('missing required mobile peer: ' + name);
}
if (!semver.satisfies(peer.version, range)) throw new Error('incompatible mobile peer: ' + name + '@' + peer.version);
if (!(semver.satisfies(peer.version, range) || (peer.version === "0.2.1-rc.5" && name.startsWith("@deepseek-ai/dsh-") && semver.satisfies(peer.version, range, { includePrerelease: true })))) throw new Error('incompatible mobile peer: ' + name + '@' + peer.version);
}
// DSH runtime resolution traverses the installation manifest, not arbitrary
// directories in node_modules. Declare the baked package in that graph.
@@ -41,4 +41,4 @@ const hostManifest = '/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'
const host = JSON.parse(readFileSync(hostManifest));
host.dependencies = { ...host.dependencies, 'dsh-web-mobile': version };
writeFileSync(hostManifest, JSON.stringify(host, null, 2));
console.log('Installed dsh-web-mobile@3.0.5 (SHA-512 verified; no lifecycle scripts)');
console.log('Installed dsh-web-mobile@3.2.1 (SHA-512 verified; no lifecycle scripts)');
+21
View File
@@ -0,0 +1,21 @@
import { readFileSync, writeFileSync, copyFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
export const target = 'import { createConverter } from "@deepseek-ai/libreoffice-kit";';
export function patchOffice(source, hostVersion, packageVersion) {
if (hostVersion !== '0.2.1-rc.5' || packageVersion !== '0.2.1-rc.5') throw new Error('unsupported Office preview version');
if (source.split(target).length !== 2) throw new Error('expected exactly one original converter import');
return source.replace(target, 'import { createConverter } from "./dsh-native-office.mjs";');
}
export function install(root = '/usr/local/lib/node_modules/@deepseek-ai/dsh') {
const dir = join(root, 'node_modules/@deepseek-ai/dsh-office-to-pdf');
const version = path => JSON.parse(readFileSync(join(path, 'package.json'), 'utf8')).version;
const file = join(dir, 'lib/index.js');
const patched = patchOffice(readFileSync(file, 'utf8'), version(root), version(dir));
for (const [source, name] of [['native-office.mjs', 'dsh-native-office.mjs'], ['validate-office.py', 'validate-office.py'], ['office-worker.py', 'office-worker.py']]) {
copyFileSync(new URL(source, import.meta.url), join(dir, 'lib', name));
}
writeFileSync(file, patched);
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) install();
+8
View File
@@ -0,0 +1,8 @@
import { cpSync, mkdirSync, readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
const root = '/usr/local/lib/dsh-mgr';
const host = JSON.parse(readFileSync('/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'));
if (host.version !== '0.2.1-rc.5') throw new Error('Unverified preview download host');
mkdirSync(root + '/preview-download-package', { recursive: true });
cpSync(new URL('./preview-download', import.meta.url), root + '/preview-download-package/package', { recursive: true });
execFileSync('tar', ['-czf', root + '/dsh-preview-download-1.0.1.tgz', '-C', root + '/preview-download-package', 'package']);
+25
View File
@@ -0,0 +1,25 @@
// Local Docker integration: no published ports or existing workspaces are used.
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
const oldImage = process.env.DSH_OLD_IMAGE || 'dsh-web:runtime-7.10-amd64';
const newImage = process.env.DSH_NEW_IMAGE || 'dsh-web:runtime-7.11-amd64';
const volume = `dsh-lockfile-smoke-${randomUUID()}`;
const docker = args => execFileSync('docker', args, { encoding: 'utf8', timeout: 180000, maxBuffer: 8 * 1024 * 1024 });
const run = (image, args, mounts = []) => docker(['run', '--rm', '--network', 'none', '-v', `${volume}:/workspace`, ...mounts, '--entrypoint', 'bash', image, '-ec', args]);
docker(['volume', 'create', volume]);
try {
run(oldImage, 'node /usr/local/lib/dsh-mgr/init-profile.mjs; CI=true npm_config_offline=true dsh plugin --profile web install --ignore-scripts; test -s /workspace/profiles/web/pnpm-lock.yaml');
const mounts = ['init-profile.mjs', 'reconcile-profile.mjs'].flatMap(name => ['-v', `${fileURLToPath(new URL(name, import.meta.url))}:/usr/local/lib/dsh-mgr/${name}:ro`]);
mounts.push('-v', `${fileURLToPath(new URL('entrypoint.sh', import.meta.url))}:/usr/local/bin/dsh-entrypoint:ro`);
const output = run(newImage, 'bash /usr/local/bin/dsh-entrypoint --version; test ! -e /workspace/profiles/web/.dsh-lockfile-pending; CI=true npm_config_offline=true dsh plugin --profile web install --ignore-scripts', mounts);
assert.doesNotMatch(output, /ERR_PNPM_OUTDATED_LOCKFILE/);
const again = run(newImage, 'sha256sum /workspace/profiles/web/pnpm-lock.yaml; bash /usr/local/bin/dsh-entrypoint --version; sha256sum /workspace/profiles/web/pnpm-lock.yaml', mounts);
const hashes = again.match(/^[a-f0-9]{64} .*pnpm-lock.yaml$/gm);
assert.equal(hashes?.length, 2, again);
assert.equal(hashes[0], hashes[1]);
console.log(`PASS: ${oldImage} → ${newImage} migration, frozen reinstall, unchanged lockfile on restart`);
} finally {
docker(['volume', 'rm', volume]);
}
+119
View File
@@ -0,0 +1,119 @@
import { spawn } from 'node:child_process';
import { constants } from 'node:fs';
import { open, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, extname, isAbsolute } from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
const failure = (code, message) => Object.assign(new Error(message), { code });
// No shell; terminate the whole group, including soffice.bin, on cancellation.
export function runProcess(executable, args, signal, env = process.env) {
signal.throwIfAborted();
return new Promise((resolve, reject) => {
const child = spawn(executable, args, { detached: true, stdio: 'ignore', env });
let error;
const kill = () => {
if (child.pid) {
try { process.kill(-child.pid, 'SIGKILL'); }
catch (cause) { if (cause.code !== 'ESRCH') error ??= cause; }
}
};
const abort = () => { error = signal.reason; kill(); };
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) abort();
child.once('error', cause => { error ??= failure('unavailable', 'Cannot start helper: ' + cause.code); });
child.once('close', code => {
signal.removeEventListener('abort', abort);
kill();
if (error) reject(error);
else if (code !== 0) reject(failure('invalid-document', 'Conversion helper exit: ' + code));
else resolve();
});
});
}
async function readBounded(path, maximum, kind, signal) {
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const stat = await file.stat();
if (!stat.isFile()) throw failure('invalid-' + (kind === 'input' ? 'document' : 'output'), 'Regular file required');
if (stat.size > maximum) throw failure(kind + '-too-large', 'File exceeds limit');
const bytes = Buffer.alloc(stat.size + 1);
let length = 0;
while (length < bytes.length) {
signal.throwIfAborted();
const read = await file.read(bytes, length, bytes.length - length, null);
if (!read.bytesRead) break;
length += read.bytesRead;
}
if (length !== stat.size) throw failure('invalid-' + (kind === 'input' ? 'document' : 'output'), 'File changed');
return bytes.subarray(0, length);
} finally { await file.close(); }
}
export async function createConverter(options = {}) {
if (process.platform !== 'linux') throw failure('unavailable', 'Native preview requires Linux');
if (options.fontDirectories !== undefined || options.fontFallbacks !== undefined) {
throw failure('unavailable', 'Native preview uses system fonts; remove kit-only font overrides');
}
const lifetime = new AbortController();
const active = new Set();
const limits = { timeoutMs: 60000, maxInputBytes: 50 * 1024 ** 2, maxOutputBytes: 100 * 1024 ** 2, maxArchiveEntries: 10000, maxUncompressedBytes: 250 * 1024 ** 2, maxImageResolution: 192, ...options };
for (const key of ['timeoutMs', 'maxInputBytes', 'maxOutputBytes', 'maxArchiveEntries', 'maxUncompressedBytes', 'maxImageResolution']) {
if (!Number.isSafeInteger(limits[key]) || limits[key] < 1) throw new TypeError('Invalid ' + key);
}
async function render({ inputPath, outputPath }, external = new AbortController().signal) {
const deadline = new AbortController();
const signal = AbortSignal.any([external, lifetime.signal, deadline.signal]);
const timer = setTimeout(() => deadline.abort(failure('timeout', 'Native Office conversion timed out')), limits.timeoutMs);
let scratch;
let output;
let success = false;
try {
signal.throwIfAborted();
const extension = extname(inputPath).slice(1).toLowerCase();
if (!['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(extension)) throw failure('unsupported-format', 'Unsupported Office extension');
if (!isAbsolute(inputPath) || !isAbsolute(outputPath)) throw failure('invalid-document', 'Absolute paths required');
output = await open(outputPath, 'wx', 0o600);
scratch = await mkdtemp(join(tmpdir(), 'dsh-native-office-'));
const source = join(scratch, 'source.' + extension);
await writeFile(source, await readBounded(inputPath, limits.maxInputBytes, 'input', signal), { flag: 'wx', mode: 0o600, signal });
const launcher = fileURLToPath(new URL('./office-worker.py', import.meta.url));
const env = { PATH: '/usr/bin:/bin', LANG: 'C.UTF-8', HOME: scratch, TMPDIR: scratch, SAL_USE_VCLPLUGIN: 'svp', SAL_DISABLE_PRINTERLIST: '1', SAL_DISABLE_DEFAULTPRINTER: '1' };
await runProcess('/usr/bin/python3', ['-I', launcher, String(512 * 1024 ** 2), String(limits.maxOutputBytes), '/usr/bin/python3', '-I', fileURLToPath(new URL('./validate-office.py', import.meta.url)), source, extension, String(limits.maxArchiveEntries), String(limits.maxUncompressedBytes)], signal, env);
const profile = join(scratch, 'profile');
await mkdir(join(profile, 'user'), { recursive: true, mode: 0o700 });
await writeFile(join(profile, 'user/registrymodifications.xcu'), '<?xml version="1.0"?><oor:items xmlns:oor="http://openoffice.org/2001/registry"><item oor:path="/org.openoffice.Office.Common/Security/Scripting"><prop oor:name="MacroSecurityLevel" oor:op="fuse"><value>3</value></prop></item></oor:items>');
const filter = extension.startsWith('ppt') ? 'impress_pdf_Export' : extension.startsWith('xls') ? 'calc_pdf_Export' : 'writer_pdf_Export';
const pdfOptions = JSON.stringify({ ReduceImageResolution: { type: 'boolean', value: 'true' }, MaxImageResolution: { type: 'long', value: String(limits.maxImageResolution) } });
await runProcess('/usr/bin/python3', ['-I', launcher, String(2 * 1024 ** 3), String(Math.max(limits.maxOutputBytes, 16 * 1024 ** 2)), '/usr/bin/libreoffice', '-env:UserInstallation=' + pathToFileURL(profile).href, '--headless', '--nologo', '--nodefault', '--nofirststartwizard', '--convert-to', 'pdf:' + filter + ':' + pdfOptions, '--outdir', scratch, source], signal, env);
let bytes;
try { bytes = await readBounded(join(scratch, 'source.pdf'), limits.maxOutputBytes, 'output', signal); }
catch (cause) { if (cause.code === 'ENOENT') throw failure('invalid-output', 'LibreOffice produced no PDF'); throw cause; }
if (!/^%PDF-[0-9][.][0-9]/.test(bytes.subarray(0, 8).toString()) || !bytes.subarray(-1024).toString().trimEnd().endsWith('%%EOF')) throw failure('invalid-output', 'Incomplete PDF');
signal.throwIfAborted();
await output.writeFile(bytes);
signal.throwIfAborted();
success = true;
// System LibreOffice has no kit missing-font diagnostic API.
return { backend: 'native-system', missingFonts: [] };
} finally {
clearTimeout(timer);
try { await output?.close(); }
finally {
try { if (output && !success) await rm(outputPath, { force: true }); }
finally { if (scratch) await rm(scratch, { recursive: true, force: true }); }
}
}
}
return {
render(request, signal) {
const task = render(request, signal);
active.add(task);
task.then(() => active.delete(task), () => active.delete(task));
return task;
},
async dispose() { lifetime.abort(failure('unavailable', 'Converter disposed')); await Promise.allSettled([...active]); }
};
}
+50
View File
@@ -0,0 +1,50 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, writeFile, readFile, rm, access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createConverter, runProcess } from './native-office.mjs';
import { patchOffice, target } from './install-office-preview.mjs';
test('patch is exact, version gated and refuses repeat application', () => {
const patched = patchOffice(target, '0.2.1-rc.5', '0.2.1-rc.5');
assert.match(patched, /dsh-native-office/);
for (const source of ['', target + target, patched]) assert.throws(() => patchOffice(source, '0.2.1-rc.5', '0.2.1-rc.5'));
assert.throws(() => patchOffice(target, 'next', '0.2.1-rc.5'));
assert.throws(() => patchOffice(target, '0.2.1-rc.5', 'next'));
});
test('process cancellation, missing binary and failure are classified', async () => {
const signal = new AbortController().signal;
await assert.rejects(runProcess('/not-an-office-binary', [], signal), { code: 'unavailable' });
await assert.rejects(runProcess(process.execPath, ['-e', 'process.exit(2)'], signal), { code: 'invalid-document' });
const controller = new AbortController();
const reason = new Error('cancel test');
const pending = runProcess(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], controller.signal);
controller.abort(reason);
await assert.rejects(pending, error => error === reason);
});
test('input bounds, corruption, exclusive output, abort and disposal', async () => {
const directory = await mkdtemp(join(tmpdir(), 'office-test-'));
const inputPath = join(directory, 'source.docx');
const outputPath = join(directory, 'output.pdf');
const converter = await createConverter();
try {
await writeFile(inputPath, 'invalid');
await assert.rejects(converter.render({ inputPath, outputPath }), { code: 'invalid-document' });
await assert.rejects(access(outputPath));
const small = await createConverter({ maxInputBytes: 1 });
await assert.rejects(small.render({ inputPath, outputPath }), { code: 'input-too-large' });
await small.dispose();
await writeFile(outputPath, 'preserve');
await assert.rejects(converter.render({ inputPath, outputPath }), { code: 'EEXIST' });
assert.equal(await readFile(outputPath, 'utf8'), 'preserve');
const controller = new AbortController();
controller.abort(new Error('cancelled'));
await assert.rejects(converter.render({ inputPath, outputPath }, controller.signal), /cancelled/);
await converter.dispose();
await assert.rejects(converter.render({ inputPath, outputPath }), { code: 'unavailable' });
await assert.rejects(createConverter({ fontDirectories: [] }), { code: 'unavailable' });
} finally { await converter.dispose(); await rm(directory, { recursive: true, force: true }); }
});
+47
View File
@@ -0,0 +1,47 @@
// Run inside the built image as UID 1000. No downloads or fixture dependencies.
import assert from 'node:assert/strict';
import { mkdtemp, writeFile, readFile, rm, readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { pathToFileURL } from 'node:url';
import { runProcess } from './native-office.mjs';
const installed = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-office-to-pdf/lib/';
const { createConverter } = await import(installed + 'dsh-native-office.mjs');
const { OfficeToPdf } = await import(installed + 'index.js');
assert.notEqual(process.getuid(), 0, 'smoke test must run as non-root');
const dir = await mkdtemp(join(tmpdir(), 'office-smoke-'));
const signal = new AbortController().signal;
const ns = 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"';
const converter = await createConverter();
try {
await writeFile(join(dir, 'word.fodt'), '<?xml version="1.0"?><office:document ' + ns + ' office:version="1.2" office:mimetype="application/vnd.oasis.opendocument.text"><office:body><office:text><text:p>中文预览 Word conversion test</text:p></office:text></office:body></office:document>');
await writeFile(join(dir, 'slides.fodp'), '<?xml version="1.0"?><office:document ' + ns + ' office:version="1.2" office:mimetype="application/vnd.oasis.opendocument.presentation"><office:body><office:presentation><draw:page draw:name="slide1"><draw:frame svg:x="2cm" svg:y="2cm" svg:width="20cm" svg:height="5cm"><draw:text-box><text:p>中文预览 PPT conversion test</text:p></draw:text-box></draw:frame></draw:page></office:presentation></office:body></office:document>');
for (const [source, format] of [['word.fodt', 'docx'], ['slides.fodp', 'pptx'], ['word.fodt', 'doc'], ['slides.fodp', 'ppt']]) {
await runProcess('/usr/bin/libreoffice', ['-env:UserInstallation=' + pathToFileURL(join(dir, 'fixture-profile')).href, '--headless', '--convert-to', format, '--outdir', dir, join(dir, source)], signal);
}
await Promise.all(['word.docx', 'slides.pptx', 'word.doc', 'slides.ppt'].map(async name => {
const start = performance.now();
const outputPath = join(dir, name + '.pdf');
const result = await converter.render({ inputPath: join(dir, name), outputPath });
assert.equal(result.backend, 'native-system');
assert.match((await readFile(outputPath)).subarray(0, 8).toString(), /^%PDF-/);
console.log(name + ': native PDF in ' + Math.round(performance.now() - start) + 'ms');
}));
const small = await createConverter({ maxOutputBytes: 1 });
await assert.rejects(small.render({ inputPath: join(dir, 'word.docx'), outputPath: join(dir, 'small.pdf') }), { code: 'output-too-large' });
await small.dispose();
const timeout = await createConverter({ timeoutMs: 1 });
await assert.rejects(timeout.render({ inputPath: join(dir, 'word.docx'), outputPath: join(dir, 'timeout.pdf') }), { code: 'timeout' });
await timeout.dispose();
const limited = await createConverter({ maxArchiveEntries: 1 });
await assert.rejects(limited.render({ inputPath: join(dir, 'word.docx'), outputPath: join(dir, 'limit.pdf') }), { code: 'invalid-document' });
await limited.dispose();
const provider = { slots: [], options: {}, config: { maxOutputBytes: 100 * 1024 ** 2 } };
const converted = await OfficeToPdf.prototype.convertBytes.call(provider, await readFile(join(dir, 'slides.pptx')), 'pptx', signal);
assert.match(Buffer.from(converted.pdf).subarray(0, 8).toString(), /^%PDF-/);
await Promise.all(provider.slots.map(async slot => (await slot.converter).dispose()));
console.log('installed DSH convertBytes integration passed');
assert.equal((await readdir(tmpdir())).filter(name => name.startsWith('dsh-native-office-')).length, 0);
console.log('concurrent DOCX/PPTX/DOC/PPT, output/ZIP limits, timeout and cleanup passed');
} finally { await converter.dispose(); await rm(dir, { recursive: true, force: true }); }
+10
View File
@@ -0,0 +1,10 @@
"""Apply process limits before executing a trusted conversion helper."""
import os
import resource
import sys
memory, file_bytes, *command = sys.argv[1:]
resource.setrlimit(resource.RLIMIT_AS, (int(memory), int(memory)))
resource.setrlimit(resource.RLIMIT_FSIZE, (int(file_bytes), int(file_bytes)))
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
os.execv(command[0], command)
+24
View File
@@ -0,0 +1,24 @@
// Scoped to this plugin; host authentication and global fetch are unchanged.
async function dshChatFetch(path, init) {
const parts = window.location.pathname.split('/');
const prefix = parts[1] === 'dsh' && parts[2] ? '/dsh/' + parts[2] : '';
return fetch(prefix + path, init);
}
async function dshChatJson(response) {
const text = await response.text();
try { return JSON.parse(text); } catch {
throw new Error('Chat Manager: HTTP ' + response.status + ' returned a non-JSON response; check the instance proxy path and authentication.');
}
}
export const clientHelpers = dshChatFetch.toString() + String.fromCharCode(10) + dshChatJson.toString() + String.fromCharCode(10);
export function patchClient(source, version) {
if (version !== '1.5.20') throw new Error('unsupported chat-manager version');
if (source.includes('dshChatFetch') || source.split('await fetch(').length !== 4
|| source.split('await response.json()').length !== 4
|| source.split('/plugins/dsh-session-delete/').length !== 4) {
throw new Error('unexpected chat-manager client; review upstream changes');
}
return clientHelpers + source.replaceAll('await fetch(', 'await dshChatFetch(')
.replaceAll('await response.json()', 'await dshChatJson(response)');
}
+44
View File
@@ -0,0 +1,44 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { runInNewContext } from 'node:vm';
import { clientHelpers, patchClient } from './patch-chat-manager.mjs';
test('chat paths preserve instance prefix and POST options', async () => {
for (const pathname of ['/dsh/demo/', '/dsh/demo', '/dsh/demo/session/123', '/', '/other/']) {
let received;
const context = { window: { location: { pathname } }, fetch: (...args) => { received = args; } };
runInNewContext(clientHelpers, context);
for (const action of ['delete', 'restore', 'archive-search']) {
const path = '/plugins/dsh-session-delete/' + action;
const options = { method: 'POST', body: '{}', headers: { 'x-dsh-session-delete-confirmation': 'delete-session' } };
await context.dshChatFetch(path, options);
assert.equal(received[0], (pathname.startsWith('/dsh/demo') ? '/dsh/demo' : '') + path);
assert.equal(received[1], options);
}
}
});
test('non-JSON errors report HTTP status without exposing response content', async () => {
const context = {};
runInNewContext(clientHelpers, context);
await assert.rejects(context.dshChatJson(new Response('method not allowed', { status: 405 })), /HTTP 405.*non-JSON/);
const body = await context.dshChatJson(Response.json({ ok: false, error: { code: 'forbidden' } }, { status: 403 }));
assert.equal(body.error.code, 'forbidden');
});
test('patch rejects unsupported or changed clients and double patching', () => {
const source = [
"await fetch('/plugins/dsh-session-delete/delete', options); await response.json()",
'await fetch(`/plugins/dsh-session-delete/${action}`, options); await response.json()',
"await fetch('/plugins/dsh-session-delete/archive-search', options); await response.json()",
].join(';');
const patched = patchClient(source, '1.5.20');
assert.equal(patched.split('await dshChatFetch(').length, 4);
assert.equal(patched.slice(clientHelpers.length).replaceAll('await dshChatFetch(', 'await fetch(')
.replaceAll('await dshChatJson(response)', 'await response.json()'), source);
assert.throws(() => patchClient(source, '1.5.16'), /unsupported/);
assert.throws(() => patchClient(source, '1.5.13'), /unsupported/);
assert.throws(() => patchClient('', '1.5.20'), /unexpected/);
assert.throws(() => patchClient(source.replace('await fetch(', 'fetch('), '1.5.20'), /unexpected/);
assert.throws(() => patchClient(source.replace('await response.json()', 'response.json()'), '1.5.20'), /unexpected/);
assert.throws(() => patchClient(source.replace('/plugins/dsh-session-delete/', '/plugins/changed/'), '1.5.20'), /unexpected/);
assert.throws(() => patchClient(patched, '1.5.20'), /unexpected/);
});
+25
View File
@@ -0,0 +1,25 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
test('release targets AMD64 and ARM64 with layout 7', () => {
const release = JSON.parse(readFileSync(new URL('../release.json', import.meta.url), 'utf8'));
assert.deepEqual(release.platforms, ['linux/amd64', 'linux/arm64']);
assert.equal(release.deployment_schema, 7);
});
test('Node and Caddy stages inherit the target platform', () => {
const dockerfile = readFileSync(new URL('./Dockerfile', import.meta.url), 'utf8');
const stages = dockerfile.split(String.fromCharCode(10)).filter(line => line.startsWith('FROM '));
assert.deepEqual(stages, ['FROM caddy:2.10.2 AS proxy', 'FROM node:24-bookworm-slim']);
assert.ok(dockerfile.includes('COPY --from=proxy /usr/bin/caddy /usr/bin/caddy'));
});
test('runtime includes Python tooling and headless LibreOffice with CJK fonts', () => {
const dockerfile = readFileSync(new URL('./Dockerfile', import.meta.url), 'utf8');
const install = dockerfile.split(String.fromCharCode(92, 10)).join(' ').split(String.fromCharCode(10)).find(line => line.startsWith('RUN apt-get update && apt-get install'));
for (const pkg of ['python3', 'python3-pip', 'python3-venv', 'python-is-python3', 'libreoffice', 'fonts-noto-cjk']) {
assert.ok(install.split(/\s+/).includes(pkg), `missing runtime package: ${pkg}`);
}
assert.ok(dockerfile.includes('RUN python --version && python3 -m pip --version && libreoffice --headless --version'));
});
+95
View File
@@ -0,0 +1,95 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { runInNewContext } from 'node:vm';
test('installer accepts the release host and rejects unverified hosts before writing', () => {
const source = readFileSync(new URL('./install-preview-download.mjs', import.meta.url), 'utf8')
.replace(/^import .*;$/gm, '').replaceAll('import.meta.url', JSON.stringify(import.meta.url));
const release = JSON.parse(readFileSync(new URL('../release.json', import.meta.url)));
for (const version of [release.version, '0.2.1-rc.2', '0.2.1-rc.4', 'next']) {
const calls = [];
const run = () => runInNewContext(source, {
URL, readFileSync: () => JSON.stringify({ version }),
mkdirSync: (...args) => calls.push(['mkdir', ...args]),
cpSync: (...args) => calls.push(['copy', ...args]),
execFileSync: (...args) => calls.push(['exec', ...args]),
});
if (version === release.version) {
run();
assert.deepEqual(calls.map(call => call[0]), ['mkdir', 'copy', 'exec']);
assert.equal(calls[2][1], 'tar');
assert.equal(calls[2][2][1], '/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.1.tgz');
} else {
assert.throws(run, /Unverified preview download host/);
assert.equal(calls.length, 0);
}
}
});
function load() {
let plugin;
runInNewContext(readFileSync(new URL('./preview-download/client.js', import.meta.url), 'utf8'), {
Uint8Array, window: { __ModuleLoader__: { load({ id, factory }) { assert.equal(id, 'dsh-preview-download'); plugin = factory(() => ({})); } } }
});
return plugin;
}
test('download passes exact session/path/signal to authorized complete-file RPC', async () => {
const plugin = load();
const data = new Uint8Array([0, 255, 10]);
const signal = new AbortController().signal;
const remote = { workspaceFiles: { async readBytes(...args) {
assert.equal(args[0], 'session-A'); assert.equal(args[1], '/workspace/中文.pptx');
assert.equal(Object.keys(args[2]).length, 0); assert.equal(args[3], signal);
return { ok: true, value: { offset: 0, eof: true, bytes: 3, data } };
} } };
assert.equal(await plugin.readOriginal(remote, 'session-A', '/workspace/中文.pptx', signal), data);
assert.equal(plugin.filename('/workspace/中文.pptx'), '中文.pptx');
assert.equal(plugin.filename('C:' + String.fromCharCode(92) + 'folder' + String.fromCharCode(92) + 'file.docx'), 'file.docx');
});
test('denied, partial, size mismatch and cancelled reads never produce download data', async () => {
const plugin = load();
for (const result of [
{ ok: false, error: { code: 'gateway/forbidden' } },
{ ok: true, value: { offset: 0, eof: false, data: new Uint8Array() } },
{ ok: true, value: { offset: 0, eof: true, bytes: 5, data: new Uint8Array(2) } }
]) {
await assert.rejects(plugin.readOriginal({ workspaceFiles: { readBytes: async () => result } }, 's', '/file', new AbortController().signal));
}
const controller = new AbortController(); controller.abort();
await assert.rejects(plugin.readOriginal({ workspaceFiles: { readBytes: async () => ({ ok: true, value: {} }) } }, 's', '/file', controller.signal));
});
test('plugin registers only the supported preview action slot', () => {
const plugin = load();
let registration;
plugin.apply({ effect: fn => fn(), locale: { register: () => () => {} }, remote: {}, slots: {
inject(name, fn) { assert.equal(name, 'sidebar.right.tab.document.actions'); return fn(); },
register(options, component) { registration = options; assert.equal(typeof component, 'function'); return () => {}; }
} });
assert.equal(registration.id, 'dsh-preview-download');
assert.equal(typeof registration.inject('session-A').download, 'function');
});
test('registered download declares every service used by its callback', async () => {
const plugin = load();
assert.ok(plugin.inject.includes('remote'));
assert.ok(plugin.inject.includes('remote.workspaceFiles'));
let registration;
let calls = 0;
const data = new Uint8Array([1, 2, 255]);
const ctx = {
effect: fn => fn(),
locale: { register: () => () => {} },
slots: { inject: (_name, fn) => fn(), register(options) { registration = options; return () => {}; } },
get remote() {
assert.ok(plugin.inject.includes('remote'), 'Cordis requires explicit remote injection');
return { workspaceFiles: { async readBytes(session, path) {
assert.equal(session, 'session-A'); assert.equal(path, '/file.bin'); calls++;
return { ok: true, value: { offset: 0, eof: true, bytes: 3, data } };
} } };
}
};
plugin.apply(ctx);
assert.equal(await registration.inject('session-A').download('/file.bin', new AbortController().signal), data);
assert.equal(calls, 1);
});
+77
View File
@@ -0,0 +1,77 @@
window.__ModuleLoader__.load({ id: 'dsh-preview-download', factory: require => {
const React = require('react');
const { createElement: h, useState, useRef, useEffect } = React;
const namespace = 'previewDownload';
function filename(path) {
const name = path.replaceAll('\\', '/').split('/').pop() || 'download';
return Array.from(name, char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127 || '<>:"|?*'.includes(char) ? '_' : char).join('');
}
async function readOriginal(remote, sessionId, path, signal) {
signal.throwIfAborted();
const result = await remote.workspaceFiles.readBytes(sessionId, path, {}, signal);
signal.throwIfAborted();
if (!result.ok) throw Object.assign(new Error('Download refused'), { code: result.error.code });
const file = result.value;
if (!file.eof || file.offset !== 0 || !(file.data instanceof Uint8Array) || (file.bytes !== undefined && file.bytes !== file.data.byteLength)) {
throw new Error('Incomplete file response');
}
return file.data;
}
function Download({ absolutePath, download, t }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const pending = useRef(null);
useEffect(() => {
setBusy(false);
setError('');
return () => { pending.current?.abort(); pending.current = null; };
}, [absolutePath, download]);
async function start() {
if (pending.current) return;
const controller = new AbortController();
pending.current = controller;
setBusy(true);
setError('');
try {
const bytes = await download(absolutePath, controller.signal);
controller.signal.throwIfAborted();
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/octet-stream' }));
const link = document.createElement('a');
link.href = url;
link.download = filename(absolutePath);
link.hidden = true;
document.body.appendChild(link);
try { link.click(); }
finally { link.remove(); setTimeout(() => URL.revokeObjectURL(url), 60000); }
} catch (cause) {
if (!controller.signal.aborted) setError(t(cause?.code === 'workspace-file/too-large' ? 'tooLarge' : 'failed'));
} finally {
if (pending.current === controller) { pending.current = null; setBusy(false); }
}
}
return h('span', { style: { display: 'inline-flex', alignItems: 'center', gap: '6px', flexShrink: 0 } },
h('button', { type: 'button', disabled: busy, onClick: start, title: t('download'), 'aria-label': t('download'), 'aria-busy': busy, 'data-preview-download': '',
style: { color: 'inherit', background: 'transparent', border: '1px solid currentColor', borderRadius: '6px', padding: '4px 8px', font: 'inherit', cursor: busy ? 'wait' : 'pointer' }
}, busy ? t('loading') : t('download')),
error && h('span', { role: 'alert', style: { fontSize: '12px', maxWidth: '180px' } }, error));
}
return {
inject: ['slots', 'locale', 'remote', 'remote.workspaceFiles'],
apply(ctx) {
ctx.effect(() => ctx.locale.register(namespace, {
zh: { download: '下载原文件', loading: '下载中…', failed: '下载失败,请检查文件权限或重试。', tooLarge: '文件超过下载大小限制。' },
en: { download: 'Download original', loading: 'Downloading…', failed: 'Download failed. Check access or retry.', tooLarge: 'File exceeds the download size limit.' }
}));
ctx.effect(() => ctx.slots.inject('sidebar.right.tab.document.actions', () => ctx.slots.register({
name: 'sidebar.right.tab.document.actions', id: 'dsh-preview-download', locale: namespace,
inject: sessionId => ({ download: (path, signal) => readOriginal(ctx.remote, sessionId, path, signal) })
}, Download)));
},
// Pure helpers are also exercised by the offline regression suite.
filename, readOriginal
};
} });
+3
View File
@@ -0,0 +1,3 @@
- insert:
- id: dsh-preview-download
name: dsh-preview-download
+3
View File
@@ -0,0 +1,3 @@
// Client-only extension: no new server endpoints or authorization changes.
export const name = 'dsh-preview-download';
export function apply() {}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "dsh-preview-download",
"version": "1.0.1",
"private": true,
"type": "module",
"main": "index.js",
"exports": { ".": "./index.js", "./client": "./client.js" },
"dsh": {
"bundle": { "patch": "cordis.patch.yml" },
"client": {
"platform": "web",
"inject": ["@deepseek-ai/dsh-client-ui-sidebar-documentpreview", "@deepseek-ai/dsh-client-ui-slots", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-api-workspace-files"]
}
}
}
+133
View File
@@ -0,0 +1,133 @@
import { lstatSync, readFileSync, writeFileSync, openSync, closeSync, fsyncSync, renameSync, unlinkSync } from 'node:fs';
import { dirname, join, resolve, basename } from 'node:path';
import { randomUUID } from 'node:crypto';
export const BUILTIN_DEPENDENCIES = Object.freeze({
'dsh-preview-download': 'file:/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.1.tgz',
'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.20-dsh-mgr.1.tgz',
'dsh-mgr-workspace': 'file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz',
'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.2.1.tgz',
'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.9.21.tgz',
});
export const BUILTIN_OVERRIDES = Object.freeze({
'dsh-chat-manager>clsx': 'file:/usr/local/lib/dsh-mgr/clsx-2.1.1.tgz',
'dsh-free-search>@deepseek-ai/schemastery': 'file:/usr/local/lib/dsh-mgr/schemastery-3.18.4.tgz',
'@deepseek-ai/schemastery>@deepseek-ai/cosmokit': 'file:/usr/local/lib/dsh-mgr/cosmokit-1.8.5.tgz',
'@deepseek-ai/schemastery>@standard-schema/spec': 'file:/usr/local/lib/dsh-mgr/spec-1.1.0.tgz',
});
const sections = ['dependencies', 'devDependencies', 'optionalDependencies'];
export const LOCKFILE_PENDING = '.dsh-lockfile-pending';
export function markLockfilePending(profile) {
assertSafeProfile(profile);
const fd = openSync(join(profile, LOCKFILE_PENDING), 'w', 0o600);
try { writeFileSync(fd, 'builtin profile migration\n'); fsyncSync(fd); }
finally { closeSync(fd); }
const directoryFd = openSync(profile, 'r');
try { fsyncSync(directoryFd); } finally { closeSync(directoryFd); }
}
const overrideOwners = ['dsh-chat-manager', 'dsh-free-search', 'dsh-free-search', 'dsh-free-search'];
const semver = '(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?';
function object(value, label) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`invalid ${label}`);
}
export function reconcileManifest(input) {
object(input, 'manifest');
const manifest = structuredClone(input);
const active = new Set();
const archives = new Set();
let changed = false;
for (const section of sections) {
if (manifest[section] === undefined) continue;
object(manifest[section], section);
for (const [name, target] of Object.entries(BUILTIN_DEPENDENCIES)) {
const current = manifest[section][name];
if (typeof current !== 'string' || !new RegExp(`^file:/usr/local/lib/dsh-mgr/${name}-${semver}\\.tgz$`).test(current)) continue;
active.add(name);
archives.add(target);
if (current !== target) {
manifest[section][name] = target;
changed = true;
}
}
}
if (manifest.pnpm !== undefined) {
object(manifest.pnpm, 'pnpm');
if (manifest.pnpm.overrides !== undefined) object(manifest.pnpm.overrides, 'pnpm.overrides');
}
Object.entries(BUILTIN_OVERRIDES).forEach(([key, target], index) => {
if (!active.has(overrideOwners[index])) return;
manifest.pnpm ??= {};
manifest.pnpm.overrides ??= {};
if (!Object.hasOwn(manifest.pnpm.overrides, key)) {
manifest.pnpm.overrides[key] = target;
changed = true;
}
if (manifest.pnpm.overrides[key] === target) archives.add(target);
});
return { manifest, changed, archives: [...archives] };
}
function statIfPresent(path) {
try { return lstatSync(path); } catch (error) {
if (error.code !== 'ENOENT') throw error;
}
}
export function assertSafeProfile(profile) {
for (let dir = resolve(profile); ; dir = dirname(dir)) {
const stat = statIfPresent(dir);
if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) throw new Error(`unsafe profile directory: ${dir}`);
if (dirname(dir) === dir) break;
}
for (const name of ['package.json', 'pnpm-workspace.yaml', 'pnpm-lock.yaml', LOCKFILE_PENDING]) {
const path = join(profile, name);
const stat = statIfPresent(path);
if (stat && (!stat.isFile() || stat.isSymbolicLink())) throw new Error(`unsafe profile file: ${path}`);
}
}
export function reconcileProfile(profile, { archiveDirectory = '/usr/local/lib/dsh-mgr' } = {}) {
assertSafeProfile(profile);
const path = join(profile, 'package.json');
if (!statIfPresent(path)) return { changed: false };
const original = readFileSync(path);
const result = reconcileManifest(JSON.parse(original.toString('utf8')));
for (const reference of result.archives) {
const archive = join(archiveDirectory, basename(reference.slice(5)));
const stat = lstatSync(archive);
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`unsafe builtin archive: ${archive}`);
}
if (!result.changed) return { changed: false };
const backupPath = `${path}.backup-${randomUUID()}`;
const temporaryPath = `${path}.tmp-${randomUUID()}`;
let temporaryCreated = false;
try {
const backupFd = openSync(backupPath, 'wx', 0o600);
try { writeFileSync(backupFd, original); fsyncSync(backupFd); }
finally { closeSync(backupFd); }
const syncDirectory = () => {
const directoryFd = openSync(profile, 'r');
try { fsyncSync(directoryFd); } finally { closeSync(directoryFd); }
};
// Make the recovery copy durable before replacing the active manifest.
syncDirectory();
const fd = openSync(temporaryPath, 'wx', 0o600);
temporaryCreated = true;
try {
writeFileSync(fd, JSON.stringify(result.manifest, null, 2) + '\n');
fsyncSync(fd);
} finally { closeSync(fd); }
assertSafeProfile(profile);
// Persist before changing the manifest, including across failed installs/restarts.
markLockfilePending(profile);
renameSync(temporaryPath, path);
temporaryCreated = false;
syncDirectory();
} finally {
if (temporaryCreated) unlinkSync(temporaryPath);
}
return { changed: true, backupPath };
}
+280
View File
@@ -0,0 +1,280 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs, { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync, readdirSync, statSync, linkSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import { tmpdir } from 'node:os';
import { join, basename } from 'node:path';
import { BUILTIN_DEPENDENCIES, BUILTIN_OVERRIDES, reconcileManifest, reconcileProfile } from './reconcile-profile.mjs';
import { initProfile } from './init-profile.mjs';
const old = name => 'file:/usr/local/lib/dsh-mgr/' + name + '-0.0.1-rc.1+build.2.tgz';
test('runtime-7.9 plugin references migrate to runtime-7.11 without changing bundle choices', t => {
const bundles = ['dsh-web-mobile', 'dsh-chat-manager'];
const f = fixture(t, {
dependencies: {
'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.1.0.tgz',
'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.9.2.tgz',
'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.16-dsh-mgr.1.tgz',
},
dsh: { profile: { bundles } },
});
const result = f.run();
assert.equal(result.changed, true);
assert.equal(readFileSync(result.backupPath, 'utf8'), f.original);
const current = JSON.parse(readFileSync(f.path));
for (const name of Object.keys(current.dependencies)) assert.equal(current.dependencies[name], BUILTIN_DEPENDENCIES[name]);
assert.deepEqual(current.dsh.profile.bundles, bundles);
assert.deepEqual(f.run(), { changed: false });
});
test('runtime-7.10 plugin references migrate to runtime-7.11 without changing bundle choices', t => {
const bundles = ['dsh-web-mobile', 'dsh-chat-manager'];
const f = fixture(t, {
dependencies: {
'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.2.1.tgz',
'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.9.15.tgz',
'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.18-dsh-mgr.1.tgz',
},
dsh: { profile: { bundles } },
});
const result = f.run();
assert.equal(result.changed, true);
assert.equal(readFileSync(result.backupPath, 'utf8'), f.original);
const current = JSON.parse(readFileSync(f.path));
for (const name of Object.keys(current.dependencies)) assert.equal(current.dependencies[name], BUILTIN_DEPENDENCIES[name]);
assert.deepEqual(current.dsh.profile.bundles, bundles);
assert.deepEqual(f.run(), { changed: false });
});
function fixture(t, manifest) {
const root = mkdtempSync(join(tmpdir(), 'dsh-reconcile-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const profile = join(root, 'profiles/web');
const archiveDirectory = join(root, 'archives');
mkdirSync(profile, { recursive: true });
mkdirSync(archiveDirectory);
for (const reference of [...Object.values(BUILTIN_DEPENDENCIES), ...Object.values(BUILTIN_OVERRIDES)]) {
writeFileSync(join(archiveDirectory, basename(reference)), 'fixture');
}
const path = join(profile, 'package.json');
const original = typeof manifest === 'string' ? manifest : JSON.stringify(manifest, null, 4) + '\n';
writeFileSync(path, original);
return { root, profile, path, original, archiveDirectory, run: () => reconcileProfile(profile, { archiveDirectory }) };
}
test('migrates all sections, backs up exact bytes, replaces atomically with 0600, and is idempotent', t => {
const f = fixture(t, {
dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') },
devDependencies: { 'dsh-chat-manager': old('dsh-chat-manager') },
optionalDependencies: { 'dsh-free-search': old('dsh-free-search') },
dsh: { profile: { bundles: [] } },
});
const originalLink = join(f.root, 'original-link');
linkSync(f.path, originalLink);
const first = f.run();
assert.equal(first.changed, true);
assert.equal(readFileSync(first.backupPath, 'utf8'), f.original);
assert.equal(readFileSync(originalLink, 'utf8'), f.original);
assert.equal(statSync(f.path).mode & 0o777, 0o600);
assert.equal(statSync(first.backupPath).mode & 0o777, 0o600);
const current = JSON.parse(readFileSync(f.path));
for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) {
for (const name of Object.keys(current[section])) assert.equal(current[section][name], BUILTIN_DEPENDENCIES[name]);
}
assert.deepEqual(current.pnpm.overrides, BUILTIN_OVERRIDES);
assert.deepEqual(current.dsh.profile.bundles, []);
const before = statSync(f.path);
assert.deepEqual(f.run(), { changed: false });
assert.equal(statSync(f.path).ino, before.ino);
assert.equal(statSync(f.path).mtimeMs, before.mtimeMs);
assert.equal(readdirSync(f.profile).length, 3);
assert.equal(readFileSync(join(f.profile, '.dsh-lockfile-pending'), 'utf8'), 'builtin profile migration\n');
writeFileSync(f.path, f.original);
assert.notEqual(f.run().backupPath, first.backupPath);
assert.equal(readFileSync(first.backupPath, 'utf8'), f.original);
});
function withSyncTrace(f, callback, failAt) {
const originals = Object.fromEntries(
['openSync', 'writeFileSync', 'fsyncSync', 'closeSync', 'renameSync'].map(name => [name, fs[name]]),
);
const descriptors = new Map();
const events = [];
let directoryOpens = 0;
fs.openSync = (path, ...args) => {
const fd = originals.openSync(path, ...args);
const target = path === f.profile ? `directory-${++directoryOpens}`
: String(path).startsWith(`${f.path}.backup-`) ? 'backup'
: String(path).startsWith(`${f.path}.tmp-`) ? 'temporary' : undefined;
if (target) {
descriptors.set(fd, target);
events.push(`open:${target}`);
}
return fd;
};
for (const [name, operation] of [['writeFileSync', 'write'], ['fsyncSync', 'fsync'], ['closeSync', 'close']]) {
fs[name] = (fd, ...args) => {
const target = descriptors.get(fd);
if (target) {
const event = `${operation}:${target}`;
events.push(event);
if (event === failAt) throw new Error(`injected ${event} failure`);
}
const result = originals[name](fd, ...args);
if (name === 'closeSync') descriptors.delete(fd);
return result;
};
}
fs.renameSync = (source, destination) => {
assert.ok(source.startsWith(`${f.path}.tmp-`));
assert.equal(destination, f.path);
events.push('rename:manifest');
return originals.renameSync(source, destination);
};
syncBuiltinESMExports();
try {
callback(events);
} finally {
Object.assign(fs, originals);
syncBuiltinESMExports();
}
}
const durableWriteOrder = [
'open:backup', 'write:backup', 'fsync:backup', 'close:backup',
'open:directory-1', 'fsync:directory-1', 'close:directory-1',
'open:temporary', 'write:temporary', 'fsync:temporary', 'close:temporary',
'open:directory-2', 'fsync:directory-2', 'close:directory-2',
'rename:manifest',
'open:directory-3', 'fsync:directory-3', 'close:directory-3',
];
test('syncs backup and directory before replacement, then syncs the renamed manifest directory', t => {
const f = fixture(t, { dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } });
withSyncTrace(f, events => {
const result = f.run();
assert.equal(result.changed, true);
assert.deepEqual(events, durableWriteOrder);
assert.equal(readFileSync(result.backupPath, 'utf8'), f.original);
assert.equal(JSON.parse(readFileSync(f.path)).dependencies['dsh-web-mobile'], BUILTIN_DEPENDENCIES['dsh-web-mobile']);
});
});
for (const target of ['backup', 'directory-1', 'temporary']) {
test(`${target} fsync failure preserves the original manifest and does not rename`, t => {
const f = fixture(t, { dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } });
const before = statSync(f.path);
const failAt = `fsync:${target}`;
withSyncTrace(f, events => {
assert.throws(f.run, { message: `injected ${failAt} failure` });
assert.deepEqual(events, [
...durableWriteOrder.slice(0, durableWriteOrder.indexOf(failAt) + 1),
`close:${target}`,
]);
}, failAt);
assert.equal(readFileSync(f.path, 'utf8'), f.original);
assert.equal(statSync(f.path).ino, before.ino);
assert.equal(statSync(f.path).mtimeMs, before.mtimeMs);
const backups = readdirSync(f.profile).filter(name => name.startsWith('package.json.backup-'));
assert.equal(backups.length, 1);
assert.equal(readFileSync(join(f.profile, backups[0]), 'utf8'), f.original);
assert.deepEqual(readdirSync(f.profile).sort(), ['package.json', ...backups].sort());
});
}
test('preserves deleted plugins, disabled bundles, custom dependencies and overrides', t => {
const custom = {
dependencies: { 'dsh-chat-manager': old('dsh-chat-manager'), 'dsh-web-mobile': '^3.0.0', other: old('other') },
devDependencies: { 'dsh-free-search': 'file:/custom/dsh-free-search-0.1.0.tgz' },
optionalDependencies: { 'dsh-preview-download': 'file:/usr/local/lib/dsh-mgr/not-the-same-1.0.0.tgz' },
pnpm: { overrides: { 'dsh-chat-manager>clsx': '^2.0.0', unrelated: 'custom' }, other: true },
dsh: { profile: { bundles: ['user-plugin'] } },
};
const f = fixture(t, custom);
writeFileSync(join(f.profile, 'pnpm-workspace.yaml'), 'custom workspace');
writeFileSync(join(f.profile, 'cordis.patch.yml'), 'custom patch');
f.run();
const expected = structuredClone(custom);
expected.dependencies['dsh-chat-manager'] = BUILTIN_DEPENDENCIES['dsh-chat-manager'];
assert.deepEqual(JSON.parse(readFileSync(f.path)), expected);
assert.equal(readFileSync(join(f.profile, 'pnpm-workspace.yaml'), 'utf8'), 'custom workspace');
assert.equal(readFileSync(join(f.profile, 'cordis.patch.yml'), 'utf8'), 'custom patch');
});
test('strict archive matching leaves non-semver references untouched', () => {
for (const version of ['latest', '01.0.0', '1.0', '1.0.0-01', '1.0.0.tgz/extra']) {
const input = { dependencies: { 'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-' + version + '.tgz' } };
assert.equal(reconcileManifest(input).changed, false);
}
});
test('current defaults and user-only manifests remain byte-identical', t => {
for (const manifest of [
{ dependencies: BUILTIN_DEPENDENCIES, pnpm: { overrides: BUILTIN_OVERRIDES } },
{ dependencies: { 'dsh-chat-manager': '^1.0.0' }, custom: true },
{ dsh: { profile: { bundles: [] } } },
]) {
const f = fixture(t, manifest);
assert.equal(f.run().changed, false);
assert.equal(readFileSync(f.path, 'utf8'), f.original);
assert.deepEqual(readdirSync(f.profile), ['package.json']);
}
});
test('missing archives fail before writes, even when already current', t => {
for (const reference of [old('dsh-web-mobile'), BUILTIN_DEPENDENCIES['dsh-web-mobile']]) {
const f = fixture(t, { dependencies: { 'dsh-web-mobile': reference } });
rmSync(join(f.archiveDirectory, basename(BUILTIN_DEPENDENCIES['dsh-web-mobile'])));
assert.throws(f.run, /ENOENT/);
assert.equal(readFileSync(f.path, 'utf8'), f.original);
assert.deepEqual(readdirSync(f.profile), ['package.json']);
}
});
test('invalid JSON and malformed sections fail without changing original', t => {
for (const input of ['{ broken', 'null', '[]', JSON.stringify({ dependencies: [] }), JSON.stringify({ pnpm: { overrides: null } })]) {
const f = fixture(t, input);
assert.throws(f.run);
assert.equal(readFileSync(f.path, 'utf8'), input);
assert.deepEqual(readdirSync(f.profile), ['package.json']);
}
});
test('rejects manifest/workspace symlinks, dangling links and directories', t => {
for (const name of ['package.json', 'pnpm-workspace.yaml', 'pnpm-lock.yaml', '.dsh-lockfile-pending']) {
for (const kind of ['symlink', 'dangling', 'directory']) {
const f = fixture(t, { dependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } });
const target = join(f.root, 'outside');
if (kind === 'symlink') writeFileSync(target, f.original);
const path = join(f.profile, name);
rmSync(path, { force: true });
if (kind === 'directory') mkdirSync(path);
else symlinkSync(target, path);
const before = readdirSync(f.profile);
assert.throws(f.run, /unsafe profile file/);
assert.deepEqual(readdirSync(f.profile), before);
if (kind === 'symlink') assert.equal(readFileSync(target, 'utf8'), f.original);
if (name !== 'package.json') assert.equal(readFileSync(f.path, 'utf8'), f.original);
}
}
});
test('rejects symlinked profile and ancestors', t => {
const f = fixture(t, '{}');
const link = join(f.root, 'linked');
symlinkSync(join(f.root, 'profiles'), link);
assert.throws(() => reconcileProfile(join(link, 'web'), { archiveDirectory: f.archiveDirectory }), /unsafe profile directory/);
const direct = join(f.root, 'direct');
symlinkSync(f.profile, direct);
assert.throws(() => reconcileProfile(direct), /unsafe profile directory/);
});
test('init migrates before restoring workspace and rejects unsafe workspace before writes', t => {
const f = fixture(t, { optionalDependencies: { 'dsh-web-mobile': old('dsh-web-mobile') } });
const run = () => initProfile(f.root, { archiveDirectory: f.archiveDirectory });
symlinkSync(join(f.root, 'missing'), join(f.profile, 'pnpm-workspace.yaml'));
assert.throws(run, /unsafe profile file/);
assert.equal(readFileSync(f.path, 'utf8'), f.original);
rmSync(join(f.profile, 'pnpm-workspace.yaml'));
run();
assert.equal(JSON.parse(readFileSync(f.path)).optionalDependencies['dsh-web-mobile'], BUILTIN_DEPENDENCIES['dsh-web-mobile']);
assert.match(readFileSync(join(f.profile, 'pnpm-workspace.yaml'), 'utf8'), /autoInstallPeers: false/);
});
+29
View File
@@ -0,0 +1,29 @@
"""Bounded, non-extracting Office container validation; Python stdlib only."""
import sys
import zipfile
path, ext, entries, maximum = sys.argv[1:]
if ext in ('doc', 'xls', 'ppt'):
with open(path, 'rb') as source:
if source.read(8) != bytes.fromhex('d0cf11e0a1b11ae1'):
raise ValueError('not OLE')
else:
expected = {'docx': 'word/document.xml', 'xlsx': 'xl/workbook.xml', 'pptx': 'ppt/presentation.xml'}[ext]
with zipfile.ZipFile(path) as archive:
info = archive.infolist()
names = [entry.filename for entry in info]
if len(info) > int(entries) or len(set(names)) != len(names):
raise ValueError('entry limit or duplicate')
if expected not in names or '[Content_Types].xml' not in names:
raise ValueError('format mismatch')
if sum(entry.file_size for entry in info) > int(maximum):
raise ValueError('uncompressed size limit')
total = 0
for entry in info:
if entry.flag_bits & 1:
raise ValueError('encrypted')
with archive.open(entry) as stream:
while chunk := stream.read(65536):
total += len(chunk)
if total > int(maximum):
raise ValueError('uncompressed size limit')
+3 -3
View File
@@ -1,7 +1,7 @@
{
"schema_version": 1,
"release_id": "runtime-7.1",
"version": "0.2.0-rc.2",
"release_id": "runtime-7.11",
"version": "0.2.1-rc.5",
"deployment_schema": 7,
"platforms": ["linux/amd64"]
"platforms": ["linux/amd64", "linux/arm64"]
}