diff --git a/README.md b/README.md new file mode 100644 index 0000000..9ca66d2 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# DSH Runtime Release + +`dockerfile` 分支只维护发布清单和 runtime 镜像构建文件,不包含管理面板源码或部署配置。 + +## 发布清单 + +`release.json` 是发布入口:`schema_version` 为清单格式版本,`release_id` 标识一次发布,`version` 为精确 DSH 版本,`deployment_schema` 为运行布局版本,`platforms` 为支持的平台。当前为 `runtime-7.1`、DSH `0.2.0-rc.2`、布局 7、`linux/amd64`。 + +此分支是可信代码来源,不是仅供读取的数据源:Dockerfile 和安装脚本会在构建时执行。只使用经过审核的提交,不要将不可信分支、PR 或任意远端内容直接用于构建。清单本身不代表镜像已经完成运行验收。 + +## 本地构建与检查 + +需要 Docker、Node.js,以及拉取基础镜像和构建依赖的网络。从仓库根目录执行,版本和平台直接取自清单,不跟踪 latest: + +~~~sh +DSH_VERSION="$(node -p "require('./release.json').version")" +PLATFORM="$(node -p "require('./release.json').platforms[0]")" +RELEASE_ID="$(node -p "require('./release.json').release_id")" +node --test image/*.test.mjs +docker build --platform "$PLATFORM" \ + --build-arg DSH_VERSION="$DSH_VERSION" \ + -t "dsh-web:$RELEASE_ID" ./image +~~~ + +`image/` 是构建上下文。构建只生成本地镜像,不推送镜像,也不自动发布分支。保留 `image/init-profile.test.mjs` 作为独立 runtime 回归测试,覆盖初始化幂等性、保留已有配置和拒绝符号链接目录;它不依赖面板,且被 `image/.dockerignore` 排除在构建上下文之外。此测试不能替代真实容器启动、插件和持久化验收。 + +## 布局 7 合约 + +- 以 UID/GID 1000 运行;实例持久目录挂载到 `/workspace`,必须允许该身份读写。`DSH_HOME`、`HOME` 和工作目录均为 `/workspace`,XDG 数据与配置也位于其中。 +- `/data/dsh-home` 是指向 `/workspace` 的兼容符号链接;不要用旧 home 挂载覆盖该路径。`/shared` 不是布局 7 的持久化入口。 +- DSH 监听 loopback 3080,Caddy 在容器 8080 转发;只通过受信入口访问,不直接公开 runtime 端口,不挂载 Docker socket。原生模块需要可执行的 `/tmp`,可保留 nosuid/nodev 与容量限制。 +- 新 profile 默认启用固定版本的 mobile、free-search 和 workspace bundle,默认工作区为 `/workspace`。已有非空 profile 不自动注入或覆盖;升级镜像不会强制改变已有插件选择。 +- 启动前协调插件依赖,失败则不启动应用;仅含内置固定依赖的 profile 使用离线安装。用户添加插件后可能需要网络。 + +## 手动发布 + +1. 审核 runtime 资产变更及其可信来源,选择精确 DSH 版本,更新 `release.json`。每次发布变更都必须使用新的 `release_id`;布局合约变化时同步更新 `deployment_schema`、镜像及调用方适配,不能只修改数字。 +2. 运行上述测试与本地构建,并完成目标平台的容器启动、插件、持久化与兼容验收;检查 `git diff --check` 和最终差异。 +3. 维护者确认后,**手动提交并手动推送** `dockerfile` 分支到已核实的发布远端。修改清单或本地构建不会自动 commit/push,也不会自动更新运行中的实例。 + +## 文件范围 + +- `release.json`:发布清单。 +- `README.md`:本分支说明。 +- `image/Dockerfile`、`image/Caddyfile`、`image/entrypoint.sh`、`image/.dockerignore`:镜像构建与启动。 +- `image/install-*.mjs`、`image/init-profile.mjs`:内置 runtime 资产安装与 profile 初始化。 +- `image/init-profile.test.mjs`:保留的 profile 初始化回归测试。 +- `image/entrypoint.test.mjs`:从面板测试迁移的 runtime 启动失败保护与镜像约束测试,需要 Bash;不依赖 Go 或面板。 diff --git a/image/.dockerignore b/image/.dockerignore new file mode 100644 index 0000000..d84e469 --- /dev/null +++ b/image/.dockerignore @@ -0,0 +1,10 @@ +.git +**/.git +.env +.env.* +**/.env +**/.env.* +node_modules +**/node_modules +*.test.mjs +embed.go diff --git a/image/Caddyfile b/image/Caddyfile new file mode 100644 index 0000000..008f263 --- /dev/null +++ b/image/Caddyfile @@ -0,0 +1,7 @@ +{ + admin off + auto_https off +} +:8080 { + reverse_proxy 127.0.0.1:3080 +} diff --git a/image/Dockerfile b/image/Dockerfile new file mode 100644 index 0000000..bc09b8f --- /dev/null +++ b/image/Dockerfile @@ -0,0 +1,30 @@ +FROM caddy:2.10.2 AS proxy +FROM node:24-bookworm-slim +ARG DSH_VERSION +# Keep the historical dependency-layer cache key; the template revision is set below. +ARG RECIPE_REV=3 +RUN apt-get update && apt-get install -y --no-install-recommends tini ca-certificates git procps && rm -rf /var/lib/apt/lists/* +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 pnpm@10.11.0 && 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-workspace.mjs /usr/local/lib/dsh-mgr/install-workspace.mjs +RUN node /usr/local/lib/dsh-mgr/install-workspace.mjs +COPY init-profile.mjs /usr/local/lib/dsh-mgr/init-profile.mjs +COPY --from=proxy /usr/bin/caddy /usr/bin/caddy +# Strip upstream file capabilities without granting any runtime capabilities. +# A fresh file copy omits security.capability xattrs; port 8080 needs none. +RUN cp /usr/bin/caddy /usr/bin/caddy.clean && mv /usr/bin/caddy.clean /usr/bin/caddy +COPY Caddyfile /etc/caddy/Caddyfile +COPY entrypoint.sh /usr/local/bin/dsh-entrypoint +RUN chmod 755 /usr/local/bin/dsh-entrypoint && mkdir -p /data /workspace /shared && ln -s /workspace /data/dsh-home && chown -R node:node /data /workspace /shared +ARG RECIPE_REV=7 +LABEL dsh-mgr.role=runtime dsh-mgr.version=$DSH_VERSION dsh-mgr.recipe=$RECIPE_REV +ENV DSH_HOME=/workspace HOME=/workspace XDG_DATA_HOME=/workspace/.local/share XDG_CONFIG_HOME=/workspace/.config +USER node +WORKDIR /workspace +EXPOSE 8080 +ENTRYPOINT ["/usr/bin/tini", "-g", "--", "/usr/local/bin/dsh-entrypoint"] +CMD ["web", "--no-open", "--port", "3080"] diff --git a/image/entrypoint.sh b/image/entrypoint.sh new file mode 100644 index 0000000..e389c7a --- /dev/null +++ b/image/entrypoint.sh @@ -0,0 +1,44 @@ +#!/bin/bash +set -Eeuo pipefail + +export npm_config_ignore_scripts=true npm_config_ignore_pnpmfile=true +node /usr/local/lib/dsh-mgr/init-profile.mjs +if [[ -f "${DSH_HOME}/profiles/web/package.json" ]]; then + echo "dsh-mgr: plugin prestart reconciliation starting" >&2 + # pnpm otherwise probes registry metadata for the overlapping optional peer. + # Restrict offline mode to profiles containing only our fixed archives. + install_env=() + if node -e '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 + 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 + echo "dsh-mgr: plugin prestart reconciliation complete" >&2 + else + status=$? + echo "dsh-mgr: plugin prestart reconciliation failed (exit $status); application not started" >&2 + exit "$status" + fi +else + echo "dsh-mgr: no web plugin manifest; using bundled plugins" >&2 +fi + +caddy run --config /etc/caddy/Caddyfile --adapter caddyfile & +proxy_pid=$! +echo +dsh "$@" & +dsh_pid=$! +cleanup() { + trap - TERM INT + kill -TERM "$proxy_pid" "$dsh_pid" 2>/dev/null || true + wait "$proxy_pid" 2>/dev/null || true + wait "$dsh_pid" 2>/dev/null || true +} +trap cleanup EXIT +trap 'exit 143' TERM +trap 'exit 130' INT +# Either child exiting terminates the container; never leave a proxy-only instance. +set +e +wait -n "$proxy_pid" "$dsh_pid" +status=$? +set -e +exit "$status" diff --git a/image/entrypoint.test.mjs b/image/entrypoint.test.mjs new file mode 100644 index 0000000..bf754fa --- /dev/null +++ b/image/entrypoint.test.mjs @@ -0,0 +1,76 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +for (const status of ['0', '23', '124', '137', 'empty']) { + test('runtime prestart status ' + status, () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-prestart-')); + try { + if (status !== 'empty') { + mkdirSync(join(dir, 'profiles/web'), { recursive: true, mode: 0o700 }); + writeFileSync(join(dir, 'profiles/web/package.json'), '{}', { mode: 0o600 }); + } + const scripts = { + node: `#!/bin/bash +exit 0 +`, + timeout: `#!/bin/bash +printf 'timeout %s\n' "$*" >> "$TRACE" +test "$1 $2 $3" = "--signal=TERM --kill-after=5s 120s" || exit 90 +shift 3 +exec "$@" +`, + dsh: `#!/bin/bash +printf 'dsh %s\n' "$*" >> "$TRACE" +test "$npm_config_ignore_scripts:$npm_config_ignore_pnpmfile" = true:true || exit 91 +if [ "$1" = plugin ]; then + test "$*" = "plugin --profile web install --ignore-scripts" || exit 92 + exit "$PLUGIN_STATUS" +fi +exit 0 +`, + caddy: `#!/bin/bash +echo caddy >> "$TRACE" +sleep 0.1 +`, + }; + for (const [name, script] of Object.entries(scripts)) { + writeFileSync(join(dir, name), script, { mode: 0o700 }); + } + const result = spawnSync('bash', [fileURLToPath(new URL('./entrypoint.sh', import.meta.url)), 'web', '--no-open', '--port', '3080'], { + env: { ...process.env, PATH: dir + ':' + process.env.PATH, DSH_HOME: dir, TRACE: join(dir, 'trace'), PLUGIN_STATUS: status }, + encoding: 'utf8', timeout: 5000, + }); + assert.ifError(result.error); + const output = result.stdout + result.stderr; + const trace = readFileSync(join(dir, 'trace'), 'utf8'); + assert.equal(result.status, status === 'empty' ? 0 : Number(status), output); + if (status === 'empty') { + assert.doesNotMatch(trace, /plugin/); + assert.match(trace, /dsh web/); + return; + } + assert.match(trace, /dsh plugin --profile web install --ignore-scripts/); + if (status === '0') { + assert.match(output, /reconciliation complete/); + assert.match(trace, /dsh web/); + } else { + assert.ok(output.includes('failed (exit ' + status + ')'), output); + assert.doesNotMatch(trace, /caddy|dsh web/); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +} + +test('runtime pins pnpm and uses nonroot with tini process-group forwarding', () => { + const dockerfile = readFileSync(new URL('./Dockerfile', import.meta.url), 'utf8'); + for (const required of ['pnpm@10.11.0', 'test "$(pnpm --version)" = "10.11.0"', 'USER node', '"/usr/bin/tini", "-g"']) { + assert.ok(dockerfile.includes(required), 'missing ' + required); + } +}); diff --git a/image/init-profile.mjs b/image/init-profile.mjs new file mode 100644 index 0000000..d8fd0b5 --- /dev/null +++ b/image/init-profile.mjs @@ -0,0 +1,35 @@ +import { mkdirSync, existsSync, writeFileSync, readFileSync, readdirSync, lstatSync } from 'node:fs'; +import { join } from 'node:path'; +const home = process.env.DSH_HOME; +if (!home) throw new Error('DSH_HOME required'); +const profile = join(home, 'profiles/web'); +for (const dir of [home, join(home, 'profiles'), profile]) { + try { + const stat = lstatSync(dir); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('unsafe profile directory'); + } catch (error) { if (error.code !== 'ENOENT') throw error; } +} +// Never rewrite an existing user manifest or patch. +if (!existsSync(profile) || readdirSync(profile).length === 0) { + mkdirSync(profile, { recursive: true }); + writeFileSync(join(profile, 'package.json'), JSON.stringify({ + name: 'dsh-profile-web', private: true, + dependencies: { 'dsh-mgr-workspace': 'file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz', 'dsh-web-mobile': 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz', 'dsh-free-search': 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz' }, + pnpm: { overrides: { + 'dsh-free-search>@deepseek-ai/schemastery': 'file:/usr/local/lib/dsh-mgr/schemastery-3.18.4.tgz', + '@deepseek-ai/schemastery>@deepseek-ai/cosmokit': 'file:/usr/local/lib/dsh-mgr/cosmokit-1.8.5.tgz', + '@deepseek-ai/schemastery>@standard-schema/spec': 'file:/usr/local/lib/dsh-mgr/spec-1.1.0.tgz', + } }, + dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', 'dsh-web-mobile', 'dsh-free-search', 'dsh-mgr-workspace'] } }, + }, null, 2), { flag: 'wx', mode: 0o600 }); + console.log('dsh-mgr: new web profile includes dsh-web-mobile@3.0.5 and dsh-free-search@0.8.5'); +} +const manifestPath = join(profile, 'package.json'); +const workspacePath = join(profile, 'pnpm-workspace.yaml'); +// Backups omit workspace settings; restore host-provided peers before pnpm runs. +if (existsSync(manifestPath) && !existsSync(workspacePath)) { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + if (manifest.dependencies?.['dsh-web-mobile'] === 'file:/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz' || manifest.dependencies?.['dsh-free-search'] === 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz') { + writeFileSync(workspacePath, 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n', { flag: 'wx', mode: 0o600 }); + } +} diff --git a/image/init-profile.test.mjs b/image/init-profile.test.mjs new file mode 100644 index 0000000..99be286 --- /dev/null +++ b/image/init-profile.test.mjs @@ -0,0 +1,64 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { execFileSync } from 'node:child_process'; +test('new profile enables bundle once and preserves existing configuration', () => { + const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-init-')); + try { + const run = () => execFileSync(process.execPath, [new URL('./init-profile.mjs', import.meta.url).pathname], {env:{...process.env, DSH_HOME:home}}); + run(); + const file=join(home,'profiles/web/package.json'); + const original=readFileSync(file,'utf8'); + assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-web-mobile')); + assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-free-search')); + assert.ok(JSON.parse(original).dsh.profile.bundles.includes('dsh-mgr-workspace')); + assert.equal(JSON.parse(original).dependencies['dsh-mgr-workspace'], 'file:/usr/local/lib/dsh-mgr/dsh-mgr-workspace-1.0.0.tgz'); + assert.equal(JSON.parse(original).dependencies['dsh-free-search'], 'file:/usr/local/lib/dsh-mgr/dsh-free-search-0.8.5.tgz'); + assert.equal(JSON.parse(original).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'); + const workspace = join(home, 'profiles/web/pnpm-workspace.yaml'); + assert.match(readFileSync(workspace, 'utf8'), /autoInstallPeers: false/); + assert.match(readFileSync(workspace, 'utf8'), /nodeLinker: hoisted/); + run();assert.equal(readFileSync(file,'utf8'),original); + const legacy = JSON.parse(original); + delete legacy.dependencies['dsh-mgr-workspace']; + 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-web-mobile'); + writeFileSync(file, JSON.stringify(disabled)); + writeFileSync(workspace, 'custom workspace'); + const patch = join(home, 'profiles/web/cordis.patch.yml'); + writeFileSync(patch, '[]'); + run(); + assert.deepEqual(JSON.parse(readFileSync(file)), disabled); + assert.equal(readFileSync(workspace, 'utf8'), 'custom workspace'); + assert.equal(readFileSync(patch, 'utf8'), '[]'); + const custom=JSON.stringify({custom:true}); + writeFileSync(file,custom);run();assert.equal(readFileSync(file,'utf8'),custom); + } finally { rmSync(home,{recursive:true,force:true}); } +}); +test('nonempty legacy profiles are not automatically injected', () => { + const home = mkdtempSync(join(tmpdir(), 'dsh-plugin-legacy-')); + try { + const profile = join(home, 'profiles/web'); + mkdirSync(profile, { recursive: true }); + writeFileSync(join(profile, 'cordis.patch.yml'), '[]'); + execFileSync(process.execPath, [new URL('./init-profile.mjs', import.meta.url).pathname], { env: { ...process.env, DSH_HOME: home } }); + assert.equal(existsSync(join(profile, 'package.json')), false); + assert.equal(existsSync(join(profile, 'pnpm-workspace.yaml')), false); + } finally { rmSync(home, { recursive: true, force: true }); } +}); +test('rejects symlinked profile ancestors', () => { + const home=mkdtempSync(join(tmpdir(),'dsh-plugin-init-')); + const outside=mkdtempSync(join(tmpdir(),'dsh-plugin-outside-')); + try { + symlinkSync(outside,join(home,'profiles')); + assert.throws(()=>execFileSync(process.execPath,[new URL('./init-profile.mjs',import.meta.url).pathname],{env:{...process.env,DSH_HOME:home},stdio:'pipe'})); + assert.equal(existsSync(join(outside,'web/package.json')),false); + } finally {rmSync(home,{recursive:true,force:true});rmSync(outside,{recursive:true,force:true});} +}); diff --git a/image/install-free-search.mjs b/image/install-free-search.mjs new file mode 100644 index 0000000..f314897 --- /dev/null +++ b/image/install-free-search.mjs @@ -0,0 +1,40 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +const root = '/usr/local/lib/dsh-mgr'; +const packages = [ + ['dsh-free-search', '0.8.5', 'https://registry.npmjs.org/dsh-free-search/-/dsh-free-search-0.8.5.tgz', 'MT00Xtypw9uk9QsqaiRmtVbYwnPON+HJY1lZjYritEvuwSe9ahgG9NVO43lUI+Eje2umrqUapaeqLRYf34DrZw=='], + ['@deepseek-ai/schemastery', '3.18.4', 'https://registry.npmjs.org/@deepseek-ai/schemastery/-/schemastery-3.18.4.tgz', 'SSXO6tYuyrIqKVbmOnIq0s+riUywYzouFMcnBluHF9n4KMo2G8HvEzhCYj6pj2617/glOjbJuVInJ9hfONsjkg=='], + ['@deepseek-ai/cosmokit', '1.8.5', 'https://registry.npmjs.org/@deepseek-ai/cosmokit/-/cosmokit-1.8.5.tgz', 'LXsrlem9z8dq4sLflj2yuCuYX7KqhdzF5hZly3eZyuTo8d6oDSOXuEgC5DbQWKW+lksm6zmOutQLsP/ma6wR6A=='], + ['@standard-schema/spec', '1.1.0', 'https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz', 'l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=='], +]; +for (const [name, version, url, integrity] of packages) { + const response = await fetch(url, { signal: AbortSignal.timeout(120000) }); + if (!response.ok) throw new Error('download failed: ' + name); + const data = Buffer.from(await response.arrayBuffer()); + if (createHash('sha512').update(data).digest('base64') !== integrity) throw new Error('integrity mismatch: ' + name); + const archive = `${root}/${name.split('/').pop()}-${version}.tgz`; + writeFileSync(archive, data, { mode: 0o644 }); +} +const hostManifest = '/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'; +const destination = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/dsh-free-search'; +mkdirSync(destination, { recursive: true }); +execFileSync('tar', ['-xzf', `${root}/dsh-free-search-0.8.5.tgz`, '--strip-components=1', '--no-same-owner', '-C', destination]); +execFileSync('chmod', ['-R', 'a+rX', destination]); +const pkg = JSON.parse(readFileSync(destination + '/package.json')); +if (pkg.name !== 'dsh-free-search' || pkg.version !== '0.8.5' || JSON.stringify(pkg.dependencies) !== JSON.stringify({ '@deepseek-ai/schemastery': '^3.18.4' })) throw new Error('unexpected free-search metadata'); +const require = createRequire(hostManifest); +const semver = createRequire('/usr/local/lib/node_modules/npm/package.json')('semver'); +for (const [name, range] of Object.entries({ ...pkg.peerDependencies, ...pkg.dependencies })) { + if (name === 'react') { + if (!['0.2.0-rc.1', '0.2.0-rc.2'].includes(require('./package.json').version)) throw new Error('unverified browser React host'); + continue; + } + const peer = require(name + '/package.json'); + if (!semver.satisfies(peer.version, range)) throw new Error('incompatible free-search host dependency: ' + name); +} +const host = JSON.parse(readFileSync(hostManifest)); +host.dependencies = { ...host.dependencies, 'dsh-free-search': '0.8.5' }; +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)'); diff --git a/image/install-mobile.mjs b/image/install-mobile.mjs new file mode 100644 index 0000000..9c6f819 --- /dev/null +++ b/image/install-mobile.mjs @@ -0,0 +1,44 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, writeFileSync, readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +const version = '3.0.5'; +const integrity = 'N3wmegFHSd23J0+xjotKyBq0ZbLXUbkWZ65E+aSw5pzB/nudhaNHWT2V3CSNaW/LX3s6AxAWB2APpfA0dLXJZQ=='; +const response = await fetch('https://registry.npmjs.org/dsh-web-mobile/-/dsh-web-mobile-3.0.5.tgz', { signal: AbortSignal.timeout(120000) }); +if (!response.ok) throw new Error('mobile plugin download failed'); +const data = Buffer.from(await response.arrayBuffer()); +if (createHash('sha512').update(data).digest('base64') !== integrity) throw new Error('mobile plugin integrity mismatch'); +const destination = '/usr/local/lib/node_modules/@deepseek-ai/dsh/node_modules/dsh-web-mobile'; +mkdirSync(destination, { recursive: true }); +const archive = '/usr/local/lib/dsh-mgr/dsh-web-mobile-3.0.5.tgz'; +writeFileSync(archive, data, { mode: 0o644 }); +execFileSync('tar', ['-xzf', archive, '--strip-components=1', '--no-same-owner', '-C', destination]); +// Published tar modes can be owner-only; runtime is UID 1000, not build root. +execFileSync('chmod', ['-R', 'a+rX', destination]); +const pkg = JSON.parse(readFileSync(destination + '/package.json')); +if (pkg.version !== version || pkg.name !== 'dsh-web-mobile' || Object.keys(pkg.dependencies || {}).length) throw new Error('unexpected mobile package metadata'); +const require = createRequire('/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'); +const semver = createRequire('/usr/local/lib/node_modules/npm/package.json')('semver'); +for (const [name, range] of Object.entries(pkg.peerDependencies || {})) { + // React is provided by the browser shell, not a Node package in DSH. + // Limit that exception to the upstream-declared, tested RC hosts. + if (name === 'react') { + const host = require('./package.json').version; + if (!['0.2.0-rc.1', '0.2.0-rc.2'].includes(host)) throw new Error('unverified browser React host: ' + host); + continue; + } + let peer; + try { peer = require(name + '/package.json'); } + catch (error) { + if (pkg.peerDependenciesMeta?.[name]?.optional) continue; + throw new Error('missing required mobile peer: ' + name); + } + if (!semver.satisfies(peer.version, range)) throw new Error('incompatible mobile peer: ' + name + '@' + peer.version); +} +// DSH runtime resolution traverses the installation manifest, not arbitrary +// directories in node_modules. Declare the baked package in that graph. +const hostManifest = '/usr/local/lib/node_modules/@deepseek-ai/dsh/package.json'; +const host = JSON.parse(readFileSync(hostManifest)); +host.dependencies = { ...host.dependencies, 'dsh-web-mobile': version }; +writeFileSync(hostManifest, JSON.stringify(host, null, 2)); +console.log('Installed dsh-web-mobile@3.0.5 (SHA-512 verified; no lifecycle scripts)'); diff --git a/image/install-workspace.mjs b/image/install-workspace.mjs new file mode 100644 index 0000000..ba21c8a --- /dev/null +++ b/image/install-workspace.mjs @@ -0,0 +1,12 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +const root = '/usr/local/lib/dsh-mgr'; +const destination = root + '/workspace-package/package'; +mkdirSync(destination, { recursive: true }); +writeFileSync(destination + '/package.json', JSON.stringify({ + name: 'dsh-mgr-workspace', version: '1.0.0', type: 'module', main: 'index.js', + dsh: { bundle: { patch: 'cordis.patch.yml' } }, +})); +writeFileSync(destination + '/index.js', "export const inject = ['workspaceRegistry', 'sessions'];\nexport async function apply(ctx) {\n await ctx.workspaceRegistry.initializeDefault(() => '/workspace');\n}\n"); +writeFileSync(destination + '/cordis.patch.yml', "- insert:\n - id: dsh-mgr-workspace\n name: dsh-mgr-workspace\n"); +execFileSync('tar', ['-czf', root + '/dsh-mgr-workspace-1.0.0.tgz', '-C', root + '/workspace-package', 'package']); diff --git a/release.json b/release.json new file mode 100644 index 0000000..ef73d9b --- /dev/null +++ b/release.json @@ -0,0 +1,7 @@ +{ + "schema_version": 1, + "release_id": "runtime-7.1", + "version": "0.2.0-rc.2", + "deployment_schema": 7, + "platforms": ["linux/amd64"] +}