diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0cf6703..511dfc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,12 +80,21 @@ jobs: # Rust 编译检查 - name: Rust 编译检查 working-directory: src-tauri - run: cargo check + run: cargo check --locked # Rust Lint(警告视为错误) - name: Rust Clippy working-directory: src-tauri - run: cargo clippy --all-targets -- -D warnings + run: cargo clippy --locked --all-targets -- -D warnings + + # Node 单元测试 + - name: Node 单元测试 + run: node --test tests/*.test.js + + # Rust 单元测试 + - name: Rust 单元测试 + working-directory: src-tauri + run: cargo test --locked # 前端构建验证 - name: 前端构建验证 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 71fba07..0353f30 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -23,6 +23,7 @@ concurrency: jobs: build: + if: ${{ github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main' }} runs-on: ubuntu-latest steps: - name: 签出代码 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5c3f56..205d4e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,12 +15,107 @@ on: default: 'v1.0.0' jobs: + validate-release: + name: 验证发布目标 + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + tag_name: ${{ steps.release.outputs.tag_name }} + version: ${{ steps.release.outputs.version }} + steps: + - name: 签出代码和标签 + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 验证 SemVer、Tag 绑定与 Release 唯一性 + id: release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + INPUT_TAG: ${{ github.event.inputs.tag_name }} + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + TAG_NAME="$INPUT_TAG" + else + TAG_NAME="${{ github.ref_name }}" + fi + + SEMVER_PATTERN='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' + if [[ ! "$TAG_NAME" =~ $SEMVER_PATTERN ]]; then + echo "无效发布标签: $TAG_NAME(必须是 vX.Y.Z 格式的 SemVer)" >&2 + exit 1 + fi + + TAG_COMMIT=$(git rev-parse "refs/tags/$TAG_NAME^{commit}" 2>/dev/null) || { + echo "发布标签不存在: $TAG_NAME" >&2 + exit 1 + } + HEAD_COMMIT=$(git rev-parse HEAD) + if [ "$TAG_COMMIT" != "$HEAD_COMMIT" ]; then + echo "发布标签 $TAG_NAME 未绑定当前 ref ($HEAD_COMMIT)" >&2 + exit 1 + fi + + if gh release view "$TAG_NAME" >/dev/null 2>&1; then + echo "Release $TAG_NAME 已存在,禁止覆盖" >&2 + exit 1 + fi + + echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" + echo "version=${TAG_NAME#v}" >> "$GITHUB_OUTPUT" + + test-release: + name: 发布前单元测试 + needs: validate-release + runs-on: ubuntu-latest + steps: + - name: 签出代码 + uses: actions/checkout@v4 + + - name: 安装 Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.19.0 + cache: npm + + - name: 安装前端依赖 + run: npm ci + + - name: Node 单元测试 + run: node --test tests/*.test.js + + - name: 安装 Rust 工具链 + uses: dtolnay/rust-toolchain@stable + + - name: Rust 编译缓存 + uses: swatinem/rust-cache@v2 + with: + workspaces: src-tauri -> target + + - name: 安装 Linux 系统依赖 + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + librsvg2-dev \ + patchelf \ + libssl-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev + + - name: Rust 单元测试 + working-directory: src-tauri + run: cargo test --locked + # ── 跨平台构建 job ───────────────────────────────────────────────────────── build: name: 构建 (${{ matrix.platform.name }}) + needs: [validate-release, test-release] runs-on: ${{ matrix.platform.os }} permissions: - contents: write + contents: read strategy: fail-fast: false matrix: @@ -29,22 +124,27 @@ jobs: os: macos-latest args: --target aarch64-apple-darwin rust_target: aarch64-apple-darwin + artifact: macos-arm64 - name: macOS (Intel) os: macos-latest args: --target x86_64-apple-darwin rust_target: x86_64-apple-darwin + artifact: macos-x64 - name: Linux (x64) os: ubuntu-latest args: "" rust_target: "" + artifact: linux-x64 - name: Windows (x64) os: windows-latest args: "" rust_target: "" + artifact: windows-x64 - name: Windows (x64) 完整包 os: windows-latest args: "" rust_target: "" + artifact: windows-x64-full steps: - name: 签出代码 @@ -52,16 +152,6 @@ jobs: with: fetch-depth: 0 - - name: 设置版本标签 - id: vars - shell: bash - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "TAG_NAME=${{ github.event.inputs.tag_name }}" >> "$GITHUB_ENV" - else - echo "TAG_NAME=${{ github.ref_name }}" >> "$GITHUB_ENV" - fi - - name: 安装 Node.js uses: actions/setup-node@v4 with: @@ -74,8 +164,7 @@ jobs: - name: 同步版本号到构建产物 shell: bash run: | - VERSION="${TAG_NAME#v}" - node scripts/sync-version.js "$VERSION" + node scripts/sync-version.js "${{ needs.validate-release.outputs.version }}" - name: 安装 Rust 工具链 uses: dtolnay/rust-toolchain@stable @@ -103,26 +192,22 @@ jobs: - name: 构建 Tauri 应用 if: matrix.platform.name != 'Windows (x64) 完整包' - uses: tauri-apps/tauri-action@v0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # macOS 代码签名(可选) - # APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - # APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - # APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - # APPLE_ID: ${{ secrets.APPLE_ID }} - # APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - # APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - # Windows 代码签名(可选) - # TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - # TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: npx tauri build ${{ matrix.platform.args }} -- --locked + + - name: 上传平台构建产物 + if: matrix.platform.name != 'Windows (x64) 完整包' + uses: actions/upload-artifact@v4 with: - tagName: ${{ env.TAG_NAME }} - releaseName: "ClawPanel ${{ env.TAG_NAME }}" - releaseBody: "正在构建所有平台安装包,请稍候..." - releaseDraft: false - prerelease: false - args: ${{ matrix.platform.args }} + name: release-${{ matrix.platform.artifact }} + path: | + src-tauri/target/**/release/bundle/**/*.dmg + src-tauri/target/**/release/bundle/**/*.exe + src-tauri/target/**/release/bundle/**/*.msi + src-tauri/target/**/release/bundle/**/*.AppImage + src-tauri/target/**/release/bundle/**/*.deb + src-tauri/target/**/release/bundle/**/*.rpm + if-no-files-found: error + compression-level: 0 # ── Windows 完整包(内嵌 WebView2 离线安装器)────────────────────────────── - name: 配置 WebView2 完整包模式 @@ -140,57 +225,37 @@ jobs: - name: 构建 Windows 完整包 if: matrix.platform.name == 'Windows (x64) 完整包' shell: bash - run: npx tauri build + run: npx tauri build -- --locked - - name: 重命名并上传完整包 + - name: 重命名 Windows 完整包 if: matrix.platform.name == 'Windows (x64) 完整包' shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - VERSION="${TAG_NAME#v}" + VERSION="${{ needs.validate-release.outputs.version }}" BUNDLE_DIR="src-tauri/target/release/bundle" - - # 确保 Release 已存在(可能被其他 matrix job 的 tauri-action 创建) - gh release create "$TAG_NAME" --title "ClawPanel $TAG_NAME" \ - --notes "正在构建所有平台安装包,请稍候..." 2>/dev/null || true - - # 重命名 NSIS exe 并上传 mv "${BUNDLE_DIR}/nsis/ClawPanel_${VERSION}_x64-setup.exe" \ "${BUNDLE_DIR}/nsis/ClawPanel_${VERSION}_x64-setup-full.exe" - gh release upload "$TAG_NAME" \ - "${BUNDLE_DIR}/nsis/ClawPanel_${VERSION}_x64-setup-full.exe" --clobber - - # 重命名 MSI 并上传 mv "${BUNDLE_DIR}/msi/ClawPanel_${VERSION}_x64_en-US.msi" \ "${BUNDLE_DIR}/msi/ClawPanel_${VERSION}_x64-full_en-US.msi" - gh release upload "$TAG_NAME" \ - "${BUNDLE_DIR}/msi/ClawPanel_${VERSION}_x64-full_en-US.msi" --clobber - # ── 所有平台构建完成后,统一更新 Release Notes ───────────────────────────── - # 独立 job 确保只执行一次,彻底避免多个 matrix job 的竞争条件 - update-release-notes: - name: 更新 Release Notes - needs: build + - name: 上传 Windows 完整包产物 + if: matrix.platform.name == 'Windows (x64) 完整包' + uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.platform.artifact }} + path: | + src-tauri/target/release/bundle/nsis/ClawPanel_${{ needs.validate-release.outputs.version }}_x64-setup-full.exe + src-tauri/target/release/bundle/msi/ClawPanel_${{ needs.validate-release.outputs.version }}_x64-full_en-US.msi + if-no-files-found: error + compression-level: 0 + + build-web: + name: 构建 Web 热更新包 + needs: [validate-release, test-release] runs-on: ubuntu-latest - if: always() && needs.build.result != 'cancelled' - permissions: - contents: write - steps: - name: 签出代码 uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: 设置版本标签 - shell: bash - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - echo "TAG_NAME=${{ github.event.inputs.tag_name }}" >> "$GITHUB_ENV" - else - echo "TAG_NAME=${{ github.ref_name }}" >> "$GITHUB_ENV" - fi - name: 安装 Node.js uses: actions/setup-node@v4 @@ -198,63 +263,59 @@ jobs: node-version: 22.19.0 cache: npm - - name: 构建前端并上传热更新包 + - name: 安装前端依赖 + run: npm ci + + - name: 构建前端 + run: npm run build + + - name: 打包 Web 热更新产物 shell: bash - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - VERSION="${TAG_NAME#v}" - - # 构建前端 - npm ci - npm run build - - # 打包 dist 目录为 zip + VERSION="${{ needs.validate-release.outputs.version }}" cd dist zip -r "../web-${VERSION}.zip" . cd .. - # 计算 SHA-256 - HASH=$(sha256sum "web-${VERSION}.zip" | cut -d' ' -f1) - SIZE=$(stat -c%s "web-${VERSION}.zip" 2>/dev/null || stat -f%z "web-${VERSION}.zip") + - name: 上传 Web workflow artifact + uses: actions/upload-artifact@v4 + with: + name: release-web + path: web-${{ needs.validate-release.outputs.version }}.zip + if-no-files-found: error + compression-level: 0 - # 上传为 Release Asset - gh release upload "$TAG_NAME" "web-${VERSION}.zip" --clobber + # ── 所有构建成功后一次性创建并公开 Release ──────────────────────────────── + publish-release: + name: 发布 Release + needs: [validate-release, test-release, build, build-web] + runs-on: ubuntu-latest + permissions: + contents: write + env: + TAG_NAME: ${{ needs.validate-release.outputs.tag_name }} + VERSION: ${{ needs.validate-release.outputs.version }} - # 读取现有 minAppVersion(前端热更新通常不需要更新 Rust 后端,保留旧值) - MIN_APP_VER=$(cat docs/update/latest.json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('minAppVersion','0.9.0'))" 2>/dev/null || echo "0.9.0") + steps: + - name: 签出发布代码 + uses: actions/checkout@v4 + with: + fetch-depth: 0 - # 更新 docs/update/latest.json - DL_URL="https://github.com/${{ github.repository }}/releases/download/${TAG_NAME}/web-${VERSION}.zip" - cat > docs/update/latest.json << EOF - { - "version": "${VERSION}", - "minAppVersion": "${MIN_APP_VER}", - "hash": "sha256:${HASH}", - "url": "${DL_URL}", - "size": ${SIZE}, - "changelog": "", - "releasedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" - } - EOF - # 去掉 heredoc 缩进 - sed -i 's/^ //' docs/update/latest.json + - name: 下载全部 workflow artifacts + uses: actions/download-artifact@v4 + with: + pattern: release-* + path: release-assets + merge-multiple: true - # 提交 latest.json 到 main 分支 - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add docs/update/latest.json - git commit -m "ci: update latest.json for ${TAG_NAME}" || true - git push origin HEAD:main || true - - - name: 生成并更新 Release Notes + - name: 生成 Release Notes shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} BUILD_RESULT: ${{ needs.build.result }} REPO: ${{ github.repository }} run: | - VERSION="${TAG_NAME#v}" DL="https://github.com/${REPO}/releases/download/${TAG_NAME}" # ── 生成分类 Changelog ── @@ -385,4 +446,58 @@ jobs: echo "📖 [项目主页](https://github.com/${REPO}) · 💬 [反馈问题](https://github.com/${REPO}/issues) · 📣 [QQ 群](https://qt.cool/c/OpenClaw) · Telegram: https://t.me/clawpanel" } > release_body.md - gh release edit "$TAG_NAME" --notes-file release_body.md + - name: 创建草稿 Release 并上传完整资产 + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + mapfile -d '' ASSETS < <(find release-assets -type f -print0) + if [ "${#ASSETS[@]}" -eq 0 ]; then + echo "没有可发布的 workflow artifacts" >&2 + exit 1 + fi + + gh release create "$TAG_NAME" --title "ClawPanel $TAG_NAME" --notes-file release_body.md --draft + gh release upload "$TAG_NAME" "${ASSETS[@]}" + + - name: 写入本次热更新清单 + shell: bash + run: | + WEB_PACKAGE=$(find release-assets -type f -name "web-${VERSION}.zip" -print -quit) + if [ -z "$WEB_PACKAGE" ]; then + echo "缺少 web-${VERSION}.zip" >&2 + exit 1 + fi + + HASH=$(sha256sum "$WEB_PACKAGE" | cut -d' ' -f1) + SIZE=$(stat -c%s "$WEB_PACKAGE") + DL_URL="https://github.com/${{ github.repository }}/releases/download/${TAG_NAME}/web-${VERSION}.zip" + + git fetch origin main + git checkout -B release-manifest origin/main + cat > docs/update/latest.json << EOF + { + "version": "${VERSION}", + "minAppVersion": "${VERSION}", + "hash": "sha256:${HASH}", + "url": "${DL_URL}", + "size": ${SIZE}, + "changelog": "", + "releasedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } + EOF + sed -i 's/^ //' docs/update/latest.json + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add docs/update/latest.json + if ! git diff --cached --quiet; then + git commit -m "ci: update latest.json for ${TAG_NAME}" + git push origin HEAD:main + fi + + - name: 一次性公开 Release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: gh release edit "$TAG_NAME" --draft=false diff --git a/CHANGELOG.md b/CHANGELOG.md index 77381d9..f7bcd48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ - 渠道文件存放在 OpenClaw 数据目录下(便携迁移自动带走);密钥读取只返回掩码,写入支持保留旧 Key - Hermes 同步覆盖 OpenAI / Anthropic / Gemini 兼容接口(API Key 型);OAuth / 云 SDK 型服务商仍走 Hermes 安装向导 -## [0.18.6] - 2026-07-04 +## [0.18.6 候选] - 尚未发布 ### 新功能 (Features) diff --git a/docs/plans/2026-07-12-release-stabilization.md b/docs/plans/2026-07-12-release-stabilization.md new file mode 100644 index 0000000..6ba32b5 --- /dev/null +++ b/docs/plans/2026-07-12-release-stabilization.md @@ -0,0 +1,117 @@ +# ClawPanel Release Stabilization Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use test-driven-development and subagent-driven-development to implement this plan task-by-task. + +**Goal:** 修复 `v0.18.5` 之后审计确认的上线阻断问题,使双运行时功能、迁移、媒体下载与发布链具备可验证的失败保护。 + +**Architecture:** 将高风险操作收口到后端原子命令,前端只提交意图;文件迁移和媒体下载使用 staging + 原子切换;发布流程先验证、后构建、最后一次性公开并更新热更新清单。测试从源码正则升级为真实函数/HTTP/文件系统行为测试。 + +**Tech Stack:** Vanilla JS、Node.js `node:test`、Rust/Tauri、GitHub Actions、Playwright。 + +## 执行结果(2026-07-12) + +**状态:本地稳定化闭环完成,暂不打 tag。** + +- 发布链改为先校验 tag/ref 与既有 Release,再测试、构建并汇总 workflow artifacts;只有全部平台成功后才公开 Release 和提交本次热更新清单。 +- Hermes 模型渠道改用专用事务命令;安装向导恢复阶段、安装单飞、Web 异步安装、镜像传递和候选 Gateway 先探活后保存均已覆盖。 +- 便携迁移增加 canonical path、junction/reparse 拒绝、staging/回滚、外部绝对路径警告和严格 RealUsb 只读检查。 +- 媒体下载改为手动重定向、逐跳凭据判断、响应体全程超时、流式 staging 落盘和失败清理;Node 真实 HTTP 测试覆盖跨源鉴权、超时与超限。 +- 移动端 Hermes 页面为 FAB 预留 96px 底部空间;390×844 Playwright 实测无重叠。 +- `package.json` / `package-lock.json` 已锁定 Vite `^6.4.3`,`npm audit --omit=dev` 为 0 漏洞。当前工作区 `node_modules/vite` 实体仍残留 6.4.2,未擅自删除依赖目录;干净 `npm ci` 会按锁文件安装 6.4.3。 + +**已验证:** Node 451/451、Rust 330/330、前端构建、Rust fmt/check/clippy、Windows Tauri MSI/NSIS、便携模拟 smoke、`git diff --check`、新增 diff 敏感内容扫描。 + +**外部依赖:** GitHub Actions 真实发布链、macOS/Linux 打包、真实服务商账号、真实 U 盘完整迁移仍需发布前在对应环境执行。 + +--- + +### Task 1: 发布与热更新门禁 + +**Files:** +- Modify: `.github/workflows/ci.yml` +- Modify: `.github/workflows/release.yml` +- Modify: `.github/workflows/pages.yml` +- Modify: `scripts/sync-version.js` +- Modify: `src-tauri/src/commands/update.rs` +- Create: `tests/release-safety-policy.test.js` + +1. 先写失败测试,覆盖 tag/ref 绑定、禁止覆盖既有发布、构建失败不得更新 Release、CI 必跑 Node/Rust 测试、版本同步失败非零退出、兼容判断使用真实二进制版本。 +2. 运行目标测试并确认按预期失败。 +3. 增加 verify job,发布产物先作为 workflow artifact 汇总,完整成功后再创建/公开 Release。 +4. 将 `minAppVersion` 设为显式发布输入或当前目标版本;兼容判断拆分 `appVersion` 与 `frontendVersion`。 +5. 运行目标测试、Rust 更新模块测试与 YAML 静态复核。 + +### Task 2: Hermes 模型渠道同步 + +**Files:** +- Modify: `src/lib/model-channels.js` +- Modify: `src/lib/tauri-api.js` +- Modify: `src-tauri/src/commands/hermes.rs` +- Modify: `scripts/dev-api.js` +- Modify: `src-tauri/src/lib.rs` +- Modify: `tests/model-channels.test.js` + +1. 写失败测试复现受管 Provider Key 被 `hermes_env_set` 拒绝。 +2. 新增专用 `hermes_sync_provider` 后端命令,原子更新受管 Key、Base URL 和可选默认模型;保留通用 env 编辑器的拒绝策略。 +3. 前端改用专用命令并在成功后回读确认。 +4. 对齐 Rust/Web Provider Registry 的字段和默认模型,并增加字段级契约测试。 + +### Task 3: 便携迁移事务与路径安全 + +**Files:** +- Modify: `src-tauri/src/utils.rs` +- Modify: `src-tauri/src/commands/portable.rs` +- Modify: `scripts/smoke-portable-usb.ps1` + +1. 写失败 Rust 测试覆盖 `..` 目标、自包含复制、symlink/junction、复制失败回滚和 manifest 最后提交。 +2. 规范化源/目标及已存在祖先,拒绝链接和重叠路径。 +3. 正反向迁移先复制到同级 staging,验证后原子切换;失败自动清理 staging 并恢复备份。 +4. 检测并报告 OpenClaw/Hermes 内部根目录外绝对路径;非 Windows 平台不再声称单文件复制为完整便携应用。 +5. 真实 U 盘 smoke 默认只读,组件缺失必须令检查失败。 + +### Task 4: 媒体下载安全与资源边界 + +**Files:** +- Modify: `scripts/dev-api.js` +- Modify: `src-tauri/src/commands/media.rs` +- Replace: `tests/media-download-policy.test.js` + +1. 写真实本地 HTTP 测试,覆盖协议降级不带鉴权、401/403 无鉴权重试、无 Content-Length 超限、响应体超时和取消。 +2. 同源判定比较 scheme + host + 有效端口。 +3. 下载边读边写 staging 文件并计数,成功后原子改名;错误、超时或超限时取消并删除 staging。 +4. 避免合法 512MB 文件在内存中形成一至两份完整副本。 + +### Task 5: Hermes 安装向导可靠性 + +**Files:** +- Modify: `src/engines/hermes/pages/setup.js` +- Modify: `src-tauri/src/commands/hermes.rs` +- Modify: `scripts/dev-api.js` +- Create: `tests/hermes-setup-runtime.test.js` + +1. 写失败测试覆盖阶段恢复、安装 single-flight、候选 Gateway 验证后保存、Web 异步安装和 PyPI 镜像传递。 +2. 首次绘制前读取恢复阶段;安装入口立即进入忙碌态,后端增加互斥。 +3. Web 改为异步子进程并提供状态/日志轮询,不阻塞 API 事件循环。 +4. 自定义 Gateway 先探测候选 URL,成功后再持久化。 + +### Task 6: UI、依赖与版本说明收口 + +**Files:** +- Modify: `src/style/ai-drawer.css` +- Modify: `package.json` +- Modify: `package-lock.json` +- Modify: `CHANGELOG.md` +- Add/Modify: relevant tests + +1. 写移动端碰撞测试,确保 FAB 不覆盖固定操作区。 +2. 更新 Vite 到已修复的 `6.4.3`。 +3. 暂不改发布版本号;将未发布内容保持在单一 Unreleased 口径,真正发布前再执行 `version:set`。 + +### Task 7: 全量验证 + +1. 运行 `node --test tests/*.test.js`。 +2. 运行 `npm run build` 和 `npm audit --omit=dev`。 +3. 运行 `cargo fmt --all -- --check`、`cargo check --locked`、`cargo clippy --all-targets --locked -- -D warnings`、`cargo test --locked --lib`。 +4. 运行 Web 生产模式桌面/移动端 Playwright 冒烟。 +5. 运行 `npm run tauri build` 生成 Windows MSI/NSIS。 +6. 运行 `git diff --check` 并确认未创建 commit/tag/push。 diff --git a/package-lock.json b/package-lock.json index 2d49794..31182e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", - "vite": "^6.4.2" + "vite": "^6.4.3" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1279,9 +1279,9 @@ } }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 7dbd60d..492fe99 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,6 @@ }, "devDependencies": { "@tauri-apps/cli": "^2.5.0", - "vite": "^6.4.2" + "vite": "^6.4.3" } } diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 75de9da..b33712d 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -12,6 +12,7 @@ import { fileURLToPath } from 'url' import net from 'net' import http from 'http' import crypto from 'crypto' +import { once } from 'events' import * as YAML from 'yaml' import * as skillhubSdk from './lib/skillhub-sdk.js' const DOCKER_TASK_TIMEOUT_MS = 10 * 60 * 1000 @@ -25,6 +26,7 @@ const HERMES_STABLE_VERSION = '0.18.0' const HERMES_STABLE_TAG = 'v2026.7.1' const HERMES_REPO_URL = 'https://github.com/NousResearch/hermes-agent.git' const HERMES_GIT_URL = `git+${HERMES_REPO_URL}@${HERMES_STABLE_TAG}` +let _hermesInstallRunning = false function hermesProvider(id, name, authType, baseUrl, baseUrlEnvVar, apiKeyEnvVars, transport, modelsProbe, models, isAggregator = false, cliAuthHint = '') { return { id, name, authType, baseUrl, baseUrlEnvVar, apiKeyEnvVars, transport, modelsProbe, models, isAggregator, cliAuthHint } @@ -57,7 +59,7 @@ const HERMES_PROVIDER_REGISTRY = [ hermesProvider('zai', 'Z.AI / GLM', 'api_key', 'https://api.z.ai/api/paas/v4', 'GLM_BASE_URL', ['GLM_API_KEY', 'ZAI_API_KEY', 'Z_AI_API_KEY'], 'openai_chat', 'openai', ['glm-5.2', 'glm-5.1', 'glm-5', 'glm-5v-turbo', 'glm-5-turbo', 'glm-4.7', 'glm-4.5', 'glm-4.5-flash']), hermesProvider('kimi-coding', 'Kimi / Moonshot', 'api_key', 'https://api.moonshot.ai/v1', 'KIMI_BASE_URL', ['KIMI_API_KEY'], 'openai_chat', 'openai', ['kimi-k2.7-code', 'kimi-for-coding', 'kimi-k2.6', 'kimi-k2.5', 'kimi-k2-thinking', 'kimi-k2-turbo-preview', 'kimi-k2-0905-preview']), hermesProvider('kimi-coding-cn', 'Kimi / Moonshot (China)', 'api_key', 'https://api.moonshot.cn/v1', '', ['KIMI_CN_API_KEY'], 'openai_chat', 'openai', ['kimi-k2.7-code', 'kimi-for-coding', 'kimi-k2.6', 'kimi-k2.5', 'kimi-k2-thinking', 'kimi-k2-turbo-preview']), - hermesProvider('alibaba', 'Alibaba Cloud (DashScope)', 'api_key', 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', 'DASHSCOPE_BASE_URL', ['DASHSCOPE_API_KEY'], 'openai_chat', 'openai', ['qwen3.5-plus', 'qwen3-coder-plus', 'qwen3-coder-next', 'glm-5.2', 'glm-5', 'glm-4.7', 'kimi-k2.7-code', 'kimi-k2.5', 'MiniMax-M2.5']), + hermesProvider('alibaba', 'Qwen Cloud', 'api_key', 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', 'DASHSCOPE_BASE_URL', ['DASHSCOPE_API_KEY'], 'openai_chat', 'openai', ['qwen3.5-plus', 'qwen3-coder-plus', 'qwen3-coder-next', 'glm-5.2', 'glm-5', 'glm-4.7', 'kimi-k2.7-code', 'kimi-k2.5', 'MiniMax-M2.5']), hermesProvider('alibaba-coding-plan', 'Alibaba Cloud (Coding Plan)', 'api_key', 'https://coding-intl.dashscope.aliyuncs.com/v1', 'ALIBABA_CODING_PLAN_BASE_URL', ['ALIBABA_CODING_PLAN_API_KEY', 'DASHSCOPE_API_KEY'], 'openai_chat', 'openai', ['qwen3-coder-plus', 'qwen3-coder-next', 'qwen3.5-plus', 'qwen3.5-coder']), hermesProvider('minimax-cn', 'MiniMax (China)', 'api_key', 'https://api.minimaxi.com/v1', 'MINIMAX_CN_BASE_URL', ['MINIMAX_CN_API_KEY'], 'anthropic_messages', 'anthropic', ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed']), hermesProvider('xiaomi', 'Xiaomi MiMo', 'api_key', 'https://api.xiaomimimo.com/v1', 'XIAOMI_BASE_URL', ['XIAOMI_API_KEY'], 'openai_chat', 'openai', ['mimo-v2-pro', 'mimo-v2-omni', 'mimo-v2-flash']), @@ -231,6 +233,54 @@ function gitMirrorEnv() { } } +export function buildHermesInstallEnv(config = {}, baseEnv = process.env) { + const env = { + ...baseEnv, + PATH: hermesEnhancedPath(), + GIT_TERMINAL_PROMPT: '0', + } + const pypiMirror = String(config?.pypiMirror || '').trim() + if (pypiMirror) { + env.UV_DEFAULT_INDEX = pypiMirror + env.PIP_INDEX_URL = pypiMirror + } + let gitMirror = String(config?.gitMirror || '').trim() + if (gitMirror) { + if (!gitMirror.endsWith('/')) gitMirror += '/' + env.GIT_CONFIG_COUNT = '1' + env.GIT_CONFIG_KEY_0 = `url.${gitMirror}https://github.com/.insteadOf` + env.GIT_CONFIG_VALUE_0 = 'https://github.com/' + } + return env +} + +export function runHermesInstallCommand(program, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(program, args, { + ...options, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let stdout = '' + let stderr = '' + const append = (current, chunk) => (current + String(chunk)).slice(-1024 * 1024) + child.stdout?.on('data', chunk => { stdout = append(stdout, chunk) }) + child.stderr?.on('data', chunk => { stderr = append(stderr, chunk) }) + const timeoutMs = Number(options.timeout) || 600000 + const timer = setTimeout(() => { + child.kill() + reject(new Error(`Hermes 安装超时(${timeoutMs}ms)`)) + }, timeoutMs) + child.once('error', error => { + clearTimeout(timer) + reject(error) + }) + child.once('close', status => { + clearTimeout(timer) + resolve({ status, stdout, stderr }) + }) + }) +} + // 判断输出是否命中 「网络无法访问」 类失败,命中返回建议文案。 function diagnoseHermesInstallError(text = '') { const lower = String(text || '').toLowerCase() @@ -8778,7 +8828,7 @@ const ALWAYS_LOCAL = new Set([ 'assistant_list_dir', 'assistant_system_info', 'assistant_list_processes', 'assistant_check_port', 'assistant_web_search', 'assistant_fetch_url', 'assistant_ensure_data_dir', 'assistant_save_image', 'assistant_load_image', 'assistant_delete_image', - 'read_model_channels', 'write_model_channels', 'reveal_model_channel_key', + 'read_model_channels', 'write_model_channels', 'reveal_model_channel_key', 'hermes_sync_provider', 'read_media_config', 'write_media_config', 'test_media_provider', 'fetch_media_models', 'generate_image', 'create_video_task', 'poll_video_task', 'cancel_media_job', 'list_media_jobs', 'delete_media_job', @@ -9238,6 +9288,102 @@ function mediaApiKeyMask(key) { return `${value.slice(0, 3)}***${value.slice(-4)}` } +function replaceHermesFilesTransaction(entries) { + const suffix = `${process.pid}-${Date.now()}-${crypto.randomBytes(4).toString('hex')}` + const staged = entries.map(({ file, content, mode }) => ({ + file, + content, + mode, + temp: `${file}.tmp-${suffix}`, + backup: `${file}.bak-sync-${suffix}`, + existed: fs.existsSync(file), + installed: false, + })) + + try { + for (const entry of staged) { + fs.mkdirSync(path.dirname(entry.file), { recursive: true }) + fs.writeFileSync(entry.temp, entry.content, entry.mode ? { mode: entry.mode } : undefined) + } + for (const entry of staged) { + if (entry.existed) fs.renameSync(entry.file, entry.backup) + fs.renameSync(entry.temp, entry.file) + entry.installed = true + } + for (const entry of staged) { + if (entry.existed) fs.rmSync(entry.backup, { force: true }) + } + } catch (error) { + for (const entry of [...staged].reverse()) { + try { + if (entry.installed) fs.rmSync(entry.file, { force: true }) + if (entry.existed && fs.existsSync(entry.backup)) fs.renameSync(entry.backup, entry.file) + } catch {} + try { fs.rmSync(entry.temp, { force: true }) } catch {} + } + throw error + } finally { + for (const entry of staged) { + try { fs.rmSync(entry.temp, { force: true }) } catch {} + } + } +} + +export function syncHermesProviderFilesAt(home, { + provider, + apiKey, + baseUrl = '', + model = '', + setDefault = false, +} = {}) { + const providerId = String(provider || '').trim() + const key = String(apiKey || '').trim() + const config = HERMES_PROVIDER_REGISTRY.find(item => item.id === providerId) + if (!config || config.authType !== 'api_key' || !config.apiKeyEnvVars?.length) { + throw new Error(`Hermes Provider 不支持 API Key 同步: ${providerId || '-'}`) + } + if (!key) throw new Error('Hermes Provider API Key 不能为空') + + const replaceKeys = new Set(config.apiKeyEnvVars) + if (config.baseUrlEnvVar) replaceKeys.add(config.baseUrlEnvVar) + const pairs = [[config.apiKeyEnvVars[0], key]] + if (providerId === 'custom') { + replaceKeys.add('CUSTOM_API_KEY') + pairs.push(['CUSTOM_API_KEY', key]) + } + const normalizedBaseUrl = String(baseUrl || '').trim().replace(/\/+$/, '') + if (config.baseUrlEnvVar && normalizedBaseUrl) { + pairs.push([config.baseUrlEnvVar, normalizedBaseUrl]) + } + + const envPath = path.join(home, '.env') + const currentEnv = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : '' + const envContent = _mergeEnvFile(currentEnv, [...replaceKeys], pairs) + const entries = [{ file: envPath, content: envContent, mode: 0o600 }] + + if (setDefault) { + const modelId = String(model || '').trim() + if (!modelId) throw new Error('设为默认模型时 model 不能为空') + const configPath = path.join(home, 'config.yaml') + const currentConfig = fs.existsSync(configPath) + ? fs.readFileSync(configPath, 'utf8') + : 'platform_toolsets:\n api_server:\n - hermes-api-server\nterminal:\n backend: local\nplatforms:\n api_server:\n enabled: true\n' + const baseUrlLine = normalizedBaseUrl && !config.baseUrlEnvVar + ? ` base_url: ${normalizedBaseUrl}\n` + : '' + const configContent = _mergeHermesConfigYaml( + currentConfig, + modelId, + baseUrlLine, + ` provider: ${providerId}\n`, + ) + entries.push({ file: configPath, content: configContent }) + } + + replaceHermesFilesTransaction(entries) + return { providerId, envKey: config.apiKeyEnvVars[0] } +} + // === 统一模型渠道(与 src-tauri/src/commands/model_channels.rs 行为保持一致) === const MODEL_CHANNELS_FILE = 'model-channels.json' @@ -9784,6 +9930,34 @@ function writeMediaAssetBytes(kind, jobId, index, buffer, mime, sourceUrl = '') return { kind, path: rel, root, mime, bytes: buffer.length, sourceUrl } } +function mediaContentLengthExceedsLimit(headers) { + const raw = headers?.get?.('content-length') + if (!raw) return false + const size = Number(raw) + return Number.isFinite(size) && size > MEDIA_MAX_ASSET_BYTES +} + +async function readMediaResponseBuffer(resp, label = '媒体资产') { + if (mediaContentLengthExceedsLimit(resp.headers)) throw new Error('媒体文件超过 512MB,已停止保存') + const reader = resp.body?.getReader?.() + if (!reader) { + const fallback = Buffer.from(await resp.arrayBuffer()) + if (fallback.length > MEDIA_MAX_ASSET_BYTES) throw new Error('媒体文件超过 512MB,已停止保存') + return fallback + } + const chunks = [] + let total = 0 + while (true) { + const { done, value } = await reader.read() + if (done) break + const chunk = Buffer.from(value) + total += chunk.length + if (total > MEDIA_MAX_ASSET_BYTES) throw new Error('媒体文件超过 512MB,已停止保存') + chunks.push(chunk) + } + return Buffer.concat(chunks, total) +} + async function fetchWithTimeout(url, options = {}, timeoutSeconds = 600) { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeoutSeconds * 1000) @@ -9812,34 +9986,127 @@ function sanitizeProviderError(raw, apiKey) { // 防止服务商响应中混入第三方 URL 后把密钥发给任意主机 function mediaAssetUrlSameHost(url, baseUrl) { try { - return new URL(url).host.toLowerCase() === new URL(baseUrl).host.toLowerCase() + return new URL(url).origin.toLowerCase() === new URL(baseUrl).origin.toLowerCase() } catch { return false } } -async function downloadMediaAsset(provider, url, kind, jobId, index) { - const sameHost = mediaAssetUrlSameHost(url, provider.baseUrl) - let resp = await fetchWithTimeout(url, sameHost ? { - headers: { Authorization: `Bearer ${provider.apiKey}` }, - } : {}, provider.timeoutSeconds) - if (!resp.ok && sameHost && [401, 403].includes(resp.status)) { - resp = await fetchWithTimeout(url, {}, provider.timeoutSeconds) +export async function downloadMediaUrlToFile({ + url, + baseUrl, + apiKey, + target, + timeoutSeconds = 600, + maxBytes = MEDIA_MAX_ASSET_BYTES, +}) { + const controller = new AbortController() + const timeoutMs = Math.max(1, Number(timeoutSeconds) * 1000) + const timer = setTimeout(() => controller.abort(), timeoutMs) + let currentUrl = String(url) + let response = null + let tempPath = '' + let output = null + let retriedWithoutAuth = false + try { + for (let redirects = 0; redirects <= 5; redirects += 1) { + const sendAuth = !retriedWithoutAuth && !!apiKey && mediaAssetUrlSameHost(currentUrl, baseUrl) + response = await fetch(currentUrl, { + redirect: 'manual', + signal: controller.signal, + headers: sendAuth ? { Authorization: `Bearer ${apiKey}` } : {}, + }) + if ([301, 302, 303, 307, 308].includes(response.status)) { + const location = response.headers.get('location') + await response.body?.cancel().catch(() => {}) + if (!location) throw new Error('媒体资产重定向缺少 Location') + if (redirects === 5) throw new Error('媒体资产重定向次数过多') + currentUrl = new URL(location, currentUrl).toString() + retriedWithoutAuth = false + continue + } + if (sendAuth && [401, 403].includes(response.status) && !retriedWithoutAuth) { + await response.body?.cancel().catch(() => {}) + retriedWithoutAuth = true + continue + } + break + } + if (!response?.ok) throw new Error(`下载媒体资产失败: HTTP ${response?.status || 0}`) + if (mediaContentLengthExceedsLimit(response.headers)) throw new Error('媒体文件超过 512MB,已停止保存') + + const mime = response.headers.get('content-type') || 'application/octet-stream' + output = typeof target === 'function' ? target(mime) : target + if (!output) throw new Error('媒体资产目标路径为空') + fs.mkdirSync(path.dirname(output), { recursive: true }) + tempPath = `${output}.part-${crypto.randomUUID()}` + const writer = fs.createWriteStream(tempPath, { flags: 'wx' }) + let total = 0 + try { + await once(writer, 'open') + const reader = response.body?.getReader() + if (!reader) throw new Error('媒体资产响应体不可读') + while (true) { + const { done, value } = await reader.read() + if (done) break + total += value.byteLength + if (total > maxBytes) { + await reader.cancel().catch(() => {}) + throw new Error('媒体文件超过 512MB,已停止保存') + } + if (!writer.write(Buffer.from(value))) await once(writer, 'drain') + } + writer.end() + await once(writer, 'finish') + } catch (error) { + writer.destroy() + if (!writer.closed) await once(writer, 'close').catch(() => {}) + throw error + } + if (fs.existsSync(output)) fs.rmSync(output, { force: true }) + fs.renameSync(tempPath, output) + tempPath = '' + return { path: output, bytes: total, mime, sourceUrl: currentUrl } + } catch (error) { + if (controller.signal.aborted) throw new Error(`媒体资产下载超时(${timeoutMs}ms)`) + throw error + } finally { + clearTimeout(timer) + if (tempPath) fs.rmSync(tempPath, { force: true }) } - if (!resp.ok) throw new Error(`下载媒体资产失败: HTTP ${resp.status}`) - const mime = resp.headers.get('content-type') || (kind === 'video' ? 'video/mp4' : 'image/jpeg') - const bytes = Buffer.from(await resp.arrayBuffer()) - return writeMediaAssetBytes(kind, jobId, index, bytes, mime, url) +} + +async function downloadMediaAsset(provider, url, kind, jobId, index) { + const root = ensureMediaOutputRoot() + let rel = '' + const saved = await downloadMediaUrlToFile({ + url, + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + timeoutSeconds: provider.timeoutSeconds, + target: mime => { + const ext = contentTypeToExt(mime, kind) + rel = mediaAssetRelativePath(jobId, index, ext) + return resolveMediaPath(rel, root) + }, + }) + return { kind, path: rel, root, mime: saved.mime, bytes: saved.bytes, sourceUrl: url } } async function downloadOpenAIVideoContent(provider, providerTaskId, jobId, index) { - const resp = await fetchWithTimeout(buildMediaApiUrl(provider.baseUrl, `/videos/${providerTaskId}/content`), { - headers: { Authorization: `Bearer ${provider.apiKey}` }, - }, provider.timeoutSeconds) - if (!resp.ok) throw new Error(`下载视频内容失败: HTTP ${resp.status}`) - const mime = resp.headers.get('content-type') || 'video/mp4' - const bytes = Buffer.from(await resp.arrayBuffer()) - return writeMediaAssetBytes('video', jobId, index, bytes, mime) + const root = ensureMediaOutputRoot() + let rel = '' + const saved = await downloadMediaUrlToFile({ + url: buildMediaApiUrl(provider.baseUrl, `/videos/${providerTaskId}/content`), + baseUrl: provider.baseUrl, + apiKey: provider.apiKey, + timeoutSeconds: provider.timeoutSeconds, + target: mime => { + rel = mediaAssetRelativePath(jobId, index, contentTypeToExt(mime, 'video')) + return resolveMediaPath(rel, root) + }, + }) + return { kind: 'video', path: rel, root, mime: saved.mime, bytes: saved.bytes, sourceUrl: '' } } function collectImageOutputs(value) { @@ -13965,6 +14232,9 @@ const handlers = { }, async install_hermes({ method = 'uv-tool', extras = [] } = {}) { + if (_hermesInstallRunning) throw new Error('Hermes Agent 正在安装,请勿重复操作') + _hermesInstallRunning = true + try { // 1. 查找 uv const uvPath = path.join(uvBinDir(), isWindows ? 'uv.exe' : 'uv') let uv = fs.existsSync(uvPath) ? uvPath : null @@ -13975,12 +14245,10 @@ const handlers = { const installArgs = method === 'uv-pip' ? ['pip', 'install', pkg] : ['tool', 'install', '--force', pkg, '--python', '3.11', '--with', 'croniter', '--with', 'httpx', '--with', 'openai', '--with', 'aiohttp', '--with', 'websockets'] - const result = spawnSync(uv, installArgs, { - env: { ...process.env, PATH: hermesEnhancedPath(), GIT_TERMINAL_PROMPT: '0', ...gitMirrorEnv() }, + const result = await runHermesInstallCommand(uv, installArgs, { + env: buildHermesInstallEnv(readPanelConfig()), timeout: 600000, windowsHide: true, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], }) if (result.status !== 0) { const cleaned = sanitizeHermesInstallOutput((result.stderr || '').trim()) @@ -13992,6 +14260,9 @@ const handlers = { const ver = runHermesSilent('hermes', ['version']) if (ver.ok) return ver.stdout throw new Error('安装完成但验证失败: hermes version 不可用') + } finally { + _hermesInstallRunning = false + } }, async configure_hermes({ provider, apiKey, model, baseUrl } = {}) { @@ -14120,6 +14391,18 @@ const handlers = { return await resp.json() }, + async hermes_probe_gateway({ url } = {}) { + const normalized = String(url || '').trim().replace(/\/+$/, '') + const parsed = new URL(normalized) + if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('Gateway URL 仅支持 HTTP/HTTPS') + const resp = await globalThis.fetch(`${normalized}/health`, { + signal: AbortSignal.timeout(5000), + headers: { 'User-Agent': 'ClawPanel-Web' }, + }) + if (!resp.ok) throw new Error(`Gateway 返回 HTTP ${resp.status}`) + return await resp.json() + }, + async hermes_capabilities() { const url = `${hermesGatewayUrl()}/v1/capabilities` const resp = await globalThis.fetch(url, { signal: AbortSignal.timeout(5000), headers: { 'User-Agent': 'ClawPanel-Web' } }) @@ -15551,6 +15834,16 @@ const handlers = { return null }, + hermes_sync_provider({ provider, apiKey, baseUrl, model, setDefault } = {}) { + return syncHermesProviderFilesAt(hermesHome(), { + provider, + apiKey, + baseUrl, + model, + setDefault: Boolean(setDefault), + }) + }, + hermes_env_delete({ key } = {}) { key = (key || '').trim() if (!key) throw new Error('Key cannot be empty') diff --git a/scripts/smoke-portable-usb.ps1 b/scripts/smoke-portable-usb.ps1 index 67cc852..5196df7 100644 --- a/scripts/smoke-portable-usb.ps1 +++ b/scripts/smoke-portable-usb.ps1 @@ -5,9 +5,8 @@ param( # 模拟模式:用 subst 虚拟盘符 + 桩 CLI(默认,无副作用,用于 CI/本机快速验证布局约定) [string]$DriveLetter = "", - # 真实模式:指定真实 U 盘路径(如 F:\),只做无损校验—— - # 补建缺失目录、portable.json 缺失时补种,绝不覆盖任何已有文件、绝不写桩 CLI; - # 对盘上真实存在的组件(openclaw/hermes/uv/git/node)逐一实测版本 + # 真实模式:指定真实 U 盘路径(如 F:\),严格只读校验,不创建目录、不补配置、不写桩。 + # openclaw/hermes/uv/git/node 任一缺失或执行失败都会令 ok=false 且退出码非零。 [string]$UsbPath = "", [string]$RootName = "ClawPanelPortable", [switch]$Keep @@ -69,6 +68,60 @@ try { $uvBin = Join-Path $usbRoot "runtimes\uv\bin" $gitCmd = Join-Path $usbRoot "runtimes\git\cmd" + if ($realMode) { + # ===== 真实模式:严格只读,逐组件实测 ===== + function Test-Component { + param([string]$Path, [string[]]$CmdArgs) + if (-not (Test-Path -LiteralPath $Path)) { + return [pscustomobject]@{ ok = $false; status = "missing"; output = $null } + } + try { + $out = & $Path @CmdArgs 2>&1 | Select-Object -First 1 + if ($LASTEXITCODE -ne 0) { + return [pscustomobject]@{ ok = $false; status = "error"; output = ($out -join " ") } + } + return [pscustomobject]@{ ok = $true; status = "ok"; output = ($out -join " ") } + } catch { + return [pscustomobject]@{ ok = $false; status = "error"; output = $_.Exception.Message } + } + } + + $manifest = $null + $manifestOk = $false + try { + $raw = Get-Content -LiteralPath (Join-Path $usbRoot "portable.json") -Raw + $manifest = $raw -replace "^", "" | ConvertFrom-Json + $manifestOk = ($manifest.mode -eq "portable") + } catch {} + + $fsName = "unknown" + try { $fsName = (Get-Volume -FilePath $usbRoot -ErrorAction Stop).FileSystem } catch {} + + $components = [ordered]@{ + openclaw = Test-Component -Path (Join-Path $openclawEngine "openclaw.cmd") -CmdArgs @("--version") + hermes = Test-Component -Path (Join-Path $hermesBin "hermes.cmd") -CmdArgs @("version") + node = Test-Component -Path (Join-Path $openclawEngine "node.exe") -CmdArgs @("--version") + uv = Test-Component -Path (Join-Path $uvBin "uv.exe") -CmdArgs @("--version") + git = Test-Component -Path (Join-Path $gitCmd "git.exe") -CmdArgs @("--version") + } + $componentsOk = -not ($components.Values | Where-Object { -not $_.ok } | Select-Object -First 1) + $result = [pscustomobject]@{ + ok = ($manifestOk -and $componentsOk) + mode = "real-usb" + fileSystem = $fsName + usbRoot = $usbRoot + manifestMode = $manifest.mode + openclaw = $components.openclaw + hermes = $components.hermes + node = $components.node + uv = $components.uv + git = $components.git + } + $result | ConvertTo-Json -Depth 4 + if (-not $result.ok) { exit 1 } + return + } + foreach ($dir in @($panelDir, $openclawDir, $hermesHome, $openclawEngine, $hermesBin, $uvBin, $gitCmd)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } @@ -88,45 +141,6 @@ try { $null = Set-ContentIfAbsent -Path (Join-Path $openclawDir "openclaw.json") -Value '{ "gateway": { "port": 18789 }, "agents": { "main": { "name": "main" } } }' $null = Set-ContentIfAbsent -Path (Join-Path $hermesHome "config.yaml") -Value "model: smoke" - if ($realMode) { - # ===== 真实模式:无损校验,逐组件实测(存在才测,不存在报 missing)===== - function Test-Component { - param([string]$Path, [string[]]$CmdArgs) - if (-not (Test-Path -LiteralPath $Path)) { return "(missing)" } - try { - $out = & $Path @CmdArgs 2>&1 | Select-Object -First 1 - return ($out -join " ") - } catch { - return "(error) $($_.Exception.Message)" - } - } - - $manifest = $null - $manifestOk = $false - try { - $raw = Get-Content -LiteralPath (Join-Path $usbRoot "portable.json") -Raw - $manifest = $raw -replace "^", "" | ConvertFrom-Json - $manifestOk = ($manifest.mode -eq "portable") - } catch {} - - $fsName = "unknown" - try { $fsName = (Get-Volume -FilePath $usbRoot -ErrorAction Stop).FileSystem } catch {} - - [pscustomobject]@{ - ok = $manifestOk - mode = "real-usb" - fileSystem = $fsName - usbRoot = $usbRoot - manifestMode = $manifest.mode - openclaw = Test-Component -Path (Join-Path $openclawEngine "openclaw.cmd") -CmdArgs @("--version") - hermes = Test-Component -Path (Join-Path $hermesBin "hermes.cmd") -CmdArgs @("version") - node = Test-Component -Path (Join-Path $openclawEngine "node.exe") -CmdArgs @("--version") - uv = Test-Component -Path (Join-Path $uvBin "uv.exe") -CmdArgs @("--version") - git = Test-Component -Path (Join-Path $gitCmd "git.exe") -CmdArgs @("--version") - } | ConvertTo-Json -Depth 4 - return - } - # ===== 模拟模式:写桩 CLI,验证布局与 PATH 约定 ===== @( "@echo off", diff --git a/scripts/sync-version.js b/scripts/sync-version.js index 6ad6f2d..911097e 100644 --- a/scripts/sync-version.js +++ b/scripts/sync-version.js @@ -83,6 +83,7 @@ const targets = [ ] let changed = 0 +let failed = 0 for (const { file, update } of targets) { const filepath = resolve(root, file) try { @@ -97,7 +98,11 @@ for (const { file, update } of targets) { } } catch (e) { console.error(`❌ ${file}: ${e.message}`) + failed++ } } console.log(`\n版本 ${version},${changed ? `已同步 ${changed} 个文件` : '所有文件已是最新'}`) +if (failed > 0) { + process.exitCode = 1 +} diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 0163786..ebe2708 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -29,6 +29,7 @@ static GW_GUARDIAN_ACTIVE: AtomicBool = AtomicBool::new(false); /// 通知 guardian 停止的 flag static GW_GUARDIAN_STOP: AtomicBool = AtomicBool::new(false); static GW_STARTING: AtomicBool = AtomicBool::new(false); +static HERMES_INSTALLING: AtomicBool = AtomicBool::new(false); /// 缓存 AppHandle 供 guardian 发送事件 static GW_APP_HANDLE: OnceLock = OnceLock::new(); @@ -47,6 +48,21 @@ fn try_gateway_start_guard() -> Option { .map(|_| GatewayStartGuard) } +struct HermesInstallGuard; + +impl Drop for HermesInstallGuard { + fn drop(&mut self) { + HERMES_INSTALLING.store(false, Ordering::SeqCst); + } +} + +fn try_hermes_install_guard() -> Option { + HERMES_INSTALLING + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .ok() + .map(|_| HermesInstallGuard) +} + /// 获取 Gateway 的完整 URL(当前本地,未来可扩展为远程) fn hermes_gateway_custom_url() -> Option { super::read_panel_config_value() @@ -1835,6 +1851,7 @@ pub async fn install_hermes( method: String, extras: Vec, ) -> Result { + let _install_guard = try_hermes_install_guard().ok_or("Hermes Agent 正在安装,请勿重复操作")?; let _ = app.emit("hermes-install-log", "🚀 开始安装 Hermes Agent..."); let _ = app.emit("hermes-install-progress", 0u32); @@ -3087,6 +3104,152 @@ fn merge_env_file(existing: &str, managed_keys: &[&str], new_pairs: &[(String, S content } +fn replace_hermes_files_transaction(entries: &[(PathBuf, String, bool)]) -> Result<(), String> { + let suffix = format!( + "{}-{}", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + ); + let mut staged: Vec<(PathBuf, PathBuf, PathBuf, bool, bool)> = Vec::new(); + + for (file, content, _private) in entries { + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("创建 Hermes 配置目录失败: {e}"))?; + } + let temp = file.with_extension(format!("tmp-{suffix}")); + let backup = file.with_extension(format!("bak-sync-{suffix}")); + std::fs::write(&temp, content).map_err(|e| format!("写入临时配置失败: {e}"))?; + #[cfg(not(target_os = "windows"))] + if *private { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&temp, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("设置临时配置权限失败: {e}"))?; + } + staged.push((file.clone(), temp, backup, file.exists(), false)); + } + + let result = (|| { + for entry in &mut staged { + if entry.3 { + std::fs::rename(&entry.0, &entry.2) + .map_err(|e| format!("备份 Hermes 配置失败: {e}"))?; + } + std::fs::rename(&entry.1, &entry.0) + .map_err(|e| format!("提交 Hermes 配置失败: {e}"))?; + entry.4 = true; + } + Ok(()) + })(); + + if let Err(error) = result { + for entry in staged.iter().rev() { + if entry.4 { + let _ = std::fs::remove_file(&entry.0); + } + if entry.3 && entry.2.exists() { + let _ = std::fs::rename(&entry.2, &entry.0); + } + let _ = std::fs::remove_file(&entry.1); + } + return Err(error); + } + + for entry in &staged { + if entry.3 { + let _ = std::fs::remove_file(&entry.2); + } + } + Ok(()) +} + +fn sync_hermes_provider_files_at( + home: &Path, + provider: &str, + api_key: &str, + base_url: Option<&str>, + model: Option<&str>, + set_default: bool, +) -> Result { + use super::hermes_providers; + + let provider = provider.trim(); + let api_key = api_key.trim(); + let config = hermes_providers::get_provider(provider) + .filter(|item| item.auth_type == hermes_providers::AUTH_API_KEY) + .ok_or_else(|| format!("Hermes Provider 不支持 API Key 同步: {provider}"))?; + let env_key = config + .api_key_env_vars + .first() + .copied() + .ok_or_else(|| format!("Hermes Provider 缺少 API Key 环境变量: {provider}"))?; + if api_key.is_empty() { + return Err("Hermes Provider API Key 不能为空".into()); + } + + let mut replace_keys: Vec<&str> = config.api_key_env_vars.to_vec(); + if !config.base_url_env_var.is_empty() { + replace_keys.push(config.base_url_env_var); + } + let mut pairs = vec![(env_key.to_string(), api_key.to_string())]; + if provider == "custom" { + if !replace_keys.contains(&"CUSTOM_API_KEY") { + replace_keys.push("CUSTOM_API_KEY"); + } + pairs.push(("CUSTOM_API_KEY".into(), api_key.into())); + } + let normalized_base = base_url.unwrap_or_default().trim().trim_end_matches('/'); + if !config.base_url_env_var.is_empty() && !normalized_base.is_empty() { + pairs.push((config.base_url_env_var.into(), normalized_base.into())); + } + + let env_path = home.join(".env"); + let current_env = std::fs::read_to_string(&env_path).unwrap_or_default(); + let env_content = merge_env_file(¤t_env, &replace_keys, &pairs); + let mut entries = vec![(env_path, env_content, true)]; + + if set_default { + let model = model.unwrap_or_default().trim(); + if model.is_empty() { + return Err("设为默认模型时 model 不能为空".into()); + } + let config_path = home.join("config.yaml"); + let current_config = std::fs::read_to_string(&config_path).unwrap_or_else(|_| { + "platform_toolsets:\n api_server:\n - hermes-api-server\nterminal:\n backend: local\nplatforms:\n api_server:\n enabled: true\n".into() + }); + let base_url_line = if !normalized_base.is_empty() && config.base_url_env_var.is_empty() { + format!(" base_url: {normalized_base}\n") + } else { + String::new() + }; + let provider_line = format!(" provider: {provider}\n"); + let config_content = + merge_hermes_config_yaml(¤t_config, model, &base_url_line, &provider_line); + entries.push((config_path, config_content, false)); + } + + replace_hermes_files_transaction(&entries)?; + Ok(serde_json::json!({ "providerId": provider, "envKey": env_key })) +} + +#[tauri::command] +pub fn hermes_sync_provider( + provider: String, + api_key: String, + base_url: Option, + model: Option, + set_default: bool, +) -> Result { + sync_hermes_provider_files_at( + &hermes_home(), + &provider, + &api_key, + base_url.as_deref(), + model.as_deref(), + set_default, + ) +} + // --------------------------------------------------------------------------- // Hermes 渠道配置 — 读写 ~/.hermes/config.yaml 的 platforms., // 并同步 Hermes 运行时仍会读取的 .env 变量。 @@ -14136,6 +14299,29 @@ pub async fn hermes_health_check() -> Result { } } +#[tauri::command] +pub async fn hermes_probe_gateway(url: String) -> Result { + let normalized = url.trim().trim_end_matches('/'); + let parsed = reqwest::Url::parse(normalized).map_err(|e| format!("Gateway URL 无效: {e}"))?; + if parsed.scheme() != "http" && parsed.scheme() != "https" { + return Err("Gateway URL 仅支持 HTTP/HTTPS".into()); + } + let client = hermes_gateway_http_client(std::time::Duration::from_secs(5)) + .map_err(|e| format!("HTTP 客户端创建失败: {e}"))?; + let response = client + .get(format!("{normalized}/health")) + .send() + .await + .map_err(|e| format!("Gateway 不可达: {e}"))?; + if !response.status().is_success() { + return Err(format!("Gateway 返回 HTTP {}", response.status())); + } + response + .json() + .await + .map_err(|e| format!("Gateway 响应无效: {e}")) +} + // --------------------------------------------------------------------------- // hermes_capabilities — 探测 Gateway 暴露的 API 能力描述(GET /v1/capabilities) // @@ -25981,3 +26167,84 @@ platforms: assert!(err.contains("display.platforms.telegram.streaming")); } } +#[cfg(test)] +mod hermes_provider_sync_tests { + use super::sync_hermes_provider_files_at; + use std::path::PathBuf; + + fn temp_home(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "clawpanel-hermes-provider-sync-{tag}-{}", + std::process::id() + )) + } + + #[test] + fn provider_sync_preserves_other_provider_credentials_and_updates_target() { + let home = temp_home("preserve"); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).unwrap(); + std::fs::write( + home.join(".env"), + "ANTHROPIC_API_KEY=keep-me\nOPENAI_API_KEY=old\nCUSTOM_FLAG=keep\n", + ) + .unwrap(); + std::fs::write( + home.join("config.yaml"), + "model:\n default: old-model\n provider: anthropic\nlogging:\n level: INFO\n", + ) + .unwrap(); + + sync_hermes_provider_files_at( + &home, + "custom", + "sk-new", + Some("https://gateway.example/v1"), + Some("gpt-test"), + true, + ) + .unwrap(); + + let env = std::fs::read_to_string(home.join(".env")).unwrap(); + assert!(env.contains("ANTHROPIC_API_KEY=keep-me")); + assert!(env.contains("CUSTOM_FLAG=keep")); + assert!(env.contains("OPENAI_API_KEY=sk-new")); + assert!(env.contains("CUSTOM_API_KEY=sk-new")); + assert!(env.contains("OPENAI_BASE_URL=https://gateway.example/v1")); + + let config = std::fs::read_to_string(home.join("config.yaml")).unwrap(); + assert!(config.contains("default: gpt-test")); + assert!(config.contains("provider: custom")); + assert!(config.contains("level: INFO")); + + let _ = std::fs::remove_dir_all(&home); + } + + #[test] + fn provider_sync_without_default_keeps_model_config_unchanged() { + let home = temp_home("no-default"); + let _ = std::fs::remove_dir_all(&home); + std::fs::create_dir_all(&home).unwrap(); + let original = "model:\n default: old-model\n provider: anthropic\n"; + std::fs::write(home.join("config.yaml"), original).unwrap(); + + sync_hermes_provider_files_at( + &home, + "anthropic", + "sk-ant", + None, + Some("claude-test"), + false, + ) + .unwrap(); + + assert_eq!( + std::fs::read_to_string(home.join("config.yaml")).unwrap(), + original + ); + let env = std::fs::read_to_string(home.join(".env")).unwrap(); + assert!(env.contains("ANTHROPIC_API_KEY=sk-ant")); + + let _ = std::fs::remove_dir_all(&home); + } +} diff --git a/src-tauri/src/commands/hermes_providers.rs b/src-tauri/src/commands/hermes_providers.rs index a9881d9..b4c5e7b 100644 --- a/src-tauri/src/commands/hermes_providers.rs +++ b/src-tauri/src/commands/hermes_providers.rs @@ -147,6 +147,7 @@ const P_ZAI: HermesProvider = HermesProvider { transport: TRANSPORT_OPENAI_CHAT, models_probe: PROBE_OPENAI, models: &[ + "glm-5.2", "glm-5.1", "glm-5", "glm-5v-turbo", @@ -169,6 +170,7 @@ const P_KIMI_CODING: HermesProvider = HermesProvider { transport: TRANSPORT_OPENAI_CHAT, models_probe: PROBE_OPENAI, models: &[ + "kimi-k2.7-code", "kimi-for-coding", "kimi-k2.6", "kimi-k2.5", @@ -190,6 +192,7 @@ const P_KIMI_CODING_CN: HermesProvider = HermesProvider { transport: TRANSPORT_OPENAI_CHAT, models_probe: PROBE_OPENAI, models: &[ + "kimi-k2.7-code", "kimi-for-coding", "kimi-k2.6", "kimi-k2.5", @@ -271,8 +274,10 @@ const P_ALIBABA: HermesProvider = HermesProvider { "qwen3.5-plus", "qwen3-coder-plus", "qwen3-coder-next", + "glm-5.2", "glm-5", "glm-4.7", + "kimi-k2.7-code", "kimi-k2.5", "MiniMax-M2.5", ], diff --git a/src-tauri/src/commands/media.rs b/src-tauri/src/commands/media.rs index 7f6e6ad..9c879db 100644 --- a/src-tauri/src/commands/media.rs +++ b/src-tauri/src/commands/media.rs @@ -1,8 +1,10 @@ use base64::{engine::general_purpose, Engine as _}; +use futures_util::StreamExt; use serde_json::{json, Map, Value}; use std::path::{Component, Path, PathBuf}; use std::sync::Mutex; use std::time::Duration; +use tokio::io::AsyncWriteExt; /// media-jobs.json 的读改写锁:并发轮询/写入时防止互相覆盖 static MEDIA_JOBS_LOCK: Mutex<()> = Mutex::new(()); @@ -1029,6 +1031,29 @@ fn guess_mime_from_ext(ext: &str, kind: &str) -> &'static str { } } +fn media_content_length_exceeds_limit( + headers: &reqwest::header::HeaderMap, +) -> Result { + let Some(value) = headers.get(reqwest::header::CONTENT_LENGTH) else { + return Ok(false); + }; + let Ok(raw) = value.to_str() else { + return Ok(false); + }; + let Ok(size) = raw.trim().parse::() else { + return Ok(false); + }; + Ok(size > MAX_ASSET_BYTES) +} + +fn ensure_media_content_length_allowed(headers: &reqwest::header::HeaderMap) -> Result<(), String> { + if media_content_length_exceeds_limit(headers)? { + Err("媒体文件超过 512MB,已停止保存".into()) + } else { + Ok(()) + } +} + fn relative_asset_path(_kind: &str, job_id: &str, index: usize, ext: &str) -> PathBuf { PathBuf::from("assets") .join(chrono::Utc::now().format("%Y").to_string()) @@ -1072,14 +1097,20 @@ async fn write_asset_bytes( /// 仅当资产 URL 与服务商 Base URL 同主机同端口时才允许携带 API Key, /// 防止服务商响应中混入第三方 URL 后把密钥发给任意主机 -fn asset_url_same_host(url: &str, base_url: &str) -> bool { +fn asset_url_same_origin(url: &str, base_url: &str) -> bool { let (Ok(asset), Ok(base)) = (reqwest::Url::parse(url), reqwest::Url::parse(base_url)) else { return false; }; - asset.host_str().map(str::to_ascii_lowercase) == base.host_str().map(str::to_ascii_lowercase) + asset.scheme().eq_ignore_ascii_case(base.scheme()) + && asset.host_str().map(str::to_ascii_lowercase) + == base.host_str().map(str::to_ascii_lowercase) && asset.port_or_known_default() == base.port_or_known_default() } +fn should_retry_asset_without_auth(status: reqwest::StatusCode, same_host: bool) -> bool { + same_host && matches!(status.as_u16(), 401 | 403) +} + async fn download_asset_to_media_root( client: &reqwest::Client, provider: &MediaProviderConfig, @@ -1088,18 +1119,73 @@ async fn download_asset_to_media_root( job_id: &str, index: usize, ) -> Result { - let mut request = client.get(url); - if asset_url_same_host(url, &provider.base_url) { - request = request.bearer_auth(&provider.api_key); - } - let resp = request - .send() - .await - .map_err(|e| format!("下载媒体资产失败: {e}"))?; + let (resp, final_url) = fetch_media_asset_response(client, provider, url).await?; if !resp.status().is_success() { return Err(format!("下载媒体资产失败: HTTP {}", resp.status())); } - let mime_owned = resp + stream_response_to_asset( + resp, + kind, + job_id, + index, + Some(&final_url), + provider.timeout_seconds, + ) + .await +} + +async fn fetch_media_asset_response( + client: &reqwest::Client, + provider: &MediaProviderConfig, + url: &str, +) -> Result<(reqwest::Response, String), String> { + let mut current = reqwest::Url::parse(url).map_err(|e| format!("媒体资产 URL 无效: {e}"))?; + let mut retry_without_auth = false; + for redirects in 0..=5 { + let same_origin = asset_url_same_origin(current.as_str(), &provider.base_url); + let send_auth = same_origin && !retry_without_auth && !provider.api_key.is_empty(); + let mut request = client.get(current.clone()); + if send_auth { + request = request.bearer_auth(&provider.api_key); + } + let response = request + .send() + .await + .map_err(|e| format!("下载媒体资产失败: {e}"))?; + if response.status().is_redirection() { + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + .ok_or("媒体资产重定向缺少 Location")?; + if redirects == 5 { + return Err("媒体资产重定向次数过多".into()); + } + current = current + .join(location) + .map_err(|e| format!("媒体资产重定向 URL 无效: {e}"))?; + retry_without_auth = false; + continue; + } + if send_auth && should_retry_asset_without_auth(response.status(), same_origin) { + retry_without_auth = true; + continue; + } + return Ok((response, current.to_string())); + } + Err("媒体资产重定向次数过多".into()) +} + +async fn stream_response_to_asset( + resp: reqwest::Response, + kind: &str, + job_id: &str, + index: usize, + source_url: Option<&str>, + timeout_seconds: u64, +) -> Result { + ensure_media_content_length_allowed(resp.headers())?; + let mime = resp .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) @@ -1111,11 +1197,79 @@ async fn download_asset_to_media_root( } }) .to_string(); - let bytes = resp - .bytes() - .await - .map_err(|e| format!("读取媒体资产失败: {e}"))?; - write_asset_bytes(kind, job_id, index, bytes.as_ref(), &mime_owned, Some(url)).await + let cfg = read_media_config_private(); + let root = ensure_media_output_root_from_config(&cfg)?; + let relative = relative_asset_path( + kind, + job_id, + index, + asset_ext_from_content_type(&mime, kind), + ); + let target = root.join(&relative); + if let Some(parent) = target.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|e| format!("创建媒体资产目录失败: {e}"))?; + } + let temp = target.with_extension(format!( + "{}.part-{}-{}", + target + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("asset"), + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + let operation = async { + let mut file = tokio::fs::File::create(&temp) + .await + .map_err(|e| format!("创建媒体资产临时文件失败: {e}"))?; + let mut stream = resp.bytes_stream(); + let mut total = 0u64; + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("读取媒体资产失败: {e}"))?; + total = total.saturating_add(chunk.len() as u64); + if total > MAX_ASSET_BYTES { + return Err("媒体文件超过 512MB,已停止保存".into()); + } + file.write_all(&chunk) + .await + .map_err(|e| format!("保存媒体资产失败: {e}"))?; + } + file.flush() + .await + .map_err(|e| format!("刷新媒体资产失败: {e}"))?; + drop(file); + if target.exists() { + tokio::fs::remove_file(&target) + .await + .map_err(|e| format!("替换媒体资产失败: {e}"))?; + } + tokio::fs::rename(&temp, &target) + .await + .map_err(|e| format!("提交媒体资产失败: {e}"))?; + Ok::(total) + }; + let result = tokio::time::timeout(Duration::from_secs(timeout_seconds.max(1)), operation).await; + let bytes = match result { + Ok(Ok(bytes)) => bytes, + Ok(Err(error)) => { + let _ = tokio::fs::remove_file(&temp).await; + return Err(error); + } + Err(_) => { + let _ = tokio::fs::remove_file(&temp).await; + return Err("媒体资产下载超时".into()); + } + }; + Ok(json!({ + "kind": kind, + "path": relative.to_string_lossy().replace('\\', "/"), + "root": root.to_string_lossy(), + "mime": mime, + "bytes": bytes, + "sourceUrl": source_url.unwrap_or("") + })) } async fn download_openai_video_content( @@ -1129,26 +1283,19 @@ async fn download_openai_video_content( &provider.base_url, &format!("/videos/{provider_task_id}/content"), ); - let resp = client - .get(endpoint) - .bearer_auth(&provider.api_key) - .send() - .await - .map_err(|e| format!("下载视频内容失败: {e}"))?; + let (resp, final_url) = fetch_media_asset_response(client, provider, &endpoint).await?; if !resp.status().is_success() { return Err(format!("下载视频内容失败: HTTP {}", resp.status())); } - let mime = resp - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("video/mp4") - .to_string(); - let bytes = resp - .bytes() - .await - .map_err(|e| format!("读取视频内容失败: {e}"))?; - write_asset_bytes("video", job_id, index, bytes.as_ref(), &mime, None).await + stream_response_to_asset( + resp, + "video", + job_id, + index, + Some(&final_url), + provider.timeout_seconds, + ) + .await } fn collect_image_outputs(value: &Value) -> Vec { @@ -1214,7 +1361,7 @@ fn sanitize_provider_error(raw: &str, api_key: &str) -> String { } fn media_http_client(timeout_seconds: u64) -> Result { - super::build_http_client_no_proxy( + super::build_http_client_no_proxy_no_redirect( Duration::from_secs(timeout_seconds), Some("ClawPanel Media"), ) @@ -1955,6 +2102,58 @@ mod tests { assert_eq!(provider_status_to_job_status("cancelled"), "canceled"); } + #[test] + fn same_host_asset_download_retries_without_auth_on_auth_rejection() { + assert!(should_retry_asset_without_auth( + reqwest::StatusCode::UNAUTHORIZED, + true + )); + assert!(should_retry_asset_without_auth( + reqwest::StatusCode::FORBIDDEN, + true + )); + assert!(!should_retry_asset_without_auth( + reqwest::StatusCode::UNAUTHORIZED, + false + )); + assert!(!should_retry_asset_without_auth( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + true + )); + } + + #[test] + fn asset_auth_requires_matching_scheme_host_and_port() { + assert!(asset_url_same_origin( + "https://api.example.com/v1/asset", + "https://api.example.com/v1" + )); + assert!(!asset_url_same_origin( + "http://api.example.com/v1/asset", + "https://api.example.com/v1" + )); + assert!(!asset_url_same_origin( + "https://api.example.com:8443/asset", + "https://api.example.com/v1" + )); + } + + #[test] + fn media_download_rejects_oversized_content_length_before_buffering() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::CONTENT_LENGTH, + reqwest::header::HeaderValue::from_static("536870913"), + ); + assert!(media_content_length_exceeds_limit(&headers).unwrap()); + + headers.insert( + reqwest::header::CONTENT_LENGTH, + reqwest::header::HeaderValue::from_static("536870912"), + ); + assert!(!media_content_length_exceeds_limit(&headers).unwrap()); + } + #[test] fn extracts_media_models_from_common_response_shapes() { let openai = json!({ diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ae8470d..69810ef 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -366,7 +366,7 @@ pub fn build_http_client( timeout: Duration, user_agent: Option<&str>, ) -> Result { - build_http_client_opt(timeout, user_agent, true) + build_http_client_opt(timeout, user_agent, true, true) } /// 构建模型请求用的 HTTP 客户端 @@ -378,19 +378,33 @@ pub fn build_http_client_no_proxy( let use_proxy = read_panel_config_value() .and_then(|v| v.get("networkProxy")?.get("proxyModelRequests")?.as_bool()) .unwrap_or(false); - build_http_client_opt(timeout, user_agent, use_proxy) + build_http_client_opt(timeout, user_agent, use_proxy, true) +} + +pub fn build_http_client_no_proxy_no_redirect( + timeout: Duration, + user_agent: Option<&str>, +) -> Result { + let use_proxy = read_panel_config_value() + .and_then(|v| v.get("networkProxy")?.get("proxyModelRequests")?.as_bool()) + .unwrap_or(false); + build_http_client_opt(timeout, user_agent, use_proxy, false) } fn build_http_client_opt( timeout: Duration, user_agent: Option<&str>, use_proxy: bool, + follow_redirects: bool, ) -> Result { let mut builder = reqwest::Client::builder() .timeout(timeout) .gzip(true) .brotli(true) .deflate(true); + if !follow_redirects { + builder = builder.redirect(reqwest::redirect::Policy::none()); + } if let Some(ua) = user_agent { builder = builder.user_agent(ua); } diff --git a/src-tauri/src/commands/portable.rs b/src-tauri/src/commands/portable.rs index daae603..938cc2e 100644 --- a/src-tauri/src/commands/portable.rs +++ b/src-tauri/src/commands/portable.rs @@ -369,8 +369,43 @@ fn path_is_inside_or_same(path: &Path, base: &Path) -> bool { crate::utils::path_is_inside_or_same(path, base) } +fn normalize_migration_path(path: &Path, label: &str) -> Result { + crate::utils::canonicalize_path_for_safety(path, label) +} + +fn metadata_is_link_or_reparse(metadata: &std::fs::Metadata) -> bool { + if metadata.file_type().is_symlink() { + return true; + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0400; + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 + } + #[cfg(not(windows))] + false +} + +fn reject_link_or_reparse_root(path: &Path, label: &str) -> Result<(), String> { + if let Ok(metadata) = std::fs::symlink_metadata(path) { + if metadata_is_link_or_reparse(&metadata) { + return Err(format!( + "{label}不能是链接或 reparse point: {}", + path.display() + )); + } + } + Ok(()) +} + fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> { - if !src.is_dir() { + let source_metadata = std::fs::symlink_metadata(src) + .map_err(|e| format!("读取源目录元数据 {} 失败: {e}", src.display()))?; + if metadata_is_link_or_reparse(&source_metadata) { + return Err(format!("拒绝复制链接或 reparse point: {}", src.display())); + } + if !source_metadata.is_dir() { return Err(format!("源目录不存在: {}", src.display())); } std::fs::create_dir_all(dst).map_err(|e| format!("创建目录 {} 失败: {e}", dst.display()))?; @@ -380,9 +415,14 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> { let entry = entry.map_err(|e| format!("读取目录项失败: {e}"))?; let src_path = entry.path(); let dst_path = dst.join(entry.file_name()); - let meta = entry - .metadata() + let meta = std::fs::symlink_metadata(&src_path) .map_err(|e| format!("读取元数据 {} 失败: {e}", src_path.display()))?; + if metadata_is_link_or_reparse(&meta) { + return Err(format!( + "拒绝复制链接或 reparse point: {}", + src_path.display() + )); + } if meta.is_dir() { copy_dir_recursive(&src_path, &dst_path)?; } else if meta.is_file() { @@ -402,16 +442,192 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), String> { Ok(()) } +struct StagingDir { + path: PathBuf, + active: bool, +} + +impl StagingDir { + fn create_for(target: &Path) -> Result { + let parent = target + .parent() + .ok_or_else(|| format!("目标目录缺少父目录: {}", target.display()))?; + std::fs::create_dir_all(parent) + .map_err(|e| format!("创建目标父目录 {} 失败: {e}", parent.display()))?; + let name = target + .file_name() + .map(|value| value.to_string_lossy().to_string()) + .unwrap_or_else(|| "portable".into()); + for attempt in 0..100_u32 { + let candidate = parent.join(format!("{name}.staging-{}-{attempt}", std::process::id())); + if !candidate.exists() { + std::fs::create_dir(&candidate) + .map_err(|e| format!("创建 staging 目录 {} 失败: {e}", candidate.display()))?; + return Ok(Self { + path: candidate, + active: true, + }); + } + } + Err(format!("无法为 {} 分配 staging 目录", target.display())) + } + + fn disarm(&mut self) { + self.active = false; + } +} + +impl Drop for StagingDir { + fn drop(&mut self) { + if self.active { + let _ = std::fs::remove_dir_all(&self.path); + } + } +} + +fn path_looks_absolute(value: &str) -> bool { + let bytes = value.as_bytes(); + Path::new(value).is_absolute() + || value.starts_with('/') + || value.starts_with("\\\\") + || (bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'\\' | b'/')) +} + +fn collect_external_paths_from_value( + value: &Value, + root: &Path, + file_label: &str, + field_path: &str, + warnings: &mut Vec, +) { + match value { + Value::Object(map) => { + for (key, child) in map { + let child_path = if field_path.is_empty() { + key.clone() + } else { + format!("{field_path}.{key}") + }; + let lower = key.to_ascii_lowercase(); + if (lower == "workspace" || lower == "path" || lower.ends_with("path")) + && child.as_str().is_some_and(path_looks_absolute) + { + let raw = child.as_str().unwrap_or_default(); + let candidate = Path::new(raw); + let is_inside = candidate.is_absolute() + && normalize_migration_path(candidate, "配置绝对路径") + .map(|resolved| path_is_inside_or_same(&resolved, root)) + .unwrap_or(false); + if !is_inside { + warnings.push(format!("external-absolute-path:{file_label}:{child_path}")); + } + } + collect_external_paths_from_value(child, root, file_label, &child_path, warnings); + } + } + Value::Array(items) => { + for (index, child) in items.iter().enumerate() { + collect_external_paths_from_value( + child, + root, + file_label, + &format!("{field_path}[{index}]"), + warnings, + ); + } + } + _ => {} + } +} + +fn collect_external_path_warnings(root: &Path, warnings: &mut Vec) { + fn visit(dir: &Path, root: &Path, warnings: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(metadata) = std::fs::symlink_metadata(&path) else { + continue; + }; + if metadata_is_link_or_reparse(&metadata) { + continue; + } + if metadata.is_dir() { + visit(&path, root, warnings); + continue; + } + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let parsed = match extension.as_str() { + "json" => std::fs::read_to_string(&path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()), + "yaml" | "yml" => std::fs::read_to_string(&path).ok().and_then(|text| { + serde_yaml::from_str::(&text) + .ok() + .and_then(|yaml| serde_json::to_value(yaml).ok()) + }), + _ => None, + }; + if let Some(value) = parsed { + let label = path + .strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + collect_external_paths_from_value(&value, root, &label, "", warnings); + } + } + } + + if root.is_dir() { + visit(root, root, warnings); + warnings.sort(); + warnings.dedup(); + } +} + +fn reject_overlapping_roots(target: &Path, sources: &[(&Path, &str)]) -> Result<(), String> { + for (source, label) in sources { + if source.exists() + && (path_is_inside_or_same(target, source) || path_is_inside_or_same(source, target)) + { + return Err(format!("目标目录与{label}重叠,无法迁移")); + } + } + Ok(()) +} + fn migrate_to_portable_impl( target_root: &Path, panel_config: Option, source_openclaw_dir: &Path, source_engine_dir: Option<&Path>, source_hermes_home: Option<&Path>, + include_current_app: bool, ) -> Result { - if target_root.as_os_str().is_empty() { - return Err("请选择便携模式目标目录".into()); + reject_link_or_reparse_root(source_openclaw_dir, "OpenClaw 源目录")?; + if let Some(path) = source_engine_dir { + reject_link_or_reparse_root(path, "OpenClaw 引擎源目录")?; } + if let Some(path) = source_hermes_home { + reject_link_or_reparse_root(path, "Hermes 源目录")?; + } + let target_root = normalize_migration_path(target_root, "便携模式目标目录")?; + let source_openclaw_dir = normalize_migration_path(source_openclaw_dir, "OpenClaw 源目录")?; + let source_engine_dir = source_engine_dir + .map(|path| normalize_migration_path(path, "OpenClaw 引擎源目录")) + .transpose()?; + let source_hermes_home = source_hermes_home + .map(|path| normalize_migration_path(path, "Hermes 源目录")) + .transpose()?; if target_root.is_file() { return Err("目标路径是文件,请选择目录".into()); } @@ -420,35 +636,35 @@ fn migrate_to_portable_impl( if portable_json.exists() { return Err("目标目录已存在 portable.json,请选择空目录或新的便携目录".into()); } + if target_root.exists() && needs_backup(&target_root) { + return Err("目标目录不是空目录,请选择空目录或新的便携目录".into()); + } - let data_dir = target_root.join("data"); + let mut sources = vec![(source_openclaw_dir.as_path(), "当前 OpenClaw 配置目录")]; + if let Some(path) = source_engine_dir.as_deref() { + sources.push((path, "当前 OpenClaw 引擎目录")); + } + if let Some(path) = source_hermes_home.as_deref() { + sources.push((path, "当前 Hermes 数据目录")); + } + reject_overlapping_roots(&target_root, &sources)?; + + let target_existed_empty = target_root.is_dir(); + let mut staging = StagingDir::create_for(&target_root)?; + let staging_root = staging.path.clone(); + + let data_dir = staging_root.join("data"); let panel_dir = data_dir.join("clawpanel"); let portable_panel_config = panel_dir.join("clawpanel.json"); let portable_openclaw_dir = data_dir.join("openclaw"); let portable_hermes_home = data_dir.join("hermes"); - let engines_openclaw_dir = target_root.join("engines").join("openclaw"); - let engines_hermes_dir = target_root.join("engines").join("hermes"); - let runtimes_node_dir = target_root.join("runtimes").join("node"); - let runtimes_uv_dir = target_root.join("runtimes").join("uv"); - - if source_openclaw_dir.is_dir() - && path_is_inside_or_same(&portable_openclaw_dir, source_openclaw_dir) - { - return Err("目标目录不能放在当前 OpenClaw 配置目录内部,避免递归复制".into()); - } - if let Some(engine_dir) = source_engine_dir { - if engine_dir.is_dir() && path_is_inside_or_same(&engines_openclaw_dir, engine_dir) { - return Err("目标目录不能放在当前 OpenClaw 引擎目录内部,避免递归复制".into()); - } - } - if let Some(hermes_home) = source_hermes_home { - if hermes_home.is_dir() && path_is_inside_or_same(&portable_hermes_home, hermes_home) { - return Err("目标目录不能放在当前 Hermes 数据目录内部,避免递归复制".into()); - } - } + let engines_openclaw_dir = staging_root.join("engines").join("openclaw"); + let engines_hermes_dir = staging_root.join("engines").join("hermes"); + let runtimes_node_dir = staging_root.join("runtimes").join("node"); + let runtimes_uv_dir = staging_root.join("runtimes").join("uv"); for dir in [ - target_root, + &staging_root, &data_dir, &panel_dir, &portable_openclaw_dir, @@ -462,12 +678,6 @@ fn migrate_to_portable_impl( .map_err(|e| format!("创建目录 {} 失败: {e}", dir.display()))?; } - let manifest = portable_manifest(); - let manifest_text = serde_json::to_string_pretty(&manifest) - .map_err(|e| format!("序列化 portable.json 失败: {e}"))?; - std::fs::write(&portable_json, manifest_text) - .map_err(|e| format!("写入 portable.json 失败: {e}"))?; - let (portable_panel, removed_panel_keys) = sanitized_panel_config(panel_config); let panel_text = serde_json::to_string_pretty(&portable_panel) .map_err(|e| format!("序列化 clawpanel.json 失败: {e}"))?; @@ -476,7 +686,8 @@ fn migrate_to_portable_impl( let mut warnings = Vec::new(); let copied_openclaw = if source_openclaw_dir.is_dir() { - copy_dir_recursive(source_openclaw_dir, &portable_openclaw_dir)?; + collect_external_path_warnings(&source_openclaw_dir, &mut warnings); + copy_dir_recursive(&source_openclaw_dir, &portable_openclaw_dir)?; true } else { warnings.push("openclaw-source-missing".to_string()); @@ -484,7 +695,7 @@ fn migrate_to_portable_impl( }; let mut copied_engine = false; - if let Some(engine_dir) = source_engine_dir { + if let Some(engine_dir) = source_engine_dir.as_deref() { if engine_dir.is_dir() { copy_dir_recursive(engine_dir, &engines_openclaw_dir)?; copied_engine = true; @@ -492,14 +703,58 @@ fn migrate_to_portable_impl( } let mut copied_hermes_home = false; - if let Some(hermes_home) = source_hermes_home { + if let Some(hermes_home) = source_hermes_home.as_deref() { if hermes_home.is_dir() { + collect_external_path_warnings(hermes_home, &mut warnings); copy_dir_recursive(hermes_home, &portable_hermes_home)?; copied_hermes_home = true; } } - Ok(json!({ + let mut app_copied = false; + let mut portable_app_path = None; + if include_current_app { + match copy_current_app_binary(&staging_root) { + Ok(staged_path) => { + let file_name = staged_path + .file_name() + .ok_or_else(|| "staging 应用路径无文件名".to_string())?; + app_copied = true; + portable_app_path = Some(target_root.join(file_name)); + } + Err(error) if cfg!(not(windows)) => { + warnings.push(format!("app-copy-unsupported:{error}")); + } + Err(error) => return Err(error), + } + } + + let manifest = portable_manifest(); + let manifest_text = serde_json::to_string_pretty(&manifest) + .map_err(|e| format!("序列化 portable.json 失败: {e}"))?; + std::fs::write(staging_root.join("portable.json"), manifest_text) + .map_err(|e| format!("写入 staging portable.json 失败: {e}"))?; + + if target_existed_empty { + std::fs::remove_dir(&target_root) + .map_err(|e| format!("移除空目标目录 {} 失败: {e}", target_root.display()))?; + } + if let Err(error) = std::fs::rename(&staging_root, &target_root) { + if target_existed_empty { + let _ = std::fs::create_dir_all(&target_root); + } + return Err(format!("提交便携迁移 staging 失败: {error}")); + } + staging.disarm(); + + let data_dir = target_root.join("data"); + let portable_panel_config = data_dir.join("clawpanel").join("clawpanel.json"); + let portable_openclaw_dir = data_dir.join("openclaw"); + let portable_hermes_home = data_dir.join("hermes"); + let engines_openclaw_dir = target_root.join("engines").join("openclaw"); + let engines_hermes_dir = target_root.join("engines").join("hermes"); + + let mut report = json!({ "root": target_root.to_string_lossy(), "portableJson": portable_json.to_string_lossy(), "panelConfigPath": portable_panel_config.to_string_lossy(), @@ -514,7 +769,18 @@ fn migrate_to_portable_impl( "needsHermesInstall": true, "removedPanelKeys": removed_panel_keys, "warnings": warnings, - })) + }); + if include_current_app { + let object = report.as_object_mut().expect("migration report object"); + object.insert("appCopied".into(), Value::Bool(app_copied)); + object.insert( + "portableAppPath".into(), + portable_app_path + .map(|path| Value::String(path.to_string_lossy().to_string())) + .unwrap_or(Value::Null), + ); + } + Ok(report) } /// 目录存在且非空才值得备份;空目录直接复用,避免产生噪音备份 @@ -536,6 +802,94 @@ fn backup_sibling_path(path: &Path, timestamp: &str) -> PathBuf { path.with_file_name(format!("{name}.backup-{timestamp}")) } +struct SwitchRecord { + target: PathBuf, + backup: Option, + restore_empty_dir: bool, +} + +fn remove_switched_target(path: &Path) -> Result<(), String> { + if !path.exists() { + return Ok(()); + } + let metadata = std::fs::symlink_metadata(path) + .map_err(|e| format!("读取切换目标 {} 失败: {e}", path.display()))?; + if metadata.is_dir() { + std::fs::remove_dir_all(path) + .map_err(|e| format!("清理切换目标 {} 失败: {e}", path.display())) + } else { + std::fs::remove_file(path).map_err(|e| format!("清理切换目标 {} 失败: {e}", path.display())) + } +} + +fn rollback_switch(record: &SwitchRecord) -> Result<(), String> { + remove_switched_target(&record.target)?; + if let Some(backup) = &record.backup { + std::fs::rename(backup, &record.target).map_err(|e| { + format!( + "恢复备份 {} -> {} 失败: {e}", + backup.display(), + record.target.display() + ) + })?; + } else if record.restore_empty_dir { + std::fs::create_dir_all(&record.target) + .map_err(|e| format!("恢复空目录 {} 失败: {e}", record.target.display()))?; + } + Ok(()) +} + +fn switch_staged_directory( + staging: &mut StagingDir, + target: &Path, + timestamp: &str, +) -> Result { + let restore_empty_dir = target.is_dir() && !needs_backup(target); + let backup = if target.exists() && needs_backup(target) { + let backup = backup_sibling_path(target, timestamp); + if backup.exists() { + return Err(format!("备份路径已存在,请稍后重试: {}", backup.display())); + } + std::fs::rename(target, &backup) + .map_err(|e| format!("备份现有目录 {} 失败: {e}", target.display()))?; + Some(backup) + } else { + if restore_empty_dir { + std::fs::remove_dir(target) + .map_err(|e| format!("移除空目标目录 {} 失败: {e}", target.display()))?; + } + None + }; + + if let Err(error) = std::fs::rename(&staging.path, target) { + let restore_result = if let Some(backup) = &backup { + std::fs::rename(backup, target) + .map_err(|e| format!("恢复备份 {} 失败: {e}", backup.display())) + } else if restore_empty_dir { + std::fs::create_dir_all(target) + .map_err(|e| format!("恢复空目录 {} 失败: {e}", target.display())) + } else { + Ok(()) + }; + return match restore_result { + Ok(()) => Err(format!( + "切换 staging 到 {} 失败: {error}", + target.display() + )), + Err(restore_error) => Err(format!( + "切换 staging 到 {} 失败: {error}; {restore_error}", + target.display() + )), + }; + } + staging.disarm(); + Ok(SwitchRecord { + target: target.to_path_buf(), + backup, + restore_empty_dir, + }) +} + /// 将便携数据迁移回本机默认位置(migrate_to_portable 的反向)。 /// 语义:以 U 盘数据为准——本机已有数据先整体改名备份(.backup-<时间戳>), /// 再全新复制,避免新旧数据合并出难排查的混合状态。 @@ -547,61 +901,98 @@ fn migrate_to_local_impl( target_openclaw_dir: &Path, target_hermes_home: &Path, ) -> Result { + reject_link_or_reparse_root(source_openclaw_dir, "便携 OpenClaw 源目录")?; + reject_link_or_reparse_root(source_hermes_home, "便携 Hermes 源目录")?; + let source_openclaw_dir = + normalize_migration_path(source_openclaw_dir, "便携 OpenClaw 源目录")?; + let source_hermes_home = normalize_migration_path(source_hermes_home, "便携 Hermes 源目录")?; + let target_openclaw_dir = + normalize_migration_path(target_openclaw_dir, "本机 OpenClaw 目标目录")?; + let target_hermes_home = normalize_migration_path(target_hermes_home, "本机 Hermes 目标目录")?; + // 防呆:目标不能位于便携源内部或与其相同(自定义路径可能指回 U 盘) - if path_is_inside_or_same(target_openclaw_dir, source_openclaw_dir) - || path_is_inside_or_same(source_openclaw_dir, target_openclaw_dir) - { - return Err("本机 OpenClaw 目录与便携目录重叠,无法迁移".into()); - } - if path_is_inside_or_same(target_hermes_home, source_hermes_home) - || path_is_inside_or_same(source_hermes_home, target_hermes_home) - { - return Err("本机 Hermes 目录与便携目录重叠,无法迁移".into()); - } + reject_overlapping_roots( + &target_openclaw_dir, + &[ + (&source_openclaw_dir, "便携 OpenClaw 目录"), + (&source_hermes_home, "便携 Hermes 目录"), + ], + )?; + reject_overlapping_roots( + &target_hermes_home, + &[ + (&source_openclaw_dir, "便携 OpenClaw 目录"), + (&source_hermes_home, "便携 Hermes 目录"), + ], + )?; let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string(); let mut backups: Vec = Vec::new(); let mut warnings: Vec = Vec::new(); + let (panel, removed_keys) = sanitized_panel_config(source_panel_config); - // OpenClaw 数据(含面板级 clawpanel/ 子目录:模型渠道、媒体数据随之带回) - let mut copied_openclaw = false; - if source_openclaw_dir.is_dir() { - if target_openclaw_dir.exists() && needs_backup(target_openclaw_dir) { - let bak = backup_sibling_path(target_openclaw_dir, ×tamp); - std::fs::rename(target_openclaw_dir, &bak).map_err(|e| { - format!("备份本机 OpenClaw 数据失败(若本机 Gateway 正在运行请先停止): {e}") - })?; - backups.push(bak.to_string_lossy().to_string()); - } - copy_dir_recursive(source_openclaw_dir, target_openclaw_dir)?; - copied_openclaw = true; + let mut openclaw_staging = if source_openclaw_dir.is_dir() { + let staging = StagingDir::create_for(&target_openclaw_dir)?; + collect_external_path_warnings(&source_openclaw_dir, &mut warnings); + copy_dir_recursive(&source_openclaw_dir, &staging.path)?; + let panel_text = serde_json::to_string_pretty(&panel) + .map_err(|e| format!("序列化 clawpanel.json 失败: {e}"))?; + std::fs::write(staging.path.join("clawpanel.json"), panel_text) + .map_err(|e| format!("写入 staging clawpanel.json 失败: {e}"))?; + Some(staging) } else { warnings.push("portable-openclaw-missing".into()); - } + None + }; + + let mut hermes_staging = if source_hermes_home.is_dir() { + let staging = StagingDir::create_for(&target_hermes_home)?; + collect_external_path_warnings(&source_hermes_home, &mut warnings); + copy_dir_recursive(&source_hermes_home, &staging.path)?; + Some(staging) + } else { + None + }; + + // OpenClaw 数据(含面板级 clawpanel/ 子目录:模型渠道、媒体数据随之带回) + let mut openclaw_switch = None; + let copied_openclaw = if let Some(staging) = openclaw_staging.as_mut() { + let switched = switch_staged_directory(staging, &target_openclaw_dir, ×tamp) + .map_err(|e| format!("切换本机 OpenClaw 数据失败: {e}"))?; + if let Some(backup) = &switched.backup { + backups.push(backup.to_string_lossy().to_string()); + } + openclaw_switch = Some(switched); + true + } else { + false + }; // Hermes 数据 - let mut copied_hermes = false; - if source_hermes_home.is_dir() { - if target_hermes_home.exists() && needs_backup(target_hermes_home) { - let bak = backup_sibling_path(target_hermes_home, ×tamp); - std::fs::rename(target_hermes_home, &bak) - .map_err(|e| format!("备份本机 Hermes 数据失败: {e}"))?; - backups.push(bak.to_string_lossy().to_string()); + let copied_hermes = if let Some(staging) = hermes_staging.as_mut() { + match switch_staged_directory(staging, &target_hermes_home, ×tamp) { + Ok(switched) => { + if let Some(backup) = &switched.backup { + backups.push(backup.to_string_lossy().to_string()); + } + true + } + Err(error) => { + if let Some(switched) = &openclaw_switch { + if let Err(rollback_error) = rollback_switch(switched) { + return Err(format!( + "切换本机 Hermes 数据失败: {error}; 回滚 OpenClaw 失败: {rollback_error}" + )); + } + } + return Err(format!("切换本机 Hermes 数据失败: {error}")); + } } - copy_dir_recursive(source_hermes_home, target_hermes_home)?; - copied_hermes = true; - } + } else { + false + }; - // 面板配置:写入本机 openclaw 目录下的 clawpanel.json; - // 与正向迁移同理清洗绝对路径字段(便携配置里可能残留指向 U 盘的路径) - let (panel, removed_keys) = sanitized_panel_config(source_panel_config); let target_panel_config = target_openclaw_dir.join("clawpanel.json"); - std::fs::create_dir_all(target_openclaw_dir) - .map_err(|e| format!("创建本机数据目录失败: {e}"))?; - let panel_text = serde_json::to_string_pretty(&panel) - .map_err(|e| format!("序列化 clawpanel.json 失败: {e}"))?; - std::fs::write(&target_panel_config, panel_text) - .map_err(|e| format!("写入本机 clawpanel.json 失败: {e}"))?; Ok(json!({ "openclawDir": target_openclaw_dir.to_string_lossy(), @@ -648,6 +1039,7 @@ fn active_standalone_engine_dir() -> Option { None } +#[cfg(windows)] fn copy_current_app_binary(target_root: &Path) -> Result { let exe = std::env::current_exe().map_err(|e| format!("读取当前程序路径失败: {e}"))?; let file_name = exe @@ -666,6 +1058,11 @@ fn copy_current_app_binary(target_root: &Path) -> Result { Ok(target) } +#[cfg(not(windows))] +fn copy_current_app_binary(_target_root: &Path) -> Result { + Err("当前平台不支持将应用复制为单文件便携程序".into()) +} + /// 将当前本机配置复制到一个新的便携目录。当前进程不会切换到便携模式; /// 用户需要从目标目录里的程序重新启动,启动期才会读取 portable.json。 #[tauri::command] @@ -677,45 +1074,43 @@ pub fn migrate_to_portable(target_root: String) -> Result { if target_root.is_empty() { return Err("请选择便携模式目标目录".into()); } - let target_root = PathBuf::from(target_root); + let target_root = normalize_migration_path(&PathBuf::from(target_root), "便携模式目标目录")?; let source_openclaw_dir = crate::commands::openclaw_dir(); let engine_dir = active_standalone_engine_dir(); - let mut report = migrate_to_portable_impl( + migrate_to_portable_impl( &target_root, crate::commands::read_panel_config_value(), &source_openclaw_dir, engine_dir.as_deref(), Some(&crate::commands::hermes::hermes_home_path()), - )?; - - match copy_current_app_binary(&target_root) { - Ok(path) => { - if let Some(obj) = report.as_object_mut() { - obj.insert("appCopied".into(), Value::Bool(true)); - obj.insert( - "portableAppPath".into(), - Value::String(path.to_string_lossy().to_string()), - ); - } - } - Err(err) => { - if let Some(obj) = report.as_object_mut() { - obj.insert("appCopied".into(), Value::Bool(false)); - obj.insert("portableAppPath".into(), Value::Null); - if let Some(warnings) = obj.get_mut("warnings").and_then(|v| v.as_array_mut()) { - warnings.push(Value::String(format!("app-copy-failed:{err}"))); - } - } - } - } - - Ok(report) + true, + ) } #[cfg(test)] mod tests { use super::*; + #[cfg(windows)] + fn create_directory_link(link: &Path, target: &Path) { + let status = std::process::Command::new("cmd") + .args([ + "/C", + "mklink", + "/J", + &link.to_string_lossy(), + &target.to_string_lossy(), + ]) + .status() + .unwrap(); + assert!(status.success(), "failed to create test junction"); + } + + #[cfg(unix)] + fn create_directory_link(link: &Path, target: &Path) { + std::os::unix::fs::symlink(target, link).unwrap(); + } + fn temp_root(tag: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "clawpanel-portable-test-{tag}-{}", @@ -989,6 +1384,24 @@ mod tests { let _ = std::fs::remove_dir_all(&usb); } + #[test] + fn migrate_to_local_rejects_canonicalized_link_alias_overlap() { + let parent = temp_root("to-local-link-overlap"); + let source = parent.join("portable-openclaw"); + let alias = parent.join("source-alias"); + let hermes_source = parent.join("portable-hermes"); + let hermes_target = parent.join("local-hermes"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::create_dir_all(&hermes_source).unwrap(); + create_directory_link(&alias, &source); + + let err = migrate_to_local_impl(&source, &hermes_source, None, &alias, &hermes_target) + .unwrap_err(); + + assert!(err.contains("重叠"), "unexpected error: {err}"); + let _ = std::fs::remove_dir_all(&parent); + } + #[test] fn migration_creates_portable_layout_and_sanitizes_host_paths() { let target = temp_root("migrate-target"); @@ -1018,9 +1431,15 @@ mod tests { let hermes_source = temp_root("migrate-hermes-source"); std::fs::write(hermes_source.join("config.yaml"), b"model: test\n").unwrap(); - let report = - migrate_to_portable_impl(&target, Some(panel), &source, None, Some(&hermes_source)) - .unwrap(); + let report = migrate_to_portable_impl( + &target, + Some(panel), + &source, + None, + Some(&hermes_source), + false, + ) + .unwrap(); assert!(target.join("portable.json").is_file()); assert!(target @@ -1074,4 +1493,200 @@ mod tests { let _ = std::fs::remove_dir_all(&source); let _ = std::fs::remove_dir_all(&hermes_source); } + + #[test] + fn migration_rejects_parent_components_in_target_path() { + let base = temp_root("parent-target"); + let source = base.join("source"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write(source.join("openclaw.json"), b"{}").unwrap(); + let target = base.join("unused").join(".."); + + let err = migrate_to_portable_impl(&target, None, &source, None, None, false).unwrap_err(); + + assert!(err.contains(".."), "unexpected error: {err}"); + assert!(!base.join("portable.json").exists()); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn copy_dir_recursive_rejects_directory_links() { + let source = temp_root("link-source"); + let external = temp_root("link-external"); + let target = temp_root("link-target"); + std::fs::write(external.join("outside.txt"), b"outside").unwrap(); + create_directory_link(&source.join("linked"), &external); + + let err = copy_dir_recursive(&source, &target).unwrap_err(); + + assert!( + err.contains("链接") || err.contains("reparse"), + "unexpected error: {err}" + ); + assert!(!target.join("linked").join("outside.txt").exists()); + let _ = std::fs::remove_dir_all(&source); + let _ = std::fs::remove_dir_all(&external); + let _ = std::fs::remove_dir_all(&target); + } + + #[test] + fn migration_rejects_link_used_as_source_root() { + let parent = temp_root("link-source-root"); + let real_source = parent.join("real-source"); + let source_link = parent.join("source-link"); + let target = parent.join("portable"); + std::fs::create_dir_all(&real_source).unwrap(); + std::fs::write(real_source.join("outside.txt"), b"outside").unwrap(); + create_directory_link(&source_link, &real_source); + + let err = + migrate_to_portable_impl(&target, None, &source_link, None, None, false).unwrap_err(); + + assert!( + err.contains("链接") || err.contains("reparse"), + "unexpected error: {err}" + ); + assert!(!target.exists()); + let _ = std::fs::remove_dir_all(&parent); + } + + #[test] + fn failed_forward_migration_leaves_no_manifest_or_staging() { + let parent = temp_root("forward-failure"); + let source = parent.join("source"); + let external = parent.join("external"); + let target = parent.join("portable"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::create_dir_all(&external).unwrap(); + create_directory_link(&source.join("linked"), &external); + + let err = migrate_to_portable_impl(&target, None, &source, None, None, false).unwrap_err(); + + assert!( + err.contains("链接") || err.contains("reparse"), + "unexpected error: {err}" + ); + assert!(!target.join("portable.json").exists()); + let staging_prefix = format!("{}.staging-", target.file_name().unwrap().to_string_lossy()); + assert!(!std::fs::read_dir(&parent).unwrap().flatten().any(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with(&staging_prefix) + })); + let _ = std::fs::remove_dir_all(&parent); + } + + #[test] + fn failed_reverse_copy_keeps_existing_target_in_place() { + let parent = temp_root("reverse-failure"); + let source = parent.join("portable-openclaw"); + let external = parent.join("external"); + let target = parent.join("local-openclaw"); + let hermes_source = parent.join("portable-hermes"); + let hermes_target = parent.join("local-hermes"); + for dir in [&source, &external, &target, &hermes_source] { + std::fs::create_dir_all(dir).unwrap(); + } + std::fs::write(target.join("local-only.txt"), b"keep").unwrap(); + create_directory_link(&source.join("linked"), &external); + + let err = migrate_to_local_impl(&source, &hermes_source, None, &target, &hermes_target) + .unwrap_err(); + + assert!( + err.contains("链接") || err.contains("reparse"), + "unexpected error: {err}" + ); + assert_eq!( + std::fs::read(target.join("local-only.txt")).unwrap(), + b"keep" + ); + assert!(!std::fs::read_dir(&parent).unwrap().flatten().any(|entry| { + entry.file_name().to_string_lossy().contains(".backup-") + || entry.file_name().to_string_lossy().contains(".staging-") + })); + let _ = std::fs::remove_dir_all(&parent); + } + + #[test] + fn rollback_switch_restores_backup_after_later_switch_failure() { + let parent = temp_root("switch-rollback"); + let target = parent.join("local-openclaw"); + let backup = parent.join("local-openclaw.backup-test"); + std::fs::create_dir_all(&target).unwrap(); + std::fs::write(target.join("new.txt"), b"new").unwrap(); + std::fs::create_dir_all(&backup).unwrap(); + std::fs::write(backup.join("old.txt"), b"old").unwrap(); + let record = SwitchRecord { + target: target.clone(), + backup: Some(backup.clone()), + restore_empty_dir: false, + }; + + rollback_switch(&record).unwrap(); + + assert!(target.join("old.txt").is_file()); + assert!(!target.join("new.txt").exists()); + assert!(!backup.exists()); + let _ = std::fs::remove_dir_all(&parent); + } + + #[test] + fn migration_warns_about_absolute_paths_outside_source_root() { + let parent = temp_root("external-path-warning"); + let source = parent.join("source"); + let target = parent.join("portable"); + let outside = parent.join("outside-workspace"); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write( + source.join("openclaw.json"), + serde_json::to_vec(&json!({ + "agents": { + "defaults": { "workspace": outside }, + "list": [{ "id": "main", "path": outside.join("agent") }] + } + })) + .unwrap(), + ) + .unwrap(); + + let report = migrate_to_portable_impl(&target, None, &source, None, None, false).unwrap(); + let warnings = report["warnings"].as_array().unwrap(); + + assert!(warnings.iter().any(|warning| warning + .as_str() + .is_some_and(|text| text.starts_with("external-absolute-path:")))); + let _ = std::fs::remove_dir_all(&parent); + } + + #[test] + fn migration_reports_platform_app_copy_truthfully() { + let parent = temp_root("app-copy-platform"); + let source = parent.join("source"); + let target = parent.join("portable"); + std::fs::create_dir_all(&source).unwrap(); + + let report = migrate_to_portable_impl(&target, None, &source, None, None, true).unwrap(); + + if cfg!(windows) { + assert_eq!(report["appCopied"], true); + let app_path = PathBuf::from(report["portableAppPath"].as_str().unwrap()); + assert!(app_path.is_file()); + } else { + assert_eq!(report["appCopied"], false); + assert!(report["portableAppPath"].is_null()); + assert!(report["warnings"] + .as_array() + .unwrap() + .iter() + .any(|warning| { + warning + .as_str() + .is_some_and(|text| text.starts_with("app-copy-unsupported:")) + })); + } + assert!(target.join("portable.json").is_file()); + let _ = std::fs::remove_dir_all(&parent); + } } diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs index 663d5f1..35d630f 100644 --- a/src-tauri/src/commands/update.rs +++ b/src-tauri/src/commands/update.rs @@ -48,7 +48,7 @@ pub async fn check_frontend_update() -> Result { .to_string(); // 优先读取已热更新的版本,避免 macOS/Linux 用户安装旧包后永远提示有更新 - let current = { + let frontend_current = { let version_file = update_dir().join(".version"); std::fs::read_to_string(&version_file) .ok() @@ -63,13 +63,14 @@ pub async fn check_frontend_update() -> Result { .and_then(|v| v.as_str()) .unwrap_or("0.0.0"); - let compatible = version_ge(¤t, min_app); - let remote_newer = !latest.is_empty() && compatible && version_gt(&latest, ¤t); + let app_version = env!("CARGO_PKG_VERSION"); + let compatible = version_ge(app_version, min_app); + let remote_newer = !latest.is_empty() && compatible && version_gt(&latest, &frontend_current); let update_ready = remote_newer && update_dir().join("index.html").exists(); let has_update = remote_newer && !update_ready; Ok(serde_json::json!({ - "currentVersion": current, + "currentVersion": frontend_current, "latestVersion": latest, "hasUpdate": has_update, "compatible": compatible, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 365399e..e690ead 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -266,6 +266,7 @@ pub fn run() { hermes::configure_hermes, hermes::hermes_gateway_action, hermes::hermes_health_check, + hermes::hermes_probe_gateway, hermes::hermes_capabilities, hermes::hermes_api_proxy, hermes::hermes_agent_run, @@ -381,6 +382,7 @@ pub fn run() { hermes_providers::hermes_list_providers, hermes::hermes_env_read_unmanaged, hermes::hermes_env_set, + hermes::hermes_sync_provider, hermes::hermes_env_delete, hermes::hermes_env_reveal, hermes::hermes_config_raw_read, diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 7a3751b..063b5b0 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -325,6 +325,57 @@ pub fn path_compare_key(path: &std::path::Path) -> String { } } +/// 将路径解析为可用于安全比较的绝对路径。 +/// 已存在路径直接 canonicalize;不存在路径 canonicalize 最近的存在祖先后再拼回尾部。 +pub fn canonicalize_path_for_safety( + path: &std::path::Path, + label: &str, +) -> Result { + use std::path::Component; + + if path.as_os_str().is_empty() { + return Err(format!("{label}不能为空")); + } + if path + .components() + .any(|part| matches!(part, Component::ParentDir)) + { + return Err(format!("{label}不能包含 .. 路径段")); + } + + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|e| format!("读取当前目录失败: {e}"))? + .join(path) + }; + if absolute.exists() { + return std::fs::canonicalize(&absolute) + .map_err(|e| format!("解析{label} {} 失败: {e}", absolute.display())); + } + + let mut ancestor = absolute.clone(); + let mut missing = Vec::new(); + while !ancestor.exists() { + let name = ancestor + .file_name() + .ok_or_else(|| format!("找不到{label}的存在祖先: {}", absolute.display()))? + .to_os_string(); + missing.push(name); + if !ancestor.pop() { + return Err(format!("找不到{label}的存在祖先: {}", absolute.display())); + } + } + + let mut resolved = std::fs::canonicalize(&ancestor) + .map_err(|e| format!("解析{label}祖先 {} 失败: {e}", ancestor.display()))?; + for name in missing.into_iter().rev() { + resolved.push(name); + } + Ok(resolved) +} + /// path 是否等于 base 或位于 base 之下(带路径分隔符边界,media 不会误匹配 media-evil) pub fn path_is_inside_or_same(path: &std::path::Path, base: &std::path::Path) -> bool { let path_key = path_compare_key(path); diff --git a/src/engines/hermes/pages/setup.js b/src/engines/hermes/pages/setup.js index 449230a..add79c2 100644 --- a/src/engines/hermes/pages/setup.js +++ b/src/engines/hermes/pages/setup.js @@ -30,6 +30,22 @@ const ICONS = { let hermesProviders = [] let hermesGroups = { apiKeyIntl: [], apiKeyCn: [], aggregators: [], oauth: [], externalProc: [], custom: [] } +const HERMES_SETUP_PHASES = ['detect', 'install', 'configure', 'gateway', 'complete'] + +export function resolveHermesSetupPhase(target, stored, installed) { + if (!HERMES_SETUP_PHASES.includes(target) || !HERMES_SETUP_PHASES.includes(stored) || stored === 'complete') return target + if (HERMES_SETUP_PHASES.indexOf(stored) <= HERMES_SETUP_PHASES.indexOf(target)) return target + if (stored !== 'install' && !installed) return target + return stored +} + +export async function probeAndCommitHermesGateway(apiClient, url) { + const health = await apiClient.hermesProbeGateway(url) + if (!health || health.ok === false) throw new Error('Gateway health check failed') + await apiClient.hermesSetGatewayUrl(url) + return health +} + export function render() { const el = document.createElement('div') el.className = 'page' @@ -53,8 +69,9 @@ export function render() { let mirrorPrefs = null let mirrorOpen = false - const PHASE_ORDER = ['detect', 'install', 'configure', 'gateway', 'complete'] const PHASE_STORE_KEY = 'hermes-setup-phase' + let storedPhase = null + try { storedPhase = sessionStorage.getItem(PHASE_STORE_KEY) } catch (_) {} function draw() { // 记录进行到的阶段:中途关闭后重开可从这里继续(检测结果允许时) @@ -495,14 +512,7 @@ export function render() { // 恢复上次进行到的阶段:中途关闭后重开不必从头再走 // (仅在前提一致时生效:configure/gateway 需要已安装) - try { - const stored = sessionStorage.getItem(PHASE_STORE_KEY) - if (stored && stored !== 'complete' - && PHASE_ORDER.indexOf(stored) > PHASE_ORDER.indexOf(target) - && (stored === 'install' || hm.installed)) { - target = stored - } - } catch (_) {} + target = resolveHermesSetupPhase(target, storedPhase, hm.installed) // 环境有警告(Python 版本、Git 缺失等)且尚未安装 → 停在检测页 // 展示结果,等用户确认后再继续,避免 800ms 后自动跳走看不清问题 @@ -540,12 +550,7 @@ export function render() { draw() try { - // 保存 Gateway URL - await api.hermesSetGatewayUrl(url) - - // 测试连接(health 可能返回对象,ok === false 也视为失败) - const health = await api.hermesHealthCheck() - if (!health || health.ok === false) throw new Error(t('engine.installCustomNoResponse')) + await probeAndCommitHermesGateway(api, url) installing = false customGatewayUrl = url @@ -561,14 +566,21 @@ export function render() { // --- 安装流程 --- async function doInstall() { + if (installing) return + const pypiSel = el.querySelector('#hm-pypi-mirror') + const pypiMirror = pypiSel?.value === 'custom' + ? (el.querySelector('#hm-pypi-custom')?.value?.trim() || '') + : (pypiSel?.value || '') + const gitMirror = el.querySelector('#hm-git-mirror')?.value?.trim() || '' + installing = true + installError = null + progress = 0 + logs = [] + draw() + // 持久化镜像偏好(后端安装命令读取 panelConfig.pypiMirror / gitMirror) try { - const pypiSel = el.querySelector('#hm-pypi-mirror') if (pypiSel) { - const pypiMirror = pypiSel.value === 'custom' - ? (el.querySelector('#hm-pypi-custom')?.value?.trim() || '') - : pypiSel.value - const gitMirror = el.querySelector('#hm-git-mirror')?.value?.trim() || '' if (pypiMirror !== (mirrorPrefs?.pypiMirror || '') || gitMirror !== (mirrorPrefs?.gitMirror || '')) { const cfg = (await api.readPanelConfig()) || {} cfg.pypiMirror = pypiMirror @@ -581,12 +593,6 @@ export function render() { console.warn('[hermes/setup] 保存镜像偏好失败(不阻塞安装):', e) } - installing = true - installError = null - progress = 0 - logs = [] - draw() - // 监听安装事件;Web 模式跳过桌面事件监听。 try { if (!isTauriRuntime()) throw new Error('skip-listen-in-web-mode') diff --git a/src/engines/hermes/style/hermes.css b/src/engines/hermes/style/hermes.css index 560c0ca..4738c3c 100644 --- a/src/engines/hermes/style/hermes.css +++ b/src/engines/hermes/style/hermes.css @@ -1017,7 +1017,7 @@ } @media (max-width: 480px) { [data-engine="hermes"].page { - padding: 20px 16px 32px; + padding: 20px 16px 96px; } } diff --git a/src/lib/model-channels.js b/src/lib/model-channels.js index 1092110..02c006a 100644 --- a/src/lib/model-channels.js +++ b/src/lib/model-channels.js @@ -147,27 +147,20 @@ export async function syncChannelToHermes(channel, { setDefault = false } = {}) const apiKey = await api.revealModelChannelKey(channel.id) if (!apiKey) throw new Error('no-key') - await api.hermesEnvSet(target.apiKeyEnvVars[0], apiKey) - // 仅 OpenAI 兼容渠道写自定义端点:anthropic / gemini 在 Hermes 侧走各自的 - // transport 专用端点,渠道里的原生 API 地址格式不一定一致,误写会破坏请求 + // transport 专用端点,渠道里的原生 API 地址格式不一定一致,误写会破坏请求。 + // Provider Key/Base URL/默认模型由后端单个事务命令更新,避免通用 env + // 编辑器拒绝受管 Key,也避免多步写入留下半同步状态。 const targetBase = String(target.baseUrl || '').replace(/\/+$/, '') const baseUrlDiffers = channel.apiType === 'openai-completions' && Boolean(channel.baseUrl) && channel.baseUrl !== targetBase - if (baseUrlDiffers && target.baseUrlEnvVar) { - await api.hermesEnvSet(target.baseUrlEnvVar, channel.baseUrl) - } - - if (setDefault && channel.defaultModel) { - // 内核核对结论:model.default 是纯模型 ID(含斜杠的聚合器模型 ID 原样保留), - // provider 由 model.provider 单独指定,内核不解析 "provider/model" 前缀 - await api.hermesModelConfigSave({ - modelDefault: channel.defaultModel, - modelProvider: target.id, - modelBaseUrl: baseUrlDiffers && !target.baseUrlEnvVar ? channel.baseUrl : '', - }) - } - return { providerId: target.id, envKey: target.apiKeyEnvVars[0] } + return api.hermesSyncProvider({ + provider: target.id, + apiKey, + baseUrl: baseUrlDiffers ? channel.baseUrl : '', + model: channel.defaultModel || '', + setDefault: Boolean(setDefault && channel.defaultModel), + }) } /** diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 7bc1663..68efad6 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -537,6 +537,7 @@ export const api = { configureHermes: (provider, apiKey, model, baseUrl) => invoke('configure_hermes', { provider, apiKey, model: model || null, baseUrl: baseUrl || null }), hermesGatewayAction: (action) => invoke('hermes_gateway_action', { action }), hermesHealthCheck: () => invoke('hermes_health_check'), + hermesProbeGateway: (url) => invoke('hermes_probe_gateway', { url }), hermesCapabilities: () => invoke('hermes_capabilities'), hermesApiProxy: (method, path, body, headers) => invoke('hermes_api_proxy', { method, path, body: body || null, headers: headers || null }), hermesAgentRun: (input, sessionId, conversationHistory, instructions, attachments) => invoke('hermes_agent_run', { input, sessionId: sessionId || null, conversationHistory: conversationHistory || null, instructions: instructions || null, attachments: attachments && attachments.length ? attachments : null }), @@ -662,6 +663,13 @@ export const api = { hermesListProviders: () => cachedInvoke('hermes_list_providers', {}, 600000), hermesEnvReadUnmanaged: () => invoke('hermes_env_read_unmanaged'), hermesEnvSet: (key, value) => invoke('hermes_env_set', { key, value }), + hermesSyncProvider: ({ provider, apiKey, baseUrl = '', model = '', setDefault = false }) => invoke('hermes_sync_provider', { + provider, + apiKey, + baseUrl: baseUrl || null, + model: model || null, + setDefault, + }), hermesEnvDelete: (key) => invoke('hermes_env_delete', { key }), hermesEnvReveal: (key) => invoke('hermes_env_reveal', { key }), hermesConfigRawRead: () => invoke('hermes_config_raw_read'), diff --git a/src/style/layout.css b/src/style/layout.css index 5cc3bbf..ff78b65 100644 --- a/src/style/layout.css +++ b/src/style/layout.css @@ -1317,6 +1317,7 @@ body:has(#sidebar.sidebar-collapsed) .password-change-banner { } .page { padding: var(--space-lg) var(--space-lg); + padding-bottom: 96px; } .page-title { font-size: var(--font-size-xl); @@ -1380,5 +1381,6 @@ body:has(#sidebar.sidebar-collapsed) .password-change-banner { @media (max-width: 480px) { .page { padding: var(--space-md) var(--space-md); + padding-bottom: 96px; } } diff --git a/tests/hermes-setup-safety.test.js b/tests/hermes-setup-safety.test.js new file mode 100644 index 0000000..6f60aa0 --- /dev/null +++ b/tests/hermes-setup-safety.test.js @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +test('Hermes setup phase resolver restores a valid later phase without bypassing prerequisites', async () => { + const { resolveHermesSetupPhase } = await import('../src/engines/hermes/pages/setup.js') + + assert.equal(resolveHermesSetupPhase('install', 'gateway', false), 'install') + assert.equal(resolveHermesSetupPhase('configure', 'gateway', true), 'gateway') + assert.equal(resolveHermesSetupPhase('install', 'configure', true), 'configure') + assert.equal(resolveHermesSetupPhase('gateway', 'complete', true), 'gateway') +}) + +test('Hermes setup claims the install single-flight guard before its first await', () => { + const source = fs.readFileSync(path.join(root, 'src/engines/hermes/pages/setup.js'), 'utf8') + const body = source.match(/async function doInstall\(\) \{([\s\S]*?)\n \}\n\n \/\/ --- 获取模型列表 ---/)?.[1] + assert.ok(body, 'doInstall body must be present') + const guard = body.indexOf('if (installing) return') + const claim = body.indexOf('installing = true') + const firstAwait = body.indexOf('await ') + assert.ok(guard >= 0 && claim > guard && claim < firstAwait, 'guard and claim must precede the first await') +}) + +test('Custom Hermes Gateway is probed before it is persisted', async () => { + const { probeAndCommitHermesGateway } = await import('../src/engines/hermes/pages/setup.js') + const calls = [] + const fakeApi = { + async hermesProbeGateway(url) { calls.push(['probe', url]); return { ok: true } }, + async hermesSetGatewayUrl(url) { calls.push(['save', url]) }, + } + await probeAndCommitHermesGateway(fakeApi, 'http://gateway.example:8642') + assert.deepEqual(calls, [ + ['probe', 'http://gateway.example:8642'], + ['save', 'http://gateway.example:8642'], + ]) + + await assert.rejects( + probeAndCommitHermesGateway({ + async hermesProbeGateway() { return { ok: false } }, + async hermesSetGatewayUrl() { throw new Error('must not save') }, + }, 'http://bad.example:8642'), + ) +}) + +test('Web Hermes install environment includes configured PyPI mirror', async () => { + const { buildHermesInstallEnv } = await import('../scripts/dev-api.js') + const env = buildHermesInstallEnv({ pypiMirror: 'https://mirror.example/simple', gitMirror: '' }, { PATH: 'base' }) + assert.equal(env.UV_DEFAULT_INDEX, 'https://mirror.example/simple') + assert.equal(env.PIP_INDEX_URL, 'https://mirror.example/simple') + assert.equal(env.GIT_TERMINAL_PROMPT, '0') +}) + +test('Web install_hermes does not block the server event loop with spawnSync', () => { + const source = fs.readFileSync(path.join(root, 'scripts/dev-api.js'), 'utf8') + const body = source.match(/async install_hermes\([^]*?\n \},\n\n async configure_hermes/)?.[0] + assert.ok(body, 'install_hermes handler must be present') + assert.doesNotMatch(body, /spawnSync\s*\(/) + assert.match(body, /await runHermesInstallCommand\s*\(/) +}) diff --git a/tests/media-download-policy.test.js b/tests/media-download-policy.test.js new file mode 100644 index 0000000..cb38069 --- /dev/null +++ b/tests/media-download-policy.test.js @@ -0,0 +1,19 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const devApi = readFileSync(new URL('../scripts/dev-api.js', import.meta.url), 'utf8') + +test('Web 媒体资产下载流式落盘并同时执行 Content-Length 与流量上限', () => { + assert.match(devApi, /function mediaContentLengthExceedsLimit[\s\S]*content-length/) + assert.match(devApi, /async function downloadMediaUrlToFile[\s\S]*createWriteStream[\s\S]*getReader[\s\S]*total > maxBytes/) + assert.match(devApi, /async function downloadMediaAsset[\s\S]*downloadMediaUrlToFile/) + assert.match(devApi, /async function downloadOpenAIVideoContent[\s\S]*downloadMediaUrlToFile/) +}) + +test('Web 媒体资产逐跳处理重定向并只向同源地址发送 Authorization', () => { + assert.match(devApi, /new URL\(url\)\.origin\.toLowerCase\(\)/) + assert.match(devApi, /redirect:\s*'manual'/) + assert.match(devApi, /response\.body\?\.cancel/) + assert.match(devApi, /sendAuth\s*&&\s*\[401,\s*403\]\.includes\(response\.status\)/) +}) diff --git a/tests/media-download-runtime.test.js b/tests/media-download-runtime.test.js new file mode 100644 index 0000000..386b680 --- /dev/null +++ b/tests/media-download-runtime.test.js @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { downloadMediaUrlToFile } from '../scripts/dev-api.js' + +async function listen(handler) { + const server = http.createServer(handler) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + return { + server, + url: `http://127.0.0.1:${address.port}`, + close: () => new Promise(resolve => server.close(resolve)), + } +} + +test('媒体下载跨源重定向时剥离 Authorization 并流式落盘', async t => { + let redirectedAuthorization + const targetServer = await listen((req, res) => { + redirectedAuthorization = req.headers.authorization + res.writeHead(200, { 'content-type': 'image/png' }) + res.end('image-data') + }) + const originServer = await listen((req, res) => { + assert.equal(req.headers.authorization, 'Bearer secret-key') + res.writeHead(302, { location: `${targetServer.url}/asset` }) + res.end() + }) + t.after(async () => { await originServer.close(); await targetServer.close() }) + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-media-')) + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + const target = path.join(dir, 'asset.png') + const result = await downloadMediaUrlToFile({ + url: `${originServer.url}/redirect`, + baseUrl: originServer.url, + apiKey: 'secret-key', + target, + timeoutSeconds: 2, + }) + + assert.equal(redirectedAuthorization, undefined) + assert.equal(result.bytes, 10) + assert.equal(fs.readFileSync(target, 'utf8'), 'image-data') +}) + +test('媒体下载超时覆盖响应体读取并清理临时文件', async t => { + const server = await listen((_req, res) => { + res.writeHead(200, { 'content-type': 'video/mp4' }) + res.write('partial') + setTimeout(() => res.end('late'), 250) + }) + t.after(server.close) + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-media-')) + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + const target = path.join(dir, 'asset.mp4') + + await assert.rejects(downloadMediaUrlToFile({ + url: `${server.url}/slow`, baseUrl: server.url, apiKey: '', target, timeoutSeconds: 0.05, + }), /超时|abort/i) + assert.equal(fs.existsSync(target), false) + assert.deepEqual(fs.readdirSync(dir), []) +}) + +test('媒体下载在流式写入时执行大小上限并清理临时文件', async t => { + const server = await listen((_req, res) => { + res.writeHead(200, { 'content-type': 'application/octet-stream' }) + res.end('0123456789') + }) + t.after(server.close) + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-media-')) + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + const target = path.join(dir, 'asset.bin') + + await assert.rejects(downloadMediaUrlToFile({ + url: `${server.url}/large`, baseUrl: server.url, apiKey: '', target, timeoutSeconds: 2, maxBytes: 5, + }), /超过/) + assert.equal(fs.existsSync(target), false) + assert.deepEqual(fs.readdirSync(dir), []) +}) diff --git a/tests/model-channels-runtime.test.js b/tests/model-channels-runtime.test.js new file mode 100644 index 0000000..35f49b4 --- /dev/null +++ b/tests/model-channels-runtime.test.js @@ -0,0 +1,100 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { api } from '../src/lib/tauri-api.js' +import { syncChannelToHermes } from '../src/lib/model-channels.js' + +test('Hermes 渠道同步使用专用原子命令而不是通用 env 编辑器', async () => { + const originals = { + hermesListProviders: api.hermesListProviders, + revealModelChannelKey: api.revealModelChannelKey, + hermesEnvSet: api.hermesEnvSet, + hermesSyncProvider: api.hermesSyncProvider, + } + + let request = null + try { + api.hermesListProviders = async () => [{ + id: 'custom', + authType: 'api_key', + baseUrl: '', + baseUrlEnvVar: 'OPENAI_BASE_URL', + apiKeyEnvVars: ['OPENAI_API_KEY', 'CUSTOM_API_KEY'], + }] + api.revealModelChannelKey = async () => 'sk-runtime-test' + api.hermesEnvSet = async () => { + throw new Error('通用 env 编辑器不应被模型渠道同步调用') + } + api.hermesSyncProvider = async payload => { + request = payload + return { providerId: payload.provider, envKey: 'OPENAI_API_KEY' } + } + + const result = await syncChannelToHermes({ + id: 'channel-1', + presetKey: '', + apiType: 'openai-completions', + baseUrl: 'https://gateway.example/v1', + defaultModel: 'gpt-test', + }, { setDefault: true }) + + assert.deepEqual(request, { + provider: 'custom', + apiKey: 'sk-runtime-test', + baseUrl: 'https://gateway.example/v1', + model: 'gpt-test', + setDefault: true, + }) + assert.equal(result.providerId, 'custom') + } finally { + Object.assign(api, originals) + } +}) + +test('Web Hermes Provider 同步保留其它 Provider 凭据', async () => { + const devApi = await import('../scripts/dev-api.js') + assert.equal(typeof devApi.syncHermesProviderFilesAt, 'function') + + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'clawpanel-hermes-sync-')) + try { + fs.writeFileSync(path.join(home, '.env'), [ + 'ANTHROPIC_API_KEY=keep-me', + 'OPENAI_API_KEY=old', + 'CUSTOM_FLAG=keep', + '', + ].join('\n')) + fs.writeFileSync(path.join(home, 'config.yaml'), [ + 'model:', + ' default: old-model', + ' provider: anthropic', + 'logging:', + ' level: INFO', + '', + ].join('\n')) + + devApi.syncHermesProviderFilesAt(home, { + provider: 'custom', + apiKey: 'sk-new', + baseUrl: 'https://gateway.example/v1', + model: 'gpt-test', + setDefault: true, + }) + + const env = fs.readFileSync(path.join(home, '.env'), 'utf8') + assert.match(env, /ANTHROPIC_API_KEY=keep-me/) + assert.match(env, /CUSTOM_FLAG=keep/) + assert.match(env, /OPENAI_API_KEY=sk-new/) + assert.match(env, /CUSTOM_API_KEY=sk-new/) + assert.match(env, /OPENAI_BASE_URL=https:\/\/gateway\.example\/v1/) + + const config = fs.readFileSync(path.join(home, 'config.yaml'), 'utf8') + assert.match(config, /default: gpt-test/) + assert.match(config, /provider: custom/) + assert.match(config, /level: INFO/) + } finally { + fs.rmSync(home, { recursive: true, force: true }) + } +}) diff --git a/tests/model-channels.test.js b/tests/model-channels.test.js index e20acc6..6a3c1bb 100644 --- a/tests/model-channels.test.js +++ b/tests/model-channels.test.js @@ -39,7 +39,7 @@ test('Hermes 同步契约与内核注册表一致(已按内核源码核对)' assert.match(lib, /'google-generative-ai':\s*\{\s*fallbackProvider:\s*'gemini'/, 'Gemini 渠道应回退到 gemini provider(内核经 OpenAI 兼容端点接入)') assert.match(lib, /authType === 'api_key'/, 'OAuth/SDK 型 provider 必须被排除在渠道同步之外') // 内核不解析 "provider/model" 前缀:model.default 必须写纯模型 ID - assert.match(lib, /modelDefault:\s*channel\.defaultModel,/, 'model.default 必须是纯模型 ID,不得拼接 provider 前缀') + assert.match(lib, /model:\s*channel\.defaultModel\s*\|\|\s*''/, 'model.default 必须是纯模型 ID,不得拼接 provider 前缀') // 自定义端点只对 OpenAI 兼容渠道生效,避免破坏 anthropic/gemini 的专用端点 assert.match(lib, /channel\.apiType === 'openai-completions'\s*&&\s*Boolean\(channel\.baseUrl\)/, '自定义 Base URL 仅限 OpenAI 兼容渠道') }) diff --git a/tests/release-readiness-regressions.test.js b/tests/release-readiness-regressions.test.js new file mode 100644 index 0000000..a5d01b7 --- /dev/null +++ b/tests/release-readiness-regressions.test.js @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const read = file => fs.readFileSync(path.join(root, file), 'utf8') + +test('移动端页面为全局 AI FAB 预留底部滚动空间', () => { + const css = read('src/style/layout.css') + assert.match(css, /@media\s*\(max-width:\s*768px\)[\s\S]*\.page\s*\{[\s\S]*padding-bottom:\s*(?:8[0-9]|9[0-9]|1\d\d)px/) + const hermesCss = read('src/engines/hermes/style/hermes.css') + assert.match(hermesCss, /@media\s*\(max-width:\s*480px\)[\s\S]*\[data-engine="hermes"\]\.page\s*\{[\s\S]*padding:\s*20px\s+16px\s+96px/) +}) + +test('Vite 使用修复开发服务器漏洞的 6.4.3 或更高 6.x 版本', () => { + const pkg = JSON.parse(read('package.json')) + const match = String(pkg.devDependencies?.vite || '').match(/(\d+)\.(\d+)\.(\d+)/) + assert.ok(match) + const [, major, minor, patch] = match.map(Number) + assert.equal(major, 6) + assert.ok(minor > 4 || (minor === 4 && patch >= 3)) +}) + +test('未打标签前 0.18.6 不伪装成已发布版本', () => { + const changelog = read('CHANGELOG.md') + assert.doesNotMatch(changelog, /^## \[0\.18\.6\] - \d{4}-\d{2}-\d{2}$/m) + assert.match(changelog, /^## \[0\.18\.6 候选\] - 尚未发布$/m) +}) + +test('Hermes Rust 与 Web 关键 Provider 注册表保持一致', () => { + const rust = read('src-tauri/src/commands/hermes_providers.rs') + const web = read('scripts/dev-api.js') + for (const model of ['glm-5.2', 'kimi-k2.7-code']) { + assert.match(rust, new RegExp(`"${model.replace('.', '\\.') }"`)) + assert.match(web, new RegExp(`'${model.replace('.', '\\.') }'`)) + } + assert.match(rust, /id:\s*"alibaba",[\s\S]*?name:\s*"Qwen Cloud"/) + assert.match(web, /hermesProvider\('alibaba',\s*'Qwen Cloud'/) +}) diff --git a/tests/release-safety-policy.test.js b/tests/release-safety-policy.test.js new file mode 100644 index 0000000..a02b85a --- /dev/null +++ b/tests/release-safety-policy.test.js @@ -0,0 +1,132 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync, copyFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' +import { parse } from 'yaml' + +const root = resolve(import.meta.dirname, '..') + +function read(path) { + return readFileSync(resolve(root, path), 'utf8') +} + +function workflow(path) { + return parse(read(path)) +} + +function stepText(job) { + return (job.steps || []).map((step) => `${step.name || ''}\n${step.run || ''}\n${step.uses || ''}`).join('\n') +} + +function needs(job) { + return Array.isArray(job.needs) ? job.needs : [job.needs] +} + +test('CI runs Node and locked Rust tests and checks', () => { + const ci = workflow('.github/workflows/ci.yml') + const text = stepText(ci.jobs.check) + + assert.match(text, /node --test tests/) + assert.match(text, /cargo test --locked/) + assert.match(text, /cargo check --locked/) + assert.match(text, /cargo clippy --locked --all-targets -- -D warnings/) +}) + +test('release dispatch validates semver, binds an existing tag to HEAD, and rejects an existing release', () => { + const release = workflow('.github/workflows/release.yml') + const validate = release.jobs['validate-release'] + assert.ok(validate) + + const text = stepText(validate) + assert.match(text, /SEMVER_PATTERN=/) + assert.match(text, /refs\/tags\/\$TAG_NAME\^\{commit\}/) + assert.match(text, /git rev-parse HEAD/) + assert.match(text, /gh release view "\$TAG_NAME"/) + assert.ok(validate.outputs?.tag_name) + assert.ok(validate.outputs?.version) +}) + +test('release runs tests before builders and builders only upload workflow artifacts', () => { + const release = workflow('.github/workflows/release.yml') + const verify = release.jobs['test-release'] + const build = release.jobs.build + const web = release.jobs['build-web'] + + assert.ok(verify) + const verifyText = stepText(verify) + assert.match(verifyText, /node --test tests/) + assert.match(verifyText, /cargo test --locked/) + + for (const job of [build, web]) { + assert.ok(job) + assert.ok(needs(job).includes('validate-release')) + assert.ok(needs(job).includes('test-release')) + const text = stepText(job) + assert.match(text, /actions\/upload-artifact@/) + assert.doesNotMatch(text, /tauri-apps\/tauri-action/) + assert.doesNotMatch(text, /gh release (create|upload|edit)/) + assert.doesNotMatch(text, /latest\.json/) + } +}) + +test('one final job publishes only after all artifacts succeed and pins minAppVersion', () => { + const release = workflow('.github/workflows/release.yml') + const publish = release.jobs['publish-release'] + assert.ok(publish) + assert.deepEqual( + [...needs(publish)].sort(), + ['build', 'build-web', 'test-release', 'validate-release'].sort(), + ) + + const text = stepText(publish) + assert.match(text, /actions\/download-artifact@/) + assert.match(text, /gh release create "\$TAG_NAME"[^\n]*--draft/) + assert.match(text, /gh release upload "\$TAG_NAME"/) + assert.match(text, /gh release edit "\$TAG_NAME"[^\n]*--draft=false/) + assert.match(text, /"minAppVersion": "\$\{VERSION\}"/) + assert.doesNotMatch(text, /MIN_APP_VER|\.get\(['"]minAppVersion/) + assert.doesNotMatch(text, /git push[^\n]*\|\| true/) +}) + +test('frontend compatibility uses the packaged app version while update comparison uses frontend current', () => { + const source = read('src-tauri/src/commands/update.rs') + + assert.match(source, /let app_version = env!\("CARGO_PKG_VERSION"\);/) + assert.match(source, /let compatible = version_ge\(app_version, min_app\);/) + assert.match(source, /let remote_newer =[^;]*version_gt\(&latest, &frontend_current\);/s) + assert.match(source, /"currentVersion": frontend_current/) +}) + +test('sync-version exits nonzero when any target cannot be updated', () => { + const temp = mkdtempSync(join(tmpdir(), 'clawpanel-version-policy-')) + try { + mkdirSync(join(temp, 'scripts')) + mkdirSync(join(temp, 'src-tauri')) + copyFileSync(resolve(root, 'scripts/sync-version.js'), join(temp, 'scripts/sync-version.js')) + writeFileSync(join(temp, 'package.json'), '{"version":"1.2.3","type":"module"}\n') + writeFileSync(join(temp, 'package-lock.json'), '{"version":"1.2.3","packages":{"":{"version":"1.2.3"}}}\n') + writeFileSync(join(temp, 'src-tauri/tauri.conf.json'), '{"version":"1.2.3"}\n') + writeFileSync(join(temp, 'src-tauri/Cargo.toml'), '[package]\nname = "clawpanel"\nversion = "1.2.3"\n') + writeFileSync(join(temp, 'src-tauri/Cargo.lock'), 'version = 3\n') + + const result = spawnSync(process.execPath, [join(temp, 'scripts/sync-version.js')], { + cwd: temp, + encoding: 'utf8', + }) + + assert.notEqual(result.status, 0, result.stdout + result.stderr) + assert.match(result.stderr, /Cargo\.lock/) + } finally { + rmSync(temp, { recursive: true, force: true }) + } +}) + +test('Pages manual deployments only run from main', () => { + const pages = workflow('.github/workflows/pages.yml') + assert.equal( + pages.jobs.build.if, + "${{ github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main' }}", + ) +})