Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
169e1ffdc2 | ||
|
|
0389555d76 | ||
|
|
9961d42a2f | ||
|
|
e36f3c1fdd | ||
|
|
605a48adb0 | ||
|
|
e84906652e | ||
|
|
b13b71b27c | ||
|
|
b2d97dfda2 | ||
|
|
2e31e74972 | ||
|
|
eb7e809999 | ||
|
|
bbdee632f6 | ||
|
|
65d118a145 | ||
|
|
39cd470f92 | ||
|
|
1fbb2a6fed | ||
|
|
a6bf83f037 | ||
|
|
ed6467d132 | ||
|
|
1fda59fb09 | ||
|
|
05ecfc88c0 | ||
|
|
e72d73c497 |
+48
@@ -0,0 +1,48 @@
|
||||
# Local tooling and browser artifacts
|
||||
/.mimocode/
|
||||
.playwright-cli/
|
||||
|
||||
# Dependencies and generated files, including leftovers when switching branches
|
||||
node_modules/
|
||||
.vite/
|
||||
/web/dist/
|
||||
*.tsbuildinfo
|
||||
/bin/
|
||||
output/
|
||||
|
||||
# Local runtime data and credentials
|
||||
/data/
|
||||
.env
|
||||
.env.*
|
||||
!.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
|
||||
@@ -0,0 +1,171 @@
|
||||
# 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.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,以及拉取基础镜像和构建依赖的网络。从仓库根目录执行,DSH 版本取自清单,平台按 Docker daemon 架构选择,不跟踪 latest:
|
||||
|
||||
~~~sh
|
||||
set -eu
|
||||
DSH_VERSION="$(node -p "require('./release.json').version")"
|
||||
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-${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 合约
|
||||
|
||||
- 以 UID/GID 1000 运行;实例持久目录挂载到 `/workspace`,必须允许该身份读写。`DSH_HOME`、`HOME` 和工作目录均为 `/workspace`,XDG 数据与配置也位于其中。
|
||||
- `/data/dsh-home` 是指向 `/workspace` 的兼容符号链接;不要用旧 home 挂载覆盖该路径。`/shared` 不是布局 7 的持久化入口。
|
||||
- DSH 监听 loopback 3080,Caddy 在容器 8080 转发;只通过受信入口访问,不直接公开 runtime 端口,不挂载 Docker socket。原生模块需要可执行的 `/tmp`,可保留 nosuid/nodev 与容量限制。
|
||||
- 新 profile 默认启用固定版本的 mobile、free-search 和 workspace bundle,默认工作区为 `/workspace`。已有非空 profile 不自动注入或覆盖;升级镜像不会强制改变已有插件选择。
|
||||
- 启动前协调插件依赖,失败则不启动应用;仅含内置固定依赖的 profile 使用离线安装。用户添加插件后可能需要网络。
|
||||
|
||||
## 手动发布
|
||||
|
||||
1. 审核 runtime 资产变更及其可信来源,选择精确 DSH 版本,更新 `release.json`。每次发布变更都必须使用新的 `release_id`;布局合约变化时同步更新 `deployment_schema`、镜像及调用方适配,不能只修改数字。
|
||||
2. 运行上述测试与本地构建,并完成目标平台的容器启动、插件、持久化与兼容验收;检查 `git diff --check` 和最终差异。
|
||||
3. 维护者确认后,**手动提交并手动推送** `dockerfile` 分支到已核实的发布远端。修改清单或本地构建不会自动 commit/push,也不会自动更新运行中的实例。
|
||||
|
||||
## 文件范围
|
||||
|
||||
- `release.json`:发布清单。
|
||||
- `README.md`:本分支说明。
|
||||
- `image/Dockerfile`、`image/Caddyfile`、`image/entrypoint.sh`、`image/.dockerignore`:镜像构建与启动。
|
||||
- `image/install-*.mjs`、`image/init-profile.mjs`:内置 runtime 资产安装与 profile 初始化。
|
||||
- `image/init-profile.test.mjs`:保留的 profile 初始化回归测试。
|
||||
- `image/entrypoint.test.mjs`:从面板测试迁移的 runtime 启动失败保护与镜像约束测试,需要 Bash;不依赖 Go 或面板。
|
||||
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
**/.git
|
||||
.env
|
||||
.env.*
|
||||
**/.env
|
||||
**/.env.*
|
||||
node_modules
|
||||
**/node_modules
|
||||
*.test.mjs
|
||||
embed.go
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
admin off
|
||||
auto_https off
|
||||
}
|
||||
:8080 {
|
||||
reverse_proxy 127.0.0.1:3080
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
FROM caddy:2.10.2 AS proxy
|
||||
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 \
|
||||
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 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.
|
||||
RUN cp /usr/bin/caddy /usr/bin/caddy.clean && mv /usr/bin/caddy.clean /usr/bin/caddy
|
||||
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
|
||||
USER node
|
||||
WORKDIR /workspace
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/usr/bin/tini", "-g", "--", "/usr/local/bin/dsh-entrypoint"]
|
||||
CMD ["web", "--no-open", "--port", "3080"]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
export npm_config_ignore_scripts=true npm_config_ignore_pnpmfile=true
|
||||
node /usr/local/lib/dsh-mgr/init-profile.mjs
|
||||
if [[ -f "${DSH_HOME}/profiles/web/package.json" ]]; then
|
||||
echo "dsh-mgr: plugin prestart reconciliation starting" >&2
|
||||
# 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 '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
|
||||
# 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
|
||||
|
||||
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile &
|
||||
proxy_pid=$!
|
||||
echo
|
||||
dsh "$@" &
|
||||
dsh_pid=$!
|
||||
cleanup() {
|
||||
trap - TERM INT
|
||||
kill -TERM "$proxy_pid" "$dsh_pid" 2>/dev/null || true
|
||||
wait "$proxy_pid" 2>/dev/null || true
|
||||
wait "$dsh_pid" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap 'exit 143' TERM
|
||||
trap 'exit 130' INT
|
||||
# Either child exiting terminates the container; never leave a proxy-only instance.
|
||||
set +e
|
||||
wait -n "$proxy_pid" "$dsh_pid"
|
||||
status=$?
|
||||
set -e
|
||||
exit "$status"
|
||||
@@ -0,0 +1,85 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
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']) {
|
||||
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
|
||||
exit 0
|
||||
`,
|
||||
timeout: `#!/bin/bash
|
||||
printf 'timeout %s\n' "$*" >> "$TRACE"
|
||||
test "$1 $2 $3" = "--signal=TERM --kill-after=5s 120s" || exit 90
|
||||
shift 3
|
||||
exec "$@"
|
||||
`,
|
||||
dsh: `#!/bin/bash
|
||||
printf 'dsh %s\n' "$*" >> "$TRACE"
|
||||
test "$npm_config_ignore_scripts:$npm_config_ignore_pnpmfile" = true:true || exit 91
|
||||
if [ "$1" = plugin ]; then
|
||||
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
|
||||
`,
|
||||
caddy: `#!/bin/bash
|
||||
echo caddy >> "$TRACE"
|
||||
sleep 0.1
|
||||
`,
|
||||
};
|
||||
for (const [name, script] of Object.entries(scripts)) {
|
||||
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, EXPECTED_ARGS: pending ? ' --no-frozen-lockfile' : '' },
|
||||
encoding: 'utf8', timeout: 5000,
|
||||
});
|
||||
assert.ifError(result.error);
|
||||
const output = result.stdout + result.stderr;
|
||||
const trace = readFileSync(join(dir, 'trace'), 'utf8');
|
||||
assert.equal(result.status, status === 'empty' ? 0 : Number(status), output);
|
||||
if (status === 'empty') {
|
||||
assert.doesNotMatch(trace, /plugin/);
|
||||
assert.match(trace, /dsh web/);
|
||||
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/);
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('runtime pins pnpm and uses nonroot with tini process-group forwarding', () => {
|
||||
const dockerfile = readFileSync(new URL('./Dockerfile', import.meta.url), 'utf8');
|
||||
for (const required of ['[email protected]', 'test "$(pnpm --version)" = "10.11.0"', 'USER node', '"/usr/bin/tini", "-g"']) {
|
||||
assert.ok(dockerfile.includes(required), 'missing ' + required);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
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 [email protected] 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);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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, 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 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.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.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');
|
||||
const patch = join(home, 'profiles/web/cordis.patch.yml');
|
||||
writeFileSync(patch, '[]');
|
||||
run();
|
||||
assert.deepEqual(JSON.parse(readFileSync(file)), disabled);
|
||||
assert.equal(readFileSync(workspace, 'utf8'), 'custom workspace');
|
||||
assert.equal(readFileSync(patch, 'utf8'), '[]');
|
||||
const custom=JSON.stringify({custom:true});
|
||||
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'), '[]');
|
||||
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 }); }
|
||||
});
|
||||
test('rejects symlinked profile ancestors', () => {
|
||||
const home=mkdtempSync(join(tmpdir(),'dsh-plugin-init-'));
|
||||
const outside=mkdtempSync(join(tmpdir(),'dsh-plugin-outside-'));
|
||||
try {
|
||||
symlinkSync(outside,join(home,'profiles'));
|
||||
assert.throws(()=>execFileSync(process.execPath,[new URL('./init-profile.mjs',import.meta.url).pathname],{env:{...process.env,DSH_HOME:home},stdio:'pipe'}));
|
||||
assert.equal(existsSync(join(outside,'web/package.json')),false);
|
||||
} finally {rmSync(home,{recursive:true,force:true});rmSync(outside,{recursive:true,force:true});}
|
||||
});
|
||||
@@ -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)');
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createRequire } from 'node:module';
|
||||
const root = '/usr/local/lib/dsh-mgr';
|
||||
const packages = [
|
||||
['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=='],
|
||||
];
|
||||
for (const [name, version, 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);
|
||||
const archive = `${root}/${name.split('/').pop()}-${version}.tgz`;
|
||||
writeFileSync(archive, data, { mode: 0o644 });
|
||||
}
|
||||
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.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.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 (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) || (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.9.21' };
|
||||
writeFileSync(hostManifest, JSON.stringify(host, null, 2));
|
||||
console.log('Installed [email protected] and pinned offline dependency archives (SHA-512 verified; no lifecycle scripts)');
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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)');
|
||||
@@ -0,0 +1,44 @@
|
||||
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.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.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.
|
||||
execFileSync('chmod', ['-R', 'a+rX', destination]);
|
||||
const pkg = JSON.parse(readFileSync(destination + '/package.json'));
|
||||
if (pkg.version !== version || pkg.name !== 'dsh-web-mobile' || Object.keys(pkg.dependencies || {}).length) throw new Error('unexpected mobile package metadata');
|
||||
const require = createRequire('/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json');
|
||||
const semver = createRequire('/usr/local/lib/node_modules/npm/package.json')('semver');
|
||||
for (const [name, range] of Object.entries(pkg.peerDependencies || {})) {
|
||||
// React is provided by the browser shell, not a Node package in DSH.
|
||||
// Limit that exception to the upstream-declared, tested RC hosts.
|
||||
if (name === 'react') {
|
||||
const host = require('./package.json').version;
|
||||
if (host !== '0.2.1-rc.5') throw new Error('unverified browser React host: ' + host);
|
||||
continue;
|
||||
}
|
||||
let peer;
|
||||
try { peer = require(name + '/package.json'); }
|
||||
catch (error) {
|
||||
if (pkg.peerDependenciesMeta?.[name]?.optional) continue;
|
||||
throw new Error('missing required mobile peer: ' + 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 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.
|
||||
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 [email protected] (SHA-512 verified; no lifecycle scripts)');
|
||||
@@ -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();
|
||||
@@ -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']);
|
||||
@@ -0,0 +1,12 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
const root = '/usr/local/lib/dsh-mgr';
|
||||
const destination = root + '/workspace-package/package';
|
||||
mkdirSync(destination, { recursive: true });
|
||||
writeFileSync(destination + '/package.json', JSON.stringify({
|
||||
name: 'dsh-mgr-workspace', version: '1.0.0', type: 'module', main: 'index.js',
|
||||
dsh: { bundle: { patch: 'cordis.patch.yml' } },
|
||||
}));
|
||||
writeFileSync(destination + '/index.js', "export const inject = ['workspaceRegistry', 'sessions'];\nexport async function apply(ctx) {\n await ctx.workspaceRegistry.initializeDefault(() => '/workspace');\n}\n");
|
||||
writeFileSync(destination + '/cordis.patch.yml', "- insert:\n - id: dsh-mgr-workspace\n name: dsh-mgr-workspace\n");
|
||||
execFileSync('tar', ['-czf', root + '/dsh-mgr-workspace-1.0.0.tgz', '-C', root + '/workspace-package', 'package']);
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]); }
|
||||
};
|
||||
}
|
||||
@@ -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 }); }
|
||||
});
|
||||
@@ -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 }); }
|
||||
@@ -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)
|
||||
@@ -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)');
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
} });
|
||||
@@ -0,0 +1,3 @@
|
||||
- insert:
|
||||
- id: dsh-preview-download
|
||||
name: dsh-preview-download
|
||||
@@ -0,0 +1,3 @@
|
||||
// Client-only extension: no new server endpoints or authorization changes.
|
||||
export const name = 'dsh-preview-download';
|
||||
export function apply() {}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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')
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"release_id": "runtime-7.11",
|
||||
"version": "0.2.1-rc.5",
|
||||
"deployment_schema": 7,
|
||||
"platforms": ["linux/amd64", "linux/arm64"]
|
||||
}
|
||||
Reference in New Issue
Block a user