diff --git a/README.md b/README.md
index ccd7d2a..693964b 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,42 @@
# DSH Runtime Release
+## 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.0.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.5 修订仅保留 MiSans Regular 和 Bold,从第三方 GitHub 仓库 boyan01/mi_sans_font 的固定提交下载独立 TTF 并逐个校验 SHA-256,合计约 15.73 MB,不再下载官方完整 ZIP。构建环境需要能够访问 raw.githubusercontent.com。
## runtime-7.4:文档工具、中文字体与 Chat Manager 修复
@@ -36,7 +70,7 @@ Runtime 镜像包含 Debian Bookworm 的 Python 3、pip、venv(`python` 指向
## 发布清单
-`release.json` 是发布入口:`schema_version` 为清单格式版本,`release_id` 标识一次发布,`version` 为精确 DSH 版本,`deployment_schema` 为运行布局版本,`platforms` 为支持的平台。当前待发布配置为 `runtime-7.5`、DSH `0.2.0-rc.2`、布局 7、`linux/amd64` 和 `linux/arm64`(aarch64,不含 32 位 ARM)。
+`release.json` 是发布入口:`schema_version` 为清单格式版本,`release_id` 标识一次发布,`version` 为精确 DSH 版本,`deployment_schema` 为运行布局版本,`platforms` 为支持的平台。当前待发布配置为 `runtime-7.7`、DSH `0.2.0-rc.2`、布局 7、`linux/amd64` 和 `linux/arm64`(aarch64,不含 32 位 ARM)。
ARM64 验收状态:已确认 Node/Caddy 基础镜像提供 ARM64 版本,但尚未完成 ARM64 runtime 构建及运行验收。清单声明会开放面板的对应平台构建入口,不等于兼容性证明;验收完成前不要推送发布。
diff --git a/image/Dockerfile b/image/Dockerfile
index 0298b60..29fe0a4 100644
--- a/image/Dockerfile
+++ b/image/Dockerfile
@@ -32,6 +32,11 @@ RUN chmod 755 /usr/local/bin/dsh-entrypoint && mkdir -p /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
diff --git a/image/entrypoint.sh b/image/entrypoint.sh
index f2dbe72..968c5f5 100644
--- a/image/entrypoint.sh
+++ b/image/entrypoint.sh
@@ -8,7 +8,7 @@ if [[ -f "${DSH_HOME}/profiles/web/package.json" ]]; then
# pnpm otherwise probes registry metadata for the overlapping optional peer.
# Restrict offline mode to profiles containing only our fixed archives.
install_env=()
- if node -e 'const m=require(process.env.DSH_HOME+"/profiles/web/package.json"); const pins={"dsh-chat-manager":"file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-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.0.5.tgz","dsh-free-search":"file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz"}; process.exit(Object.entries({...m.dependencies,...m.devDependencies,...m.optionalDependencies}).every(([n,v])=>pins[n]===v)?0:1)'; then
+ if node -e 'const m=require(process.env.DSH_HOME+"/profiles/web/package.json"); const pins={"dsh-preview-download":"file:/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.0.tgz","dsh-chat-manager":"file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-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.0.5.tgz","dsh-free-search":"file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz"}; process.exit(Object.entries({...m.dependencies,...m.devDependencies,...m.optionalDependencies}).every(([n,v])=>pins[n]===v)?0:1)'; then
install_env=(npm_config_offline=true)
fi
if timeout --signal=TERM --kill-after=5s 120s env CI=true "${install_env[@]}" dsh plugin --profile web install --ignore-scripts; then
diff --git a/image/init-profile.mjs b/image/init-profile.mjs
index 768956f..e3ab31d 100644
--- a/image/init-profile.mjs
+++ b/image/init-profile.mjs
@@ -14,14 +14,14 @@ if (!existsSync(profile) || readdirSync(profile).length === 0) {
mkdirSync(profile, { recursive: true });
writeFileSync(join(profile, 'package.json'), JSON.stringify({
name: 'dsh-profile-web', private: true,
- dependencies: { 'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-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.0.5.tgz', 'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz' },
+ dependencies: { 'dsh-preview-download': 'file:/usr/local/lib/dsh-mgr/dsh-preview-download-1.0.0.tgz', 'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-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.0.5.tgz', 'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz' },
pnpm: { overrides: {
'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',
} },
- 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: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'dsh-web-mobile', 'dsh-free-search', 'dsh-mgr-workspace', 'dsh-chat-manager', 'dsh-preview-download'] } },
}, null, 2), { flag: 'wx', mode: 0o600 });
console.log('dsh-mgr: new web profile includes dsh-web-mobile@3.0.5 and dsh-free-search@0.8.5 and dsh-chat-manager@1.5.12-dsh-mgr.1');
}
diff --git a/image/init-profile.test.mjs b/image/init-profile.test.mjs
index 2dcd62c..7b28d70 100644
--- a/image/init-profile.test.mjs
+++ b/image/init-profile.test.mjs
@@ -11,6 +11,8 @@ test('new profile enables bundle once and preserves existing configuration', ()
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.0.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.12-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');
diff --git a/image/install-office-preview.mjs b/image/install-office-preview.mjs
new file mode 100644
index 0000000..ca84ca1
--- /dev/null
+++ b/image/install-office-preview.mjs
@@ -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.0-rc.2' || packageVersion !== '0.2.0-rc.2') 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();
diff --git a/image/install-preview-download.mjs b/image/install-preview-download.mjs
new file mode 100644
index 0000000..2315981
--- /dev/null
+++ b/image/install-preview-download.mjs
@@ -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.0-rc.2') 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.0.tgz', '-C', root + '/preview-download-package', 'package']);
diff --git a/image/native-office.mjs b/image/native-office.mjs
new file mode 100644
index 0000000..30acbab
--- /dev/null
+++ b/image/native-office.mjs
@@ -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'), '- 3
');
+ 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]); }
+ };
+}
diff --git a/image/native-office.test.mjs b/image/native-office.test.mjs
new file mode 100644
index 0000000..0cf2007
--- /dev/null
+++ b/image/native-office.test.mjs
@@ -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.0-rc.2', '0.2.0-rc.2');
+ assert.match(patched, /dsh-native-office/);
+ for (const source of ['', target + target, patched]) assert.throws(() => patchOffice(source, '0.2.0-rc.2', '0.2.0-rc.2'));
+ assert.throws(() => patchOffice(target, 'next', '0.2.0-rc.2'));
+ assert.throws(() => patchOffice(target, '0.2.0-rc.2', '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 }); }
+});
diff --git a/image/office-preview-smoke.mjs b/image/office-preview-smoke.mjs
new file mode 100644
index 0000000..256c1fd
--- /dev/null
+++ b/image/office-preview-smoke.mjs
@@ -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'), '中文预览 Word conversion test');
+ await writeFile(join(dir, 'slides.fodp'), '中文预览 PPT conversion test');
+ 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 }); }
diff --git a/image/office-worker.py b/image/office-worker.py
new file mode 100644
index 0000000..fdacce4
--- /dev/null
+++ b/image/office-worker.py
@@ -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)
diff --git a/image/preview-download.test.mjs b/image/preview-download.test.mjs
new file mode 100644
index 0000000..9b57c49
--- /dev/null
+++ b/image/preview-download.test.mjs
@@ -0,0 +1,47 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import { runInNewContext } from 'node:vm';
+
+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');
+});
diff --git a/image/preview-download/client.js b/image/preview-download/client.js
new file mode 100644
index 0000000..93ab5fa
--- /dev/null
+++ b/image/preview-download/client.js
@@ -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.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
+ };
+} });
diff --git a/image/preview-download/cordis.patch.yml b/image/preview-download/cordis.patch.yml
new file mode 100644
index 0000000..cb54d2c
--- /dev/null
+++ b/image/preview-download/cordis.patch.yml
@@ -0,0 +1,3 @@
+- insert:
+ - id: dsh-preview-download
+ name: dsh-preview-download
diff --git a/image/preview-download/index.js b/image/preview-download/index.js
new file mode 100644
index 0000000..bbebfa1
--- /dev/null
+++ b/image/preview-download/index.js
@@ -0,0 +1,3 @@
+// Client-only extension: no new server endpoints or authorization changes.
+export const name = 'dsh-preview-download';
+export function apply() {}
diff --git a/image/preview-download/package.json b/image/preview-download/package.json
new file mode 100644
index 0000000..819cf3e
--- /dev/null
+++ b/image/preview-download/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "dsh-preview-download",
+ "version": "1.0.0",
+ "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"]
+ }
+ }
+}
diff --git a/image/validate-office.py b/image/validate-office.py
new file mode 100644
index 0000000..770bde4
--- /dev/null
+++ b/image/validate-office.py
@@ -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')
diff --git a/release.json b/release.json
index 1c70953..64702d9 100644
--- a/release.json
+++ b/release.json
@@ -1,6 +1,6 @@
{
"schema_version": 1,
- "release_id": "runtime-7.5",
+ "release_id": "runtime-7.7",
"version": "0.2.0-rc.2",
"deployment_schema": 7,
"platforms": ["linux/amd64", "linux/arm64"]