feat(runtime): upgrade DSH and bundled plugins with startup migration

This commit is contained in:
2026-10-18 02:14:27 +08:00
parent eb7e809999
commit 2e31e74972
18 changed files with 501 additions and 80 deletions
+13 -1
View File
@@ -1,5 +1,17 @@
# DSH Runtime Release # DSH Runtime Release
## runtime-7.9:DSH next 与启动配置迁移
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:修复下载按钮服务依赖 ## runtime-7.8:修复下载按钮服务依赖
下载插件升级为 dsh-preview-download@1.0.1,补齐 remote 服务声明,修复点击时在发送请求前抛出 cannot get property "remote" without inject 的问题。同步更新内置包和新 profile 引用,新增注册回调依赖回归测试。此修复不处理面板 401、Office 504 或 plugins/events HTTP/2 连接错误。 下载插件升级为 dsh-preview-download@1.0.1,补齐 remote 服务声明,修复点击时在发送请求前抛出 cannot get property "remote" without inject 的问题。同步更新内置包和新 profile 引用,新增注册回调依赖回归测试。此修复不处理面板 401、Office 504 或 plugins/events HTTP/2 连接错误。
@@ -76,7 +88,7 @@ Runtime 镜像包含 Debian Bookworm 的 Python 3、pip、venv(`python` 指向
## 发布清单 ## 发布清单
`release.json` 是发布入口:`schema_version` 为清单格式版本,`release_id` 标识一次发布,`version` 为精确 DSH 版本,`deployment_schema` 为运行布局版本,`platforms` 为支持的平台。当前待发布配置为 `runtime-7.8`、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.9`、DSH `0.2.1-rc.2`、布局 7、`linux/amd64` 和 `linux/arm64`(aarch64,不含 32 位 ARM)。
ARM64 验收状态:已确认 Node/Caddy 基础镜像提供 ARM64 版本,但尚未完成 ARM64 runtime 构建及运行验收。清单声明会开放面板的对应平台构建入口,不等于兼容性证明;验收完成前不要推送发布。 ARM64 验收状态:已确认 Node/Caddy 基础镜像提供 ARM64 版本,但尚未完成 ARM64 runtime 构建及运行验收。清单声明会开放面板的对应平台构建入口,不等于兼容性证明;验收完成前不要推送发布。
+1 -1
View File
@@ -19,7 +19,7 @@ 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 RUN node /usr/local/lib/dsh-mgr/install-chat-manager.mjs
COPY install-workspace.mjs /usr/local/lib/dsh-mgr/install-workspace.mjs COPY install-workspace.mjs /usr/local/lib/dsh-mgr/install-workspace.mjs
RUN node /usr/local/lib/dsh-mgr/install-workspace.mjs RUN node /usr/local/lib/dsh-mgr/install-workspace.mjs
COPY init-profile.mjs /usr/local/lib/dsh-mgr/init-profile.mjs COPY init-profile.mjs reconcile-profile.mjs /usr/local/lib/dsh-mgr/
COPY install-host-settings.mjs /usr/local/lib/dsh-mgr/install-host-settings.mjs 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 RUN node /usr/local/lib/dsh-mgr/install-host-settings.mjs
COPY --from=proxy /usr/bin/caddy /usr/bin/caddy COPY --from=proxy /usr/bin/caddy /usr/bin/caddy
+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. # pnpm otherwise probes registry metadata for the overlapping optional peer.
# Restrict offline mode to profiles containing only our fixed archives. # Restrict offline mode to profiles containing only our fixed archives.
install_env=() install_env=()
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.1.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 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) install_env=(npm_config_offline=true)
fi fi
if timeout --signal=TERM --kill-after=5s 120s env CI=true "${install_env[@]}" dsh plugin --profile web install --ignore-scripts; then if timeout --signal=TERM --kill-after=5s 120s env CI=true "${install_env[@]}" dsh plugin --profile web install --ignore-scripts; then
+35 -34
View File
@@ -1,36 +1,37 @@
import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync, lstatSync } from 'node:fs'; import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path'; import { join, resolve } from 'node:path';
const home = process.env.DSH_HOME; import { pathToFileURL } from 'node:url';
if (!home) throw new Error('DSH_HOME required'); import { BUILTIN_DEPENDENCIES, BUILTIN_OVERRIDES, assertSafeProfile, reconcileProfile } from './reconcile-profile.mjs';
const profile = join(home, 'profiles/web'); export function initProfile(home, options) {
for (const dir of [home, join(home, 'profiles'), profile]) { if (!home) throw new Error('DSH_HOME required');
try { const profile = join(home, 'profiles/web');
const stat = lstatSync(dir); assertSafeProfile(profile);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('unsafe profile directory'); if (!existsSync(profile) || readdirSync(profile).length === 0) {
} catch (error) { if (error.code !== 'ENOENT') throw error; } mkdirSync(profile, { recursive: true });
} writeFileSync(join(profile, 'package.json'), JSON.stringify({
// Never rewrite an existing user manifest or patch. name: 'dsh-profile-web', private: true,
if (!existsSync(profile) || readdirSync(profile).length === 0) { dependencies: BUILTIN_DEPENDENCIES,
mkdirSync(profile, { recursive: true }); pnpm: { overrides: BUILTIN_OVERRIDES },
writeFileSync(join(profile, 'package.json'), JSON.stringify({ 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'] } },
name: 'dsh-profile-web', private: true, }, null, 2), { flag: 'wx', mode: 0o600 });
dependencies: { '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.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' }, console.log('dsh-mgr: new web profile includes dsh-web-mobile@3.1.0 and [email protected] and [email protected]');
pnpm: { overrides: { }
'dsh-chat-manager>clsx': 'file:/usr/local/lib/dsh-mgr/clsx-2.1.1.tgz', const migration = reconcileProfile(profile, options);
'dsh-free-search>@deepseek-ai/schemastery': 'file:/usr/local/lib/dsh-mgr/schemastery-3.18.4.tgz', console.log(migration.changed
'@deepseek-ai/schemastery>@deepseek-ai/cosmokit': 'file:/usr/local/lib/dsh-mgr/cosmokit-1.8.5.tgz', ? "dsh-mgr: builtin profile updated; original saved to " + migration.backupPath
'@deepseek-ai/schemastery>@standard-schema/spec': 'file:/usr/local/lib/dsh-mgr/spec-1.1.0.tgz', : "dsh-mgr: builtin profile check complete; no changes");
} }, const manifestPath = join(profile, 'package.json');
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'] } }, const workspacePath = join(profile, 'pnpm-workspace.yaml');
}, null, 2), { flag: 'wx', mode: 0o600 }); // Backups omit workspace settings; restore host-provided peers before pnpm runs.
console.log('dsh-mgr: new web profile includes [email protected] and [email protected] and [email protected]'); if (existsSync(manifestPath) && !existsSync(workspacePath)) {
} const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const manifestPath = join(profile, 'package.json'); if (['dependencies', 'devDependencies', 'optionalDependencies'].some(section =>
const workspacePath = join(profile, 'pnpm-workspace.yaml'); ['dsh-chat-manager', 'dsh-web-mobile', 'dsh-free-search'].some(name => manifest[section]?.[name] === BUILTIN_DEPENDENCIES[name]))) {
// Backups omit workspace settings; restore host-provided peers before pnpm runs. writeFileSync(workspacePath, 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n', { flag: 'wx', mode: 0o600 });
if (existsSync(manifestPath) && !existsSync(workspacePath)) { }
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
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 });
} }
} }
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
initProfile(process.env.DSH_HOME);
}
+30 -12
View File
@@ -2,27 +2,38 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync, existsSync } from 'node:fs'; import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join, basename } from 'node:path';
import { execFileSync } from 'node:child_process'; 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', () => { test('new profile enables bundle once and preserves existing configuration', () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-init-')); const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-init-'));
try { try {
const run = () => execFileSync(process.execPath, [new URL('./init-profile.mjs', import.meta.url).pathname], {env:{...process.env, DSH_HOME:home}}); const options = archives(home);
const run = () => initProfile(home, options);
run(); run();
const file=join(home,'profiles/web/package.json'); const file=join(home,'profiles/web/package.json');
const original=readFileSync(file,'utf8'); 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).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.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.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).dependencies['dsh-chat-manager'], 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.16-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.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-web-mobile'));
assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-free-search')); 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.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-mgr-workspace'], 'file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz');
assert.equal(JSON.parse(original).dependencies['dsh-free-search'], 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz'); assert.equal(JSON.parse(original).dependencies['dsh-free-search'], 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.9.2.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).pnpm.overrides['dsh-free-search>@deepseek-ai/schemastery'], 'file:/usr/local/lib/dsh-mgr/schemastery-3.18.4.tgz');
assert.equal(JSON.parse(original).dependencies['dsh-web-mobile'], 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz'); assert.equal(JSON.parse(original).dependencies['dsh-web-mobile'], 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.1.0.tgz');
const workspace = join(home, 'profiles/web/pnpm-workspace.yaml'); const workspace = join(home, 'profiles/web/pnpm-workspace.yaml');
assert.match(readFileSync(workspace, 'utf8'), /autoInstallPeers: false/); assert.match(readFileSync(workspace, 'utf8'), /autoInstallPeers: false/);
assert.match(readFileSync(workspace, 'utf8'), /nodeLinker: hoisted/); assert.match(readFileSync(workspace, 'utf8'), /nodeLinker: hoisted/);
@@ -51,20 +62,27 @@ test('new profile enables bundle once and preserves existing configuration', ()
writeFileSync(file,custom);run();assert.equal(readFileSync(file,'utf8'),custom); writeFileSync(file,custom);run();assert.equal(readFileSync(file,'utf8'),custom);
} finally { rmSync(home,{recursive:true,force:true}); } } finally { rmSync(home,{recursive:true,force:true}); }
}); });
test('chat-manager-only restored profile gets offline peer settings without manifest changes', () => { test('chat-manager-only restored profile gets overrides and offline peer settings', () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-chat-restore-')); const home = mkdtempSync(join(tmpdir(), 'dsh-chat-restore-'));
try { try {
const profile = join(home, 'profiles/web'); const profile = join(home, 'profiles/web');
mkdirSync(profile, { recursive: true }); 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' } }); const manifest = JSON.stringify({ dependencies: { 'dsh-chat-manager': 'file:/usr/local/lib/dsh-mgr/dsh-chat-manager-1.5.16-dsh-mgr.1.tgz' } });
writeFileSync(join(profile, 'package.json'), manifest); writeFileSync(join(profile, 'package.json'), manifest);
execFileSync(process.execPath, [new URL('./init-profile.mjs', import.meta.url).pathname], { env: { ...process.env, DSH_HOME: home } }); initProfile(home, archives(home));
assert.equal(readFileSync(join(profile, 'package.json'), 'utf8'), manifest); 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/); assert.match(readFileSync(join(profile, 'pnpm-workspace.yaml'), 'utf8'), /autoInstallPeers: false/);
const entrypoint = readFileSync(new URL('./entrypoint.sh', import.meta.url), 'utf8'); const entrypoint = readFileSync(new URL('./entrypoint.sh', import.meta.url), 'utf8');
const predicate = entrypoint.match(/if node -e '([^']+)'/)[1]; 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 } }); 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' } })); 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' })); assert.throws(() => execFileSync(process.execPath, ['-e', predicate], { env: { ...process.env, DSH_HOME: home }, stdio: 'pipe' }));
} finally { rmSync(home, { recursive: true, force: true }); } } finally { rmSync(home, { recursive: true, force: true }); }
}); });
@@ -74,7 +92,7 @@ test('nonempty legacy profiles are not automatically injected', () => {
const profile = join(home, 'profiles/web'); const profile = join(home, 'profiles/web');
mkdirSync(profile, { recursive: true }); mkdirSync(profile, { recursive: true });
writeFileSync(join(profile, 'cordis.patch.yml'), '[]'); writeFileSync(join(profile, 'cordis.patch.yml'), '[]');
execFileSync(process.execPath, [new URL('./init-profile.mjs', import.meta.url).pathname], { env: { ...process.env, DSH_HOME: home } }); initProfile(home, archives(home));
assert.equal(existsSync(join(profile, 'package.json')), false); assert.equal(existsSync(join(profile, 'package.json')), false);
assert.equal(existsSync(join(profile, 'pnpm-workspace.yaml')), false); assert.equal(existsSync(join(profile, 'pnpm-workspace.yaml')), false);
} finally { rmSync(home, { recursive: true, force: true }); } } finally { rmSync(home, { recursive: true, force: true }); }
+20 -5
View File
@@ -1,16 +1,16 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; import { mkdirSync, writeFileSync, readFileSync, readdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { patchClient } from './patch-chat-manager.mjs'; import { patchClient } from './patch-chat-manager.mjs';
const root = '/usr/local/lib/dsh-mgr'; const root = '/usr/local/lib/dsh-mgr';
const version = '1.5.12'; const version = '1.5.16';
const patchedVersion = '1.5.12-dsh-mgr.1'; const patchedVersion = '1.5.16-dsh-mgr.1';
const hostManifest = '/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'; 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 destination = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/dsh-chat-manager';
const packages = [ 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=='], ['dsh-chat-manager', version, 'https://registry.npmjs.org/dsh-chat-manager/-/dsh-chat-manager-1.5.16.tgz', 'tPE79Q/FKA9+MxI/GAcRACyN48tm9vxqM9kb4iMRDgP4EMaS39Ipbe7+4XisZcYIR6QjndBc8mQjlxb/gnFzuQ=='],
['clsx', '2.1.1', 'https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz', 'eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=='], ['clsx', '2.1.1', 'https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz', 'eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=='],
]; ];
mkdirSync(root, { recursive: true }); mkdirSync(root, { recursive: true });
@@ -30,8 +30,23 @@ const require = createRequire(hostManifest);
const host = JSON.parse(readFileSync(hostManifest)); const host = JSON.parse(readFileSync(hostManifest));
const semver = createRequire('/usr/local/lib/node_modules/npm/package.json')('semver'); const semver = createRequire('/usr/local/lib/node_modules/npm/package.json')('semver');
for (const [name, range] of Object.entries(pkg.peerDependencies || {})) { for (const [name, range] of Object.entries(pkg.peerDependencies || {})) {
// 1.5.16 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 (name === 'react') {
if (host.version !== '0.2.0-rc.2') throw new Error('unverified browser React host: ' + host.version); if (host.version !== '0.2.1-rc.2') 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.2') {
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; continue;
} }
const peer = require(name + '/package.json'); const peer = require(name + '/package.json');
+7 -7
View File
@@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
const root = '/usr/local/lib/dsh-mgr'; const root = '/usr/local/lib/dsh-mgr';
const packages = [ const packages = [
['dsh-free-search', '0.8.5', 'https://registry.npmjs.org/dsh-free-search/-/dsh-free-search-0.8.5.tgz', 'MT00Xtypw9uk9QsqaiRmtVbYwnPON+HJY1lZjYritEvuwSe9ahgG9NVO43lUI+Eje2umrqUapaeqLRYf34DrZw=='], ['dsh-free-search', '0.9.2', 'https://registry.npmjs.org/dsh-free-search/-/dsh-free-search-0.9.2.tgz', 'pFT/ngMcA7hxjbubNbl9iIBmf0zq9I6seVFcHVR8jUckCgoqoX92vjAmVIGA5mzSKELe/CFkq7uCnXtdTIg92Q=='],
['@deepseek-ai/schemastery', '3.18.4', 'https://registry.npmjs.org/@deepseek-ai/schemastery/-/schemastery-3.18.4.tgz', 'SSXO6tYuyrIqKVbmOnIq0s+riUywYzouFMcnBluHF9n4KMo2G8HvEzhCYj6pj2617/glOjbJuVInJ9hfONsjkg=='], ['@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=='], ['@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=='], ['@standard-schema/spec', '1.1.0', 'https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz', 'l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=='],
@@ -20,21 +20,21 @@ for (const [name, version, url, integrity] of packages) {
const hostManifest = '/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'; const 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'; const destination = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/dsh-free-search';
mkdirSync(destination, { recursive: true }); mkdirSync(destination, { recursive: true });
execFileSync('tar', ['-xzf', `${root}/dsh-free-search-0.8.5.tgz`, '--strip-components=1', '--no-same-owner', '-C', destination]); execFileSync('tar', ['-xzf', `${root}/dsh-free-search-0.9.2.tgz`, '--strip-components=1', '--no-same-owner', '-C', destination]);
execFileSync('chmod', ['-R', 'a+rX', destination]); execFileSync('chmod', ['-R', 'a+rX', destination]);
const pkg = JSON.parse(readFileSync(destination + '/package.json')); const pkg = JSON.parse(readFileSync(destination + '/package.json'));
if (pkg.name !== 'dsh-free-search' || pkg.version !== '0.8.5' || JSON.stringify(pkg.dependencies) !== JSON.stringify({ '@deepseek-ai/schemastery': '^3.18.4' })) throw new Error('unexpected free-search metadata'); if (pkg.name !== 'dsh-free-search' || pkg.version !== '0.9.2' || JSON.stringify(pkg.dependencies) !== JSON.stringify({ '@deepseek-ai/schemastery': '^3.18.4' })) throw new Error('unexpected free-search metadata');
const require = createRequire(hostManifest); const require = createRequire(hostManifest);
const semver = createRequire('/usr/local/lib/node_modules/npm/package.json')('semver'); const semver = createRequire('/usr/local/lib/node_modules/npm/package.json')('semver');
for (const [name, range] of Object.entries({ ...pkg.peerDependencies, ...pkg.dependencies })) { for (const [name, range] of Object.entries({ ...pkg.peerDependencies, ...pkg.dependencies })) {
if (name === 'react') { if (name === 'react') {
if (!['0.2.0-rc.1', '0.2.0-rc.2'].includes(require('./package.json').version)) throw new Error('unverified browser React host'); if (!['0.2.0-rc.1', '0.2.1-rc.2'].includes(require('./package.json').version)) throw new Error('unverified browser React host');
continue; continue;
} }
const peer = require(name + '/package.json'); const peer = require(name + '/package.json');
if (!semver.satisfies(peer.version, range)) throw new Error('incompatible free-search host dependency: ' + name); if (!(semver.satisfies(peer.version, range) || (peer.version === "0.2.1-rc.2" && 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)); const host = JSON.parse(readFileSync(hostManifest));
host.dependencies = { ...host.dependencies, 'dsh-free-search': '0.8.5' }; host.dependencies = { ...host.dependencies, 'dsh-free-search': '0.9.2' };
writeFileSync(hostManifest, JSON.stringify(host, null, 2)); writeFileSync(hostManifest, JSON.stringify(host, null, 2));
console.log('Installed dsh-free-search@0.8.5 and pinned offline dependency archives (SHA-512 verified; no lifecycle scripts)'); console.log('Installed dsh-free-search@0.9.2 and pinned offline dependency archives (SHA-512 verified; no lifecycle scripts)');
+5
View File
@@ -8,6 +8,11 @@ const replacement = 'const persistence = "host"; /* dsh-mgr: authenticated remot
// Deliberately scoped to the settings UI, not connection.isLoopback or auth. // Deliberately scoped to the settings UI, not connection.isLoopback or auth.
// Fail closed on upstream changes instead of silently shipping a broken UI. // Fail closed on upstream changes instead of silently shipping a broken UI.
export function patchSettings(source, version) { export function patchSettings(source, version) {
// Native host settings supersede the old persistence patch.
if (version === "0.2.1-rc.2") {
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 (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)) { if (source.split(target).length !== 2 || source.includes(replacement)) {
throw new Error('expected exactly one unpatched settings persistence selector'); throw new Error('expected exactly one unpatched settings persistence selector');
+7
View File
@@ -4,6 +4,13 @@ import { runInNewContext } from 'node:vm';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { patchSettings, target } from './install-host-settings.mjs'; 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.2"), source);
assert.throws(() => patchSettings(target, "0.2.1-rc.2"), /native host settings/);
assert.throws(() => patchSettings("", "0.2.1-rc.2"), /native host settings/);
});
test('settings use host persistence on domains without changing loopback facts', () => { test('settings use host persistence on domains without changing loopback facts', () => {
for (const isLoopback of [false, true]) { for (const isLoopback of [false, true]) {
const ctx = { remote: { $host: { isLoopback } } }; const ctx = { remote: { $host: { isLoopback } } };
+7 -7
View File
@@ -2,15 +2,15 @@ import { createHash } from 'node:crypto';
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
const version = '3.0.5'; const version = '3.1.0';
const integrity = 'N3wmegFHSd23J0+xjotKyBq0ZbLXUbkWZ65E+aSw5pzB/nudhaNHWT2V3CSNaW/LX3s6AxAWB2APpfA0dLXJZQ=='; const integrity = 'dvRn0p0Y54RQErNRmW8z6K7JQJpo2euuMcgTvfJtgEMUJecs9Mcg4jqUNbmHWeMyijmZMUes/IRbzopDxKIdXA==';
const response = await fetch('https://registry.npmjs.org/dsh-web-mobile/-/dsh-web-mobile-3.0.5.tgz', { signal: AbortSignal.timeout(120000) }); const response = await fetch('https://registry.npmjs.org/dsh-web-mobile/-/dsh-web-mobile-3.1.0.tgz', { signal: AbortSignal.timeout(120000) });
if (!response.ok) throw new Error('mobile plugin download failed'); if (!response.ok) throw new Error('mobile plugin download failed');
const data = Buffer.from(await response.arrayBuffer()); const data = Buffer.from(await response.arrayBuffer());
if (createHash('sha512').update(data).digest('base64') !== integrity) throw new Error('mobile plugin integrity mismatch'); 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'; const destination = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/dsh-web-mobile';
mkdirSync(destination, { recursive: true }); mkdirSync(destination, { recursive: true });
const archive = '/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz'; const archive = '/usr/local/lib/dsh-mgr/dsh-web-mobile-3.1.0.tgz';
writeFileSync(archive, data, { mode: 0o644 }); writeFileSync(archive, data, { mode: 0o644 });
execFileSync('tar', ['-xzf', archive, '--strip-components=1', '--no-same-owner', '-C', destination]); 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. // Published tar modes can be owner-only; runtime is UID 1000, not build root.
@@ -24,7 +24,7 @@ for (const [name, range] of Object.entries(pkg.peerDependencies || {})) {
// Limit that exception to the upstream-declared, tested RC hosts. // Limit that exception to the upstream-declared, tested RC hosts.
if (name === 'react') { if (name === 'react') {
const host = require('./package.json').version; const host = require('./package.json').version;
if (!['0.2.0-rc.1', '0.2.0-rc.2'].includes(host)) throw new Error('unverified browser React host: ' + host); if (!['0.2.0-rc.1', '0.2.1-rc.2'].includes(host)) throw new Error('unverified browser React host: ' + host);
continue; continue;
} }
let peer; let peer;
@@ -33,7 +33,7 @@ for (const [name, range] of Object.entries(pkg.peerDependencies || {})) {
if (pkg.peerDependenciesMeta?.[name]?.optional) continue; if (pkg.peerDependenciesMeta?.[name]?.optional) continue;
throw new Error('missing required mobile peer: ' + name); throw new Error('missing required mobile peer: ' + name);
} }
if (!semver.satisfies(peer.version, range)) throw new Error('incompatible mobile peer: ' + name + '@' + peer.version); if (!(semver.satisfies(peer.version, range) || (peer.version === "0.2.1-rc.2" && 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 // DSH runtime resolution traverses the installation manifest, not arbitrary
// directories in node_modules. Declare the baked package in that graph. // directories in node_modules. Declare the baked package in that graph.
@@ -41,4 +41,4 @@ const hostManifest = '/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'
const host = JSON.parse(readFileSync(hostManifest)); const host = JSON.parse(readFileSync(hostManifest));
host.dependencies = { ...host.dependencies, 'dsh-web-mobile': version }; host.dependencies = { ...host.dependencies, 'dsh-web-mobile': version };
writeFileSync(hostManifest, JSON.stringify(host, null, 2)); writeFileSync(hostManifest, JSON.stringify(host, null, 2));
console.log('Installed dsh-web-mobile@3.0.5 (SHA-512 verified; no lifecycle scripts)'); console.log('Installed dsh-web-mobile@3.1.0 (SHA-512 verified; no lifecycle scripts)');
+1 -1
View File
@@ -4,7 +4,7 @@ import { pathToFileURL } from 'node:url';
export const target = 'import { createConverter } from "@deepseek-ai/libreoffice-kit";'; export const target = 'import { createConverter } from "@deepseek-ai/libreoffice-kit";';
export function patchOffice(source, hostVersion, packageVersion) { 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 (hostVersion !== '0.2.1-rc.2' || packageVersion !== '0.2.1-rc.2') throw new Error('unsupported Office preview version');
if (source.split(target).length !== 2) throw new Error('expected exactly one original converter import'); 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";'); return source.replace(target, 'import { createConverter } from "./dsh-native-office.mjs";');
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import { cpSync, mkdirSync, readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
const root = '/usr/local/lib/dsh-mgr'; const root = '/usr/local/lib/dsh-mgr';
const host = JSON.parse(readFileSync('/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json')); 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'); if (host.version !== '0.2.1-rc.2') throw new Error('Unverified preview download host');
mkdirSync(root + '/preview-download-package', { recursive: true }); mkdirSync(root + '/preview-download-package', { recursive: true });
cpSync(new URL('./preview-download', import.meta.url), root + '/preview-download-package/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']); execFileSync('tar', ['-czf', root + '/dsh-preview-download-1.0.1.tgz', '-C', root + '/preview-download-package', 'package']);
+4 -4
View File
@@ -7,11 +7,11 @@ import { createConverter, runProcess } from './native-office.mjs';
import { patchOffice, target } from './install-office-preview.mjs'; import { patchOffice, target } from './install-office-preview.mjs';
test('patch is exact, version gated and refuses repeat application', () => { test('patch is exact, version gated and refuses repeat application', () => {
const patched = patchOffice(target, '0.2.0-rc.2', '0.2.0-rc.2'); const patched = patchOffice(target, '0.2.1-rc.2', '0.2.1-rc.2');
assert.match(patched, /dsh-native-office/); 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')); for (const source of ['', target + target, patched]) assert.throws(() => patchOffice(source, '0.2.1-rc.2', '0.2.1-rc.2'));
assert.throws(() => patchOffice(target, 'next', '0.2.0-rc.2')); assert.throws(() => patchOffice(target, 'next', '0.2.1-rc.2'));
assert.throws(() => patchOffice(target, '0.2.0-rc.2', 'next')); assert.throws(() => patchOffice(target, '0.2.1-rc.2', 'next'));
}); });
test('process cancellation, missing binary and failure are classified', async () => { test('process cancellation, missing binary and failure are classified', async () => {
+1 -1
View File
@@ -13,7 +13,7 @@ async function dshChatJson(response) {
export const clientHelpers = dshChatFetch.toString() + String.fromCharCode(10) + dshChatJson.toString() + String.fromCharCode(10); export const clientHelpers = dshChatFetch.toString() + String.fromCharCode(10) + dshChatJson.toString() + String.fromCharCode(10);
export function patchClient(source, version) { export function patchClient(source, version) {
if (version !== '1.5.12') throw new Error('unsupported chat-manager version'); if (version !== '1.5.16') throw new Error('unsupported chat-manager version');
if (source.includes('dshChatFetch') || source.split('await fetch(').length !== 4 if (source.includes('dshChatFetch') || source.split('await fetch(').length !== 4
|| source.split('await response.json()').length !== 4 || source.split('await response.json()').length !== 4
|| source.split('/plugins/dsh-session-delete/').length !== 4) { || source.split('/plugins/dsh-session-delete/').length !== 4) {
+3 -3
View File
@@ -26,9 +26,9 @@ test('non-JSON errors report HTTP status without exposing response content', asy
}); });
test('patch rejects unsupported or changed clients and double patching', () => { 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 source = Array(3).fill("await fetch('/plugins/dsh-session-delete/delete'); await response.json()").join(';');
const patched = patchClient(source, '1.5.12'); const patched = patchClient(source, '1.5.16');
assert.equal(patched.split('await dshChatFetch(').length, 4); assert.equal(patched.split('await dshChatFetch(').length, 4);
assert.throws(() => patchClient(source, '1.5.13'), /unsupported/); assert.throws(() => patchClient(source, '1.5.13'), /unsupported/);
assert.throws(() => patchClient('', '1.5.12'), /unexpected/); assert.throws(() => patchClient('', '1.5.16'), /unexpected/);
assert.throws(() => patchClient(patched, '1.5.12'), /unexpected/); assert.throws(() => patchClient(patched, '1.5.16'), /unexpected/);
}); });
+121
View File
@@ -0,0 +1,121 @@
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.16-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.1.0.tgz',
'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.9.2.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'];
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']) {
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);
renameSync(temporaryPath, path);
temporaryCreated = false;
syncDirectory();
} finally {
if (temporaryCreated) unlinkSync(temporaryPath);
}
return { changed: true, backupPath };
}
+242
View File
@@ -0,0 +1,242 @@
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';
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, 2);
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',
'rename:manifest',
'open:directory-2', 'fsync:directory-2', 'close:directory-2',
];
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']) {
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/);
});
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"schema_version": 1, "schema_version": 1,
"release_id": "runtime-7.8", "release_id": "runtime-7.9",
"version": "0.2.0-rc.2", "version": "0.2.1-rc.2",
"deployment_schema": 7, "deployment_schema": 7,
"platforms": ["linux/amd64", "linux/arm64"] "platforms": ["linux/amd64", "linux/arm64"]
} }