feat(runtime): add document tools, MiSans and patched chat manager

This commit is contained in:
2026-10-16 16:13:35 +08:00
parent a6bf83f037
commit 1fbb2a6fed
11 changed files with 219 additions and 6 deletions
+16
View File
@@ -1,5 +1,21 @@
# DSH Runtime Release
## 内置修复版 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 简体中文字体的 10 个静态 TTF 字重,保留 Noto CJK,不修改默认字体优先级。构建时从小米官方下载并校验固定 SHA-256,仅安装原始 TTF 文件;上游包变化会令构建失败,需审核后更新校验值。字体及声明位于 `/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 或认证逻辑。版本或目标语句不匹配时构建失败,升级上游版本必须重新审核补丁。
+11 -1
View File
@@ -3,13 +3,20 @@ FROM node:24-bookworm-slim
ARG DSH_VERSION
# Keep the historical dependency-layer cache key; the template revision is set below.
ARG RECIPE_REV=3
RUN apt-get update && apt-get install -y --no-install-recommends tini ca-certificates git procps && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends \
tini ca-certificates git procps \
python3 python3-pip python3-venv python-is-python3 \
libreoffice fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
RUN python --version && python3 -m pip --version && libreoffice --headless --version
RUN test -n "$DSH_VERSION" && npm install --global --foreground-scripts --loglevel=verbose --registry=https://registry.npmjs.org "@deepseek-ai/dsh@${DSH_VERSION}"
RUN npm install --global --ignore-scripts --registry=https://registry.npmjs.org [email protected] && test "$(pnpm --version)" = "10.11.0"
COPY install-mobile.mjs /usr/local/lib/dsh-mgr/install-mobile.mjs
RUN node /usr/local/lib/dsh-mgr/install-mobile.mjs
COPY install-free-search.mjs /usr/local/lib/dsh-mgr/install-free-search.mjs
RUN node /usr/local/lib/dsh-mgr/install-free-search.mjs
COPY install-chat-manager.mjs patch-chat-manager.mjs /usr/local/lib/dsh-mgr/
RUN node /usr/local/lib/dsh-mgr/install-chat-manager.mjs
COPY install-workspace.mjs /usr/local/lib/dsh-mgr/install-workspace.mjs
RUN node /usr/local/lib/dsh-mgr/install-workspace.mjs
COPY init-profile.mjs /usr/local/lib/dsh-mgr/init-profile.mjs
@@ -22,6 +29,9 @@ RUN cp /usr/bin/caddy /usr/bin/caddy.clean && mv /usr/bin/caddy.clean /usr/bin/c
COPY Caddyfile /etc/caddy/Caddyfile
COPY entrypoint.sh /usr/local/bin/dsh-entrypoint
RUN chmod 755 /usr/local/bin/dsh-entrypoint && mkdir -p /data /workspace /shared && ln -s /workspace /data/dsh-home && chown -R node:node /data /workspace /shared
COPY install-misans.mjs /usr/local/lib/dsh-mgr/install-misans.mjs
RUN node /usr/local/lib/dsh-mgr/install-misans.mjs
COPY MiSans-NOTICE.txt /usr/local/share/fonts/misans/NOTICE.txt
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
+9
View File
@@ -0,0 +1,9 @@
This runtime uses MiSans fonts by Xiaomi.
Official source: https://hyperos.mi.com/font/zh/download/
Archive: https://hyperos.mi.com/font-download/MiSans.zip
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.
+1 -1
View File
@@ -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-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-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
+5 -4
View File
@@ -14,22 +14,23 @@ if (!existsSync(profile) || readdirSync(profile).length === 0) {
mkdirSync(profile, { recursive: true });
writeFileSync(join(profile, 'package.json'), JSON.stringify({
name: 'dsh-profile-web', private: true,
dependencies: { 'dsh-mgr-workspace': 'file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz', 'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz', 'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz' },
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' },
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: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'dsh-web-mobile', 'dsh-free-search', 'dsh-mgr-workspace', 'dsh-chat-manager'] } },
}, null, 2), { flag: 'wx', mode: 0o600 });
console.log('dsh-mgr: new web profile includes [email protected] and [email protected]');
console.log('dsh-mgr: new web profile includes [email protected] and [email protected] and [email protected]');
}
const manifestPath = join(profile, 'package.json');
const workspacePath = join(profile, 'pnpm-workspace.yaml');
// Backups omit workspace settings; restore host-provided peers before pnpm runs.
if (existsSync(manifestPath) && !existsSync(workspacePath)) {
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
if (manifest.dependencies?.['dsh-web-mobile'] === 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz' || manifest.dependencies?.['dsh-free-search'] === 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz') {
if (manifest.dependencies?.['dsh-chat-manager'] === 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.12-dsh-mgr.1.tgz' || manifest.dependencies?.['dsh-web-mobile'] === 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz' || manifest.dependencies?.['dsh-free-search'] === 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz') {
writeFileSync(workspacePath, 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n', { flag: 'wx', mode: 0o600 });
}
}
+24
View File
@@ -11,6 +11,9 @@ 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.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');
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'));
@@ -24,11 +27,15 @@ test('new profile enables bundle once and preserves existing configuration', ()
run();assert.equal(readFileSync(file,'utf8'),original);
const legacy = JSON.parse(original);
delete legacy.dependencies['dsh-mgr-workspace'];
delete legacy.dependencies['dsh-chat-manager'];
delete legacy.pnpm.overrides['dsh-chat-manager>clsx'];
legacy.dsh.profile.bundles = legacy.dsh.profile.bundles.filter(name => name !== 'dsh-chat-manager');
legacy.dsh.profile.bundles = legacy.dsh.profile.bundles.filter(name => name !== 'dsh-mgr-workspace');
writeFileSync(file, JSON.stringify(legacy));
run();
assert.deepEqual(JSON.parse(readFileSync(file)), legacy);
const disabled = JSON.parse(original);
disabled.dsh.profile.bundles = disabled.dsh.profile.bundles.filter(name => name !== 'dsh-chat-manager');
disabled.dsh.profile.bundles = disabled.dsh.profile.bundles.filter(name => name !== 'dsh-web-mobile');
writeFileSync(file, JSON.stringify(disabled));
writeFileSync(workspace, 'custom workspace');
@@ -42,6 +49,23 @@ test('new profile enables bundle once and preserves existing configuration', ()
writeFileSync(file,custom);run();assert.equal(readFileSync(file,'utf8'),custom);
} finally { rmSync(home,{recursive:true,force:true}); }
});
test('chat-manager-only restored profile gets offline peer settings without manifest changes', () => {
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.12-dsh-mgr.1.tgz' } });
writeFileSync(join(profile, 'package.json'), manifest);
execFileSync(process.execPath, [new URL('./init-profile.mjs', import.meta.url).pathname], { env: { ...process.env, DSH_HOME: home } });
assert.equal(readFileSync(join(profile, 'package.json'), 'utf8'), manifest);
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];
execFileSync(process.execPath, ['-e', predicate], { env: { ...process.env, DSH_HOME: home } });
writeFileSync(join(profile, 'package.json'), JSON.stringify({ dependencies: { 'dsh-chat-manager': '^1.5.12' } }));
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 {
+53
View File
@@ -0,0 +1,53 @@
import { createHash } from 'node:crypto';
import { mkdirSync, writeFileSync, readFileSync } 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.12';
const patchedVersion = '1.5.12-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.12.tgz', 'UpecIKftHTKZMAdZg0w/GFtYGN2A/fB7NVQ/OrrBOKsq1frN5nm4CDBH8KZggwfT9GSx+MjwLlwYgxDfDWLm4g=='],
['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 || {})) {
if (name === 'react') {
if (host.version !== '0.2.0-rc.2') throw new Error('unverified browser React host: ' + host.version);
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)');
+33
View File
@@ -0,0 +1,33 @@
import { createHash } from 'node:crypto';
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
const url = 'https://hyperos.mi.com/font-download/MiSans.zip';
const sha256 = 'b6aa1fc827035922612df8edf36e5609bca1c5441e25cd57572204569b7b81d9';
const weights = ['Thin', 'ExtraLight', 'Light', 'Normal', 'Regular', 'Medium', 'Demibold', 'Semibold', 'Bold', 'Heavy'];
const destination = '/usr/local/share/fonts/misans';
const temporary = mkdtempSync(join(tmpdir(), 'misans-'));
try {
const response = await fetch(url, { signal: AbortSignal.timeout(300000) });
if (!response.ok) throw new Error(`MiSans download failed: ${response.status}`);
const archive = Buffer.from(await response.arrayBuffer());
if (createHash('sha256').update(archive).digest('hex') !== sha256) {
throw new Error('MiSans archive checksum mismatch; review upstream changes before updating');
}
const archivePath = join(temporary, 'MiSans.zip');
writeFileSync(archivePath, archive);
mkdirSync(destination, { recursive: true });
for (const weight of weights) {
const name = `MiSans-${weight}.ttf`;
// Extract only fixed, reviewed filenames; never unpack arbitrary archive paths.
const font = execFileSync('python3', ['-c', 'import sys, zipfile; sys.stdout.buffer.write(zipfile.ZipFile(sys.argv[1]).read(sys.argv[2]))', archivePath, `MiSans/ttf/${name}`], { maxBuffer: 16000000 });
writeFileSync(join(destination, name), font, { mode: 0o644 });
}
execFileSync('fc-cache', ['-f', destination], { stdio: 'inherit' });
const family = execFileSync('fc-match', ['-f', '%{family}', 'MiSans'], { encoding: 'utf8' });
if (!family.split(',').includes('MiSans')) throw new Error(`MiSans font lookup failed: ${family}`);
} finally {
rmSync(temporary, { recursive: true, force: true });
}
+24
View File
@@ -0,0 +1,24 @@
// Scoped to this plugin; host authentication and global fetch are unchanged.
async function dshChatFetch(path, init) {
const parts = window.location.pathname.split('/');
const prefix = parts[1] === 'dsh' && parts[2] ? '/dsh/' + parts[2] : '';
return fetch(prefix + path, init);
}
async function dshChatJson(response) {
const text = await response.text();
try { return JSON.parse(text); } catch {
throw new Error('Chat Manager: HTTP ' + response.status + ' returned a non-JSON response; check the instance proxy path and authentication.');
}
}
export const clientHelpers = dshChatFetch.toString() + String.fromCharCode(10) + dshChatJson.toString() + String.fromCharCode(10);
export function patchClient(source, version) {
if (version !== '1.5.12') 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)');
}
+34
View File
@@ -0,0 +1,34 @@
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 = Array(3).fill("await fetch('/plugins/dsh-session-delete/delete'); await response.json()").join(';');
const patched = patchClient(source, '1.5.12');
assert.equal(patched.split('await dshChatFetch(').length, 4);
assert.throws(() => patchClient(source, '1.5.13'), /unsupported/);
assert.throws(() => patchClient('', '1.5.12'), /unexpected/);
assert.throws(() => patchClient(patched, '1.5.12'), /unexpected/);
});
+9
View File
@@ -14,3 +14,12 @@ test('Node and Caddy stages inherit the target platform', () => {
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'));
});