diff --git a/docs/architecture.md b/docs/architecture.md
index 2c75a133..b5ad2168 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -130,26 +130,36 @@ backend 子系统对上提供统一的“后端可否管理、如何启动、何
### 3.3 资源与根目录解析
- `runtime_paths.rs` 负责 packaged root、workspace root 和资源路径探测。
-- Tauri 资源路径支持直接资源路径和 `_up_/resources` 回退路径。
-- `launch_plan.rs` 根据当前模式决定 backend cwd、root_dir 和 webui_dir。
+- Tauri 资源路径支持直接资源路径和 `_up_/resources` 候选;`launch_plan.rs` 会把每个候选视为完整的 backend/WebUI 根,不跨根混用资源。
+- 正式构建会把最终 `runtime-manifest.json` 的 SHA-256 编入可执行文件;打包态只接受 manifest 摘要与当前可执行文件一致的候选。
+- `launch_plan.rs` 校验 Desktop/Core/WebUI 版本、manifest 路径、WebUI marker、index 和入口摘要,再决定 backend cwd、root_dir 和 webui_dir。
+- packaged Core 最低要求为 `4.26.0`,因为打包态 readiness 必须通过 `/api/v1/stats/versions` 核对实际运行的 Core/code/WebUI。该限制不应用于 debug/dev 启动计划或显式外部 backend。
## 4. 主要流程
-### 4.1 启动流程
+### 4.1 打包资源生成与身份绑定
+
+1. `scripts/prepare-resources.mjs all` 从同一个 AstrBot checkout 依次准备 WebUI 和 backend,避免两次任务之间 source ref 漂移。
+2. `resource-identity.mjs` 要求 Core `>=4.26.0`,写入 WebUI `assets/version`,并校验 index 及其本地 JavaScript/CSS 入口。
+3. `runtime-manifest.mjs` 生成 backend manifest;最终 attestation 加入 Desktop/Core/source 信息以及 WebUI marker/index/入口摘要。
+4. `src-tauri/build.rs` 对最终 manifest 原始字节计算 SHA-256 并编入可执行文件;release 构建缺少 manifest 时直接失败。
+
+### 4.2 启动流程
1. `app_runtime.rs` 初始化 Tauri 插件、窗口事件、页面加载事件和托盘。
-2. `startup_task.rs` 异步解析启动计划,执行 backend readiness 检查与必要拉起。
-3. backend ready 后导航主窗口;失败时进入 startup error 路径。
-4. 页面加载过程中按来源策略注入 desktop bridge,并在需要时注入 startup loading mode。
+2. `startup_task.rs` 异步解析启动计划;打包态从 direct / `_up_/resources` 中选取与可执行文件绑定的完整资源根,开发态仍使用独立的 dev/custom 计划。
+3. backend readiness 在接受已运行或刚拉起的打包 backend 前,校验 `/api/v1/stats/versions`,并核对实际送出的 index 和 manifest 声明的入口摘要。
+4. backend ready 后用 manifest 摘要生成的 `astrbot_bundle` 查询参数导航主窗口;失败时进入可见的 startup error 路径。
+5. 页面加载过程中按来源策略注入 desktop bridge,并在需要时注入 startup loading mode。
-### 4.2 bridge 注入与桌面交互流程
+### 4.3 bridge 注入与桌面交互流程
1. `bridge/origin_policy.rs` 判断当前页面是否允许注入 desktop bridge。
2. `bridge/desktop.rs` 把 bootstrap 脚本注入 WebView。
3. WebUI 通过 `bridge/commands.rs` 调用 desktop IPC。
4. tray / window 子系统根据当前 locale 和窗口状态刷新文案与可见性。
-### 4.3 更新检查/安装流程
+### 4.4 更新检查/安装流程
1. `bridge/commands.rs` 先用 `bridge/updater_mode.rs` 判定当前 updater 模式。
2. `ManualDownload` / `Unsupported` 直接短路,复用 `bridge/updater_messages.rs` 和 `bridge/updater_types.rs` 返回统一结果。
@@ -157,14 +167,14 @@ backend 子系统对上提供统一的“后端可否管理、如何启动、何
4. updater manifest endpoint 优先取 `ASTRBOT_DESKTOP_UPDATER_STABLE_ENDPOINT` / `ASTRBOT_DESKTOP_UPDATER_NIGHTLY_ENDPOINT`,否则回退到 `tauri.conf.json`。
5. 版本比较仍由 `update_channel.rs` 统一控制 stable / nightly 跨通道规则。
-### 4.4 重启流程
+### 4.5 重启流程
1. 触发源来自 tray 菜单或 bridge IPC。
2. `restart_backend_flow.rs` 统一处理并发门禁。
3. `backend/restart.rs` 和 `backend/restart_strategy.rs` 决定 graceful 或 fallback 路径。
4. 完成后刷新 bridge / tray 侧可观察状态。
-### 4.5 退出流程
+### 4.6 退出流程
1. `lifecycle/events.rs` 在 `ExitRequested` 阶段先阻止直接退出。
2. `exit_state.rs` 尝试进入清理态。
@@ -179,11 +189,15 @@ backend 子系统对上提供统一的“后端可否管理、如何启动、何
- 源码仓库 URL/ref、clone/fetch/checkout。
- `scripts/prepare-resources/version-sync.mjs`
- 桌面版本同步。
+- `scripts/prepare-resources/resource-identity.mjs`
+ - packaged Core 最低能力门禁、WebUI marker/index/入口校验,以及最终 Core/WebUI attestation。
- `scripts/prepare-resources/backend-runtime.mjs`
- CPython runtime 准备。
- `scripts/prepare-resources/mode-tasks.mjs`
- WebUI / backend 资源准备任务。
- `scripts/prepare-resources/desktop-bridge-checks.mjs`
- bridge 工件校验。
+- `scripts/backend/runtime-manifest.mjs`
+ - backend runtime manifest 字段、相对路径和 source identity 生成规则。
当前本地和 CI 主要通过 `make lint`、`make test`、`check-rust.yml`、`check-scripts.yml` 维持这些边界。
diff --git a/docs/development.md b/docs/development.md
index 1dff8fff..799ffd3b 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -95,11 +95,13 @@ make prune
```bash
make update
-make update ASTRBOT_SOURCE_GIT_REF=v4.17.5
-make build ASTRBOT_DESKTOP_VERSION=v4.17.5
+make update ASTRBOT_SOURCE_GIT_REF=v4.26.0
+make build ASTRBOT_SOURCE_GIT_REF=v4.26.0 ASTRBOT_DESKTOP_VERSION=v4.26.0
make build ASTRBOT_BUILD_SOURCE_DIR=/path/to/AstrBot
```
+正式打包要求 AstrBot Core `>=4.26.0`。这是 `/api/v1/stats/versions` 首次可用于 Desktop 启动期 Core/code/WebUI 身份核对的版本;更早的 Core 会在 packaged resource 准备开始时明确失败。`make dev` 的开发启动计划和显式配置的外部 backend 不使用这条 packaged identity 门禁。
+
如果需要清理构建相关环境变量:
```bash
@@ -118,9 +120,14 @@ beforeBuildCommand = pnpm run prepare:resources
构建时会自动完成以下步骤:
1. 拉取或更新 AstrBot 源码。
-2. 构建并同步 `resources/webui`。
-3. 准备 `resources/backend`(包括运行时与启动脚本)。
-4. 执行 Tauri 打包。
+2. 校验 packaged Core 至少为 `4.26.0`。
+3. 从同一个 source checkout 构建并同步 `resources/webui`,写入 `assets/version`,校验 index 与本地 JavaScript/CSS 入口。
+4. 准备 `resources/backend`(包括运行时与启动脚本),生成包含 Desktop/Core/source identity 的 `runtime-manifest.json`。
+5. 把 WebUI version/index/入口摘要写入最终 manifest,并再次校验整套资源。
+6. `src-tauri/build.rs` 把最终 manifest 的 SHA-256 编入可执行文件;release 构建缺少 manifest 时失败。
+7. 执行 Tauri 打包。
+
+运行时不会把 direct 与 `_up_/resources` 下的 backend/WebUI 混用。打包启动计划只接受 manifest 摘要与当前可执行文件一致的完整资源根,并在导航前核对运行中 backend 的公开版本、served index 和入口资产。主窗口 URL 使用该 manifest 摘要作为 `astrbot_bundle` 缓存身份。
补充说明:主窗口当前显式设置了 `backgroundThrottling = "disabled"`,用于缓解 macOS 上窗口隐藏或转入后台后 `WKWebView` 被系统节流/挂起导致的前端假死问题。根据当前 Tauri 2 配置能力,该选项在 macOS 14+ 上生效;更早版本的 macOS 会回退到系统默认后台策略。
diff --git a/docs/environment-variables.md b/docs/environment-variables.md
index 69a85a0b..61027b47 100644
--- a/docs/environment-variables.md
+++ b/docs/environment-variables.md
@@ -18,7 +18,7 @@
| `ASTRBOT_BRIDGE_BACKEND_PING_TIMEOUT_MS` | 桥接层 ping 超时 | 默认回退到 `ASTRBOT_BACKEND_PING_TIMEOUT_MS` |
| `ASTRBOT_BACKEND_CMD` | 后端启动命令覆盖 | 未设置则按 launch plan 推导 |
| `ASTRBOT_BACKEND_CWD` | 后端工作目录覆盖 | 未设置则按 launch plan 推导 |
-| `ASTRBOT_WEBUI_DIR` | WebUI 目录覆盖 | 未设置则按资源目录推导 |
+| `ASTRBOT_WEBUI_DIR` | 自定义/开发启动时的 WebUI 目录覆盖 | 打包版忽略该变量,以保证 Core 与 WebUI 来自同一已校验资源包 |
| `ASTRBOT_ROOT` | AstrBot 根目录 | 未设置则按打包/临时目录回退 |
| `ASTRBOT_DASHBOARD_HOST` | 后端读取的 dashboard host 变量 | 若 `DASHBOARD_HOST` 与本变量都未设置,打包态桌面默认写入 `DASHBOARD_HOST=127.0.0.1` |
| `ASTRBOT_DASHBOARD_PORT` | 后端读取的 dashboard port 变量 | 若 `DASHBOARD_PORT` 与本变量都未设置,打包态桌面默认写入 `DASHBOARD_PORT=6185` |
diff --git a/docs/repository-structure.md b/docs/repository-structure.md
index 4ee7595b..dec8e1d7 100644
--- a/docs/repository-structure.md
+++ b/docs/repository-structure.md
@@ -80,7 +80,7 @@
- `backend/runtime.rs`
- backend 运行时参数(timeout/readiness/ping)解析与缓存。
- `backend/readiness.rs`
- - backend 就绪探测、等待轮询与超时日志收敛。
+ - backend 就绪探测、等待轮询、打包态 live Core/WebUI identity 校验与超时日志收敛。
- `backend/restart.rs`
- backend restart token 管理、graceful/fallback 策略与 bridge 状态组装。
- `backend/restart_strategy.rs`
@@ -115,7 +115,7 @@
- `restart_backend_flow.rs`
- backend 重启任务与并发判定流程封装。
- `launch_plan.rs`
- - custom/packaged/dev 启动计划构建与路径解析。
+ - custom/packaged/dev 启动计划构建;打包态完整资源候选选择、manifest 绑定和 WebUI 摘要校验。
- `startup_task.rs`
- 启动阶段后端就绪等待与主线程导航分发。
- `app_runtime.rs`
@@ -143,9 +143,18 @@
- `webui/backend/all` 任务实现。
- `desktop-bridge-checks.mjs`
- bridge 相关校验。
+- `resource-identity.mjs`
+ - packaged Core 最低版本门禁、WebUI marker/index/入口校验,以及最终 Core/WebUI bundle attestation。
- `*.test.mjs`
- Node 行为测试。
+`scripts/backend/` 中与资源身份直接相关的模块:
+
+- `runtime-manifest.mjs`
+ - 生成 backend runtime manifest,规范 runtime 相对路径、Desktop/Core 版本和 source ref/commit 字段。
+
+正式 `prepare:resources` 会按 WebUI -> backend -> 最终 attestation 的顺序运行;`src-tauri/build.rs` 再把最终 manifest 摘要编入可执行文件。`version` 单独模式以及 Rust 的 debug/dev/custom external backend 路径不使用 packaged identity 门禁。
+
## 4. 文档组织(`docs/`)
- `architecture.md`
diff --git a/package.json b/package.json
index e03e45fe..b88c552f 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
"test:prepare-resources": "node --test \"scripts/**/*.test.mjs\"",
"prepare:webui": "node scripts/prepare-resources.mjs webui",
"prepare:backend": "node scripts/prepare-resources.mjs backend",
- "prepare:resources": "pnpm run prepare:webui && pnpm run prepare:backend",
+ "prepare:resources": "node scripts/prepare-resources.mjs all",
"dev": "tauri dev",
"build": "tauri build"
},
diff --git a/scripts/backend/build-backend.mjs b/scripts/backend/build-backend.mjs
index 9a500b76..1aeb99dd 100644
--- a/scripts/backend/build-backend.mjs
+++ b/scripts/backend/build-backend.mjs
@@ -20,6 +20,7 @@ import {
} from './runtime-linux-compat-utils.mjs';
import { isWindowsArm64BundledRuntime } from './runtime-arch-utils.mjs';
import { generateRuntimeCoreLock } from './runtime-core-lock.mjs';
+import { createRuntimeManifest } from './runtime-manifest.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..', '..');
@@ -40,6 +41,9 @@ const runtimeSource =
process.env.ASTRBOT_DESKTOP_BACKEND_RUNTIME ||
process.env.ASTRBOT_DESKTOP_CPYTHON_HOME;
const requirePipProbe = process.env.ASTRBOT_DESKTOP_REQUIRE_PIP === '1';
+const desktopVersionOverride = process.env.ASTRBOT_DESKTOP_VERSION || '';
+const sourceRef = process.env.ASTRBOT_SOURCE_GIT_REF || '';
+const sourceCommit = process.env.ASTRBOT_SOURCE_GIT_COMMIT || '';
const requiredSourceEntries = ['astrbot', 'main.py', 'requirements.txt'];
const optionalSourceEntries = ['changelogs'];
@@ -449,13 +453,32 @@ const writeLauncherScript = () => {
fs.writeFileSync(launcherPath, content, 'utf8');
};
-const writeRuntimeManifest = (runtimePython) => {
- const manifest = {
- mode: 'cpython-runtime',
+const readCoreVersion = (resolvedSourceDir) => {
+ const explicitVersion = String(process.env.ASTRBOT_CORE_VERSION || '').trim();
+ if (explicitVersion) {
+ return explicitVersion;
+ }
+
+ const pyprojectPath = path.join(resolvedSourceDir, 'pyproject.toml');
+ const content = fs.readFileSync(pyprojectPath, 'utf8');
+ const match = /^version\s*=\s*["']([^"']+)["']/m.exec(content);
+ if (!match) {
+ throw new Error(`Cannot resolve AstrBot Core version from ${pyprojectPath}.`);
+ }
+ return match[1];
+};
+
+const writeRuntimeManifest = (runtimePython, resolvedSourceDir) => {
+ const coreVersion = readCoreVersion(resolvedSourceDir);
+ const manifest = createRuntimeManifest({
python: runtimePython.relative,
entrypoint: path.basename(launcherPath),
app: path.relative(outputDir, appDir),
- };
+ desktopVersion: desktopVersionOverride || coreVersion,
+ coreVersion,
+ sourceRef,
+ sourceCommit,
+ });
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
};
@@ -705,7 +728,7 @@ const main = () => {
pruneLinuxTkinterRuntime(runtimeDir);
patchLinuxRuntimeRpaths(runtimeDir);
writeLauncherScript();
- writeRuntimeManifest(runtimePython);
+ writeRuntimeManifest(runtimePython, resolvedSourceDir);
console.log(`Prepared CPython backend runtime in ${outputDir}`);
console.log(`Runtime source: ${runtimeSourceReal}`);
diff --git a/scripts/backend/runtime-manifest.mjs b/scripts/backend/runtime-manifest.mjs
new file mode 100644
index 00000000..56d4fc54
--- /dev/null
+++ b/scripts/backend/runtime-manifest.mjs
@@ -0,0 +1,50 @@
+import path from 'node:path';
+
+const requiredString = (value, field) => {
+ const normalized = typeof value === 'string' ? value.trim() : '';
+ if (!normalized) {
+ throw new Error(`Backend runtime manifest field ${field} must not be empty.`);
+ }
+ return normalized;
+};
+
+export const requiredRuntimeRelativePath = (value, field) => {
+ const normalized = requiredString(value, field);
+ const portablePath = normalized.replaceAll('\\', '/');
+ const segments = portablePath.split('/');
+ if (
+ normalized.includes('\0') ||
+ path.posix.isAbsolute(portablePath) ||
+ path.win32.parse(normalized).root ||
+ segments.some((segment) => !segment || segment === '.' || segment === '..')
+ ) {
+ throw new Error(
+ `Backend runtime manifest field ${field} must be a canonical relative path inside the backend directory.`,
+ );
+ }
+ return normalized;
+};
+
+const optionalString = (value) => {
+ const normalized = typeof value === 'string' ? value.trim() : '';
+ return normalized || null;
+};
+
+export const createRuntimeManifest = ({
+ python,
+ entrypoint,
+ app,
+ desktopVersion,
+ coreVersion,
+ sourceRef,
+ sourceCommit,
+}) => ({
+ mode: 'cpython-runtime',
+ python: requiredRuntimeRelativePath(python, 'python'),
+ entrypoint: requiredRuntimeRelativePath(entrypoint, 'entrypoint'),
+ app: requiredString(app, 'app'),
+ desktopVersion: requiredString(desktopVersion, 'desktopVersion').replace(/^v/i, ''),
+ coreVersion: requiredString(coreVersion, 'coreVersion').replace(/^v/i, ''),
+ sourceRef: optionalString(sourceRef),
+ sourceCommit: optionalString(sourceCommit),
+});
diff --git a/scripts/backend/runtime-manifest.test.mjs b/scripts/backend/runtime-manifest.test.mjs
new file mode 100644
index 00000000..7231f762
--- /dev/null
+++ b/scripts/backend/runtime-manifest.test.mjs
@@ -0,0 +1,75 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+
+import { createRuntimeManifest } from './runtime-manifest.mjs';
+
+test('createRuntimeManifest records Core source identity', () => {
+ const manifest = createRuntimeManifest({
+ python: 'python/bin/python3',
+ entrypoint: 'launch_backend.py',
+ app: 'app',
+ desktopVersion: 'v4.27.4',
+ coreVersion: 'v4.27.4',
+ sourceRef: 'v4.27.4',
+ sourceCommit: 'a'.repeat(40),
+ });
+
+ assert.deepEqual(manifest, {
+ mode: 'cpython-runtime',
+ python: 'python/bin/python3',
+ entrypoint: 'launch_backend.py',
+ app: 'app',
+ desktopVersion: '4.27.4',
+ coreVersion: '4.27.4',
+ sourceRef: 'v4.27.4',
+ sourceCommit: 'a'.repeat(40),
+ });
+});
+
+test('createRuntimeManifest keeps optional source identity explicit', () => {
+ const manifest = createRuntimeManifest({
+ python: 'python/bin/python3',
+ entrypoint: 'launch_backend.py',
+ app: 'app',
+ desktopVersion: '4.27.4-nightly.20260901.abcdef12',
+ coreVersion: '4.27.4',
+ });
+
+ assert.equal(manifest.sourceRef, null);
+ assert.equal(manifest.sourceCommit, null);
+});
+
+test('createRuntimeManifest requires a Core version', () => {
+ assert.throws(
+ () =>
+ createRuntimeManifest({
+ python: 'python/bin/python3',
+ entrypoint: 'launch_backend.py',
+ app: 'app',
+ desktopVersion: '4.27.4',
+ coreVersion: '',
+ }),
+ /coreVersion must not be empty/,
+ );
+});
+
+test('createRuntimeManifest rejects backend paths that escape the bundle', () => {
+ for (const [field, value] of [
+ ['python', '../python.exe'],
+ ['python', 'C:\\outside\\python.exe'],
+ ['entrypoint', '/tmp/launch_backend.py'],
+ ['entrypoint', 'scripts/../launch_backend.py'],
+ ]) {
+ assert.throws(
+ () =>
+ createRuntimeManifest({
+ python: field === 'python' ? value : 'python/bin/python3',
+ entrypoint: field === 'entrypoint' ? value : 'launch_backend.py',
+ app: 'app',
+ desktopVersion: '4.27.4',
+ coreVersion: '4.27.4',
+ }),
+ new RegExp(`${field} must be a canonical relative path`),
+ );
+ }
+});
diff --git a/scripts/ci/backend-smoke-test.mjs b/scripts/ci/backend-smoke-test.mjs
index 97417d22..8ba24635 100644
--- a/scripts/ci/backend-smoke-test.mjs
+++ b/scripts/ci/backend-smoke-test.mjs
@@ -5,12 +5,21 @@ import process from 'node:process';
import net from 'node:net';
import http from 'node:http';
import https from 'node:https';
+import { createHash } from 'node:crypto';
import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
import { pathToFileURL } from 'node:url';
const defaultBackendDir = path.resolve('resources', 'backend');
const defaultWebuiDir = path.resolve('resources', 'webui');
+const versionsPath = '/api/v1/stats/versions';
+const webuiIndexPath = '/index.html';
+const maxVersionsResponseBytes = 64 * 1024;
+const maxIndexResponseBytes = 4 * 1024 * 1024;
+// Keep this in sync with MAX_BACKEND_HTTP_BODY_BYTES in src-tauri/src/backend/http.rs
+// so a resource accepted by release smoke checks cannot fail runtime identity verification.
+const maxEntryResponseBytes = 32 * 1024 * 1024;
+const sha256Pattern = /^[0-9a-f]{64}$/;
const usageMessage = () => `
Usage: node scripts/ci/backend-smoke-test.mjs [options]
@@ -96,6 +105,320 @@ const assertPathExists = (fsLike, targetPath, description) => {
}
};
+const sha256 = (content) => createHash('sha256').update(content).digest('hex');
+
+const normalizeVersion = (value, field) => {
+ const trimmed = typeof value === 'string' ? value.trim() : '';
+ const normalized = trimmed.replace(/^v/i, '');
+ if (!normalized) {
+ throw new Error(`Backend runtime manifest ${field} must not be empty.`);
+ }
+ return normalized;
+};
+
+const normalizeSha256 = (value, field) => {
+ const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
+ if (!sha256Pattern.test(normalized)) {
+ throw new Error(`Backend runtime manifest ${field} must be a SHA-256 digest.`);
+ }
+ return normalized;
+};
+
+const normalizeEntryPath = (value) => {
+ const raw = typeof value === 'string' ? value.trim() : '';
+ const portable = raw.replaceAll('\\', '/');
+ const segments = portable.split('/');
+ if (
+ !portable ||
+ raw.includes('\0') ||
+ path.posix.isAbsolute(portable) ||
+ path.win32.parse(raw).root ||
+ segments.some((segment) => !segment || segment === '.' || segment === '..')
+ ) {
+ throw new Error(
+ 'Backend runtime manifest webui.entryAssets[].path must be a canonical relative path.',
+ );
+ }
+ const extension = path.posix.extname(portable).toLowerCase();
+ if (extension !== '.js' && extension !== '.css') {
+ throw new Error(
+ `Backend runtime manifest contains unsupported WebUI entry asset: ${raw}`,
+ );
+ }
+ return portable;
+};
+
+const parseRuntimeIdentityManifest = (manifest, manifestPath) => {
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
+ throw new Error(`Invalid backend runtime manifest: ${manifestPath}`);
+ }
+ const coreVersion = normalizeVersion(manifest.coreVersion, 'coreVersion');
+ const attestation = manifest.webui;
+ if (!attestation || typeof attestation !== 'object' || Array.isArray(attestation)) {
+ throw new Error(
+ `Backend runtime manifest is missing the WebUI bundle attestation: ${manifestPath}`,
+ );
+ }
+ const webuiVersion = normalizeVersion(attestation.version, 'webui.version');
+ if (webuiVersion !== coreVersion) {
+ throw new Error(
+ `Backend runtime manifest Core/WebUI version mismatch: Core is ${coreVersion}, WebUI is ${webuiVersion}.`,
+ );
+ }
+ const indexSha256 = normalizeSha256(attestation.indexSha256, 'webui.indexSha256');
+ if (!Array.isArray(attestation.entryAssets)) {
+ throw new Error('Backend runtime manifest webui.entryAssets must be an array.');
+ }
+
+ const seenPaths = new Set();
+ let hasJavascriptEntry = false;
+ const entryAssets = attestation.entryAssets.map((entry) => {
+ const entryPath = normalizeEntryPath(entry?.path);
+ if (seenPaths.has(entryPath)) {
+ throw new Error(
+ `Backend runtime manifest contains duplicate WebUI entry asset: ${entryPath}`,
+ );
+ }
+ seenPaths.add(entryPath);
+ hasJavascriptEntry ||= entryPath.toLowerCase().endsWith('.js');
+ return {
+ path: entryPath,
+ sha256: normalizeSha256(entry?.sha256, 'webui.entryAssets[].sha256'),
+ };
+ });
+ if (!hasJavascriptEntry) {
+ throw new Error('Backend runtime manifest WebUI attestation has no JavaScript entry asset.');
+ }
+
+ return { coreVersion, indexSha256, entryAssets };
+};
+
+const fetchIdentityBytesWithTimeout = async (url, timeoutMs, maxResponseBytes) =>
+ new Promise((resolve, reject) => {
+ const urlObject = new URL(url);
+ const client = urlObject.protocol === 'https:' ? https : http;
+ let settled = false;
+ let timer = null;
+ const finish = (callback, value) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ clearTimeout(timer);
+ callback(value);
+ };
+ const request = client.request(
+ urlObject,
+ {
+ method: 'GET',
+ headers: {
+ Accept: '*/*',
+ 'Accept-Encoding': 'identity',
+ 'Cache-Control': 'no-cache',
+ Pragma: 'no-cache',
+ },
+ },
+ (response) => {
+ const contentEncoding = String(response.headers['content-encoding'] || '')
+ .trim()
+ .toLowerCase();
+ if (contentEncoding && contentEncoding !== 'identity') {
+ finish(reject, new Error(`Unexpected Content-Encoding: ${contentEncoding}`));
+ response.destroy();
+ return;
+ }
+ const declaredLength = Number(response.headers['content-length']);
+ if (Number.isFinite(declaredLength) && declaredLength > maxResponseBytes) {
+ finish(
+ reject,
+ new Error(
+ `Response exceeds ${maxResponseBytes} bytes (Content-Length: ${declaredLength}).`,
+ ),
+ );
+ response.destroy();
+ return;
+ }
+
+ const chunks = [];
+ let receivedBytes = 0;
+ response.on('data', (chunk) => {
+ receivedBytes += chunk.length;
+ if (receivedBytes > maxResponseBytes) {
+ finish(
+ reject,
+ new Error(`Response exceeds ${maxResponseBytes} bytes while streaming.`),
+ );
+ response.destroy();
+ return;
+ }
+ chunks.push(chunk);
+ });
+ response.on('end', () => {
+ finish(resolve, {
+ status: response.statusCode || 0,
+ ok: Boolean(
+ response.statusCode &&
+ response.statusCode >= 200 &&
+ response.statusCode < 300,
+ ),
+ body: Buffer.concat(chunks),
+ });
+ });
+ response.on('aborted', () => {
+ finish(reject, new Error('Response ended before the complete body was received.'));
+ });
+ response.on('close', () => {
+ if (!response.complete) {
+ finish(reject, new Error('Response closed before the complete body was received.'));
+ }
+ });
+ response.on('error', (error) => finish(reject, error));
+ },
+ );
+ timer = setTimeout(() => {
+ request.destroy(new Error(`Request timed out after ${timeoutMs}ms.`));
+ }, timeoutMs);
+ request.on('error', (error) => finish(reject, error));
+ request.end();
+ });
+
+const requestIdentityResource = async ({
+ backendUrl,
+ requestPath,
+ description,
+ timeoutMs,
+ maxResponseBytes,
+ notFoundMessage = '',
+ runtime,
+}) => {
+ const url = new URL(requestPath, backendUrl).href;
+ let response;
+ try {
+ response = await runtime.fetchIdentityBytesWithTimeout(
+ url,
+ timeoutMs,
+ maxResponseBytes,
+ );
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : String(error);
+ throw new Error(`Cannot fetch ${description} at ${requestPath}: ${reason}`);
+ }
+ if (!response.ok) {
+ if (response.status === 404 && notFoundMessage) {
+ throw new Error(notFoundMessage);
+ }
+ throw new Error(
+ `Cannot fetch ${description} at ${requestPath}: HTTP ${response.status}.`,
+ );
+ }
+ return response.body;
+};
+
+const normalizeRunningVersion = (value, field) => {
+ const trimmed = typeof value === 'string' ? value.trim() : '';
+ const normalized = trimmed.replace(/^v/i, '');
+ if (!normalized) {
+ throw new Error(`Running backend version field ${field} is missing or empty.`);
+ }
+ return normalized;
+};
+
+const verifyRunningResourceIdentity = async ({
+ backendUrl,
+ expectedIdentity,
+ timeoutMs,
+ runtime,
+}) => {
+ const deadline = Date.now() + timeoutMs;
+ const remainingTimeoutMs = () => {
+ const remaining = deadline - Date.now();
+ if (remaining <= 0) {
+ throw new Error(`Running resource identity check timed out after ${timeoutMs}ms.`);
+ }
+ return remaining;
+ };
+ const versionsBody = await requestIdentityResource({
+ backendUrl,
+ requestPath: versionsPath,
+ description: 'running AstrBot resource versions',
+ timeoutMs: remainingTimeoutMs(),
+ maxResponseBytes: maxVersionsResponseBytes,
+ runtime,
+ });
+ let versionsPayload;
+ try {
+ versionsPayload = JSON.parse(versionsBody.toString('utf8'));
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : String(error);
+ throw new Error(`Running AstrBot resource versions response is invalid JSON: ${reason}`);
+ }
+ if (versionsPayload?.status !== 'ok' || !versionsPayload.data) {
+ throw new Error(
+ 'Running AstrBot resource versions endpoint did not return status=ok with data.',
+ );
+ }
+ const runningVersions = {
+ core: normalizeRunningVersion(
+ versionsPayload.data.astrbot_version,
+ 'astrbot_version',
+ ),
+ code: normalizeRunningVersion(
+ versionsPayload.data.astrbot_code_version,
+ 'astrbot_code_version',
+ ),
+ webui: normalizeRunningVersion(
+ versionsPayload.data.webui_version,
+ 'webui_version',
+ ),
+ };
+ if (
+ runningVersions.core !== expectedIdentity.coreVersion ||
+ runningVersions.code !== expectedIdentity.coreVersion ||
+ runningVersions.webui !== expectedIdentity.coreVersion
+ ) {
+ throw new Error(
+ `Running Core/WebUI version mismatch: expected ${expectedIdentity.coreVersion}, got Core ${runningVersions.core}, code ${runningVersions.code}, WebUI ${runningVersions.webui}.`,
+ );
+ }
+
+ const indexBody = await requestIdentityResource({
+ backendUrl,
+ requestPath: webuiIndexPath,
+ description: 'running WebUI index',
+ timeoutMs: remainingTimeoutMs(),
+ maxResponseBytes: maxIndexResponseBytes,
+ runtime,
+ });
+ const runningIndexSha256 = sha256(indexBody);
+ if (runningIndexSha256 !== expectedIdentity.indexSha256) {
+ throw new Error(
+ `Running WebUI index SHA-256 mismatch: expected ${expectedIdentity.indexSha256}, got ${runningIndexSha256}.`,
+ );
+ }
+
+ for (const entry of expectedIdentity.entryAssets) {
+ const requestPath = `/${entry.path
+ .split('/')
+ .map((segment) => encodeURIComponent(segment))
+ .join('/')}`;
+ const entryBody = await requestIdentityResource({
+ backendUrl,
+ requestPath,
+ description: `running WebUI entry ${entry.path}`,
+ timeoutMs: remainingTimeoutMs(),
+ maxResponseBytes: maxEntryResponseBytes,
+ notFoundMessage: `Running WebUI entry is missing: ${entry.path}.`,
+ runtime,
+ });
+ const runningEntrySha256 = sha256(entryBody);
+ if (runningEntrySha256 !== entry.sha256) {
+ throw new Error(
+ `Running WebUI entry SHA-256 mismatch for ${entry.path}: expected ${entry.sha256}, got ${runningEntrySha256}.`,
+ );
+ }
+ }
+};
+
const reserveLoopbackPort = async () =>
new Promise((resolve, reject) => {
// NOTE: this reserve-then-bind pattern has a small race window by design.
@@ -195,6 +518,7 @@ const createMainRuntime = (overrides = {}) => ({
spawn,
reserveLoopbackPort,
fetchWithTimeout,
+ fetchIdentityBytesWithTimeout,
terminateChild,
sleep,
now: () => Date.now(),
@@ -218,10 +542,17 @@ const main = async (options, runtime = createMainRuntime()) => {
assertPathExists(runtime.fs, launcherPath, 'Backend launcher');
assertPathExists(runtime.fs, appMainPath, 'Backend app main.py');
- const manifest = JSON.parse(runtime.fs.readFileSync(manifestPath, 'utf8'));
+ let manifest;
+ try {
+ manifest = JSON.parse(runtime.fs.readFileSync(manifestPath, 'utf8'));
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : String(error);
+ throw new Error(`Invalid backend runtime manifest: ${manifestPath} (${reason})`);
+ }
if (!manifest.python || typeof manifest.python !== 'string') {
throw new Error(`Invalid runtime manifest python entry: ${manifestPath}`);
}
+ const expectedIdentity = parseRuntimeIdentityManifest(manifest, manifestPath);
const pythonPath = path.join(backendDir, manifest.python);
assertPathExists(runtime.fs, pythonPath, 'Runtime python executable');
@@ -317,6 +648,16 @@ const main = async (options, runtime = createMainRuntime()) => {
if (child.exitCode !== null) {
throw new Error(`Backend crashed after readiness (exit=${child.exitCode}).`);
}
+ const identityTimeoutMs = Math.min(
+ 10_000,
+ Math.max(1_200, options.startupTimeoutMs),
+ );
+ await verifyRunningResourceIdentity({
+ backendUrl,
+ expectedIdentity,
+ timeoutMs: identityTimeoutMs,
+ runtime,
+ });
console.log(`${tracePrefix} backend startup smoke test passed.`);
} catch (error) {
const details = childLogs.length
@@ -385,4 +726,13 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
process.exit(exitCode);
}
-export { createMainRuntime, main, parseCliOptions, runCli, usageMessage };
+export {
+ createMainRuntime,
+ fetchIdentityBytesWithTimeout,
+ main,
+ parseCliOptions,
+ parseRuntimeIdentityManifest,
+ runCli,
+ usageMessage,
+ verifyRunningResourceIdentity,
+};
diff --git a/scripts/ci/backend-smoke-test.test.mjs b/scripts/ci/backend-smoke-test.test.mjs
index fcb77d62..f9abcd75 100644
--- a/scripts/ci/backend-smoke-test.test.mjs
+++ b/scripts/ci/backend-smoke-test.test.mjs
@@ -2,11 +2,52 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import assert from 'node:assert/strict';
+import { createHash } from 'node:crypto';
import { EventEmitter } from 'node:events';
+import { createServer } from 'node:http';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { test } from 'node:test';
-import { main, parseCliOptions, runCli, usageMessage } from './backend-smoke-test.mjs';
+import {
+ fetchIdentityBytesWithTimeout,
+ main,
+ parseCliOptions,
+ parseRuntimeIdentityManifest,
+ runCli,
+ usageMessage,
+ verifyRunningResourceIdentity,
+} from './backend-smoke-test.mjs';
+
+const sha256 = (content) => createHash('sha256').update(content).digest('hex');
+
+const createIdentityScenario = () => {
+ const indexBody = Buffer.from(
+ '',
+ );
+ const cssBody = Buffer.from('body { color: #123456; }\n');
+ const javascriptBody = Buffer.from('console.log("current");\n');
+ const manifest = {
+ python: 'python/python',
+ coreVersion: '4.27.5',
+ webui: {
+ version: '4.27.5',
+ indexSha256: sha256(indexBody),
+ entryAssets: [
+ { path: 'assets/index.css', sha256: sha256(cssBody) },
+ { path: 'assets/index.js', sha256: sha256(javascriptBody) },
+ ],
+ },
+ };
+ return {
+ manifest,
+ expectedIdentity: parseRuntimeIdentityManifest(manifest, 'runtime-manifest.json'),
+ indexBody,
+ entryBodies: new Map([
+ ['assets/index.css', cssBody],
+ ['assets/index.js', javascriptBody],
+ ]),
+ };
+};
const createFixtureLayout = async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'astrbot-backend-smoke-test-'));
@@ -17,20 +58,64 @@ const createFixtureLayout = async () => {
const launcherPath = path.join(backendDir, 'launch_backend.py');
const mainPath = path.join(appDir, 'main.py');
const pythonPath = path.join(pythonDir, 'python');
+ const scenario = createIdentityScenario();
await mkdir(appDir, { recursive: true });
await mkdir(pythonDir, { recursive: true });
- await mkdir(webuiDir, { recursive: true });
+ await mkdir(path.join(webuiDir, 'assets'), { recursive: true });
await writeFile(launcherPath, '# launcher', 'utf8');
await writeFile(mainPath, '# main', 'utf8');
await writeFile(pythonPath, '#!/bin/sh\n', 'utf8');
+ await writeFile(path.join(webuiDir, 'index.html'), scenario.indexBody);
+ for (const [entryPath, body] of scenario.entryBodies) {
+ await writeFile(path.join(webuiDir, entryPath), body);
+ }
await writeFile(
path.join(backendDir, 'runtime-manifest.json'),
- JSON.stringify({ python: 'python/python' }),
+ JSON.stringify(scenario.manifest),
'utf8',
);
- return { root, backendDir, webuiDir };
+ return { root, backendDir, webuiDir, ...scenario };
+};
+
+const createIdentityResponse = (body, status = 200) => ({
+ status,
+ ok: status >= 200 && status < 300,
+ body: Buffer.isBuffer(body) ? body : Buffer.from(body),
+});
+
+const createIdentityFetch = ({
+ coreVersion = '4.27.5',
+ runningVersions = {},
+ indexBody,
+ entryBodies,
+ entryStatuses = new Map(),
+}) => async (url) => {
+ const requestPath = new URL(url).pathname;
+ if (requestPath === '/api/v1/stats/versions') {
+ return createIdentityResponse(
+ JSON.stringify({
+ status: 'ok',
+ data: {
+ astrbot_version: runningVersions.core ?? coreVersion,
+ astrbot_code_version: runningVersions.code ?? coreVersion,
+ webui_version: runningVersions.webui ?? `v${coreVersion}`,
+ },
+ }),
+ );
+ }
+ if (requestPath === '/index.html') {
+ return createIdentityResponse(indexBody);
+ }
+ const entryPath = decodeURIComponent(requestPath.replace(/^\//, ''));
+ if (!entryBodies.has(entryPath)) {
+ return createIdentityResponse('missing', 404);
+ }
+ return createIdentityResponse(
+ entryBodies.get(entryPath),
+ entryStatuses.get(entryPath) ?? 200,
+ );
};
const createFakeChild = () => {
@@ -483,6 +568,175 @@ test('main fails when manifest.python points to a non-existent executable', asyn
}
});
+test('runtime manifest rejects mismatched Core and WebUI attestation versions', () => {
+ const scenario = createIdentityScenario();
+ assert.throws(
+ () =>
+ parseRuntimeIdentityManifest(
+ {
+ ...scenario.manifest,
+ webui: { ...scenario.manifest.webui, version: '4.27.0' },
+ },
+ 'runtime-manifest.json',
+ ),
+ /Core\/WebUI version mismatch: Core is 4\.27\.5, WebUI is 4\.27\.0/,
+ );
+});
+
+test('running resource identity accepts matching versions, index, and every entry asset', async () => {
+ const scenario = createIdentityScenario();
+ const requestedPaths = [];
+ const fetchIdentity = createIdentityFetch(scenario);
+
+ await verifyRunningResourceIdentity({
+ backendUrl: 'http://127.0.0.1:6190/',
+ expectedIdentity: scenario.expectedIdentity,
+ timeoutMs: 2_000,
+ runtime: {
+ fetchIdentityBytesWithTimeout: async (...args) => {
+ requestedPaths.push(new URL(args[0]).pathname);
+ return fetchIdentity(...args);
+ },
+ },
+ });
+
+ assert.deepEqual(requestedPaths, [
+ '/api/v1/stats/versions',
+ '/index.html',
+ '/assets/index.css',
+ '/assets/index.js',
+ ]);
+});
+
+test('identity requests disable caches and compression and enforce response size limits', async () => {
+ const server = createServer((request, response) => {
+ assert.equal(request.headers['accept-encoding'], 'identity');
+ assert.equal(request.headers['cache-control'], 'no-cache');
+ assert.equal(request.headers.pragma, 'no-cache');
+ const body = request.url === '/oversized' ? 'four' : 'ok';
+ response.writeHead(200, { 'Content-Length': Buffer.byteLength(body) });
+ response.end(body);
+ });
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', resolve);
+ });
+ const address = server.address();
+ assert.ok(address && typeof address === 'object');
+
+ try {
+ const response = await fetchIdentityBytesWithTimeout(
+ `http://127.0.0.1:${address.port}/ok`,
+ 2_000,
+ 2,
+ );
+ assert.equal(response.status, 200);
+ assert.equal(response.body.toString('utf8'), 'ok');
+ await assert.rejects(
+ () =>
+ fetchIdentityBytesWithTimeout(
+ `http://127.0.0.1:${address.port}/oversized`,
+ 2_000,
+ 3,
+ ),
+ /Response exceeds 3 bytes/,
+ );
+ } finally {
+ await new Promise((resolve) => server.close(resolve));
+ }
+});
+
+test('running resource identity rejects a mismatched Core, code, or WebUI version', async () => {
+ const scenario = createIdentityScenario();
+ await assert.rejects(
+ () =>
+ verifyRunningResourceIdentity({
+ backendUrl: 'http://127.0.0.1:6190/',
+ expectedIdentity: scenario.expectedIdentity,
+ timeoutMs: 2_000,
+ runtime: {
+ fetchIdentityBytesWithTimeout: createIdentityFetch({
+ ...scenario,
+ runningVersions: { code: '4.27.0' },
+ }),
+ },
+ }),
+ (error) =>
+ error instanceof Error &&
+ error.message.includes('Running Core/WebUI version mismatch') &&
+ error.message.includes('Core 4.27.5, code 4.27.0, WebUI 4.27.5'),
+ );
+});
+
+test('running resource identity rejects mismatched WebUI index content', async () => {
+ const scenario = createIdentityScenario();
+ await assert.rejects(
+ () =>
+ verifyRunningResourceIdentity({
+ backendUrl: 'http://127.0.0.1:6190/',
+ expectedIdentity: scenario.expectedIdentity,
+ timeoutMs: 2_000,
+ runtime: {
+ fetchIdentityBytesWithTimeout: createIdentityFetch({
+ ...scenario,
+ indexBody: Buffer.from('
stale'),
+ }),
+ },
+ }),
+ (error) =>
+ error instanceof Error &&
+ error.message.includes('Running WebUI index SHA-256 mismatch') &&
+ error.message.includes(scenario.expectedIdentity.indexSha256),
+ );
+});
+
+test('running resource identity rejects mismatched WebUI entry content', async () => {
+ const scenario = createIdentityScenario();
+ const staleEntries = new Map(scenario.entryBodies);
+ staleEntries.set('assets/index.js', Buffer.from('console.log("stale");\n'));
+ await assert.rejects(
+ () =>
+ verifyRunningResourceIdentity({
+ backendUrl: 'http://127.0.0.1:6190/',
+ expectedIdentity: scenario.expectedIdentity,
+ timeoutMs: 2_000,
+ runtime: {
+ fetchIdentityBytesWithTimeout: createIdentityFetch({
+ ...scenario,
+ entryBodies: staleEntries,
+ }),
+ },
+ }),
+ (error) =>
+ error instanceof Error &&
+ error.message.includes('Running WebUI entry SHA-256 mismatch for assets/index.js') &&
+ error.message.includes(scenario.expectedIdentity.entryAssets[1].sha256),
+ );
+});
+
+test('running resource identity rejects a missing attested WebUI entry', async () => {
+ const scenario = createIdentityScenario();
+ const incompleteEntries = new Map(scenario.entryBodies);
+ incompleteEntries.delete('assets/index.js');
+ await assert.rejects(
+ () =>
+ verifyRunningResourceIdentity({
+ backendUrl: 'http://127.0.0.1:6190/',
+ expectedIdentity: scenario.expectedIdentity,
+ timeoutMs: 2_000,
+ runtime: {
+ fetchIdentityBytesWithTimeout: createIdentityFetch({
+ ...scenario,
+ entryBodies: incompleteEntries,
+ }),
+ },
+ }),
+ (error) =>
+ error instanceof Error &&
+ error.message.includes('Running WebUI entry is missing: assets/index.js'),
+ );
+});
+
test('main succeeds on readiness and always runs terminate/cleanup', async () => {
const fixture = await createFixtureLayout();
try {
@@ -503,6 +757,10 @@ test('main succeeds on readiness and always runs terminate/cleanup', async () =>
spawn: () => child,
reserveLoopbackPort: async () => 6190,
fetchWithTimeout: async () => ({ ok: true, status: 200 }),
+ fetchIdentityBytesWithTimeout: createIdentityFetch({
+ indexBody: fixture.indexBody,
+ entryBodies: fixture.entryBodies,
+ }),
terminateChild: async (actualChild) => {
assert.equal(actualChild, child);
terminated += 1;
diff --git a/scripts/prepare-resources.mjs b/scripts/prepare-resources.mjs
index f50fa2c6..030d9962 100644
--- a/scripts/prepare-resources.mjs
+++ b/scripts/prepare-resources.mjs
@@ -8,12 +8,14 @@ import {
} from './prepare-resources/version-sync.mjs';
import {
ensureSourceRepo,
+ resolveSourceRepoCommit,
} from './prepare-resources/source-repo.mjs';
import {
ensureStartupShellAssets,
} from './prepare-resources/mode-tasks.mjs';
import { runModeTasks } from './prepare-resources/mode-dispatch.mjs';
import { createPrepareResourcesContext } from './prepare-resources/context.mjs';
+import { requiresDesktopCoreMatch } from './prepare-resources/resource-identity.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(__dirname, '..');
@@ -38,7 +40,11 @@ const prepareAstrbotVersionSync = async ({ context }) => {
console.log(
'[prepare-resources] Skip source repo sync in version-only mode because ASTRBOT_DESKTOP_VERSION is set.',
);
- return desktopVersionOverride;
+ return {
+ desktopVersion: desktopVersionOverride,
+ coreVersion: '',
+ sourceRepoCommit: '',
+ };
}
ensureSourceRepo({
@@ -49,23 +55,32 @@ const prepareAstrbotVersionSync = async ({ context }) => {
sourceDirOverrideRaw: sourceDirOverrideInput,
});
- const astrbotVersion =
- desktopVersionOverride || (await readAstrbotVersionFromPyproject({ sourceDir }));
+ const coreVersion = await readAstrbotVersionFromPyproject({ sourceDir });
+ const desktopVersion = desktopVersionOverride || coreVersion;
await validateAstrbotRuntimeVersion({
sourceDir,
- expectedVersion: desktopVersionOverride ? undefined : astrbotVersion,
+ expectedVersion: coreVersion,
});
+ if (requiresDesktopCoreMatch(desktopVersion) && desktopVersion !== coreVersion) {
+ throw new Error(
+ `Stable bundle version mismatch: Desktop is ${desktopVersion}, but Core is ${coreVersion}.`,
+ );
+ }
+
if (desktopVersionOverride) {
- const sourceVersion = await readAstrbotVersionFromPyproject({ sourceDir });
- if (sourceVersion !== desktopVersionOverride) {
+ if (coreVersion !== desktopVersionOverride) {
console.warn(
- `[prepare-resources] Version override drift detected: ASTRBOT_DESKTOP_VERSION=${desktopVersionInput} (normalized=${desktopVersionOverride}), source pyproject version=${sourceVersion} (${sourceDir})`,
+ `[prepare-resources] Version override drift detected: ASTRBOT_DESKTOP_VERSION=${desktopVersionInput} (normalized=${desktopVersionOverride}), source pyproject version=${coreVersion} (${sourceDir})`,
);
}
}
- return astrbotVersion;
+ return {
+ desktopVersion,
+ coreVersion,
+ sourceRepoCommit: resolveSourceRepoCommit(sourceDir),
+ };
};
const main = async () => {
@@ -87,18 +102,24 @@ const main = async () => {
);
}
- const astrbotVersion = await prepareAstrbotVersionSync({ context });
+ const { desktopVersion, coreVersion, sourceRepoCommit } =
+ await prepareAstrbotVersionSync({ context });
- await syncDesktopVersionFiles({ projectRoot, version: astrbotVersion });
+ await syncDesktopVersionFiles({ projectRoot, version: desktopVersion });
if (desktopVersionOverride) {
console.log(
- `[prepare-resources] Synced desktop version to override ${astrbotVersion} (ASTRBOT_DESKTOP_VERSION)`,
+ `[prepare-resources] Synced desktop version to override ${desktopVersion} (ASTRBOT_DESKTOP_VERSION)`,
);
} else {
- console.log(`[prepare-resources] Synced desktop version to AstrBot ${astrbotVersion}`);
+ console.log(`[prepare-resources] Synced desktop version to AstrBot ${desktopVersion}`);
}
- await runModeTasks(mode, context);
+ await runModeTasks(mode, {
+ ...context,
+ desktopVersion,
+ coreVersion,
+ sourceRepoCommit,
+ });
};
main().catch((error) => {
diff --git a/scripts/prepare-resources/mode-dispatch.mjs b/scripts/prepare-resources/mode-dispatch.mjs
index 8de4b2c7..d56da043 100644
--- a/scripts/prepare-resources/mode-dispatch.mjs
+++ b/scripts/prepare-resources/mode-dispatch.mjs
@@ -1,10 +1,16 @@
-import { prepareBackend, prepareWebui } from './mode-tasks.mjs';
+import {
+ prepareBackend,
+ prepareWebui,
+ validatePreparedResources,
+} from './mode-tasks.mjs';
+import { validatePackagedCoreVersion } from './resource-identity.mjs';
const VALID_MODES = new Set(['version', 'webui', 'backend', 'all']);
const defaultTaskRunner = {
prepareWebui,
prepareBackend,
+ validatePreparedResources,
};
export const runModeTasks = async (
@@ -15,6 +21,9 @@ export const runModeTasks = async (
const {
sourceDir,
projectRoot,
+ desktopVersion,
+ coreVersion,
+ sourceRepoCommit,
sourceRepoRef,
isSourceRepoRefVersionTag,
isDesktopBridgeExpectationStrict,
@@ -30,10 +39,13 @@ export const runModeTasks = async (
return;
}
+ validatePackagedCoreVersion(coreVersion);
+
if (mode === 'webui' || mode === 'all') {
await taskRunner.prepareWebui({
sourceDir,
projectRoot,
+ coreVersion,
sourceRepoRef,
isSourceRepoRefVersionTag,
isDesktopBridgeExpectationStrict,
@@ -44,8 +56,22 @@ export const runModeTasks = async (
await taskRunner.prepareBackend({
sourceDir,
projectRoot,
+ desktopVersion,
+ coreVersion,
+ sourceRepoRef,
+ sourceRepoCommit,
pythonBuildStandaloneRelease,
pythonBuildStandaloneVersion,
});
}
+
+ if (mode === 'all') {
+ await taskRunner.validatePreparedResources({
+ projectRoot,
+ desktopVersion,
+ coreVersion,
+ sourceRepoRef,
+ sourceRepoCommit,
+ });
+ }
};
diff --git a/scripts/prepare-resources/mode-dispatch.test.mjs b/scripts/prepare-resources/mode-dispatch.test.mjs
index 931b8cf4..492d4953 100644
--- a/scripts/prepare-resources/mode-dispatch.test.mjs
+++ b/scripts/prepare-resources/mode-dispatch.test.mjs
@@ -1,12 +1,16 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
import { runModeTasks } from './mode-dispatch.mjs';
const createContext = (calls) => ({
sourceDir: '/tmp/source',
projectRoot: '/tmp/project',
- sourceRepoRef: 'v4.19.2',
+ desktopVersion: '4.27.5',
+ coreVersion: '4.27.5',
+ sourceRepoCommit: 'a'.repeat(40),
+ sourceRepoRef: 'v4.27.5',
isSourceRepoRefVersionTag: true,
isDesktopBridgeExpectationStrict: false,
pythonBuildStandaloneRelease: '20260211',
@@ -16,16 +20,29 @@ const createContext = (calls) => ({
const createTaskRunner = (calls) => ({
prepareWebui: async () => calls.push('webui'),
prepareBackend: async () => calls.push('backend'),
+ validatePreparedResources: async () => calls.push('validate'),
});
test('runModeTasks skips handlers in version mode', async () => {
const calls = [];
+ const context = { ...createContext(calls), coreVersion: '4.17.5' };
- await runModeTasks('version', createContext(calls), createTaskRunner(calls));
+ await runModeTasks('version', context, createTaskRunner(calls));
assert.deepEqual(calls, []);
});
+test('runModeTasks rejects an unsupported Core before preparing packaged resources', async () => {
+ const calls = [];
+ const context = { ...createContext(calls), coreVersion: 'v4.25.9' };
+
+ await assert.rejects(
+ runModeTasks('all', context, createTaskRunner(calls)),
+ /packaged resource identity requires Core 4\.26\.0 or newer/,
+ );
+ assert.deepEqual(calls, []);
+});
+
test('runModeTasks runs webui handler in webui mode', async () => {
const calls = [];
@@ -47,7 +64,7 @@ test('runModeTasks runs webui then backend handlers in all mode', async () => {
await runModeTasks('all', createContext(calls), createTaskRunner(calls));
- assert.deepEqual(calls, ['webui', 'backend']);
+ assert.deepEqual(calls, ['webui', 'backend', 'validate']);
});
test('runModeTasks throws for unsupported mode', async () => {
@@ -57,3 +74,22 @@ test('runModeTasks throws for unsupported mode', async () => {
/Unsupported mode: desktop\. Expected version\/webui\/backend\/all\./,
);
});
+
+test('prepare:resources uses the single all-mode validation path', async () => {
+ const packageJson = JSON.parse(await readFile('package.json', 'utf8'));
+
+ assert.equal(packageJson.scripts['prepare:resources'], 'node scripts/prepare-resources.mjs all');
+});
+
+test('prepare:resources always validates the runtime version against Core', async () => {
+ const source = await readFile('scripts/prepare-resources.mjs', 'utf8');
+
+ assert.match(
+ source,
+ /validateAstrbotRuntimeVersion\(\{\s*sourceDir,\s*expectedVersion: coreVersion,\s*\}\)/,
+ );
+ assert.doesNotMatch(
+ source,
+ /expectedVersion:\s*desktopVersionOverride\s*&&\s*!isSourceRepoRefVersionTag/,
+ );
+});
diff --git a/scripts/prepare-resources/mode-tasks.mjs b/scripts/prepare-resources/mode-tasks.mjs
index db9a3331..41cf6c19 100644
--- a/scripts/prepare-resources/mode-tasks.mjs
+++ b/scripts/prepare-resources/mode-tasks.mjs
@@ -8,6 +8,12 @@ import {
verifyDesktopBridgeArtifacts,
} from './desktop-bridge-checks.mjs';
import { ensureBundledRuntime } from './backend-runtime.mjs';
+import {
+ attestPreparedResourceBundle,
+ validateBackendRuntimeIdentity,
+ validateWebuiResources,
+ writeWebuiVersionMarker,
+} from './resource-identity.mjs';
const runChecked = (cmd, args, cwd, envExtra = {}, spawnExtra = {}) => {
const result = spawnSync(cmd, args, {
@@ -60,6 +66,7 @@ const resolveDesktopReleaseBaseUrl = () => {
export const prepareWebui = async ({
sourceDir,
projectRoot,
+ coreVersion,
sourceRepoRef,
isSourceRepoRefVersionTag,
isDesktopBridgeExpectationStrict,
@@ -86,11 +93,20 @@ export const prepareWebui = async ({
const targetWebuiDir = path.join(projectRoot, 'resources', 'webui');
await syncResourceDir(sourceWebuiDir, targetWebuiDir);
+ await writeWebuiVersionMarker({ webuiDir: targetWebuiDir, coreVersion });
+ await validateWebuiResources({
+ webuiDir: targetWebuiDir,
+ expectedCoreVersion: coreVersion,
+ });
};
export const prepareBackend = async ({
sourceDir,
projectRoot,
+ desktopVersion,
+ coreVersion,
+ sourceRepoRef,
+ sourceRepoCommit,
pythonBuildStandaloneRelease,
pythonBuildStandaloneVersion,
}) => {
@@ -106,6 +122,10 @@ export const prepareBackend = async ({
{
ASTRBOT_SOURCE_DIR: sourceDir,
ASTRBOT_DESKTOP_CPYTHON_HOME: runtimeRoot,
+ ASTRBOT_DESKTOP_VERSION: desktopVersion,
+ ASTRBOT_CORE_VERSION: coreVersion,
+ ASTRBOT_SOURCE_GIT_REF: sourceRepoRef,
+ ASTRBOT_SOURCE_GIT_COMMIT: sourceRepoCommit,
},
);
@@ -113,8 +133,30 @@ export const prepareBackend = async ({
if (!existsSync(path.join(sourceBackendDir, 'runtime-manifest.json'))) {
throw new Error(`Backend runtime output missing: ${sourceBackendDir}`);
}
+ await validateBackendRuntimeIdentity({
+ backendDir: sourceBackendDir,
+ expectedDesktopVersion: desktopVersion,
+ expectedCoreVersion: coreVersion,
+ expectedSourceRef: sourceRepoRef,
+ expectedSourceCommit: sourceRepoCommit,
+ });
};
+export const validatePreparedResources = async ({
+ projectRoot,
+ desktopVersion,
+ coreVersion,
+ sourceRepoRef,
+ sourceRepoCommit,
+}) =>
+ attestPreparedResourceBundle({
+ projectRoot,
+ desktopVersion,
+ coreVersion,
+ sourceRepoRef,
+ sourceRepoCommit,
+ });
+
export const ensureStartupShellAssets = (projectRoot) => {
const startupUiDir = path.join(projectRoot, 'ui');
const requiredFiles = ['index.html', 'astrbot-logo.png'];
diff --git a/scripts/prepare-resources/resource-identity.mjs b/scripts/prepare-resources/resource-identity.mjs
new file mode 100644
index 00000000..cfaeb08d
--- /dev/null
+++ b/scripts/prepare-resources/resource-identity.mjs
@@ -0,0 +1,429 @@
+import { existsSync } from 'node:fs';
+import { createHash } from 'node:crypto';
+import { mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+
+import { requiredRuntimeRelativePath } from '../backend/runtime-manifest.mjs';
+
+const VERSION_PREFIX_PATTERN = /^v/i;
+const LOCAL_ENTRY_PATTERN = /\.(?:css|js)$/i;
+const SHA256_PATTERN = /^[0-9a-f]{64}$/;
+const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
+
+export const MINIMUM_PACKAGED_CORE_VERSION = '4.26.0';
+
+const sha256 = (content) => createHash('sha256').update(content).digest('hex');
+
+export const normalizeResourceVersion = (version) => {
+ const normalized = typeof version === 'string' ? version.trim().replace(VERSION_PREFIX_PATTERN, '') : '';
+ if (!normalized) {
+ throw new Error('Resource version must not be empty.');
+ }
+ return normalized;
+};
+
+export const formatWebuiVersion = (coreVersion) =>
+ `v${normalizeResourceVersion(coreVersion)}`;
+
+const parseSemver = (version) => {
+ const normalized = normalizeResourceVersion(version);
+ const match = SEMVER_PATTERN.exec(normalized);
+ if (!match) {
+ throw new Error(`Core version ${version} is not valid semantic version.`);
+ }
+ const prerelease = match[4] ? match[4].split('.') : [];
+ if (
+ prerelease.some(
+ (identifier) =>
+ /^\d+$/.test(identifier) &&
+ identifier.length > 1 &&
+ identifier.startsWith('0'),
+ )
+ ) {
+ throw new Error(`Core version ${version} is not valid semantic version.`);
+ }
+ return {
+ normalized,
+ core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])],
+ prerelease,
+ };
+};
+
+const compareSemver = (left, right) => {
+ for (let index = 0; index < left.core.length; index += 1) {
+ if (left.core[index] !== right.core[index]) {
+ return left.core[index] < right.core[index] ? -1 : 1;
+ }
+ }
+ if (left.prerelease.length === 0 && right.prerelease.length === 0) {
+ return 0;
+ }
+ if (left.prerelease.length === 0) {
+ return 1;
+ }
+ if (right.prerelease.length === 0) {
+ return -1;
+ }
+ const count = Math.max(left.prerelease.length, right.prerelease.length);
+ for (let index = 0; index < count; index += 1) {
+ const leftIdentifier = left.prerelease[index];
+ const rightIdentifier = right.prerelease[index];
+ if (leftIdentifier === undefined) {
+ return -1;
+ }
+ if (rightIdentifier === undefined) {
+ return 1;
+ }
+ if (leftIdentifier === rightIdentifier) {
+ continue;
+ }
+ const leftIsNumeric = /^\d+$/.test(leftIdentifier);
+ const rightIsNumeric = /^\d+$/.test(rightIdentifier);
+ if (leftIsNumeric && rightIsNumeric) {
+ return BigInt(leftIdentifier) < BigInt(rightIdentifier) ? -1 : 1;
+ }
+ if (leftIsNumeric !== rightIsNumeric) {
+ return leftIsNumeric ? -1 : 1;
+ }
+ return leftIdentifier < rightIdentifier ? -1 : 1;
+ }
+ return 0;
+};
+
+export const validatePackagedCoreVersion = (coreVersion) => {
+ const parsed = parseSemver(coreVersion);
+ const minimum = parseSemver(MINIMUM_PACKAGED_CORE_VERSION);
+ if (compareSemver(parsed, minimum) < 0) {
+ throw new Error(
+ `Packaged Core ${parsed.normalized} is unsupported: packaged resource identity requires Core ${MINIMUM_PACKAGED_CORE_VERSION} or newer because Desktop verifies /api/v1/stats/versions at startup.`,
+ );
+ }
+ return parsed.normalized;
+};
+
+export const requiresDesktopCoreMatch = (desktopVersion) => {
+ try {
+ return parseSemver(desktopVersion).prerelease.length === 0;
+ } catch {
+ // Match semver::Version on the Rust side: invalid versions fail closed as stable.
+ return true;
+ }
+};
+
+const normalizeLocalAssetReference = (reference) => {
+ const trimmed = reference.trim();
+ if (
+ !trimmed ||
+ trimmed.startsWith('#') ||
+ trimmed.startsWith('//') ||
+ /^[a-z][a-z\d+.-]*:/i.test(trimmed)
+ ) {
+ return null;
+ }
+
+ const withoutQuery = trimmed.split(/[?#]/, 1)[0];
+ let decoded;
+ try {
+ decoded = decodeURIComponent(withoutQuery);
+ } catch {
+ throw new Error(`WebUI index contains an invalid asset URL: ${reference}`);
+ }
+
+ const relative = decoded.replace(/^\/+/, '').replace(/^\.\//, '');
+ if (!relative || !LOCAL_ENTRY_PATTERN.test(relative)) {
+ return null;
+ }
+
+ const normalized = path.normalize(relative);
+ if (normalized === '..' || normalized.startsWith(`..${path.sep}`) || path.isAbsolute(normalized)) {
+ throw new Error(`WebUI index asset escapes the WebUI directory: ${reference}`);
+ }
+ return normalized;
+};
+
+export const extractWebuiEntryAssets = (indexHtml) => {
+ const entries = new Set();
+ const attributePattern = /\b(?:src|href)\s*=\s*["']([^"']+)["']/gi;
+ for (const match of indexHtml.matchAll(attributePattern)) {
+ const entry = normalizeLocalAssetReference(match[1]);
+ if (entry) {
+ entries.add(entry);
+ }
+ }
+ return [...entries].sort();
+};
+
+export const writeWebuiVersionMarker = async ({ webuiDir, coreVersion }) => {
+ const assetsDir = path.join(webuiDir, 'assets');
+ await mkdir(assetsDir, { recursive: true });
+ await writeFile(
+ path.join(assetsDir, 'version'),
+ `${formatWebuiVersion(coreVersion)}\n`,
+ 'utf8',
+ );
+};
+
+export const validateWebuiResources = async ({ webuiDir, expectedCoreVersion }) => {
+ const indexPath = path.join(webuiDir, 'index.html');
+ if (!existsSync(indexPath)) {
+ throw new Error(`WebUI index is missing: ${indexPath}`);
+ }
+
+ const markerPath = path.join(webuiDir, 'assets', 'version');
+ if (!existsSync(markerPath)) {
+ throw new Error(`WebUI version marker is missing: ${markerPath}`);
+ }
+
+ const [indexContent, marker] = await Promise.all([
+ readFile(indexPath),
+ readFile(markerPath, 'utf8'),
+ ]);
+ const indexHtml = indexContent.toString('utf8');
+ const webuiVersion = normalizeResourceVersion(marker);
+ const coreVersion = normalizeResourceVersion(expectedCoreVersion);
+ if (webuiVersion !== coreVersion) {
+ throw new Error(
+ `WebUI version mismatch: assets/version has ${marker.trim()}, expected v${coreVersion}.`,
+ );
+ }
+
+ const entryAssets = extractWebuiEntryAssets(indexHtml);
+ if (!entryAssets.some((entry) => entry.toLowerCase().endsWith('.js'))) {
+ throw new Error(`WebUI index does not reference a JavaScript entry: ${indexPath}`);
+ }
+ const entryDigests = [];
+ for (const entry of entryAssets) {
+ const entryPath = path.join(webuiDir, entry);
+ if (!existsSync(entryPath)) {
+ throw new Error(`WebUI index references a missing entry asset: ${entryPath}`);
+ }
+ entryDigests.push({
+ path: entry.split(path.sep).join('/'),
+ sha256: sha256(await readFile(entryPath)),
+ });
+ }
+
+ return {
+ webuiVersion,
+ indexSha256: sha256(indexContent),
+ entryAssets,
+ entryDigests,
+ };
+};
+
+const expectedWebuiAttestation = (webui) => ({
+ version: webui.webuiVersion,
+ indexSha256: webui.indexSha256,
+ entryAssets: webui.entryDigests,
+});
+
+const normalizeWebuiAttestation = (attestation) => {
+ if (!attestation || typeof attestation !== 'object' || Array.isArray(attestation)) {
+ throw new Error('Backend runtime manifest is missing the WebUI bundle attestation.');
+ }
+ const version = normalizeResourceVersion(attestation.version);
+ const indexSha256 = typeof attestation.indexSha256 === 'string'
+ ? attestation.indexSha256.trim().toLowerCase()
+ : '';
+ if (!SHA256_PATTERN.test(indexSha256)) {
+ throw new Error('Backend runtime manifest WebUI indexSha256 must be a SHA-256 digest.');
+ }
+ if (!Array.isArray(attestation.entryAssets)) {
+ throw new Error('Backend runtime manifest WebUI entryAssets must be an array.');
+ }
+ const entryAssets = attestation.entryAssets.map((entry) => {
+ const entryPath = typeof entry?.path === 'string' ? entry.path.trim() : '';
+ const entrySha256 = typeof entry?.sha256 === 'string'
+ ? entry.sha256.trim().toLowerCase()
+ : '';
+ if (!entryPath || !SHA256_PATTERN.test(entrySha256)) {
+ throw new Error('Backend runtime manifest contains an invalid WebUI entry digest.');
+ }
+ return { path: entryPath, sha256: entrySha256 };
+ });
+ return { version, indexSha256, entryAssets };
+};
+
+const validateWebuiAttestation = ({ manifest, webui, required }) => {
+ if (manifest.webui === undefined && !required) {
+ return;
+ }
+ const actual = normalizeWebuiAttestation(manifest.webui);
+ const expected = expectedWebuiAttestation(webui);
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
+ throw new Error('Backend runtime manifest WebUI bundle attestation does not match the prepared WebUI.');
+ }
+};
+
+const validateRuntimeFileContainment = async ({ backendDir, relativePath, field }) => {
+ let backendRoot;
+ let resolvedFile;
+ try {
+ [backendRoot, resolvedFile] = await Promise.all([
+ realpath(backendDir),
+ realpath(path.resolve(backendDir, relativePath)),
+ ]);
+ } catch (error) {
+ throw new Error(
+ `Backend runtime manifest ${field} file is missing or unreadable: ${relativePath} (${error instanceof Error ? error.message : String(error)})`,
+ );
+ }
+ const relativeToBackend = path.relative(backendRoot, resolvedFile);
+ if (
+ !relativeToBackend ||
+ path.isAbsolute(relativeToBackend) ||
+ relativeToBackend === '..' ||
+ relativeToBackend.startsWith(`..${path.sep}`)
+ ) {
+ throw new Error(
+ `Backend runtime manifest ${field} resolves outside the backend directory: ${relativePath}`,
+ );
+ }
+ if (!(await stat(resolvedFile)).isFile()) {
+ throw new Error(`Backend runtime manifest ${field} is not a file: ${relativePath}`);
+ }
+};
+
+export const validateBackendRuntimeIdentity = async ({
+ backendDir,
+ expectedDesktopVersion = '',
+ expectedCoreVersion,
+ expectedSourceRef = '',
+ expectedSourceCommit = '',
+}) => {
+ const manifestPath = path.join(backendDir, 'runtime-manifest.json');
+ if (!existsSync(manifestPath)) {
+ throw new Error(`Backend runtime manifest is missing: ${manifestPath}`);
+ }
+
+ let manifest;
+ try {
+ manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
+ } catch (error) {
+ throw new Error(
+ `Backend runtime manifest is invalid: ${manifestPath} (${error instanceof Error ? error.message : String(error)})`,
+ );
+ }
+
+ const manifestCoreVersion = normalizeResourceVersion(manifest.coreVersion);
+ const runtimePython = requiredRuntimeRelativePath(manifest.python, 'python');
+ const runtimeEntrypoint = requiredRuntimeRelativePath(manifest.entrypoint, 'entrypoint');
+ await Promise.all([
+ validateRuntimeFileContainment({
+ backendDir,
+ relativePath: runtimePython,
+ field: 'python',
+ }),
+ validateRuntimeFileContainment({
+ backendDir,
+ relativePath: runtimeEntrypoint,
+ field: 'entrypoint',
+ }),
+ ]);
+ const coreVersion = normalizeResourceVersion(expectedCoreVersion);
+ if (manifestCoreVersion !== coreVersion) {
+ throw new Error(
+ `Backend core version mismatch: runtime-manifest.json has ${manifest.coreVersion}, expected ${coreVersion}.`,
+ );
+ }
+ if (expectedDesktopVersion) {
+ const manifestDesktopVersion = normalizeResourceVersion(manifest.desktopVersion);
+ const desktopVersion = normalizeResourceVersion(expectedDesktopVersion);
+ if (manifestDesktopVersion !== desktopVersion) {
+ throw new Error(
+ `Backend Desktop version mismatch: runtime-manifest.json has ${manifest.desktopVersion}, expected ${desktopVersion}.`,
+ );
+ }
+ }
+ for (const field of ['sourceRef', 'sourceCommit']) {
+ if (
+ !(field in manifest) ||
+ (manifest[field] !== null &&
+ (typeof manifest[field] !== 'string' || !manifest[field].trim()))
+ ) {
+ throw new Error(`Backend runtime manifest field ${field} must be a string or null.`);
+ }
+ }
+ if (expectedSourceRef && manifest.sourceRef !== expectedSourceRef) {
+ throw new Error(
+ `Backend source ref mismatch: runtime-manifest.json has ${manifest.sourceRef}, expected ${expectedSourceRef}.`,
+ );
+ }
+ if (expectedSourceCommit && manifest.sourceCommit !== expectedSourceCommit) {
+ throw new Error(
+ `Backend source commit mismatch: runtime-manifest.json has ${manifest.sourceCommit}, expected ${expectedSourceCommit}.`,
+ );
+ }
+ if (manifest.sourceCommit && !/^[0-9a-f]{40,64}$/i.test(manifest.sourceCommit)) {
+ throw new Error('Backend runtime manifest sourceCommit must be a full Git commit hash.');
+ }
+
+ return manifest;
+};
+
+export const validatePreparedResourceBundle = async ({
+ projectRoot,
+ desktopVersion,
+ coreVersion,
+ sourceRepoRef = '',
+ sourceRepoCommit = '',
+ requireWebuiAttestation = false,
+}) => {
+ const normalizedDesktopVersion = normalizeResourceVersion(desktopVersion);
+ const normalizedCoreVersion = validatePackagedCoreVersion(coreVersion);
+ const packageJson = JSON.parse(await readFile(path.join(projectRoot, 'package.json'), 'utf8'));
+ const packageVersion = normalizeResourceVersion(packageJson.version);
+
+ if (packageVersion !== normalizedDesktopVersion) {
+ throw new Error(
+ `Desktop version mismatch: package.json has ${packageJson.version}, expected ${normalizedDesktopVersion}.`,
+ );
+ }
+ if (
+ requiresDesktopCoreMatch(normalizedDesktopVersion) &&
+ normalizedDesktopVersion !== normalizedCoreVersion
+ ) {
+ throw new Error(
+ `Stable bundle version mismatch: Desktop is ${normalizedDesktopVersion}, but Core is ${normalizedCoreVersion}.`,
+ );
+ }
+
+ const webui = await validateWebuiResources({
+ webuiDir: path.join(projectRoot, 'resources', 'webui'),
+ expectedCoreVersion: normalizedCoreVersion,
+ });
+ const backend = await validateBackendRuntimeIdentity({
+ backendDir: path.join(projectRoot, 'resources', 'backend'),
+ expectedDesktopVersion: normalizedDesktopVersion,
+ expectedCoreVersion: normalizedCoreVersion,
+ expectedSourceRef: sourceRepoRef,
+ expectedSourceCommit: sourceRepoCommit,
+ });
+ validateWebuiAttestation({
+ manifest: backend,
+ webui,
+ required: requireWebuiAttestation,
+ });
+
+ console.log(
+ `[prepare-resources] Verified resource identity: Desktop ${normalizedDesktopVersion}, Core ${normalizedCoreVersion}, WebUI v${webui.webuiVersion}.`,
+ );
+ return { desktopVersion: normalizedDesktopVersion, coreVersion: normalizedCoreVersion, webui, backend };
+};
+
+export const attestPreparedResourceBundle = async (options) => {
+ const identity = await validatePreparedResourceBundle({
+ ...options,
+ requireWebuiAttestation: false,
+ });
+ const manifestPath = path.join(options.projectRoot, 'resources', 'backend', 'runtime-manifest.json');
+ const manifest = {
+ ...identity.backend,
+ webui: expectedWebuiAttestation(identity.webui),
+ };
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
+ return validatePreparedResourceBundle({
+ ...options,
+ requireWebuiAttestation: true,
+ });
+};
diff --git a/scripts/prepare-resources/resource-identity.test.mjs b/scripts/prepare-resources/resource-identity.test.mjs
new file mode 100644
index 00000000..d0f7b2f7
--- /dev/null
+++ b/scripts/prepare-resources/resource-identity.test.mjs
@@ -0,0 +1,314 @@
+import assert from 'node:assert/strict';
+import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { test } from 'node:test';
+
+import {
+ attestPreparedResourceBundle,
+ extractWebuiEntryAssets,
+ formatWebuiVersion,
+ MINIMUM_PACKAGED_CORE_VERSION,
+ requiresDesktopCoreMatch,
+ validatePackagedCoreVersion,
+ validatePreparedResourceBundle,
+ validateWebuiResources,
+ writeWebuiVersionMarker,
+} from './resource-identity.mjs';
+
+const createBundleFixture = async ({
+ desktopVersion = '4.27.4',
+ coreVersion = '4.27.4',
+} = {}) => {
+ const projectRoot = await mkdtemp(path.join(os.tmpdir(), 'astrbot-resource-identity-'));
+ const webuiDir = path.join(projectRoot, 'resources', 'webui');
+ const backendDir = path.join(projectRoot, 'resources', 'backend');
+ const assetsDir = path.join(webuiDir, 'assets');
+ const pythonDir = path.join(backendDir, 'python', 'bin');
+ await mkdir(assetsDir, { recursive: true });
+ await mkdir(pythonDir, { recursive: true });
+ await writeFile(
+ path.join(projectRoot, 'package.json'),
+ `${JSON.stringify({ version: desktopVersion })}\n`,
+ 'utf8',
+ );
+ await writeFile(
+ path.join(webuiDir, 'index.html'),
+ '' +
+ '',
+ 'utf8',
+ );
+ await writeFile(path.join(assetsDir, 'index-a1.js'), 'export {};\n', 'utf8');
+ await writeFile(path.join(assetsDir, 'index-b2.css'), 'body {}\n', 'utf8');
+ await writeFile(path.join(pythonDir, 'python3'), '', 'utf8');
+ await writeFile(path.join(backendDir, 'launch_backend.py'), '', 'utf8');
+ await writeWebuiVersionMarker({ webuiDir, coreVersion });
+ await writeFile(
+ path.join(backendDir, 'runtime-manifest.json'),
+ `${JSON.stringify({
+ mode: 'cpython-runtime',
+ python: 'python/bin/python3',
+ entrypoint: 'launch_backend.py',
+ app: 'app',
+ desktopVersion,
+ coreVersion,
+ sourceRef: `v${coreVersion}`,
+ sourceCommit: 'a'.repeat(40),
+ })}\n`,
+ 'utf8',
+ );
+ return { projectRoot, webuiDir, assetsDir, backendDir };
+};
+
+test('formatWebuiVersion produces the marker expected by AstrBot Core', () => {
+ assert.equal(formatWebuiVersion('4.27.4'), 'v4.27.4');
+ assert.equal(formatWebuiVersion('v4.27.4'), 'v4.27.4');
+});
+
+test('requiresDesktopCoreMatch mirrors the runtime stable-version rule', () => {
+ assert.equal(requiresDesktopCoreMatch('4.27.5'), true);
+ assert.equal(requiresDesktopCoreMatch('4.27.5+rebuilt.1'), true);
+ assert.equal(requiresDesktopCoreMatch('4.27.5-nightly.20260901.abcdef12'), false);
+ assert.equal(requiresDesktopCoreMatch('not-semver'), true);
+ assert.equal(requiresDesktopCoreMatch('4.27.5-alpha..1'), true);
+ assert.equal(requiresDesktopCoreMatch('4.27.5-01'), true);
+});
+
+test('validatePackagedCoreVersion accepts the minimum and newer SemVer variants', () => {
+ assert.equal(MINIMUM_PACKAGED_CORE_VERSION, '4.26.0');
+ assert.equal(validatePackagedCoreVersion('v4.26.0'), '4.26.0');
+ assert.equal(validatePackagedCoreVersion('V4.26.0+desktop.1'), '4.26.0+desktop.1');
+ assert.equal(validatePackagedCoreVersion('4.26.1-rc.1'), '4.26.1-rc.1');
+ assert.equal(validatePackagedCoreVersion('5.0.0-alpha.1'), '5.0.0-alpha.1');
+});
+
+test('validatePackagedCoreVersion rejects versions below the identity capability floor', () => {
+ for (const version of ['4.25.99', 'v4.26.0-rc.1']) {
+ assert.throws(
+ () => validatePackagedCoreVersion(version),
+ /packaged resource identity requires Core 4\.26\.0 or newer/,
+ );
+ }
+});
+
+test('validatePackagedCoreVersion rejects malformed semantic versions', () => {
+ for (const version of ['4.26', '4.26.0-01', '04.26.0', 'not-semver']) {
+ assert.throws(
+ () => validatePackagedCoreVersion(version),
+ /is not valid semantic version/,
+ );
+ }
+});
+
+test('extractWebuiEntryAssets finds local JavaScript and CSS entries', () => {
+ const entries = extractWebuiEntryAssets(
+ '' +
+ '' +
+ '',
+ );
+
+ assert.deepEqual(entries, [path.join('assets', 'app.css'), path.join('assets', 'app.js')]);
+});
+
+test('validatePreparedResourceBundle accepts a matching stable bundle', async () => {
+ const fixture = await createBundleFixture();
+ try {
+ const identity = await validatePreparedResourceBundle({
+ projectRoot: fixture.projectRoot,
+ desktopVersion: '4.27.4',
+ coreVersion: '4.27.4',
+ sourceRepoRef: 'v4.27.4',
+ sourceRepoCommit: 'a'.repeat(40),
+ });
+
+ assert.equal(identity.webui.webuiVersion, '4.27.4');
+ assert.equal(identity.backend.coreVersion, '4.27.4');
+ } finally {
+ await rm(fixture.projectRoot, { recursive: true, force: true });
+ }
+});
+
+test('validatePreparedResourceBundle rejects a Core below the packaged identity minimum', async () => {
+ const fixture = await createBundleFixture({
+ desktopVersion: '4.25.9',
+ coreVersion: '4.25.9',
+ });
+ try {
+ await assert.rejects(
+ validatePreparedResourceBundle({
+ projectRoot: fixture.projectRoot,
+ desktopVersion: '4.25.9',
+ coreVersion: 'v4.25.9',
+ sourceRepoRef: 'v4.25.9',
+ sourceRepoCommit: 'a'.repeat(40),
+ }),
+ /packaged resource identity requires Core 4\.26\.0 or newer/,
+ );
+ } finally {
+ await rm(fixture.projectRoot, { recursive: true, force: true });
+ }
+});
+
+test('attestPreparedResourceBundle binds the runtime manifest to WebUI content', async () => {
+ const fixture = await createBundleFixture();
+ try {
+ const options = {
+ projectRoot: fixture.projectRoot,
+ desktopVersion: '4.27.4',
+ coreVersion: '4.27.4',
+ sourceRepoRef: 'v4.27.4',
+ sourceRepoCommit: 'a'.repeat(40),
+ };
+ const identity = await attestPreparedResourceBundle(options);
+ assert.equal(identity.backend.webui.version, '4.27.4');
+ assert.match(identity.backend.webui.indexSha256, /^[0-9a-f]{64}$/);
+ assert.equal(identity.backend.webui.entryAssets.length, 2);
+
+ await writeFile(path.join(fixture.assetsDir, 'index-a1.js'), 'export const stale = true;\n', 'utf8');
+ await assert.rejects(
+ validatePreparedResourceBundle({ ...options, requireWebuiAttestation: true }),
+ /WebUI bundle attestation does not match/,
+ );
+ } finally {
+ await rm(fixture.projectRoot, { recursive: true, force: true });
+ }
+});
+
+test('validatePreparedResourceBundle rejects stable Desktop/Core drift without relying on a source tag', async () => {
+ const fixture = await createBundleFixture({ desktopVersion: '4.27.5' });
+ try {
+ await assert.rejects(
+ validatePreparedResourceBundle({
+ projectRoot: fixture.projectRoot,
+ desktopVersion: '4.27.5',
+ coreVersion: '4.27.4',
+ sourceRepoRef: 'abcdef0123456789abcdef0123456789abcdef01',
+ sourceRepoCommit: 'a'.repeat(40),
+ }),
+ /Stable bundle version mismatch/,
+ );
+ } finally {
+ await rm(fixture.projectRoot, { recursive: true, force: true });
+ }
+});
+
+test('validatePreparedResourceBundle allows a derived nightly Desktop version', async () => {
+ const fixture = await createBundleFixture({ desktopVersion: '4.27.5-nightly.20260901.abcdef12' });
+ try {
+ await validatePreparedResourceBundle({
+ projectRoot: fixture.projectRoot,
+ desktopVersion: '4.27.5-nightly.20260901.abcdef12',
+ coreVersion: '4.27.4',
+ sourceRepoRef: 'v4.27.4',
+ sourceRepoCommit: 'a'.repeat(40),
+ });
+ } finally {
+ await rm(fixture.projectRoot, { recursive: true, force: true });
+ }
+});
+
+test('validateWebuiResources rejects a missing index entry asset', async () => {
+ const fixture = await createBundleFixture();
+ try {
+ await rm(path.join(fixture.assetsDir, 'index-a1.js'));
+ await assert.rejects(
+ validateWebuiResources({
+ webuiDir: fixture.webuiDir,
+ expectedCoreVersion: '4.27.4',
+ }),
+ /missing entry asset/,
+ );
+ } finally {
+ await rm(fixture.projectRoot, { recursive: true, force: true });
+ }
+});
+
+test('validateWebuiResources rejects a stale version marker', async () => {
+ const fixture = await createBundleFixture();
+ try {
+ await writeFile(path.join(fixture.assetsDir, 'version'), 'v4.27.0\n', 'utf8');
+ await assert.rejects(
+ validateWebuiResources({
+ webuiDir: fixture.webuiDir,
+ expectedCoreVersion: '4.27.4',
+ }),
+ /WebUI version mismatch/,
+ );
+ } finally {
+ await rm(fixture.projectRoot, { recursive: true, force: true });
+ }
+});
+
+test('validatePreparedResourceBundle rejects an escaping backend manifest path', async () => {
+ const fixture = await createBundleFixture();
+ try {
+ const manifestPath = path.join(fixture.backendDir, 'runtime-manifest.json');
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
+ manifest.entrypoint = '../launch_backend.py';
+ await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`, 'utf8');
+
+ await assert.rejects(
+ validatePreparedResourceBundle({
+ projectRoot: fixture.projectRoot,
+ desktopVersion: '4.27.4',
+ coreVersion: '4.27.4',
+ sourceRepoRef: 'v4.27.4',
+ sourceRepoCommit: 'a'.repeat(40),
+ }),
+ /entrypoint must be a canonical relative path/,
+ );
+ } finally {
+ await rm(fixture.projectRoot, { recursive: true, force: true });
+ }
+});
+
+test('validatePreparedResourceBundle rejects a missing backend runtime file', async () => {
+ const fixture = await createBundleFixture();
+ try {
+ await rm(path.join(fixture.backendDir, 'launch_backend.py'));
+ await assert.rejects(
+ validatePreparedResourceBundle({
+ projectRoot: fixture.projectRoot,
+ desktopVersion: '4.27.4',
+ coreVersion: '4.27.4',
+ sourceRepoRef: 'v4.27.4',
+ sourceRepoCommit: 'a'.repeat(40),
+ }),
+ /entrypoint file is missing or unreadable/,
+ );
+ } finally {
+ await rm(fixture.projectRoot, { recursive: true, force: true });
+ }
+});
+
+test(
+ 'validatePreparedResourceBundle rejects a backend runtime symlink escape',
+ { skip: process.platform === 'win32' },
+ async () => {
+ const fixture = await createBundleFixture();
+ const outsideDir = await mkdtemp(path.join(os.tmpdir(), 'astrbot-runtime-outside-'));
+ try {
+ const manifestPath = path.join(fixture.backendDir, 'runtime-manifest.json');
+ const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
+ const outsideEntrypoint = path.join(outsideDir, 'outside.py');
+ await writeFile(outsideEntrypoint, '', 'utf8');
+ await rm(path.join(fixture.backendDir, 'launch_backend.py'));
+ await symlink(outsideEntrypoint, path.join(fixture.backendDir, 'launch_backend.py'));
+ await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`, 'utf8');
+
+ await assert.rejects(
+ validatePreparedResourceBundle({
+ projectRoot: fixture.projectRoot,
+ desktopVersion: '4.27.4',
+ coreVersion: '4.27.4',
+ sourceRepoRef: 'v4.27.4',
+ sourceRepoCommit: 'a'.repeat(40),
+ }),
+ /entrypoint resolves outside the backend directory/,
+ );
+ } finally {
+ await rm(fixture.projectRoot, { recursive: true, force: true });
+ await rm(outsideDir, { recursive: true, force: true });
+ }
+ },
+);
diff --git a/scripts/prepare-resources/source-repo.mjs b/scripts/prepare-resources/source-repo.mjs
index fca4d9fd..ea1a0511 100644
--- a/scripts/prepare-resources/source-repo.mjs
+++ b/scripts/prepare-resources/source-repo.mjs
@@ -65,6 +65,23 @@ export const resolveSourceDir = (projectRoot, sourceDirOverrideRaw, cwd = proces
return path.join(projectRoot, 'vendor', 'AstrBot');
};
+export const resolveSourceRepoCommit = (sourceDir, spawn = spawnSync) => {
+ if (!existsSync(path.join(sourceDir, '.git'))) {
+ return '';
+ }
+
+ const result = spawn('git', ['-C', sourceDir, 'rev-parse', 'HEAD'], {
+ encoding: 'utf8',
+ windowsHide: true,
+ });
+ if (result.error || result.status !== 0) {
+ return '';
+ }
+
+ const commit = String(result.stdout || '').trim();
+ return /^[0-9a-f]{40,64}$/i.test(commit) ? commit : '';
+};
+
export const ensureSourceRepo = ({
sourceDir,
sourceRepoUrl,
diff --git a/scripts/prepare-resources/source-repo.test.mjs b/scripts/prepare-resources/source-repo.test.mjs
index 3df4b942..1f1a60ef 100644
--- a/scripts/prepare-resources/source-repo.test.mjs
+++ b/scripts/prepare-resources/source-repo.test.mjs
@@ -1,10 +1,14 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
+import { mkdir, mkdtemp, rm } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
import {
getSourceRefInfo,
normalizeSourceRepoConfig,
resolveSourceDir,
+ resolveSourceRepoCommit,
} from './source-repo.mjs';
test('normalizeSourceRepoConfig normalizes GitHub tree URL and infers ref', () => {
@@ -45,8 +49,35 @@ test('getSourceRefInfo respects explicit commit hint env flag', () => {
test('resolveSourceDir honors override and default project layout', () => {
const resolvedOverride = resolveSourceDir('/project/root', './vendor/custom', '/work');
- assert.equal(resolvedOverride, '/work/vendor/custom');
+ assert.equal(resolvedOverride, path.resolve('/work', 'vendor/custom'));
const resolvedDefault = resolveSourceDir('/project/root', '', '/work');
- assert.equal(resolvedDefault, '/project/root/vendor/AstrBot');
+ assert.equal(resolvedDefault, path.join('/project/root', 'vendor', 'AstrBot'));
+});
+
+test('resolveSourceRepoCommit returns the checked out commit', async () => {
+ const sourceDir = await mkdtemp(path.join(os.tmpdir(), 'astrbot-source-ref-'));
+ try {
+ await mkdir(path.join(sourceDir, '.git'));
+ const commit = 'a'.repeat(40);
+ const calls = [];
+ const spawn = (...args) => {
+ calls.push(args);
+ return { status: 0, stdout: `${commit}\n` };
+ };
+
+ assert.equal(resolveSourceRepoCommit(sourceDir, spawn), commit);
+ assert.deepEqual(calls[0][1], ['-C', sourceDir, 'rev-parse', 'HEAD']);
+ } finally {
+ await rm(sourceDir, { recursive: true, force: true });
+ }
+});
+
+test('resolveSourceRepoCommit tolerates sources without Git metadata', async () => {
+ const sourceDir = await mkdtemp(path.join(os.tmpdir(), 'astrbot-source-ref-'));
+ try {
+ assert.equal(resolveSourceRepoCommit(sourceDir), '');
+ } finally {
+ await rm(sourceDir, { recursive: true, force: true });
+ }
});
diff --git a/scripts/prepare-resources/startup-shell-copy.test.mjs b/scripts/prepare-resources/startup-shell-copy.test.mjs
index 1939aeee..e0dbd20d 100644
--- a/scripts/prepare-resources/startup-shell-copy.test.mjs
+++ b/scripts/prepare-resources/startup-shell-copy.test.mjs
@@ -72,6 +72,21 @@ test('startup shell loads shared copy config, reuses applyStartupMode, and expos
/if\s*\(status\.textContent\s*===\s*next\.status\)\s*return;/,
'expected startup shell to skip duplicate status announcements',
);
+ assert.match(
+ source,
+ /window\.__astrbotShowStartupError\s*=\s*\(message\)\s*=>/,
+ 'expected startup failures to be rendered in the visible startup shell',
+ );
+ assert.match(
+ source,
+ /typeof\s+window\.__astrbotPendingStartupError\s*===\s*["']string["']/,
+ 'expected failures dispatched before page load to be rendered after initialization',
+ );
+ assert.match(
+ source,
+ /panel\.classList\.add\(["']error["']\)/,
+ 'expected startup failures to switch the shell into its error state',
+ );
assert.match(
configSource,
diff --git a/scripts/prepare-resources/version-sync.mjs b/scripts/prepare-resources/version-sync.mjs
index 57a2e082..ac3c9d6e 100644
--- a/scripts/prepare-resources/version-sync.mjs
+++ b/scripts/prepare-resources/version-sync.mjs
@@ -87,13 +87,16 @@ export const readAstrbotRuntimeVersion = async ({ sourceDir }) => {
};
export const validateAstrbotRuntimeVersion = async ({ sourceDir, expectedVersion }) => {
+ if (!expectedVersion) {
+ throw new Error('Expected AstrBot Core version is required for runtime validation.');
+ }
const runtimeVersion = await readAstrbotRuntimeVersion({ sourceDir });
if (runtimeVersion === '0.0.0') {
throw new Error(
`AstrBot runtime VERSION resolved to 0.0.0 in ${sourceDir}. Use an AstrBot source ref that contains the static runtime VERSION fix.`,
);
}
- if (expectedVersion && runtimeVersion !== expectedVersion) {
+ if (runtimeVersion !== expectedVersion) {
throw new Error(
`AstrBot version mismatch in ${sourceDir}: pyproject.toml has ${expectedVersion}, but runtime VERSION is ${runtimeVersion}.`,
);
diff --git a/scripts/prepare-resources/version-sync.test.mjs b/scripts/prepare-resources/version-sync.test.mjs
index 21db59a5..2ef231d9 100644
--- a/scripts/prepare-resources/version-sync.test.mjs
+++ b/scripts/prepare-resources/version-sync.test.mjs
@@ -142,24 +142,15 @@ test('validateAstrbotRuntimeVersion rejects runtime version drift', async () =>
}
});
-test('validateAstrbotRuntimeVersion allows drift when no expected version is supplied', async () => {
+test('validateAstrbotRuntimeVersion requires the expected Core version', async () => {
const tempDir = await createTempAstrBotSource({
pyprojectVersion: '4.26.0-beta.10',
runtimeVersion: '4.26.0-beta.9',
});
- try {
- await validateAstrbotRuntimeVersion({ sourceDir: tempDir });
- } finally {
- await rm(tempDir, { recursive: true, force: true });
- }
-});
-
-test('validateAstrbotRuntimeVersion still rejects 0.0.0 when no expected version is supplied', async () => {
- const tempDir = await createTempAstrBotSource({ runtimeVersion: '0.0.0' });
try {
await assert.rejects(
validateAstrbotRuntimeVersion({ sourceDir: tempDir }),
- /runtime VERSION resolved to 0\.0\.0/,
+ /Expected AstrBot Core version is required/,
);
} finally {
await rm(tempDir, { recursive: true, force: true });
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 11bb7490..6b597596 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -66,6 +66,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
+ "sha2",
"shlex",
"tauri",
"tauri-build",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 1dc6dd11..f87a3e24 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -11,6 +11,7 @@ build = "build.rs"
[build-dependencies]
serde_json = "1.0"
+sha2 = "0.10"
tauri-build = { version = "2.0", features = [] }
[dependencies]
@@ -20,6 +21,7 @@ home = "0.5"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
semver = "1.0"
+sha2 = "0.10"
shlex = "1.3"
tauri = { version = "2.0", features = ["tray-icon"] }
tauri-plugin-autostart = "2.0"
diff --git a/src-tauri/build.rs b/src-tauri/build.rs
index ecc07d82..873652c1 100644
--- a/src-tauri/build.rs
+++ b/src-tauri/build.rs
@@ -1,12 +1,15 @@
use serde_json::Value;
+use sha2::{Digest, Sha256};
use std::{
- fs,
+ env, fs,
path::{Component, Path},
};
const TAURI_CONFIG_PATH: &str = "tauri.conf.json";
const BACKEND_RESOURCE_SOURCE: &str = "../resources/backend";
const WEBUI_RESOURCE_SOURCE: &str = "../resources/webui";
+const RUNTIME_MANIFEST_RELATIVE_PATH: &str = "../resources/backend/runtime-manifest.json";
+const DEVELOPMENT_UNBOUND_MANIFEST: &str = "development-unbound";
fn load_bundle_resource_alias(tauri_config: &Value, source_relative_path: &str) -> String {
// Keep validation rules aligned with
@@ -60,6 +63,31 @@ fn load_bundle_resource_alias(tauri_config: &Value, source_relative_path: &str)
alias.to_string()
}
+fn runtime_manifest_sha256() -> String {
+ let manifest_dir = env::var_os("CARGO_MANIFEST_DIR")
+ .expect("Cargo did not provide CARGO_MANIFEST_DIR to build.rs");
+ let manifest_path = Path::new(&manifest_dir).join(RUNTIME_MANIFEST_RELATIVE_PATH);
+ println!("cargo:rerun-if-changed={}", manifest_path.display());
+ match fs::read(&manifest_path) {
+ Ok(bytes) if !bytes.is_empty() => format!("{:x}", Sha256::digest(bytes)),
+ Ok(_) => panic!(
+ "packaged runtime manifest is empty: {}",
+ manifest_path.display()
+ ),
+ Err(error) if env::var("PROFILE").as_deref() != Ok("release") => {
+ println!(
+ "cargo:warning=packaged runtime manifest is unavailable in a development build: {} ({error})",
+ manifest_path.display()
+ );
+ DEVELOPMENT_UNBOUND_MANIFEST.to_string()
+ }
+ Err(error) => panic!(
+ "failed to read packaged runtime manifest {} before release compilation: {error}",
+ manifest_path.display()
+ ),
+ }
+}
+
fn main() {
let marker_path = Path::new("windows").join("portable-runtime-marker.txt");
let tauri_config_path = Path::new(TAURI_CONFIG_PATH);
@@ -85,6 +113,10 @@ fn main() {
let webui_resource_alias = load_bundle_resource_alias(&tauri_config, WEBUI_RESOURCE_SOURCE);
println!("cargo:rustc-env=ASTRBOT_BACKEND_RESOURCE_ALIAS={backend_resource_alias}");
println!("cargo:rustc-env=ASTRBOT_WEBUI_RESOURCE_ALIAS={webui_resource_alias}");
+ println!(
+ "cargo:rustc-env=ASTRBOT_RUNTIME_MANIFEST_SHA256={}",
+ runtime_manifest_sha256()
+ );
tauri_build::build()
}
diff --git a/src-tauri/src/app_helpers.rs b/src-tauri/src/app_helpers.rs
index 99ea83c2..a6197237 100644
--- a/src-tauri/src/app_helpers.rs
+++ b/src-tauri/src/app_helpers.rs
@@ -13,9 +13,16 @@ use crate::{
static DESKTOP_LOG_WRITE_LOCK: OnceLock> = OnceLock::new();
static BACKEND_PATH_OVERRIDE: OnceLock