From ed4a2644c9e523f01b71b60186bf5a725fc9762f Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 13:16:45 +0800 Subject: [PATCH 001/112] ci: guard workflow shell interpolation --- scripts/check_workflow_shell_interpolation.py | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 scripts/check_workflow_shell_interpolation.py diff --git a/scripts/check_workflow_shell_interpolation.py b/scripts/check_workflow_shell_interpolation.py new file mode 100644 index 00000000000..08673fe6b47 --- /dev/null +++ b/scripts/check_workflow_shell_interpolation.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Reject unsafe GitHub expression interpolation inside shell scripts. + +GitHub expands `${{ ... }}` expressions before the selected shell parses a +`run:` block. Secrets and workflow-dispatch inputs therefore must cross the +shell boundary through `env:` rather than being embedded in script source. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +WORKFLOW_DIR = Path(".github/workflows") +FORBIDDEN = re.compile(r"\$\{\{\s*(?:secrets|inputs)\.", re.IGNORECASE) +RUN_LINE = re.compile(r"^(?P\s*)run:\s*(?P.*)$") + + +def scan_workflow(path: Path) -> list[tuple[int, str]]: + lines = path.read_text(encoding="utf-8").splitlines() + findings: list[tuple[int, str]] = [] + index = 0 + + while index < len(lines): + line = lines[index] + match = RUN_LINE.match(line) + if not match: + index += 1 + continue + + indent = len(match.group("indent")) + body = match.group("body").strip() + + # One-line form: `run: command ...`. + if body and not body.startswith(("|", ">")): + if FORBIDDEN.search(body): + findings.append((index + 1, line.strip())) + index += 1 + continue + + # Block scalar form. Blank lines belong to the block; a non-blank line + # at the same or lower indentation ends it. + index += 1 + while index < len(lines): + block_line = lines[index] + stripped = block_line.lstrip() + block_indent = len(block_line) - len(stripped) + if stripped and block_indent <= indent: + break + if FORBIDDEN.search(block_line): + findings.append((index + 1, stripped)) + index += 1 + + return findings + + +def main() -> int: + failed = False + for path in sorted([*WORKFLOW_DIR.glob("*.yml"), *WORKFLOW_DIR.glob("*.yaml")]): + for line_no, excerpt in scan_workflow(path): + failed = True + print( + f"{path}:{line_no}: unsafe GitHub expression interpolation in run block: {excerpt}", + file=sys.stderr, + ) + + if failed: + print( + "Pass secrets/workflow inputs through step-level env: and reference the environment variable from the script.", + file=sys.stderr, + ) + return 1 + + print("Workflow shell interpolation check passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 4d31165ca3b161de06b0f8b4d069c6be322c34b6 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 13:17:03 +0800 Subject: [PATCH 002/112] ci: enforce workflow shell boundary --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33dab3f0b31..e1692fafe32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,16 @@ concurrency: cancel-in-progress: true jobs: + workflow-policy: + name: Workflow Policy + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Reject unsafe shell interpolation + run: python scripts/check_workflow_shell_interpolation.py + frontend: name: Frontend Checks runs-on: ubuntu-latest From 33b74e58e59b02c75ecf00e2d318c335ef6a8e00 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 13:17:26 +0800 Subject: [PATCH 003/112] ci: harden supplemental Linux release inputs --- .github/workflows/supplemental-linux-release.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/supplemental-linux-release.yml b/.github/workflows/supplemental-linux-release.yml index fa088521ce3..4e95d639d23 100644 --- a/.github/workflows/supplemental-linux-release.yml +++ b/.github/workflows/supplemental-linux-release.yml @@ -15,6 +15,8 @@ jobs: linux-x86_64: name: Build and upload Linux x86_64 assets runs-on: ubuntu-22.04 + env: + RELEASE_TAG: ${{ inputs.tag }} steps: - name: Checkout release tag uses: actions/checkout@v6 @@ -66,7 +68,7 @@ jobs: shell: bash run: | set -euxo pipefail - expected="${{ inputs.tag }}" + expected="$RELEASE_TAG" expected="${expected#v}" actual="$(node -p "require('./package.json').version")" test "$actual" = "$expected" @@ -87,7 +89,7 @@ jobs: shell: bash run: | set -euxo pipefail - version="${{ inputs.tag }}" + version="$RELEASE_TAG" version="${version#v}" mkdir -p release-assets cp "$(find src-tauri/target/release/bundle/appimage -name '*.AppImage' | head -1)" \ @@ -104,8 +106,8 @@ jobs: shell: bash run: | set -euxo pipefail - gh release upload "${{ inputs.tag }}" release-assets/* --clobber --repo "$GITHUB_REPOSITORY" - gh release delete-asset "${{ inputs.tag }}" linux-build-note.md --yes --repo "$GITHUB_REPOSITORY" || true + gh release upload "$RELEASE_TAG" release-assets/* --clobber --repo "$GITHUB_REPOSITORY" + gh release delete-asset "$RELEASE_TAG" linux-build-note.md --yes --repo "$GITHUB_REPOSITORY" || true - name: Refresh release checksums env: @@ -113,8 +115,8 @@ jobs: shell: bash run: | set -euxo pipefail - gh release view "${{ inputs.tag }}" --repo "$GITHUB_REPOSITORY" --json assets \ + gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json assets \ --jq '.assets[] | select(.name != "SHA256SUMS.txt") | select(.digest != null and .digest != "") | "\(.digest | sub("^sha256:";"") | ascii_upcase) \(.name)"' \ | sort > SHA256SUMS.txt cat SHA256SUMS.txt - gh release upload "${{ inputs.tag }}" SHA256SUMS.txt --clobber --repo "$GITHUB_REPOSITORY" + gh release upload "$RELEASE_TAG" SHA256SUMS.txt --clobber --repo "$GITHUB_REPOSITORY" From 04b3e28086b77b7b18fdf5859cfa16ed8776fdc3 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 13:17:59 +0800 Subject: [PATCH 004/112] ci: make supplemental macOS release fail closed --- .../workflows/supplemental-macos-release.yml | 131 +++++++++++++----- 1 file changed, 94 insertions(+), 37 deletions(-) diff --git a/.github/workflows/supplemental-macos-release.yml b/.github/workflows/supplemental-macos-release.yml index cfb870cbfed..00c7b8359a8 100644 --- a/.github/workflows/supplemental-macos-release.yml +++ b/.github/workflows/supplemental-macos-release.yml @@ -15,6 +15,8 @@ jobs: macos-universal: name: Build and upload macOS universal assets runs-on: macos-14 + env: + RELEASE_TAG: ${{ inputs.tag }} steps: - name: Checkout release tag uses: actions/checkout@v6 @@ -60,68 +62,109 @@ jobs: shell: bash run: | set -euxo pipefail - expected="${{ inputs.tag }}" + expected="$RELEASE_TAG" expected="${expected#v}" actual="$(node -p "require('./package.json').version")" test "$actual" = "$expected" - name: Prepare Tauri signing key shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY_RAW: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD_RAW: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | set -euo pipefail - if [ -z "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}" ]; then + if [ -z "${TAURI_SIGNING_PRIVATE_KEY_RAW:-}" ]; then echo "❌ TAURI_SIGNING_PRIVATE_KEY Secret is missing" >&2 exit 1 fi - RAW="${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}" + RAW="$TAURI_SIGNING_PRIVATE_KEY_RAW" KEY_PATH="$RUNNER_TEMP/tauri_signing.key" if echo "$RAW" | head -n1 | grep -q '^untrusted comment:'; then printf '%s\n' "$RAW" > "$KEY_PATH" + elif DECODED=$(printf '%s' "$RAW" | (base64 --decode 2>/dev/null || base64 -D 2>/dev/null)) \ + && echo "$DECODED" | head -n1 | grep -q '^untrusted comment:'; then + printf '%s\n' "$DECODED" > "$KEY_PATH" + elif echo "$RAW" | grep -Eq '^[A-Za-z0-9+/=]+$'; then + ONE=$(printf '%s' "$RAW" | tr -d '\r\n') + printf '%s\n%s\n' "untrusted comment: tauri signing key" "$ONE" > "$KEY_PATH" else - if DECODED=$(printf '%s' "$RAW" | (base64 --decode 2>/dev/null || base64 -D 2>/dev/null)) \ - && echo "$DECODED" | head -n1 | grep -q '^untrusted comment:'; then - printf '%s\n' "$DECODED" > "$KEY_PATH" - else - if echo "$RAW" | grep -Eq '^[A-Za-z0-9+/=]+$'; then - ONE=$(printf '%s' "$RAW" | tr -d '\r\n') - printf '%s\n%s\n' "untrusted comment: tauri signing key" "$ONE" > "$KEY_PATH" - else - echo "❌ TAURI_SIGNING_PRIVATE_KEY format is not recognized" >&2 - exit 1 - fi - fi + echo "❌ TAURI_SIGNING_PRIVATE_KEY format is not recognized" >&2 + exit 1 fi if command -v base64 >/dev/null 2>&1; then KEY_B64=$(base64 < "$KEY_PATH" | tr -d '\r\n') - elif command -v openssl >/dev/null 2>&1; then - KEY_B64=$(openssl base64 -A -in "$KEY_PATH") else - KEY_B64=$(KEY_PATH="$KEY_PATH" node -e "process.stdout.write(require('fs').readFileSync(process.env.KEY_PATH).toString('base64'))") + KEY_B64=$(openssl base64 -A -in "$KEY_PATH") + fi + test -n "$KEY_B64" + echo "TAURI_SIGNING_PRIVATE_KEY=$KEY_B64" >> "$GITHUB_ENV" + if [ -n "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD_RAW:-}" ]; then + echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=$TAURI_SIGNING_PRIVATE_KEY_PASSWORD_RAW" >> "$GITHUB_ENV" fi - if [ -z "$KEY_B64" ]; then - echo "❌ Failed to encode Tauri signing key" >&2 + + - name: Import Apple signing certificate + shell: bash + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + if [ -z "${APPLE_CERTIFICATE:-}" ] || [ -z "${APPLE_CERTIFICATE_PASSWORD:-}" ] || [ -z "${KEYCHAIN_PASSWORD:-}" ]; then + echo "❌ Apple signing certificate secrets are required for release assets" >&2 exit 1 fi - echo "TAURI_SIGNING_PRIVATE_KEY=$KEY_B64" >> "$GITHUB_ENV" - if [ -n "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" ]; then - echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" >> "$GITHUB_ENV" + CERT_PATH="$RUNNER_TEMP/certificate.p12" + printf '%s' "$APPLE_CERTIFICATE" | (base64 --decode 2>/dev/null || base64 -D) > "$CERT_PATH" + ORIGINAL_DEFAULT_KEYCHAIN=$(security default-keychain -d user | tr -d '"' | xargs) + echo "ORIGINAL_DEFAULT_KEYCHAIN=$ORIGINAL_DEFAULT_KEYCHAIN" >> "$GITHUB_ENV" + + KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security default-keychain -s "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERT_PATH" \ + -k "$KEYCHAIN_PATH" \ + -P "$APPLE_CERTIFICATE_PASSWORD" \ + -T /usr/bin/codesign \ + -T /usr/bin/security + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \ + | grep "Developer ID Application" | grep -oE '"[^"]+"' | head -1 | tr -d '"') + if [ -z "$IDENTITY" ]; then + echo "❌ No Developer ID Application signing identity found" >&2 + exit 1 fi + echo "APPLE_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" + rm -f "$CERT_PATH" - - name: Build unsigned macOS bundles + - name: Build signed and notarized macOS bundles shell: bash timeout-minutes: 60 + env: + APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | - set -euxo pipefail + set -euo pipefail + if [ -z "${APPLE_SIGNING_IDENTITY:-}" ] || [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then + echo "❌ macOS signing/notarization credentials are required for release assets" >&2 + exit 1 + fi pnpm tauri build --target universal-apple-darwin - - name: Stage macOS release assets + - name: Stage and verify macOS release assets shell: bash run: | set -euxo pipefail - version="${{ inputs.tag }}" + version="$RELEASE_TAG" version="${version#v}" mkdir -p release-assets @@ -138,19 +181,22 @@ jobs: fi done - if [ -z "$TAR_GZ" ]; then - echo "❌ No macOS updater tarball found" >&2 + if [ -z "$TAR_GZ" ] || [ -z "$APP_PATH" ]; then + echo "❌ Signed macOS updater/app artifact is incomplete" >&2 exit 1 fi - if [ -z "$APP_PATH" ]; then - echo "❌ No macOS .app bundle found" >&2 + if [ ! -f "$TAR_GZ.sig" ]; then + echo "❌ macOS updater signature is missing" >&2 exit 1 fi + xcrun stapler staple "$APP_PATH" + codesign --verify --deep --strict --verbose=2 "$APP_PATH" + spctl -a -t exec -vv "$APP_PATH" + xcrun stapler validate "$APP_PATH" + cp "$TAR_GZ" "release-assets/CCSwitchMulti_${version}_universal.tar.gz" - if [ -f "$TAR_GZ.sig" ]; then - cp "$TAR_GZ.sig" "release-assets/CCSwitchMulti_${version}_universal.tar.gz.sig" - fi + cp "$TAR_GZ.sig" "release-assets/CCSwitchMulti_${version}_universal.tar.gz.sig" ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "release-assets/CCSwitchMulti_${version}_universal.app.zip" shasum -a 256 release-assets/* @@ -160,7 +206,7 @@ jobs: shell: bash run: | set -euxo pipefail - gh release upload "${{ inputs.tag }}" release-assets/* --clobber --repo "$GITHUB_REPOSITORY" + gh release upload "$RELEASE_TAG" release-assets/* --clobber --repo "$GITHUB_REPOSITORY" - name: Refresh release checksums env: @@ -168,8 +214,19 @@ jobs: shell: bash run: | set -euxo pipefail - gh release view "${{ inputs.tag }}" --repo "$GITHUB_REPOSITORY" --json assets \ + gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json assets \ --jq '.assets[] | select(.name != "SHA256SUMS.txt") | select(.digest != null and .digest != "") | "\(.digest | sub("^sha256:";"") | ascii_upcase) \(.name)"' \ | sort > SHA256SUMS.txt cat SHA256SUMS.txt - gh release upload "${{ inputs.tag }}" SHA256SUMS.txt --clobber --repo "$GITHUB_REPOSITORY" + gh release upload "$RELEASE_TAG" SHA256SUMS.txt --clobber --repo "$GITHUB_REPOSITORY" + + - name: Clean up Apple signing keychain + if: always() + shell: bash + run: | + if [ -n "${ORIGINAL_DEFAULT_KEYCHAIN:-}" ]; then + security default-keychain -s "$ORIGINAL_DEFAULT_KEYCHAIN" || true + fi + if [ -f "$RUNNER_TEMP/build.keychain-db" ]; then + security delete-keychain "$RUNNER_TEMP/build.keychain-db" || true + fi From 3f16ba723d156a1d10a256303940b612b653d69b Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 13:19:46 +0800 Subject: [PATCH 005/112] ci: harden release signing boundary --- .github/workflows/release.yml | 198 +++++++++++----------------------- 1 file changed, 62 insertions(+), 136 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92f6b794572..28b1ef22365 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -150,43 +150,35 @@ jobs: - name: Prepare Tauri signing key shell: bash + env: + TAURI_SIGNING_PRIVATE_KEY_RAW: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD_RAW: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | - # 调试:检查 Secret 是否存在 - if [ -z "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}" ]; then + set -euo pipefail + if [ -z "${TAURI_SIGNING_PRIVATE_KEY_RAW:-}" ]; then echo "❌ TAURI_SIGNING_PRIVATE_KEY Secret 为空或不存在" >&2 echo "请检查 GitHub 仓库 Settings > Secrets and variables > Actions" >&2 exit 1 fi - - RAW="${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}" - # 目标:提供正确的私钥“文件路径”给 Tauri CLI,避免内容解码歧义 + + RAW="$TAURI_SIGNING_PRIVATE_KEY_RAW" KEY_PATH="$RUNNER_TEMP/tauri_signing.key" - # 情况 1:原始两行文本(第一行以 "untrusted comment:" 开头) if echo "$RAW" | head -n1 | grep -q '^untrusted comment:'; then printf '%s\n' "$RAW" > "$KEY_PATH" echo "✅ 使用原始两行密钥文件格式" + elif DECODED=$(printf '%s' "$RAW" | (base64 --decode 2>/dev/null || base64 -D 2>/dev/null)) \ + && echo "$DECODED" | head -n1 | grep -q '^untrusted comment:'; then + printf '%s\n' "$DECODED" > "$KEY_PATH" + echo "✅ 成功解码 base64 包裹密钥,已还原为两行文件" + elif echo "$RAW" | grep -Eq '^[A-Za-z0-9+/=]+$'; then + ONE=$(printf '%s' "$RAW" | tr -d '\r\n') + printf '%s\n%s\n' "untrusted comment: tauri signing key" "$ONE" > "$KEY_PATH" + echo "✅ 使用一行 Base64 私钥,已构造两行文件" else - # 情况 2:整体被 base64 包裹(解包后应当是两行) - if DECODED=$(printf '%s' "$RAW" | (base64 --decode 2>/dev/null || base64 -D 2>/dev/null)) \ - && echo "$DECODED" | head -n1 | grep -q '^untrusted comment:'; then - printf '%s\n' "$DECODED" > "$KEY_PATH" - echo "✅ 成功解码 base64 包裹密钥,已还原为两行文件" - else - # 情况 3:已是第二行(纯 Base64 一行)→ 构造两行文件 - if echo "$RAW" | grep -Eq '^[A-Za-z0-9+/=]+$'; then - ONE=$(printf '%s' "$RAW" | tr -d '\r\n') - printf '%s\n%s\n' "untrusted comment: tauri signing key" "$ONE" > "$KEY_PATH" - echo "✅ 使用一行 Base64 私钥,已构造两行文件" - else - echo "❌ TAURI_SIGNING_PRIVATE_KEY 格式无法识别:既不是两行原文,也不是其 base64,亦非一行 base64" >&2 - echo "密钥前10个字符: $(echo "$RAW" | head -c 10)..." >&2 - exit 1 - fi - fi + echo "❌ TAURI_SIGNING_PRIVATE_KEY 格式无法识别" >&2 + exit 1 fi - # 将“完整两行内容”作为环境变量注入(Tauri 支持传入完整私钥文本或文件路径) - # 使用多行写入语法,保持换行以便解析 - # 将完整两行私钥内容进行 base64 编码,作为单行内容注入环境变量 + if command -v base64 >/dev/null 2>&1; then KEY_B64=$(base64 < "$KEY_PATH" | tr -d '\r\n') elif command -v openssl >/dev/null 2>&1; then @@ -199,8 +191,8 @@ jobs: exit 1 fi echo "TAURI_SIGNING_PRIVATE_KEY=$KEY_B64" >> "$GITHUB_ENV" - if [ -n "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" ]; then - echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}" >> $GITHUB_ENV + if [ -n "${TAURI_SIGNING_PRIVATE_KEY_PASSWORD_RAW:-}" ]; then + echo "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=$TAURI_SIGNING_PRIVATE_KEY_PASSWORD_RAW" >> "$GITHUB_ENV" fi echo "✅ Tauri signing key prepared" @@ -214,52 +206,37 @@ jobs: run: | set -euo pipefail if [ -z "${APPLE_CERTIFICATE:-}" ] || [ -z "${APPLE_CERTIFICATE_PASSWORD:-}" ] || [ -z "${KEYCHAIN_PASSWORD:-}" ]; then - echo "⚠️ Apple signing certificate secrets are missing; continuing without macOS signing" - echo "APPLE_SIGNING_IDENTITY=" >> "$GITHUB_ENV" - exit 0 + echo "❌ Apple signing certificate secrets are required for a release" >&2 + exit 1 fi - # Decode .p12 certificate from base64 + CERT_PATH="$RUNNER_TEMP/certificate.p12" printf '%s' "$APPLE_CERTIFICATE" | (base64 --decode 2>/dev/null || base64 -D) > "$CERT_PATH" - # Save original default keychain for cleanup ORIGINAL_DEFAULT_KEYCHAIN=$(security default-keychain -d user | tr -d '"' | xargs) echo "ORIGINAL_DEFAULT_KEYCHAIN=$ORIGINAL_DEFAULT_KEYCHAIN" >> "$GITHUB_ENV" - # Create temporary keychain KEYCHAIN_PATH="$RUNNER_TEMP/build.keychain-db" security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security default-keychain -s "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - - # Import certificate - if ! security import "$CERT_PATH" \ + security import "$CERT_PATH" \ -k "$KEYCHAIN_PATH" \ -P "$APPLE_CERTIFICATE_PASSWORD" \ -T /usr/bin/codesign \ - -T /usr/bin/security; then - echo "⚠️ Apple certificate import failed; continuing without macOS signing" >&2 - echo "APPLE_SIGNING_IDENTITY=" >> "$GITHUB_ENV" - rm -f "$CERT_PATH" - exit 0 - fi + -T /usr/bin/security security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - # Dynamically resolve signing identity (must be "Developer ID Application") IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \ | grep "Developer ID Application" | grep -oE '"[^"]+"' | head -1 | tr -d '"') if [ -z "$IDENTITY" ]; then - echo "⚠️ No 'Developer ID Application' identity found; continuing without macOS signing" >&2 + echo "❌ No Developer ID Application signing identity found" >&2 security find-identity -v -p codesigning "$KEYCHAIN_PATH" || true - echo "APPLE_SIGNING_IDENTITY=" >> "$GITHUB_ENV" - rm -f "$CERT_PATH" - exit 0 + exit 1 fi echo "✅ Signing identity: $IDENTITY" echo "APPLE_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" - - # Cleanup certificate file rm -f "$CERT_PATH" - name: Build Tauri App (macOS) @@ -267,16 +244,16 @@ jobs: shell: bash timeout-minutes: 60 env: - APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | set -euo pipefail - if [ -z "${APPLE_SIGNING_IDENTITY:-}" ]; then - unset APPLE_SIGNING_IDENTITY - echo "⚠️ APPLE_SIGNING_IDENTITY is missing; building unsigned macOS bundles" + if [ -z "${APPLE_SIGNING_IDENTITY:-}" ] || [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then + echo "❌ macOS signing/notarization credentials are required for a release" >&2 + exit 1 fi + max_attempts=3 for attempt in $(seq 1 "$max_attempts"); do echo "=== macOS build/notarization attempt ${attempt}/${max_attempts} ===" @@ -341,16 +318,14 @@ jobs: if: runner.os == 'macOS' shell: bash env: - APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }} APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: | set -euxo pipefail mkdir -p release-assets - VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0 + VERSION="${GITHUB_REF_NAME}" - # Locate bundle artifacts TAR_GZ=""; APP_PATH="" for path in \ "src-tauri/target/universal-apple-darwin/release/bundle/macos" \ @@ -363,59 +338,32 @@ jobs: fi done - if [ -z "$TAR_GZ" ]; then - echo "❌ No macOS .tar.gz updater artifact found" >&2 + if [ -z "$TAR_GZ" ] || [ -z "$APP_PATH" ]; then + echo "❌ macOS release artifacts are incomplete" >&2 exit 1 fi - if [ -z "$APP_PATH" ]; then - echo "❌ No .app found" >&2 + if [ ! -f "$TAR_GZ.sig" ]; then + echo "❌ macOS updater signature is missing" >&2 exit 1 fi - - if [ -n "${APPLE_SIGNING_IDENTITY:-}" ] && [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then - # Only staple when a signed/notarized app is expected. - xcrun stapler staple "$APP_PATH" - echo "✅ .app stapled" - else - echo "⚠️ macOS signing or notarization credentials are missing; skipping .app stapling" + if [ -z "${APPLE_SIGNING_IDENTITY:-}" ] || [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then + echo "❌ macOS signing/notarization credentials disappeared before staging" >&2 + exit 1 fi - # 1) Collect .tar.gz (updater artifact) + xcrun stapler staple "$APP_PATH" + echo "✅ .app stapled" + NEW_TAR_GZ="CCSwitchMulti-${VERSION}-macOS.tar.gz" cp "$TAR_GZ" "release-assets/$NEW_TAR_GZ" - [ -f "$TAR_GZ.sig" ] && cp "$TAR_GZ.sig" "release-assets/$NEW_TAR_GZ.sig" || echo ".sig for macOS not found yet" + cp "$TAR_GZ.sig" "release-assets/$NEW_TAR_GZ.sig" echo "macOS updater artifact copied: $NEW_TAR_GZ" - # 2) Collect .app as zip NEW_ZIP="CCSwitchMulti-${VERSION}-macOS.zip" ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "release-assets/$NEW_ZIP" echo "macOS zip ready: $NEW_ZIP" - # 3) 收集 DMG。没有 Apple 签名时,发布 Tauri 已生成的未签名 DMG, - # 避免工作流成功但 Release 静默缺少 macOS DMG。 NEW_DMG="CCSwitchMulti-${VERSION}-macOS.dmg" - if [ -z "${APPLE_SIGNING_IDENTITY:-}" ]; then - DMG_PATH="" - for path in \ - "src-tauri/target/universal-apple-darwin/release/bundle/dmg" \ - "src-tauri/target/aarch64-apple-darwin/release/bundle/dmg" \ - "src-tauri/target/x86_64-apple-darwin/release/bundle/dmg" \ - "src-tauri/target/release/bundle/dmg"; do - if [ -d "$path" ]; then - [ -z "$DMG_PATH" ] && DMG_PATH=$(find "$path" -maxdepth 1 -name "*.dmg" -type f | head -1 || true) - fi - done - - if [ -z "$DMG_PATH" ]; then - echo "❌ APPLE_SIGNING_IDENTITY is missing and no unsigned macOS .dmg artifact was found" >&2 - exit 1 - fi - - cp "$DMG_PATH" "release-assets/$NEW_DMG" - echo "⚠️ APPLE_SIGNING_IDENTITY is missing; publishing unsigned macOS DMG: $NEW_DMG" - exit 0 - fi - HOMEBREW_NO_AUTO_UPDATE=1 brew install create-dmg DMG_STAGE_DIR="$RUNNER_TEMP/dmg-stage" rm -rf "$DMG_STAGE_DIR" @@ -450,8 +398,8 @@ jobs: run: | set -euo pipefail if [ -z "${APPLE_SIGNING_IDENTITY:-}" ] || [ -z "${APPLE_ID:-}" ] || [ -z "${APPLE_PASSWORD:-}" ] || [ -z "${APPLE_TEAM_ID:-}" ]; then - echo "⚠️ macOS signing or notarization credentials are missing; skipping DMG notarization" - exit 0 + echo "❌ macOS signing/notarization credentials are required" >&2 + exit 1 fi DMG_PATH=$(find release-assets -maxdepth 1 -name "*.dmg" -type f | head -1 || true) @@ -491,7 +439,6 @@ jobs: run: | set -euo pipefail - # Verify .app (from Tauri bundle) APP_PATH="" for path in \ "src-tauri/target/universal-apple-darwin/release/bundle/macos" \ @@ -503,36 +450,25 @@ jobs: fi done - if [ -z "$APP_PATH" ]; then - echo "❌ No .app found for verification" >&2 + if [ -z "$APP_PATH" ] || [ -z "${APPLE_SIGNING_IDENTITY:-}" ]; then + echo "❌ Signed .app is unavailable for verification" >&2 exit 1 fi - if [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then - echo "=== Verifying signed .app: $APP_PATH ===" - codesign --verify --deep --strict --verbose=2 "$APP_PATH" - echo "✅ codesign verification passed" - spctl -a -t exec -vv "$APP_PATH" - echo "✅ spctl assessment passed" - xcrun stapler validate "$APP_PATH" - echo "✅ .app stapler validation passed" - else - echo "⚠️ APPLE_SIGNING_IDENTITY is missing; skipping signed macOS verification" - fi + echo "=== Verifying signed .app: $APP_PATH ===" + codesign --verify --deep --strict --verbose=2 "$APP_PATH" + spctl -a -t exec -vv "$APP_PATH" + xcrun stapler validate "$APP_PATH" - # Verify .dmg (from release-assets/, created by create-dmg + notarized) DMG_PATH=$(find release-assets -maxdepth 1 -name "*.dmg" -type f | head -1 || true) - if [ -n "$DMG_PATH" ] && [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then - echo "=== Verifying .dmg: $DMG_PATH ===" - codesign --verify --verbose=2 "$DMG_PATH" - echo "✅ .dmg codesign verification passed" - spctl -a -t open --context context:primary-signature -vv "$DMG_PATH" - echo "✅ .dmg spctl assessment passed" - xcrun stapler validate "$DMG_PATH" - echo "✅ .dmg stapler validation passed" - else - echo "⚠️ No signed macOS DMG available; skipping DMG verification" + if [ -z "$DMG_PATH" ]; then + echo "❌ Signed macOS DMG is unavailable for verification" >&2 + exit 1 fi + echo "=== Verifying .dmg: $DMG_PATH ===" + codesign --verify --verbose=2 "$DMG_PATH" + spctl -a -t open --context context:primary-signature -vv "$DMG_PATH" + xcrun stapler validate "$DMG_PATH" - name: Prepare Windows Assets if: runner.os == 'Windows' @@ -542,7 +478,7 @@ jobs: run: | $ErrorActionPreference = 'Stop' New-Item -ItemType Directory -Force -Path release-assets | Out-Null - $VERSION = $env:GITHUB_REF_NAME # e.g., v3.5.0 + $VERSION = $env:GITHUB_REF_NAME $isArm64 = $env:WINDOWS_RELEASE_ARCH -eq 'arm64' $targetRoot = if ($isArm64) { 'src-tauri/target/aarch64-pc-windows-msvc/release' } else { 'src-tauri/target/release' } $assetSuffix = if ($isArm64) { '-arm64' } else { '' } @@ -567,11 +503,8 @@ jobs: throw 'No Windows ARM64 NSIS installer found' } - # MSI 是兼容旧发布的可选资产。Windows ARM64 不再强制产 MSI,因为 WiX v3 - # light.exe 在 GitHub hosted runner 上对 ARM64 MSI 仍会失败。 $msi = Get-ChildItem -Path (Join-Path $targetRoot 'bundle/msi') -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1 if ($null -eq $msi) { - # 兜底:全局搜索 .msi $msi = Get-ChildItem -Path (Join-Path $targetRoot 'bundle') -Recurse -Include *.msi -ErrorAction SilentlyContinue | Select-Object -First 1 } if ($null -ne $msi) { @@ -635,9 +568,8 @@ jobs: run: | set -euxo pipefail mkdir -p release-assets - VERSION="${GITHUB_REF_NAME}" # e.g., v3.5.0 + VERSION="${GITHUB_REF_NAME}" ARCH="${{ matrix.arch || 'x86_64' }}" - # Updater artifact: AppImage(含对应 .sig) APPIMAGE=$(find src-tauri/target/release/bundle -name "*.AppImage" | head -1 || true) if [ -n "$APPIMAGE" ]; then NEW_APPIMAGE="CCSwitchMulti-${VERSION}-Linux-${ARCH}.AppImage" @@ -647,7 +579,6 @@ jobs: else echo "No AppImage found under target/release/bundle" >&2 fi - # 额外上传 .deb(用于手动安装,不参与 Updater) DEB=$(find src-tauri/target/release/bundle -name "*.deb" | head -1 || true) if [ -n "$DEB" ]; then cp "$DEB" "release-assets/CCSwitchMulti-${VERSION}-Linux-${ARCH}.deb" @@ -655,7 +586,6 @@ jobs: else echo "No .deb found (optional)" fi - # 额外上传 .rpm(用于 Fedora/RHEL/openSUSE 等,不参与 Updater) RPM=$(find src-tauri/target/release/bundle -name "*.rpm" | head -1 || true) if [ -n "$RPM" ]; then cp "$RPM" "release-assets/CCSwitchMulti-${VERSION}-Linux-${ARCH}.rpm" @@ -742,7 +672,7 @@ jobs: ## 下载 - - **macOS**: \`CCSwitchMulti-${TAG}-macOS.dmg\`(未配置 Apple 签名时为未签名版)或 \`CCSwitchMulti-${TAG}-macOS.zip\`(解压即用) + - **macOS**: \`CCSwitchMulti-${TAG}-macOS.dmg\`(签名并公证)或 \`CCSwitchMulti-${TAG}-macOS.zip\`(解压即用) - **Windows (x86_64)**: \`CCSwitchMulti-${TAG}-Windows-Setup.exe\`(安装版)或 \`CCSwitchMulti-${TAG}-Windows-Portable.zip\`(绿色版) - **Windows (ARM64)**: \`CCSwitchMulti-${TAG}-Windows-arm64-Setup.exe\`(安装版)或 \`CCSwitchMulti-${TAG}-Windows-arm64-Portable.zip\`(绿色版) - **Linux (x86_64)**: \`CCSwitchMulti-${TAG}-Linux-x86_64.AppImage\` / \`.deb\` / \`.rpm\` @@ -752,7 +682,7 @@ jobs: --- - macOS DMG 在仓库配置 Apple 签名和公证密钥时会签名并公证;未配置时发布 Tauri 生成的未签名 DMG、updater tarball 和 app zip。 + macOS 正式发布资产要求 Developer ID 签名并通过 Apple 公证;签名或公证失败时 Release workflow 会失败,不发布未签名替代品。 EOF } > release-body.md cat release-body.md @@ -810,7 +740,6 @@ jobs: VERSION="${TAG#v}" PUB_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) base_url="https://github.com/$REPO/releases/download/$TAG" - # 初始化空平台映射 mac_url=""; mac_sig="" win_x64_url=""; win_x64_sig="" win_arm64_url=""; win_arm64_sig="" @@ -824,7 +753,6 @@ jobs: sig_content=$(cat "$sig") case "$fname" in *.tar.gz) - # 视为 macOS updater artifact mac_url="$url"; mac_sig="$sig_content";; *-Windows-arm64.msi) win_arm64_url="$url"; win_arm64_sig="$sig_content";; @@ -840,7 +768,6 @@ jobs: linux_x64_url="$url"; linux_x64_sig="$sig_content";; esac done - # 构造 JSON(仅包含存在的目标) tmp_json=$(mktemp) { echo '{' @@ -850,7 +777,6 @@ jobs: echo ' "platforms": {' first=1 if [ -n "$mac_url" ] && [ -n "$mac_sig" ]; then - # 为兼容 arm64 / x64,重复写入两个键,指向同一 universal 包 for key in darwin-aarch64 darwin-x86_64; do [ $first -eq 0 ] && echo ',' echo " \"$key\": {\"signature\": \"$mac_sig\", \"url\": \"$mac_url\"}" From c00f7ff6a8374b17684e77d0e5776d75145eb8ab Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 13:22:44 +0800 Subject: [PATCH 006/112] fix(storage): restore app config dir migration --- src-tauri/src/app_store.rs | 206 +++++++++++++++++++++++++++++++------ 1 file changed, 174 insertions(+), 32 deletions(-) diff --git a/src-tauri/src/app_store.rs b/src-tauri/src/app_store.rs index a1333c9004b..edab9850418 100644 --- a/src-tauri/src/app_store.rs +++ b/src-tauri/src/app_store.rs @@ -7,6 +7,16 @@ use crate::error::AppError; /// Store 中的键名 const STORE_KEY_APP_CONFIG_DIR: &str = "app_config_dir_override"; +/// 旧 settings.json -> Store 迁移完成标记。 +/// +/// 该标记必须与 override 分离保存:用户迁移后如果主动清除覆盖目录,旧版 +/// settings.json 中残留的字段不能在下一次启动时再次把覆盖目录“复活”。 +const STORE_KEY_APP_CONFIG_DIR_LEGACY_MIGRATED: &str = "app_config_dir_legacy_migrated_v1"; +const LEGACY_APP_CONFIG_DIR_KEYS: &[&str] = &[ + "appConfigDir", + "app_config_dir", + "app_config_dir_override", +]; /// 缓存当前的 app_config_dir 覆盖路径,避免存储 AppHandle static APP_CONFIG_DIR_OVERRIDE: OnceLock>> = OnceLock::new(); @@ -26,8 +36,16 @@ pub fn get_app_config_dir_override() -> Option { override_cache().read().ok()?.clone() } +fn open_paths_store( + app: &tauri::AppHandle, +) -> Result>, AppError> { + app.store_builder("app_paths.json") + .build() + .map_err(|e| AppError::Message(format!("创建 Store 失败: {e}"))) +} + fn read_override_from_store(app: &tauri::AppHandle) -> Option { - let store = match app.store_builder("app_paths.json").build() { + let store = match open_paths_store(app) { Ok(store) => store, Err(e) => { log::warn!("无法创建 Store: {e}"); @@ -63,9 +81,129 @@ fn read_override_from_store(app: &tauri::AppHandle) -> Option { } } -/// 从 Store 刷新 app_config_dir 覆盖值并更新缓存 +fn legacy_migration_completed(app: &tauri::AppHandle) -> bool { + open_paths_store(app) + .ok() + .and_then(|store| store.get(STORE_KEY_APP_CONFIG_DIR_LEGACY_MIGRATED)) + .and_then(|value| value.as_bool()) + .unwrap_or(false) +} + +/// 从旧版 `~/.cc-switch/settings.json` 读取 app_config_dir。 +/// +/// 这里故意读取原始 JSON,而不是反序列化为当前 AppSettings:当前结构已经删除了 +/// 这个字段,直接按新结构读取会静默丢失迁移信息。兼容 snake_case / camelCase 以及 +/// 早期实验版的 override 键名。 +fn read_legacy_override_from_settings() -> Option { + let settings_path = crate::config::get_home_dir() + .join(".cc-switch") + .join("settings.json"); + let content = match std::fs::read_to_string(&settings_path) { + Ok(content) => content, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None, + Err(err) => { + log::warn!( + "读取旧 settings.json 以迁移 app_config_dir 失败: path={}, error={err}", + settings_path.display() + ); + return None; + } + }; + + let root: Value = match serde_json::from_str(&content) { + Ok(value) => value, + Err(err) => { + log::warn!( + "旧 settings.json 无法解析,跳过 app_config_dir 自动迁移: path={}, error={err}", + settings_path.display() + ); + return None; + } + }; + + for key in LEGACY_APP_CONFIG_DIR_KEYS { + let Some(raw) = root.get(*key).and_then(Value::as_str) else { + continue; + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + let resolved = resolve_path(trimmed); + if !resolved.exists() { + log::warn!( + "旧 settings.json 的 {key} 指向不存在的目录,跳过自动迁移: {}", + resolved.display() + ); + return None; + } + return Some(resolved); + } + + None +} + +fn persist_override_and_migration_marker( + app: &tauri::AppHandle, + path: Option<&str>, +) -> Result<(), AppError> { + let store = open_paths_store(app)?; + + match path { + Some(value) => store.set( + STORE_KEY_APP_CONFIG_DIR, + Value::String(value.trim().to_string()), + ), + None => store.delete(STORE_KEY_APP_CONFIG_DIR), + } + store.set( + STORE_KEY_APP_CONFIG_DIR_LEGACY_MIGRATED, + Value::Bool(true), + ); + store + .save() + .map_err(|e| AppError::Message(format!("保存 Store 失败: {e}"))) +} + +fn migrate_legacy_override_if_needed(app: &tauri::AppHandle) -> Result, AppError> { + if legacy_migration_completed(app) { + return Ok(None); + } + if read_override_from_store(app).is_some() { + // 已经存在新格式配置,也标记为迁移完成,防止将来用户主动清除后旧值复活。 + persist_override_and_migration_marker( + app, + get_app_config_dir_override() + .as_deref() + .and_then(|path| path.to_str()), + )?; + return Ok(None); + } + + let Some(legacy_path) = read_legacy_override_from_settings() else { + return Ok(None); + }; + let path_string = legacy_path.to_string_lossy().to_string(); + persist_override_and_migration_marker(app, Some(&path_string))?; + log::info!( + "已将旧 settings.json 的 app_config_dir 自动迁移到 Store: {}", + legacy_path.display() + ); + Ok(Some(legacy_path)) +} + +/// 从 Store 刷新 app_config_dir 覆盖值并更新缓存。 +/// +/// 启动阶段会顺带执行一次旧 settings.json 兼容迁移;迁移成功后 Store 成为唯一事实源。 pub fn refresh_app_config_dir_override(app: &tauri::AppHandle) -> Option { - let value = read_override_from_store(app); + let migrated = match migrate_legacy_override_if_needed(app) { + Ok(value) => value, + Err(err) => { + log::warn!("app_config_dir 旧配置迁移失败,将继续读取 Store: {err}"); + None + } + }; + let value = migrated.or_else(|| read_override_from_store(app)); update_cached_override(value.clone()); value } @@ -75,32 +213,14 @@ pub fn set_app_config_dir_to_store( app: &tauri::AppHandle, path: Option<&str>, ) -> Result<(), AppError> { - let store = app - .store_builder("app_paths.json") - .build() - .map_err(|e| AppError::Message(format!("创建 Store 失败: {e}")))?; + let normalized = path.map(str::trim).filter(|value| !value.is_empty()); + persist_override_and_migration_marker(app, normalized)?; - match path { - Some(p) => { - let trimmed = p.trim(); - if !trimmed.is_empty() { - store.set(STORE_KEY_APP_CONFIG_DIR, Value::String(trimmed.to_string())); - log::info!("已将 app_config_dir 写入 Store: {trimmed}"); - } else { - store.delete(STORE_KEY_APP_CONFIG_DIR); - log::info!("已从 Store 中删除 app_config_dir 配置"); - } - } - None => { - store.delete(STORE_KEY_APP_CONFIG_DIR); - log::info!("已从 Store 中删除 app_config_dir 配置"); - } + match normalized { + Some(value) => log::info!("已将 app_config_dir 写入 Store: {value}"), + None => log::info!("已从 Store 中删除 app_config_dir 配置"), } - store - .save() - .map_err(|e| AppError::Message(format!("保存 Store 失败: {e}")))?; - refresh_app_config_dir_override(app); Ok(()) } @@ -124,12 +244,34 @@ fn resolve_path(raw: &str) -> PathBuf { PathBuf::from(raw) } -/// 从旧的 settings.json 迁移 app_config_dir 到 Store +/// 从旧的 settings.json 迁移 app_config_dir 到 Store。 +/// +/// 保留该入口供旧调用方使用;实际迁移由 refresh 路径统一执行,保证启动时数据库初始化 +/// 之前就能得到正确目录。 pub fn migrate_app_config_dir_from_settings(app: &tauri::AppHandle) -> Result<(), AppError> { - // app_config_dir 已从 settings.json 移除,此函数保留但不再执行迁移 - // 如果用户在旧版本设置过 app_config_dir,需要在 Store 中手动配置 - log::info!("app_config_dir 迁移功能已移除,请在设置中重新配置"); - - let _ = refresh_app_config_dir_override(app); + let migrated = migrate_legacy_override_if_needed(app)?; + let value = migrated.or_else(|| read_override_from_store(app)); + update_cached_override(value); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_path_preserves_normal_paths() { + let input = if cfg!(windows) { + r"C:\Users\test\.cc-switch" + } else { + "/tmp/.cc-switch" + }; + assert_eq!(resolve_path(input), PathBuf::from(input)); + } + + #[test] + fn legacy_keys_cover_camel_and_snake_case() { + assert!(LEGACY_APP_CONFIG_DIR_KEYS.contains(&"appConfigDir")); + assert!(LEGACY_APP_CONFIG_DIR_KEYS.contains(&"app_config_dir")); + } +} From 80ffd5f95044d15feabe46d92a398025e0579c12 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 13:23:34 +0800 Subject: [PATCH 007/112] fix(storage): preserve existing app dir override during migration --- src-tauri/src/app_store.rs | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/app_store.rs b/src-tauri/src/app_store.rs index edab9850418..9732d9cad3f 100644 --- a/src-tauri/src/app_store.rs +++ b/src-tauri/src/app_store.rs @@ -150,11 +150,15 @@ fn persist_override_and_migration_marker( let store = open_paths_store(app)?; match path { - Some(value) => store.set( - STORE_KEY_APP_CONFIG_DIR, - Value::String(value.trim().to_string()), - ), - None => store.delete(STORE_KEY_APP_CONFIG_DIR), + Some(value) => { + store.set( + STORE_KEY_APP_CONFIG_DIR, + Value::String(value.trim().to_string()), + ); + } + None => { + store.delete(STORE_KEY_APP_CONFIG_DIR); + } } store.set( STORE_KEY_APP_CONFIG_DIR_LEGACY_MIGRATED, @@ -169,15 +173,12 @@ fn migrate_legacy_override_if_needed(app: &tauri::AppHandle) -> Result Date: Fri, 4 Sep 2026 13:24:23 +0800 Subject: [PATCH 008/112] fix(storage): enable Windows atomic replace API --- src-tauri/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 72884885a94..7ea7cedef98 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -103,7 +103,7 @@ webkit2gtk = { version = "2.0.1", features = ["v2_16"] } [target.'cfg(target_os = "windows")'.dependencies] winreg = "0.52" -windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Graphics_Gdi", "Win32_System_LibraryLoader", "Win32_UI_Controls", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Globalization", "Win32_Graphics_Gdi", "Win32_Storage_FileSystem", "Win32_System_LibraryLoader", "Win32_UI_Controls", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } [target.'cfg(all(target_os = "windows", target_arch = "aarch64"))'.dependencies] rquickjs = { version = "0.8", features = ["bindgen"] } From 370c664214cb368e50065d214c111c775d864899 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 13:25:51 +0800 Subject: [PATCH 009/112] fix(storage): make config writes durable and atomic --- src-tauri/src/config.rs | 175 +++++++++++++++++++++++++++++++--------- 1 file changed, 139 insertions(+), 36 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index ed4686522db..f7f4a3102f4 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -1,11 +1,15 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -use std::fs; -use std::io::Write; +use std::fs::{self, OpenOptions}; +use std::io::{ErrorKind, Write}; use std::path::{Component, Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use crate::error::AppError; +static ATOMIC_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0); +const ATOMIC_TEMP_CREATE_ATTEMPTS: usize = 16; + /// 获取用户主目录,带回退和日志 /// /// ## Windows 注意事项 @@ -293,67 +297,126 @@ pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> { atomic_write(path, data.as_bytes()) } -/// 原子写入:写入临时文件后 rename 替换,避免半写状态 -pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; +fn create_atomic_temp_file(parent: &Path, file_name: &str) -> Result<(PathBuf, fs::File), AppError> { + for _ in 0..ATOMIC_TEMP_CREATE_ATTEMPTS { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let sequence = ATOMIC_WRITE_COUNTER.fetch_add(1, Ordering::Relaxed); + let tmp = parent.join(format!( + ".{file_name}.tmp.{}.{}.{}", + std::process::id(), + ts, + sequence + )); + + match OpenOptions::new().write(true).create_new(true).open(&tmp) { + Ok(file) => return Ok((tmp, file)), + Err(err) if err.kind() == ErrorKind::AlreadyExists => continue, + Err(err) => return Err(AppError::io(&tmp, err)), + } } + Err(AppError::Config(format!( + "无法为 {file_name} 创建唯一临时文件,已重试 {ATOMIC_TEMP_CREATE_ATTEMPTS} 次" + ))) +} + +#[cfg(windows)] +fn replace_file_atomically(tmp: &Path, path: &Path) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let source: Vec = tmp.as_os_str().encode_wide().chain(Some(0)).collect(); + let destination: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let result = unsafe { + MoveFileExW( + source.as_ptr(), + destination.as_ptr(), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + }; + if result == 0 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(not(windows))] +fn replace_file_atomically(tmp: &Path, path: &Path) -> std::io::Result<()> { + fs::rename(tmp, path) +} + +/// 原子且持久地写入文件。 +/// +/// 契约: +/// - 临时文件使用 `create_new`,并发写入不会共享/截断同一个临时文件; +/// - 数据在替换目标之前 `sync_all`,避免进程崩溃留下零长度或半写文件; +/// - Windows 使用 `MoveFileExW(REPLACE_EXISTING)` 原位替换,绝不先删除旧目标; +/// - 任何写入/替换错误都会清理临时文件,旧目标保持不变(文件系统自身故障除外)。 +pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { let parent = path .parent() .ok_or_else(|| AppError::Config("无效的路径".to_string()))?; - let mut tmp = parent.to_path_buf(); + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + let file_name = path .file_name() .ok_or_else(|| AppError::Config("无效的文件名".to_string()))? .to_string_lossy() .to_string(); - let ts = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - tmp.push(format!("{file_name}.tmp.{ts}")); - - { - let mut f = fs::File::create(&tmp).map_err(|e| AppError::io(&tmp, e))?; - f.write_all(data).map_err(|e| AppError::io(&tmp, e))?; - f.flush().map_err(|e| AppError::io(&tmp, e))?; - } + let (tmp, mut file) = create_atomic_temp_file(parent, &file_name)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - if let Ok(meta) = fs::metadata(path) { - let perm = meta.permissions().mode(); - let _ = fs::set_permissions(&tmp, fs::Permissions::from_mode(perm)); + let mode = fs::metadata(path) + .map(|meta| meta.permissions().mode()) + .unwrap_or(0o600); + if let Err(err) = file.set_permissions(fs::Permissions::from_mode(mode)) { + drop(file); + let _ = fs::remove_file(&tmp); + return Err(AppError::io(&tmp, err)); } } - #[cfg(windows)] - { - // Windows 上 rename 目标存在会失败,先移除再重命名(尽量接近原子性) - if path.exists() { - let _ = fs::remove_file(path); - } - fs::rename(&tmp, path).map_err(|e| AppError::IoContext { - context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()), - source: e, - })?; + if let Err(err) = file.write_all(data).and_then(|_| file.sync_all()) { + drop(file); + let _ = fs::remove_file(&tmp); + return Err(AppError::io(&tmp, err)); } + drop(file); - #[cfg(not(windows))] - { - fs::rename(&tmp, path).map_err(|e| AppError::IoContext { + if let Err(err) = replace_file_atomically(&tmp, path) { + let _ = fs::remove_file(&tmp); + return Err(AppError::IoContext { context: format!("原子替换失败: {} -> {}", tmp.display(), path.display()), - source: e, - })?; + source: err, + }); + } + + #[cfg(unix)] + if let Ok(directory) = fs::File::open(parent) { + if let Err(err) = directory.sync_all() { + // 文件本身已经成功替换;目录 fsync 在部分文件系统上可能不支持,因此只记录。 + log::debug!( + "目录元数据同步失败(文件内容已成功替换): path={}, error={err}", + parent.display() + ); + } } + Ok(()) } #[cfg(test)] mod tests { use super::*; + use std::collections::HashSet; #[test] fn derive_mcp_path_from_override_uses_config_dir_for_custom_path() { @@ -521,6 +584,46 @@ mod tests { serde_json::to_string(&sorted_b).unwrap(), ); } + + #[test] + fn atomic_write_replaces_complete_file() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("settings.json"); + + atomic_write(&path, b"first").expect("first write"); + atomic_write(&path, b"second-complete-value").expect("replacement write"); + + assert_eq!(fs::read(&path).expect("read final"), b"second-complete-value"); + } + + #[test] + fn atomic_temp_creation_is_collision_safe() { + let dir = tempfile::tempdir().expect("temp dir"); + let mut paths = HashSet::new(); + let mut files = Vec::new(); + + for _ in 0..32 { + let (path, file) = create_atomic_temp_file(dir.path(), "same.json") + .expect("unique temp file"); + assert!(paths.insert(path.clone()), "duplicate temp path: {path:?}"); + files.push((path, file)); + } + + drop(files); + } + + #[cfg(unix)] + #[test] + fn atomic_write_creates_private_new_files() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("secret.json"); + atomic_write(&path, b"secret").expect("write private file"); + + let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } } /// 复制文件 From 4643b53aa57d6c3665627697999e087c32dc23b3 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 13:33:05 +0800 Subject: [PATCH 010/112] style: rustfmt storage migration --- src-tauri/src/app_store.rs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/app_store.rs b/src-tauri/src/app_store.rs index 9732d9cad3f..53e3d6ea67a 100644 --- a/src-tauri/src/app_store.rs +++ b/src-tauri/src/app_store.rs @@ -12,11 +12,8 @@ const STORE_KEY_APP_CONFIG_DIR: &str = "app_config_dir_override"; /// 该标记必须与 override 分离保存:用户迁移后如果主动清除覆盖目录,旧版 /// settings.json 中残留的字段不能在下一次启动时再次把覆盖目录“复活”。 const STORE_KEY_APP_CONFIG_DIR_LEGACY_MIGRATED: &str = "app_config_dir_legacy_migrated_v1"; -const LEGACY_APP_CONFIG_DIR_KEYS: &[&str] = &[ - "appConfigDir", - "app_config_dir", - "app_config_dir_override", -]; +const LEGACY_APP_CONFIG_DIR_KEYS: &[&str] = + &["appConfigDir", "app_config_dir", "app_config_dir_override"]; /// 缓存当前的 app_config_dir 覆盖路径,避免存储 AppHandle static APP_CONFIG_DIR_OVERRIDE: OnceLock>> = OnceLock::new(); @@ -160,10 +157,7 @@ fn persist_override_and_migration_marker( store.delete(STORE_KEY_APP_CONFIG_DIR); } } - store.set( - STORE_KEY_APP_CONFIG_DIR_LEGACY_MIGRATED, - Value::Bool(true), - ); + store.set(STORE_KEY_APP_CONFIG_DIR_LEGACY_MIGRATED, Value::Bool(true)); store .save() .map_err(|e| AppError::Message(format!("保存 Store 失败: {e}"))) From e9c2d732ef93525acdc23d58cc2f9c53402349be Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 13:34:13 +0800 Subject: [PATCH 011/112] style: rustfmt atomic storage hardening --- src-tauri/src/config.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index f7f4a3102f4..69df1c7a89c 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -297,7 +297,10 @@ pub fn write_text_file(path: &Path, data: &str) -> Result<(), AppError> { atomic_write(path, data.as_bytes()) } -fn create_atomic_temp_file(parent: &Path, file_name: &str) -> Result<(PathBuf, fs::File), AppError> { +fn create_atomic_temp_file( + parent: &Path, + file_name: &str, +) -> Result<(PathBuf, fs::File), AppError> { for _ in 0..ATOMIC_TEMP_CREATE_ATTEMPTS { let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -593,7 +596,10 @@ mod tests { atomic_write(&path, b"first").expect("first write"); atomic_write(&path, b"second-complete-value").expect("replacement write"); - assert_eq!(fs::read(&path).expect("read final"), b"second-complete-value"); + assert_eq!( + fs::read(&path).expect("read final"), + b"second-complete-value" + ); } #[test] @@ -603,8 +609,8 @@ mod tests { let mut files = Vec::new(); for _ in 0..32 { - let (path, file) = create_atomic_temp_file(dir.path(), "same.json") - .expect("unique temp file"); + let (path, file) = + create_atomic_temp_file(dir.path(), "same.json").expect("unique temp file"); assert!(paths.insert(path.clone()), "duplicate temp path: {path:?}"); files.push((path, file)); } From 203b9069ff6f3dfc0fdf0862cd1607fd6c7cecbd Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 13:52:37 +0800 Subject: [PATCH 012/112] chore: stage branch hardening patch --- .github/workflows/branch-hardening-patch.yml | 139 +++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 .github/workflows/branch-hardening-patch.yml diff --git a/.github/workflows/branch-hardening-patch.yml b/.github/workflows/branch-hardening-patch.yml new file mode 100644 index 00000000000..d774917f5f1 --- /dev/null +++ b/.github/workflows/branch-hardening-patch.yml @@ -0,0 +1,139 @@ +name: Branch Hardening Patch + +on: + push: + branches: + - fix/global-hardening-20260904 + paths: + - .github/workflows/branch-hardening-patch.yml + +permissions: + contents: write + +jobs: + apply: + if: github.repository == 'z13321812367-sys/ccswitchmulti' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: fix/global-hardening-20260904 + fetch-depth: 0 + + - name: Apply exact hardening patches + shell: bash + run: | + python - <<'PY' + from pathlib import Path + + def replace_once(path: str, old: str, new: str) -> None: + p = Path(path) + text = p.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected exactly one match, found {count}") + p.write_text(text.replace(old, new, 1), encoding="utf-8") + + # 1. Cross-platform boundary: the Windows-only discovery function needs no non-Windows stub. + replace_once( + "src-tauri/src/codex_desktop.rs", + '''#[cfg(not(target_os = "windows"))]\nfn find_latest_windows_codex_executable() -> Option {\n None\n}\n\n''', + "", + ) + + # 2. settings.json: route persistence through the common durable atomic-write boundary, + # preserve corrupt input before default recovery, and surface non-NotFound read failures. + replace_once( + "src-tauri/src/settings.rs", + '''use serde::{Deserialize, Serialize};\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write;\nuse std::path::PathBuf;\nuse std::sync::{OnceLock, RwLock};\n''', + '''use serde::{Deserialize, Serialize};\nuse sha2::{Digest, Sha256};\nuse std::fs;\nuse std::path::{Path, PathBuf};\nuse std::sync::{OnceLock, RwLock};\n''', + ) + + replace_once( + "src-tauri/src/settings.rs", + ''' fn load_from_file() -> Self {\n let Some(path) = Self::settings_path() else {\n return Self::default();\n };\n if let Ok(content) = fs::read_to_string(&path) {\n match serde_json::from_str::(&content) {\n Ok(mut settings) => {\n settings.normalize_paths();\n settings\n }\n Err(err) => {\n log::warn!(\n "解析设置文件失败,将使用默认设置。路径: {}, 错误: {}",\n path.display(),\n err\n );\n Self::default()\n }\n }\n } else {\n Self::default()\n }\n }\n}\n\nfn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {\n let mut normalized = settings.clone();\n normalized.normalize_paths();\n let Some(path) = AppSettings::settings_path() else {\n return Err(AppError::Config("无法获取用户主目录".to_string()));\n };\n\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n }\n\n let json = serde_json::to_string_pretty(&normalized)\n .map_err(|e| AppError::JsonSerialize { source: e })?;\n #[cfg(unix)]\n {\n use std::fs::OpenOptions;\n use std::os::unix::fs::OpenOptionsExt;\n\n let mut file = OpenOptions::new()\n .create(true)\n .write(true)\n .truncate(true)\n .mode(0o600)\n .open(&path)\n .map_err(|e| AppError::io(&path, e))?;\n file.write_all(json.as_bytes())\n .map_err(|e| AppError::io(&path, e))?;\n }\n\n #[cfg(not(unix))]\n {\n fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;\n }\n\n Ok(())\n}\n''', + ''' fn load_from_file() -> Self {\n let Some(path) = Self::settings_path() else {\n return Self::default();\n };\n Self::load_from_path(&path)\n }\n\n fn load_from_path(path: &Path) -> Self {\n match fs::read_to_string(path) {\n Ok(content) => match serde_json::from_str::(&content) {\n Ok(mut settings) => {\n settings.normalize_paths();\n settings\n }\n Err(err) => {\n preserve_corrupt_settings_file(path, &content);\n log::warn!(\n "解析设置文件失败,将使用默认设置。路径: {}, 错误: {}",\n path.display(),\n err\n );\n Self::default()\n }\n },\n Err(err) if err.kind() == std::io::ErrorKind::NotFound => Self::default(),\n Err(err) => {\n log::warn!(\n "读取设置文件失败,将使用默认设置。路径: {}, 错误: {}",\n path.display(),\n err\n );\n Self::default()\n }\n }\n }\n}\n\nfn corrupt_settings_backup_path(path: &Path, content: &str) -> PathBuf {\n let digest = Sha256::digest(content.as_bytes());\n let fingerprint = digest[..8]\n .iter()\n .map(|byte| format!("{byte:02x}"))\n .collect::();\n let file_name = path\n .file_name()\n .and_then(|name| name.to_str())\n .unwrap_or("settings.json");\n path.with_file_name(format!("{file_name}.corrupt-{fingerprint}"))\n}\n\nfn preserve_corrupt_settings_file(path: &Path, content: &str) {\n let backup_path = corrupt_settings_backup_path(path, content);\n if backup_path.exists() {\n return;\n }\n\n match crate::config::atomic_write(&backup_path, content.as_bytes()) {\n Ok(()) => log::warn!(\n "已保留损坏设置文件快照: {}",\n backup_path.display()\n ),\n Err(err) => log::error!(\n "保留损坏设置文件快照失败。原文件仍保留在 {},备份路径: {},错误: {}",\n path.display(),\n backup_path.display(),\n err\n ),\n }\n}\n\nfn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {\n let Some(path) = AppSettings::settings_path() else {\n return Err(AppError::Config("无法获取用户主目录".to_string()));\n };\n save_settings_file_to_path(settings, &path)\n}\n\nfn save_settings_file_to_path(settings: &AppSettings, path: &Path) -> Result<(), AppError> {\n let mut normalized = settings.clone();\n normalized.normalize_paths();\n\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n }\n\n let json = serde_json::to_string_pretty(&normalized)\n .map_err(|e| AppError::JsonSerialize { source: e })?;\n crate::config::atomic_write(path, json.as_bytes())\n}\n''', + ) + + replace_once( + "src-tauri/src/settings.rs", + ''' let _ = set_current_provider(app_type, None);\n''', + ''' set_current_provider(app_type, None)?;\n''', + ) + + replace_once( + "src-tauri/src/settings.rs", + ''' use crate::app_config::AppType;\n\n #[test]\n fn visible_apps_old_settings_default_claude_desktop_visible() {\n''', + ''' use crate::app_config::AppType;\n\n #[test]\n fn corrupt_settings_are_backed_up_once_before_default_recovery() {\n let dir = tempfile::tempdir().expect("tempdir");\n let path = dir.path().join("settings.json");\n let corrupt = r#"{\"webdavSync\":{"#;\n fs::write(&path, corrupt).expect("write corrupt settings");\n\n let loaded = AppSettings::load_from_path(&path);\n assert_eq!(loaded.show_in_tray, AppSettings::default().show_in_tray);\n\n let backup = corrupt_settings_backup_path(&path, corrupt);\n assert_eq!(\n fs::read_to_string(&backup).expect("read corruption backup"),\n corrupt\n );\n\n let _ = AppSettings::load_from_path(&path);\n let backup_count = fs::read_dir(dir.path())\n .expect("read tempdir")\n .filter_map(Result::ok)\n .filter(|entry| {\n entry\n .file_name()\n .to_string_lossy()\n .starts_with("settings.json.corrupt-")\n })\n .count();\n assert_eq!(backup_count, 1, "same corruption should not create backup spam");\n }\n\n #[test]\n fn settings_save_uses_common_atomic_persistence_boundary() {\n let dir = tempfile::tempdir().expect("tempdir");\n let path = dir.path().join("settings.json");\n let mut settings = AppSettings::default();\n settings.show_in_tray = false;\n\n save_settings_file_to_path(&settings, &path).expect("save settings");\n let saved: AppSettings = serde_json::from_str(\n &fs::read_to_string(&path).expect("read settings"),\n )\n .expect("parse saved settings");\n assert!(!saved.show_in_tray);\n\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt;\n let mode = fs::metadata(&path)\n .expect("settings metadata")\n .permissions()\n .mode()\n & 0o777;\n assert_eq!(mode, 0o600);\n }\n }\n\n #[test]\n fn visible_apps_old_settings_default_claude_desktop_visible() {\n''', + ) + + # 3. Deep links are secret-bearing input. Raw URLs may only reach the parser/import payload; + # diagnostics and error events receive a redacted representation. + replace_once( + "src-tauri/src/lib.rs", + ''' log::info!("✓ Deep link URL detected from {source}: {redacted_url}");\n log::debug!("Deep link URL (raw) from {source}: {url_str}");\n''', + ''' log::info!("✓ Deep link URL detected from {source}: {redacted_url}");\n log::debug!(\n "Deep link URL metadata from {source}: length={}, redacted={redacted_url}",\n url_str.len()\n );\n''', + ) + replace_once( + "src-tauri/src/lib.rs", + ''' Err(e) => {\n log::error!("✗ Failed to parse deep link URL: {e}");\n\n if let Err(emit_err) = app.emit(\n "deeplink-error",\n serde_json::json!({\n "url": url_str,\n "error": e.to_string()\n }),\n ) {\n''', + ''' Err(e) => {\n log::error!("✗ Failed to parse deep link URL ({redacted_url}): {e}");\n\n if let Err(emit_err) = app.emit(\n "deeplink-error",\n serde_json::json!({\n "url": redacted_url,\n "error": e.to_string()\n }),\n ) {\n''', + ) + + lib_path = Path("src-tauri/src/lib.rs") + lib_text = lib_path.read_text(encoding="utf-8") + test_marker = "mod sensitive_deeplink_boundary_tests" + if test_marker in lib_text: + raise SystemExit("src-tauri/src/lib.rs: sensitive deep-link tests already exist") + lib_text += '''\n\n#[cfg(test)]\nmod sensitive_deeplink_boundary_tests {\n use super::redact_url_for_log;\n\n #[test]\n fn deep_link_log_redaction_keeps_keys_but_never_secret_values() {\n let raw = "ccswitch://v1/import?resource=provider&name=demo&apiKey=sk-secret&usageAccessToken=token-secret#fragment-secret";\n let redacted = redact_url_for_log(raw);\n\n assert!(redacted.contains("apiKey"));\n assert!(redacted.contains("usageAccessToken"));\n assert!(!redacted.contains("sk-secret"));\n assert!(!redacted.contains("token-secret"));\n assert!(!redacted.contains("fragment-secret"));\n }\n\n #[test]\n fn malformed_deep_link_redaction_drops_query_values() {\n let raw = "ccswitch://v1/import?apiKey=top-secret%ZZ";\n let redacted = redact_url_for_log(raw);\n assert!(!redacted.contains("top-secret"));\n assert!(redacted.contains("?[redacted]") || redacted.contains("?[keys:"));\n }\n}\n''' + lib_path.write_text(lib_text, encoding="utf-8") + + # 4. Auto-sync: preserve the primary upload error while making persistence failure observable. + for path, updater, label in [ + ("src-tauri/src/services/webdav_auto_sync.rs", "update_webdav_sync_status", "WebDAV"), + ("src-tauri/src/services/s3_auto_sync.rs", "update_s3_sync_status", "S3"), + ]: + replace_once( + path, + f'''fn persist_auto_sync_error(settings: &mut {{TYPE}}, error: &AppError) {{\n'''.replace("{TYPE}", "WebDavSyncSettings" if label == "WebDAV" else "S3SyncSettings") + + f''' settings.status.last_error = Some(error.to_string());\n settings.status.last_error_source = Some("auto".to_string());\n let _ = settings::{updater}(settings.status.clone());\n}}\n''', + f'''fn persist_auto_sync_error(settings: &mut {{TYPE}}, error: &AppError) -> Result<(), AppError> {{\n'''.replace("{TYPE}", "WebDavSyncSettings" if label == "WebDAV" else "S3SyncSettings") + + f''' settings.status.last_error = Some(error.to_string());\n settings.status.last_error_source = Some("auto".to_string());\n settings::{updater}(settings.status.clone())\n}}\n''', + ) + replace_once( + path, + ''' Err(err) => {\n persist_auto_sync_error(&mut sync_settings, &err);\n emit_auto_sync_status_updated(app, "error", Some(&err.to_string()));\n Err(err)\n }\n''', + f''' Err(err) => {{\n if let Err(persist_err) = persist_auto_sync_error(&mut sync_settings, &err) {{\n log::error!(\n "[{label}][AutoSync] Upload failed and persisting the error status also failed: upload_error={{err}}; persistence_error={{persist_err}}"\n );\n }}\n emit_auto_sync_status_updated(app, "error", Some(&err.to_string()));\n Err(err)\n }}\n''', + ) + + # 5. CI regression guard for the exact failure boundaries fixed above. + guard = Path("scripts/check_rust_failure_boundaries.py") + guard.write_text('''#!/usr/bin/env python3\nfrom pathlib import Path\nimport re\nimport sys\n\nROOT = Path(__file__).resolve().parents[1]\nchecks = [\n (\n ROOT / "src-tauri/src/lib.rs",\n re.compile(r"Deep link URL \\(raw\\)|\\\"url\\\"\\s*:\\s*url_str"),\n "raw deep-link data must not cross the diagnostics/event boundary",\n ),\n (\n ROOT / "src-tauri/src/settings.rs",\n re.compile(r"let _ = set_current_provider\\("),\n "current-provider persistence errors must propagate",\n ),\n (\n ROOT / "src-tauri/src/services/webdav_auto_sync.rs",\n re.compile(r"let _ = settings::update_webdav_sync_status\\("),\n "WebDAV auto-sync status persistence errors must be observable",\n ),\n (\n ROOT / "src-tauri/src/services/s3_auto_sync.rs",\n re.compile(r"let _ = settings::update_s3_sync_status\\("),\n "S3 auto-sync status persistence errors must be observable",\n ),\n]\n\nfailures = []\nfor path, pattern, message in checks:\n text = path.read_text(encoding="utf-8")\n if pattern.search(text):\n failures.append(f"{path.relative_to(ROOT)}: {message}")\n\nif failures:\n print("Rust failure-boundary policy violations:", file=sys.stderr)\n for failure in failures:\n print(f"- {failure}", file=sys.stderr)\n raise SystemExit(1)\n\nprint("Rust failure-boundary policy checks passed")\n''', encoding="utf-8") + + ci_path = Path(".github/workflows/ci.yml") + ci_text = ci_path.read_text(encoding="utf-8") + needle = ''' - name: Check workflow shell interpolation policy\n run: python scripts/check_workflow_shell_interpolation.py\n''' + replacement = needle + '''\n - name: Check Rust failure-boundary policy\n run: python scripts/check_rust_failure_boundaries.py\n''' + if ci_text.count(needle) != 1: + raise SystemExit(".github/workflows/ci.yml: workflow-policy insertion point mismatch") + ci_path.write_text(ci_text.replace(needle, replacement, 1), encoding="utf-8") + PY + + - name: Format Rust + run: cargo fmt --manifest-path src-tauri/Cargo.toml --all + + - name: Verify patch diff + run: git diff --check + + - name: Commit hardening changes + shell: bash + run: | + if git diff --quiet; then + echo "No patch changes produced" + exit 1 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src-tauri/src/codex_desktop.rs src-tauri/src/settings.rs src-tauri/src/lib.rs src-tauri/src/services/webdav_auto_sync.rs src-tauri/src/services/s3_auto_sync.rs scripts/check_rust_failure_boundaries.py .github/workflows/ci.yml + git commit -m "fix: close persistence and sensitive-input failure boundaries" + git push origin HEAD:fix/global-hardening-20260904 From a209ceee8497e2e249e3a1f37b02b3febaba44cd Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 13:55:21 +0800 Subject: [PATCH 013/112] chore: add one-shot hardening patch driver --- scripts/apply_branch_hardening_once.py | 101 +++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 scripts/apply_branch_hardening_once.py diff --git a/scripts/apply_branch_hardening_once.py b/scripts/apply_branch_hardening_once.py new file mode 100644 index 00000000000..8177c146a14 --- /dev/null +++ b/scripts/apply_branch_hardening_once.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def replace_once(path: str, old: str, new: str) -> None: + target = ROOT / path + text = target.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected exactly one match, found {count}") + target.write_text(text.replace(old, new, 1), encoding="utf-8") + + +# 1. Cross-platform boundary: no dead non-Windows stub for a Windows-only probe. +replace_once( + "src-tauri/src/codex_desktop.rs", + '''#[cfg(not(target_os = "windows"))]\nfn find_latest_windows_codex_executable() -> Option {\n None\n}\n\n''', + "", +) + +# 2. settings.json durability + corruption evidence + error propagation. +replace_once( + "src-tauri/src/settings.rs", + '''use serde::{Deserialize, Serialize};\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write;\nuse std::path::PathBuf;\nuse std::sync::{OnceLock, RwLock};\n''', + '''use serde::{Deserialize, Serialize};\nuse sha2::{Digest, Sha256};\nuse std::fs;\nuse std::path::{Path, PathBuf};\nuse std::sync::{OnceLock, RwLock};\n''', +) + +replace_once( + "src-tauri/src/settings.rs", + ''' fn load_from_file() -> Self {\n let Some(path) = Self::settings_path() else {\n return Self::default();\n };\n if let Ok(content) = fs::read_to_string(&path) {\n match serde_json::from_str::(&content) {\n Ok(mut settings) => {\n settings.normalize_paths();\n settings\n }\n Err(err) => {\n log::warn!(\n "解析设置文件失败,将使用默认设置。路径: {}, 错误: {}",\n path.display(),\n err\n );\n Self::default()\n }\n }\n } else {\n Self::default()\n }\n }\n}\n\nfn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {\n let mut normalized = settings.clone();\n normalized.normalize_paths();\n let Some(path) = AppSettings::settings_path() else {\n return Err(AppError::Config("无法获取用户主目录".to_string()));\n };\n\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n }\n\n let json = serde_json::to_string_pretty(&normalized)\n .map_err(|e| AppError::JsonSerialize { source: e })?;\n #[cfg(unix)]\n {\n use std::fs::OpenOptions;\n use std::os::unix::fs::OpenOptionsExt;\n\n let mut file = OpenOptions::new()\n .create(true)\n .write(true)\n .truncate(true)\n .mode(0o600)\n .open(&path)\n .map_err(|e| AppError::io(&path, e))?;\n file.write_all(json.as_bytes())\n .map_err(|e| AppError::io(&path, e))?;\n }\n\n #[cfg(not(unix))]\n {\n fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;\n }\n\n Ok(())\n}\n''', + ''' fn load_from_file() -> Self {\n let Some(path) = Self::settings_path() else {\n return Self::default();\n };\n Self::load_from_path(&path)\n }\n\n fn load_from_path(path: &Path) -> Self {\n match fs::read_to_string(path) {\n Ok(content) => match serde_json::from_str::(&content) {\n Ok(mut settings) => {\n settings.normalize_paths();\n settings\n }\n Err(err) => {\n preserve_corrupt_settings_file(path, &content);\n log::warn!(\n "解析设置文件失败,将使用默认设置。路径: {}, 错误: {}",\n path.display(),\n err\n );\n Self::default()\n }\n },\n Err(err) if err.kind() == std::io::ErrorKind::NotFound => Self::default(),\n Err(err) => {\n log::warn!(\n "读取设置文件失败,将使用默认设置。路径: {}, 错误: {}",\n path.display(),\n err\n );\n Self::default()\n }\n }\n }\n}\n\nfn corrupt_settings_backup_path(path: &Path, content: &str) -> PathBuf {\n let digest = Sha256::digest(content.as_bytes());\n let fingerprint = digest[..8]\n .iter()\n .map(|byte| format!("{byte:02x}"))\n .collect::();\n let file_name = path\n .file_name()\n .and_then(|name| name.to_str())\n .unwrap_or("settings.json");\n path.with_file_name(format!("{file_name}.corrupt-{fingerprint}"))\n}\n\nfn preserve_corrupt_settings_file(path: &Path, content: &str) {\n let backup_path = corrupt_settings_backup_path(path, content);\n if backup_path.exists() {\n return;\n }\n\n match crate::config::atomic_write(&backup_path, content.as_bytes()) {\n Ok(()) => log::warn!(\n "已保留损坏设置文件快照: {}",\n backup_path.display()\n ),\n Err(err) => log::error!(\n "保留损坏设置文件快照失败。原文件仍保留在 {},备份路径: {},错误: {}",\n path.display(),\n backup_path.display(),\n err\n ),\n }\n}\n\nfn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {\n let Some(path) = AppSettings::settings_path() else {\n return Err(AppError::Config("无法获取用户主目录".to_string()));\n };\n save_settings_file_to_path(settings, &path)\n}\n\nfn save_settings_file_to_path(settings: &AppSettings, path: &Path) -> Result<(), AppError> {\n let mut normalized = settings.clone();\n normalized.normalize_paths();\n\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n }\n\n let json = serde_json::to_string_pretty(&normalized)\n .map_err(|e| AppError::JsonSerialize { source: e })?;\n crate::config::atomic_write(path, json.as_bytes())\n}\n''', +) + +replace_once( + "src-tauri/src/settings.rs", + ''' let _ = set_current_provider(app_type, None);\n''', + ''' set_current_provider(app_type, None)?;\n''', +) + +replace_once( + "src-tauri/src/settings.rs", + ''' use crate::app_config::AppType;\n\n #[test]\n fn visible_apps_old_settings_default_claude_desktop_visible() {\n''', + ''' use crate::app_config::AppType;\n\n #[test]\n fn corrupt_settings_are_backed_up_once_before_default_recovery() {\n let dir = tempfile::tempdir().expect("tempdir");\n let path = dir.path().join("settings.json");\n let corrupt = r#"{\"webdavSync\":{"#;\n fs::write(&path, corrupt).expect("write corrupt settings");\n\n let loaded = AppSettings::load_from_path(&path);\n assert_eq!(loaded.show_in_tray, AppSettings::default().show_in_tray);\n\n let backup = corrupt_settings_backup_path(&path, corrupt);\n assert_eq!(\n fs::read_to_string(&backup).expect("read corruption backup"),\n corrupt\n );\n\n let _ = AppSettings::load_from_path(&path);\n let backup_count = fs::read_dir(dir.path())\n .expect("read tempdir")\n .filter_map(Result::ok)\n .filter(|entry| {\n entry\n .file_name()\n .to_string_lossy()\n .starts_with("settings.json.corrupt-")\n })\n .count();\n assert_eq!(backup_count, 1, "same corruption should not create backup spam");\n }\n\n #[test]\n fn settings_save_uses_common_atomic_persistence_boundary() {\n let dir = tempfile::tempdir().expect("tempdir");\n let path = dir.path().join("settings.json");\n let mut settings = AppSettings::default();\n settings.show_in_tray = false;\n\n save_settings_file_to_path(&settings, &path).expect("save settings");\n let saved: AppSettings = serde_json::from_str(\n &fs::read_to_string(&path).expect("read settings"),\n )\n .expect("parse saved settings");\n assert!(!saved.show_in_tray);\n\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt;\n let mode = fs::metadata(&path)\n .expect("settings metadata")\n .permissions()\n .mode()\n & 0o777;\n assert_eq!(mode, 0o600);\n }\n }\n\n #[test]\n fn visible_apps_old_settings_default_claude_desktop_visible() {\n''', +) + +# 3. Deep-link raw input is secret-bearing. Only parser/import may receive raw values. +replace_once( + "src-tauri/src/lib.rs", + ''' log::info!("✓ Deep link URL detected from {source}: {redacted_url}");\n log::debug!("Deep link URL (raw) from {source}: {url_str}");\n''', + ''' log::info!("✓ Deep link URL detected from {source}: {redacted_url}");\n log::debug!(\n "Deep link URL metadata from {source}: length={}, redacted={redacted_url}",\n url_str.len()\n );\n''', +) + +replace_once( + "src-tauri/src/lib.rs", + ''' Err(e) => {\n log::error!("✗ Failed to parse deep link URL: {e}");\n\n if let Err(emit_err) = app.emit(\n "deeplink-error",\n serde_json::json!({\n "url": url_str,\n "error": e.to_string()\n }),\n ) {\n''', + ''' Err(e) => {\n log::error!("✗ Failed to parse deep link URL ({redacted_url}): {e}");\n\n if let Err(emit_err) = app.emit(\n "deeplink-error",\n serde_json::json!({\n "url": redacted_url,\n "error": e.to_string()\n }),\n ) {\n''', +) + +lib_path = ROOT / "src-tauri/src/lib.rs" +lib_text = lib_path.read_text(encoding="utf-8") +if "mod sensitive_deeplink_boundary_tests" in lib_text: + raise SystemExit("src-tauri/src/lib.rs: sensitive deep-link tests already exist") +lib_text += '''\n\n#[cfg(test)]\nmod sensitive_deeplink_boundary_tests {\n use super::redact_url_for_log;\n\n #[test]\n fn deep_link_log_redaction_keeps_keys_but_never_secret_values() {\n let raw = "ccswitch://v1/import?resource=provider&name=demo&apiKey=sk-secret&usageAccessToken=token-secret#fragment-secret";\n let redacted = redact_url_for_log(raw);\n\n assert!(redacted.contains("apiKey"));\n assert!(redacted.contains("usageAccessToken"));\n assert!(!redacted.contains("sk-secret"));\n assert!(!redacted.contains("token-secret"));\n assert!(!redacted.contains("fragment-secret"));\n }\n\n #[test]\n fn malformed_deep_link_redaction_drops_query_values() {\n let raw = "ccswitch://v1/import?apiKey=top-secret%ZZ";\n let redacted = redact_url_for_log(raw);\n assert!(!redacted.contains("top-secret"));\n assert!(redacted.contains("?[redacted]") || redacted.contains("?[keys:"));\n }\n}\n''' +lib_path.write_text(lib_text, encoding="utf-8") + +# 4. Auto-sync must not swallow the secondary persistence failure. +for path, type_name, updater, label in [ + ( + "src-tauri/src/services/webdav_auto_sync.rs", + "WebDavSyncSettings", + "update_webdav_sync_status", + "WebDAV", + ), + ( + "src-tauri/src/services/s3_auto_sync.rs", + "S3SyncSettings", + "update_s3_sync_status", + "S3", + ), +]: + replace_once( + path, + f'''fn persist_auto_sync_error(settings: &mut {type_name}, error: &AppError) {{\n settings.status.last_error = Some(error.to_string());\n settings.status.last_error_source = Some("auto".to_string());\n let _ = settings::{updater}(settings.status.clone());\n}}\n''', + f'''fn persist_auto_sync_error(\n settings: &mut {type_name},\n error: &AppError,\n) -> Result<(), AppError> {{\n settings.status.last_error = Some(error.to_string());\n settings.status.last_error_source = Some("auto".to_string());\n settings::{updater}(settings.status.clone())\n}}\n''', + ) + replace_once( + path, + ''' Err(err) => {\n persist_auto_sync_error(&mut sync_settings, &err);\n emit_auto_sync_status_updated(app, "error", Some(&err.to_string()));\n Err(err)\n }\n''', + f''' Err(err) => {{\n if let Err(persist_err) = persist_auto_sync_error(&mut sync_settings, &err) {{\n log::error!(\n "[{label}][AutoSync] Upload failed and persisting the error status also failed: upload_error={{err}}; persistence_error={{persist_err}}"\n );\n }}\n emit_auto_sync_status_updated(app, "error", Some(&err.to_string()));\n Err(err)\n }}\n''', + ) + +# 5. Permanent guard for these exact failure boundaries. +guard = ROOT / "scripts/check_rust_failure_boundaries.py" +guard.write_text( + '''#!/usr/bin/env python3\nfrom pathlib import Path\nimport re\nimport sys\n\nROOT = Path(__file__).resolve().parents[1]\nCHECKS = [\n (ROOT / "src-tauri/src/lib.rs", re.compile(r'Deep link URL \\(raw\\)|"url"\\s*:\\s*url_str'), "raw deep-link data must not cross the diagnostics/event boundary"),\n (ROOT / "src-tauri/src/settings.rs", re.compile(r"let _ = set_current_provider\\("), "current-provider persistence errors must propagate"),\n (ROOT / "src-tauri/src/services/webdav_auto_sync.rs", re.compile(r"let _ = settings::update_webdav_sync_status\\("), "WebDAV auto-sync status persistence errors must be observable"),\n (ROOT / "src-tauri/src/services/s3_auto_sync.rs", re.compile(r"let _ = settings::update_s3_sync_status\\("), "S3 auto-sync status persistence errors must be observable"),\n]\n\nfailures = []\nfor path, pattern, message in CHECKS:\n if pattern.search(path.read_text(encoding="utf-8")):\n failures.append(f"{path.relative_to(ROOT)}: {message}")\n\nif failures:\n print("Rust failure-boundary policy violations:", file=sys.stderr)\n for failure in failures:\n print(f"- {failure}", file=sys.stderr)\n raise SystemExit(1)\n\nprint("Rust failure-boundary policy checks passed")\n''', + encoding="utf-8", +) + +print("One-shot branch hardening patches applied") From 24a8aec214d9656adc9d97c0d601b0fa826d0aff Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 13:55:36 +0800 Subject: [PATCH 014/112] chore: run one-shot hardening patch --- .../workflows/branch-hardening-patch-v2.yml | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/branch-hardening-patch-v2.yml diff --git a/.github/workflows/branch-hardening-patch-v2.yml b/.github/workflows/branch-hardening-patch-v2.yml new file mode 100644 index 00000000000..ae32282638b --- /dev/null +++ b/.github/workflows/branch-hardening-patch-v2.yml @@ -0,0 +1,47 @@ +name: Branch Hardening Patch V2 + +on: + push: + branches: + - fix/global-hardening-20260904 + paths: + - .github/workflows/branch-hardening-patch-v2.yml + +permissions: + contents: write + +jobs: + apply: + if: github.repository == 'z13321812367-sys/ccswitchmulti' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: fix/global-hardening-20260904 + fetch-depth: 0 + + - name: Apply exact hardening patches + run: python scripts/apply_branch_hardening_once.py + + - name: Format Rust + run: cargo fmt --manifest-path src-tauri/Cargo.toml --all + + - name: Verify patch diff + run: git diff --check + + - name: Commit hardening changes + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + src-tauri/src/codex_desktop.rs \ + src-tauri/src/settings.rs \ + src-tauri/src/lib.rs \ + src-tauri/src/services/webdav_auto_sync.rs \ + src-tauri/src/services/s3_auto_sync.rs \ + scripts/check_rust_failure_boundaries.py + git diff --cached --check + test -n "$(git status --porcelain)" + git commit -m "fix: close persistence and sensitive-input failure boundaries" + git push origin HEAD:fix/global-hardening-20260904 From 8d449b36981bc4e634479f9399c5520bd311d2bd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 05:55:57 +0000 Subject: [PATCH 015/112] fix: close persistence and sensitive-input failure boundaries --- scripts/check_rust_failure_boundaries.py | 25 ++++ src-tauri/src/codex_desktop.rs | 5 - src-tauri/src/lib.rs | 34 ++++- src-tauri/src/services/s3_auto_sync.rs | 13 +- src-tauri/src/services/webdav_auto_sync.rs | 13 +- src-tauri/src/settings.rs | 146 ++++++++++++++++----- 6 files changed, 190 insertions(+), 46 deletions(-) create mode 100644 scripts/check_rust_failure_boundaries.py diff --git a/scripts/check_rust_failure_boundaries.py b/scripts/check_rust_failure_boundaries.py new file mode 100644 index 00000000000..ce981b13842 --- /dev/null +++ b/scripts/check_rust_failure_boundaries.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +from pathlib import Path +import re +import sys + +ROOT = Path(__file__).resolve().parents[1] +CHECKS = [ + (ROOT / "src-tauri/src/lib.rs", re.compile(r'Deep link URL \(raw\)|"url"\s*:\s*url_str'), "raw deep-link data must not cross the diagnostics/event boundary"), + (ROOT / "src-tauri/src/settings.rs", re.compile(r"let _ = set_current_provider\("), "current-provider persistence errors must propagate"), + (ROOT / "src-tauri/src/services/webdav_auto_sync.rs", re.compile(r"let _ = settings::update_webdav_sync_status\("), "WebDAV auto-sync status persistence errors must be observable"), + (ROOT / "src-tauri/src/services/s3_auto_sync.rs", re.compile(r"let _ = settings::update_s3_sync_status\("), "S3 auto-sync status persistence errors must be observable"), +] + +failures = [] +for path, pattern, message in CHECKS: + if pattern.search(path.read_text(encoding="utf-8")): + failures.append(f"{path.relative_to(ROOT)}: {message}") + +if failures: + print("Rust failure-boundary policy violations:", file=sys.stderr) + for failure in failures: + print(f"- {failure}", file=sys.stderr) + raise SystemExit(1) + +print("Rust failure-boundary policy checks passed") diff --git a/src-tauri/src/codex_desktop.rs b/src-tauri/src/codex_desktop.rs index 96e93db8788..81739ac0358 100644 --- a/src-tauri/src/codex_desktop.rs +++ b/src-tauri/src/codex_desktop.rs @@ -1353,11 +1353,6 @@ fn collect_windowsapps_codex_executable_candidates(candidates: &mut Vec<(Vec Option { - None -} - /// `Get-AppxPackage` 返回的 Codex Windows App 安装摘要。 #[cfg_attr(not(target_os = "windows"), allow(dead_code))] #[derive(Debug, Deserialize)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d13b12af16e..661aacfa66a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -135,7 +135,10 @@ fn handle_deeplink_url( let redacted_url = redact_url_for_log(url_str); log::info!("✓ Deep link URL detected from {source}: {redacted_url}"); - log::debug!("Deep link URL (raw) from {source}: {url_str}"); + log::debug!( + "Deep link URL metadata from {source}: length={}, redacted={redacted_url}", + url_str.len() + ); match crate::deeplink::parse_deeplink_url(url_str) { Ok(request) => { @@ -166,12 +169,12 @@ fn handle_deeplink_url( } } Err(e) => { - log::error!("✗ Failed to parse deep link URL: {e}"); + log::error!("✗ Failed to parse deep link URL ({redacted_url}): {e}"); if let Err(emit_err) = app.emit( "deeplink-error", serde_json::json!({ - "url": url_str, + "url": redacted_url, "error": e.to_string() }), ) { @@ -2157,3 +2160,28 @@ mod tests { ); } } + +#[cfg(test)] +mod sensitive_deeplink_boundary_tests { + use super::redact_url_for_log; + + #[test] + fn deep_link_log_redaction_keeps_keys_but_never_secret_values() { + let raw = "ccswitch://v1/import?resource=provider&name=demo&apiKey=sk-secret&usageAccessToken=token-secret#fragment-secret"; + let redacted = redact_url_for_log(raw); + + assert!(redacted.contains("apiKey")); + assert!(redacted.contains("usageAccessToken")); + assert!(!redacted.contains("sk-secret")); + assert!(!redacted.contains("token-secret")); + assert!(!redacted.contains("fragment-secret")); + } + + #[test] + fn malformed_deep_link_redaction_drops_query_values() { + let raw = "ccswitch://v1/import?apiKey=top-secret%ZZ"; + let redacted = redact_url_for_log(raw); + assert!(!redacted.contains("top-secret")); + assert!(redacted.contains("?[redacted]") || redacted.contains("?[keys:")); + } +} diff --git a/src-tauri/src/services/s3_auto_sync.rs b/src-tauri/src/services/s3_auto_sync.rs index 3249caeec14..587e3ff3ea5 100644 --- a/src-tauri/src/services/s3_auto_sync.rs +++ b/src-tauri/src/services/s3_auto_sync.rs @@ -79,10 +79,13 @@ fn should_run_auto_sync(settings: Option<&S3SyncSettings>) -> bool { sync.enabled && sync.auto_sync } -fn persist_auto_sync_error(settings: &mut S3SyncSettings, error: &AppError) { +fn persist_auto_sync_error( + settings: &mut S3SyncSettings, + error: &AppError, +) -> Result<(), AppError> { settings.status.last_error = Some(error.to_string()); settings.status.last_error_source = Some("auto".to_string()); - let _ = settings::update_s3_sync_status(settings.status.clone()); + settings::update_s3_sync_status(settings.status.clone()) } fn emit_auto_sync_status_updated(app: &AppHandle, status: &str, error: Option<&str>) { @@ -124,7 +127,11 @@ async fn run_auto_sync_upload( Ok(()) } Err(err) => { - persist_auto_sync_error(&mut sync_settings, &err); + if let Err(persist_err) = persist_auto_sync_error(&mut sync_settings, &err) { + log::error!( + "[S3][AutoSync] Upload failed and persisting the error status also failed: upload_error={err}; persistence_error={persist_err}" + ); + } emit_auto_sync_status_updated(app, "error", Some(&err.to_string())); Err(err) } diff --git a/src-tauri/src/services/webdav_auto_sync.rs b/src-tauri/src/services/webdav_auto_sync.rs index 5fe26e8c9aa..9aadfd7e36f 100644 --- a/src-tauri/src/services/webdav_auto_sync.rs +++ b/src-tauri/src/services/webdav_auto_sync.rs @@ -79,10 +79,13 @@ fn should_run_auto_sync(settings: Option<&WebDavSyncSettings>) -> bool { sync.enabled && sync.auto_sync } -fn persist_auto_sync_error(settings: &mut WebDavSyncSettings, error: &AppError) { +fn persist_auto_sync_error( + settings: &mut WebDavSyncSettings, + error: &AppError, +) -> Result<(), AppError> { settings.status.last_error = Some(error.to_string()); settings.status.last_error_source = Some("auto".to_string()); - let _ = settings::update_webdav_sync_status(settings.status.clone()); + settings::update_webdav_sync_status(settings.status.clone()) } fn emit_auto_sync_status_updated(app: &AppHandle, status: &str, error: Option<&str>) { @@ -128,7 +131,11 @@ async fn run_auto_sync_upload( Ok(()) } Err(err) => { - persist_auto_sync_error(&mut sync_settings, &err); + if let Err(persist_err) = persist_auto_sync_error(&mut sync_settings, &err) { + log::error!( + "[WebDAV][AutoSync] Upload failed and persisting the error status also failed: upload_error={err}; persistence_error={persist_err}" + ); + } emit_auto_sync_status_updated(app, "error", Some(&err.to_string())); Err(err) } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index e4a151453d6..71e772c5214 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1,8 +1,7 @@ use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::fs; -#[cfg(unix)] -use std::io::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::{OnceLock, RwLock}; use crate::app_config::AppType; @@ -626,13 +625,18 @@ impl AppSettings { let Some(path) = Self::settings_path() else { return Self::default(); }; - if let Ok(content) = fs::read_to_string(&path) { - match serde_json::from_str::(&content) { + Self::load_from_path(&path) + } + + fn load_from_path(path: &Path) -> Self { + match fs::read_to_string(path) { + Ok(content) => match serde_json::from_str::(&content) { Ok(mut settings) => { settings.normalize_paths(); settings } Err(err) => { + preserve_corrupt_settings_file(path, &content); log::warn!( "解析设置文件失败,将使用默认设置。路径: {}, 错误: {}", path.display(), @@ -640,19 +644,60 @@ impl AppSettings { ); Self::default() } + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Self::default(), + Err(err) => { + log::warn!( + "读取设置文件失败,将使用默认设置。路径: {}, 错误: {}", + path.display(), + err + ); + Self::default() } - } else { - Self::default() } } } +fn corrupt_settings_backup_path(path: &Path, content: &str) -> PathBuf { + let digest = Sha256::digest(content.as_bytes()); + let fingerprint = digest[..8] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("settings.json"); + path.with_file_name(format!("{file_name}.corrupt-{fingerprint}")) +} + +fn preserve_corrupt_settings_file(path: &Path, content: &str) { + let backup_path = corrupt_settings_backup_path(path, content); + if backup_path.exists() { + return; + } + + match crate::config::atomic_write(&backup_path, content.as_bytes()) { + Ok(()) => log::warn!("已保留损坏设置文件快照: {}", backup_path.display()), + Err(err) => log::error!( + "保留损坏设置文件快照失败。原文件仍保留在 {},备份路径: {},错误: {}", + path.display(), + backup_path.display(), + err + ), + } +} + fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> { - let mut normalized = settings.clone(); - normalized.normalize_paths(); let Some(path) = AppSettings::settings_path() else { return Err(AppError::Config("无法获取用户主目录".to_string())); }; + save_settings_file_to_path(settings, &path) +} + +fn save_settings_file_to_path(settings: &AppSettings, path: &Path) -> Result<(), AppError> { + let mut normalized = settings.clone(); + normalized.normalize_paths(); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; @@ -660,28 +705,7 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> { let json = serde_json::to_string_pretty(&normalized) .map_err(|e| AppError::JsonSerialize { source: e })?; - #[cfg(unix)] - { - use std::fs::OpenOptions; - use std::os::unix::fs::OpenOptionsExt; - - let mut file = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .mode(0o600) - .open(&path) - .map_err(|e| AppError::io(&path, e))?; - file.write_all(json.as_bytes()) - .map_err(|e| AppError::io(&path, e))?; - } - - #[cfg(not(unix))] - { - fs::write(&path, json).map_err(|e| AppError::io(&path, e))?; - } - - Ok(()) + crate::config::atomic_write(path, json.as_bytes()) } static SETTINGS_STORE: OnceLock> = OnceLock::new(); @@ -1013,7 +1037,7 @@ pub fn get_effective_current_provider( local_id, app_type.as_str() ); - let _ = set_current_provider(app_type, None); + set_current_provider(app_type, None)?; } // Fallback 到数据库的 is_current @@ -1142,6 +1166,64 @@ mod tests { use super::*; use crate::app_config::AppType; + #[test] + fn corrupt_settings_are_backed_up_once_before_default_recovery() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("settings.json"); + let corrupt = r#"{"webdavSync":{"#; + fs::write(&path, corrupt).expect("write corrupt settings"); + + let loaded = AppSettings::load_from_path(&path); + assert_eq!(loaded.show_in_tray, AppSettings::default().show_in_tray); + + let backup = corrupt_settings_backup_path(&path, corrupt); + assert_eq!( + fs::read_to_string(&backup).expect("read corruption backup"), + corrupt + ); + + let _ = AppSettings::load_from_path(&path); + let backup_count = fs::read_dir(dir.path()) + .expect("read tempdir") + .filter_map(Result::ok) + .filter(|entry| { + entry + .file_name() + .to_string_lossy() + .starts_with("settings.json.corrupt-") + }) + .count(); + assert_eq!( + backup_count, 1, + "same corruption should not create backup spam" + ); + } + + #[test] + fn settings_save_uses_common_atomic_persistence_boundary() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("settings.json"); + let mut settings = AppSettings::default(); + settings.show_in_tray = false; + + save_settings_file_to_path(&settings, &path).expect("save settings"); + let saved: AppSettings = + serde_json::from_str(&fs::read_to_string(&path).expect("read settings")) + .expect("parse saved settings"); + assert!(!saved.show_in_tray); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&path) + .expect("settings metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o600); + } + } + #[test] fn visible_apps_old_settings_default_claude_desktop_visible() { let visible: VisibleApps = serde_json::from_value(serde_json::json!({ From 20d97b2c79f305bf4d5833eb397d0976c7848e73 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 13:56:42 +0800 Subject: [PATCH 016/112] chore: remove one-shot hardening workflow --- .github/workflows/branch-hardening-patch.yml | 139 ------------------- 1 file changed, 139 deletions(-) delete mode 100644 .github/workflows/branch-hardening-patch.yml diff --git a/.github/workflows/branch-hardening-patch.yml b/.github/workflows/branch-hardening-patch.yml deleted file mode 100644 index d774917f5f1..00000000000 --- a/.github/workflows/branch-hardening-patch.yml +++ /dev/null @@ -1,139 +0,0 @@ -name: Branch Hardening Patch - -on: - push: - branches: - - fix/global-hardening-20260904 - paths: - - .github/workflows/branch-hardening-patch.yml - -permissions: - contents: write - -jobs: - apply: - if: github.repository == 'z13321812367-sys/ccswitchmulti' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: fix/global-hardening-20260904 - fetch-depth: 0 - - - name: Apply exact hardening patches - shell: bash - run: | - python - <<'PY' - from pathlib import Path - - def replace_once(path: str, old: str, new: str) -> None: - p = Path(path) - text = p.read_text(encoding="utf-8") - count = text.count(old) - if count != 1: - raise SystemExit(f"{path}: expected exactly one match, found {count}") - p.write_text(text.replace(old, new, 1), encoding="utf-8") - - # 1. Cross-platform boundary: the Windows-only discovery function needs no non-Windows stub. - replace_once( - "src-tauri/src/codex_desktop.rs", - '''#[cfg(not(target_os = "windows"))]\nfn find_latest_windows_codex_executable() -> Option {\n None\n}\n\n''', - "", - ) - - # 2. settings.json: route persistence through the common durable atomic-write boundary, - # preserve corrupt input before default recovery, and surface non-NotFound read failures. - replace_once( - "src-tauri/src/settings.rs", - '''use serde::{Deserialize, Serialize};\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write;\nuse std::path::PathBuf;\nuse std::sync::{OnceLock, RwLock};\n''', - '''use serde::{Deserialize, Serialize};\nuse sha2::{Digest, Sha256};\nuse std::fs;\nuse std::path::{Path, PathBuf};\nuse std::sync::{OnceLock, RwLock};\n''', - ) - - replace_once( - "src-tauri/src/settings.rs", - ''' fn load_from_file() -> Self {\n let Some(path) = Self::settings_path() else {\n return Self::default();\n };\n if let Ok(content) = fs::read_to_string(&path) {\n match serde_json::from_str::(&content) {\n Ok(mut settings) => {\n settings.normalize_paths();\n settings\n }\n Err(err) => {\n log::warn!(\n "解析设置文件失败,将使用默认设置。路径: {}, 错误: {}",\n path.display(),\n err\n );\n Self::default()\n }\n }\n } else {\n Self::default()\n }\n }\n}\n\nfn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {\n let mut normalized = settings.clone();\n normalized.normalize_paths();\n let Some(path) = AppSettings::settings_path() else {\n return Err(AppError::Config("无法获取用户主目录".to_string()));\n };\n\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n }\n\n let json = serde_json::to_string_pretty(&normalized)\n .map_err(|e| AppError::JsonSerialize { source: e })?;\n #[cfg(unix)]\n {\n use std::fs::OpenOptions;\n use std::os::unix::fs::OpenOptionsExt;\n\n let mut file = OpenOptions::new()\n .create(true)\n .write(true)\n .truncate(true)\n .mode(0o600)\n .open(&path)\n .map_err(|e| AppError::io(&path, e))?;\n file.write_all(json.as_bytes())\n .map_err(|e| AppError::io(&path, e))?;\n }\n\n #[cfg(not(unix))]\n {\n fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;\n }\n\n Ok(())\n}\n''', - ''' fn load_from_file() -> Self {\n let Some(path) = Self::settings_path() else {\n return Self::default();\n };\n Self::load_from_path(&path)\n }\n\n fn load_from_path(path: &Path) -> Self {\n match fs::read_to_string(path) {\n Ok(content) => match serde_json::from_str::(&content) {\n Ok(mut settings) => {\n settings.normalize_paths();\n settings\n }\n Err(err) => {\n preserve_corrupt_settings_file(path, &content);\n log::warn!(\n "解析设置文件失败,将使用默认设置。路径: {}, 错误: {}",\n path.display(),\n err\n );\n Self::default()\n }\n },\n Err(err) if err.kind() == std::io::ErrorKind::NotFound => Self::default(),\n Err(err) => {\n log::warn!(\n "读取设置文件失败,将使用默认设置。路径: {}, 错误: {}",\n path.display(),\n err\n );\n Self::default()\n }\n }\n }\n}\n\nfn corrupt_settings_backup_path(path: &Path, content: &str) -> PathBuf {\n let digest = Sha256::digest(content.as_bytes());\n let fingerprint = digest[..8]\n .iter()\n .map(|byte| format!("{byte:02x}"))\n .collect::();\n let file_name = path\n .file_name()\n .and_then(|name| name.to_str())\n .unwrap_or("settings.json");\n path.with_file_name(format!("{file_name}.corrupt-{fingerprint}"))\n}\n\nfn preserve_corrupt_settings_file(path: &Path, content: &str) {\n let backup_path = corrupt_settings_backup_path(path, content);\n if backup_path.exists() {\n return;\n }\n\n match crate::config::atomic_write(&backup_path, content.as_bytes()) {\n Ok(()) => log::warn!(\n "已保留损坏设置文件快照: {}",\n backup_path.display()\n ),\n Err(err) => log::error!(\n "保留损坏设置文件快照失败。原文件仍保留在 {},备份路径: {},错误: {}",\n path.display(),\n backup_path.display(),\n err\n ),\n }\n}\n\nfn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {\n let Some(path) = AppSettings::settings_path() else {\n return Err(AppError::Config("无法获取用户主目录".to_string()));\n };\n save_settings_file_to_path(settings, &path)\n}\n\nfn save_settings_file_to_path(settings: &AppSettings, path: &Path) -> Result<(), AppError> {\n let mut normalized = settings.clone();\n normalized.normalize_paths();\n\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n }\n\n let json = serde_json::to_string_pretty(&normalized)\n .map_err(|e| AppError::JsonSerialize { source: e })?;\n crate::config::atomic_write(path, json.as_bytes())\n}\n''', - ) - - replace_once( - "src-tauri/src/settings.rs", - ''' let _ = set_current_provider(app_type, None);\n''', - ''' set_current_provider(app_type, None)?;\n''', - ) - - replace_once( - "src-tauri/src/settings.rs", - ''' use crate::app_config::AppType;\n\n #[test]\n fn visible_apps_old_settings_default_claude_desktop_visible() {\n''', - ''' use crate::app_config::AppType;\n\n #[test]\n fn corrupt_settings_are_backed_up_once_before_default_recovery() {\n let dir = tempfile::tempdir().expect("tempdir");\n let path = dir.path().join("settings.json");\n let corrupt = r#"{\"webdavSync\":{"#;\n fs::write(&path, corrupt).expect("write corrupt settings");\n\n let loaded = AppSettings::load_from_path(&path);\n assert_eq!(loaded.show_in_tray, AppSettings::default().show_in_tray);\n\n let backup = corrupt_settings_backup_path(&path, corrupt);\n assert_eq!(\n fs::read_to_string(&backup).expect("read corruption backup"),\n corrupt\n );\n\n let _ = AppSettings::load_from_path(&path);\n let backup_count = fs::read_dir(dir.path())\n .expect("read tempdir")\n .filter_map(Result::ok)\n .filter(|entry| {\n entry\n .file_name()\n .to_string_lossy()\n .starts_with("settings.json.corrupt-")\n })\n .count();\n assert_eq!(backup_count, 1, "same corruption should not create backup spam");\n }\n\n #[test]\n fn settings_save_uses_common_atomic_persistence_boundary() {\n let dir = tempfile::tempdir().expect("tempdir");\n let path = dir.path().join("settings.json");\n let mut settings = AppSettings::default();\n settings.show_in_tray = false;\n\n save_settings_file_to_path(&settings, &path).expect("save settings");\n let saved: AppSettings = serde_json::from_str(\n &fs::read_to_string(&path).expect("read settings"),\n )\n .expect("parse saved settings");\n assert!(!saved.show_in_tray);\n\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt;\n let mode = fs::metadata(&path)\n .expect("settings metadata")\n .permissions()\n .mode()\n & 0o777;\n assert_eq!(mode, 0o600);\n }\n }\n\n #[test]\n fn visible_apps_old_settings_default_claude_desktop_visible() {\n''', - ) - - # 3. Deep links are secret-bearing input. Raw URLs may only reach the parser/import payload; - # diagnostics and error events receive a redacted representation. - replace_once( - "src-tauri/src/lib.rs", - ''' log::info!("✓ Deep link URL detected from {source}: {redacted_url}");\n log::debug!("Deep link URL (raw) from {source}: {url_str}");\n''', - ''' log::info!("✓ Deep link URL detected from {source}: {redacted_url}");\n log::debug!(\n "Deep link URL metadata from {source}: length={}, redacted={redacted_url}",\n url_str.len()\n );\n''', - ) - replace_once( - "src-tauri/src/lib.rs", - ''' Err(e) => {\n log::error!("✗ Failed to parse deep link URL: {e}");\n\n if let Err(emit_err) = app.emit(\n "deeplink-error",\n serde_json::json!({\n "url": url_str,\n "error": e.to_string()\n }),\n ) {\n''', - ''' Err(e) => {\n log::error!("✗ Failed to parse deep link URL ({redacted_url}): {e}");\n\n if let Err(emit_err) = app.emit(\n "deeplink-error",\n serde_json::json!({\n "url": redacted_url,\n "error": e.to_string()\n }),\n ) {\n''', - ) - - lib_path = Path("src-tauri/src/lib.rs") - lib_text = lib_path.read_text(encoding="utf-8") - test_marker = "mod sensitive_deeplink_boundary_tests" - if test_marker in lib_text: - raise SystemExit("src-tauri/src/lib.rs: sensitive deep-link tests already exist") - lib_text += '''\n\n#[cfg(test)]\nmod sensitive_deeplink_boundary_tests {\n use super::redact_url_for_log;\n\n #[test]\n fn deep_link_log_redaction_keeps_keys_but_never_secret_values() {\n let raw = "ccswitch://v1/import?resource=provider&name=demo&apiKey=sk-secret&usageAccessToken=token-secret#fragment-secret";\n let redacted = redact_url_for_log(raw);\n\n assert!(redacted.contains("apiKey"));\n assert!(redacted.contains("usageAccessToken"));\n assert!(!redacted.contains("sk-secret"));\n assert!(!redacted.contains("token-secret"));\n assert!(!redacted.contains("fragment-secret"));\n }\n\n #[test]\n fn malformed_deep_link_redaction_drops_query_values() {\n let raw = "ccswitch://v1/import?apiKey=top-secret%ZZ";\n let redacted = redact_url_for_log(raw);\n assert!(!redacted.contains("top-secret"));\n assert!(redacted.contains("?[redacted]") || redacted.contains("?[keys:"));\n }\n}\n''' - lib_path.write_text(lib_text, encoding="utf-8") - - # 4. Auto-sync: preserve the primary upload error while making persistence failure observable. - for path, updater, label in [ - ("src-tauri/src/services/webdav_auto_sync.rs", "update_webdav_sync_status", "WebDAV"), - ("src-tauri/src/services/s3_auto_sync.rs", "update_s3_sync_status", "S3"), - ]: - replace_once( - path, - f'''fn persist_auto_sync_error(settings: &mut {{TYPE}}, error: &AppError) {{\n'''.replace("{TYPE}", "WebDavSyncSettings" if label == "WebDAV" else "S3SyncSettings") + - f''' settings.status.last_error = Some(error.to_string());\n settings.status.last_error_source = Some("auto".to_string());\n let _ = settings::{updater}(settings.status.clone());\n}}\n''', - f'''fn persist_auto_sync_error(settings: &mut {{TYPE}}, error: &AppError) -> Result<(), AppError> {{\n'''.replace("{TYPE}", "WebDavSyncSettings" if label == "WebDAV" else "S3SyncSettings") + - f''' settings.status.last_error = Some(error.to_string());\n settings.status.last_error_source = Some("auto".to_string());\n settings::{updater}(settings.status.clone())\n}}\n''', - ) - replace_once( - path, - ''' Err(err) => {\n persist_auto_sync_error(&mut sync_settings, &err);\n emit_auto_sync_status_updated(app, "error", Some(&err.to_string()));\n Err(err)\n }\n''', - f''' Err(err) => {{\n if let Err(persist_err) = persist_auto_sync_error(&mut sync_settings, &err) {{\n log::error!(\n "[{label}][AutoSync] Upload failed and persisting the error status also failed: upload_error={{err}}; persistence_error={{persist_err}}"\n );\n }}\n emit_auto_sync_status_updated(app, "error", Some(&err.to_string()));\n Err(err)\n }}\n''', - ) - - # 5. CI regression guard for the exact failure boundaries fixed above. - guard = Path("scripts/check_rust_failure_boundaries.py") - guard.write_text('''#!/usr/bin/env python3\nfrom pathlib import Path\nimport re\nimport sys\n\nROOT = Path(__file__).resolve().parents[1]\nchecks = [\n (\n ROOT / "src-tauri/src/lib.rs",\n re.compile(r"Deep link URL \\(raw\\)|\\\"url\\\"\\s*:\\s*url_str"),\n "raw deep-link data must not cross the diagnostics/event boundary",\n ),\n (\n ROOT / "src-tauri/src/settings.rs",\n re.compile(r"let _ = set_current_provider\\("),\n "current-provider persistence errors must propagate",\n ),\n (\n ROOT / "src-tauri/src/services/webdav_auto_sync.rs",\n re.compile(r"let _ = settings::update_webdav_sync_status\\("),\n "WebDAV auto-sync status persistence errors must be observable",\n ),\n (\n ROOT / "src-tauri/src/services/s3_auto_sync.rs",\n re.compile(r"let _ = settings::update_s3_sync_status\\("),\n "S3 auto-sync status persistence errors must be observable",\n ),\n]\n\nfailures = []\nfor path, pattern, message in checks:\n text = path.read_text(encoding="utf-8")\n if pattern.search(text):\n failures.append(f"{path.relative_to(ROOT)}: {message}")\n\nif failures:\n print("Rust failure-boundary policy violations:", file=sys.stderr)\n for failure in failures:\n print(f"- {failure}", file=sys.stderr)\n raise SystemExit(1)\n\nprint("Rust failure-boundary policy checks passed")\n''', encoding="utf-8") - - ci_path = Path(".github/workflows/ci.yml") - ci_text = ci_path.read_text(encoding="utf-8") - needle = ''' - name: Check workflow shell interpolation policy\n run: python scripts/check_workflow_shell_interpolation.py\n''' - replacement = needle + '''\n - name: Check Rust failure-boundary policy\n run: python scripts/check_rust_failure_boundaries.py\n''' - if ci_text.count(needle) != 1: - raise SystemExit(".github/workflows/ci.yml: workflow-policy insertion point mismatch") - ci_path.write_text(ci_text.replace(needle, replacement, 1), encoding="utf-8") - PY - - - name: Format Rust - run: cargo fmt --manifest-path src-tauri/Cargo.toml --all - - - name: Verify patch diff - run: git diff --check - - - name: Commit hardening changes - shell: bash - run: | - if git diff --quiet; then - echo "No patch changes produced" - exit 1 - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src-tauri/src/codex_desktop.rs src-tauri/src/settings.rs src-tauri/src/lib.rs src-tauri/src/services/webdav_auto_sync.rs src-tauri/src/services/s3_auto_sync.rs scripts/check_rust_failure_boundaries.py .github/workflows/ci.yml - git commit -m "fix: close persistence and sensitive-input failure boundaries" - git push origin HEAD:fix/global-hardening-20260904 From 52889009f198beb9519f67cf722967a56cd97e96 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 13:56:48 +0800 Subject: [PATCH 017/112] chore: remove one-shot hardening workflow v2 --- .../workflows/branch-hardening-patch-v2.yml | 47 ------------------- 1 file changed, 47 deletions(-) delete mode 100644 .github/workflows/branch-hardening-patch-v2.yml diff --git a/.github/workflows/branch-hardening-patch-v2.yml b/.github/workflows/branch-hardening-patch-v2.yml deleted file mode 100644 index ae32282638b..00000000000 --- a/.github/workflows/branch-hardening-patch-v2.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Branch Hardening Patch V2 - -on: - push: - branches: - - fix/global-hardening-20260904 - paths: - - .github/workflows/branch-hardening-patch-v2.yml - -permissions: - contents: write - -jobs: - apply: - if: github.repository == 'z13321812367-sys/ccswitchmulti' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: fix/global-hardening-20260904 - fetch-depth: 0 - - - name: Apply exact hardening patches - run: python scripts/apply_branch_hardening_once.py - - - name: Format Rust - run: cargo fmt --manifest-path src-tauri/Cargo.toml --all - - - name: Verify patch diff - run: git diff --check - - - name: Commit hardening changes - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add \ - src-tauri/src/codex_desktop.rs \ - src-tauri/src/settings.rs \ - src-tauri/src/lib.rs \ - src-tauri/src/services/webdav_auto_sync.rs \ - src-tauri/src/services/s3_auto_sync.rs \ - scripts/check_rust_failure_boundaries.py - git diff --cached --check - test -n "$(git status --porcelain)" - git commit -m "fix: close persistence and sensitive-input failure boundaries" - git push origin HEAD:fix/global-hardening-20260904 From 63957cbbdefb949be9b4f1460204c30be11fc747 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 13:56:57 +0800 Subject: [PATCH 018/112] chore: remove one-shot hardening patch driver --- scripts/apply_branch_hardening_once.py | 101 ------------------------- 1 file changed, 101 deletions(-) delete mode 100644 scripts/apply_branch_hardening_once.py diff --git a/scripts/apply_branch_hardening_once.py b/scripts/apply_branch_hardening_once.py deleted file mode 100644 index 8177c146a14..00000000000 --- a/scripts/apply_branch_hardening_once.py +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] - - -def replace_once(path: str, old: str, new: str) -> None: - target = ROOT / path - text = target.read_text(encoding="utf-8") - count = text.count(old) - if count != 1: - raise SystemExit(f"{path}: expected exactly one match, found {count}") - target.write_text(text.replace(old, new, 1), encoding="utf-8") - - -# 1. Cross-platform boundary: no dead non-Windows stub for a Windows-only probe. -replace_once( - "src-tauri/src/codex_desktop.rs", - '''#[cfg(not(target_os = "windows"))]\nfn find_latest_windows_codex_executable() -> Option {\n None\n}\n\n''', - "", -) - -# 2. settings.json durability + corruption evidence + error propagation. -replace_once( - "src-tauri/src/settings.rs", - '''use serde::{Deserialize, Serialize};\nuse std::fs;\n#[cfg(unix)]\nuse std::io::Write;\nuse std::path::PathBuf;\nuse std::sync::{OnceLock, RwLock};\n''', - '''use serde::{Deserialize, Serialize};\nuse sha2::{Digest, Sha256};\nuse std::fs;\nuse std::path::{Path, PathBuf};\nuse std::sync::{OnceLock, RwLock};\n''', -) - -replace_once( - "src-tauri/src/settings.rs", - ''' fn load_from_file() -> Self {\n let Some(path) = Self::settings_path() else {\n return Self::default();\n };\n if let Ok(content) = fs::read_to_string(&path) {\n match serde_json::from_str::(&content) {\n Ok(mut settings) => {\n settings.normalize_paths();\n settings\n }\n Err(err) => {\n log::warn!(\n "解析设置文件失败,将使用默认设置。路径: {}, 错误: {}",\n path.display(),\n err\n );\n Self::default()\n }\n }\n } else {\n Self::default()\n }\n }\n}\n\nfn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {\n let mut normalized = settings.clone();\n normalized.normalize_paths();\n let Some(path) = AppSettings::settings_path() else {\n return Err(AppError::Config("无法获取用户主目录".to_string()));\n };\n\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n }\n\n let json = serde_json::to_string_pretty(&normalized)\n .map_err(|e| AppError::JsonSerialize { source: e })?;\n #[cfg(unix)]\n {\n use std::fs::OpenOptions;\n use std::os::unix::fs::OpenOptionsExt;\n\n let mut file = OpenOptions::new()\n .create(true)\n .write(true)\n .truncate(true)\n .mode(0o600)\n .open(&path)\n .map_err(|e| AppError::io(&path, e))?;\n file.write_all(json.as_bytes())\n .map_err(|e| AppError::io(&path, e))?;\n }\n\n #[cfg(not(unix))]\n {\n fs::write(&path, json).map_err(|e| AppError::io(&path, e))?;\n }\n\n Ok(())\n}\n''', - ''' fn load_from_file() -> Self {\n let Some(path) = Self::settings_path() else {\n return Self::default();\n };\n Self::load_from_path(&path)\n }\n\n fn load_from_path(path: &Path) -> Self {\n match fs::read_to_string(path) {\n Ok(content) => match serde_json::from_str::(&content) {\n Ok(mut settings) => {\n settings.normalize_paths();\n settings\n }\n Err(err) => {\n preserve_corrupt_settings_file(path, &content);\n log::warn!(\n "解析设置文件失败,将使用默认设置。路径: {}, 错误: {}",\n path.display(),\n err\n );\n Self::default()\n }\n },\n Err(err) if err.kind() == std::io::ErrorKind::NotFound => Self::default(),\n Err(err) => {\n log::warn!(\n "读取设置文件失败,将使用默认设置。路径: {}, 错误: {}",\n path.display(),\n err\n );\n Self::default()\n }\n }\n }\n}\n\nfn corrupt_settings_backup_path(path: &Path, content: &str) -> PathBuf {\n let digest = Sha256::digest(content.as_bytes());\n let fingerprint = digest[..8]\n .iter()\n .map(|byte| format!("{byte:02x}"))\n .collect::();\n let file_name = path\n .file_name()\n .and_then(|name| name.to_str())\n .unwrap_or("settings.json");\n path.with_file_name(format!("{file_name}.corrupt-{fingerprint}"))\n}\n\nfn preserve_corrupt_settings_file(path: &Path, content: &str) {\n let backup_path = corrupt_settings_backup_path(path, content);\n if backup_path.exists() {\n return;\n }\n\n match crate::config::atomic_write(&backup_path, content.as_bytes()) {\n Ok(()) => log::warn!(\n "已保留损坏设置文件快照: {}",\n backup_path.display()\n ),\n Err(err) => log::error!(\n "保留损坏设置文件快照失败。原文件仍保留在 {},备份路径: {},错误: {}",\n path.display(),\n backup_path.display(),\n err\n ),\n }\n}\n\nfn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {\n let Some(path) = AppSettings::settings_path() else {\n return Err(AppError::Config("无法获取用户主目录".to_string()));\n };\n save_settings_file_to_path(settings, &path)\n}\n\nfn save_settings_file_to_path(settings: &AppSettings, path: &Path) -> Result<(), AppError> {\n let mut normalized = settings.clone();\n normalized.normalize_paths();\n\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;\n }\n\n let json = serde_json::to_string_pretty(&normalized)\n .map_err(|e| AppError::JsonSerialize { source: e })?;\n crate::config::atomic_write(path, json.as_bytes())\n}\n''', -) - -replace_once( - "src-tauri/src/settings.rs", - ''' let _ = set_current_provider(app_type, None);\n''', - ''' set_current_provider(app_type, None)?;\n''', -) - -replace_once( - "src-tauri/src/settings.rs", - ''' use crate::app_config::AppType;\n\n #[test]\n fn visible_apps_old_settings_default_claude_desktop_visible() {\n''', - ''' use crate::app_config::AppType;\n\n #[test]\n fn corrupt_settings_are_backed_up_once_before_default_recovery() {\n let dir = tempfile::tempdir().expect("tempdir");\n let path = dir.path().join("settings.json");\n let corrupt = r#"{\"webdavSync\":{"#;\n fs::write(&path, corrupt).expect("write corrupt settings");\n\n let loaded = AppSettings::load_from_path(&path);\n assert_eq!(loaded.show_in_tray, AppSettings::default().show_in_tray);\n\n let backup = corrupt_settings_backup_path(&path, corrupt);\n assert_eq!(\n fs::read_to_string(&backup).expect("read corruption backup"),\n corrupt\n );\n\n let _ = AppSettings::load_from_path(&path);\n let backup_count = fs::read_dir(dir.path())\n .expect("read tempdir")\n .filter_map(Result::ok)\n .filter(|entry| {\n entry\n .file_name()\n .to_string_lossy()\n .starts_with("settings.json.corrupt-")\n })\n .count();\n assert_eq!(backup_count, 1, "same corruption should not create backup spam");\n }\n\n #[test]\n fn settings_save_uses_common_atomic_persistence_boundary() {\n let dir = tempfile::tempdir().expect("tempdir");\n let path = dir.path().join("settings.json");\n let mut settings = AppSettings::default();\n settings.show_in_tray = false;\n\n save_settings_file_to_path(&settings, &path).expect("save settings");\n let saved: AppSettings = serde_json::from_str(\n &fs::read_to_string(&path).expect("read settings"),\n )\n .expect("parse saved settings");\n assert!(!saved.show_in_tray);\n\n #[cfg(unix)]\n {\n use std::os::unix::fs::PermissionsExt;\n let mode = fs::metadata(&path)\n .expect("settings metadata")\n .permissions()\n .mode()\n & 0o777;\n assert_eq!(mode, 0o600);\n }\n }\n\n #[test]\n fn visible_apps_old_settings_default_claude_desktop_visible() {\n''', -) - -# 3. Deep-link raw input is secret-bearing. Only parser/import may receive raw values. -replace_once( - "src-tauri/src/lib.rs", - ''' log::info!("✓ Deep link URL detected from {source}: {redacted_url}");\n log::debug!("Deep link URL (raw) from {source}: {url_str}");\n''', - ''' log::info!("✓ Deep link URL detected from {source}: {redacted_url}");\n log::debug!(\n "Deep link URL metadata from {source}: length={}, redacted={redacted_url}",\n url_str.len()\n );\n''', -) - -replace_once( - "src-tauri/src/lib.rs", - ''' Err(e) => {\n log::error!("✗ Failed to parse deep link URL: {e}");\n\n if let Err(emit_err) = app.emit(\n "deeplink-error",\n serde_json::json!({\n "url": url_str,\n "error": e.to_string()\n }),\n ) {\n''', - ''' Err(e) => {\n log::error!("✗ Failed to parse deep link URL ({redacted_url}): {e}");\n\n if let Err(emit_err) = app.emit(\n "deeplink-error",\n serde_json::json!({\n "url": redacted_url,\n "error": e.to_string()\n }),\n ) {\n''', -) - -lib_path = ROOT / "src-tauri/src/lib.rs" -lib_text = lib_path.read_text(encoding="utf-8") -if "mod sensitive_deeplink_boundary_tests" in lib_text: - raise SystemExit("src-tauri/src/lib.rs: sensitive deep-link tests already exist") -lib_text += '''\n\n#[cfg(test)]\nmod sensitive_deeplink_boundary_tests {\n use super::redact_url_for_log;\n\n #[test]\n fn deep_link_log_redaction_keeps_keys_but_never_secret_values() {\n let raw = "ccswitch://v1/import?resource=provider&name=demo&apiKey=sk-secret&usageAccessToken=token-secret#fragment-secret";\n let redacted = redact_url_for_log(raw);\n\n assert!(redacted.contains("apiKey"));\n assert!(redacted.contains("usageAccessToken"));\n assert!(!redacted.contains("sk-secret"));\n assert!(!redacted.contains("token-secret"));\n assert!(!redacted.contains("fragment-secret"));\n }\n\n #[test]\n fn malformed_deep_link_redaction_drops_query_values() {\n let raw = "ccswitch://v1/import?apiKey=top-secret%ZZ";\n let redacted = redact_url_for_log(raw);\n assert!(!redacted.contains("top-secret"));\n assert!(redacted.contains("?[redacted]") || redacted.contains("?[keys:"));\n }\n}\n''' -lib_path.write_text(lib_text, encoding="utf-8") - -# 4. Auto-sync must not swallow the secondary persistence failure. -for path, type_name, updater, label in [ - ( - "src-tauri/src/services/webdav_auto_sync.rs", - "WebDavSyncSettings", - "update_webdav_sync_status", - "WebDAV", - ), - ( - "src-tauri/src/services/s3_auto_sync.rs", - "S3SyncSettings", - "update_s3_sync_status", - "S3", - ), -]: - replace_once( - path, - f'''fn persist_auto_sync_error(settings: &mut {type_name}, error: &AppError) {{\n settings.status.last_error = Some(error.to_string());\n settings.status.last_error_source = Some("auto".to_string());\n let _ = settings::{updater}(settings.status.clone());\n}}\n''', - f'''fn persist_auto_sync_error(\n settings: &mut {type_name},\n error: &AppError,\n) -> Result<(), AppError> {{\n settings.status.last_error = Some(error.to_string());\n settings.status.last_error_source = Some("auto".to_string());\n settings::{updater}(settings.status.clone())\n}}\n''', - ) - replace_once( - path, - ''' Err(err) => {\n persist_auto_sync_error(&mut sync_settings, &err);\n emit_auto_sync_status_updated(app, "error", Some(&err.to_string()));\n Err(err)\n }\n''', - f''' Err(err) => {{\n if let Err(persist_err) = persist_auto_sync_error(&mut sync_settings, &err) {{\n log::error!(\n "[{label}][AutoSync] Upload failed and persisting the error status also failed: upload_error={{err}}; persistence_error={{persist_err}}"\n );\n }}\n emit_auto_sync_status_updated(app, "error", Some(&err.to_string()));\n Err(err)\n }}\n''', - ) - -# 5. Permanent guard for these exact failure boundaries. -guard = ROOT / "scripts/check_rust_failure_boundaries.py" -guard.write_text( - '''#!/usr/bin/env python3\nfrom pathlib import Path\nimport re\nimport sys\n\nROOT = Path(__file__).resolve().parents[1]\nCHECKS = [\n (ROOT / "src-tauri/src/lib.rs", re.compile(r'Deep link URL \\(raw\\)|"url"\\s*:\\s*url_str'), "raw deep-link data must not cross the diagnostics/event boundary"),\n (ROOT / "src-tauri/src/settings.rs", re.compile(r"let _ = set_current_provider\\("), "current-provider persistence errors must propagate"),\n (ROOT / "src-tauri/src/services/webdav_auto_sync.rs", re.compile(r"let _ = settings::update_webdav_sync_status\\("), "WebDAV auto-sync status persistence errors must be observable"),\n (ROOT / "src-tauri/src/services/s3_auto_sync.rs", re.compile(r"let _ = settings::update_s3_sync_status\\("), "S3 auto-sync status persistence errors must be observable"),\n]\n\nfailures = []\nfor path, pattern, message in CHECKS:\n if pattern.search(path.read_text(encoding="utf-8")):\n failures.append(f"{path.relative_to(ROOT)}: {message}")\n\nif failures:\n print("Rust failure-boundary policy violations:", file=sys.stderr)\n for failure in failures:\n print(f"- {failure}", file=sys.stderr)\n raise SystemExit(1)\n\nprint("Rust failure-boundary policy checks passed")\n''', - encoding="utf-8", -) - -print("One-shot branch hardening patches applied") From 54cfa7c8c70cda3d113a729d67a4432323cce233 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 13:57:14 +0800 Subject: [PATCH 019/112] ci: enforce Rust failure-boundary policy --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1692fafe32..66ca2b7706e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,9 @@ jobs: - name: Reject unsafe shell interpolation run: python scripts/check_workflow_shell_interpolation.py + - name: Check Rust failure boundaries + run: python scripts/check_rust_failure_boundaries.py + frontend: name: Frontend Checks runs-on: ubuntu-latest @@ -106,4 +109,4 @@ jobs: run: cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings - name: Run tests - run: cargo test --manifest-path src-tauri/Cargo.toml + run: cargo test --manifest-path src-tauri/Cargo.toml \ No newline at end of file From 3518ff80264185a14c964f6f330c7d8e3c174a5e Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 14:01:58 +0800 Subject: [PATCH 020/112] refactor: centralize auto-sync signal semantics --- src-tauri/src/services/auto_sync_common.rs | 102 +++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src-tauri/src/services/auto_sync_common.rs diff --git a/src-tauri/src/services/auto_sync_common.rs b/src-tauri/src/services/auto_sync_common.rs new file mode 100644 index 00000000000..8b29c438b6a --- /dev/null +++ b/src-tauri/src/services/auto_sync_common.rs @@ -0,0 +1,102 @@ +use std::any::Any; +use std::time::{Duration, Instant}; + +use tokio::sync::mpsc::error::TrySendError; +use tokio::sync::mpsc::Sender; + +pub(crate) const AUTO_SYNC_DEBOUNCE_MS: u64 = 1_000; +pub(crate) const MAX_AUTO_SYNC_WAIT_MS: u64 = 10_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ChangeSignalOutcome { + Enqueued, + Coalesced, + WorkerUnavailable, +} + +pub(crate) fn enqueue_change_signal(tx: &Sender, table: &str) -> ChangeSignalOutcome { + match tx.try_send(table.to_string()) { + Ok(()) => ChangeSignalOutcome::Enqueued, + Err(TrySendError::Full(_)) => ChangeSignalOutcome::Coalesced, + Err(TrySendError::Closed(_)) => ChangeSignalOutcome::WorkerUnavailable, + } +} + +pub(crate) fn should_trigger_for_table(table: &str) -> bool { + let normalized = table.trim().to_ascii_lowercase(); + matches!( + normalized.as_str(), + "providers" + | "provider_endpoints" + | "mcp_servers" + | "prompts" + | "skills" + | "skill_repos" + | "settings" + | "proxy_config" + ) +} + +pub(crate) fn auto_sync_wait_duration(started_at: Instant, now: Instant) -> Option { + let max_wait = Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS); + let debounce = Duration::from_millis(AUTO_SYNC_DEBOUNCE_MS); + let elapsed = now.saturating_duration_since(started_at); + if elapsed >= max_wait { + return None; + } + Some(debounce.min(max_wait - elapsed)) +} + +pub(crate) fn panic_message(payload: &(dyn Any + Send)) -> &str { + if let Some(message) = payload.downcast_ref::<&'static str>() { + message + } else if let Some(message) = payload.downcast_ref::() { + message.as_str() + } else { + "non-string panic payload" + } +} + +#[cfg(test)] +mod tests { + use super::{ + auto_sync_wait_duration, enqueue_change_signal, should_trigger_for_table, + ChangeSignalOutcome, MAX_AUTO_SYNC_WAIT_MS, + }; + use std::time::{Duration, Instant}; + use tokio::sync::mpsc::channel; + + #[test] + fn config_tables_share_one_trigger_policy() { + assert!(should_trigger_for_table("providers")); + assert!(should_trigger_for_table("settings")); + assert!(!should_trigger_for_table("proxy_request_logs")); + assert!(!should_trigger_for_table("provider_health")); + } + + #[test] + fn max_wait_caps_flush_latency_for_continuous_events() { + let started = Instant::now(); + let later = started + Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS + 1); + assert!(auto_sync_wait_duration(started, later).is_none()); + } + + #[tokio::test] + async fn full_queue_is_coalescing_but_closed_queue_is_worker_failure() { + let (tx, rx) = channel::(1); + assert_eq!( + enqueue_change_signal(&tx, "providers"), + ChangeSignalOutcome::Enqueued + ); + assert_eq!( + enqueue_change_signal(&tx, "settings"), + ChangeSignalOutcome::Coalesced + ); + + drop(rx); + assert_eq!( + enqueue_change_signal(&tx, "providers"), + ChangeSignalOutcome::WorkerUnavailable + ); + } +} From c1b1fddde68b2f59515e5778014c9ca2333677b0 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 14:02:08 +0800 Subject: [PATCH 021/112] refactor: register shared auto-sync core --- src-tauri/src/services/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index ae43e079c6c..5945440493b 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,3 +1,4 @@ +pub(crate) mod auto_sync_common; pub mod balance; pub mod codex_oauth_models; pub mod coding_plan; @@ -43,4 +44,4 @@ pub use usage_cache::UsageCache; pub use usage_stats::{ DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats, RequestLogDetail, UsageSummary, UsageSummaryByApp, -}; +}; \ No newline at end of file From 76d095112e99aaa6a03ca2e1e407c01fbb8f323b Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 14:03:08 +0800 Subject: [PATCH 022/112] chore: stage deep-link and auto-sync unification --- ...pply_deeplink_autosync_unification_once.py | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 scripts/apply_deeplink_autosync_unification_once.py diff --git a/scripts/apply_deeplink_autosync_unification_once.py b/scripts/apply_deeplink_autosync_unification_once.py new file mode 100644 index 00000000000..f40d5841d6b --- /dev/null +++ b/scripts/apply_deeplink_autosync_unification_once.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def replace_once(path: str, old: str, new: str) -> None: + target = ROOT / path + text = target.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected exactly one match, found {count}") + target.write_text(text.replace(old, new, 1), encoding="utf-8") + + +# Deep links: collapse the macOS RunEvent::Opened duplicate parser/event path into +# the same redacted boundary used by single-instance and plugin callbacks. +replace_once( + "src-tauri/src/lib.rs", + ''' // 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...)\n RunEvent::Opened { urls } => {\n if let Some(url) = urls.first() {\n let url_str = url.to_string();\n log::info!("RunEvent::Opened with URL: {url_str}");\n\n if url_str.starts_with("ccswitch://") {\n if crate::lightweight::is_lightweight_mode() {\n if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle)\n {\n log::error!("退出轻量模式重建窗口失败: {e}");\n }\n }\n\n // 解析并广播深链接事件,复用与 single_instance 相同的逻辑\n match crate::deeplink::parse_deeplink_url(&url_str) {\n Ok(request) => {\n log::info!(\n "Successfully parsed deep link from RunEvent::Opened: resource={}, app={:?}",\n request.resource,\n request.app\n );\n\n if let Err(e) =\n app_handle.emit("deeplink-import", &request)\n {\n log::error!(\n "Failed to emit deep link event from RunEvent::Opened: {e}"\n );\n }\n }\n Err(e) => {\n log::error!(\n "Failed to parse deep link URL from RunEvent::Opened: {e}"\n );\n\n if let Err(emit_err) = app_handle.emit(\n "deeplink-error",\n serde_json::json!({\n "url": url_str,\n "error": e.to_string()\n }),\n ) {\n log::error!(\n "Failed to emit deep link error event from RunEvent::Opened: {emit_err}"\n );\n }\n }\n }\n\n // 确保主窗口可见\n if let Some(window) = app_handle.get_webview_window("main") {\n let _ = window.unminimize();\n let _ = window.show();\n let _ = window.set_focus();\n }\n }\n }\n }\n''', + ''' // 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...)。\n // 原始 URL 只能进入统一处理器;日志与错误事件都在该边界内脱敏。\n RunEvent::Opened { urls } => {\n if let Some(url) = urls.first() {\n let url_str = url.as_str();\n log::debug!(\n "RunEvent::Opened URL: {}",\n redact_url_for_log(url_str)\n );\n\n if url_str.starts_with("ccswitch://")\n && crate::lightweight::is_lightweight_mode()\n {\n if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle) {\n log::error!("退出轻量模式重建窗口失败: {e}");\n }\n }\n\n handle_deeplink_url(app_handle, url_str, true, "RunEvent::Opened");\n }\n }\n''', +) + +for path, label, settings_type in [ + ("src-tauri/src/services/webdav_auto_sync.rs", "WebDAV", "WebDavSyncSettings"), + ("src-tauri/src/services/s3_auto_sync.rs", "S3", "S3SyncSettings"), +]: + # Imports and duplicate queue/timing policy move into auto_sync_common. + replace_once( + path, + '''use std::sync::atomic::{AtomicUsize, Ordering};\nuse std::sync::Arc;\nuse std::sync::OnceLock;\nuse std::time::{Duration, Instant};\n\nuse serde_json::json;\nuse tauri::{AppHandle, Emitter};\nuse tokio::sync::mpsc::error::TrySendError;\nuse tokio::sync::mpsc::{channel, Receiver, Sender};\n\nuse crate::error::AppError;\n''', + '''use std::panic::AssertUnwindSafe;\nuse std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};\nuse std::sync::Arc;\nuse std::sync::OnceLock;\nuse std::time::Instant;\n\nuse futures::FutureExt;\nuse serde_json::json;\nuse tauri::{AppHandle, Emitter};\nuse tokio::sync::mpsc::{channel, Receiver, Sender};\n\nuse crate::error::AppError;\nuse crate::services::auto_sync_common::{\n auto_sync_wait_duration, enqueue_change_signal, panic_message, should_trigger_for_table,\n ChangeSignalOutcome,\n};\n''', + ) + + replace_once( + path, + '''\nconst AUTO_SYNC_DEBOUNCE_MS: u64 = 1000;\npub(crate) const MAX_AUTO_SYNC_WAIT_MS: u64 = 10_000;\n\nstatic DB_CHANGE_TX: OnceLock> = OnceLock::new();\nstatic AUTO_SYNC_SUPPRESS_DEPTH: AtomicUsize = AtomicUsize::new(0);\n''', + '''\nstatic DB_CHANGE_TX: OnceLock> = OnceLock::new();\nstatic AUTO_SYNC_SUPPRESS_DEPTH: AtomicUsize = AtomicUsize::new(0);\nstatic WORKER_CHANNEL_CLOSED_REPORTED: AtomicBool = AtomicBool::new(false);\n''', + ) + + replace_once( + path, + '''pub fn should_trigger_for_table(table: &str) -> bool {\n let normalized = table.trim().to_ascii_lowercase();\n matches!(\n normalized.as_str(),\n "providers"\n | "provider_endpoints"\n | "mcp_servers"\n | "prompts"\n | "skills"\n | "skill_repos"\n | "settings"\n | "proxy_config"\n )\n}\n\npub(crate) fn enqueue_change_signal(tx: &Sender, table: &str) -> bool {\n match tx.try_send(table.to_string()) {\n Ok(()) => true,\n Err(TrySendError::Full(_)) | Err(TrySendError::Closed(_)) => false,\n }\n}\n\npub(crate) fn auto_sync_wait_duration(started_at: Instant, now: Instant) -> Option {\n let max_wait = Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS);\n let debounce = Duration::from_millis(AUTO_SYNC_DEBOUNCE_MS);\n let elapsed = now.saturating_duration_since(started_at);\n if elapsed >= max_wait {\n return None;\n }\n Some(debounce.min(max_wait - elapsed))\n}\n\n''', + "", + ) + + replace_once( + path, + ''' let Some(tx) = DB_CHANGE_TX.get() else {\n return;\n };\n let _ = enqueue_change_signal(tx, table);\n}\n''', + f''' let Some(tx) = DB_CHANGE_TX.get() else {{\n return;\n }};\n match enqueue_change_signal(tx, table) {{\n ChangeSignalOutcome::Enqueued | ChangeSignalOutcome::Coalesced => {{}}\n ChangeSignalOutcome::WorkerUnavailable => {{\n if !WORKER_CHANNEL_CLOSED_REPORTED.swap(true, Ordering::SeqCst) {{\n log::error!(\n "[{label}][AutoSync] Change-signal channel is closed; the auto-sync worker is unavailable. Further database changes cannot be auto-synced until the worker is reinitialized."\n );\n }}\n }}\n }}\n}}\n''', + ) + + replace_once( + path, + ''' if DB_CHANGE_TX.set(tx).is_err() {\n return;\n }\n\n tauri::async_runtime::spawn(async move {\n''', + ''' if DB_CHANGE_TX.set(tx).is_err() {\n return;\n }\n WORKER_CHANNEL_CLOSED_REPORTED.store(false, Ordering::SeqCst);\n\n tauri::async_runtime::spawn(async move {\n''', + ) + + replace_once( + path, + f''' if let Err(err) = run_auto_sync_upload(&db, &app).await {{\n log::warn!("[{label}][AutoSync] Upload failed: {{err}}");\n }}\n''', + f''' match AssertUnwindSafe(run_auto_sync_upload(&db, &app))\n .catch_unwind()\n .await\n {{\n Ok(Ok(())) => {{}}\n Ok(Err(err)) => log::warn!("[{label}][AutoSync] Upload failed: {{err}}"),\n Err(payload) => log::error!(\n "[{label}][AutoSync] Upload panicked; worker will continue processing later changes: {{}}",\n panic_message(payload.as_ref())\n ),\n }}\n''', + ) + + # Remove duplicate tests now owned by auto_sync_common and simplify imports. + replace_once( + path, + ''' use super::{\n auto_sync_wait_duration, enqueue_change_signal, is_auto_sync_suppressed,\n should_run_auto_sync, should_trigger_for_table, AutoSyncSuppressionGuard,\n MAX_AUTO_SYNC_WAIT_MS,\n };\n''', + ''' use super::{is_auto_sync_suppressed, should_run_auto_sync, AutoSyncSuppressionGuard};\n''', + ) + replace_once(path, ''' use std::time::{Duration, Instant};\n use tokio::sync::mpsc::channel;\n\n''', "") + replace_once( + path, + ''' #[test]\n fn should_trigger_sync_for_config_tables_only() {\n assert!(should_trigger_for_table("providers"));\n assert!(should_trigger_for_table("settings"));\n assert!(!should_trigger_for_table("proxy_request_logs"));\n assert!(!should_trigger_for_table("provider_health"));\n }\n\n''', + "", + ) + replace_once( + path, + ''' #[test]\n fn max_wait_caps_flush_latency_for_continuous_events() {\n let started = Instant::now();\n let later = started + Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS + 1);\n assert!(auto_sync_wait_duration(started, later).is_none());\n }\n\n #[tokio::test]\n async fn enqueue_change_signal_drops_when_channel_is_full() {\n let (tx, _rx) = channel::(1);\n assert!(enqueue_change_signal(&tx, "providers"));\n assert!(!enqueue_change_signal(&tx, "providers"));\n }\n\n''', + "", + ) + +print("Applied deep-link and auto-sync unification patches") From f23a2951c1cf31c9bbb482bcc87ff0e8ca9346cb Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 14:03:20 +0800 Subject: [PATCH 023/112] chore: run one-shot deep-link and auto-sync unification --- .../deeplink-autosync-unify-once.yml | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/deeplink-autosync-unify-once.yml diff --git a/.github/workflows/deeplink-autosync-unify-once.yml b/.github/workflows/deeplink-autosync-unify-once.yml new file mode 100644 index 00000000000..5caf12b7aca --- /dev/null +++ b/.github/workflows/deeplink-autosync-unify-once.yml @@ -0,0 +1,44 @@ +name: Deep Link Auto Sync Unify Once + +on: + push: + branches: + - fix/global-hardening-20260904 + paths: + - .github/workflows/deeplink-autosync-unify-once.yml + +permissions: + contents: write + +jobs: + apply: + if: github.repository == 'z13321812367-sys/ccswitchmulti' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: fix/global-hardening-20260904 + fetch-depth: 0 + + - name: Apply exact unification patches + run: python scripts/apply_deeplink_autosync_unification_once.py + + - name: Format Rust + run: cargo fmt --manifest-path src-tauri/Cargo.toml --all + + - name: Run permanent failure-boundary policy locally + run: python scripts/check_rust_failure_boundaries.py + + - name: Verify patch diff + run: git diff --check + + - name: Commit changes + shell: bash + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src-tauri/src/lib.rs src-tauri/src/services/webdav_auto_sync.rs src-tauri/src/services/s3_auto_sync.rs + git diff --cached --check + test -n "$(git status --porcelain)" + git commit -m "fix: unify deep-link and auto-sync failure boundaries" + git push origin HEAD:fix/global-hardening-20260904 From 05bcc28f04644b0b3f19d54fcd083b3a36e38081 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 06:03:42 +0000 Subject: [PATCH 024/112] fix: unify deep-link and auto-sync failure boundaries --- src-tauri/src/lib.rs | 63 +++---------- src-tauri/src/services/s3_auto_sync.rs | 102 +++++++-------------- src-tauri/src/services/webdav_auto_sync.rs | 102 +++++++-------------- 3 files changed, 73 insertions(+), 194 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 661aacfa66a..008c3c59f69 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1646,63 +1646,22 @@ pub fn run() { } } } - // 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...) + // 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...)。 + // 原始 URL 只能进入统一处理器;日志与错误事件都在该边界内脱敏。 RunEvent::Opened { urls } => { if let Some(url) = urls.first() { - let url_str = url.to_string(); - log::info!("RunEvent::Opened with URL: {url_str}"); - - if url_str.starts_with("ccswitch://") { - if crate::lightweight::is_lightweight_mode() { - if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle) - { - log::error!("退出轻量模式重建窗口失败: {e}"); - } - } - - // 解析并广播深链接事件,复用与 single_instance 相同的逻辑 - match crate::deeplink::parse_deeplink_url(&url_str) { - Ok(request) => { - log::info!( - "Successfully parsed deep link from RunEvent::Opened: resource={}, app={:?}", - request.resource, - request.app - ); - - if let Err(e) = - app_handle.emit("deeplink-import", &request) - { - log::error!( - "Failed to emit deep link event from RunEvent::Opened: {e}" - ); - } - } - Err(e) => { - log::error!( - "Failed to parse deep link URL from RunEvent::Opened: {e}" - ); - - if let Err(emit_err) = app_handle.emit( - "deeplink-error", - serde_json::json!({ - "url": url_str, - "error": e.to_string() - }), - ) { - log::error!( - "Failed to emit deep link error event from RunEvent::Opened: {emit_err}" - ); - } - } - } + let url_str = url.as_str(); + log::debug!("RunEvent::Opened URL: {}", redact_url_for_log(url_str)); - // 确保主窗口可见 - if let Some(window) = app_handle.get_webview_window("main") { - let _ = window.unminimize(); - let _ = window.show(); - let _ = window.set_focus(); + if url_str.starts_with("ccswitch://") + && crate::lightweight::is_lightweight_mode() + { + if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle) { + log::error!("退出轻量模式重建窗口失败: {e}"); } } + + handle_deeplink_url(app_handle, url_str, true, "RunEvent::Opened"); } } _ => {} diff --git a/src-tauri/src/services/s3_auto_sync.rs b/src-tauri/src/services/s3_auto_sync.rs index 587e3ff3ea5..4d2c57886f0 100644 --- a/src-tauri/src/services/s3_auto_sync.rs +++ b/src-tauri/src/services/s3_auto_sync.rs @@ -1,22 +1,25 @@ -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::panic::AssertUnwindSafe; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::sync::OnceLock; -use std::time::{Duration, Instant}; +use std::time::Instant; +use futures::FutureExt; use serde_json::json; use tauri::{AppHandle, Emitter}; -use tokio::sync::mpsc::error::TrySendError; use tokio::sync::mpsc::{channel, Receiver, Sender}; use crate::error::AppError; +use crate::services::auto_sync_common::{ + auto_sync_wait_duration, enqueue_change_signal, panic_message, should_trigger_for_table, + ChangeSignalOutcome, +}; use crate::services::s3_sync; use crate::settings::{self, S3SyncSettings}; -const AUTO_SYNC_DEBOUNCE_MS: u64 = 1000; -pub(crate) const MAX_AUTO_SYNC_WAIT_MS: u64 = 10_000; - static DB_CHANGE_TX: OnceLock> = OnceLock::new(); static AUTO_SYNC_SUPPRESS_DEPTH: AtomicUsize = AtomicUsize::new(0); +static WORKER_CHANNEL_CLOSED_REPORTED: AtomicBool = AtomicBool::new(false); pub(crate) struct AutoSyncSuppressionGuard; @@ -40,38 +43,6 @@ pub(crate) fn is_auto_sync_suppressed() -> bool { AUTO_SYNC_SUPPRESS_DEPTH.load(Ordering::SeqCst) > 0 } -pub fn should_trigger_for_table(table: &str) -> bool { - let normalized = table.trim().to_ascii_lowercase(); - matches!( - normalized.as_str(), - "providers" - | "provider_endpoints" - | "mcp_servers" - | "prompts" - | "skills" - | "skill_repos" - | "settings" - | "proxy_config" - ) -} - -pub(crate) fn enqueue_change_signal(tx: &Sender, table: &str) -> bool { - match tx.try_send(table.to_string()) { - Ok(()) => true, - Err(TrySendError::Full(_)) | Err(TrySendError::Closed(_)) => false, - } -} - -pub(crate) fn auto_sync_wait_duration(started_at: Instant, now: Instant) -> Option { - let max_wait = Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS); - let debounce = Duration::from_millis(AUTO_SYNC_DEBOUNCE_MS); - let elapsed = now.saturating_duration_since(started_at); - if elapsed >= max_wait { - return None; - } - Some(debounce.min(max_wait - elapsed)) -} - fn should_run_auto_sync(settings: Option<&S3SyncSettings>) -> bool { let Some(sync) = settings else { return false; @@ -148,7 +119,16 @@ pub fn notify_db_changed(table: &str) { let Some(tx) = DB_CHANGE_TX.get() else { return; }; - let _ = enqueue_change_signal(tx, table); + match enqueue_change_signal(tx, table) { + ChangeSignalOutcome::Enqueued | ChangeSignalOutcome::Coalesced => {} + ChangeSignalOutcome::WorkerUnavailable => { + if !WORKER_CHANNEL_CLOSED_REPORTED.swap(true, Ordering::SeqCst) { + log::error!( + "[S3][AutoSync] Change-signal channel is closed; the auto-sync worker is unavailable. Further database changes cannot be auto-synced until the worker is reinitialized." + ); + } + } + } } pub fn start_worker(db: Arc, app: tauri::AppHandle) { @@ -161,6 +141,7 @@ pub fn start_worker(db: Arc, app: tauri::AppHandle) { if DB_CHANGE_TX.set(tx).is_err() { return; } + WORKER_CHANNEL_CLOSED_REPORTED.store(false, Ordering::SeqCst); tauri::async_runtime::spawn(async move { run_worker_loop(db, rx, app).await; @@ -190,31 +171,24 @@ async fn run_worker_loop( "[S3][AutoSync] Triggered by table={first_table}, merged_changes={merged_count}" ); - if let Err(err) = run_auto_sync_upload(&db, &app).await { - log::warn!("[S3][AutoSync] Upload failed: {err}"); + match AssertUnwindSafe(run_auto_sync_upload(&db, &app)) + .catch_unwind() + .await + { + Ok(Ok(())) => {} + Ok(Err(err)) => log::warn!("[S3][AutoSync] Upload failed: {err}"), + Err(payload) => log::error!( + "[S3][AutoSync] Upload panicked; worker will continue processing later changes: {}", + panic_message(payload.as_ref()) + ), } } } #[cfg(test)] mod tests { - use super::{ - auto_sync_wait_duration, enqueue_change_signal, is_auto_sync_suppressed, - should_run_auto_sync, should_trigger_for_table, AutoSyncSuppressionGuard, - MAX_AUTO_SYNC_WAIT_MS, - }; + use super::{is_auto_sync_suppressed, should_run_auto_sync, AutoSyncSuppressionGuard}; use crate::settings::S3SyncSettings; - use std::time::{Duration, Instant}; - use tokio::sync::mpsc::channel; - - #[test] - fn should_trigger_sync_for_config_tables_only() { - assert!(should_trigger_for_table("providers")); - assert!(should_trigger_for_table("settings")); - assert!(!should_trigger_for_table("proxy_request_logs")); - assert!(!should_trigger_for_table("provider_health")); - } - #[test] fn suppression_guard_enables_and_restores_state() { assert!(!is_auto_sync_suppressed()); @@ -225,20 +199,6 @@ mod tests { assert!(!is_auto_sync_suppressed()); } - #[test] - fn max_wait_caps_flush_latency_for_continuous_events() { - let started = Instant::now(); - let later = started + Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS + 1); - assert!(auto_sync_wait_duration(started, later).is_none()); - } - - #[tokio::test] - async fn enqueue_change_signal_drops_when_channel_is_full() { - let (tx, _rx) = channel::(1); - assert!(enqueue_change_signal(&tx, "providers")); - assert!(!enqueue_change_signal(&tx, "providers")); - } - #[test] fn should_run_auto_sync_requires_enabled_and_auto_sync_flag() { assert!(!should_run_auto_sync(None)); diff --git a/src-tauri/src/services/webdav_auto_sync.rs b/src-tauri/src/services/webdav_auto_sync.rs index 9aadfd7e36f..c92993227d1 100644 --- a/src-tauri/src/services/webdav_auto_sync.rs +++ b/src-tauri/src/services/webdav_auto_sync.rs @@ -1,22 +1,25 @@ -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::panic::AssertUnwindSafe; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::sync::OnceLock; -use std::time::{Duration, Instant}; +use std::time::Instant; +use futures::FutureExt; use serde_json::json; use tauri::{AppHandle, Emitter}; -use tokio::sync::mpsc::error::TrySendError; use tokio::sync::mpsc::{channel, Receiver, Sender}; use crate::error::AppError; +use crate::services::auto_sync_common::{ + auto_sync_wait_duration, enqueue_change_signal, panic_message, should_trigger_for_table, + ChangeSignalOutcome, +}; use crate::services::webdav_sync as webdav_sync_service; use crate::settings::{self, WebDavSyncSettings}; -const AUTO_SYNC_DEBOUNCE_MS: u64 = 1000; -pub(crate) const MAX_AUTO_SYNC_WAIT_MS: u64 = 10_000; - static DB_CHANGE_TX: OnceLock> = OnceLock::new(); static AUTO_SYNC_SUPPRESS_DEPTH: AtomicUsize = AtomicUsize::new(0); +static WORKER_CHANNEL_CLOSED_REPORTED: AtomicBool = AtomicBool::new(false); pub(crate) struct AutoSyncSuppressionGuard; @@ -40,38 +43,6 @@ pub(crate) fn is_auto_sync_suppressed() -> bool { AUTO_SYNC_SUPPRESS_DEPTH.load(Ordering::SeqCst) > 0 } -pub fn should_trigger_for_table(table: &str) -> bool { - let normalized = table.trim().to_ascii_lowercase(); - matches!( - normalized.as_str(), - "providers" - | "provider_endpoints" - | "mcp_servers" - | "prompts" - | "skills" - | "skill_repos" - | "settings" - | "proxy_config" - ) -} - -pub(crate) fn enqueue_change_signal(tx: &Sender, table: &str) -> bool { - match tx.try_send(table.to_string()) { - Ok(()) => true, - Err(TrySendError::Full(_)) | Err(TrySendError::Closed(_)) => false, - } -} - -pub(crate) fn auto_sync_wait_duration(started_at: Instant, now: Instant) -> Option { - let max_wait = Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS); - let debounce = Duration::from_millis(AUTO_SYNC_DEBOUNCE_MS); - let elapsed = now.saturating_duration_since(started_at); - if elapsed >= max_wait { - return None; - } - Some(debounce.min(max_wait - elapsed)) -} - fn should_run_auto_sync(settings: Option<&WebDavSyncSettings>) -> bool { let Some(sync) = settings else { return false; @@ -152,7 +123,16 @@ pub fn notify_db_changed(table: &str) { let Some(tx) = DB_CHANGE_TX.get() else { return; }; - let _ = enqueue_change_signal(tx, table); + match enqueue_change_signal(tx, table) { + ChangeSignalOutcome::Enqueued | ChangeSignalOutcome::Coalesced => {} + ChangeSignalOutcome::WorkerUnavailable => { + if !WORKER_CHANNEL_CLOSED_REPORTED.swap(true, Ordering::SeqCst) { + log::error!( + "[WebDAV][AutoSync] Change-signal channel is closed; the auto-sync worker is unavailable. Further database changes cannot be auto-synced until the worker is reinitialized." + ); + } + } + } } pub fn start_worker(db: Arc, app: tauri::AppHandle) { @@ -165,6 +145,7 @@ pub fn start_worker(db: Arc, app: tauri::AppHandle) { if DB_CHANGE_TX.set(tx).is_err() { return; } + WORKER_CHANNEL_CLOSED_REPORTED.store(false, Ordering::SeqCst); tauri::async_runtime::spawn(async move { run_worker_loop(db, rx, app).await; @@ -194,31 +175,24 @@ async fn run_worker_loop( "[WebDAV][AutoSync] Triggered by table={first_table}, merged_changes={merged_count}" ); - if let Err(err) = run_auto_sync_upload(&db, &app).await { - log::warn!("[WebDAV][AutoSync] Upload failed: {err}"); + match AssertUnwindSafe(run_auto_sync_upload(&db, &app)) + .catch_unwind() + .await + { + Ok(Ok(())) => {} + Ok(Err(err)) => log::warn!("[WebDAV][AutoSync] Upload failed: {err}"), + Err(payload) => log::error!( + "[WebDAV][AutoSync] Upload panicked; worker will continue processing later changes: {}", + panic_message(payload.as_ref()) + ), } } } #[cfg(test)] mod tests { - use super::{ - auto_sync_wait_duration, enqueue_change_signal, is_auto_sync_suppressed, - should_run_auto_sync, should_trigger_for_table, AutoSyncSuppressionGuard, - MAX_AUTO_SYNC_WAIT_MS, - }; + use super::{is_auto_sync_suppressed, should_run_auto_sync, AutoSyncSuppressionGuard}; use crate::settings::WebDavSyncSettings; - use std::time::{Duration, Instant}; - use tokio::sync::mpsc::channel; - - #[test] - fn should_trigger_sync_for_config_tables_only() { - assert!(should_trigger_for_table("providers")); - assert!(should_trigger_for_table("settings")); - assert!(!should_trigger_for_table("proxy_request_logs")); - assert!(!should_trigger_for_table("provider_health")); - } - #[test] fn suppression_guard_enables_and_restores_state() { assert!(!is_auto_sync_suppressed()); @@ -229,20 +203,6 @@ mod tests { assert!(!is_auto_sync_suppressed()); } - #[test] - fn max_wait_caps_flush_latency_for_continuous_events() { - let started = Instant::now(); - let later = started + Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS + 1); - assert!(auto_sync_wait_duration(started, later).is_none()); - } - - #[tokio::test] - async fn enqueue_change_signal_drops_when_channel_is_full() { - let (tx, _rx) = channel::(1); - assert!(enqueue_change_signal(&tx, "providers")); - assert!(!enqueue_change_signal(&tx, "providers")); - } - #[test] fn should_run_auto_sync_requires_enabled_and_auto_sync_flag() { assert!(!should_run_auto_sync(None)); From 7c90a6471dddbd734e990fb0cc4ccf33de16c0df Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 14:05:11 +0800 Subject: [PATCH 025/112] chore: remove one-shot deep-link auto-sync workflow --- .../deeplink-autosync-unify-once.yml | 44 ------------------- 1 file changed, 44 deletions(-) delete mode 100644 .github/workflows/deeplink-autosync-unify-once.yml diff --git a/.github/workflows/deeplink-autosync-unify-once.yml b/.github/workflows/deeplink-autosync-unify-once.yml deleted file mode 100644 index 5caf12b7aca..00000000000 --- a/.github/workflows/deeplink-autosync-unify-once.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Deep Link Auto Sync Unify Once - -on: - push: - branches: - - fix/global-hardening-20260904 - paths: - - .github/workflows/deeplink-autosync-unify-once.yml - -permissions: - contents: write - -jobs: - apply: - if: github.repository == 'z13321812367-sys/ccswitchmulti' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: fix/global-hardening-20260904 - fetch-depth: 0 - - - name: Apply exact unification patches - run: python scripts/apply_deeplink_autosync_unification_once.py - - - name: Format Rust - run: cargo fmt --manifest-path src-tauri/Cargo.toml --all - - - name: Run permanent failure-boundary policy locally - run: python scripts/check_rust_failure_boundaries.py - - - name: Verify patch diff - run: git diff --check - - - name: Commit changes - shell: bash - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src-tauri/src/lib.rs src-tauri/src/services/webdav_auto_sync.rs src-tauri/src/services/s3_auto_sync.rs - git diff --cached --check - test -n "$(git status --porcelain)" - git commit -m "fix: unify deep-link and auto-sync failure boundaries" - git push origin HEAD:fix/global-hardening-20260904 From e13e9e122ff912f8ee9cc3256f02f9a5832c0a22 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 14:05:21 +0800 Subject: [PATCH 026/112] chore: remove one-shot deep-link auto-sync patch driver --- ...pply_deeplink_autosync_unification_once.py | 83 ------------------- 1 file changed, 83 deletions(-) delete mode 100644 scripts/apply_deeplink_autosync_unification_once.py diff --git a/scripts/apply_deeplink_autosync_unification_once.py b/scripts/apply_deeplink_autosync_unification_once.py deleted file mode 100644 index f40d5841d6b..00000000000 --- a/scripts/apply_deeplink_autosync_unification_once.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] - - -def replace_once(path: str, old: str, new: str) -> None: - target = ROOT / path - text = target.read_text(encoding="utf-8") - count = text.count(old) - if count != 1: - raise SystemExit(f"{path}: expected exactly one match, found {count}") - target.write_text(text.replace(old, new, 1), encoding="utf-8") - - -# Deep links: collapse the macOS RunEvent::Opened duplicate parser/event path into -# the same redacted boundary used by single-instance and plugin callbacks. -replace_once( - "src-tauri/src/lib.rs", - ''' // 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...)\n RunEvent::Opened { urls } => {\n if let Some(url) = urls.first() {\n let url_str = url.to_string();\n log::info!("RunEvent::Opened with URL: {url_str}");\n\n if url_str.starts_with("ccswitch://") {\n if crate::lightweight::is_lightweight_mode() {\n if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle)\n {\n log::error!("退出轻量模式重建窗口失败: {e}");\n }\n }\n\n // 解析并广播深链接事件,复用与 single_instance 相同的逻辑\n match crate::deeplink::parse_deeplink_url(&url_str) {\n Ok(request) => {\n log::info!(\n "Successfully parsed deep link from RunEvent::Opened: resource={}, app={:?}",\n request.resource,\n request.app\n );\n\n if let Err(e) =\n app_handle.emit("deeplink-import", &request)\n {\n log::error!(\n "Failed to emit deep link event from RunEvent::Opened: {e}"\n );\n }\n }\n Err(e) => {\n log::error!(\n "Failed to parse deep link URL from RunEvent::Opened: {e}"\n );\n\n if let Err(emit_err) = app_handle.emit(\n "deeplink-error",\n serde_json::json!({\n "url": url_str,\n "error": e.to_string()\n }),\n ) {\n log::error!(\n "Failed to emit deep link error event from RunEvent::Opened: {emit_err}"\n );\n }\n }\n }\n\n // 确保主窗口可见\n if let Some(window) = app_handle.get_webview_window("main") {\n let _ = window.unminimize();\n let _ = window.show();\n let _ = window.set_focus();\n }\n }\n }\n }\n''', - ''' // 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...)。\n // 原始 URL 只能进入统一处理器;日志与错误事件都在该边界内脱敏。\n RunEvent::Opened { urls } => {\n if let Some(url) = urls.first() {\n let url_str = url.as_str();\n log::debug!(\n "RunEvent::Opened URL: {}",\n redact_url_for_log(url_str)\n );\n\n if url_str.starts_with("ccswitch://")\n && crate::lightweight::is_lightweight_mode()\n {\n if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle) {\n log::error!("退出轻量模式重建窗口失败: {e}");\n }\n }\n\n handle_deeplink_url(app_handle, url_str, true, "RunEvent::Opened");\n }\n }\n''', -) - -for path, label, settings_type in [ - ("src-tauri/src/services/webdav_auto_sync.rs", "WebDAV", "WebDavSyncSettings"), - ("src-tauri/src/services/s3_auto_sync.rs", "S3", "S3SyncSettings"), -]: - # Imports and duplicate queue/timing policy move into auto_sync_common. - replace_once( - path, - '''use std::sync::atomic::{AtomicUsize, Ordering};\nuse std::sync::Arc;\nuse std::sync::OnceLock;\nuse std::time::{Duration, Instant};\n\nuse serde_json::json;\nuse tauri::{AppHandle, Emitter};\nuse tokio::sync::mpsc::error::TrySendError;\nuse tokio::sync::mpsc::{channel, Receiver, Sender};\n\nuse crate::error::AppError;\n''', - '''use std::panic::AssertUnwindSafe;\nuse std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};\nuse std::sync::Arc;\nuse std::sync::OnceLock;\nuse std::time::Instant;\n\nuse futures::FutureExt;\nuse serde_json::json;\nuse tauri::{AppHandle, Emitter};\nuse tokio::sync::mpsc::{channel, Receiver, Sender};\n\nuse crate::error::AppError;\nuse crate::services::auto_sync_common::{\n auto_sync_wait_duration, enqueue_change_signal, panic_message, should_trigger_for_table,\n ChangeSignalOutcome,\n};\n''', - ) - - replace_once( - path, - '''\nconst AUTO_SYNC_DEBOUNCE_MS: u64 = 1000;\npub(crate) const MAX_AUTO_SYNC_WAIT_MS: u64 = 10_000;\n\nstatic DB_CHANGE_TX: OnceLock> = OnceLock::new();\nstatic AUTO_SYNC_SUPPRESS_DEPTH: AtomicUsize = AtomicUsize::new(0);\n''', - '''\nstatic DB_CHANGE_TX: OnceLock> = OnceLock::new();\nstatic AUTO_SYNC_SUPPRESS_DEPTH: AtomicUsize = AtomicUsize::new(0);\nstatic WORKER_CHANNEL_CLOSED_REPORTED: AtomicBool = AtomicBool::new(false);\n''', - ) - - replace_once( - path, - '''pub fn should_trigger_for_table(table: &str) -> bool {\n let normalized = table.trim().to_ascii_lowercase();\n matches!(\n normalized.as_str(),\n "providers"\n | "provider_endpoints"\n | "mcp_servers"\n | "prompts"\n | "skills"\n | "skill_repos"\n | "settings"\n | "proxy_config"\n )\n}\n\npub(crate) fn enqueue_change_signal(tx: &Sender, table: &str) -> bool {\n match tx.try_send(table.to_string()) {\n Ok(()) => true,\n Err(TrySendError::Full(_)) | Err(TrySendError::Closed(_)) => false,\n }\n}\n\npub(crate) fn auto_sync_wait_duration(started_at: Instant, now: Instant) -> Option {\n let max_wait = Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS);\n let debounce = Duration::from_millis(AUTO_SYNC_DEBOUNCE_MS);\n let elapsed = now.saturating_duration_since(started_at);\n if elapsed >= max_wait {\n return None;\n }\n Some(debounce.min(max_wait - elapsed))\n}\n\n''', - "", - ) - - replace_once( - path, - ''' let Some(tx) = DB_CHANGE_TX.get() else {\n return;\n };\n let _ = enqueue_change_signal(tx, table);\n}\n''', - f''' let Some(tx) = DB_CHANGE_TX.get() else {{\n return;\n }};\n match enqueue_change_signal(tx, table) {{\n ChangeSignalOutcome::Enqueued | ChangeSignalOutcome::Coalesced => {{}}\n ChangeSignalOutcome::WorkerUnavailable => {{\n if !WORKER_CHANNEL_CLOSED_REPORTED.swap(true, Ordering::SeqCst) {{\n log::error!(\n "[{label}][AutoSync] Change-signal channel is closed; the auto-sync worker is unavailable. Further database changes cannot be auto-synced until the worker is reinitialized."\n );\n }}\n }}\n }}\n}}\n''', - ) - - replace_once( - path, - ''' if DB_CHANGE_TX.set(tx).is_err() {\n return;\n }\n\n tauri::async_runtime::spawn(async move {\n''', - ''' if DB_CHANGE_TX.set(tx).is_err() {\n return;\n }\n WORKER_CHANNEL_CLOSED_REPORTED.store(false, Ordering::SeqCst);\n\n tauri::async_runtime::spawn(async move {\n''', - ) - - replace_once( - path, - f''' if let Err(err) = run_auto_sync_upload(&db, &app).await {{\n log::warn!("[{label}][AutoSync] Upload failed: {{err}}");\n }}\n''', - f''' match AssertUnwindSafe(run_auto_sync_upload(&db, &app))\n .catch_unwind()\n .await\n {{\n Ok(Ok(())) => {{}}\n Ok(Err(err)) => log::warn!("[{label}][AutoSync] Upload failed: {{err}}"),\n Err(payload) => log::error!(\n "[{label}][AutoSync] Upload panicked; worker will continue processing later changes: {{}}",\n panic_message(payload.as_ref())\n ),\n }}\n''', - ) - - # Remove duplicate tests now owned by auto_sync_common and simplify imports. - replace_once( - path, - ''' use super::{\n auto_sync_wait_duration, enqueue_change_signal, is_auto_sync_suppressed,\n should_run_auto_sync, should_trigger_for_table, AutoSyncSuppressionGuard,\n MAX_AUTO_SYNC_WAIT_MS,\n };\n''', - ''' use super::{is_auto_sync_suppressed, should_run_auto_sync, AutoSyncSuppressionGuard};\n''', - ) - replace_once(path, ''' use std::time::{Duration, Instant};\n use tokio::sync::mpsc::channel;\n\n''', "") - replace_once( - path, - ''' #[test]\n fn should_trigger_sync_for_config_tables_only() {\n assert!(should_trigger_for_table("providers"));\n assert!(should_trigger_for_table("settings"));\n assert!(!should_trigger_for_table("proxy_request_logs"));\n assert!(!should_trigger_for_table("provider_health"));\n }\n\n''', - "", - ) - replace_once( - path, - ''' #[test]\n fn max_wait_caps_flush_latency_for_continuous_events() {\n let started = Instant::now();\n let later = started + Duration::from_millis(MAX_AUTO_SYNC_WAIT_MS + 1);\n assert!(auto_sync_wait_duration(started, later).is_none());\n }\n\n #[tokio::test]\n async fn enqueue_change_signal_drops_when_channel_is_full() {\n let (tx, _rx) = channel::(1);\n assert!(enqueue_change_signal(&tx, "providers"));\n assert!(!enqueue_change_signal(&tx, "providers"));\n }\n\n''', - "", - ) - -print("Applied deep-link and auto-sync unification patches") From ec1dc470d0d631dd45c97ce344344ce76da4ac47 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 14:09:08 +0800 Subject: [PATCH 027/112] refactor: model proxy timeout semantics explicitly --- src-tauri/src/proxy/timeout_policy.rs | 99 +++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src-tauri/src/proxy/timeout_policy.rs diff --git a/src-tauri/src/proxy/timeout_policy.rs b/src-tauri/src/proxy/timeout_policy.rs new file mode 100644 index 00000000000..31dc3c39082 --- /dev/null +++ b/src-tauri/src/proxy/timeout_policy.rs @@ -0,0 +1,99 @@ +use std::time::Duration; + +/// Even when user/failover timeouts are disabled, transport setup and response headers must not +/// be allowed to wait forever. This is a transport safety boundary, not a failover policy value. +pub(crate) const TRANSPORT_RESPONSE_HEADER_SAFETY_TIMEOUT: Duration = Duration::from_secs(600); + +/// Streaming bodies are governed by first-byte/idle logic after headers. Reqwest still needs a +/// finite request-level guard so a broken body cannot hold internal resources forever. +pub(crate) const STREAMING_REQUEST_SAFETY_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ForwarderTimeoutPolicy { + /// User-configured non-streaming timeout. `None` means the failover timeout is disabled. + non_streaming: Option, + /// User-configured streaming response-header / first-byte timeout. `None` disables the + /// failover timer; the transport safety boundary still applies while waiting for headers. + streaming_first_byte: Option, +} + +impl ForwarderTimeoutPolicy { + pub(crate) fn from_seconds(non_streaming: u64, streaming_first_byte: u64) -> Self { + Self { + non_streaming: non_zero_seconds(non_streaming), + streaming_first_byte: non_zero_seconds(streaming_first_byte), + } + } + + pub(crate) fn non_streaming_failover_timeout(self) -> Option { + self.non_streaming + } + + pub(crate) fn streaming_first_byte_failover_timeout(self) -> Option { + self.streaming_first_byte + } + + /// Timeout used until upstream response headers arrive for non-streaming requests. + /// Configured failover timeout wins; otherwise the transport safety cap applies. + pub(crate) fn non_streaming_transport_timeout(self) -> Duration { + self.non_streaming + .unwrap_or(TRANSPORT_RESPONSE_HEADER_SAFETY_TIMEOUT) + } + + /// Timeout used until upstream response headers arrive for streaming requests. + /// Configured first-byte timeout wins; otherwise the transport safety cap applies. + pub(crate) fn streaming_header_transport_timeout(self) -> Duration { + self.streaming_first_byte + .unwrap_or(TRANSPORT_RESPONSE_HEADER_SAFETY_TIMEOUT) + } +} + +fn non_zero_seconds(seconds: u64) -> Option { + (seconds > 0).then(|| Duration::from_secs(seconds)) +} + +#[cfg(test)] +mod tests { + use super::{ + ForwarderTimeoutPolicy, TRANSPORT_RESPONSE_HEADER_SAFETY_TIMEOUT, + }; + use std::time::Duration; + + #[test] + fn zero_disables_failover_timeout_but_not_transport_safety() { + let policy = ForwarderTimeoutPolicy::from_seconds(0, 0); + + assert_eq!(policy.non_streaming_failover_timeout(), None); + assert_eq!(policy.streaming_first_byte_failover_timeout(), None); + assert_eq!( + policy.non_streaming_transport_timeout(), + TRANSPORT_RESPONSE_HEADER_SAFETY_TIMEOUT + ); + assert_eq!( + policy.streaming_header_transport_timeout(), + TRANSPORT_RESPONSE_HEADER_SAFETY_TIMEOUT + ); + } + + #[test] + fn configured_timeouts_override_transport_header_safety_cap() { + let policy = ForwarderTimeoutPolicy::from_seconds(45, 12); + + assert_eq!( + policy.non_streaming_failover_timeout(), + Some(Duration::from_secs(45)) + ); + assert_eq!( + policy.streaming_first_byte_failover_timeout(), + Some(Duration::from_secs(12)) + ); + assert_eq!( + policy.non_streaming_transport_timeout(), + Duration::from_secs(45) + ); + assert_eq!( + policy.streaming_header_transport_timeout(), + Duration::from_secs(12) + ); + } +} From 225668cf9ce5eeba2d51c20f17415f407a89a179 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 14:09:21 +0800 Subject: [PATCH 028/112] refactor: register proxy timeout policy --- src-tauri/src/proxy/mod.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 6a211ce7534..198699cffe7 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -32,6 +32,7 @@ pub(crate) mod server; pub mod session; pub(crate) mod sse; pub(crate) mod switch_lock; +pub(crate) mod timeout_policy; pub mod thinking_budget_rectifier; pub mod thinking_optimizer; pub mod thinking_rectifier; @@ -59,4 +60,4 @@ pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus}; // 内部模块间共享(供子模块使用) // 注意:这个导出用于模块内部,编译器可能警告未使用但实际被子模块使用 #[allow(unused_imports)] -pub(crate) use types::*; +pub(crate) use types::*; \ No newline at end of file From 77509d040aa124ce725d8f55b89c7f005037f42a Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 14:10:10 +0800 Subject: [PATCH 029/112] chore: stage explicit proxy timeout policy integration --- scripts/apply_timeout_policy_once.py | 117 +++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 scripts/apply_timeout_policy_once.py diff --git a/scripts/apply_timeout_policy_once.py b/scripts/apply_timeout_policy_once.py new file mode 100644 index 00000000000..47b883d93f7 --- /dev/null +++ b/scripts/apply_timeout_policy_once.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def replace_once(path: str, old: str, new: str) -> None: + target = ROOT / path + text = target.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise SystemExit(f"{path}: expected exactly one match, found {count}") + target.write_text(text.replace(old, new, 1), encoding="utf-8") + + +replace_once( + "src-tauri/src/proxy/forwarder.rs", + ''' thinking_rectifier::{\n normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,\n },\n types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig},\n''', + ''' thinking_rectifier::{\n normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,\n },\n timeout_policy::{ForwarderTimeoutPolicy, STREAMING_REQUEST_SAFETY_TIMEOUT},\n types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig},\n''', +) + +replace_once( + "src-tauri/src/proxy/forwarder.rs", + ''' /// 非流式请求超时(秒)\n non_streaming_timeout: std::time::Duration,\n /// 流式请求响应头等待超时(秒)\n streaming_first_byte_timeout: std::time::Duration,\n''', + ''' /// 显式区分用户/故障转移 timeout 与传输层安全上限,禁止再用 Duration::ZERO\n /// 同时表达“禁用用户 timeout”和“使用 600s transport fallback”两种不同语义。\n timeout_policy: ForwarderTimeoutPolicy,\n''', +) + +replace_once( + "src-tauri/src/proxy/forwarder.rs", + ''' codex_responses_lite_fallbacks: Arc::new(RwLock::new(HashMap::new())),\n non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),\n streaming_first_byte_timeout: std::time::Duration::from_secs(\n streaming_first_byte_timeout,\n ),\n max_attempts,\n''', + ''' codex_responses_lite_fallbacks: Arc::new(RwLock::new(HashMap::new())),\n timeout_policy: ForwarderTimeoutPolicy::from_seconds(\n non_streaming_timeout,\n streaming_first_byte_timeout,\n ),\n max_attempts,\n''', +) + +replace_once( + "src-tauri/src/proxy/forwarder.rs", + ''' // 确定超时\n let timeout = if self.non_streaming_timeout.is_zero() {\n std::time::Duration::from_secs(600) // 默认 600 秒\n } else {\n self.non_streaming_timeout\n };\n''', + ''' // 传输层安全上限与用户/故障转移 timeout 是两种独立语义。\n // 即使用户配置 0(禁用故障转移 timeout),等待上游响应头也不能无限挂起。\n let transport_header_timeout = if request_is_streaming {\n self.timeout_policy.streaming_header_transport_timeout()\n } else {\n self.timeout_policy.non_streaming_transport_timeout()\n };\n''', +) + +replace_once( + "src-tauri/src/proxy/forwarder.rs", + ''' ("timeout_ms", timeout.as_millis().to_string()),\n''', + ''' (\n "transport_header_timeout_ms",\n transport_header_timeout.as_millis().to_string(),\n ),\n (\n "failover_timeout_enabled",\n (if request_is_streaming {\n self.timeout_policy\n .streaming_first_byte_failover_timeout()\n .is_some()\n } else {\n self.timeout_policy.non_streaming_failover_timeout().is_some()\n })\n .to_string(),\n ),\n''', +) + +replace_once( + "src-tauri/src/proxy/forwarder.rs", + ''' timeout,\n request_is_streaming,\n self.non_streaming_timeout,\n self.streaming_first_byte_timeout,\n is_socks_proxy,\n''', + ''' transport_header_timeout,\n request_is_streaming,\n self.timeout_policy,\n is_socks_proxy,\n''', +) + +replace_once( + "src-tauri/src/proxy/forwarder.rs", + ''' if self.non_streaming_timeout.is_zero() {\n return Ok(response);\n }\n\n let status = response.status();\n let headers = response.headers().clone();\n let body_timeout = self.non_streaming_timeout;\n''', + ''' let Some(body_timeout) = self.timeout_policy.non_streaming_failover_timeout() else {\n return Ok(response);\n };\n\n let status = response.status();\n let headers = response.headers().clone();\n''', +) + +replace_once( + "src-tauri/src/proxy/forwarder.rs", + ''' if self.streaming_first_byte_timeout.is_zero() {\n return Ok(response);\n }\n\n let status = response.status();\n let headers = response.headers().clone();\n let timeout = self.streaming_first_byte_timeout;\n''', + ''' let Some(timeout) = self\n .timeout_policy\n .streaming_first_byte_failover_timeout()\n else {\n return Ok(response);\n };\n\n let status = response.status();\n let headers = response.headers().clone();\n''', +) + +replace_once( + "src-tauri/src/proxy/forwarder.rs", + ''' timeout: std::time::Duration,\n request_is_streaming: bool,\n non_streaming_timeout: std::time::Duration,\n streaming_first_byte_timeout: std::time::Duration,\n is_socks_proxy: bool,\n''', + ''' transport_header_timeout: std::time::Duration,\n request_is_streaming: bool,\n timeout_policy: ForwarderTimeoutPolicy,\n is_socks_proxy: bool,\n''', +) + +replace_once( + "src-tauri/src/proxy/forwarder.rs", + ''' let client = super::http_client::get();\n let mut request = client.request(method.clone(), &url);\n if request_is_streaming {\n request = request.timeout(std::time::Duration::from_secs(24 * 60 * 60));\n } else if !non_streaming_timeout.is_zero() {\n request = request.timeout(non_streaming_timeout);\n }\n''', + ''' let client = super::http_client::get();\n let mut request = client.request(method.clone(), &url);\n if request_is_streaming {\n request = request.timeout(STREAMING_REQUEST_SAFETY_TIMEOUT);\n } else {\n // Explicit per-request value keeps Reqwest aligned with the Hyper path even when\n // the user disables failover timeouts. Do not depend on the shared client's default.\n request = request.timeout(timeout_policy.non_streaming_transport_timeout());\n }\n''', +) + +replace_once( + "src-tauri/src/proxy/forwarder.rs", + ''' let send_result = if request_is_streaming {\n let header_timeout = if streaming_first_byte_timeout.is_zero() {\n timeout\n } else {\n streaming_first_byte_timeout\n };\n match tokio::time::timeout(header_timeout, send).await {\n''', + ''' let send_result = if request_is_streaming {\n match tokio::time::timeout(transport_header_timeout, send).await {\n''', +) + +replace_once( + "src-tauri/src/proxy/forwarder.rs", + ''' "流式响应首包超时: {}s(上游未返回响应头)",\n header_timeout.as_secs()\n''', + ''' "流式响应头等待超时: {}s(上游未返回响应头)",\n transport_header_timeout.as_secs()\n''', +) + +replace_once( + "src-tauri/src/proxy/forwarder.rs", + ''' timeout,\n upstream_proxy_url,\n''', + ''' transport_header_timeout,\n upstream_proxy_url,\n''', +) + +# Make the public-facing contract accurate: 0 disables failover/body timers, not the independent +# transport header safety cap used by both Reqwest and Hyper. +replace_once( + "src-tauri/src/proxy/handler_context.rs", + ''' /// 配置生效规则:\n /// - 故障转移开启:超时配置正常生效(0 表示禁用超时)\n /// - 故障转移关闭:超时配置不生效(全部传入 0)\n''', + ''' /// 配置生效规则:\n /// - 故障转移开启:用户超时配置正常生效(0 表示禁用故障转移/body timeout);\n /// - 故障转移关闭:用户超时配置不生效(全部传入 0);\n /// - 两种模式都保留独立的 transport response-header safety cap,避免连接永久挂起。\n''', +) +replace_once( + "src-tauri/src/proxy/handler_context.rs", + ''' // 故障转移关闭时强制 max_retries=0(仅尝试 1 个 provider),与「不超时 + 不切换」语义一致。\n''', + ''' // 故障转移关闭时强制 max_retries=0(仅尝试 1 个 provider)。\n // 用户级 failover/body timeout 被禁用,但 transport safety cap 仍保留。\n''', +) + +forwarder = (ROOT / "src-tauri/src/proxy/forwarder.rs").read_text(encoding="utf-8") +for forbidden in ( + "self.non_streaming_timeout", + "self.streaming_first_byte_timeout", + "let header_timeout = if streaming_first_byte_timeout.is_zero()", + "Duration::from_secs(600) // 默认 600 秒", +): + if forbidden in forwarder: + raise SystemExit(f"forwarder still contains legacy timeout semantic: {forbidden}") + +print("Applied explicit proxy timeout policy integration") From 23b41ea9af6eea6f9215a3cd7dcd77f5b95b7d1a Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 20:58:53 +0800 Subject: [PATCH 030/112] chore: run self-cleaning timeout policy integration --- .../timeout-policy-integrate-once.yml | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/timeout-policy-integrate-once.yml diff --git a/.github/workflows/timeout-policy-integrate-once.yml b/.github/workflows/timeout-policy-integrate-once.yml new file mode 100644 index 00000000000..c1bb8c4639b --- /dev/null +++ b/.github/workflows/timeout-policy-integrate-once.yml @@ -0,0 +1,44 @@ +name: Timeout Policy Integrate Once + +on: + push: + branches: + - fix/global-hardening-20260904 + paths: + - .github/workflows/timeout-policy-integrate-once.yml + +permissions: + contents: write + +jobs: + apply: + if: github.repository == 'z13321812367-sys/ccswitchmulti' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: fix/global-hardening-20260904 + fetch-depth: 0 + + - name: Apply timeout policy integration + run: python scripts/apply_timeout_policy_once.py + + - name: Format Rust with repository toolchain + run: cargo fmt --manifest-path src-tauri/Cargo.toml + + - name: Run permanent failure-boundary guards + run: | + python scripts/check_workflow_shell_interpolation.py + python scripts/check_rust_failure_boundaries.py + + - name: Verify patch + run: git diff --check + + - name: Remove one-shot driver and commit + run: | + git rm scripts/apply_timeout_policy_once.py .github/workflows/timeout-policy-integrate-once.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src-tauri/src/proxy/forwarder.rs src-tauri/src/proxy/handler_context.rs src-tauri/src/proxy/mod.rs src-tauri/src/proxy/timeout_policy.rs src-tauri/src/services/mod.rs + git commit -m "fix: make proxy timeout semantics explicit" + git push origin HEAD:fix/global-hardening-20260904 From f745c38e6abed0346cc403453353e2f1e023db51 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:59:17 +0000 Subject: [PATCH 031/112] fix: make proxy timeout semantics explicit --- .../timeout-policy-integrate-once.yml | 44 ------- scripts/apply_timeout_policy_once.py | 117 ------------------ src-tauri/src/proxy/forwarder.rs | 80 ++++++------ src-tauri/src/proxy/handler_context.rs | 8 +- src-tauri/src/proxy/mod.rs | 4 +- src-tauri/src/proxy/timeout_policy.rs | 4 +- src-tauri/src/services/mod.rs | 2 +- 7 files changed, 54 insertions(+), 205 deletions(-) delete mode 100644 .github/workflows/timeout-policy-integrate-once.yml delete mode 100644 scripts/apply_timeout_policy_once.py diff --git a/.github/workflows/timeout-policy-integrate-once.yml b/.github/workflows/timeout-policy-integrate-once.yml deleted file mode 100644 index c1bb8c4639b..00000000000 --- a/.github/workflows/timeout-policy-integrate-once.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Timeout Policy Integrate Once - -on: - push: - branches: - - fix/global-hardening-20260904 - paths: - - .github/workflows/timeout-policy-integrate-once.yml - -permissions: - contents: write - -jobs: - apply: - if: github.repository == 'z13321812367-sys/ccswitchmulti' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: fix/global-hardening-20260904 - fetch-depth: 0 - - - name: Apply timeout policy integration - run: python scripts/apply_timeout_policy_once.py - - - name: Format Rust with repository toolchain - run: cargo fmt --manifest-path src-tauri/Cargo.toml - - - name: Run permanent failure-boundary guards - run: | - python scripts/check_workflow_shell_interpolation.py - python scripts/check_rust_failure_boundaries.py - - - name: Verify patch - run: git diff --check - - - name: Remove one-shot driver and commit - run: | - git rm scripts/apply_timeout_policy_once.py .github/workflows/timeout-policy-integrate-once.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src-tauri/src/proxy/forwarder.rs src-tauri/src/proxy/handler_context.rs src-tauri/src/proxy/mod.rs src-tauri/src/proxy/timeout_policy.rs src-tauri/src/services/mod.rs - git commit -m "fix: make proxy timeout semantics explicit" - git push origin HEAD:fix/global-hardening-20260904 diff --git a/scripts/apply_timeout_policy_once.py b/scripts/apply_timeout_policy_once.py deleted file mode 100644 index 47b883d93f7..00000000000 --- a/scripts/apply_timeout_policy_once.py +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] - - -def replace_once(path: str, old: str, new: str) -> None: - target = ROOT / path - text = target.read_text(encoding="utf-8") - count = text.count(old) - if count != 1: - raise SystemExit(f"{path}: expected exactly one match, found {count}") - target.write_text(text.replace(old, new, 1), encoding="utf-8") - - -replace_once( - "src-tauri/src/proxy/forwarder.rs", - ''' thinking_rectifier::{\n normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,\n },\n types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig},\n''', - ''' thinking_rectifier::{\n normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature,\n },\n timeout_policy::{ForwarderTimeoutPolicy, STREAMING_REQUEST_SAFETY_TIMEOUT},\n types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig},\n''', -) - -replace_once( - "src-tauri/src/proxy/forwarder.rs", - ''' /// 非流式请求超时(秒)\n non_streaming_timeout: std::time::Duration,\n /// 流式请求响应头等待超时(秒)\n streaming_first_byte_timeout: std::time::Duration,\n''', - ''' /// 显式区分用户/故障转移 timeout 与传输层安全上限,禁止再用 Duration::ZERO\n /// 同时表达“禁用用户 timeout”和“使用 600s transport fallback”两种不同语义。\n timeout_policy: ForwarderTimeoutPolicy,\n''', -) - -replace_once( - "src-tauri/src/proxy/forwarder.rs", - ''' codex_responses_lite_fallbacks: Arc::new(RwLock::new(HashMap::new())),\n non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout),\n streaming_first_byte_timeout: std::time::Duration::from_secs(\n streaming_first_byte_timeout,\n ),\n max_attempts,\n''', - ''' codex_responses_lite_fallbacks: Arc::new(RwLock::new(HashMap::new())),\n timeout_policy: ForwarderTimeoutPolicy::from_seconds(\n non_streaming_timeout,\n streaming_first_byte_timeout,\n ),\n max_attempts,\n''', -) - -replace_once( - "src-tauri/src/proxy/forwarder.rs", - ''' // 确定超时\n let timeout = if self.non_streaming_timeout.is_zero() {\n std::time::Duration::from_secs(600) // 默认 600 秒\n } else {\n self.non_streaming_timeout\n };\n''', - ''' // 传输层安全上限与用户/故障转移 timeout 是两种独立语义。\n // 即使用户配置 0(禁用故障转移 timeout),等待上游响应头也不能无限挂起。\n let transport_header_timeout = if request_is_streaming {\n self.timeout_policy.streaming_header_transport_timeout()\n } else {\n self.timeout_policy.non_streaming_transport_timeout()\n };\n''', -) - -replace_once( - "src-tauri/src/proxy/forwarder.rs", - ''' ("timeout_ms", timeout.as_millis().to_string()),\n''', - ''' (\n "transport_header_timeout_ms",\n transport_header_timeout.as_millis().to_string(),\n ),\n (\n "failover_timeout_enabled",\n (if request_is_streaming {\n self.timeout_policy\n .streaming_first_byte_failover_timeout()\n .is_some()\n } else {\n self.timeout_policy.non_streaming_failover_timeout().is_some()\n })\n .to_string(),\n ),\n''', -) - -replace_once( - "src-tauri/src/proxy/forwarder.rs", - ''' timeout,\n request_is_streaming,\n self.non_streaming_timeout,\n self.streaming_first_byte_timeout,\n is_socks_proxy,\n''', - ''' transport_header_timeout,\n request_is_streaming,\n self.timeout_policy,\n is_socks_proxy,\n''', -) - -replace_once( - "src-tauri/src/proxy/forwarder.rs", - ''' if self.non_streaming_timeout.is_zero() {\n return Ok(response);\n }\n\n let status = response.status();\n let headers = response.headers().clone();\n let body_timeout = self.non_streaming_timeout;\n''', - ''' let Some(body_timeout) = self.timeout_policy.non_streaming_failover_timeout() else {\n return Ok(response);\n };\n\n let status = response.status();\n let headers = response.headers().clone();\n''', -) - -replace_once( - "src-tauri/src/proxy/forwarder.rs", - ''' if self.streaming_first_byte_timeout.is_zero() {\n return Ok(response);\n }\n\n let status = response.status();\n let headers = response.headers().clone();\n let timeout = self.streaming_first_byte_timeout;\n''', - ''' let Some(timeout) = self\n .timeout_policy\n .streaming_first_byte_failover_timeout()\n else {\n return Ok(response);\n };\n\n let status = response.status();\n let headers = response.headers().clone();\n''', -) - -replace_once( - "src-tauri/src/proxy/forwarder.rs", - ''' timeout: std::time::Duration,\n request_is_streaming: bool,\n non_streaming_timeout: std::time::Duration,\n streaming_first_byte_timeout: std::time::Duration,\n is_socks_proxy: bool,\n''', - ''' transport_header_timeout: std::time::Duration,\n request_is_streaming: bool,\n timeout_policy: ForwarderTimeoutPolicy,\n is_socks_proxy: bool,\n''', -) - -replace_once( - "src-tauri/src/proxy/forwarder.rs", - ''' let client = super::http_client::get();\n let mut request = client.request(method.clone(), &url);\n if request_is_streaming {\n request = request.timeout(std::time::Duration::from_secs(24 * 60 * 60));\n } else if !non_streaming_timeout.is_zero() {\n request = request.timeout(non_streaming_timeout);\n }\n''', - ''' let client = super::http_client::get();\n let mut request = client.request(method.clone(), &url);\n if request_is_streaming {\n request = request.timeout(STREAMING_REQUEST_SAFETY_TIMEOUT);\n } else {\n // Explicit per-request value keeps Reqwest aligned with the Hyper path even when\n // the user disables failover timeouts. Do not depend on the shared client's default.\n request = request.timeout(timeout_policy.non_streaming_transport_timeout());\n }\n''', -) - -replace_once( - "src-tauri/src/proxy/forwarder.rs", - ''' let send_result = if request_is_streaming {\n let header_timeout = if streaming_first_byte_timeout.is_zero() {\n timeout\n } else {\n streaming_first_byte_timeout\n };\n match tokio::time::timeout(header_timeout, send).await {\n''', - ''' let send_result = if request_is_streaming {\n match tokio::time::timeout(transport_header_timeout, send).await {\n''', -) - -replace_once( - "src-tauri/src/proxy/forwarder.rs", - ''' "流式响应首包超时: {}s(上游未返回响应头)",\n header_timeout.as_secs()\n''', - ''' "流式响应头等待超时: {}s(上游未返回响应头)",\n transport_header_timeout.as_secs()\n''', -) - -replace_once( - "src-tauri/src/proxy/forwarder.rs", - ''' timeout,\n upstream_proxy_url,\n''', - ''' transport_header_timeout,\n upstream_proxy_url,\n''', -) - -# Make the public-facing contract accurate: 0 disables failover/body timers, not the independent -# transport header safety cap used by both Reqwest and Hyper. -replace_once( - "src-tauri/src/proxy/handler_context.rs", - ''' /// 配置生效规则:\n /// - 故障转移开启:超时配置正常生效(0 表示禁用超时)\n /// - 故障转移关闭:超时配置不生效(全部传入 0)\n''', - ''' /// 配置生效规则:\n /// - 故障转移开启:用户超时配置正常生效(0 表示禁用故障转移/body timeout);\n /// - 故障转移关闭:用户超时配置不生效(全部传入 0);\n /// - 两种模式都保留独立的 transport response-header safety cap,避免连接永久挂起。\n''', -) -replace_once( - "src-tauri/src/proxy/handler_context.rs", - ''' // 故障转移关闭时强制 max_retries=0(仅尝试 1 个 provider),与「不超时 + 不切换」语义一致。\n''', - ''' // 故障转移关闭时强制 max_retries=0(仅尝试 1 个 provider)。\n // 用户级 failover/body timeout 被禁用,但 transport safety cap 仍保留。\n''', -) - -forwarder = (ROOT / "src-tauri/src/proxy/forwarder.rs").read_text(encoding="utf-8") -for forbidden in ( - "self.non_streaming_timeout", - "self.streaming_first_byte_timeout", - "let header_timeout = if streaming_first_byte_timeout.is_zero()", - "Duration::from_secs(600) // 默认 600 秒", -): - if forbidden in forwarder: - raise SystemExit(f"forwarder still contains legacy timeout semantic: {forbidden}") - -print("Applied explicit proxy timeout policy integration") diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 56cdcd6b415..6ec5aba9b5c 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -19,6 +19,7 @@ use super::{ thinking_rectifier::{ normalize_thinking_type, rectify_anthropic_request, should_rectify_thinking_signature, }, + timeout_policy::{ForwarderTimeoutPolicy, STREAMING_REQUEST_SAFETY_TIMEOUT}, types::{CopilotOptimizerConfig, OptimizerConfig, ProxyStatus, RectifierConfig}, ProxyError, }; @@ -128,10 +129,9 @@ pub struct RequestForwarder { /// 去掉 `x-openai-internal-codex-responses-lite`,过期后重新带头探测,避免每次 /// 请求都先失败一次,也避免永久禁用未来可能支持 Lite 的上游。 codex_responses_lite_fallbacks: Arc>>, - /// 非流式请求超时(秒) - non_streaming_timeout: std::time::Duration, - /// 流式请求响应头等待超时(秒) - streaming_first_byte_timeout: std::time::Duration, + /// 显式区分用户/故障转移 timeout 与传输层安全上限,禁止再用 Duration::ZERO + /// 同时表达“禁用用户 timeout”和“使用 600s transport fallback”两种不同语义。 + timeout_policy: ForwarderTimeoutPolicy, /// 单个客户端请求最多尝试的 provider 数。 /// /// 由 `AppProxyConfig.max_retries` (UI: "请求失败时的重试次数, 0-10") 派生: @@ -224,8 +224,8 @@ impl RequestForwarder { optimizer_config, copilot_optimizer_config, codex_responses_lite_fallbacks: Arc::new(RwLock::new(HashMap::new())), - non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout), - streaming_first_byte_timeout: std::time::Duration::from_secs( + timeout_policy: ForwarderTimeoutPolicy::from_seconds( + non_streaming_timeout, streaming_first_byte_timeout, ), max_attempts, @@ -2222,11 +2222,12 @@ impl RequestForwarder { ); } - // 确定超时 - let timeout = if self.non_streaming_timeout.is_zero() { - std::time::Duration::from_secs(600) // 默认 600 秒 + // 传输层安全上限与用户/故障转移 timeout 是两种独立语义。 + // 即使用户配置 0(禁用故障转移 timeout),等待上游响应头也不能无限挂起。 + let transport_header_timeout = if request_is_streaming { + self.timeout_policy.streaming_header_transport_timeout() } else { - self.non_streaming_timeout + self.timeout_policy.non_streaming_transport_timeout() }; // 获取全局代理 URL @@ -2262,7 +2263,23 @@ impl RequestForwarder { ("request_bytes", request_bytes_len.to_string()), ("header_count", ordered_headers.len().to_string()), ("streaming", request_is_streaming.to_string()), - ("timeout_ms", timeout.as_millis().to_string()), + ( + "transport_header_timeout_ms", + transport_header_timeout.as_millis().to_string(), + ), + ( + "failover_timeout_enabled", + (if request_is_streaming { + self.timeout_policy + .streaming_first_byte_failover_timeout() + .is_some() + } else { + self.timeout_policy + .non_streaming_failover_timeout() + .is_some() + }) + .to_string(), + ), ( "uses_upstream_proxy", upstream_proxy_url.is_some().to_string(), @@ -2285,10 +2302,9 @@ impl RequestForwarder { headers, extensions, body_bytes, - timeout, + transport_header_timeout, request_is_streaming, - self.non_streaming_timeout, - self.streaming_first_byte_timeout, + self.timeout_policy, is_socks_proxy, preserve_exact_header_case, upstream_proxy_url.as_deref(), @@ -2510,13 +2526,12 @@ impl RequestForwarder { return self.prime_streaming_response(response).await; } - if self.non_streaming_timeout.is_zero() { + let Some(body_timeout) = self.timeout_policy.non_streaming_failover_timeout() else { return Ok(response); - } + }; let status = response.status(); let headers = response.headers().clone(); - let body_timeout = self.non_streaming_timeout; let body = tokio::time::timeout(body_timeout, response.bytes()) .await .map_err(|_| { @@ -2533,13 +2548,12 @@ impl RequestForwarder { &self, response: ProxyResponse, ) -> Result { - if self.streaming_first_byte_timeout.is_zero() { + let Some(timeout) = self.timeout_policy.streaming_first_byte_failover_timeout() else { return Ok(response); - } + }; let status = response.status(); let headers = response.headers().clone(); - let timeout = self.streaming_first_byte_timeout; let mut stream = Box::pin(response.bytes_stream()); let first = tokio::time::timeout(timeout, stream.next()) @@ -3190,10 +3204,9 @@ async fn send_forwarder_upstream_request( headers: http::HeaderMap, extensions: Extensions, body_bytes: Vec, - timeout: std::time::Duration, + transport_header_timeout: std::time::Duration, request_is_streaming: bool, - non_streaming_timeout: std::time::Duration, - streaming_first_byte_timeout: std::time::Duration, + timeout_policy: ForwarderTimeoutPolicy, is_socks_proxy: bool, preserve_exact_header_case: bool, upstream_proxy_url: Option<&str>, @@ -3205,26 +3218,23 @@ async fn send_forwarder_upstream_request( let client = super::http_client::get(); let mut request = client.request(method.clone(), &url); if request_is_streaming { - request = request.timeout(std::time::Duration::from_secs(24 * 60 * 60)); - } else if !non_streaming_timeout.is_zero() { - request = request.timeout(non_streaming_timeout); + request = request.timeout(STREAMING_REQUEST_SAFETY_TIMEOUT); + } else { + // Explicit per-request value keeps Reqwest aligned with the Hyper path even when + // the user disables failover timeouts. Do not depend on the shared client's default. + request = request.timeout(timeout_policy.non_streaming_transport_timeout()); } for (key, value) in &headers { request = request.header(key, value); } let send = request.body(body_bytes).send(); let send_result = if request_is_streaming { - let header_timeout = if streaming_first_byte_timeout.is_zero() { - timeout - } else { - streaming_first_byte_timeout - }; - match tokio::time::timeout(header_timeout, send).await { + match tokio::time::timeout(transport_header_timeout, send).await { Ok(result) => result, Err(_) => { return Err(ProxyError::Timeout(format!( - "流式响应首包超时: {}s(上游未返回响应头)", - header_timeout.as_secs() + "流式响应头等待超时: {}s(上游未返回响应头)", + transport_header_timeout.as_secs() ))); } } @@ -3245,7 +3255,7 @@ async fn send_forwarder_upstream_request( headers, extensions, body_bytes, - timeout, + transport_header_timeout, upstream_proxy_url, ) .await diff --git a/src-tauri/src/proxy/handler_context.rs b/src-tauri/src/proxy/handler_context.rs index 67571b7a4ed..be57c425e7d 100644 --- a/src-tauri/src/proxy/handler_context.rs +++ b/src-tauri/src/proxy/handler_context.rs @@ -256,8 +256,9 @@ impl RequestContext { /// 使用共享的 ProviderRouter,确保熔断器状态跨请求保持 /// /// 配置生效规则: - /// - 故障转移开启:超时配置正常生效(0 表示禁用超时) - /// - 故障转移关闭:超时配置不生效(全部传入 0) + /// - 故障转移开启:用户超时配置正常生效(0 表示禁用故障转移/body timeout); + /// - 故障转移关闭:用户超时配置不生效(全部传入 0); + /// - 两种模式都保留独立的 transport response-header safety cap,避免连接永久挂起。 pub fn create_forwarder(&self, state: &ProxyState) -> RequestForwarder { let (non_streaming_timeout, first_byte_timeout, idle_timeout) = if self.app_config.auto_failover_enabled { @@ -276,7 +277,8 @@ impl RequestContext { (0, 0, 0) }; - // 故障转移关闭时强制 max_retries=0(仅尝试 1 个 provider),与「不超时 + 不切换」语义一致。 + // 故障转移关闭时强制 max_retries=0(仅尝试 1 个 provider)。 + // 用户级 failover/body timeout 被禁用,但 transport safety cap 仍保留。 let max_retries = if self.app_config.auto_failover_enabled { self.app_config.max_retries } else { diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 198699cffe7..243e7f16a5f 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -32,10 +32,10 @@ pub(crate) mod server; pub mod session; pub(crate) mod sse; pub(crate) mod switch_lock; -pub(crate) mod timeout_policy; pub mod thinking_budget_rectifier; pub mod thinking_optimizer; pub mod thinking_rectifier; +pub(crate) mod timeout_policy; pub(crate) mod types; pub mod usage; @@ -60,4 +60,4 @@ pub use types::{ProxyConfig, ProxyServerInfo, ProxyStatus}; // 内部模块间共享(供子模块使用) // 注意:这个导出用于模块内部,编译器可能警告未使用但实际被子模块使用 #[allow(unused_imports)] -pub(crate) use types::*; \ No newline at end of file +pub(crate) use types::*; diff --git a/src-tauri/src/proxy/timeout_policy.rs b/src-tauri/src/proxy/timeout_policy.rs index 31dc3c39082..c080c56708c 100644 --- a/src-tauri/src/proxy/timeout_policy.rs +++ b/src-tauri/src/proxy/timeout_policy.rs @@ -54,9 +54,7 @@ fn non_zero_seconds(seconds: u64) -> Option { #[cfg(test)] mod tests { - use super::{ - ForwarderTimeoutPolicy, TRANSPORT_RESPONSE_HEADER_SAFETY_TIMEOUT, - }; + use super::{ForwarderTimeoutPolicy, TRANSPORT_RESPONSE_HEADER_SAFETY_TIMEOUT}; use std::time::Duration; #[test] diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 5945440493b..cc1f810d95c 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -44,4 +44,4 @@ pub use usage_cache::UsageCache; pub use usage_stats::{ DailyStats, LogFilters, ModelStats, PaginatedLogs, ProviderLimitStatus, ProviderStats, RequestLogDetail, UsageSummary, UsageSummaryByApp, -}; \ No newline at end of file +}; From 7e9bfd4c9c1c6c3f7922fbf01561af5204d7b59a Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 20:59:58 +0800 Subject: [PATCH 032/112] chore: create one-shot audit source snapshot --- .github/workflows/audit-snapshot-once.yml | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/workflows/audit-snapshot-once.yml diff --git a/.github/workflows/audit-snapshot-once.yml b/.github/workflows/audit-snapshot-once.yml new file mode 100644 index 00000000000..dad071efeb3 --- /dev/null +++ b/.github/workflows/audit-snapshot-once.yml @@ -0,0 +1,28 @@ +name: Audit Snapshot Once + +on: + push: + branches: + - fix/global-hardening-20260904 + paths: + - .github/workflows/audit-snapshot-once.yml + +permissions: + contents: read + +jobs: + snapshot: + if: github.repository == 'z13321812367-sys/ccswitchmulti' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + ref: fix/global-hardening-20260904 + - name: Package source snapshot + run: tar --exclude=.git -czf /tmp/ccswitchmulti-audit.tar.gz . + - uses: actions/upload-artifact@v4 + with: + name: ccswitchmulti-audit-source + path: /tmp/ccswitchmulti-audit.tar.gz + if-no-files-found: error + retention-days: 1 From 04a65f5ed4adfd01ab67ac210b2caf0603863634 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 21:00:45 +0800 Subject: [PATCH 033/112] chore: remove one-shot audit snapshot workflow --- .github/workflows/audit-snapshot-once.yml | 28 ----------------------- 1 file changed, 28 deletions(-) delete mode 100644 .github/workflows/audit-snapshot-once.yml diff --git a/.github/workflows/audit-snapshot-once.yml b/.github/workflows/audit-snapshot-once.yml deleted file mode 100644 index dad071efeb3..00000000000 --- a/.github/workflows/audit-snapshot-once.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: Audit Snapshot Once - -on: - push: - branches: - - fix/global-hardening-20260904 - paths: - - .github/workflows/audit-snapshot-once.yml - -permissions: - contents: read - -jobs: - snapshot: - if: github.repository == 'z13321812367-sys/ccswitchmulti' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: fix/global-hardening-20260904 - - name: Package source snapshot - run: tar --exclude=.git -czf /tmp/ccswitchmulti-audit.tar.gz . - - uses: actions/upload-artifact@v4 - with: - name: ccswitchmulti-audit-source - path: /tmp/ccswitchmulti-audit.tar.gz - if-no-files-found: error - retention-days: 1 From 493b00a2d023054800a716f05c91ee12c597915b Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 21:14:27 +0800 Subject: [PATCH 034/112] chore: stage final global hardening patch driver --- scripts/apply_final_global_hardening_once.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 scripts/apply_final_global_hardening_once.py diff --git a/scripts/apply_final_global_hardening_once.py b/scripts/apply_final_global_hardening_once.py new file mode 100644 index 00000000000..ee357a9d190 --- /dev/null +++ b/scripts/apply_final_global_hardening_once.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +import base64 +import gzip +import subprocess +from pathlib import Path + +PATCH_B64 = "H4sIAK/DmmoC/+19eXcTR7b4/3yKinKekcaSvIORYwgBMuE3JOQAM5nzgNNpSyW7Y7lbr7uFcWydA0lYwp4JhDV7SJiFZSYLBkP4LhO3bP+Vr/C7t6q6Vd1dLcmGzOS8Nz4JtlrVVbdu3b1u1c3lckTvcexiztVrtoF/9VSMsbztrOvu7iZj6q9efJHk+vqzG0g3/LuRvPjiOjJllUjRMsvG+Aj/UNJdfUx3qP+R0mrFMCdH1nWzj4Y+blqOaxQd0YDatmWLv8fplGEaWqg/8WyqWB1h4w9vzA4AAMPDAAYCgD/1dfjfulzZJDYt6UVXq9kVrWzZWsUaT+PfjmsXSBf8myG5zWSvaxvmOJldl8O3p3S3OEGgVaHwe/ynqtsO9d/K+K3wZ/ckPs6Q0c3yU/ypUJdM1Vxi1dwq/BolMDj0+1w6NVsv9PSksth/3ilO0CmazmRGwm8bZdbBXgu+nADswAisPf6NQKQz0fHwh4+Vr9acCdaIvRnpuR7+GH0FB6nq7gQHST2lSTrjFMgfaPEFjrbNArj/qVF7Rqvqhu2kM/kpvZqeS09miZaZI5N518L+oTX0nC9alQotuukocNhz3rHshG9KtFSrpuOAAbaeYw0MR6NTVXemM/SktuxnU0lFB1M17mIDvGUZZjqVTWXavJJef3C9AvMq3DefSUuzw7bTWhJZITNxlCNkeQfYyU2vf359Jm/Sw4C6fM2ctvWqZtkBzUZA4QSO/fC3Ncss0vT6LeuVeGNUmK7atGwcxuVkcDXJmX9R37KfcxotHUxlsvFeXrNMii+yUSVqyCbSp/gTfiEvk56eHrK08Oni/BHvq/eWLhwnxaIzbcBEgJ1I4/7fVz78sXHuJvn9nl2sLZdNAwMglrr7egeFdPJ/bOrWbJOU9QoTTEJm8AERx/5cUG4ArpOFCKC2O+Gloq27tFCQJFyh0LIjvsLWeKFgmGULcPvPGx+S7SAvCQpMnBlIT5fiEKRsW1Nk1rFqdpHWC2RWHrueCnVWomO18efSzcmnwn1OUVdHIR3ts0LNcXdidLaeDWY2Ghkny7DcP4Do7e7vHYpgOTqf0dFRshdWvULJTtNxdaA6sk2vVMb04iTZZxvj4xT6J9AsmEGoGzGT1FZ73AElUzNdmDlAQXR4kAd4mdgKvQdYJmmDtcgQw+QtDZeCCM1TszZFcZHU8iI0JME3988a9YNiSMVK4hg+PXTeUYdkIjonYWYh4QcgCUV3FWN8wp2m+C+g39GkzxpoUT5lXLxN/bhq3cObNojFW0dUPz4emboDPMLvBDySpB9kEUHsQmPoDldmCmGagDug1zaL4LOTaiE663SVfIuLkjxpWJEJ3SxVqOYbPthJukuvVjX+RdZHSpa4dg0+pixTs6rUxIaplhjFnzGb6pMjIPHI67ZVpI5DLLMyQ8qghd2QiETByGTihqE+JhQ39Pcr+BV/9tTMHYcocFdhN8ABHDnL1pvUuUZKmmhgsnCbiNMIA+SXJowYwDDZZ0giLbp/1sQSaHVXt11Hg9WbSKekZUxlWpNDV1fnEoAL7z4gg75+FN+bBrJ9fUIGPL+/WB5PuxQsyIPc7nao6RiucUii5DEQwSUdjD5s5/gytAbmiVOrUluFiWD+z+/Hlw7yD2CnY6ca6xVaafxFAzhhEp47Glpf2ljN1Ux6iNqaQ4ugwbVDeqVGnTBxMUWsTwMhyWg71NdjTFXBstxiU67gRqu2dcgoUbvL1KfoaIlOWcCUxu/ozKgzmeMDdNUcfZxuLSJb7bMmqTnq4r/i2+fLtj4+BWQhPqckapXNAbX9ADDK9Bd5oUOy4p1IFKU7DrXBLPO7AlPbdHXDdNIpPrlUSIm0aB6dOnuRE8zAJqbu+3o3SfIjup5TegVtRLAUmivbXNWSbcGqco9hLasoVsq1qgL5//Xf//3vw38Unc8p8NkEteMlCJnUZG6OKNsIHyYTmLE5hStftKamQNs4PT7vJjn2yobMzWZWQravl604GuOvo1tMdMa5TWsSXvata3eCEuY74xNYa1BIaEaAmWm61BRhAnQjgCJQ4jAoCgUBApBStTaGpMT6CKQOytKC8NiZ876HOrWK+wIatbvg+52MQvbwAbPE91GFcJLNUZwAuv2hCYBc903o7sgLTWpJfBVN4qBVh0TUheqSv+UvYnjG3GLAVuhVaxScwzk6R0JuVEZEPBRrD4Lu8EwPjDat2yDvklZe0YyxO/A7Y3f8LVY/+qOwSxX+nIz6/bOuPl4/SDZv3kyW795v/P0dCfckjfqpgs4GW0Smrir1TCrEsKp1YWuT2Hdyx9lwF6tfuCgW0BpCAFGXUVMfq9DSc2n2ZBcosEqhsB1Ni5hVpPbXWsxs8fGHjQ+uLH99tEDGZkAAM29tzCrNgHHpoO8GU1PRBMicQwbI9h6GAa1MQcAmEUZSW2ZMDnNbEn5JtOFHiio64BjItUB2V5HJpYARhgMCzYVSAX1m8Cu6isD6Brij1JF9spA5tv9VBORlhOMguI0zyIggT6qWwRzCEPvGsBqhldZd1ddIGcmE4aPGp8CxmlEBbkOVUzHAlgiven6curyz8OMJqsNL4AjX3AnLNt5mIhSs0SAq8xLVbeiVmagWw72TB52JdhSIimQpAdZRFdpSrcqdCauNuFC3Z5TRK0IvAxEnI6Bi7/4/GuduNi7+sDh/qjE/73141nt4EQi6QMD4dWuClgOq5nN2OE0HvfGm6DDU+jZgNKkZ4tGnNfY2DwxIcSaOJU10mO4Sf2RaS+7wW00hELwdrHWYCRg3MkDQEAmAKlaAA9LCpuobYk44/xVBVhxTje+Peo/Oc3yFkKVGUdE9nAeMt0JaEmr85c37DzJrQ5KqnxBrYAjvyyONT2/Kc2tcuetd+Hr5i796504vPfrzz49O7t27g6wcuebNzy/On/WfnvG+fqfxyQ3+0Tt5lWCr5W++bHxyYfHRNe+rvy9/d/PnR++vi7mp1CxaJWZFjBJgNNxmALsEJJZ4rgJbRE36+4eym1Ap4i9/wVAXg1dvVWGJ2a+AOwJS46wffiqRjCyocmEaiDCJd/yYd+dBVEQpmy7fPdX46LZoGqeKYBwunGENbWtKq7nlYVg6x5lJdzUpuM3qV/WZiqWXtDL0Q+0qdOeG346wCKw6XyfvwQ/Ld+55jy8tPn6ydPHWyonzPx15xzv27crl24Q5IoiacRTOjcs/LF3/Drhh+f63y09OwOqvHH3iHTu78vEnIdK59J135hj5f3t3v8aNKeiPLdzGTWDDAqvhb+V+UYQDxO8C6XqF/fWqXlXsGIlWTVyK0Jj0QOyH0JksYQ5PZk4V22dfiUAI+1vYeKGofuoF0zJzuEKbY3sXzbg8DFUfnQ06BJUohdZlyMRuTKHwAu7paJs3h+AWWx4EX8ctDEVkwPf+ScT7/81IC03Dg1+201q/yK1w+YaGh1mwgv0eVhqjuE1w7p534yGne5Aagq9z7kyV9iA5Em4zgVBA6XHhaxAa3gePF+dve5ePNc59sXzinaVr7wEdNk7e987/pXH6iXfynnKg5S/nl49e8y6cXLl4dfnu3Z+OHF2c/wIkDlBj0BUK7xvvQ4ccHu+LT7xHDwGqbRsDoST/6OPjNh3X0cuTjf1ZdSSMSQy2Y4p20baKXivRg1wAMri8Yze8h5e4DAQJQIV1CH8ytuR0kRAGk/tODpStbtSYQdXOuHLpYVchTzCWpu4o6ovE8KqVxY6DxuaWplnSFRXJ8Kg5TKyremZLor/j795R1e5d8ooJdSUJ7tWvWfv1Wu14iav11CulWiURNUDs1arwGtWnNNYF94b5aqkXN/WyboCLRVyLS3vidxAoW1n1heKlVOxh9Q4P+mHQXpVoYdgF19hkywZew+FA0d59wLUPd8mWn1z0rn/SuPGXxmcnQKkB4zPOAEUMwqZx5n2ybUJ3Y8wS2/BC6cK+X3zwKV8bUH0/P7q2+OTjpUtXvWOoJAkXZjfOeqc+X/rr3cblm96TyyjSHvywtHDVu/MFCDB4zoXTytX3vPmvI6JRIYSKAJ7mAMJB8wR8wdSItKLtZFOEyjm6nl4sdUTinQ/2r6XvZySBQtJHkjyJUke5FlwAcFpcu9hpvR5rGky5Jk+1HtG1WKOcSZYxyDEtBU0gZDZsGsI4SXf/xt6N/l5LyLGPGHl0s7/fUx9ZF7LvJPcgLds3iMER4j/xPRn+FCe5P5/v6+89WCDrZ+vrQ4Gv1fWTcyZ0v5W/chl57pyOeaKN3HEq06aRP1Yoi4QvrWlUq9RN44csgWlk2gRbGYkwQLUJpBB8saN3ZLISL3FSSo6yBlGyaTpW0g+1DaY1m7EAQB+Lr/NfMlWkU0uPP1hauMF1C2cYtMZN6k5b9mQzsM6IU6aWZjKLo5dpLJEl7eo2Or0YXZJyWKS2Hca6Qv2w0bdWqzuQiTD8WdQrxtu0JPETukDr4v7KG3Rs+9Y/kFlwm9+eqPte+8nZtydAC+mOZdZBUwGV+QDWkT645u7n2SfR3Ww2HRbpYBN3qnn+KS0LBJ4KJZpForEORgvg5VHw9vB7lGaFwrY9O7bu27Ed92NYxplTY5tk6ZYGnx/25rM8SF793bbdu4g1GdukTneVDJshM1H/temr0+C1NE4rYxbskK1TY8Z4zQIE/fPIRXKI2kZ5hsDr4DRa9gyBnmug0GYIPWygI3jI0Mnre3a//vLO17Z3gtBXd+x7Zfd27bXd+7Stu3btfmPHdrauA4MbcV35L3ldZUWXZKKpguc+vnzYQG7T4qTgHBaDnq0zXZRSpK9JKxQO7q4lMtzSVNg9mWZpaRlZyzOEbOAI2bAxyNTBbbiXMIJMdJO86fPdmwTTBogQNTmnSotG2SgSlIEOm6hgNpx6DTgjH2y1camkcX4QqtCq8kzZrGCTgrR6LHlFSqT1QQi22xLFz1PLHUng+KHWtyfkRNumRKmTwNLgU6iTdFOQZFLRfqiZ1A8nlk76eWt8BjoB+fCWoZvAPTM1U4CMaznY28uylvF3MwylEpwpoSgmXNcXYMDkb09kAcqMKs0ZN6XbpDZLWc24gx3JaEYM8G3bpAxUDWbGW+QdFP1AaZhEkU7FwlGK1lXdcUB1ldK4BZScZWyps6b9jlaROC1eaZs7varEaXkg3G+WBqqyBOZWg6TXF9YnZBBLGcfYT2iLt20qd/NlH5Q1pHKLV1tmc4MgsTRr2gT6fBbp3GtL5u48k3u1adytcrgjTdukbwN7+QnaW1QJ2sh+imzouoi1MsYWjqFrWVoFbS0hlgPBmwWuPswj7AVSc0BqREVxc82h4dQYLHLwBukhfb39g+JXKwOOia2BjSxIMtjLfstaOXBMwlpXCM/mZsQPx5afnAAncOXqBTTv6uTVl7hhV496ThzYiC5uqYfXpIMz2U4n4S8FD7vQw0VKSw6pGFOGC3oAZ5L5FcyDKQUWK+/vRXthcFNvKLM3mqgldTxhlKijFeEJOGIGGCGabpbiWVrts6zSKVRXDohsoJ4iLfDEpxfpYX2qWqEgMKYKw4ODAz2g2LawlLpRfazYVbas0b7U02dlPd3gkcwsjf5PMzkLHC+/b2V3XAxBV1nW8cFUp9lh0cywemsf0xlo61/yJnyvclMWOLwbf28Q2VvknxeP8P8IFxITtFKlttN8/u/6zzdr9zAkEZ0llaHNinYWMcxiBUScZWKuCJODZAqcL30cJFma7b86hNErqjEwSVzcX80/awuptXX0H+vlmVsv/2ql6wxorq2bDk4iFBAE/gbKWY92ODhUqHz5cxa1EOqYM91GlhjVP+Snv7CzGrzrX7N3hZ4jYFgI/Y59rb0DT+9n+X2097E4hnGfpg9jqf1yKPVpzZym1kcJGjO+UrFkGQ55ZwaObwtEl0oOSj5Fr2te2w5sIIDm12L/JM+jhTU03McOqW2MnHNqYQ1xdaL5jhCoE5X5E+a7iOmyVn6L4N63OaZmxmrFSermgTRrTo7qjpvry+tT+tuWqU87aIv0lIF58u5hd8sfc1un3s5tC4y50a2/27m9iz/da4ybwFs2RdNHXi+1vSIZKthJcBRgWJwd693YKU6rQENgoYCVyYV7G3Q2Z26YhpVnbFrY1Nvb28MR0YO99LgWn/VbjmXGzce1rsHaxlZaj5LZuPrOkjYc+KH45Dx+/1t+QpMlPcGvgT4/f5+bWYvzDxcfHl++82fyhmGWrGmHbNvZ07jxN+/Gvcb3p5fvXlp+9/HSrW+9O9dWrl1c+nph6can3p1PeG5M49K9xtk7uN98/u7KkWsgoMib27Zpe9/YuW/bK9q+HXv3aa/sfnXHm3ykxpUfvUfnl78+vnT9IzIBlgeGkEGWLS4cg+4WH57j4/Us33sXfnk3Hjau3gURh4lbORGdxH0GfFODN9NMI74OaHupVvbJSNg1YKJhM7RqHLdUKFDzUKFwSLfTKQV4qRgRAt9PTTGHA3vJ48dQmAJDFKJNiyiF2GYUEPK0urR4Te6tvq6bu/lW5RBtTg9ZSLMOgZEATlmQQI2mwOZscAw4eC5G2SwfiBDP5CMQ3XErk6MpNBqL9uAFCQUEN5MHmsRstjlsPEeeY0hpzjvj9ytNGhYgPG82js+aMGP+B7e1gzPNUj/cMoUuMggu/oEj6mOApBo7ZwpmHjPEoUVW+Ro0QIsw0GbhvO/Gh2cXH99Y+nZhaeFTnrABXLDy8SdLCx97dx9w8l6cX1i6fsd7fGn5/l3vx/dUe9MMsJIBxqYOmJAyvmWg/JPvCE6qcfmzxreXls/d985/FBnm50fXG6f/hBBc/2TlyBHv5D3v8Yfe+2e9+zcRVtHmzOLCTZHyeOzs0uM7PZwLvYcfrnx8dWXhinfyuHf+/VTI9M76kqQ7OOQOJAZCsMlMTfNYoxXweubmYun3Ivml5QzORGDnz0NxuTBhpPIpwBvKB2WPyP34LW8S+bJx5W4w+Z+OHF2+8yPgw/vTGe+ra4vzfyHbdu0kHEd8AZeuvecd+7t34UzjswfQr5gHS01oXP7Be3Js5fMFZnySYsUC/+PnR9f4sI3L3yx/fgYkZTA58mb+TbL46Br0hMNfuOudutX46AHIt8apUzDC4vwRDiuMKUA8fnX5yfXFhYXGe+fJtje2A6qWP7+1dO4eCuAfr2MyxKXjPXwKPIcQWvDhMQHo/F2Qw9j3idtBZ9CT99VHEp46kJKBRxBi+A7lZN6aTPs83FpeYWp5idq0DM5VjNTitAYvzcncH8pYmYU/wpknVd00ivI3fk4r05UsTtmHoY7uwYGBbP8QU3iryloV4sm3Y7qFHcOmwCZeY4cYkVYcjROLNj1BTc1yOEJAVk0ZjsO4LzQxwDwAzfejw/hDKZFlsiKTp4erIBFZflVK9MM15lTNcYlp4XZ/pULYtQmuRYrTpRB+fMMN3pcst7ayJRUX0O0wYNO3AFAHfld0djTYkgy77vgRR+WcmdCOSAW/wxy2BcAyTOHYdro9jIC8ilE0XHYomY/lkyXAOYXI0GCp0bXH7Ys4oMxmC8v5GOQM5hSOICD0l46TdjoTURSR6Ukvho4JdUsb8IwkkSvF9gqe4SkUXtGdib3UjR2jbhkuZLnUbSOGQSt/g1LsT/b1DsUTlaxJWVU0A114rwHLH9pSJ5zEAumGRzZuX/Fu3MI8SCZ8G+duLt35nOyChRaSOuUfDEFO9lcU7XNYuiIu4wwPmzjpKpgmuj1TIMZUtUJ2mq7l72Ghdx1qG9rhCof8QpwpusRYGP+L7XOlZdYCYyTSedgCDFsHvJfmwzpBhEWbqU0UZqbMii7qI8GwRK84VjNGokyXi4LI97vIHElFU+Mk2CJspTszZhGxXzZMFhvRXH2SWuz0/UTNBVfB1IyyVjOBTkvpLodWyrLxiYI/Ynf6aNbNGf9MJCoeeDEMVL40FnnAbi84JEGAp8gP0XRkNnl9WjfcyDM5UTUgU3HWiJHf0qkfGkeOSqmIQINbwksuQzwblQy+wZsOZewEeGR5LzDH+KxwM1w5LZH+sda5zR9bufqVd+yRd+cBn2HjsxPek8stZ8ggBDTbNdNkWouPF50ta+a4VjWtyIBRgdgCzBtHvAvnwFACU42Dyc/7wEPv6I3G7S8WF75cunA8Gep680+O/SQCFtyg6WXwYprIFskvEtsxIpbYqYWIkdYWL+oA/c+kpzZGYYLNFS2QMcuqiNYtJA8/FhZiWmANkFqFgkmnQxJIJKzhQoBWcnE0RkTc4XeSVo6jiO9YdCfc3eInVvn02o5C1SMlCSG2dRCh0mT6VCS/1TsHvEThMdXASOLwo6VUqyYip2OQ8doxpq28r04sLjxeLciRj3IiW7fidBFP9YcVtyepTboJnwW6gD9eX5w/3fjb59zzWFr4ZmnhduMoeA1nmSOwynmxF71zn3rfnJZ1sZ+Deca7/w8Oimj58EPQ4wmzrisZFCVMKz6JIkC5rlzuJK9g61kKoTL/Hfp6HG3X3lPJmI4mtBcAAo+4hWESs0KS5ZMlTgP4N9Jo/MKTADstRFRgbpGurdXqPvgrJL/oIcOqgYtSs228vigcRVI0FEouLrbkFKGoOhcgiFOL/kfpDqeYqFNLuGY7oH0UPQLsAC88rVEnePqR5ZrCl4TZXsQogdNOdegULx3Z/hIm3onXSbmCx1zlzqcnDJChQnzyVyp03HBh4VwaYIMHbnATisJikWkKLhf8Zqa/UcrkQ+QdI9d2No1qgmkZl9nY+smnA1LySewIM7TgckSNj5cArYn0X0+eogipwyxcIAqH/ZU8n/hcMqsAmoejVwe3UmGCZsDgCBdBXGGycD+AGUJ9sg5F6uUvJsltEXhvGrhR5hrpXI3VqngRhhpeAUVrhdaKEmblGYNnIgBWmcNPodmUECzfXfDOfxSFwJeDIfXjg5BNWF0l6hSGkY83zTBNiTAT8LcKvMUUZms+4j03VYEQST2Eh/5wT/oVdub550cnwVUGz3np4i3v5Megm70zH4HuWr73LjwJTu2JmB9yX/NKQe6OZcXNgwUiLlYK+g7dO1Ou4UYgcDA9TIs1F7fDxyoWqCPLTHMdxa88GNzQmx0m3UNDm5QnlzB7yMDrXqqY22LraHmhhNZYDgV4iialJfQU+XpkCR5jYNkm0ctNuBtBmsJNmVHMthHtkFEwotyA4aQRPbi2OqM7Qmk4KL6RUcOxeuOkcebo4oNjsMDcPln6613v/JecnJL8IR+G1iZLBA8tzBYaN1jk7czQVaNArkN5NE/BDuSnT72rt7w7nyz+eJpnHm4czg4NkO4Nw73ZwTCtwKsDeYKUffyqd0y44Jx9Fh+cxojksZPLFx4vXb8i+wYYipe7gJ/lJ1cbn933zn67+PgJdMdbLz85sXTr9OL87cZHt5cuft8zaVQqaC9LxvLig08xlP7lkcb3pzEM+uCH5RN/wfB9YD6v3S3C+zsTaKAzgkvSCmjbmE2K69TJUZ9F8vdxkt2ZYDj1Kdw1u0lyv22OHXXgMeRU94hqKm+htcoNc3MHjsazcTZaMLACH/VE2dbUqkyDJAe1RtYiFuKAdTpe+7hkpEVLeTOYJyJKdOE9rhTxYL2kfFFfMpEi0HzjnvfxEYw0Azn++SFhd1iGzroruTvg6ISYSi7ioguY/NOKYp/uyt2Vd2/hRiwDSFwzcu/j5buXZF/bh/v68umb4jmX+CevCnebyb7F+XOcjaCTpdvvez8ek8VZVHh1rxlAfre6AOTBD41TpxYXFhYfX1p8eAZHYz3gHQKnPgOxuTi/4F958s665JP/8QB/M6iQgA9BPcob6zuJesWkghz5yiVdP6ytNuo10llfbWS0opd6/BGSqD9rLqc2d3QfTBy5EvaPi4skGGktLtxc/PGJTFf8XXaxfDBwXXm+pt4u0/hZyvFcZzKRthB2cbEkyKpllJhmW88j4TYaDnc2+UIChV0l7BQmxgLrap2KFRhEEu1H6R5NbD9Vhv2dXcVFNQAMhyQgmjg9KcOWkbdCciUs+PCelAVxQ9vi/FmQTDw9rHHyo5VrF5Yvn08WMzFiF2I/PPbqhMxTCpqOhM2zFjjPRugkCJ5VCp9/kwBKAF7xaC3CaDUCqYX8pgoBoZZGv7BESpRKIls68R5f6ST6xv6NGAoY7tvYvDBeIQgGMwSzrW7f5AYAM8EIGA9B0hbP/uRpAhg4Vl6Ipt4LmDGLgmesZrgRqyq0YdAEXpH4RBUJTKC6VhonfIQt6vSFg5KdAZHkioTlTtOdbHHTWtSn3EwO0eJz+6M+nCrU1cKlO5gwYn0kyYd6BoGJRH+JZ4JtHOCkOtCbdF9gONksPOMO7ddAnZDVqZNIUDJMvmTN+gXNfh44P3nBO/Vp4/2zfD0DN4LnjwpvgyVag+TlWYfexbtYYen8Xa6bGx/fbDy8sGrV05adutcK+3X/I7MZHnzK0zbRrfnmncb3p5Xbny3TMGKysd0U2ryekAShTogIuGeuFXN3sOnY/mUWfPZvHc521j4kFDp7hWdXr0V8dNZ/opBJSGtSyiPMI1F/s450bgORTmwgLoQ29DEhtGEw6Qb9aNC7de6M2CtYxQ7OluBCKInRNoJ+5mlCl79ZObKw/OMHPz+6xkMCmPKUs8rED7+eDIx07/Hf8VTaKYyior7OrcmylSKlI7GYhRIuzPq+8al35FHjyl1SpXYOJu9PGcXC5W/8RIkA1JUrC+yWwzOLj4+3kxPPMtYbjbRylCZlZAFyI9PB+76XLn7auHSSH/Gj8TBsrPIXoG04Q96AAXEquKMd2y41HGKVy0bR0CskbRvOJHwkepHVUSNjuskubmKmZEYZiAbdw/a+/V1eo4SaiB8EG9iU7e8n3ZsGB2NVxNCpzRCMcV3+zDv2g3fnXY4DdCivf9f46B4snU9meHOlvNvB3d9QXyJ1j18N/uRK48b7vFO0HwnGBJs7r7hE4ZM9itTDXHSbPtdh6mFOwb+5Z5J6mAsfNQpnH66N3yKeZDxGk5j/p4oLNG5/1bj8Q+MfnwP2eZCQYd/76j08xiVNDVeULaeIY5z9FjjwmQR8QtU05fy/QNF3lLeakQVvvXkveQ9uUUmZBidPNM5+sXLxKF67ztMUz17CMyaC6AS1spn7N41v6hvK9vdhBQRlXUSVxFdTjHfrQ++D7xp/PRcV+CtnT//07s3GO2dXLlxZPvn9P9/52+Lj+Z+OfrD0pwtblHL/V8IC3td/BVhXzp/56d2vvfnvlhY++/nRe9438yvnlBP4dbPDr5V2Xxe0u5en9zOdsnC+ceYo8CznVp+m+R4VphmcvcS3VgIBjeL45Al02c+c8O5cCxgioPGhIVb8uXegT0XinSXbrJUv1Fkk8o5ycvIwT/cPJ+ZgVpNQbeEknkj5lgndEY6Bz0AkykBxNCS7EwyPG/vRQOzrHVQmVwTnBtQuDe825Fji0b+iG01zUZm9qwivxJNm/hNi+b8dYmn+yah4eIjdDtbXuwH+GIzLA7VBxI1AluMnQoEJGX5bQqUOO96/TWCGKPmG7sTxRYNRysgpfH4iZvN4Y1uVINRQp6meRkkV4vZ7XG3WZWwybW7hCUG+6iy6VeZfxVk2njfLyGY1CdLyT4AH9dctVrXdG0IxKCPoCediRn6ZpKywQhNsk6SWeF2uvl5emGtTv1S7KGjWKueUf1cSj9euuJXhi9Yu0TOKMDzTlK5fzsWvKwQd42WebdjU0zz50KZ6YA4iFfuSKWIqYFtBA1jzFw03ENVKSc0veY5gg1WZE9tR07bh0lB+t2BNEDKryuf8j9iVAyyh/PK1Jq3n2uakM/9npD0KO05A/49a+d+lVtZFzF4uNgbwqrVeEBuDQ4MoP2KBvoE84eYckUmGLH9+a3H+NgbnVDJWCjL7Ud+l90+G9oJ5ym/j21uN984HNXBCY+TD0e9I0i/mzUuJcqGAwqo4LykSIIBQTk/Nbh2wWiKL1eNHqgSmOzt7FJ/VWg8bcx+crxsPgrU7Tt0RptqcnG6Bq2dxalqO5iVupkSSU72Tn+H7YpfypLxjfP8fnOownsKTXebPesfPrtw4wmvQrYttUDJm27CRXffft3HDkMJOi/7wwl77BEKxLuzrVsUozhQKr4sr13bgiTlQX7vZtywZI5vcZyZxh6y9z89sA63IIGJiPt0lCfskL15pmrXpqpW/LptnY3jfBi3Daru+gOII4w53YswoAdT6anxj/BFnMwsFVhCJbx+yRd6I9hdKVLyXMHKjdlyLw7uBsgrsvIRFzMR2/FqtFes67DyjTg5sm9UvWdseE+cpr3B2DejIJaBDXS7waUiILea/mIJ+S/EKQZmE+gdFCe4NbeREPZmpOyeVcTb+M2HrxK6eYk0Eev41i6KxZaiL62g3sHUY6B0azg4nJMWxDRiQvuySpKDqMNJx8DSdUQdpsQESuTX2Fqu1wEoP4XVEullKz8HTOQL/RO7la7mowqvhOMe6wyxCihdQprsCcJJw1dnLLVIQ1TtDTIsKxsJuFBHApCkJVyKGWel5OokBIrPh5CkqgeGMpD6ypFgez6wjq5wYz6rgExO2WmxqrMhP7zDLFRkAva/aCljdWUuTTmvtzlsmxW5aeNjYbfvzl52E79dwDrPF6a0EuNZ43Yd/JnPlxFncH1OdyQSJs9pTXd2/WIg+lhPyG44Lbbym2+j1s7BEE0eZkYR7ZZtck3y7bLgNU0SDw+w+bvZb9g3x4sa//Q2PEd2+yROMfCfAtaYqQW4RFmFmOYdc1OEduNGq7YESac2gXcCh7MuQCMR90Uj/MT+QYYjXKodRrEqJyeWkbWYd4DeKIhTWlHpYupN1MRK6dCI+uevy0SqeoLQ4fy4487A4f2T5+/v8dlLmz+IBicVHf1n+8hgmR/puRSiViQVA/HP0o22nFSLGdtNZF9+PVKKlM0XSVmPU1WHbIGojeD6Y7WxiJKR5z/s27iu2uHGtjaAe4VopGDR8GZsMWzShMZMUuY+ISN/L4pxZtI2q6/SwSnWaXQOZL9L/tTGrZpZ026Agt2Z8Ju20OTvPz7JTBrND/HY/Ywp1A3FmHOTbPbt37wPk4rWFaY0tkqZl8uIaRJCrVR1DW87+voPrctte2bHtd3uh9X5OD2n2cg9JhWVGxRgDUcGKHeJF5VVceHv9dkqrpGKYk6zOxwGszXAgM5eq2ZXUAec3BfhfXMW+PpMlKfgWCIdWc+yNku7qzSsxi7bl8LtcpHu+e+ghzIETs58JaoomwOhHeOOAppqmcDz0eyCNF0CmxONcsA8j9DQ1i9S/84aBCw2qOlYCbg9PqFoosq2l4eZ8awBFlFqESfyafrinz0sqCHhFWSPsNMcuehBlMJPAHqNgYiJIGGnrGHRnYG1gY6GMGMh7B54C3O49v9+7TxOkHYM6JT6l8HqR58m+CQoiDsicOFbNLtJcBUipArCPAxewCjRMrfJ6ioLL8F5Zdp+Q3swu8eti0hKWrJmqVVyjWqE4ApAK1qmxsCpjHsebgRZ4pxF0zuppOkXdZARdrlUqZA/OybUpJdMTFMbCLxx9igbETbC5TfVqlep2fl33b3ftfmnrLu3l3Xte2rl9+47XGIcymZT+RVkwxn5OcBg8PPDrus2uti3JAOB4B2ZhxAN1Nl5zLHhxCpwdsbrgBxLLBCwF9aHg5aSRePVcVfdBEWd8O6CboE9+pgoHGxcliRW977NncBrULLHFjA6BRFHJlQxHXOD5FCPJxbMXH3+I2Yh3HgTLEit6j4U6QOI3FwgG9LmFFbKG0ZJQxoo6s5n4ZbXFdJ5ypIPIXi/v3LVDk/QGHz/17xC/wdC/akkbhfJXJFQjkPm3CCeBlN5SwBDQgXxp7EB+LrazOse/5Aa++IA+1YE0yCT+MbSBOJcciMiwyTF4+C1uPazXnsB488vfhqjWgTdNt4IFjkH+2iUsqh3MMuL3tJijbEzPyUAJnIcMzASIknCdNP760C3TMNCBfOpAhjEt3jXd448W3N8tJk7x6Gsw79DV4qhkAINY4BpvrWfF2w+ySlY+pMC+YAOiEuTOAvzrUtvM+nXZUOtxTi+s6/ab4cNAGeft8Yo1lk79BmeUKfBZovvGipKBt4TpDcyfS/u14UdTNbecG/avM7ZppdlW3ETuWswwES3EwDHIojqyEPI5xBt5BxRqcSLNXMpC5PJmv4Qyql2zlC6nZgEErCMtRqkjkHzm8IWWjCRJJooxRCirabX0BF2sSwCxHb4A/Fagq1EYmQ1B4wVNBUc3Ddd4m6WF4OkSnalo+FuyAALrJE/2Yh1qXmYLGlZRXZRso4wp6fBAx5xmLN2GvQdKv4crzzLFXE6EzkGqBMbAa/FpaYRMou2A9/PSKSBfBopDimha8ZHyqyE6v9QIFjPGKuUpaSLYrhDaxgSJVaNroFZ2h7i/YHbqwFjZPOB0S1WPthT8IkRz6tpEmS2omNPA/lGSjK5phFhbrDAuKIcBV9MvB8lNLQymG6FlRWxIN9fjRXYwLX90QWRVG6yhdIoZruKrXGCsVtn2JAEbucJXrQDTQQk56mDVYLeE/j33MGQLG8BojtKt+Nbh9/qDRkn7DUNUz4Eqp3JkVnxfVw3MXXPdANNm7wzowakdhw033ZcRxZh6SvRQjwlmuTI8FkYU87Z7s7jLl+3bxLYGunt6niN7saClzCo+0nFGzLBmjsSUPkNEFQcilWYl0AgLb7PvMM2Edcp73i516msSaAtuBBH05Ot3tFvRoOP1Rx2WxzVVxT8Y42GxxWrNzcIg1iQwbJb37xjjpjC4swiHbKOjiqnZXCZU9ZmKpZecPEpArGeAVbAKhVeAQ6j9ql4d4U+dCb2/UJjdboyDZs+SvfBxaEOd3QgL/QH4L+987bc79ry+Z+dr+7RXdvxR2wWCmlc6Bqbr28BaSjVDZSyhbOEFQXkBW7z7tYJmN+JTttoRiiqV2k+CP4aCAKGv1sbSzNDIROqZ+cXCEiqKSlWOsJJjIV5WVL7DPFRWVHl7HF+OziqLdidl2bWqLBoLiPERY8VFW9032Elx0VbjiPqibUFRlRhtBVf0/WiV0W41zldbI53OiCrpdCa5TnpopFCd9Pg3QZ30KJYVddLbIk0uld4ew7Fq6a3X7eD6jpZAeTFqs3yrgvZ9LVi29XHU8xj3bRZ5fT6pyGsEGM6I0b54L5oFng6rF6vCIqNlzOgsG4dxdRmcTR7kX9S37Pe9+YPK6+/8QmAxCEK1aNvcPs6KeLWWd2jAcxmG91nb8ByMr5rhohvBSyk35bcIJmGcpinxMOzFRwhbVliqDWseccVUspjPBE3A4Q5pslYSU11s8T/y83+B/Fw1pzNrP7QP3eToyBecv6N5gCFuV3e15Rl0FefPMBeyDRyH85ewecCzc3VmxPkWV9PkYE61aGa4LCEmyjDia60M7anNbVa2Cwdcsr82fFDBJkgzJWZC4VYvs6Gw5Ck+SIf2I9kZRsxCk7iAtyscbibHxPfIUqyX0dl6lllsQxtGw9WH2Nf5CjVDUqwLhtqfzytMOFGyKyNQGUaA2Nttzh4fJIoIFbrwBUyYYWCxI7bBim2zkHSD4GWO1TPm9h/zQlFGBvW/MaLlWqC7fQkHnq6/psL2ZrGiiuWAM5FXToT1rU0o5iHXEg/V5wtqkLJ54EeNXfelMXEIM1p/oDZbpuVyHbRf8+vATsBbIFQ1SpvLyZ4JL07s6MJL+1NIteiMpVjwnv1llNgvIGF7hv1VSB1s9pQ3sDyoxDR53ZxJz3GtOBeAweBzmAIQqtQ/9yxDBW59DCZVD+tfCKvqFN7+Uat29u4svDunhmz9/ki/uFcPzs1kBCy5CS5RzrJzY4aJm4xhEVHG2IjmUNMxmM89wbyfNCpaoAPZI3pNx2KsQBRYfSOk/agjMyK+GpTXkFgthXvTli0iMpH95zkRCs21acQ9vfhzcKhzSd8dzulVIwfmg+qrccsab/U9wpNzMR1Z+W0RY49J309PT7P30fopYkC/xZwjbRgBhDAp1Urkw0kU3aItd507bNw01VKyQHqZCVvCSUMEACQf3vdZqxW9iFKv2UtujOpMEHLvNiZ9uBQXNOcE5pb4DPQX+OIKqSpatWJ07vPgbLMchMycopZPEv0rLZmmUsIm9dEXfKN6c7R6nbqcni9BGTggP9lvocJDHkLqBSwkU3PLw5uV3hCsC8eOHzrA/VVYEAyP487wmAE6wjYqM6Si2+N0BL6vsto0wVwjNrH63LqJh4NKBR/3PsDFCZ15lZhCne7b0JvsPgYxRd4T18DkBdEP/5SQARfF9azfSf2fR75OKdI3k1HeujtlX8mujkxiYtqFwgvog2ubN4cIUDinJGCnUKHXbrnQK3spUujVf8TF8GzADVmx9H9AHNYTK8KiQxMETUG/l6ijSd6YcHI4+WCibuDyxcvCYhRutFknXq8YRVrgsuVFelhHPwx3egrDg4MDPSX90BYmo0b1sWJX2bJG+54Hq+cQirfILRfBBvNoQtwqXjmWF60X70m165Vg8HACgJBlAB1UVqJ9zu9NEoVshuGgQqvmvpjtuPuxYueNfdy1ry7LXWctvPAl26r6i41Lb9PKTNsVHqsVJ6mbF0jtwQj0lj/mtk69ndsWUNDo1t/t3N7Fn+6FkXUs1TIqUDHSUeHa9o53NObQCsKUqlxtAqoURjkWRsZy7zYm6aNYZEzhr4NmWpp4KY49PmlA4FjKmczxTzkerc4JMzxK+mXDdvi2TNw74B0k0D57MdvqPRVhsbdCtqTwmPoHA48pgST5qx0Se7QxR0In1ZuZSNOETcBJQ9bMXE7FUY+RE980GSWBjIynZ4tGeQALQE3LNmNIoPINao17PtiMJVSNMrmcU8092rFY8Bwew23ZtV7FYtDM1O1BQz7crayAA3c4Zix1iQeqxQiUrrx2/qRDpkuHb8szG20NvSTQ4kAoUZks1QL/VCvxXaGa4YDXofm+L/dgnUCICDATy2dHvV7uVJLZ+gHzgMkSINDPS2C/2MsvTLhTlc2sKhUtvdDDPrFOhM/XaT+zB1LW5IFUAS91qLMOms6dhKP6uv8PCAMNBA7FAAA=" + +patch = gzip.decompress(base64.b64decode(PATCH_B64)) +patch_path = Path("/tmp/global-hardening-final.patch") +patch_path.write_bytes(patch) +subprocess.run(["git", "apply", "--check", str(patch_path)], check=True) +subprocess.run(["git", "apply", str(patch_path)], check=True) +print(f"Applied final global hardening patch ({len(patch)} bytes)") From b8f734e53362115e180773f6c0b30bfd7dcc197f Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Fri, 4 Sep 2026 21:14:42 +0800 Subject: [PATCH 035/112] chore: run verified self-cleaning final hardening patch --- .../workflows/final-hardening-apply-once.yml | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/final-hardening-apply-once.yml diff --git a/.github/workflows/final-hardening-apply-once.yml b/.github/workflows/final-hardening-apply-once.yml new file mode 100644 index 00000000000..3cdc4fe0357 --- /dev/null +++ b/.github/workflows/final-hardening-apply-once.yml @@ -0,0 +1,63 @@ +name: Final Hardening Apply Once + +on: + push: + branches: + - fix/global-hardening-20260904 + paths: + - .github/workflows/final-hardening-apply-once.yml + +permissions: + contents: write + +jobs: + apply-and-verify: + if: github.repository == 'z13321812367-sys/ccswitchmulti' + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + with: + ref: fix/global-hardening-20260904 + fetch-depth: 0 + + - name: Apply exact global hardening patch + run: python scripts/apply_final_global_hardening_once.py + + - name: Install Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential pkg-config libssl-dev \ + libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev + sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \ + || sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev + sudo apt-get install -y --no-install-recommends libsoup-3.0-dev \ + || sudo apt-get install -y --no-install-recommends libsoup2.4-dev + + - name: Format with repository Rust toolchain + run: cargo fmt --manifest-path src-tauri/Cargo.toml + + - name: Run permanent policy guards + run: | + python scripts/check_workflow_shell_interpolation.py + python scripts/check_rust_failure_boundaries.py + git diff --check + + - name: Create frontend dist placeholder + run: mkdir -p dist + + - name: Clippy + run: cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings + + - name: Rust tests + run: cargo test --manifest-path src-tauri/Cargo.toml + + - name: Remove one-shot drivers and commit verified changes + run: | + git rm scripts/apply_final_global_hardening_once.py .github/workflows/final-hardening-apply-once.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git diff --cached --check + git commit -m "fix: close global diagnostics and rollback failure boundaries" + git push origin HEAD:fix/global-hardening-20260904 From 74f7e0c0ea4a58ad5250f62abba278bc8ed9ed51 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 01:52:08 +0800 Subject: [PATCH 036/112] chore: add one-shot forwarder timeout test migration --- scripts/fix_forwarder_timeout_test_once.py | 24 ++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 scripts/fix_forwarder_timeout_test_once.py diff --git a/scripts/fix_forwarder_timeout_test_once.py b/scripts/fix_forwarder_timeout_test_once.py new file mode 100644 index 00000000000..7fdc48ff542 --- /dev/null +++ b/scripts/fix_forwarder_timeout_test_once.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PATH = ROOT / "src-tauri/src/proxy/forwarder.rs" + +old = """ codex_responses_lite_fallbacks: Arc::new(RwLock::new(HashMap::new())),\n non_streaming_timeout,\n streaming_first_byte_timeout,\n max_attempts: 1,\n""" +new = """ codex_responses_lite_fallbacks: Arc::new(RwLock::new(HashMap::new())),\n timeout_policy: ForwarderTimeoutPolicy::from_seconds(\n non_streaming_timeout.as_secs(),\n streaming_first_byte_timeout.as_secs(),\n ),\n max_attempts: 1,\n""" + +text = PATH.read_text(encoding="utf-8") +count = text.count(old) +if count != 1: + raise SystemExit(f"expected exactly one stale test RequestForwarder timeout initializer, found {count}") + +text = text.replace(old, new, 1) + +# The old fields were removed from RequestForwarder. A direct test initializer must not resurrect +# them; cargo test is the compile-time regression barrier, while this assertion keeps this one-shot +# migration exact and auditable. +if " non_streaming_timeout,\n streaming_first_byte_timeout,\n" in text: + raise SystemExit("stale RequestForwarder timeout fields remain after replacement") + +PATH.write_text(text, encoding="utf-8") +print("updated forwarder test helper to use ForwarderTimeoutPolicy") From 010d5ba494c84defc5f18247f27ed681b1d265f8 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 01:52:19 +0800 Subject: [PATCH 037/112] ci: add one-shot forwarder timeout test fixer --- .../forwarder-timeout-test-fix-once.yml | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/forwarder-timeout-test-fix-once.yml diff --git a/.github/workflows/forwarder-timeout-test-fix-once.yml b/.github/workflows/forwarder-timeout-test-fix-once.yml new file mode 100644 index 00000000000..d1ca07dc106 --- /dev/null +++ b/.github/workflows/forwarder-timeout-test-fix-once.yml @@ -0,0 +1,51 @@ +name: Forwarder Timeout Test Fix Once + +on: + push: + branches: + - fix/global-hardening-20260904 + paths: + - .github/workflows/forwarder-timeout-test-fix-once.yml + +permissions: + contents: write + +jobs: + apply: + if: github.repository == 'z13321812367-sys/ccswitchmulti' + runs-on: ubuntu-latest + steps: + - name: Checkout audit branch + uses: actions/checkout@v6 + with: + ref: fix/global-hardening-20260904 + fetch-depth: 0 + + - name: Apply exact timeout test migration + run: python scripts/fix_forwarder_timeout_test_once.py + + - name: Format Rust + run: cargo fmt --manifest-path src-tauri/Cargo.toml --all + + - name: Enforce Rust failure boundaries + run: python scripts/check_rust_failure_boundaries.py + + - name: Compile and run affected lib test + run: cargo test --manifest-path src-tauri/Cargo.toml --lib proxy::forwarder::tests::non_streaming_success_is_buffered_before_marking_provider_successful + + - name: Validate diff + run: git diff --check + + - name: Commit exact production-tree change + shell: bash + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src-tauri/src/proxy/forwarder.rs + if git diff --cached --quiet; then + echo "Expected forwarder.rs change was not produced" >&2 + exit 1 + fi + git commit -m "test: align forwarder timeout helper with policy" + git push origin HEAD:fix/global-hardening-20260904 From c39bb42368ac5f1ad2091ac1a634d15f6cd2183b Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 01:55:06 +0800 Subject: [PATCH 038/112] ci: install native deps for timeout test fixer --- .../forwarder-timeout-test-fix-once.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/forwarder-timeout-test-fix-once.yml b/.github/workflows/forwarder-timeout-test-fix-once.yml index d1ca07dc106..271de1095f8 100644 --- a/.github/workflows/forwarder-timeout-test-fix-once.yml +++ b/.github/workflows/forwarder-timeout-test-fix-once.yml @@ -13,7 +13,7 @@ permissions: jobs: apply: if: github.repository == 'z13321812367-sys/ccswitchmulti' - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Checkout audit branch uses: actions/checkout@v6 @@ -24,12 +24,26 @@ jobs: - name: Apply exact timeout test migration run: python scripts/fix_forwarder_timeout_test_once.py + - name: Install Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential pkg-config libssl-dev \ + libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev + sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \ + || sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev + sudo apt-get install -y --no-install-recommends libsoup-3.0-dev \ + || sudo apt-get install -y --no-install-recommends libsoup2.4-dev + - name: Format Rust run: cargo fmt --manifest-path src-tauri/Cargo.toml --all - name: Enforce Rust failure boundaries run: python scripts/check_rust_failure_boundaries.py + - name: Create frontend dist placeholder + run: mkdir -p dist + - name: Compile and run affected lib test run: cargo test --manifest-path src-tauri/Cargo.toml --lib proxy::forwarder::tests::non_streaming_success_is_buffered_before_marking_provider_successful From c4273762feec673660dbaff3e1409247c1fd3de5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:59:54 +0000 Subject: [PATCH 039/112] test: align forwarder timeout helper with policy --- src-tauri/src/proxy/forwarder.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 6ec5aba9b5c..90ad8f11af3 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -3852,8 +3852,10 @@ mod tests { optimizer_config: OptimizerConfig::default(), copilot_optimizer_config: CopilotOptimizerConfig::default(), codex_responses_lite_fallbacks: Arc::new(RwLock::new(HashMap::new())), - non_streaming_timeout, - streaming_first_byte_timeout, + timeout_policy: ForwarderTimeoutPolicy::from_seconds( + non_streaming_timeout.as_secs(), + streaming_first_byte_timeout.as_secs(), + ), max_attempts: 1, } } From 67392ba7d6021912bd4b38419869806f6333cd87 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 02:01:40 +0800 Subject: [PATCH 040/112] chore: remove one-shot timeout test migration --- scripts/fix_forwarder_timeout_test_once.py | 24 ---------------------- 1 file changed, 24 deletions(-) delete mode 100644 scripts/fix_forwarder_timeout_test_once.py diff --git a/scripts/fix_forwarder_timeout_test_once.py b/scripts/fix_forwarder_timeout_test_once.py deleted file mode 100644 index 7fdc48ff542..00000000000 --- a/scripts/fix_forwarder_timeout_test_once.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -PATH = ROOT / "src-tauri/src/proxy/forwarder.rs" - -old = """ codex_responses_lite_fallbacks: Arc::new(RwLock::new(HashMap::new())),\n non_streaming_timeout,\n streaming_first_byte_timeout,\n max_attempts: 1,\n""" -new = """ codex_responses_lite_fallbacks: Arc::new(RwLock::new(HashMap::new())),\n timeout_policy: ForwarderTimeoutPolicy::from_seconds(\n non_streaming_timeout.as_secs(),\n streaming_first_byte_timeout.as_secs(),\n ),\n max_attempts: 1,\n""" - -text = PATH.read_text(encoding="utf-8") -count = text.count(old) -if count != 1: - raise SystemExit(f"expected exactly one stale test RequestForwarder timeout initializer, found {count}") - -text = text.replace(old, new, 1) - -# The old fields were removed from RequestForwarder. A direct test initializer must not resurrect -# them; cargo test is the compile-time regression barrier, while this assertion keeps this one-shot -# migration exact and auditable. -if " non_streaming_timeout,\n streaming_first_byte_timeout,\n" in text: - raise SystemExit("stale RequestForwarder timeout fields remain after replacement") - -PATH.write_text(text, encoding="utf-8") -print("updated forwarder test helper to use ForwarderTimeoutPolicy") From 9dceffd08c12f3051a7572e8758bfb94cb2557d5 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 02:01:46 +0800 Subject: [PATCH 041/112] ci: remove one-shot timeout test fixer --- .../forwarder-timeout-test-fix-once.yml | 65 ------------------- 1 file changed, 65 deletions(-) delete mode 100644 .github/workflows/forwarder-timeout-test-fix-once.yml diff --git a/.github/workflows/forwarder-timeout-test-fix-once.yml b/.github/workflows/forwarder-timeout-test-fix-once.yml deleted file mode 100644 index 271de1095f8..00000000000 --- a/.github/workflows/forwarder-timeout-test-fix-once.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: Forwarder Timeout Test Fix Once - -on: - push: - branches: - - fix/global-hardening-20260904 - paths: - - .github/workflows/forwarder-timeout-test-fix-once.yml - -permissions: - contents: write - -jobs: - apply: - if: github.repository == 'z13321812367-sys/ccswitchmulti' - runs-on: ubuntu-22.04 - steps: - - name: Checkout audit branch - uses: actions/checkout@v6 - with: - ref: fix/global-hardening-20260904 - fetch-depth: 0 - - - name: Apply exact timeout test migration - run: python scripts/fix_forwarder_timeout_test_once.py - - - name: Install Linux system dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential pkg-config libssl-dev \ - libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev - sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \ - || sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev - sudo apt-get install -y --no-install-recommends libsoup-3.0-dev \ - || sudo apt-get install -y --no-install-recommends libsoup2.4-dev - - - name: Format Rust - run: cargo fmt --manifest-path src-tauri/Cargo.toml --all - - - name: Enforce Rust failure boundaries - run: python scripts/check_rust_failure_boundaries.py - - - name: Create frontend dist placeholder - run: mkdir -p dist - - - name: Compile and run affected lib test - run: cargo test --manifest-path src-tauri/Cargo.toml --lib proxy::forwarder::tests::non_streaming_success_is_buffered_before_marking_provider_successful - - - name: Validate diff - run: git diff --check - - - name: Commit exact production-tree change - shell: bash - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src-tauri/src/proxy/forwarder.rs - if git diff --cached --quiet; then - echo "Expected forwarder.rs change was not produced" >&2 - exit 1 - fi - git commit -m "test: align forwarder timeout helper with policy" - git push origin HEAD:fix/global-hardening-20260904 From 748c7788706c0bd7f8b2aa2cdbbc4dbb5e599d6a Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 02:03:27 +0800 Subject: [PATCH 042/112] ci: remove obsolete snippet helper in final hardening pass --- .../workflows/final-hardening-apply-once.yml | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/.github/workflows/final-hardening-apply-once.yml b/.github/workflows/final-hardening-apply-once.yml index 3cdc4fe0357..8a6afb71069 100644 --- a/.github/workflows/final-hardening-apply-once.yml +++ b/.github/workflows/final-hardening-apply-once.yml @@ -23,6 +23,45 @@ jobs: - name: Apply exact global hardening patch run: python scripts/apply_final_global_hardening_once.py + - name: Remove obsolete body snippet helper and test + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + path = Path("src-tauri/src/proxy/handlers.rs") + text = path.read_text(encoding="utf-8") + + prod_start_marker = "/// 取 body 前 `max_chars` 个字符的单行摘要:" + prod_end_marker = "/// 解析单个 SSE 块的 event 名与 data 负载" + if text.count(prod_start_marker) != 1 or text.count(prod_end_marker) != 1: + raise SystemExit("body_snippet production markers are not unique") + start = text.index(prod_start_marker) + end = text.index(prod_end_marker, start) + block = text[start:end] + if block.count("fn body_snippet(") != 1: + raise SystemExit("expected exactly one body_snippet function in production block") + text = text[:start] + text[end:] + + test_marker = " #[test]\n fn body_snippet_sanitizes_controls_and_truncates() {" + if text.count(test_marker) != 1: + raise SystemExit("expected exactly one body_snippet regression test") + start = text.index(test_marker) + next_test = text.find("\n #[test]\n", start + len(test_marker)) + module_end = text.find("\n}", start + len(test_marker)) + candidates = [pos for pos in (next_test, module_end) if pos != -1] + if not candidates: + raise SystemExit("could not find end boundary for body_snippet regression test") + end = min(candidates) + text = text[:start] + text[end + 1:] + + if "body_snippet(" in text: + raise SystemExit("body_snippet references remain after exact removal") + path.write_text(text, encoding="utf-8") + print("removed obsolete body_snippet helper and regression test") + PY + - name: Install Linux system dependencies run: | sudo apt-get update @@ -35,7 +74,7 @@ jobs: || sudo apt-get install -y --no-install-recommends libsoup2.4-dev - name: Format with repository Rust toolchain - run: cargo fmt --manifest-path src-tauri/Cargo.toml + run: cargo fmt --manifest-path src-tauri/Cargo.toml --all - name: Run permanent policy guards run: | @@ -46,11 +85,11 @@ jobs: - name: Create frontend dist placeholder run: mkdir -p dist - - name: Clippy - run: cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings + - name: Clippy all targets and features + run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --all-features -- -D warnings - name: Rust tests - run: cargo test --manifest-path src-tauri/Cargo.toml + run: cargo test --manifest-path src-tauri/Cargo.toml --all-features - name: Remove one-shot drivers and commit verified changes run: | From 51a2d3dca4d14e3bb1b36bf715d3aad9891d2741 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 02:17:10 +0800 Subject: [PATCH 043/112] ci: remove stale body snippet test import --- .../workflows/final-hardening-apply-once.yml | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/.github/workflows/final-hardening-apply-once.yml b/.github/workflows/final-hardening-apply-once.yml index 8a6afb71069..908a3ceccec 100644 --- a/.github/workflows/final-hardening-apply-once.yml +++ b/.github/workflows/final-hardening-apply-once.yml @@ -23,7 +23,7 @@ jobs: - name: Apply exact global hardening patch run: python scripts/apply_final_global_hardening_once.py - - name: Remove obsolete body snippet helper and test + - name: Remove obsolete body snippet helper, test, and import shell: bash run: | set -euo pipefail @@ -56,10 +56,19 @@ jobs: end = min(candidates) text = text[:start] + text[end + 1:] - if "body_snippet(" in text: - raise SystemExit("body_snippet references remain after exact removal") + stale_import = " body_looks_like_sse, body_snippet, chat_sse_to_response_value,\n" + if text.count(stale_import) != 1: + raise SystemExit("expected exactly one stale body_snippet test import") + text = text.replace( + stale_import, + " body_looks_like_sse, chat_sse_to_response_value,\n", + 1, + ) + + if "body_snippet" in text: + raise SystemExit("body_snippet identifier remains after exact removal") path.write_text(text, encoding="utf-8") - print("removed obsolete body_snippet helper and regression test") + print("removed obsolete body_snippet helper, regression test, and test import") PY - name: Install Linux system dependencies From 117ccc9a800943451bcfff22208527e17f7fc594 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 02:23:04 +0800 Subject: [PATCH 044/112] ci: normalize all-target Clippy baselines --- .../workflows/final-hardening-apply-once.yml | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/final-hardening-apply-once.yml b/.github/workflows/final-hardening-apply-once.yml index 908a3ceccec..117f975c938 100644 --- a/.github/workflows/final-hardening-apply-once.yml +++ b/.github/workflows/final-hardening-apply-once.yml @@ -71,6 +71,40 @@ jobs: print("removed obsolete body_snippet helper, regression test, and test import") PY + - name: Normalize all-target Clippy baselines + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + codex_path = Path("src-tauri/src/codex_config.rs") + codex = codex_path.read_text(encoding="utf-8") + codex_old = """ let Some(models) = value.as_object() else {\n return None;\n };\n""" + codex_new = """ let models = value.as_object()?;\n""" + if codex.count(codex_old) != 1: + raise SystemExit(f"expected one codex let-else baseline, found {codex.count(codex_old)}") + codex = codex.replace(codex_old, codex_new, 1) + if codex_old in codex: + raise SystemExit("codex let-else baseline remains") + codex_path.write_text(codex, encoding="utf-8") + + settings_path = Path("src-tauri/src/settings.rs") + settings = settings_path.read_text(encoding="utf-8") + settings_old = """ let mut settings = AppSettings::default();\n settings.show_in_tray = false;\n""" + settings_new = """ let settings = AppSettings {\n show_in_tray: false,\n ..Default::default()\n };\n""" + if settings.count(settings_old) != 1: + raise SystemExit( + f"expected one settings field-reassign baseline, found {settings.count(settings_old)}" + ) + settings = settings.replace(settings_old, settings_new, 1) + if settings_old in settings: + raise SystemExit("settings field-reassign baseline remains") + settings_path.write_text(settings, encoding="utf-8") + + print("normalized all-target Clippy baselines without lint suppression") + PY + - name: Install Linux system dependencies run: | sudo apt-get update From c7acc5da97e633d4e81fbed11ca22475d8b8c156 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 10:34:07 +0800 Subject: [PATCH 045/112] chore: capture final hardening test failure --- .../final-hardening-diagnose-once.yml | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/final-hardening-diagnose-once.yml diff --git a/.github/workflows/final-hardening-diagnose-once.yml b/.github/workflows/final-hardening-diagnose-once.yml new file mode 100644 index 00000000000..3038d189786 --- /dev/null +++ b/.github/workflows/final-hardening-diagnose-once.yml @@ -0,0 +1,65 @@ +name: Final Hardening Diagnose Once + +on: + push: + branches: + - fix/global-hardening-20260904 + paths: + - .github/workflows/final-hardening-diagnose-once.yml + +permissions: + contents: write + +jobs: + diagnose: + if: github.repository == 'z13321812367-sys/ccswitchmulti' + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + with: + ref: fix/global-hardening-20260904 + fetch-depth: 0 + + - name: Install Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential pkg-config libssl-dev \ + libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev + sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \ + || sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev + sudo apt-get install -y --no-install-recommends libsoup-3.0-dev \ + || sudo apt-get install -y --no-install-recommends libsoup2.4-dev + + - name: Apply exact final hardening patch + run: python scripts/apply_final_global_hardening_once.py + + - name: Create frontend dist placeholder + run: mkdir -p dist + + - name: Run all-feature Rust tests and persist failure diagnostics + shell: bash + run: | + set -uo pipefail + log=/tmp/final-hardening-rust-tests.log + cargo test --manifest-path src-tauri/Cargo.toml --all-features 2>&1 | tee "$log" + status=${PIPESTATUS[0]} + if [ "$status" -ne 0 ]; then + { + echo "Final hardening Rust test failure diagnostics" + echo "cargo_exit=$status" + echo + echo "=== failure markers ===" + grep -n -E -- 'FAILED|failures:|panicked at|assertion .*failed|test result:|left:|right:|error: test failed|---- .* stdout ----' "$log" || true + echo + echo "=== final 1200 lines ===" + tail -n 1200 "$log" + } > .ci-final-hardening-test-failure.txt + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add .ci-final-hardening-test-failure.txt + git diff --cached --check + git commit -m "chore: capture final hardening test failure" + git push origin HEAD:fix/global-hardening-20260904 + fi + exit "$status" From 19f25bbb4cdf7b13322b2617cf3ca1878a81c929 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 11:34:37 +0800 Subject: [PATCH 046/112] ci: persist final hardening test diagnostics reliably --- .../final-hardening-diagnose-once.yml | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/.github/workflows/final-hardening-diagnose-once.yml b/.github/workflows/final-hardening-diagnose-once.yml index 3038d189786..7fa8e5fec8e 100644 --- a/.github/workflows/final-hardening-diagnose-once.yml +++ b/.github/workflows/final-hardening-diagnose-once.yml @@ -40,26 +40,30 @@ jobs: - name: Run all-feature Rust tests and persist failure diagnostics shell: bash run: | - set -uo pipefail + set +e log=/tmp/final-hardening-rust-tests.log - cargo test --manifest-path src-tauri/Cargo.toml --all-features 2>&1 | tee "$log" - status=${PIPESTATUS[0]} - if [ "$status" -ne 0 ]; then - { - echo "Final hardening Rust test failure diagnostics" - echo "cargo_exit=$status" - echo - echo "=== failure markers ===" - grep -n -E -- 'FAILED|failures:|panicked at|assertion .*failed|test result:|left:|right:|error: test failed|---- .* stdout ----' "$log" || true - echo - echo "=== final 1200 lines ===" - tail -n 1200 "$log" - } > .ci-final-hardening-test-failure.txt - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add .ci-final-hardening-test-failure.txt - git diff --cached --check + cargo test --manifest-path src-tauri/Cargo.toml --all-features >"$log" 2>&1 + status=$? + set -e + + { + echo "Final hardening Rust test diagnostics" + echo "cargo_exit=$status" + echo + echo "=== failure markers ===" + grep -n -E -- 'FAILED|failures:|panicked at|assertion .*failed|test result:|left:|right:|error: test failed|---- .* stdout ----' "$log" || true + echo + echo "=== final 1200 lines ===" + tail -n 1200 "$log" || true + } > .ci-final-hardening-test-failure.txt + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -f .ci-final-hardening-test-failure.txt + git diff --cached --check + if ! git diff --cached --quiet; then git commit -m "chore: capture final hardening test failure" git push origin HEAD:fix/global-hardening-20260904 fi - exit "$status" + + echo "Captured cargo test exit status: $status" From dcc244ca5bf34bbd21616e958ecd5f28621ae7a5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:39:38 +0000 Subject: [PATCH 047/112] chore: capture final hardening test failure --- .ci-final-hardening-test-failure.txt | 1213 ++++++++++++++++++++++++++ 1 file changed, 1213 insertions(+) create mode 100644 .ci-final-hardening-test-failure.txt diff --git a/.ci-final-hardening-test-failure.txt b/.ci-final-hardening-test-failure.txt new file mode 100644 index 00000000000..7413c4a3a59 --- /dev/null +++ b/.ci-final-hardening-test-failure.txt @@ -0,0 +1,1213 @@ +Final hardening Rust test diagnostics +cargo_exit=101 + +=== failure markers === +1891:test proxy::handlers::tests::upstream_body_parse_error_carries_field_diagnostics ... FAILED +3092:failures: +3094:---- proxy::handlers::tests::upstream_body_parse_error_carries_field_diagnostics stdout ---- +3096:thread 'proxy::handlers::tests::upstream_body_parse_error_carries_field_diagnostics' (15646) panicked at src/proxy/handlers.rs:3413:17: +3100:failures: +3103:test result: FAILED. 2010 passed; 1 failed; 2 ignored; 0 measured; 0 filtered out; finished in 2.99s +3105:error: test failed, to rerun pass `--lib` + +=== final 1200 lines === +test proxy::json_canonical::tests::canonicalize_tool_arguments_str_coerces_empty_to_object ... ok +test proxy::json_canonical::tests::canonicalize_tool_arguments_str_wraps_malformed_json ... ok +test proxy::json_canonical::tests::canonicalize_value_sorts_map_storage_order ... ok +test proxy::media_sanitizer::tests::deepseekv4_aliases_replace_images_before_send ... ok +test proxy::media_sanitizer::tests::detects_chat_content_unknown_variant_image_url_errors ... ok +test proxy::media_sanitizer::tests::detects_media_and_attachment_error_phrasings ... ok +test proxy::media_sanitizer::tests::detects_minimax_sensitive_image_errors ... ok +test proxy::media_sanitizer::tests::detects_unsupported_image_errors ... ok +test proxy::media_sanitizer::tests::explicit_image_modalities_preserve_model_images ... ok +test proxy::media_sanitizer::tests::explicit_text_capability_replaces_even_when_heuristic_disabled ... ok +test proxy::media_sanitizer::tests::explicit_text_modalities_can_override_visual_model_ids ... ok +test proxy::media_sanitizer::tests::explicit_text_modalities_replace_images_before_send ... ok +test proxy::media_sanitizer::tests::heuristic_disabled_keeps_images_for_listed_text_only_models ... ok +test proxy::media_sanitizer::tests::ignores_non_image_errors ... ok +test proxy::media_sanitizer::tests::keeps_images_when_model_capability_is_unknown ... ok +test proxy::media_sanitizer::tests::known_mimo_pro_replaces_but_mimo_multimodal_preserves ... ok +test proxy::media_sanitizer::tests::known_text_only_models_replace_chat_image_url_before_send ... ok +test proxy::media_sanitizer::tests::known_text_only_models_replace_codex_input_image_before_send ... ok +test proxy::media_sanitizer::tests::known_text_only_models_replace_images_before_send ... ok +test proxy::media_sanitizer::tests::known_text_only_prefixes_replace_images_before_send ... ok +test proxy::media_sanitizer::tests::multimodal_kimi_model_is_not_on_text_only_list ... ok +test proxy::media_sanitizer::tests::preserves_cache_control_when_replacing_image ... ok +test proxy::media_sanitizer::tests::preserves_images_without_explicit_capability_even_for_unknown_models ... ok +test proxy::media_sanitizer::tests::replaces_nested_tool_result_image_blocks ... ok +test proxy::media_sanitizer::tests::unconditional_marker_replacement_handles_retry_path ... ok +test proxy::model_mapper::tests::keeps_model_without_one_m_suffix ... ok +test proxy::model_mapper::tests::strips_one_m_suffix_after_mapping ... ok +test proxy::model_mapper::tests::strips_one_m_suffix_before_upstream ... ok +test proxy::model_mapper::tests::test_case_insensitive ... ok +test proxy::model_mapper::tests::test_fable_falls_back_to_default_without_opus ... ok +test proxy::model_mapper::tests::test_fable_falls_back_to_opus_when_unset ... ok +test proxy::model_mapper::tests::test_fable_mapping ... ok +test proxy::model_mapper::tests::test_fable_with_one_m_suffix_mapping ... ok +test proxy::model_mapper::tests::test_haiku_mapping ... ok +test proxy::model_mapper::tests::test_no_mapping_configured ... ok +test proxy::model_mapper::tests::test_opus_mapping ... ok +test proxy::model_mapper::tests::test_sonnet_mapping ... ok +test proxy::model_mapper::tests::test_thinking_adaptive_does_not_affect_model_mapping ... ok +test proxy::model_mapper::tests::test_thinking_disabled ... ok +test proxy::model_mapper::tests::test_thinking_does_not_affect_model_mapping ... ok +test proxy::model_mapper::tests::test_unknown_model_uses_default ... ok +test proxy::provider_router::tests::test_failover_disabled_uses_current_provider ... ok +test proxy::provider_router::tests::test_failover_enabled_uses_queue_only_even_if_current_not_in_queue ... ok +test proxy::provider_router::tests::test_failover_enabled_uses_queue_order_ignoring_current ... ok +test proxy::provider_router::tests::test_provider_router_creation ... ok +test proxy::provider_router::tests::test_release_permit_neutral_frees_half_open_slot ... ok +test proxy::provider_router::tests::test_select_providers_does_not_consume_half_open_permit ... ok +test proxy::providers::auth::tests::test_all_strategies_are_distinct ... ok +test proxy::providers::auth::tests::test_auth_info_new_has_no_access_token ... ok +test proxy::providers::auth::tests::test_auth_info_with_access_token ... ok +test proxy::providers::auth::tests::test_auth_strategy_equality ... ok +test proxy::providers::auth::tests::test_claude_auth_strategy ... ok +test proxy::providers::auth::tests::test_google_oauth_strategy ... ok +test proxy::providers::auth::tests::test_masked_access_token_long ... ok +test proxy::providers::auth::tests::test_masked_access_token_none ... ok +test proxy::providers::auth::tests::test_masked_access_token_short ... ok +test proxy::providers::auth::tests::test_masked_access_token_utf8_safe ... ok +test proxy::providers::auth::tests::test_masked_key_9_chars ... ok +test proxy::providers::auth::tests::test_masked_key_exactly_8 ... ok +test proxy::providers::auth::tests::test_masked_key_long ... ok +test proxy::providers::auth::tests::test_masked_key_short ... ok +test proxy::providers::auth::tests::test_masked_key_utf8_safe ... ok +test proxy::providers::claude::tests::test_anthropic_messages_no_longer_hoists_system_role_messages ... ok +test proxy::providers::claude::tests::test_anthropic_system_role_messages_skip_non_anthropic_format ... ok +test proxy::providers::claude::tests::test_build_url_anthropic ... ok +test proxy::providers::claude::tests::test_build_url_no_beta_for_github_copilot ... ok +test proxy::providers::claude::tests::test_build_url_no_beta_for_openai_chat_completions ... ok +test proxy::providers::claude::tests::test_build_url_no_beta_for_other_endpoints ... ok +test proxy::providers::claude::tests::test_build_url_openrouter ... ok +test proxy::providers::claude::tests::test_build_url_preserve_existing_query ... ok +test proxy::providers::claude::tests::test_deepseek_anthropic_tool_history_injects_missing_thinking ... ok +test proxy::providers::claude::tests::test_deepseek_anthropic_tool_history_keeps_thinking_text_but_drops_signature ... ok +test proxy::providers::claude::tests::test_deepseek_anthropic_tool_history_rewrites_redacted_thinking ... ok +test proxy::providers::claude::tests::test_deepseek_official_detected_via_base_url_fallback ... ok +test proxy::providers::claude::tests::test_deepseek_official_no_effort_no_change ... ok +test proxy::providers::claude::tests::test_deepseek_official_non_disabled_not_modified ... ok +test proxy::providers::claude::tests::test_deepseek_official_preserves_output_config_other_fields ... ok +test proxy::providers::claude::tests::test_deepseek_official_strips_both_effort_fields ... ok +test proxy::providers::claude::tests::test_deepseek_official_strips_output_config_effort ... ok +test proxy::providers::claude::tests::test_deepseek_official_strips_reasoning_effort ... ok +test proxy::providers::claude::tests::test_deepseek_official_url_with_trailing_slash ... ok +test proxy::providers::claude::tests::test_extract_auth_anthropic_api_key ... ok +test proxy::providers::claude::tests::test_extract_auth_anthropic_auth_token_uses_claude_auth_strategy ... ok +test proxy::providers::claude::tests::test_extract_auth_apikey_field_fallback_uses_anthropic_strategy ... ok +test proxy::providers::claude::tests::test_extract_auth_both_env_vars_prefer_auth_token ... ok +test proxy::providers::claude::tests::test_extract_auth_claude_auth_env_mode ... ok +test proxy::providers::claude::tests::test_extract_auth_claude_auth_mode ... ok +test proxy::providers::claude::tests::test_extract_auth_gemini_api_key ... ok +test proxy::providers::claude::tests::test_extract_auth_gemini_cli_access_token_with_leading_newline_classifies_correctly ... ok +test proxy::providers::claude::tests::test_extract_auth_gemini_cli_empty_access_token_degrades_to_raw_key ... ok +test proxy::providers::claude::tests::test_extract_auth_gemini_cli_json_with_leading_whitespace_classifies_correctly ... ok +test proxy::providers::claude::tests::test_extract_auth_gemini_cli_refresh_only_json_does_not_expose_empty_bearer ... ok +test proxy::providers::claude::tests::test_extract_auth_gemini_cli_valid_json_keeps_access_token ... ok +test proxy::providers::claude::tests::test_extract_auth_openrouter ... ok +test proxy::providers::claude::tests::test_extract_base_url_from_env ... ok +test proxy::providers::claude::tests::test_generic_anthropic_tool_history_is_not_modified ... ok +test proxy::providers::claude::tests::test_get_auth_headers_anthropic_emits_x_api_key ... ok +test proxy::providers::claude::tests::test_get_auth_headers_bearer_emits_authorization_bearer ... ok +test proxy::providers::claude::tests::test_get_auth_headers_claude_auth_emits_authorization_bearer ... ok +test proxy::providers::claude::tests::test_get_auth_headers_rejects_illegal_header_chars ... ok +test proxy::providers::claude::tests::test_github_copilot_auth ... ok +test proxy::providers::claude::tests::test_github_copilot_detection_by_meta ... ok +test proxy::providers::claude::tests::test_github_copilot_detection_by_url ... ok +test proxy::providers::claude::tests::test_github_copilot_needs_transform ... ok +test proxy::providers::claude::tests::test_kimi_anthropic_tool_history_injects_missing_thinking ... ok +test proxy::providers::claude::tests::test_needs_transform ... ok +test proxy::providers::claude::tests::test_non_deepseek_endpoint_not_modified ... ok +test proxy::providers::claude::tests::test_normalize_messages_pipeline_strips_effort_for_deepseek ... ok +test proxy::providers::claude::tests::test_provider_type_detection ... ok +test proxy::providers::claude::tests::test_transform_claude_request_for_api_format_codex_oauth_fast_mode_off ... ok +test proxy::providers::claude::tests::test_transform_claude_request_for_api_format_gemini_native ... ok +test proxy::providers::claude::tests::test_transform_claude_request_for_api_format_openai_chat_keeps_explicit_prompt_cache_key ... ok +test proxy::providers::claude::tests::test_transform_claude_request_for_api_format_openai_chat_skips_prompt_cache_key_by_default ... ok +test proxy::providers::claude::tests::test_transform_claude_request_for_api_format_responses ... ok +test proxy::providers::claude::tests::test_transform_claude_request_for_codex_oauth_keeps_explicit_cache_key ... ok +test proxy::providers::claude::tests::test_transform_claude_request_for_codex_oauth_uses_session_cache_key ... ok +test proxy::providers::claude::tests::test_transform_claude_request_for_codex_oauth_without_session_omits_cache_key ... ok +test proxy::providers::claude::tests::test_transform_claude_request_for_responses_uses_session_cache_key ... ok +test proxy::providers::claude::tests::test_transform_claude_request_for_responses_without_session_omits_cache_key ... ok +test proxy::providers::claude::tests::test_transform_claude_request_openai_chat_non_streaming_omits_stream_options ... ok +test proxy::providers::claude::tests::test_transform_claude_request_openai_chat_streaming_injects_include_usage ... ok +test proxy::providers::claude::tests::test_transform_openai_chat_preserves_reasoning_content_for_deepseek_provider ... ok +test proxy::providers::claude::tests::test_transform_openai_chat_preserves_reasoning_content_for_kimi_provider ... ok +test proxy::providers::claude::tests::test_transform_openai_chat_preserves_reasoning_content_for_mimo_provider ... ok +test proxy::providers::claude::tests::test_transform_openai_chat_skips_reasoning_content_for_generic_provider ... ok +test proxy::providers::codex::tests::test_apply_codex_chat_upstream_model_forces_unmatched_fallback_route_model ... ok +test proxy::providers::codex::tests::test_apply_codex_chat_upstream_model_preserves_catalog_model_selection ... ok +test proxy::providers::codex::tests::test_apply_codex_chat_upstream_model_uses_catalog_upstream_model ... ok +test proxy::providers::codex::tests::test_apply_codex_chat_upstream_model_uses_provider_config_model ... ok +test proxy::providers::codex::tests::test_apply_codex_request_upstream_model_route_override_takes_priority ... ok +test proxy::providers::codex::tests::test_apply_codex_request_upstream_model_uses_catalog_for_native_responses ... ok +test proxy::providers::codex::tests::test_build_url ... ok +test proxy::providers::codex::tests::test_build_url_chatgpt_codex_backend_strips_openai_v1_prefix ... ok +test proxy::providers::codex::tests::test_build_url_custom_prefix_no_v1 ... ok +test proxy::providers::codex::tests::test_build_url_dedup_v1 ... ok +test proxy::providers::codex::tests::test_build_url_origin_adds_v1 ... ok +test proxy::providers::codex::tests::test_codex_adapter_supports_routed_codex_oauth_provider ... ok +test proxy::providers::codex::tests::test_codex_adapter_treats_empty_official_seed_as_managed_oauth ... ok +test proxy::providers::codex::tests::test_codex_legacy_route_candidates_prefer_exact_over_earlier_prefix_route ... ok +test proxy::providers::codex::tests::test_codex_model_route_accepts_legacy_array_codex_routing ... ok +test proxy::providers::codex::tests::test_codex_model_route_overrides_cache_config ... ok +test proxy::providers::codex::tests::test_codex_model_route_overrides_chat_reasoning_config ... ok +test proxy::providers::codex::tests::test_codex_model_route_resolves_deepseek_chat_provider ... ok +test proxy::providers::codex::tests::test_codex_model_route_supports_prefix_matching ... ok +test proxy::providers::codex::tests::test_codex_model_route_uses_codex_routing_first ... ok +test proxy::providers::codex::tests::test_codex_provider_keeps_openai_responses_wire_api ... ok +test proxy::providers::codex::tests::test_codex_provider_uses_chat_completions_for_legacy_deepseek_responses_wire_api ... ok +test proxy::providers::codex::tests::test_codex_provider_uses_chat_completions_from_active_wire_api ... ok +test proxy::providers::codex::tests::test_codex_provider_uses_chat_completions_from_full_chat_url ... ok +test proxy::providers::codex::tests::test_codex_provider_uses_chat_completions_from_meta_api_format_for_compact ... ok +test proxy::providers::codex::tests::test_codex_provider_uses_chat_completions_from_meta_api_format_for_responses ... ok +test proxy::providers::codex::tests::test_codex_provider_uses_messages_from_explicit_api_format ... ok +test proxy::providers::codex::tests::test_codex_responses_provider_does_not_convert_to_chat ... ok +test proxy::providers::codex::tests::test_codex_responses_provider_ignores_stale_top_level_proxy_url ... ok +test proxy::providers::codex::tests::test_codex_route_default_route_is_used_when_no_match ... ok +test proxy::providers::codex::tests::test_codex_route_managed_auth_ignores_stale_api_key ... ok +test proxy::providers::codex::tests::test_codex_route_managed_codex_oauth_keeps_auth_in_meta ... ok +test proxy::providers::codex::tests::test_codex_route_provider_config_api_key_overrides_provider_key ... ok +test proxy::providers::codex::tests::test_codex_route_provider_config_auth_preserves_provider_key ... ok +test proxy::providers::codex::tests::test_codex_route_resolver_prefers_exact_route_over_earlier_prefix_route ... ok +test proxy::providers::codex::tests::test_codex_route_skips_disabled_matches ... ok +test proxy::providers::codex::tests::test_codex_route_target_provider_infers_empty_official_seed_as_managed_oauth ... ok +test proxy::providers::codex::tests::test_codex_route_target_provider_infers_legacy_official_oauth_base_url ... ok +test proxy::providers::codex::tests::test_codex_route_target_provider_infers_official_oauth_from_router_auth ... ok +test proxy::providers::codex::tests::test_codex_route_target_provider_reuses_provider_conversion_config ... ok +test proxy::providers::codex::tests::test_codex_route_target_provider_treats_local_proxy_official_as_managed_oauth ... ok +test proxy::providers::codex::tests::test_codex_route_target_provider_treats_polluted_official_as_managed_oauth ... ok +test proxy::providers::codex::tests::test_codex_router_duplicate_exact_routes_remain_order_dependent ... ok +test proxy::providers::codex::tests::test_codex_router_prefers_exact_route_over_earlier_prefix_route ... ok +test proxy::providers::codex::tests::test_codex_router_returns_fallback_route_candidates_after_primary ... ok +test proxy::providers::codex::tests::test_extract_auth_falls_back_to_config_bearer_when_auth_key_empty ... ok +test proxy::providers::codex::tests::test_extract_auth_from_auth_field ... ok +test proxy::providers::codex::tests::test_extract_auth_from_env ... ok +test proxy::providers::codex::tests::test_extract_base_url_direct ... ok +test proxy::providers::codex::tests::test_extract_base_url_uses_active_model_provider_only ... ok +test proxy::providers::codex::tests::test_extract_base_url_uses_openai_base_url_for_builtin_openai ... ok +test proxy::providers::codex::tests::test_is_not_official_client ... ok +test proxy::providers::codex::tests::test_is_official_client_cli ... ok +test proxy::providers::codex::tests::test_is_official_client_partial_match ... ok +test proxy::providers::codex::tests::test_is_official_client_vscode ... ok +test proxy::providers::codex::tests::test_managed_codex_oauth_stays_on_native_responses ... ok +test proxy::providers::codex::tests::test_materialize_routed_provider_preserves_model_catalog ... ok +test proxy::providers::codex::tests::test_qwen_vllm_explicit_larger_budget_is_preserved ... ok +test proxy::providers::codex::tests::test_qwen_vllm_explicit_stale_reasoning_keeps_inferred_defaults ... ok +test proxy::providers::codex::tests::test_qwen_vllm_retired_auto_default_budget_is_cleared ... ok +test proxy::providers::codex::tests::test_qwen_vllm_route_infers_thinking_without_default_output_budget ... ok +test proxy::providers::codex::tests::test_resolve_codex_chat_reasoning_explicit_meta_overrides_inference ... ok +test proxy::providers::codex::tests::test_resolve_codex_chat_reasoning_infers_deepseek_effort_support ... ok +test proxy::providers::codex::tests::test_resolve_codex_chat_reasoning_infers_glm_5_2_effort_support ... ok +test proxy::providers::codex::tests::test_resolve_codex_chat_reasoning_openrouter_platform_overrides_model ... ok +test proxy::providers::codex::tests::test_resolve_codex_chat_reasoning_siliconflow_platform_overrides_minimax ... ok +test proxy::providers::codex_chat_history::tests::does_not_restore_ambiguous_call_id_without_previous_response ... ok +test proxy::providers::codex_chat_history::tests::enriches_existing_function_call_missing_name_and_arguments ... ok +test proxy::providers::codex_chat_history::tests::enriches_existing_function_call_missing_reasoning ... ok +test proxy::providers::codex_chat_history::tests::enriches_tool_output_with_cached_function_call_from_previous_response ... ok +test proxy::providers::codex_chat_history::tests::records_streamed_function_call_done_items ... ok +test proxy::providers::codex_chat_history::tests::restores_custom_and_tool_search_calls_from_previous_response ... ok +test proxy::providers::codex_chat_history::tests::restores_parallel_tool_calls_as_one_assistant_group ... ok +test proxy::providers::codex_chat_history::tests::restores_unique_call_id_without_matching_previous_response ... ok +test proxy::providers::codex_chat_history::tests::streamed_recorder_preserves_chunks_and_records_exchange_with_request ... ok +test proxy::providers::codex_oauth_auth::tests::get_status_does_not_refresh_or_remove_invalid_account ... ok +test proxy::providers::codex_oauth_auth::tests::test_cached_token_expiring_soon ... ok +test proxy::providers::codex_oauth_auth::tests::test_compute_expires_at_ms ... ok +test proxy::providers::codex_oauth_auth::tests::test_compute_expires_at_ms_default ... ok +test proxy::providers::codex_oauth_auth::tests::test_manager_initial_state ... ok +test proxy::providers::codex_oauth_auth::tests::test_manager_save_and_load ... ok +test proxy::providers::codex_oauth_auth::tests::test_parse_interval_default ... ok +test proxy::providers::codex_oauth_auth::tests::test_parse_interval_min ... ok +test proxy::providers::codex_oauth_auth::tests::test_parse_interval_number ... ok +test proxy::providers::codex_oauth_auth::tests::test_parse_interval_string ... ok +test proxy::providers::codex_oauth_auth::tests::test_parse_jwt_claims_invalid ... ok +test proxy::providers::codex_oauth_auth::tests::test_parse_jwt_claims_organizations_fallback ... ok +test proxy::providers::codex_oauth_auth::tests::test_parse_jwt_claims_valid ... ok +test proxy::providers::codex_oauth_auth::tests::test_remove_account ... ok +test proxy::http_client::tests::test_build_client_direct ... ok +test proxy::http_client::tests::test_build_client_with_socks5_proxy ... ok +test proxy::http_client::tests::test_build_client_with_http_proxy ... ok +test proxy::providers::copilot_auth::tests::test_auth_status_serialization ... ok +test proxy::providers::copilot_auth::tests::test_clear_auth_cleans_memory_even_when_file_removal_fails ... ok +test proxy::providers::copilot_auth::tests::test_clear_auth_clears_all_api_endpoint_cache ... ok +test proxy::providers::copilot_auth::tests::test_composite_account_id ... ok +test proxy::providers::copilot_auth::tests::test_copilot_token_expiry ... ok +test proxy::providers::copilot_auth::tests::test_fallback_default_account_prefers_latest_authenticated ... ok +test proxy::providers::copilot_auth::tests::test_fetch_and_cache_endpoint_requires_account ... ok +test proxy::providers::copilot_auth::tests::test_get_api_endpoint_cache_hit_skips_fetch ... ok +test proxy::providers::copilot_auth::tests::test_get_api_endpoint_returns_cached_value ... ok +test proxy::providers::copilot_auth::tests::test_get_api_endpoint_returns_default_for_unknown_account ... ok +test proxy::providers::copilot_auth::tests::test_get_api_endpoint_returns_default_when_not_cached ... ok +test proxy::providers::copilot_auth::tests::test_get_default_api_endpoint_uses_default_account ... ok +test proxy::providers::copilot_auth::tests::test_get_model_vendor_from_cache ... ok +test proxy::providers::copilot_auth::tests::test_github_account_from_data ... ok +test proxy::providers::copilot_auth::tests::test_github_account_from_data_ghes_uses_composite_id ... ok +test proxy::providers::copilot_auth::tests::test_legacy_format_detection ... ok +test proxy::providers::copilot_auth::tests::test_multi_account_store_serialization ... ok +test proxy::providers::copilot_auth::tests::test_normalize_github_domain ... ok +test proxy::providers::copilot_auth::tests::test_remove_account_clears_api_endpoint_cache ... ok +test proxy::providers::copilot_model_map::tests::already_copilot_format_returns_none ... ok +test proxy::providers::copilot_model_map::tests::apply_handles_missing_model ... ok +test proxy::providers::copilot_model_map::tests::apply_no_change_when_already_normalized ... ok +test proxy::providers::copilot_model_map::tests::apply_rewrites_body ... ok +test proxy::providers::copilot_model_map::tests::bracket_one_m_with_date_combined ... ok +test proxy::providers::copilot_model_map::tests::case_insensitive_on_prefix_and_suffix ... ok +test proxy::providers::copilot_model_map::tests::dashes_to_dot_basic ... ok +test proxy::providers::copilot_model_map::tests::date_suffix_stripped ... ok +test proxy::providers::copilot_model_map::tests::legacy_three_part_versions_untouched ... ok +test proxy::providers::copilot_model_map::tests::non_claude_models_untouched ... ok +test proxy::providers::copilot_model_map::tests::one_m_bracket_on_already_dotted ... ok +test proxy::providers::copilot_model_map::tests::one_m_bracket_to_dash ... ok +test proxy::providers::copilot_model_map::tests::resolve_exact_match_after_normalize ... ok +test proxy::providers::copilot_model_map::tests::resolve_falls_back_to_base_when_1m_unavailable ... ok +test proxy::providers::copilot_model_map::tests::resolve_falls_back_to_highest_family_version ... ok +test proxy::providers::copilot_model_map::tests::resolve_handles_non_claude_target ... ok +test proxy::providers::copilot_model_map::tests::resolve_prefers_1m_when_requested ... ok +test proxy::providers::copilot_model_map::tests::resolve_returns_none_when_already_valid ... ok +test proxy::providers::copilot_model_map::tests::resolve_returns_none_when_family_absent ... ok +test proxy::providers::gemini::tests::test_build_url_dedup ... ok +test proxy::providers::gemini::tests::test_build_url_normal ... ok +test proxy::providers::gemini::tests::test_extract_auth_api_key ... ok +test proxy::providers::gemini::tests::test_extract_auth_fallback ... ok +test proxy::providers::gemini::tests::test_extract_auth_oauth_access_token ... ok +test proxy::providers::gemini::tests::test_extract_auth_oauth_json ... ok +test proxy::providers::gemini::tests::test_extract_base_url_from_env ... ok +test proxy::providers::gemini::tests::test_parse_oauth_credentials_direct_token ... ok +test proxy::providers::gemini::tests::test_parse_oauth_credentials_invalid ... ok +test proxy::providers::gemini::tests::test_parse_oauth_credentials_json ... ok +test proxy::providers::gemini::tests::test_provider_type_detection ... ok +test proxy::providers::gemini_schema::tests::empty_input_schema_produces_explicit_object_type ... ok +test proxy::providers::gemini_schema::tests::input_schema_missing_type_is_promoted_to_object ... ok +test proxy::providers::gemini_schema::tests::non_object_schema_is_not_mutated ... ok +test proxy::providers::gemini_schema::tests::uses_parameters_json_schema_for_additional_properties ... ok +test proxy::providers::gemini_schema::tests::uses_parameters_json_schema_for_one_of ... ok +test proxy::providers::gemini_schema::tests::uses_schema_for_simple_openapi_subset ... ok +test proxy::providers::gemini_shadow::tests::clear_session_and_provider_work ... ok +test proxy::providers::gemini_shadow::tests::evicts_oldest_session_when_capacity_is_exceeded ... ok +test proxy::providers::gemini_shadow::tests::record_and_read_latest_turn ... ok +test proxy::providers::gemini_shadow::tests::retains_only_latest_turns_per_session ... ok +test proxy::providers::gemini_shadow::tests::sessions_are_isolated_by_provider_and_session_id ... ok +test proxy::providers::openai_compat::tests::chat_request_maps_to_codex_responses_contract ... ok +test proxy::providers::openai_compat::tests::chat_request_preserves_chinese_through_codex_responses_conversion ... ok +test proxy::providers::openai_compat::tests::chat_request_preserves_responses_style_text_parts ... ok +test proxy::providers::openai_compat::tests::chat_request_without_system_prompt_still_sets_codex_instructions ... ok +test proxy::providers::openai_compat::tests::codex_oauth_responses_normalizer_promotes_control_messages_and_strips_tool_content ... ok +test proxy::providers::openai_compat::tests::codex_oauth_responses_normalizer_promotes_multi_part_developer_message ... ok +test proxy::providers::openai_compat::tests::codex_oauth_responses_normalizer_promotes_raw_reasoning_content_to_summary ... ok +test proxy::providers::openai_compat::tests::codex_oauth_responses_normalizer_removes_duplicate_reasoning_content ... ok +test proxy::providers::openai_compat::tests::codex_responses_passthrough_normalizes_function_call_arguments ... ok +test proxy::providers::openai_compat::tests::codex_responses_passthrough_promotes_control_messages_to_instructions ... ok +test proxy::providers::openai_compat::tests::codex_responses_request_normalizer_accepts_minimal_body ... ok +test proxy::providers::openai_compat::tests::codex_responses_request_normalizer_preserves_desktop_shape ... ok +test proxy::providers::openai_compat::tests::codex_responses_request_normalizer_strips_content_from_tool_output_items ... ok +test proxy::providers::openai_compat::tests::responses_json_maps_to_chat_completion ... ok +test proxy::providers::openai_compat::tests::responses_json_with_null_error_maps_to_chat_completion ... ok +test proxy::providers::openai_compat::tests::responses_sse_maps_to_chat_sse ... ok +test proxy::providers::openai_compat::tests::responses_tool_call_maps_to_chat_tool_call ... ok +test proxy::providers::streaming::tests::test_duplicate_finish_reason_emits_only_one_message_delta ... ok +test proxy::providers::streaming::tests::test_map_stop_reason_legacy_and_filtered_values ... ok +test proxy::providers::streaming::tests::test_message_delta_includes_zero_usage_when_stream_has_no_usage ... ok +test proxy::providers::streaming::tests::test_stream_end_without_finish_reason_does_not_emit_success_terminal_events ... ok +test proxy::providers::streaming::tests::test_stream_error_does_not_emit_success_terminal_events ... ok +test proxy::providers::streaming::tests::test_streaming_chinese_split_across_chunks_no_replacement_chars ... ok +test proxy::providers::streaming::tests::test_streaming_delays_tool_start_until_id_and_name_ready ... ok +test proxy::providers::streaming::tests::test_streaming_finalizes_after_finish_when_done_is_missing ... ok +test proxy::providers::streaming::tests::test_streaming_tool_calls_routed_by_index ... ok +test proxy::providers::streaming::tests::test_usage_chunk_clamps_input_to_zero_when_cache_exceeds_prompt ... ok +test proxy::providers::streaming::tests::test_usage_chunk_subtracts_cache_read_and_creation_from_input ... ok +test proxy::providers::streaming::tests::test_usage_only_chunk_after_finish_reason_updates_message_delta_usage ... ok +test proxy::providers::streaming_codex_chat::tests::canonicalizes_streamed_tool_call_arguments_on_done_events ... ok +test proxy::providers::streaming_codex_chat::tests::chat_sse_data_only_error_emits_failed_without_completed ... ok +test proxy::providers::streaming_codex_chat::tests::chat_sse_error_event_emits_failed_without_completed ... ok +test proxy::providers::streaming_codex_chat::tests::converts_inline_think_chat_sse_to_reasoning_without_leaking_tags ... ok +test proxy::providers::streaming_codex_chat::tests::converts_reasoning_content_chat_sse_to_responses_reasoning_events ... ok +test proxy::providers::streaming_codex_chat::tests::converts_text_chat_sse_to_responses_sse ... ok +test proxy::providers::streaming_codex_chat::tests::converts_tool_call_chat_sse_to_responses_sse ... ok +test proxy::providers::streaming_codex_chat::tests::preserves_late_reasoning_content_on_streamed_tool_call_items ... ok +test proxy::providers::streaming_codex_chat::tests::preserves_reasoning_content_on_streamed_tool_call_items ... ok +test proxy::providers::streaming_codex_chat::tests::restores_custom_tool_input_stream_events ... ok +test proxy::providers::streaming_codex_chat::tests::restores_namespace_on_streamed_tool_call_items ... ok +test proxy::providers::streaming_codex_chat::tests::restores_tool_search_on_streamed_tool_call_items ... ok +test proxy::providers::streaming_codex_chat::tests::stream_end_with_output_without_finish_reason_emits_failed_without_completed ... ok +test proxy::providers::streaming_codex_chat::tests::stream_end_without_output_or_finish_reason_emits_failed_without_completed ... ok +test proxy::providers::streaming_codex_chat::tests::stream_error_emits_failed_without_completed ... ok +test proxy::providers::streaming_gemini::tests::converts_crlf_delimited_stream_to_anthropic_sse ... ok +test proxy::providers::streaming_gemini::tests::converts_function_call_stream_to_tool_use_events ... ok +test proxy::providers::streaming_gemini::tests::converts_text_stream_to_anthropic_sse ... ok +test proxy::providers::streaming_gemini::tests::no_id_tool_call_reuses_synthesized_id_across_cumulative_chunks ... ok +test proxy::providers::streaming_gemini::tests::parallel_empty_string_id_calls_are_treated_as_missing_and_preserved ... ok +test proxy::providers::streaming_gemini::tests::parallel_same_name_no_id_calls_preserve_both ... ok +test proxy::providers::streaming_gemini::tests::preserves_utf8_boundaries_when_json_payload_spans_chunks ... ok +test proxy::providers::streaming_gemini::tests::rectifies_streamed_skill_args_from_nested_parameters ... ok +test proxy::providers::streaming_gemini::tests::rectifies_streamed_tool_call_args_from_tool_schema_hints ... ok +test proxy::providers::streaming_gemini::tests::single_empty_string_id_tool_call_gets_synthesized_id ... ok +test proxy::providers::streaming_gemini::tests::stores_full_text_for_shadow_replay_across_delta_chunks ... ok +test proxy::providers::streaming_gemini::tests::stores_tool_shadow_before_tool_use_events_are_fully_drained ... ok +test proxy::providers::streaming_gemini::tests::thought_signature_preserved_when_later_chunk_omits_it ... ok +test proxy::providers::streaming_gemini::tests::upgraded_real_id_merges_into_existing_synthesized_snapshot ... ok +test proxy::providers::streaming_responses::tests::test_map_responses_stop_reason_tool_use ... ok +test proxy::providers::streaming_responses::tests::test_response_object_from_event_with_wrapper ... ok +test proxy::providers::streaming_responses::tests::test_streaming_conversion_interleaved_tool_deltas_by_item_id ... ok +test proxy::providers::streaming_responses::tests::test_streaming_conversion_with_wrapped_response_events ... ok +test proxy::providers::streaming_responses::tests::test_streaming_read_tool_drops_empty_pages ... ok +test proxy::providers::streaming_responses::tests::test_streaming_read_tool_duplicate_start_preserves_buffered_args ... ok +test proxy::providers::streaming_responses::tests::test_streaming_reasoning_delta_emits_thinking_blocks ... ok +test proxy::providers::streaming_responses::tests::test_streaming_responses_chinese_split_across_chunks_no_replacement_chars ... ok +test proxy::providers::streaming_responses::tests::test_streaming_text_parts_are_merged_into_one_text_block ... ok +test proxy::providers::tests::test_from_app_type_claude_auth ... ok +test proxy::providers::tests::test_from_app_type_claude_direct ... ok +test proxy::providers::tests::test_from_app_type_claude_openrouter ... ok +test proxy::providers::tests::test_from_app_type_codex ... ok +test proxy::providers::tests::test_from_app_type_gemini_api_key ... ok +test proxy::providers::tests::test_from_app_type_gemini_cli_json ... ok +test proxy::providers::tests::test_from_app_type_gemini_cli_oauth ... ok +test proxy::providers::tests::test_get_adapter_for_provider_type ... ok +test proxy::providers::tests::test_provider_type_as_str ... ok +test proxy::providers::tests::test_provider_type_default_endpoint ... ok +test proxy::providers::tests::test_provider_type_from_str ... ok +test proxy::providers::tests::test_provider_type_needs_transform ... ok +test proxy::providers::tests::test_provider_type_serde ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_does_not_emit_reasoning_content_by_default ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_does_not_inject_prompt_cache_key ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_keeps_non_leading_billing_header_text ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_non_o_series_keeps_max_tokens ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_o_series_max_completion_tokens ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_preserves_prompt_after_billing_header_in_same_part ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_simple ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_skips_thinking_only_message ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_strips_all_cache_control ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_strips_billing_header_from_system_array_parts ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_strips_cache_control_from_conflicting_system ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_strips_cache_control_from_merged_system ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_strips_cache_control_from_mixed_system ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_strips_leading_billing_header_from_system_string ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_tool_result ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_tool_use ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_tool_use_injects_placeholder_reasoning_content_when_missing ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_tool_use_preserves_reasoning_content ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_tool_use_uses_redacted_thinking_placeholder ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_with_system ... ok +test proxy::providers::transform::tests::test_anthropic_to_openai_with_tools ... ok +test proxy::providers::transform::tests::test_deepseek_reasoning_content_round_trips_for_tool_calls ... ok +test proxy::providers::transform::tests::test_is_openai_o_series ... ok +test proxy::providers::transform::tests::test_model_passthrough ... ok +test proxy::providers::transform::tests::test_no_thinking_field_no_reasoning_effort ... ok +test proxy::providers::transform::tests::test_non_reasoning_model_no_reasoning_effort ... ok +test proxy::providers::transform::tests::test_openai_to_anthropic_clamps_input_when_cache_exceeds_prompt ... ok +test proxy::providers::transform::tests::test_openai_to_anthropic_finish_reason_content_filter_maps_end_turn ... ok +test proxy::providers::transform::tests::test_openai_to_anthropic_preserves_id_for_usage_dedup ... ok +test proxy::providers::transform::tests::test_openai_to_anthropic_simple ... ok +test proxy::providers::transform::tests::test_openai_to_anthropic_with_cache_tokens ... ok +test proxy::providers::transform::tests::test_openai_to_anthropic_with_content_parts_and_refusal ... ok +test proxy::providers::transform::tests::test_openai_to_anthropic_with_direct_cache_fields ... ok +test proxy::providers::transform::tests::test_openai_to_anthropic_with_legacy_function_call ... ok +test proxy::providers::transform::tests::test_openai_to_anthropic_with_tool_calls ... ok +test proxy::providers::transform::tests::test_output_config_high_maps_to_reasoning_effort_high ... ok +test proxy::providers::transform::tests::test_output_config_low_maps_to_reasoning_effort_low ... ok +test proxy::providers::transform::tests::test_output_config_max_maps_to_reasoning_effort_xhigh ... ok +test proxy::providers::transform::tests::test_output_config_medium_maps_to_reasoning_effort_medium ... ok +test proxy::providers::transform::tests::test_output_config_takes_priority_over_thinking ... ok +test proxy::providers::transform::tests::test_output_config_unknown_value_no_reasoning_effort ... ok +test proxy::providers::transform::tests::test_reasoning_model_no_thinking_no_effort ... ok +test proxy::providers::transform::tests::test_reasoning_model_thinking_adaptive ... ok +test proxy::providers::transform::tests::test_reasoning_model_thinking_enabled_small_budget ... ok +test proxy::providers::transform::tests::test_reasoning_model_with_output_config_effort ... ok +test proxy::providers::transform::tests::test_reasoning_model_with_output_config_max ... ok +test proxy::providers::transform::tests::test_regression_gh3805_no_cache_control_leak_to_openai ... ok +test proxy::providers::transform::tests::test_supports_reasoning_effort ... ok +test proxy::providers::transform::tests::test_thinking_adaptive_maps_xhigh ... ok +test proxy::providers::transform::tests::test_thinking_disabled_no_reasoning_effort ... ok +test proxy::providers::transform::tests::test_thinking_enabled_large_budget_maps_high ... ok +test proxy::providers::transform::tests::test_thinking_enabled_medium_budget_maps_medium ... ok +test proxy::providers::transform::tests::test_thinking_enabled_small_budget_maps_low ... ok +test proxy::providers::transform::tests::test_thinking_enabled_without_budget_maps_high ... ok +test proxy::providers::transform::tests::tool_choice_forced_tool_maps_to_nested_function_selector ... ok +test proxy::providers::transform::tests::tool_choice_object_any_maps_to_required ... ok +test proxy::providers::transform::tests::tool_choice_object_auto_and_none_collapse_to_string ... ok +test proxy::providers::transform::tests::tool_choice_string_any_maps_to_required ... ok +test proxy::providers::transform::tests::tool_choice_string_auto_and_none_pass_through ... ok +test proxy::providers::transform_codex_chat::tests::chat_error_to_response_error_falls_back_to_detail_field ... ok +test proxy::providers::transform_codex_chat::tests::chat_error_to_response_error_handles_missing_body ... ok +test proxy::providers::transform_codex_chat::tests::chat_error_to_response_error_handles_plain_text_body ... ok +test proxy::providers::transform_codex_chat::tests::chat_error_to_response_error_normalizes_minimax_base_resp ... ok +test proxy::providers::transform_codex_chat::tests::chat_error_to_response_error_normalizes_standard_openai_shape ... ok +test proxy::providers::transform_codex_chat::tests::chat_response_length_maps_to_incomplete_response ... ok +test proxy::providers::transform_codex_chat::tests::chat_response_reasoning_only_length_keeps_message_slot ... ok +test proxy::providers::transform_codex_chat::tests::chat_response_to_responses_canonicalizes_json_string_tool_arguments ... ok +test proxy::providers::transform_codex_chat::tests::chat_response_to_responses_extracts_reasoning_details ... ok +test proxy::providers::transform_codex_chat::tests::chat_response_to_responses_maps_text_tool_calls_and_usage ... ok +test proxy::providers::transform_codex_chat::tests::chat_response_to_responses_restores_custom_tool_call ... ok +test proxy::providers::transform_codex_chat::tests::chat_response_to_responses_restores_loaded_namespace_tool_call ... ok +test proxy::providers::transform_codex_chat::tests::chat_response_to_responses_restores_tool_search_call ... ok +test proxy::providers::transform_codex_chat::tests::chat_response_to_responses_splits_inline_think_content ... ok +test proxy::providers::transform_codex_chat::tests::collapse_system_messages_preserves_non_system_order ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_does_not_emit_chat_file_for_url_only_input_file ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_maps_input_file_content_parts ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_maps_top_level_input_file_item ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_merges_include_usage_into_existing_stream_options ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_applies_configured_min_output_tokens ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_applies_explicit_default_output_tokens_when_missing ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_attaches_reasoning_to_tool_call_message ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_attaches_trailing_reasoning_to_previous_assistant ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_attaches_trailing_reasoning_to_tool_call_message ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_canonicalizes_json_string_tool_payloads ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_disables_chat_template_enable_thinking_provider ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_does_not_pass_cache_options_for_auto_prefix_cache ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_downgrades_deepseekv4_images_even_with_false_override ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_downgrades_images_for_deepseekv4_aliases ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_downgrades_images_for_glm_5_text_models ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_downgrades_images_for_text_only_models ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_downgrades_images_with_text_only_override ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_drops_explicit_none_for_top_level_effort_provider ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_drops_parallel_tool_calls_when_no_tools ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_drops_responses_only_metadata_and_service_tier ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_drops_tool_choice_when_all_tools_filtered ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_drops_tool_choice_when_no_tools ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_drops_tool_choice_when_tools_empty_array ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_exposes_tool_search_and_loaded_namespace_tools ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_forces_chat_template_thinking_off_when_unsupported ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_injects_placeholder_reasoning_for_bare_tool_call ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_keeps_embedded_assistant_reasoning ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_keeps_explicit_large_output_budget ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_keeps_images_for_glm_5v_vision_models ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_keeps_images_without_text_only_override ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_keeps_missing_output_budget_unbounded_by_minimum ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_keeps_multiple_tool_calls_adjacent_to_outputs ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_keeps_tool_choice_function_when_tools_present ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_keeps_tool_choice_when_tools_present ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_maps_chat_template_enable_thinking_provider ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_maps_custom_tool_and_choice ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_maps_enable_thinking_provider ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_maps_messages_tools_and_limits ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_maps_openrouter_to_native_reasoning_object ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_maps_thinking_only_provider_without_effort ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_merges_mid_stream_system_into_head ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_no_tool_choice_no_tools_stays_clean ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_normalizes_codex_internal_roles ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_passes_explicit_none_through_for_openrouter ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_passes_openai_prompt_cache_options_when_capable ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_passes_reasoning_content_back_to_assistant_message ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_preserves_custom_tool_metadata_in_description ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_raises_small_explicit_budget_to_minimum ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_recovers_reasoning_from_function_call_item ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_sanitizes_malformed_tool_arguments ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_sanitizes_partial_json_tool_arguments ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_tool_choice_none_dropped_when_no_tools ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_tool_search_output_provides_tools_keeps_tool_choice ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_uses_provider_reasoning_effort_for_deepseek_model ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_with_stream_injects_include_usage ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_with_temperature_preserves_explicit_temperature ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_without_stream_omits_stream_options ... ok +test proxy::providers::transform_codex_chat::tests::responses_request_without_temperature_does_not_default_temperature ... ok +test proxy::providers::transform_codex_chat::tests::top_level_user_content_part_clears_pending_reasoning ... ok +test proxy::providers::transform_gemini::tests::anthropic_to_gemini_maps_system_and_messages ... ok +test proxy::providers::transform_gemini::tests::anthropic_to_gemini_maps_tools_and_tool_results ... ok +test proxy::providers::transform_gemini::tests::anthropic_to_gemini_merges_system_messages_into_system_instruction ... ok +test proxy::providers::transform_gemini::tests::anthropic_to_gemini_rejects_tool_result_without_resolvable_name ... ok +test proxy::providers::transform_gemini::tests::anthropic_to_gemini_resolves_tool_result_name_from_shadow_content ... ok +test proxy::providers::transform_gemini::tests::anthropic_to_gemini_uses_parameters_json_schema_for_rich_tool_schema ... ok +test proxy::providers::transform_gemini::tests::gemini_to_anthropic_maps_blocked_prompt_to_refusal ... ok +test proxy::providers::transform_gemini::tests::gemini_to_anthropic_maps_function_calls_to_tool_use ... ok +test proxy::providers::transform_gemini::tests::gemini_to_anthropic_maps_text_and_usage ... ok +test proxy::providers::transform_gemini::tests::gemini_to_anthropic_preserves_legitimate_parameters_arg ... ok +test proxy::providers::transform_gemini::tests::gemini_to_anthropic_rectifies_tool_args_from_schema_hints ... ok +test proxy::providers::transform_gemini::tests::gemini_to_anthropic_synthesizes_unique_ids_for_missing_functioncall_ids ... ok +test proxy::providers::transform_gemini::tests::non_stream_missing_id_scenario_a_truncated_history_resolves ... ok +test proxy::providers::transform_gemini::tests::non_stream_missing_id_scenario_b_full_history_replay_resolves ... ok +test proxy::providers::transform_gemini::tests::non_stream_preserves_original_gemini_id_when_present ... ok +test proxy::providers::transform_gemini::tests::non_stream_shadow_id_matches_client_visible_id ... ok +test proxy::providers::transform_gemini::tests::non_stream_synthesized_id_not_leaked_to_gemini_via_shadow_replay ... ok +test proxy::providers::transform_gemini::tests::shadow_replay_aligns_to_latest_turns_after_client_truncation ... ok +test proxy::providers::transform_gemini::tests::shadow_replay_falls_back_to_name_when_ids_absent ... ok +test proxy::providers::transform_gemini::tests::shadow_replay_matches_tool_use_turn_by_id_when_position_drifts ... ok +test proxy::providers::transform_gemini::tests::shadow_replay_prefers_exact_id_match_over_normalized_name_collision ... ok +test proxy::providers::transform_gemini::tests::shadow_replay_strips_synthesized_id_from_function_call ... ok +test proxy::providers::transform_gemini::tests::tool_result_with_genuine_gemini_id_round_trips ... ok +test proxy::providers::transform_gemini::tests::tool_result_with_synthesized_id_omits_id_in_gemini_request ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_codex_oauth_fast_mode_can_be_disabled ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_codex_oauth_preserves_existing_include ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_codex_oauth_sets_store_and_include ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_codex_oauth_strips_max_output_tokens ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_image ... ok +test proxy::providers::codex_oauth_auth::tests::token_request_removes_account_when_refresh_token_is_invalid ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_keeps_non_leading_billing_header_text ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_non_codex_keeps_max_output_tokens ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_non_codex_omits_store_and_include ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_o_series_uses_max_output_tokens ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_preserves_prompt_after_billing_header_in_same_part ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_simple ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_strip_cache_control_on_text ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_strip_cache_control_on_tools ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_strips_billing_header_from_system_array_parts ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_strips_billing_header_with_crlf ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_strips_leading_billing_header_from_system_string ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_tool_choice_any_to_required ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_thinking_discarded ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_tool_choice_tool_to_function ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_tool_result_lifting ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_with_cache_key ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_tool_use_lifting ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_with_cache_retention ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_with_system_array ... ok +test proxy::providers::codex_oauth_auth::tests::token_request_refreshes_expired_default_account_when_token_is_valid ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_with_system_string ... ok +test proxy::providers::transform_responses::tests::test_anthropic_to_responses_with_tools ... ok +test proxy::providers::transform_responses::tests::test_build_usage_anthropic_names_precedence ... ok +test proxy::providers::transform_responses::tests::test_build_usage_cache_tokens_direct_override ... ok +test proxy::providers::transform_responses::tests::test_build_usage_cache_tokens_from_nested_details ... ok +test proxy::providers::transform_responses::tests::test_build_usage_cache_tokens_without_input_output ... ok +test proxy::providers::transform_responses::tests::test_build_usage_clamps_input_when_cache_exceeds_input ... ok +test proxy::providers::transform_responses::tests::test_build_usage_from_empty_object ... ok +test proxy::providers::transform_responses::tests::test_build_usage_from_null_json_value ... ok +test proxy::providers::transform_responses::tests::test_build_usage_from_null_parameter ... ok +test proxy::providers::transform_responses::tests::test_build_usage_from_partial_input_only ... ok +test proxy::providers::transform_responses::tests::test_build_usage_from_partial_output_only ... ok +test proxy::providers::transform_responses::tests::test_build_usage_with_openai_field_names ... ok +test proxy::providers::transform_responses::tests::test_codex_oauth_defaults_required_fields_when_absent ... ok +test proxy::providers::transform_responses::tests::test_codex_oauth_forces_stream_true_even_when_client_sends_false ... ok +test proxy::providers::transform_responses::tests::test_codex_oauth_preserves_existing_instructions_and_tools ... ok +test proxy::providers::transform_responses::tests::test_codex_oauth_strips_temperature ... ok +test proxy::providers::transform_responses::tests::test_model_passthrough ... ok +test proxy::providers::transform_responses::tests::test_codex_oauth_strips_top_p ... ok +test proxy::providers::transform_responses::tests::test_non_codex_does_not_inject_default_required_fields ... ok +test proxy::providers::transform_responses::tests::test_non_codex_keeps_temperature_and_top_p ... ok +test proxy::providers::transform_responses::tests::test_responses_non_reasoning_model_no_reasoning ... ok +test proxy::providers::transform_responses::tests::test_responses_output_config_takes_priority_over_thinking ... ok +test proxy::providers::transform_responses::tests::test_responses_output_config_max_sets_reasoning_xhigh ... ok +test proxy::providers::transform_responses::tests::test_responses_thinking_adaptive_sets_reasoning_xhigh ... ok +test proxy::providers::transform_responses::tests::test_responses_thinking_enabled_large_budget_sets_reasoning_high ... ok +test proxy::providers::transform_responses::tests::test_responses_thinking_enabled_medium_budget_sets_reasoning_medium ... ok +test proxy::providers::transform_responses::tests::test_responses_thinking_enabled_small_budget_sets_reasoning_low ... ok +test proxy::providers::transform_responses::tests::test_responses_to_anthropic_incomplete_non_token_reason ... ok +test proxy::providers::transform_responses::tests::test_responses_to_anthropic_incomplete_status ... ok +test proxy::providers::transform_responses::tests::test_responses_to_anthropic_preserves_empty_strings_for_other_tools ... ok +test proxy::providers::transform_responses::tests::test_responses_to_anthropic_read_drops_empty_pages ... ok +test proxy::providers::transform_responses::tests::test_responses_to_anthropic_simple ... ok +test proxy::providers::transform_responses::tests::test_responses_to_anthropic_with_cache_tokens ... ok +test proxy::providers::transform_responses::tests::test_responses_to_anthropic_with_direct_cache_fields ... ok +test proxy::providers::transform_responses::tests::test_responses_to_anthropic_with_function_call ... ok +test proxy::providers::transform_responses::tests::test_responses_to_anthropic_with_reasoning ... ok +test proxy::providers::transform_responses::tests::test_responses_to_anthropic_with_refusal_block ... ok +test proxy::response_handler::tests::test_response_type_detection ... ok +test proxy::response_handler::tests::test_stream_handler_creation ... ok +test proxy::response_handler::tests::test_strip_sse_field_accepts_optional_space ... ok +test proxy::response_processor::tests::test_log_usage_falls_back_to_global_defaults ... ok +test proxy::response_processor::tests::test_claude_desktop_inherits_claude_global_defaults ... ok +test proxy::response_processor::tests::test_strip_hop_by_hop_response_headers_removes_connection_listed_extensions ... ok +test proxy::response_processor::tests::test_strip_hop_by_hop_response_headers_removes_standard_headers ... ok +test proxy::response_processor::tests::test_log_usage_uses_provider_override_config ... ok +test proxy::response_processor::tests::test_strip_sse_field_accepts_optional_space ... ok +test proxy::server::tests::bind_error_for_addr_in_use_includes_actionable_port_diagnostic ... ok +test proxy::response_processor::tests::test_request_pricing_mode_anchors_to_outbound_model ... ok +test proxy::server::tests::external_only_v1_models_never_serves_codex_catalog_by_user_agent ... ok +test proxy::providers::codex_oauth_auth::tests::token_request_reloads_rotated_refresh_token_from_disk_before_refresh ... ok +test proxy::server::tests::v1_models_for_codex_client_returns_catalog_and_openai_data ... ok +test proxy::server::tests::v1_models_requires_external_api_key_for_non_codex_clients ... ok +test proxy::server::tests::v1_models_returns_profile_backend_models_with_valid_key ... ok +test proxy::server::tests::v1_chat_completions_forwards_to_profile_backend ... ok +test proxy::server::tests::v1_responses_requires_external_api_key_for_non_codex_clients ... ok +test proxy::server::tests::v1_responses_websocket_probe_returns_http_426 ... ok +test proxy::session::tests::test_client_format_as_str ... ok +test proxy::session::tests::test_client_format_from_body_claude ... ok +test proxy::session::tests::test_client_format_from_body_codex ... ok +test proxy::session::tests::test_client_format_from_body_gemini ... ok +test proxy::session::tests::test_client_format_from_path_claude ... ok +test proxy::session::tests::test_client_format_from_path_codex ... ok +test proxy::session::tests::test_client_format_from_path_gemini ... ok +test proxy::session::tests::test_client_format_from_path_gemini_cli ... ok +test proxy::session::tests::test_client_format_from_path_openai ... ok +test proxy::session::tests::test_codex_official_session_id_header_is_preserved ... ok +test proxy::session::tests::test_codex_previous_response_id_is_not_stable_session_identity ... ok +test proxy::session::tests::test_codex_window_id_header_extracts_thread_identity ... ok +test proxy::session::tests::test_extract_session_from_claude_header ... ok +test proxy::session::tests::test_extract_session_from_claude_header_precedes_metadata ... ok +test proxy::session::tests::test_extract_session_from_claude_metadata_session_id ... ok +test proxy::session::tests::test_extract_session_from_claude_metadata_user_id ... ok +test proxy::session::tests::test_extract_session_generates_new_when_not_found ... ok +test proxy::session::tests::test_parse_session_from_user_id ... ok +test proxy::session::tests::test_session_from_request ... ok +test proxy::session::tests::test_session_id_uniqueness ... ok +test proxy::session::tests::test_session_with_provider ... ok +test proxy::sse::tests::ascii_passthrough ... ok +test proxy::sse::tests::complete_multibyte_in_single_chunk ... ok +test proxy::sse::tests::defensive_guard_flushes_oversized_remainder ... ok +test proxy::sse::tests::empty_chunks_are_harmless ... ok +test proxy::sse::tests::invalid_byte_in_slow_path_flushed_immediately ... ok +test proxy::sse::tests::invalid_bytes_flushed_immediately_not_accumulated ... ok +test proxy::sse::tests::mixed_ascii_and_split_multibyte ... ok +test proxy::sse::tests::multiple_split_characters_in_sequence ... ok +test proxy::sse::tests::split_four_byte_char_across_chunks ... ok +test proxy::sse::tests::split_multibyte_across_two_chunks ... ok +test proxy::sse::tests::sse_json_with_chinese_split_at_boundary ... ok +test proxy::sse::tests::strip_sse_field_accepts_optional_space ... ok +test proxy::sse::tests::take_sse_block_supports_crlf_delimiters ... ok +test proxy::sse::tests::take_sse_block_supports_lf_delimiters ... ok +test proxy::thinking_budget_rectifier::tests::test_detect_budget_tokens_1024_error ... ok +test proxy::thinking_budget_rectifier::tests::test_detect_budget_tokens_max_tokens_error ... ok +test proxy::thinking_budget_rectifier::tests::test_detect_budget_tokens_thinking_error ... ok +test proxy::thinking_budget_rectifier::tests::test_detect_budget_tokens_with_thinking_and_1024_error ... ok +test proxy::thinking_budget_rectifier::tests::test_disabled_budget_config ... ok +test proxy::thinking_budget_rectifier::tests::test_master_disabled ... ok +test proxy::thinking_budget_rectifier::tests::test_no_trigger_for_unrelated_error ... ok +test proxy::thinking_budget_rectifier::tests::test_rectify_budget_basic ... ok +test proxy::thinking_budget_rectifier::tests::test_rectify_budget_creates_thinking_object_when_missing ... ok +test proxy::thinking_budget_rectifier::tests::test_rectify_budget_no_change_when_already_valid ... ok +test proxy::thinking_budget_rectifier::tests::test_rectify_budget_no_max_tokens ... ok +test proxy::thinking_budget_rectifier::tests::test_rectify_budget_normalizes_non_enabled_type ... ok +test proxy::thinking_budget_rectifier::tests::test_rectify_budget_preserves_large_max_tokens ... ok +test proxy::server::tests::v1_chat_completions_stream_forwards_sse_chunks ... ok +test proxy::server::tests::v1_chat_completions_preserves_chinese_for_profile_backend ... ok +test proxy::thinking_budget_rectifier::tests::test_rectify_budget_skips_adaptive ... ok +test proxy::thinking_optimizer::tests::test_adaptive_dedup_beta ... ok +test proxy::thinking_optimizer::tests::test_adaptive_opus_4_6 ... ok +test proxy::thinking_optimizer::tests::test_adaptive_opus_4_8 ... ok +test proxy::thinking_optimizer::tests::test_adaptive_sonnet_4_6 ... ok +test proxy::thinking_optimizer::tests::test_append_beta_null_field ... ok +test proxy::thinking_optimizer::tests::test_legacy_budget_too_small_upgraded ... ok +test proxy::thinking_optimizer::tests::test_legacy_disabled_thinking_injected ... ok +test proxy::thinking_optimizer::tests::test_legacy_default_max_tokens ... ok +test proxy::thinking_optimizer::tests::test_legacy_sonnet_4_5_thinking_null ... ok +test proxy::thinking_optimizer::tests::test_skip_haiku ... ok +test proxy::thinking_optimizer::tests::test_thinking_optimizer_disabled ... ok +test proxy::thinking_rectifier::tests::test_detect_invalid_request ... ok +test proxy::thinking_rectifier::tests::test_detect_invalid_signature ... ok +test proxy::thinking_rectifier::tests::test_detect_invalid_signature_nested_json ... ok +test proxy::thinking_rectifier::tests::test_detect_invalid_signature_no_backticks ... ok +test proxy::thinking_rectifier::tests::test_detect_invalid_thought_signature_nested_json ... ok +test proxy::thinking_rectifier::tests::test_detect_invalid_thought_signature_message ... ok +test proxy::thinking_rectifier::tests::test_detect_must_start_with_thinking ... ok +test proxy::thinking_rectifier::tests::test_detect_signature_extra_inputs ... ok +test proxy::thinking_rectifier::tests::test_detect_signature_field_required ... ok +test proxy::thinking_rectifier::tests::test_detect_thinking_cannot_be_modified ... ok +test proxy::thinking_rectifier::tests::test_detect_thinking_expected ... ok +test proxy::thinking_rectifier::tests::test_disabled_config ... ok +test proxy::thinking_rectifier::tests::test_do_not_detect_thinking_type_tag_mismatch ... ok +test proxy::thinking_rectifier::tests::test_master_disabled ... ok +test proxy::thinking_rectifier::tests::test_no_trigger_for_unrelated_error ... ok +test proxy::thinking_rectifier::tests::test_normalize_thinking_type_adaptive_unchanged ... ok +test proxy::thinking_rectifier::tests::test_no_detect_thinking_expected_without_tool_use ... ok +test proxy::thinking_rectifier::tests::test_normalize_thinking_type_disabled_unchanged ... ok +test proxy::thinking_rectifier::tests::test_normalize_thinking_type_enabled_unchanged ... ok +test proxy::thinking_rectifier::tests::test_normalize_thinking_type_no_thinking ... ok +test proxy::thinking_rectifier::tests::test_normalize_thinking_type_preserves_budget ... ok +test proxy::thinking_rectifier::tests::test_normalize_thinking_type_unknown_unchanged ... ok +test proxy::thinking_rectifier::tests::test_rectify_adaptive_preserves_existing_budget_tokens ... ok +test proxy::thinking_rectifier::tests::test_rectify_does_not_change_enabled_type ... ok +test proxy::thinking_rectifier::tests::test_rectify_adaptive_still_cleans_legacy_signature_blocks ... ok +test proxy::thinking_rectifier::tests::test_rectify_keeps_adaptive_when_no_legacy_blocks ... ok +test proxy::thinking_rectifier::tests::test_rectify_no_change_when_no_issues ... ok +test proxy::thinking_rectifier::tests::test_rectify_no_messages ... ok +test proxy::thinking_rectifier::tests::test_rectify_preserves_thinking_when_prefix_exists ... ok +test proxy::thinking_rectifier::tests::test_rectify_removes_thinking_blocks ... ok +test proxy::thinking_rectifier::tests::test_rectify_removes_top_level_thinking ... ok +test proxy::timeout_policy::tests::configured_timeouts_override_transport_header_safety_cap ... ok +test proxy::thinking_rectifier::tests::test_rectify_removes_top_level_thinking_adaptive ... ok +test proxy::timeout_policy::tests::zero_disables_failover_timeout_but_not_transport_safety ... ok +test proxy::types::tests::test_log_config_default ... ok +test proxy::types::tests::test_log_config_serde_default ... ok +test proxy::types::tests::test_log_config_serde_roundtrip ... ok +test proxy::types::tests::test_log_config_to_level_filter ... ok +test proxy::types::tests::test_rectifier_config_default_enabled ... ok +test proxy::types::tests::test_rectifier_config_serde_default ... ok +test proxy::types::tests::test_rectifier_config_serde_explicit_true ... ok +test proxy::types::tests::test_rectifier_config_serde_media_explicit_false ... ok +test proxy::types::tests::test_rectifier_config_serde_partial_fields ... ok +test proxy::usage::calculator::tests::test_cost_calculation ... ok +test proxy::usage::calculator::tests::test_cost_calculation_for_cache_inclusive_app ... ok +test proxy::usage::calculator::tests::test_cost_multiplier ... ok +test proxy::usage::calculator::tests::test_unknown_model_handling ... ok +test proxy::usage::calculator::tests::test_decimal_precision ... ok +test proxy::usage::parser::tests::test_claude_response_parsing ... ok +test proxy::usage::parser::tests::test_claude_response_parsing_no_model ... ok +test proxy::usage::parser::tests::test_claude_stream_cache_only_request_is_recorded ... ok +test proxy::usage::parser::tests::test_claude_stream_keeps_start_when_delta_input_is_larger ... ok +test proxy::usage::parser::tests::test_claude_stream_parsing ... ok +test proxy::usage::parser::tests::test_claude_stream_parsing_no_model ... ok +test proxy::usage::parser::tests::test_claude_stream_prefers_smaller_delta_input_and_cache_pair ... ok +test proxy::usage::parser::tests::test_claude_stream_updates_cache_pair_from_later_delta_input ... ok +test proxy::usage::parser::tests::test_codex_response_adjusted ... ok +test proxy::usage::parser::tests::test_codex_response_adjusted_cache_read_input_tokens ... ok +test proxy::usage::parser::tests::test_codex_response_adjusted_no_cache ... ok +test proxy::usage::parser::tests::test_codex_response_adjusted_saturating_sub ... ok +test proxy::usage::parser::tests::test_codex_response_auto_codex_format ... ok +test proxy::usage::parser::tests::test_codex_response_auto_openai_format ... ok +test proxy::usage::parser::tests::test_codex_response_auto_returns_some_for_synthetic_all_zero ... ok +test proxy::usage::parser::tests::test_codex_response_parsing_cached_tokens_in_details ... ok +test proxy::usage::parser::tests::test_codex_stream_events_auto_codex_format ... ok +test proxy::usage::parser::tests::test_codex_stream_events_auto_openai_format ... ok +test proxy::usage::parser::tests::test_gemini_response_parsing ... ok +test proxy::usage::parser::tests::test_gemini_response_parsing_no_model ... ok +test proxy::usage::parser::tests::test_gemini_response_with_thoughts ... ok +test proxy::usage::parser::tests::test_has_billable_tokens_gates_empty_usage ... ok +test proxy::usage::parser::tests::test_native_claude_stream_parsing ... ok +test proxy::usage::parser::tests::test_openai_response_parses_deepseek_context_cache_fields ... ok +test proxy::usage::parser::tests::test_openai_response_parses_qwen_cache_creation_details ... ok +test proxy::usage::parser::tests::test_openrouter_response_parsing ... ok +test proxy::usage::parser::tests::test_openrouter_stream_parsing ... ok +test sensitive_deeplink_boundary_tests::deep_link_log_redaction_keeps_keys_but_never_secret_values ... ok +test sensitive_deeplink_boundary_tests::malformed_deep_link_redaction_drops_query_values ... ok +test services::auto_sync_common::tests::config_tables_share_one_trigger_policy ... ok +test services::auto_sync_common::tests::full_queue_is_coalescing_but_closed_queue_is_worker_failure ... ok +test services::auto_sync_common::tests::max_wait_caps_flush_latency_for_continuous_events ... ok +test services::codex_oauth_models::tests::parse_codex_oauth_models_accepts_model_list_shape ... ok +test services::codex_oauth_models::tests::parse_codex_oauth_models_accepts_model_map_shape ... ok +test services::codex_oauth_models::tests::parse_codex_oauth_models_accepts_openai_style_data ... ok +test services::codex_oauth_models::tests::parse_codex_oauth_models_deduplicates_ids ... ok +test services::codex_oauth_models::tests::parse_codex_oauth_models_extracts_context_window ... ok +test services::coding_plan::tests::minimax_general_two_tiers_from_remaining_percent ... ok +test services::coding_plan::tests::minimax_missing_general_returns_empty ... ok +test services::coding_plan::tests::minimax_missing_percent_fields_skips_tier ... ok +test services::coding_plan::tests::minimax_negative_percent_passes_through ... ok +test services::coding_plan::tests::minimax_skips_video_and_finds_general_in_any_position ... ok +test services::coding_plan::tests::minimax_weekly_status_2_also_skips_weekly_tier ... ok +test services::coding_plan::tests::minimax_weekly_status_3_skips_weekly_tier ... ok +test services::coding_plan::tests::volcengine_afp_partial_windows_only_subscribed_ones ... ok +test services::coding_plan::tests::volcengine_afp_three_windows_from_official_example ... ok +test services::coding_plan::tests::volcengine_afp_zero_quota_windows_treated_as_unbound ... ok +test services::coding_plan::tests::volcengine_auth_error_code_detection_and_extraction ... ok +test services::coding_plan::tests::volcengine_canonical_query_is_sorted_and_encoded ... ok +test services::coding_plan::tests::volcengine_coding_plan_real_response_levels ... ok +test services::coding_plan::tests::volcengine_coding_plan_unknown_window_skipped_and_missing_array_empty ... ok +test services::coding_plan::tests::volcengine_region_derivation ... ok +test services::coding_plan::tests::volcengine_sign_structure_and_determinism ... ok +test services::coding_plan::tests::zhipu_duplicate_unit_classification_fills_other_slot ... ok +test services::coding_plan::tests::zhipu_extreme_percentage_values_pass_through ... ok +test services::coding_plan::tests::zhipu_invalid_percentage_falls_back_to_zero ... ok +test services::coding_plan::tests::zhipu_missing_reset_time_is_five_hour_when_weekly_has_reset ... ok +test services::coding_plan::tests::zhipu_more_than_two_token_limits_keeps_first_two ... ok +test services::coding_plan::tests::zhipu_new_plan_two_tiers_sorted_by_reset_time ... ok +test services::coding_plan::tests::zhipu_no_token_limits_returns_empty ... ok +test services::coding_plan::tests::zhipu_old_plan_single_tier_falls_back_to_five_hour ... ok +test services::coding_plan::tests::zhipu_partial_unit_fields_fill_remaining_slot ... ok +test services::coding_plan::tests::zhipu_quota_base_defaults_to_en_for_unknown_url ... ok +test services::coding_plan::tests::zhipu_quota_base_routes_bigmodel_url_to_cn_endpoint ... ok +test services::coding_plan::tests::zhipu_quota_base_routes_uppercase_cn_url_to_cn_endpoint ... ok +test services::coding_plan::tests::zhipu_quota_base_routes_z_ai_url_to_en_endpoint ... ok +test services::coding_plan::tests::zhipu_type_is_case_insensitive ... ok +test services::coding_plan::tests::zhipu_unit_field_overrides_reset_order_when_weekly_resets_sooner ... ok +test services::coding_plan::tests::zhipu_unknown_unit_values_fall_back_to_reset_order ... ok +test services::coding_plan::tests::zhipu_weekly_unit_six_number_one_variant ... ok +test services::env_checker::tests::test_get_keywords ... ok +test proxy::usage::logger::tests::test_log_error ... ok +test services::env_manager::tests::test_backup_dir_creation ... ok +test services::model_fetch::tests::test_apply_missing_context_windows_preserves_explicit_model_metadata ... ok +test services::model_fetch::tests::test_candidates_bailian_strip_apps_anthropic ... ok +test services::model_fetch::tests::test_candidates_deduplicate ... ok +test services::model_fetch::tests::test_candidates_deepseek_strip_anthropic ... ok +test services::model_fetch::tests::test_candidates_doubao_strip_api_coding ... ok +test services::model_fetch::tests::test_candidates_empty ... ok +test services::model_fetch::tests::test_candidates_full_url ... ok +test services::model_fetch::tests::test_candidates_longer_suffix_wins ... ok +test proxy::usage::logger::tests::test_log_request ... ok +test services::model_fetch::tests::test_candidates_no_suffix_no_strip ... ok +test services::model_fetch::tests::test_candidates_override_returns_single ... ok +test services::model_fetch::tests::test_candidates_override_empty_falls_through ... ok +test services::model_fetch::tests::test_candidates_plain_root ... ok +test services::model_fetch::tests::test_candidates_rightcode_strip_claude ... ok +test services::model_fetch::tests::test_candidates_stepfun_strip_step_plan ... ok +test services::model_fetch::tests::test_candidates_trailing_slash ... ok +test services::model_fetch::tests::test_candidates_with_v1 ... ok +test services::model_fetch::tests::test_candidates_zai_coding_paas_v4 ... ok +test services::model_fetch::tests::test_candidates_zhipu_coding_paas_v4 ... ok +test services::model_fetch::tests::test_ends_with_version_segment ... ok +test services::model_fetch::tests::test_candidates_zhipu_strip_api_anthropic ... ok +test services::model_fetch::tests::test_find_models_dev_provider_models_uses_api_prefix ... ok +test services::model_fetch::tests::test_models_dev_endpoint_matches_provider_api ... ok +test services::model_fetch::tests::test_lookup_models_dev_context_rejects_ambiguous_suffix_ids ... ok +test services::model_fetch::tests::test_lookup_models_dev_context_matches_exact_and_suffix_ids ... ok +test services::model_fetch::tests::test_normalize_volcengine_model_list_action_accepts_only_plan_actions ... ok +test services::model_fetch::tests::test_parse_response ... ok +test services::model_fetch::tests::test_parse_response_empty_data ... ok +test services::model_fetch::tests::test_parse_response_no_owned_by ... ok +test services::model_fetch::tests::test_parse_response_extracts_context_window ... ok +test services::model_fetch::tests::test_parse_volcengine_plan_models_accepts_string_entries_and_deduplicates ... ok +test services::model_fetch::tests::test_parse_zhipu_detail_context ... ok +test services::model_fetch::tests::test_parse_volcengine_plan_models_from_official_agentplan_shape ... ok +test services::model_fetch::tests::test_parse_zhipu_detail_context_ignores_other_cards ... ok +test services::model_fetch::tests::test_parse_zhipu_model_overview_contexts ... ok +test services::model_fetch::tests::test_parse_zhipu_model_overview_contexts_skips_unparseable_rows ... ok +test services::model_fetch::tests::test_zhipu_endpoint_and_glm_model_detection_boundaries ... ok +test services::model_fetch::tests::test_zhipu_model_id_normalization_and_slug ... ok +test services::omo::tests::test_build_config_empty ... ok +test services::omo::tests::test_build_config_ignores_non_object_other_fields ... ok +test services::omo::tests::test_build_config_slim_excludes_categories ... ok +test services::omo::tests::test_build_config_with_profile ... ok +test services::omo::tests::test_build_local_file_data_keeps_all_non_agent_category_fields_in_other ... ok +test services::omo::tests::test_strip_jsonc_comments ... ok +test services::omo::tests::test_find_existing_config_falls_back_to_old_name ... ok +test services::provider::live::tests::claude_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields ... ok +test services::omo::tests::test_find_existing_config_prefers_new_name_over_old ... ok +test services::provider::live::tests::claude_common_config_array_subset_detection_and_strip_preserve_extra_items ... ok +test services::provider::live::tests::codex_common_config_array_subset_detection_and_strip_preserve_extra_items ... ok +test services::provider::live::tests::codex_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields ... ok +test services::provider::live::tests::codex_common_config_does_not_import_provider_owned_router_fields ... ok +test services::provider::live::tests::codex_common_config_provider_only_snippet_is_not_detected_or_removed ... ok +test services::provider::live::tests::codex_live_projection_keeps_legacy_catalog_without_new_flag ... ok +test services::provider::live::tests::codex_live_projection_removes_catalog_when_menu_mapping_is_disabled ... ok +test services::provider::live::tests::codex_live_snapshot_restore_empty_auth_deletes_auth_without_live_oauth_login ... ok +test services::provider::live::tests::codex_switch_backfill_keeps_live_catalog_when_db_has_none ... ok +test services::provider::live::tests::codex_switch_backfill_preserves_stored_codex_routing_when_live_lacks_it ... ok +test services::provider::live::tests::codex_switch_backfill_preserves_stored_model_catalog_when_live_lacks_it ... ok +test services::provider::live::tests::explicit_common_config_flag_overrides_legacy_subset_detection ... ok +test services::provider::live::tests::codex_live_snapshot_restore_empty_auth_preserves_live_oauth_login ... ok +test services::provider::live::tests::codex_live_snapshot_restore_stale_oauth_preserves_live_oauth_login ... ok +test services::provider::tests::add_clears_usage_credentials_that_match_provider_config ... ok +test proxy::server::tests::v1_responses_converts_to_chat_only_backend ... ok +test services::provider::tests::add_does_not_clear_token_plan_credentials ... ok +test services::provider::tests::extract_codex_common_config_preserves_mcp_servers_base_url ... ok +test services::provider::tests::extract_credentials_returns_expected_values ... ok +test services::provider::tests::add_preserves_distinct_usage_credentials ... ok +test services::provider::tests::copied_provider_uses_edited_credentials_after_add_clears_mirrored_usage_credentials ... ok +test services::provider::tests::db_only_additive_update_survives_live_config_parse_errors ... ok +test services::provider::tests::import_openclaw_providers_from_live_marks_provider_as_live_managed ... ok +test services::provider::tests::import_opencode_providers_from_live_marks_provider_as_live_managed ... ok +test services::provider::tests::legacy_additive_provider_still_errors_on_live_config_parse_failure ... ok +test services::provider::tests::rename_rejects_missing_original_provider ... ok +test services::provider::tests::switching_codex_chat_provider_auto_enables_local_proxy_takeover ... ok +test services::provider::tests::switching_codex_chat_provider_from_sync_command_has_tokio_reactor ... ok +test services::provider::tests::switching_codex_router_provider_auto_enables_dedicated_local_takeover ... ok +test services::provider::tests::sync_current_provider_for_app_preserves_legacy_live_opencode_provider ... ok +test services::provider::tests::sync_current_provider_for_app_restores_legacy_openclaw_provider_after_live_reset ... ok +test services::provider::tests::sync_current_provider_for_app_restores_legacy_opencode_provider_after_live_reset ... ok +test services::provider::tests::sync_current_provider_for_app_skips_db_only_openclaw_provider ... ok +test services::provider::tests::sync_current_provider_for_app_skips_db_only_opencode_provider ... ok +test services::provider::tests::update_clears_usage_credentials_that_match_current_config ... ok +test services::provider::tests::update_current_claude_provider_syncs_live_when_proxy_takeover_detected_without_backup ... ok +test services::provider::tests::update_current_omo_variant_does_not_persist_database_when_file_write_fails ... ok +test services::provider::tests::update_current_omo_variant_rewrites_config_from_saved_provider ... ok +test services::provider::tests::validate_provider_settings_rejects_missing_auth ... ok +test services::provider::tests::validate_provider_settings_rejects_negative_cost_multiplier ... ok +test services::provider::usage::tests::codex_fallback_reads_auth_and_config_toml ... ok +test services::provider::usage::tests::empty_script_values_fall_back_to_provider_credentials ... ok +test services::provider::usage::tests::script_values_override_provider_credentials ... ok +test services::proxy::tests::apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider ... ok +test services::proxy::tests::apply_codex_proxy_toml_config_preserves_model_for_responses_provider ... ok +test services::proxy::tests::apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider ... ok +test services::proxy::tests::apply_codex_proxy_toml_config_uses_custom_local_proxy_provider ... ok +test services::provider::tests::update_current_omo_variant_rolls_back_file_when_plugin_sync_fails ... ok +test services::provider::tests::update_persists_non_current_omo_variants_in_database ... ok +test services::proxy::tests::codex_base_url_matching_checks_openai_base_url_for_builtin_openai ... ok +test services::provider::tests::update_preserves_usage_credentials_that_only_match_previous_config ... ok +test services::proxy::tests::backup_skips_when_live_is_already_proxy_placeholder ... ok +test services::proxy::tests::bulk_backup_skips_all_when_live_is_proxy_placeholder ... ok +test services::proxy::tests::codex_custom_provider_live_write_preserves_oauth_auth_even_when_preserve_disabled ... ok +test services::proxy::tests::codex_custom_provider_live_write_preserves_oauth_auth_json ... ok +test services::proxy::tests::codex_restore_empty_auth_backup_preserves_current_live_oauth_login ... ok +test services::proxy::tests::codex_restore_empty_auth_backup_still_projects_inline_catalog ... ok +test services::proxy::tests::codex_restore_from_backup_preserves_live_desktop_settings ... ok +test services::proxy::tests::codex_restore_from_backup_preserves_model_catalog_pointer ... ok +test services::proxy::tests::codex_restore_from_backup_projects_inline_model_catalog ... ok +test services::proxy::tests::codex_restore_stale_oauth_backup_preserves_current_live_oauth_login ... ok +test services::proxy::tests::codex_set_takeover_for_app_preserves_oauth_auth_json_when_preserve_enabled ... ok +test services::proxy::tests::codex_set_takeover_rebuilds_stale_enabled_state_without_overwriting_backup ... ok +test services::proxy::tests::codex_switch_to_official_during_takeover_exits_proxy_and_cleans_router_fields ... ok +test services::proxy::tests::codex_sync_current_to_live_during_takeover_activation_keeps_proxy_live_config ... ok +test services::proxy::tests::codex_sync_current_to_live_during_takeover_preserves_oauth_auth_json ... ok +test services::proxy::tests::codex_takeover_backup_preserves_oauth_auth_even_when_setting_disabled ... ok +test services::proxy::tests::codex_takeover_cleanup_removes_config_placeholder_without_touching_oauth_auth ... ok +test services::proxy::tests::codex_takeover_preserves_oauth_auth_json_even_when_provider_category_is_official ... ok +test services::proxy::tests::codex_takeover_preserves_oauth_auth_json_even_when_setting_disabled ... ok +test services::proxy::tests::codex_takeover_preserves_oauth_auth_json_when_preserve_enabled ... ok +test services::proxy::tests::hot_switch_codex_chat_provider_updates_live_provider_display ... ok +test services::proxy::tests::managed_account_claude_takeover_codex_by_base_url_keeps_auth_token ... ok +test services::proxy::tests::managed_account_claude_takeover_codex_injects_auth_token_without_preexisting_key ... ok +test services::proxy::tests::managed_account_claude_takeover_copilot_removes_stale_auth_token ... ok +test services::proxy::tests::managed_account_claude_takeover_sources_codex_models_from_provider ... ok +test services::proxy::tests::managed_account_claude_takeover_sources_copilot_models_from_provider ... ok +test services::proxy::tests::managed_account_claude_takeover_uses_api_key_placeholder ... ok +test services::proxy::tests::merge_codex_user_config_preserves_context_window_until_provider_overrides ... ok +test services::proxy::tests::normal_claude_takeover_without_token_keeps_auth_token_fallback ... ok +test services::proxy::tests::hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore ... ok +test services::proxy::tests::hot_switch_provider_serializes_same_app_switches ... ok +test services::proxy::tests::remove_local_toml_base_url_cleans_openai_base_url ... ok +test services::proxy::tests::hot_switch_provider_updates_claude_live_while_preserving_takeover_fields ... ok +test services::proxy::tests::provider_switch_with_restored_codex_backup_propagates_catalog_write_errors ... ok +test services::proxy::tests::provider_switch_with_restored_codex_backup_refreshes_catalog_and_common_config ... ok +test services::proxy::tests::restore_falls_through_to_ssot_when_backup_is_proxy_placeholder ... ok +test services::proxy::tests::restore_waits_for_hot_switch_and_restores_latest_backup ... ok +test services::proxy::tests::start_with_takeover_ephemeral_port_writes_actual_live_url ... ok +test services::proxy::tests::switch_proxy_target_updates_live_backup_when_taken_over ... ok +test services::proxy::tests::switching_codex_deepseek_from_sync_command_keeps_catalog_and_proxy_mapping ... ok +test services::proxy::tests::sync_claude_token_does_not_add_anthropic_api_key ... ok +test services::proxy::tests::sync_claude_token_respects_existing_api_key_field ... ok +test services::proxy::tests::update_live_backup_from_provider_applies_claude_common_config ... ok +test services::proxy::tests::update_toml_base_url_falls_back_to_top_level_base_url ... ok +test services::proxy::tests::update_toml_base_url_updates_active_model_provider_base_url ... ok +test services::proxy::tests::update_toml_base_url_uses_openai_base_url_for_builtin_openai ... ok +test services::s3::integration_tests::live_s3_connection ... ignored +test services::s3::integration_tests::live_s3_put_get_head_roundtrip ... ignored +test services::s3::tests::build_bucket_url_aws ... ok +test services::s3::tests::build_bucket_url_custom_endpoint ... ok +test services::s3::tests::build_bucket_url_preserves_http_scheme ... ok +test services::s3::tests::build_object_url_bare_endpoint_defaults_to_https ... ok +test services::s3::tests::build_object_url_endpoint_with_scheme_prefix ... ok +test services::s3::tests::build_object_url_endpoint_with_trailing_slash ... ok +test services::s3::tests::build_object_url_path_style_custom_endpoint ... ok +test services::s3::tests::build_object_url_preserves_http_scheme ... ok +test services::s3::tests::build_object_url_preserves_https_scheme ... ok +test services::s3::tests::build_object_url_strips_leading_slash_from_key ... ok +test services::s3::tests::build_object_url_virtual_hosted_explicit_aws_endpoint ... ok +test services::s3::tests::build_object_url_virtual_hosted_style_aws ... ok +test services::s3::tests::ensure_content_length_within_limit_accepts_within_bounds ... ok +test services::s3::tests::ensure_content_length_within_limit_rejects_oversized ... ok +test services::s3::tests::hmac_sha256_rfc2104_test_vector ... ok +test services::s3::tests::is_aws_endpoint_detection ... ok +test services::s3::tests::redact_url_preserves_path ... ok +test services::s3::tests::redact_url_strips_query_params ... ok +test services::s3::tests::sha256_hex_empty_body ... ok +test services::s3::tests::sha256_hex_known_value ... ok +test services::s3::tests::sig_v4_includes_content_type_when_present ... ok +test services::s3::tests::sig_v4_signing_against_aws_test_vector ... ok +test services::s3::tests::sig_v4_signing_key_derivation ... ok +test services::s3::tests::uri_encode_encodes_spaces_and_special_chars ... ok +test services::s3::tests::uri_encode_preserves_unreserved_chars ... ok +test services::s3::tests::uri_encode_slash_handling ... ok +test services::s3_auto_sync::tests::service_layer_does_not_depend_on_commands_layer ... ok +test services::s3_auto_sync::tests::should_run_auto_sync_requires_enabled_and_auto_sync_flag ... ok +test services::s3_auto_sync::tests::suppression_guard_enables_and_restores_state ... ok +test services::s3_sync::tests::creds_for_maps_all_fields ... ok +test services::s3_sync::tests::s3_key_matches_expected_pattern ... ok +test services::s3_sync::tests::s3_key_uses_v2_and_correct_format ... ok +test services::s3_sync::tests::s3_key_with_custom_profile ... ok +test services::s3_sync::tests::sync_mutex_is_singleton ... ok +test services::proxy::tests::update_live_backup_from_provider_applies_codex_common_config ... ok +test services::session_usage::tests::test_collect_jsonl_files_includes_subagents ... ok +test services::session_usage::tests::test_dedup_by_message_id ... ok +test services::session_usage::tests::test_collect_jsonl_files_includes_workflow_subagents ... ok +test services::session_usage::tests::test_parse_usage_from_jsonl_line ... ok +test services::session_usage::tests::test_insert_claude_session_skips_matching_proxy_log ... ok +test services::session_usage_codex::tests::test_cached_clamped_to_input ... ok +test services::session_usage_codex::tests::test_codex_rollout_thread_id_from_path_extracts_suffix ... ok +test services::session_usage_codex::tests::test_collect_codex_session_files_nonexistent ... ok +test services::session_usage_codex::tests::test_delta_first_event ... ok +test services::session_usage_codex::tests::test_delta_saturating_sub ... ok +test services::session_usage_codex::tests::test_delta_subsequent_event ... ok +test services::session_usage_codex::tests::test_delta_zero_at_task_boundary ... ok +test services::proxy::tests::update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict ... ok +test services::session_usage_codex::tests::test_normalize_codex_model_combined ... ok +test services::session_usage_codex::tests::test_normalize_codex_model_lowercase ... ok +test services::session_usage_codex::tests::test_normalize_codex_model_no_change ... ok +test services::session_usage_codex::tests::test_normalize_codex_model_strip_compact_date ... ok +test services::session_usage_codex::tests::test_normalize_codex_model_strip_iso_date ... ok +test services::session_usage_codex::tests::test_normalize_codex_model_strip_prefix ... ok +test services::session_usage_codex::tests::test_parse_cumulative_tokens_alt_field_names ... ok +test services::session_usage::tests::test_sync_imports_billable_message_without_stop_reason ... ok +test services::session_usage_codex::tests::test_parse_cumulative_tokens_null ... ok +test services::session_usage_codex::tests::test_parse_cumulative_tokens_valid ... ok +test services::session_usage_gemini::tests::test_collect_gemini_session_files_nonexistent ... ok +test services::session_usage_codex::tests::test_insert_codex_session_skips_matching_proxy_log ... ok +test services::session_usage_gemini::tests::test_parse_gemini_tokens ... ok +test services::session_usage_gemini::tests::test_parse_gemini_tokens_all_zero ... ok +test services::session_usage_gemini::tests::test_parse_gemini_tokens_cache_only_not_skipped ... ok +test services::session_usage_gemini::tests::test_parse_gemini_tokens_missing_fields ... ok +test services::session_usage_opencode::tests::test_parse_message_data_full ... ok +test services::session_usage_opencode::tests::test_parse_message_data_ignores_role ... ok +test services::session_usage_opencode::tests::test_parse_message_data_missing_cache ... ok +test services::session_usage_opencode::tests::test_parse_message_data_skips_zero_tokens ... ok +test services::session_usage_opencode::tests::test_query_assistant_messages_skips_incomplete ... ok +test services::session_usage_opencode::tests::test_query_sessions_uses_message_update_watermark ... ok +test services::skill::tests::replace_dest_with_copy_rejects_empty_source_without_touching_existing_dest ... ok +test services::skill::tests::resolve_skill_source_dir_falls_back_to_matching_install_name ... ok +test services::skill::tests::resolve_skill_source_dir_returns_direct_nested_directory_when_present ... ok +test services::skill::tests::resolve_skill_source_dir_returns_repo_root_for_root_level_skill ... ok +test services::speedtest::tests::sanitize_timeout_clamps_values ... ok +test services::speedtest::tests::test_endpoints_handles_empty_list ... ok +test services::speedtest::tests::test_endpoints_reports_invalid_url ... ok +test services::proxy::tests::update_live_backup_from_provider_preserves_codex_mcp_servers ... ok +test services::sql_helpers::tests::fresh_input_handles_codex_with_cache_exceeding_input ... ok +test services::sql_helpers::tests::fresh_input_with_alias_emits_prefixed_columns ... ok +test services::session_usage_gemini::tests::test_insert_gemini_session_skips_matching_proxy_log ... ok +test services::sql_helpers::tests::fresh_input_without_alias_uses_bare_columns ... ok +test services::stream_check::tests::test_build_result_any_http_status_is_reachable ... ok +test services::sql_helpers::tests::fresh_input_subtracts_cache_for_cache_inclusive_providers ... ok +test services::stream_check::tests::test_build_result_network_error_is_unreachable ... ok +test services::stream_check::tests::test_build_result_slow_response_is_degraded ... ok +test services::session_usage_codex::tests::test_sync_codex_subagent_uses_rollout_thread_id ... ok +test services::stream_check::tests::test_default_config_uses_reachability_friendly_values ... ok +test services::stream_check::tests::test_determine_status ... ok +test services::stream_check::tests::test_extract_openclaw_base_url_missing_errors ... ok +test services::stream_check::tests::test_merge_provider_config_override_and_default ... ok +test services::stream_check::tests::test_resolve_opencode_base_url_errors_for_openai_compatible_without_url ... ok +test services::stream_check::tests::test_resolve_base_url_uses_explicit_url_or_errors_when_missing ... ok +test services::stream_check::tests::test_resolve_opencode_base_url_explicit_wins ... ok +test services::stream_check::tests::test_resolve_opencode_base_url_falls_back_for_known_npm ... ok +test services::stream_check::tests::test_should_retry_only_on_timeout_like_errors ... ok +test services::subscription::tests::codex_reset_at_accepts_millisecond_epoch ... ok +test services::subscription::tests::codex_reset_at_rejects_implausible_epoch ... ok +test services::sync_protocol::tests::effective_db_compat_version_defaults_legacy_layout_to_v5 ... ok +test services::subscription::tests::codex_reset_credits_parser_redacts_ids_and_accepts_string_count ... ok +test services::sync_protocol::tests::normalize_device_name_collapses_whitespace_and_drops_control_chars ... ok +test services::sync_protocol::tests::manifest_serialization_uses_device_name_only ... ok +test services::sync_protocol::tests::normalize_device_name_returns_none_for_blank_input ... ok +test services::sync_protocol::tests::normalize_device_name_truncates_to_max_len ... ok +test services::sync_protocol::tests::persist_best_effort_returns_false_on_error ... ok +test services::sync_protocol::tests::persist_best_effort_returns_true_on_success ... ok +test services::sync_protocol::tests::sha256_hex_is_correct ... ok +test services::sync_protocol::tests::snapshot_id_changes_with_artifacts ... ok +test services::sync_protocol::tests::validate_artifact_size_limit_accepts_limit_boundary ... ok +test services::sync_protocol::tests::validate_artifact_size_limit_rejects_oversized_artifacts ... ok +test services::sync_protocol::tests::validate_manifest_compat_accepts_legacy_manifest_without_db_compat ... ok +test services::sync_protocol::tests::validate_manifest_compat_accepts_supported_manifest ... ok +test services::sync_protocol::tests::validate_manifest_compat_rejects_current_manifest_with_wrong_db_compat ... ok +test services::sync_protocol::tests::snapshot_id_is_stable ... ok +test services::sync_protocol::tests::validate_manifest_compat_rejects_legacy_manifest_from_newer_db_generation ... ok +test services::sync_protocol::tests::validate_manifest_compat_rejects_wrong_format ... ok +test services::sync_protocol::tests::validate_manifest_compat_rejects_wrong_version ... ok +test services::sync_protocol::tests::verify_artifact_accepts_matching_data ... ok +test services::sync_protocol::tests::verify_artifact_rejects_hash_mismatch ... ok +test services::sync_protocol::tests::verify_artifact_rejects_size_mismatch ... ok +test services::usage_cache::tests::script_round_trip_and_invalidate ... ok +test services::usage_cache::tests::subscription_round_trip ... ok +test services::usage_cache::tests::script_keys_isolated_by_app_type ... ok +test services::usage_stats::tests::test_backfill_missing_usage_costs_uses_stored_multiplier ... ok +test services::usage_stats::tests::test_backfill_missing_usage_costs_falls_back_to_request_model ... ok +test services::usage_stats::tests::test_backfill_missing_usage_costs_uses_new_gpt_5_5_pricing ... ok +test services::usage_stats::tests::test_backfill_missing_usage_costs_keeps_claude_fresh_input ... ok +test services::usage_stats::tests::test_clear_usage_logs_removes_details_and_rollups_only ... ok +test services::usage_stats::tests::test_backfill_skips_request_model_fallback_for_real_unpriced_model ... ok +test services::usage_stats::tests::test_backfill_uses_persisted_pricing_model ... ok +test services::usage_stats::tests::test_claude_desktop_folds_into_claude_for_display ... ok +test services::usage_stats::tests::test_codex_subagent_usage_stats_only_counts_subagent_session_rows ... ok +test services::usage_stats::tests::test_codex_subagent_model_stats_counts_agents_without_usage ... ok +test services::usage_stats::tests::test_codex_subagent_usage_stats_falls_back_to_rollout_token_count ... ok +test services::usage_stats::tests::test_effective_filter_keeps_legacy_null_data_source_proxy_rows ... ok +test services::usage_stats::tests::test_codex_subagent_usage_stats_queries_session_ids_in_chunks ... ok +test services::usage_stats::tests::test_codex_subagent_usage_stats_repairs_zero_token_db_rows_from_rollout ... ok +test services::usage_stats::tests::test_effective_usage_dedup_keeps_non_matching_session_rows ... ok +test services::usage_stats::tests::test_effective_usage_dedup_prefers_proxy_for_session_sources ... ok +test services::usage_stats::tests::test_get_daily_trends_groups_ranges_longer_than_24_hours_by_local_day ... ok +test services::usage_stats::tests::test_get_daily_trends_respects_shorter_than_24_hours ... ok +test services::usage_stats::tests::test_get_model_stats ... ok +test services::usage_stats::tests::test_get_model_stats_excludes_partial_rollup_boundary_days ... ok +test services::usage_stats::tests::test_get_provider_stats_excludes_partial_rollup_boundary_days ... ok +test services::usage_stats::tests::test_get_provider_stats_labels_opencode_session_provider ... ok +test services::usage_stats::tests::test_get_provider_stats_with_time_filter ... ok +test services::usage_stats::tests::test_matching_proxy_log_treats_legacy_null_data_source_as_proxy ... ok +test services::usage_stats::tests::test_get_usage_summary ... ok +test services::usage_stats::tests::test_get_usage_summary_excludes_partial_rollup_boundary_days ... ok +test services::usage_stats::tests::test_get_usage_summary_includes_end_day_rollup_for_minute_precision_end_time ... ok +test services::usage_stats::tests::test_model_pricing_matching ... ok +test services::usage_stats::tests::test_strip_model_date_suffix_is_utf8_safe ... ok +test services::usage_stats::tests::test_prefix_pricing_does_not_match_short_base_model_to_variant ... ok +test services::webdav::tests::auth_from_credentials_trims_and_rejects_blank ... ok +test services::webdav::tests::ensure_content_length_within_limit_accepts_missing_or_small_values ... ok +test services::webdav::tests::build_remote_url_encodes_path_segments ... ok +test services::webdav::tests::ensure_content_length_within_limit_rejects_oversized_values ... ok +test services::webdav::tests::is_jianguoyun_detects_correctly ... ok +test services::webdav::tests::redact_url_hides_credentials_and_query_values ... ok +test services::webdav::tests::path_segments_splits_correctly ... ok +test services::webdav_auto_sync::tests::service_layer_does_not_depend_on_commands_layer ... ok +test services::webdav_auto_sync::tests::should_run_auto_sync_requires_enabled_and_auto_sync_flag ... ok +test services::webdav_auto_sync::tests::suppression_guard_enables_and_restores_state ... ok +test services::webdav_sync::archive::tests::copy_entry_with_total_limit_rejects_oversized_stream_before_write ... ok +test services::webdav_sync::archive::tests::mark_visited_dir_tracks_canonical_duplicates ... ok +test services::webdav_sync::tests::remote_dir_segments_uses_current_layout ... ok +test services::webdav_sync::tests::remote_dir_segments_uses_legacy_layout ... ok +test session_manager::providers::claude::tests::load_messages_mixed_text_and_tool_use ... ok +test session_manager::providers::claude::tests::delete_session_removes_main_file_and_sidecar_directory ... ok +test session_manager::providers::claude::tests::load_messages_mixed_user_tool_result_and_text_stays_user ... ok +test session_manager::providers::claude::tests::load_messages_tool_use_shows_as_assistant ... ok +test session_manager::providers::claude::tests::parse_session_custom_title_overrides_first_message ... ok +test session_manager::providers::claude::tests::parse_session_falls_back_to_dir_basename ... ok +test session_manager::providers::claude::tests::parse_session_new_format_with_snapshot ... ok +test session_manager::providers::claude::tests::parse_session_truncates_long_title ... ok +test session_manager::providers::claude::tests::parse_session_skips_command_caveat_and_slash_commands ... ok +test session_manager::providers::claude::tests::parse_session_uses_first_user_message_as_title ... ok +test session_manager::providers::codex::tests::delete_session_removes_jsonl_file ... ok +test session_manager::providers::codex::tests::load_messages_includes_function_call_and_output ... ok +test session_manager::providers::codex::tests::parse_session_extracts_inline_vscode_ide_request_as_title ... ok +test session_manager::providers::codex::tests::parse_session_extracts_vscode_ide_request_as_title ... ok +test session_manager::providers::codex::tests::parse_session_falls_back_to_dir_basename ... ok +test session_manager::providers::codex::tests::parse_session_ignores_marker_mentions_before_request_heading ... ok +test session_manager::providers::codex::tests::parse_session_keeps_trailing_part_when_request_body_repeats_heading ... ok +test session_manager::providers::codex::tests::parse_session_skips_agents_md_injection ... ok +test session_manager::providers::codex::tests::parse_session_skips_environment_context_injection ... ok +test session_manager::providers::codex::tests::parse_session_skips_subagent_sessions ... ok +test session_manager::providers::codex::tests::parse_session_skips_vscode_ide_context_without_request ... ok +test session_manager::providers::codex::tests::parse_session_truncates_long_title ... ok +test session_manager::providers::codex::tests::parse_session_uses_first_user_message_as_title ... ok +test services::usage_stats::tests::test_scoped_backfill_matches_raw_alias_rows ... ok +test session_manager::providers::codex::tests::parse_session_uses_last_request_heading_when_selection_has_one ... ok +test session_manager::providers::codex::tests::scan_sessions_in_roots_includes_active_and_archived_files ... ok +test session_manager::providers::gemini::tests::delete_session_removes_json_file ... ok +test session_manager::providers::gemini::tests::load_messages_handles_array_content ... ok +test session_manager::providers::gemini::tests::load_messages_includes_tool_calls ... ok +test session_manager::providers::hermes::tests::delete_session_removes_file ... ok +test session_manager::providers::hermes::tests::load_messages_flat_format ... ok +test session_manager::providers::hermes::tests::load_messages_nested_format ... ok +test session_manager::providers::hermes::tests::parse_sqlite_source_invalid ... ok +test session_manager::providers::hermes::tests::parse_sqlite_source_valid ... ok +test services::usage_stats::tests::test_provider_and_model_filters_cover_detail_and_rollup ... ok +test session_manager::providers::hermes::tests::parse_jsonl_session_extracts_metadata ... ok +test session_manager::providers::hermes::tests::parse_jsonl_session_fallback_to_filename ... ok +test session_manager::providers::openclaw::tests::parse_session_falls_back_to_dir_basename ... ok +test session_manager::providers::openclaw::tests::parse_session_display_name_overrides_user_message ... ok +test session_manager::providers::openclaw::tests::parse_session_truncates_long_title ... ok +test session_manager::providers::openclaw::tests::parse_session_uses_first_user_message_as_title ... ok +test session_manager::providers::openclaw::tests::delete_session_updates_index_and_removes_jsonl ... ok +test session_manager::providers::opencode::tests::delete_session_removes_session_diff_messages_and_parts ... ok +test session_manager::providers::opencode::tests::load_messages_includes_tool_parts ... ok +test session_manager::providers::opencode::tests::parse_sqlite_source_accepts_valid_references ... ok +test session_manager::providers::opencode::tests::parse_sqlite_source_rejects_invalid_references ... ok +test session_manager::providers::opencode::tests::delete_session_sqlite_rejects_foreign_db_path ... ok +test session_manager::providers::utils::tests::parse_timestamp_to_ms_supports_integers_and_rfc3339 ... ok +test session_manager::terminal::tests::build_shell_command_keeps_command_without_cwd_prefix_when_not_provided ... ok +test session_manager::terminal::tests::ghostty_uses_working_directory_arg_for_cwd ... ok +test session_manager::terminal::tests::wezterm_compatible_terminals_use_start_and_cwd_arguments ... ok +test session_manager::tests::accepts_source_path_under_any_allowed_provider_root ... ok +test session_manager::tests::batch_delete_collects_successes_and_failures_in_order ... ok +test session_manager::tests::rejects_missing_source_path ... ok +test session_manager::tests::rejects_source_path_outside_provider_root ... ok +test settings::tests::corrupt_settings_are_backed_up_once_before_default_recovery ... ok +test settings::tests::settings_save_uses_common_atomic_persistence_boundary ... ok +test settings::tests::visible_apps_accepts_claude_desktop_aliases ... ok +test settings::tests::visible_apps_old_settings_default_claude_desktop_visible ... ok +test tests::no_code_keeps_app_alive_in_tray ... ok +test tests::restart_exit_code_defers_to_tauri_default_restart ... ok +test tests::user_exit_codes_run_cleanup_then_exit ... ok +test tray::tests::claude_summary_uses_h_and_w_labels ... ok +test tray::tests::failure_quota_returns_none ... ok +test tray::tests::gemini_summary_emoji_reflects_highest_tier_including_lite ... ok +test tray::tests::gemini_summary_includes_all_three_tiers ... ok +test tray::tests::gemini_summary_lite_only_still_renders ... ok +test tray::tests::gemini_summary_uses_p_and_f_labels ... ok +test tray::tests::gemini_without_any_known_tiers_returns_none ... ok +test tray::tests::script_summary_empty_data_returns_none ... ok +test tray::tests::script_summary_failure_returns_none ... ok +test tray::tests::script_summary_official_subscription_claude_uses_h_and_w_labels ... ok +test tray::tests::script_summary_official_subscription_gemini_uses_short_labels ... ok +test tray::tests::script_summary_single_bucket_fallback_with_plan_name ... ok +test tray::tests::script_summary_single_bucket_fallback_without_plan_name ... ok +test tray::tests::script_summary_token_plan_five_hour_only ... ok +test tray::tests::script_summary_token_plan_monthly_only_renders_label_not_raw_name ... ok +test tray::tests::script_summary_token_plan_two_tiers ... ok +test tray::tests::script_summary_token_plan_volcengine_three_tiers_with_monthly ... ok +test tray::tests::script_summary_token_plan_weekly_only ... ok +test tray::tests::script_summary_token_plan_worst_drives_emoji ... ok +test tray::tests::script_summary_week_aliases_use_highest_utilization ... ok +test tray::tests::subscription_summary_week_aliases_use_highest_utilization ... ok +test tray::tests::tray_id_is_unique_to_app ... ok +test tray::tests::unknown_tiers_return_none ... ok +test tray::tests::worst_emoji_reflects_highest_utilization ... ok +test usage_script::tests::test_custom_template_allows_http_lan_request_with_different_base_url ... ok +test usage_script::tests::test_https_bypass_prevention ... ok +test usage_script::tests::test_port_comparison ... ok +test session_manager::providers::opencode::tests::load_messages_sqlite_reads_messages_and_parts ... ok +test session_manager::providers::opencode::tests::delete_session_sqlite_removes_session ... ok +test session_manager::providers::opencode::tests::scan_sessions_sqlite_reads_temp_database ... ok + +failures: + +---- proxy::handlers::tests::upstream_body_parse_error_carries_field_diagnostics stdout ---- + +thread 'proxy::handlers::tests::upstream_body_parse_error_carries_field_diagnostics' (15646) panicked at src/proxy/handlers.rs:3413:17: +Failed to parse upstream response: expected value at line 1 column 1 (content-type: text/html; content-encoding: gzip; body-shape: markup; body: bytes=21, sha256=0892be486eabc667) + + +failures: + proxy::handlers::tests::upstream_body_parse_error_carries_field_diagnostics + +test result: FAILED. 2010 passed; 1 failed; 2 ignored; 0 measured; 0 filtered out; finished in 2.99s + +error: test failed, to rerun pass `--lib` From 1db5ea1ae4dd2acbc06b4edfe157ede86efecd08 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 11:40:47 +0800 Subject: [PATCH 048/112] ci: align hardening diagnostics test with redaction boundary --- .../workflows/final-hardening-apply-once.yml | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/.github/workflows/final-hardening-apply-once.yml b/.github/workflows/final-hardening-apply-once.yml index 117f975c938..7a43fc5eb3d 100644 --- a/.github/workflows/final-hardening-apply-once.yml +++ b/.github/workflows/final-hardening-apply-once.yml @@ -23,7 +23,7 @@ jobs: - name: Apply exact global hardening patch run: python scripts/apply_final_global_hardening_once.py - - name: Remove obsolete body snippet helper, test, and import + - name: Normalize secure body diagnostics and stale test baselines shell: bash run: | set -euo pipefail @@ -65,10 +65,25 @@ jobs: 1, ) + stale_assert = ''' assert!(msg.contains("\\\\nblocked"), "{msg}"); +''' + secure_assert = ''' assert!(msg.contains("body-shape: markup"), "{msg}"); + assert!(msg.contains("body: bytes=21, sha256="), "{msg}"); + assert!(!msg.contains(""), "{msg}"); + assert!(!msg.contains("blocked"), "{msg}"); +''' + if text.count(stale_assert) != 1: + raise SystemExit( + f"expected one stale raw-body diagnostics assertion, found {text.count(stale_assert)}" + ) + text = text.replace(stale_assert, secure_assert, 1) + if "body_snippet" in text: raise SystemExit("body_snippet identifier remains after exact removal") + if 'msg.contains("\\\\nblocked")' in text: + raise SystemExit("raw upstream body expectation remains in diagnostics test") path.write_text(text, encoding="utf-8") - print("removed obsolete body_snippet helper, regression test, and test import") + print("normalized secure body diagnostics implementation and regression expectations") PY - name: Normalize all-target Clippy baselines @@ -131,12 +146,19 @@ jobs: - name: Clippy all targets and features run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --all-features -- -D warnings - - name: Rust tests + - name: Rust tests default feature set + run: cargo test --manifest-path src-tauri/Cargo.toml --all + + - name: Rust tests all features run: cargo test --manifest-path src-tauri/Cargo.toml --all-features - - name: Remove one-shot drivers and commit verified changes + - name: Remove one-shot diagnostics and commit verified changes run: | - git rm scripts/apply_final_global_hardening_once.py .github/workflows/final-hardening-apply-once.yml + git rm \ + scripts/apply_final_global_hardening_once.py \ + .github/workflows/final-hardening-apply-once.yml \ + .github/workflows/final-hardening-diagnose-once.yml \ + .ci-final-hardening-test-failure.txt git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A From d7c66cc2ef7556ac89f43d9b6e29f7c14b9b0b93 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 11:41:47 +0800 Subject: [PATCH 049/112] ci: fix final hardening workflow block syntax --- .github/workflows/final-hardening-apply-once.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/final-hardening-apply-once.yml b/.github/workflows/final-hardening-apply-once.yml index 7a43fc5eb3d..0e899a28bfa 100644 --- a/.github/workflows/final-hardening-apply-once.yml +++ b/.github/workflows/final-hardening-apply-once.yml @@ -65,13 +65,13 @@ jobs: 1, ) - stale_assert = ''' assert!(msg.contains("\\\\nblocked"), "{msg}"); -''' - secure_assert = ''' assert!(msg.contains("body-shape: markup"), "{msg}"); - assert!(msg.contains("body: bytes=21, sha256="), "{msg}"); - assert!(!msg.contains(""), "{msg}"); - assert!(!msg.contains("blocked"), "{msg}"); -''' + stale_assert = r' assert!(msg.contains("\\nblocked"), "{msg}");' + "\n" + secure_assert = ( + ' assert!(msg.contains("body-shape: markup"), "{msg}");\n' + ' assert!(msg.contains("body: bytes=21, sha256="), "{msg}");\n' + ' assert!(!msg.contains(""), "{msg}");\n' + ' assert!(!msg.contains("blocked"), "{msg}");\n' + ) if text.count(stale_assert) != 1: raise SystemExit( f"expected one stale raw-body diagnostics assertion, found {text.count(stale_assert)}" @@ -80,7 +80,7 @@ jobs: if "body_snippet" in text: raise SystemExit("body_snippet identifier remains after exact removal") - if 'msg.contains("\\\\nblocked")' in text: + if stale_assert in text: raise SystemExit("raw upstream body expectation remains in diagnostics test") path.write_text(text, encoding="utf-8") print("normalized secure body diagnostics implementation and regression expectations") From 725208a4b57df2cf4b62f1fde5893805915c27bf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:52:04 +0000 Subject: [PATCH 050/112] fix: close global diagnostics and rollback failure boundaries --- .ci-final-hardening-test-failure.txt | 1213 ----------------- .../workflows/final-hardening-apply-once.yml | 167 --- .../final-hardening-diagnose-once.yml | 69 - scripts/apply_final_global_hardening_once.py | 14 - scripts/check_rust_failure_boundaries.py | 48 +- src-tauri/src/codex_config.rs | 17 +- src-tauri/src/commands/deeplink.rs | 5 +- src-tauri/src/config.rs | 54 +- src-tauri/src/diagnostics.rs | 204 +++ src-tauri/src/lib.rs | 48 +- src-tauri/src/proxy/forwarder.rs | 5 +- src-tauri/src/proxy/handlers.rs | 64 +- src-tauri/src/proxy/response_processor.rs | 19 +- src-tauri/src/services/model_fetch.rs | 5 +- src-tauri/src/services/proxy.rs | 298 ++-- src-tauri/src/services/s3.rs | 34 +- src-tauri/src/services/webdav.rs | 49 +- src-tauri/src/settings.rs | 6 +- 18 files changed, 577 insertions(+), 1742 deletions(-) delete mode 100644 .ci-final-hardening-test-failure.txt delete mode 100644 .github/workflows/final-hardening-apply-once.yml delete mode 100644 .github/workflows/final-hardening-diagnose-once.yml delete mode 100644 scripts/apply_final_global_hardening_once.py create mode 100644 src-tauri/src/diagnostics.rs diff --git a/.ci-final-hardening-test-failure.txt b/.ci-final-hardening-test-failure.txt deleted file mode 100644 index 7413c4a3a59..00000000000 --- a/.ci-final-hardening-test-failure.txt +++ /dev/null @@ -1,1213 +0,0 @@ -Final hardening Rust test diagnostics -cargo_exit=101 - -=== failure markers === -1891:test proxy::handlers::tests::upstream_body_parse_error_carries_field_diagnostics ... FAILED -3092:failures: -3094:---- proxy::handlers::tests::upstream_body_parse_error_carries_field_diagnostics stdout ---- -3096:thread 'proxy::handlers::tests::upstream_body_parse_error_carries_field_diagnostics' (15646) panicked at src/proxy/handlers.rs:3413:17: -3100:failures: -3103:test result: FAILED. 2010 passed; 1 failed; 2 ignored; 0 measured; 0 filtered out; finished in 2.99s -3105:error: test failed, to rerun pass `--lib` - -=== final 1200 lines === -test proxy::json_canonical::tests::canonicalize_tool_arguments_str_coerces_empty_to_object ... ok -test proxy::json_canonical::tests::canonicalize_tool_arguments_str_wraps_malformed_json ... ok -test proxy::json_canonical::tests::canonicalize_value_sorts_map_storage_order ... ok -test proxy::media_sanitizer::tests::deepseekv4_aliases_replace_images_before_send ... ok -test proxy::media_sanitizer::tests::detects_chat_content_unknown_variant_image_url_errors ... ok -test proxy::media_sanitizer::tests::detects_media_and_attachment_error_phrasings ... ok -test proxy::media_sanitizer::tests::detects_minimax_sensitive_image_errors ... ok -test proxy::media_sanitizer::tests::detects_unsupported_image_errors ... ok -test proxy::media_sanitizer::tests::explicit_image_modalities_preserve_model_images ... ok -test proxy::media_sanitizer::tests::explicit_text_capability_replaces_even_when_heuristic_disabled ... ok -test proxy::media_sanitizer::tests::explicit_text_modalities_can_override_visual_model_ids ... ok -test proxy::media_sanitizer::tests::explicit_text_modalities_replace_images_before_send ... ok -test proxy::media_sanitizer::tests::heuristic_disabled_keeps_images_for_listed_text_only_models ... ok -test proxy::media_sanitizer::tests::ignores_non_image_errors ... ok -test proxy::media_sanitizer::tests::keeps_images_when_model_capability_is_unknown ... ok -test proxy::media_sanitizer::tests::known_mimo_pro_replaces_but_mimo_multimodal_preserves ... ok -test proxy::media_sanitizer::tests::known_text_only_models_replace_chat_image_url_before_send ... ok -test proxy::media_sanitizer::tests::known_text_only_models_replace_codex_input_image_before_send ... ok -test proxy::media_sanitizer::tests::known_text_only_models_replace_images_before_send ... ok -test proxy::media_sanitizer::tests::known_text_only_prefixes_replace_images_before_send ... ok -test proxy::media_sanitizer::tests::multimodal_kimi_model_is_not_on_text_only_list ... ok -test proxy::media_sanitizer::tests::preserves_cache_control_when_replacing_image ... ok -test proxy::media_sanitizer::tests::preserves_images_without_explicit_capability_even_for_unknown_models ... ok -test proxy::media_sanitizer::tests::replaces_nested_tool_result_image_blocks ... ok -test proxy::media_sanitizer::tests::unconditional_marker_replacement_handles_retry_path ... ok -test proxy::model_mapper::tests::keeps_model_without_one_m_suffix ... ok -test proxy::model_mapper::tests::strips_one_m_suffix_after_mapping ... ok -test proxy::model_mapper::tests::strips_one_m_suffix_before_upstream ... ok -test proxy::model_mapper::tests::test_case_insensitive ... ok -test proxy::model_mapper::tests::test_fable_falls_back_to_default_without_opus ... ok -test proxy::model_mapper::tests::test_fable_falls_back_to_opus_when_unset ... ok -test proxy::model_mapper::tests::test_fable_mapping ... ok -test proxy::model_mapper::tests::test_fable_with_one_m_suffix_mapping ... ok -test proxy::model_mapper::tests::test_haiku_mapping ... ok -test proxy::model_mapper::tests::test_no_mapping_configured ... ok -test proxy::model_mapper::tests::test_opus_mapping ... ok -test proxy::model_mapper::tests::test_sonnet_mapping ... ok -test proxy::model_mapper::tests::test_thinking_adaptive_does_not_affect_model_mapping ... ok -test proxy::model_mapper::tests::test_thinking_disabled ... ok -test proxy::model_mapper::tests::test_thinking_does_not_affect_model_mapping ... ok -test proxy::model_mapper::tests::test_unknown_model_uses_default ... ok -test proxy::provider_router::tests::test_failover_disabled_uses_current_provider ... ok -test proxy::provider_router::tests::test_failover_enabled_uses_queue_only_even_if_current_not_in_queue ... ok -test proxy::provider_router::tests::test_failover_enabled_uses_queue_order_ignoring_current ... ok -test proxy::provider_router::tests::test_provider_router_creation ... ok -test proxy::provider_router::tests::test_release_permit_neutral_frees_half_open_slot ... ok -test proxy::provider_router::tests::test_select_providers_does_not_consume_half_open_permit ... ok -test proxy::providers::auth::tests::test_all_strategies_are_distinct ... ok -test proxy::providers::auth::tests::test_auth_info_new_has_no_access_token ... ok -test proxy::providers::auth::tests::test_auth_info_with_access_token ... ok -test proxy::providers::auth::tests::test_auth_strategy_equality ... ok -test proxy::providers::auth::tests::test_claude_auth_strategy ... ok -test proxy::providers::auth::tests::test_google_oauth_strategy ... ok -test proxy::providers::auth::tests::test_masked_access_token_long ... ok -test proxy::providers::auth::tests::test_masked_access_token_none ... ok -test proxy::providers::auth::tests::test_masked_access_token_short ... ok -test proxy::providers::auth::tests::test_masked_access_token_utf8_safe ... ok -test proxy::providers::auth::tests::test_masked_key_9_chars ... ok -test proxy::providers::auth::tests::test_masked_key_exactly_8 ... ok -test proxy::providers::auth::tests::test_masked_key_long ... ok -test proxy::providers::auth::tests::test_masked_key_short ... ok -test proxy::providers::auth::tests::test_masked_key_utf8_safe ... ok -test proxy::providers::claude::tests::test_anthropic_messages_no_longer_hoists_system_role_messages ... ok -test proxy::providers::claude::tests::test_anthropic_system_role_messages_skip_non_anthropic_format ... ok -test proxy::providers::claude::tests::test_build_url_anthropic ... ok -test proxy::providers::claude::tests::test_build_url_no_beta_for_github_copilot ... ok -test proxy::providers::claude::tests::test_build_url_no_beta_for_openai_chat_completions ... ok -test proxy::providers::claude::tests::test_build_url_no_beta_for_other_endpoints ... ok -test proxy::providers::claude::tests::test_build_url_openrouter ... ok -test proxy::providers::claude::tests::test_build_url_preserve_existing_query ... ok -test proxy::providers::claude::tests::test_deepseek_anthropic_tool_history_injects_missing_thinking ... ok -test proxy::providers::claude::tests::test_deepseek_anthropic_tool_history_keeps_thinking_text_but_drops_signature ... ok -test proxy::providers::claude::tests::test_deepseek_anthropic_tool_history_rewrites_redacted_thinking ... ok -test proxy::providers::claude::tests::test_deepseek_official_detected_via_base_url_fallback ... ok -test proxy::providers::claude::tests::test_deepseek_official_no_effort_no_change ... ok -test proxy::providers::claude::tests::test_deepseek_official_non_disabled_not_modified ... ok -test proxy::providers::claude::tests::test_deepseek_official_preserves_output_config_other_fields ... ok -test proxy::providers::claude::tests::test_deepseek_official_strips_both_effort_fields ... ok -test proxy::providers::claude::tests::test_deepseek_official_strips_output_config_effort ... ok -test proxy::providers::claude::tests::test_deepseek_official_strips_reasoning_effort ... ok -test proxy::providers::claude::tests::test_deepseek_official_url_with_trailing_slash ... ok -test proxy::providers::claude::tests::test_extract_auth_anthropic_api_key ... ok -test proxy::providers::claude::tests::test_extract_auth_anthropic_auth_token_uses_claude_auth_strategy ... ok -test proxy::providers::claude::tests::test_extract_auth_apikey_field_fallback_uses_anthropic_strategy ... ok -test proxy::providers::claude::tests::test_extract_auth_both_env_vars_prefer_auth_token ... ok -test proxy::providers::claude::tests::test_extract_auth_claude_auth_env_mode ... ok -test proxy::providers::claude::tests::test_extract_auth_claude_auth_mode ... ok -test proxy::providers::claude::tests::test_extract_auth_gemini_api_key ... ok -test proxy::providers::claude::tests::test_extract_auth_gemini_cli_access_token_with_leading_newline_classifies_correctly ... ok -test proxy::providers::claude::tests::test_extract_auth_gemini_cli_empty_access_token_degrades_to_raw_key ... ok -test proxy::providers::claude::tests::test_extract_auth_gemini_cli_json_with_leading_whitespace_classifies_correctly ... ok -test proxy::providers::claude::tests::test_extract_auth_gemini_cli_refresh_only_json_does_not_expose_empty_bearer ... ok -test proxy::providers::claude::tests::test_extract_auth_gemini_cli_valid_json_keeps_access_token ... ok -test proxy::providers::claude::tests::test_extract_auth_openrouter ... ok -test proxy::providers::claude::tests::test_extract_base_url_from_env ... ok -test proxy::providers::claude::tests::test_generic_anthropic_tool_history_is_not_modified ... ok -test proxy::providers::claude::tests::test_get_auth_headers_anthropic_emits_x_api_key ... ok -test proxy::providers::claude::tests::test_get_auth_headers_bearer_emits_authorization_bearer ... ok -test proxy::providers::claude::tests::test_get_auth_headers_claude_auth_emits_authorization_bearer ... ok -test proxy::providers::claude::tests::test_get_auth_headers_rejects_illegal_header_chars ... ok -test proxy::providers::claude::tests::test_github_copilot_auth ... ok -test proxy::providers::claude::tests::test_github_copilot_detection_by_meta ... ok -test proxy::providers::claude::tests::test_github_copilot_detection_by_url ... ok -test proxy::providers::claude::tests::test_github_copilot_needs_transform ... ok -test proxy::providers::claude::tests::test_kimi_anthropic_tool_history_injects_missing_thinking ... ok -test proxy::providers::claude::tests::test_needs_transform ... ok -test proxy::providers::claude::tests::test_non_deepseek_endpoint_not_modified ... ok -test proxy::providers::claude::tests::test_normalize_messages_pipeline_strips_effort_for_deepseek ... ok -test proxy::providers::claude::tests::test_provider_type_detection ... ok -test proxy::providers::claude::tests::test_transform_claude_request_for_api_format_codex_oauth_fast_mode_off ... ok -test proxy::providers::claude::tests::test_transform_claude_request_for_api_format_gemini_native ... ok -test proxy::providers::claude::tests::test_transform_claude_request_for_api_format_openai_chat_keeps_explicit_prompt_cache_key ... ok -test proxy::providers::claude::tests::test_transform_claude_request_for_api_format_openai_chat_skips_prompt_cache_key_by_default ... ok -test proxy::providers::claude::tests::test_transform_claude_request_for_api_format_responses ... ok -test proxy::providers::claude::tests::test_transform_claude_request_for_codex_oauth_keeps_explicit_cache_key ... ok -test proxy::providers::claude::tests::test_transform_claude_request_for_codex_oauth_uses_session_cache_key ... ok -test proxy::providers::claude::tests::test_transform_claude_request_for_codex_oauth_without_session_omits_cache_key ... ok -test proxy::providers::claude::tests::test_transform_claude_request_for_responses_uses_session_cache_key ... ok -test proxy::providers::claude::tests::test_transform_claude_request_for_responses_without_session_omits_cache_key ... ok -test proxy::providers::claude::tests::test_transform_claude_request_openai_chat_non_streaming_omits_stream_options ... ok -test proxy::providers::claude::tests::test_transform_claude_request_openai_chat_streaming_injects_include_usage ... ok -test proxy::providers::claude::tests::test_transform_openai_chat_preserves_reasoning_content_for_deepseek_provider ... ok -test proxy::providers::claude::tests::test_transform_openai_chat_preserves_reasoning_content_for_kimi_provider ... ok -test proxy::providers::claude::tests::test_transform_openai_chat_preserves_reasoning_content_for_mimo_provider ... ok -test proxy::providers::claude::tests::test_transform_openai_chat_skips_reasoning_content_for_generic_provider ... ok -test proxy::providers::codex::tests::test_apply_codex_chat_upstream_model_forces_unmatched_fallback_route_model ... ok -test proxy::providers::codex::tests::test_apply_codex_chat_upstream_model_preserves_catalog_model_selection ... ok -test proxy::providers::codex::tests::test_apply_codex_chat_upstream_model_uses_catalog_upstream_model ... ok -test proxy::providers::codex::tests::test_apply_codex_chat_upstream_model_uses_provider_config_model ... ok -test proxy::providers::codex::tests::test_apply_codex_request_upstream_model_route_override_takes_priority ... ok -test proxy::providers::codex::tests::test_apply_codex_request_upstream_model_uses_catalog_for_native_responses ... ok -test proxy::providers::codex::tests::test_build_url ... ok -test proxy::providers::codex::tests::test_build_url_chatgpt_codex_backend_strips_openai_v1_prefix ... ok -test proxy::providers::codex::tests::test_build_url_custom_prefix_no_v1 ... ok -test proxy::providers::codex::tests::test_build_url_dedup_v1 ... ok -test proxy::providers::codex::tests::test_build_url_origin_adds_v1 ... ok -test proxy::providers::codex::tests::test_codex_adapter_supports_routed_codex_oauth_provider ... ok -test proxy::providers::codex::tests::test_codex_adapter_treats_empty_official_seed_as_managed_oauth ... ok -test proxy::providers::codex::tests::test_codex_legacy_route_candidates_prefer_exact_over_earlier_prefix_route ... ok -test proxy::providers::codex::tests::test_codex_model_route_accepts_legacy_array_codex_routing ... ok -test proxy::providers::codex::tests::test_codex_model_route_overrides_cache_config ... ok -test proxy::providers::codex::tests::test_codex_model_route_overrides_chat_reasoning_config ... ok -test proxy::providers::codex::tests::test_codex_model_route_resolves_deepseek_chat_provider ... ok -test proxy::providers::codex::tests::test_codex_model_route_supports_prefix_matching ... ok -test proxy::providers::codex::tests::test_codex_model_route_uses_codex_routing_first ... ok -test proxy::providers::codex::tests::test_codex_provider_keeps_openai_responses_wire_api ... ok -test proxy::providers::codex::tests::test_codex_provider_uses_chat_completions_for_legacy_deepseek_responses_wire_api ... ok -test proxy::providers::codex::tests::test_codex_provider_uses_chat_completions_from_active_wire_api ... ok -test proxy::providers::codex::tests::test_codex_provider_uses_chat_completions_from_full_chat_url ... ok -test proxy::providers::codex::tests::test_codex_provider_uses_chat_completions_from_meta_api_format_for_compact ... ok -test proxy::providers::codex::tests::test_codex_provider_uses_chat_completions_from_meta_api_format_for_responses ... ok -test proxy::providers::codex::tests::test_codex_provider_uses_messages_from_explicit_api_format ... ok -test proxy::providers::codex::tests::test_codex_responses_provider_does_not_convert_to_chat ... ok -test proxy::providers::codex::tests::test_codex_responses_provider_ignores_stale_top_level_proxy_url ... ok -test proxy::providers::codex::tests::test_codex_route_default_route_is_used_when_no_match ... ok -test proxy::providers::codex::tests::test_codex_route_managed_auth_ignores_stale_api_key ... ok -test proxy::providers::codex::tests::test_codex_route_managed_codex_oauth_keeps_auth_in_meta ... ok -test proxy::providers::codex::tests::test_codex_route_provider_config_api_key_overrides_provider_key ... ok -test proxy::providers::codex::tests::test_codex_route_provider_config_auth_preserves_provider_key ... ok -test proxy::providers::codex::tests::test_codex_route_resolver_prefers_exact_route_over_earlier_prefix_route ... ok -test proxy::providers::codex::tests::test_codex_route_skips_disabled_matches ... ok -test proxy::providers::codex::tests::test_codex_route_target_provider_infers_empty_official_seed_as_managed_oauth ... ok -test proxy::providers::codex::tests::test_codex_route_target_provider_infers_legacy_official_oauth_base_url ... ok -test proxy::providers::codex::tests::test_codex_route_target_provider_infers_official_oauth_from_router_auth ... ok -test proxy::providers::codex::tests::test_codex_route_target_provider_reuses_provider_conversion_config ... ok -test proxy::providers::codex::tests::test_codex_route_target_provider_treats_local_proxy_official_as_managed_oauth ... ok -test proxy::providers::codex::tests::test_codex_route_target_provider_treats_polluted_official_as_managed_oauth ... ok -test proxy::providers::codex::tests::test_codex_router_duplicate_exact_routes_remain_order_dependent ... ok -test proxy::providers::codex::tests::test_codex_router_prefers_exact_route_over_earlier_prefix_route ... ok -test proxy::providers::codex::tests::test_codex_router_returns_fallback_route_candidates_after_primary ... ok -test proxy::providers::codex::tests::test_extract_auth_falls_back_to_config_bearer_when_auth_key_empty ... ok -test proxy::providers::codex::tests::test_extract_auth_from_auth_field ... ok -test proxy::providers::codex::tests::test_extract_auth_from_env ... ok -test proxy::providers::codex::tests::test_extract_base_url_direct ... ok -test proxy::providers::codex::tests::test_extract_base_url_uses_active_model_provider_only ... ok -test proxy::providers::codex::tests::test_extract_base_url_uses_openai_base_url_for_builtin_openai ... ok -test proxy::providers::codex::tests::test_is_not_official_client ... ok -test proxy::providers::codex::tests::test_is_official_client_cli ... ok -test proxy::providers::codex::tests::test_is_official_client_partial_match ... ok -test proxy::providers::codex::tests::test_is_official_client_vscode ... ok -test proxy::providers::codex::tests::test_managed_codex_oauth_stays_on_native_responses ... ok -test proxy::providers::codex::tests::test_materialize_routed_provider_preserves_model_catalog ... ok -test proxy::providers::codex::tests::test_qwen_vllm_explicit_larger_budget_is_preserved ... ok -test proxy::providers::codex::tests::test_qwen_vllm_explicit_stale_reasoning_keeps_inferred_defaults ... ok -test proxy::providers::codex::tests::test_qwen_vllm_retired_auto_default_budget_is_cleared ... ok -test proxy::providers::codex::tests::test_qwen_vllm_route_infers_thinking_without_default_output_budget ... ok -test proxy::providers::codex::tests::test_resolve_codex_chat_reasoning_explicit_meta_overrides_inference ... ok -test proxy::providers::codex::tests::test_resolve_codex_chat_reasoning_infers_deepseek_effort_support ... ok -test proxy::providers::codex::tests::test_resolve_codex_chat_reasoning_infers_glm_5_2_effort_support ... ok -test proxy::providers::codex::tests::test_resolve_codex_chat_reasoning_openrouter_platform_overrides_model ... ok -test proxy::providers::codex::tests::test_resolve_codex_chat_reasoning_siliconflow_platform_overrides_minimax ... ok -test proxy::providers::codex_chat_history::tests::does_not_restore_ambiguous_call_id_without_previous_response ... ok -test proxy::providers::codex_chat_history::tests::enriches_existing_function_call_missing_name_and_arguments ... ok -test proxy::providers::codex_chat_history::tests::enriches_existing_function_call_missing_reasoning ... ok -test proxy::providers::codex_chat_history::tests::enriches_tool_output_with_cached_function_call_from_previous_response ... ok -test proxy::providers::codex_chat_history::tests::records_streamed_function_call_done_items ... ok -test proxy::providers::codex_chat_history::tests::restores_custom_and_tool_search_calls_from_previous_response ... ok -test proxy::providers::codex_chat_history::tests::restores_parallel_tool_calls_as_one_assistant_group ... ok -test proxy::providers::codex_chat_history::tests::restores_unique_call_id_without_matching_previous_response ... ok -test proxy::providers::codex_chat_history::tests::streamed_recorder_preserves_chunks_and_records_exchange_with_request ... ok -test proxy::providers::codex_oauth_auth::tests::get_status_does_not_refresh_or_remove_invalid_account ... ok -test proxy::providers::codex_oauth_auth::tests::test_cached_token_expiring_soon ... ok -test proxy::providers::codex_oauth_auth::tests::test_compute_expires_at_ms ... ok -test proxy::providers::codex_oauth_auth::tests::test_compute_expires_at_ms_default ... ok -test proxy::providers::codex_oauth_auth::tests::test_manager_initial_state ... ok -test proxy::providers::codex_oauth_auth::tests::test_manager_save_and_load ... ok -test proxy::providers::codex_oauth_auth::tests::test_parse_interval_default ... ok -test proxy::providers::codex_oauth_auth::tests::test_parse_interval_min ... ok -test proxy::providers::codex_oauth_auth::tests::test_parse_interval_number ... ok -test proxy::providers::codex_oauth_auth::tests::test_parse_interval_string ... ok -test proxy::providers::codex_oauth_auth::tests::test_parse_jwt_claims_invalid ... ok -test proxy::providers::codex_oauth_auth::tests::test_parse_jwt_claims_organizations_fallback ... ok -test proxy::providers::codex_oauth_auth::tests::test_parse_jwt_claims_valid ... ok -test proxy::providers::codex_oauth_auth::tests::test_remove_account ... ok -test proxy::http_client::tests::test_build_client_direct ... ok -test proxy::http_client::tests::test_build_client_with_socks5_proxy ... ok -test proxy::http_client::tests::test_build_client_with_http_proxy ... ok -test proxy::providers::copilot_auth::tests::test_auth_status_serialization ... ok -test proxy::providers::copilot_auth::tests::test_clear_auth_cleans_memory_even_when_file_removal_fails ... ok -test proxy::providers::copilot_auth::tests::test_clear_auth_clears_all_api_endpoint_cache ... ok -test proxy::providers::copilot_auth::tests::test_composite_account_id ... ok -test proxy::providers::copilot_auth::tests::test_copilot_token_expiry ... ok -test proxy::providers::copilot_auth::tests::test_fallback_default_account_prefers_latest_authenticated ... ok -test proxy::providers::copilot_auth::tests::test_fetch_and_cache_endpoint_requires_account ... ok -test proxy::providers::copilot_auth::tests::test_get_api_endpoint_cache_hit_skips_fetch ... ok -test proxy::providers::copilot_auth::tests::test_get_api_endpoint_returns_cached_value ... ok -test proxy::providers::copilot_auth::tests::test_get_api_endpoint_returns_default_for_unknown_account ... ok -test proxy::providers::copilot_auth::tests::test_get_api_endpoint_returns_default_when_not_cached ... ok -test proxy::providers::copilot_auth::tests::test_get_default_api_endpoint_uses_default_account ... ok -test proxy::providers::copilot_auth::tests::test_get_model_vendor_from_cache ... ok -test proxy::providers::copilot_auth::tests::test_github_account_from_data ... ok -test proxy::providers::copilot_auth::tests::test_github_account_from_data_ghes_uses_composite_id ... ok -test proxy::providers::copilot_auth::tests::test_legacy_format_detection ... ok -test proxy::providers::copilot_auth::tests::test_multi_account_store_serialization ... ok -test proxy::providers::copilot_auth::tests::test_normalize_github_domain ... ok -test proxy::providers::copilot_auth::tests::test_remove_account_clears_api_endpoint_cache ... ok -test proxy::providers::copilot_model_map::tests::already_copilot_format_returns_none ... ok -test proxy::providers::copilot_model_map::tests::apply_handles_missing_model ... ok -test proxy::providers::copilot_model_map::tests::apply_no_change_when_already_normalized ... ok -test proxy::providers::copilot_model_map::tests::apply_rewrites_body ... ok -test proxy::providers::copilot_model_map::tests::bracket_one_m_with_date_combined ... ok -test proxy::providers::copilot_model_map::tests::case_insensitive_on_prefix_and_suffix ... ok -test proxy::providers::copilot_model_map::tests::dashes_to_dot_basic ... ok -test proxy::providers::copilot_model_map::tests::date_suffix_stripped ... ok -test proxy::providers::copilot_model_map::tests::legacy_three_part_versions_untouched ... ok -test proxy::providers::copilot_model_map::tests::non_claude_models_untouched ... ok -test proxy::providers::copilot_model_map::tests::one_m_bracket_on_already_dotted ... ok -test proxy::providers::copilot_model_map::tests::one_m_bracket_to_dash ... ok -test proxy::providers::copilot_model_map::tests::resolve_exact_match_after_normalize ... ok -test proxy::providers::copilot_model_map::tests::resolve_falls_back_to_base_when_1m_unavailable ... ok -test proxy::providers::copilot_model_map::tests::resolve_falls_back_to_highest_family_version ... ok -test proxy::providers::copilot_model_map::tests::resolve_handles_non_claude_target ... ok -test proxy::providers::copilot_model_map::tests::resolve_prefers_1m_when_requested ... ok -test proxy::providers::copilot_model_map::tests::resolve_returns_none_when_already_valid ... ok -test proxy::providers::copilot_model_map::tests::resolve_returns_none_when_family_absent ... ok -test proxy::providers::gemini::tests::test_build_url_dedup ... ok -test proxy::providers::gemini::tests::test_build_url_normal ... ok -test proxy::providers::gemini::tests::test_extract_auth_api_key ... ok -test proxy::providers::gemini::tests::test_extract_auth_fallback ... ok -test proxy::providers::gemini::tests::test_extract_auth_oauth_access_token ... ok -test proxy::providers::gemini::tests::test_extract_auth_oauth_json ... ok -test proxy::providers::gemini::tests::test_extract_base_url_from_env ... ok -test proxy::providers::gemini::tests::test_parse_oauth_credentials_direct_token ... ok -test proxy::providers::gemini::tests::test_parse_oauth_credentials_invalid ... ok -test proxy::providers::gemini::tests::test_parse_oauth_credentials_json ... ok -test proxy::providers::gemini::tests::test_provider_type_detection ... ok -test proxy::providers::gemini_schema::tests::empty_input_schema_produces_explicit_object_type ... ok -test proxy::providers::gemini_schema::tests::input_schema_missing_type_is_promoted_to_object ... ok -test proxy::providers::gemini_schema::tests::non_object_schema_is_not_mutated ... ok -test proxy::providers::gemini_schema::tests::uses_parameters_json_schema_for_additional_properties ... ok -test proxy::providers::gemini_schema::tests::uses_parameters_json_schema_for_one_of ... ok -test proxy::providers::gemini_schema::tests::uses_schema_for_simple_openapi_subset ... ok -test proxy::providers::gemini_shadow::tests::clear_session_and_provider_work ... ok -test proxy::providers::gemini_shadow::tests::evicts_oldest_session_when_capacity_is_exceeded ... ok -test proxy::providers::gemini_shadow::tests::record_and_read_latest_turn ... ok -test proxy::providers::gemini_shadow::tests::retains_only_latest_turns_per_session ... ok -test proxy::providers::gemini_shadow::tests::sessions_are_isolated_by_provider_and_session_id ... ok -test proxy::providers::openai_compat::tests::chat_request_maps_to_codex_responses_contract ... ok -test proxy::providers::openai_compat::tests::chat_request_preserves_chinese_through_codex_responses_conversion ... ok -test proxy::providers::openai_compat::tests::chat_request_preserves_responses_style_text_parts ... ok -test proxy::providers::openai_compat::tests::chat_request_without_system_prompt_still_sets_codex_instructions ... ok -test proxy::providers::openai_compat::tests::codex_oauth_responses_normalizer_promotes_control_messages_and_strips_tool_content ... ok -test proxy::providers::openai_compat::tests::codex_oauth_responses_normalizer_promotes_multi_part_developer_message ... ok -test proxy::providers::openai_compat::tests::codex_oauth_responses_normalizer_promotes_raw_reasoning_content_to_summary ... ok -test proxy::providers::openai_compat::tests::codex_oauth_responses_normalizer_removes_duplicate_reasoning_content ... ok -test proxy::providers::openai_compat::tests::codex_responses_passthrough_normalizes_function_call_arguments ... ok -test proxy::providers::openai_compat::tests::codex_responses_passthrough_promotes_control_messages_to_instructions ... ok -test proxy::providers::openai_compat::tests::codex_responses_request_normalizer_accepts_minimal_body ... ok -test proxy::providers::openai_compat::tests::codex_responses_request_normalizer_preserves_desktop_shape ... ok -test proxy::providers::openai_compat::tests::codex_responses_request_normalizer_strips_content_from_tool_output_items ... ok -test proxy::providers::openai_compat::tests::responses_json_maps_to_chat_completion ... ok -test proxy::providers::openai_compat::tests::responses_json_with_null_error_maps_to_chat_completion ... ok -test proxy::providers::openai_compat::tests::responses_sse_maps_to_chat_sse ... ok -test proxy::providers::openai_compat::tests::responses_tool_call_maps_to_chat_tool_call ... ok -test proxy::providers::streaming::tests::test_duplicate_finish_reason_emits_only_one_message_delta ... ok -test proxy::providers::streaming::tests::test_map_stop_reason_legacy_and_filtered_values ... ok -test proxy::providers::streaming::tests::test_message_delta_includes_zero_usage_when_stream_has_no_usage ... ok -test proxy::providers::streaming::tests::test_stream_end_without_finish_reason_does_not_emit_success_terminal_events ... ok -test proxy::providers::streaming::tests::test_stream_error_does_not_emit_success_terminal_events ... ok -test proxy::providers::streaming::tests::test_streaming_chinese_split_across_chunks_no_replacement_chars ... ok -test proxy::providers::streaming::tests::test_streaming_delays_tool_start_until_id_and_name_ready ... ok -test proxy::providers::streaming::tests::test_streaming_finalizes_after_finish_when_done_is_missing ... ok -test proxy::providers::streaming::tests::test_streaming_tool_calls_routed_by_index ... ok -test proxy::providers::streaming::tests::test_usage_chunk_clamps_input_to_zero_when_cache_exceeds_prompt ... ok -test proxy::providers::streaming::tests::test_usage_chunk_subtracts_cache_read_and_creation_from_input ... ok -test proxy::providers::streaming::tests::test_usage_only_chunk_after_finish_reason_updates_message_delta_usage ... ok -test proxy::providers::streaming_codex_chat::tests::canonicalizes_streamed_tool_call_arguments_on_done_events ... ok -test proxy::providers::streaming_codex_chat::tests::chat_sse_data_only_error_emits_failed_without_completed ... ok -test proxy::providers::streaming_codex_chat::tests::chat_sse_error_event_emits_failed_without_completed ... ok -test proxy::providers::streaming_codex_chat::tests::converts_inline_think_chat_sse_to_reasoning_without_leaking_tags ... ok -test proxy::providers::streaming_codex_chat::tests::converts_reasoning_content_chat_sse_to_responses_reasoning_events ... ok -test proxy::providers::streaming_codex_chat::tests::converts_text_chat_sse_to_responses_sse ... ok -test proxy::providers::streaming_codex_chat::tests::converts_tool_call_chat_sse_to_responses_sse ... ok -test proxy::providers::streaming_codex_chat::tests::preserves_late_reasoning_content_on_streamed_tool_call_items ... ok -test proxy::providers::streaming_codex_chat::tests::preserves_reasoning_content_on_streamed_tool_call_items ... ok -test proxy::providers::streaming_codex_chat::tests::restores_custom_tool_input_stream_events ... ok -test proxy::providers::streaming_codex_chat::tests::restores_namespace_on_streamed_tool_call_items ... ok -test proxy::providers::streaming_codex_chat::tests::restores_tool_search_on_streamed_tool_call_items ... ok -test proxy::providers::streaming_codex_chat::tests::stream_end_with_output_without_finish_reason_emits_failed_without_completed ... ok -test proxy::providers::streaming_codex_chat::tests::stream_end_without_output_or_finish_reason_emits_failed_without_completed ... ok -test proxy::providers::streaming_codex_chat::tests::stream_error_emits_failed_without_completed ... ok -test proxy::providers::streaming_gemini::tests::converts_crlf_delimited_stream_to_anthropic_sse ... ok -test proxy::providers::streaming_gemini::tests::converts_function_call_stream_to_tool_use_events ... ok -test proxy::providers::streaming_gemini::tests::converts_text_stream_to_anthropic_sse ... ok -test proxy::providers::streaming_gemini::tests::no_id_tool_call_reuses_synthesized_id_across_cumulative_chunks ... ok -test proxy::providers::streaming_gemini::tests::parallel_empty_string_id_calls_are_treated_as_missing_and_preserved ... ok -test proxy::providers::streaming_gemini::tests::parallel_same_name_no_id_calls_preserve_both ... ok -test proxy::providers::streaming_gemini::tests::preserves_utf8_boundaries_when_json_payload_spans_chunks ... ok -test proxy::providers::streaming_gemini::tests::rectifies_streamed_skill_args_from_nested_parameters ... ok -test proxy::providers::streaming_gemini::tests::rectifies_streamed_tool_call_args_from_tool_schema_hints ... ok -test proxy::providers::streaming_gemini::tests::single_empty_string_id_tool_call_gets_synthesized_id ... ok -test proxy::providers::streaming_gemini::tests::stores_full_text_for_shadow_replay_across_delta_chunks ... ok -test proxy::providers::streaming_gemini::tests::stores_tool_shadow_before_tool_use_events_are_fully_drained ... ok -test proxy::providers::streaming_gemini::tests::thought_signature_preserved_when_later_chunk_omits_it ... ok -test proxy::providers::streaming_gemini::tests::upgraded_real_id_merges_into_existing_synthesized_snapshot ... ok -test proxy::providers::streaming_responses::tests::test_map_responses_stop_reason_tool_use ... ok -test proxy::providers::streaming_responses::tests::test_response_object_from_event_with_wrapper ... ok -test proxy::providers::streaming_responses::tests::test_streaming_conversion_interleaved_tool_deltas_by_item_id ... ok -test proxy::providers::streaming_responses::tests::test_streaming_conversion_with_wrapped_response_events ... ok -test proxy::providers::streaming_responses::tests::test_streaming_read_tool_drops_empty_pages ... ok -test proxy::providers::streaming_responses::tests::test_streaming_read_tool_duplicate_start_preserves_buffered_args ... ok -test proxy::providers::streaming_responses::tests::test_streaming_reasoning_delta_emits_thinking_blocks ... ok -test proxy::providers::streaming_responses::tests::test_streaming_responses_chinese_split_across_chunks_no_replacement_chars ... ok -test proxy::providers::streaming_responses::tests::test_streaming_text_parts_are_merged_into_one_text_block ... ok -test proxy::providers::tests::test_from_app_type_claude_auth ... ok -test proxy::providers::tests::test_from_app_type_claude_direct ... ok -test proxy::providers::tests::test_from_app_type_claude_openrouter ... ok -test proxy::providers::tests::test_from_app_type_codex ... ok -test proxy::providers::tests::test_from_app_type_gemini_api_key ... ok -test proxy::providers::tests::test_from_app_type_gemini_cli_json ... ok -test proxy::providers::tests::test_from_app_type_gemini_cli_oauth ... ok -test proxy::providers::tests::test_get_adapter_for_provider_type ... ok -test proxy::providers::tests::test_provider_type_as_str ... ok -test proxy::providers::tests::test_provider_type_default_endpoint ... ok -test proxy::providers::tests::test_provider_type_from_str ... ok -test proxy::providers::tests::test_provider_type_needs_transform ... ok -test proxy::providers::tests::test_provider_type_serde ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_does_not_emit_reasoning_content_by_default ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_does_not_inject_prompt_cache_key ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_keeps_non_leading_billing_header_text ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_non_o_series_keeps_max_tokens ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_o_series_max_completion_tokens ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_preserves_prompt_after_billing_header_in_same_part ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_simple ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_skips_thinking_only_message ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_strips_all_cache_control ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_strips_billing_header_from_system_array_parts ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_strips_cache_control_from_conflicting_system ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_strips_cache_control_from_merged_system ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_strips_cache_control_from_mixed_system ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_strips_leading_billing_header_from_system_string ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_tool_result ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_tool_use ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_tool_use_injects_placeholder_reasoning_content_when_missing ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_tool_use_preserves_reasoning_content ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_tool_use_uses_redacted_thinking_placeholder ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_with_system ... ok -test proxy::providers::transform::tests::test_anthropic_to_openai_with_tools ... ok -test proxy::providers::transform::tests::test_deepseek_reasoning_content_round_trips_for_tool_calls ... ok -test proxy::providers::transform::tests::test_is_openai_o_series ... ok -test proxy::providers::transform::tests::test_model_passthrough ... ok -test proxy::providers::transform::tests::test_no_thinking_field_no_reasoning_effort ... ok -test proxy::providers::transform::tests::test_non_reasoning_model_no_reasoning_effort ... ok -test proxy::providers::transform::tests::test_openai_to_anthropic_clamps_input_when_cache_exceeds_prompt ... ok -test proxy::providers::transform::tests::test_openai_to_anthropic_finish_reason_content_filter_maps_end_turn ... ok -test proxy::providers::transform::tests::test_openai_to_anthropic_preserves_id_for_usage_dedup ... ok -test proxy::providers::transform::tests::test_openai_to_anthropic_simple ... ok -test proxy::providers::transform::tests::test_openai_to_anthropic_with_cache_tokens ... ok -test proxy::providers::transform::tests::test_openai_to_anthropic_with_content_parts_and_refusal ... ok -test proxy::providers::transform::tests::test_openai_to_anthropic_with_direct_cache_fields ... ok -test proxy::providers::transform::tests::test_openai_to_anthropic_with_legacy_function_call ... ok -test proxy::providers::transform::tests::test_openai_to_anthropic_with_tool_calls ... ok -test proxy::providers::transform::tests::test_output_config_high_maps_to_reasoning_effort_high ... ok -test proxy::providers::transform::tests::test_output_config_low_maps_to_reasoning_effort_low ... ok -test proxy::providers::transform::tests::test_output_config_max_maps_to_reasoning_effort_xhigh ... ok -test proxy::providers::transform::tests::test_output_config_medium_maps_to_reasoning_effort_medium ... ok -test proxy::providers::transform::tests::test_output_config_takes_priority_over_thinking ... ok -test proxy::providers::transform::tests::test_output_config_unknown_value_no_reasoning_effort ... ok -test proxy::providers::transform::tests::test_reasoning_model_no_thinking_no_effort ... ok -test proxy::providers::transform::tests::test_reasoning_model_thinking_adaptive ... ok -test proxy::providers::transform::tests::test_reasoning_model_thinking_enabled_small_budget ... ok -test proxy::providers::transform::tests::test_reasoning_model_with_output_config_effort ... ok -test proxy::providers::transform::tests::test_reasoning_model_with_output_config_max ... ok -test proxy::providers::transform::tests::test_regression_gh3805_no_cache_control_leak_to_openai ... ok -test proxy::providers::transform::tests::test_supports_reasoning_effort ... ok -test proxy::providers::transform::tests::test_thinking_adaptive_maps_xhigh ... ok -test proxy::providers::transform::tests::test_thinking_disabled_no_reasoning_effort ... ok -test proxy::providers::transform::tests::test_thinking_enabled_large_budget_maps_high ... ok -test proxy::providers::transform::tests::test_thinking_enabled_medium_budget_maps_medium ... ok -test proxy::providers::transform::tests::test_thinking_enabled_small_budget_maps_low ... ok -test proxy::providers::transform::tests::test_thinking_enabled_without_budget_maps_high ... ok -test proxy::providers::transform::tests::tool_choice_forced_tool_maps_to_nested_function_selector ... ok -test proxy::providers::transform::tests::tool_choice_object_any_maps_to_required ... ok -test proxy::providers::transform::tests::tool_choice_object_auto_and_none_collapse_to_string ... ok -test proxy::providers::transform::tests::tool_choice_string_any_maps_to_required ... ok -test proxy::providers::transform::tests::tool_choice_string_auto_and_none_pass_through ... ok -test proxy::providers::transform_codex_chat::tests::chat_error_to_response_error_falls_back_to_detail_field ... ok -test proxy::providers::transform_codex_chat::tests::chat_error_to_response_error_handles_missing_body ... ok -test proxy::providers::transform_codex_chat::tests::chat_error_to_response_error_handles_plain_text_body ... ok -test proxy::providers::transform_codex_chat::tests::chat_error_to_response_error_normalizes_minimax_base_resp ... ok -test proxy::providers::transform_codex_chat::tests::chat_error_to_response_error_normalizes_standard_openai_shape ... ok -test proxy::providers::transform_codex_chat::tests::chat_response_length_maps_to_incomplete_response ... ok -test proxy::providers::transform_codex_chat::tests::chat_response_reasoning_only_length_keeps_message_slot ... ok -test proxy::providers::transform_codex_chat::tests::chat_response_to_responses_canonicalizes_json_string_tool_arguments ... ok -test proxy::providers::transform_codex_chat::tests::chat_response_to_responses_extracts_reasoning_details ... ok -test proxy::providers::transform_codex_chat::tests::chat_response_to_responses_maps_text_tool_calls_and_usage ... ok -test proxy::providers::transform_codex_chat::tests::chat_response_to_responses_restores_custom_tool_call ... ok -test proxy::providers::transform_codex_chat::tests::chat_response_to_responses_restores_loaded_namespace_tool_call ... ok -test proxy::providers::transform_codex_chat::tests::chat_response_to_responses_restores_tool_search_call ... ok -test proxy::providers::transform_codex_chat::tests::chat_response_to_responses_splits_inline_think_content ... ok -test proxy::providers::transform_codex_chat::tests::collapse_system_messages_preserves_non_system_order ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_does_not_emit_chat_file_for_url_only_input_file ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_maps_input_file_content_parts ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_maps_top_level_input_file_item ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_merges_include_usage_into_existing_stream_options ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_applies_configured_min_output_tokens ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_applies_explicit_default_output_tokens_when_missing ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_attaches_reasoning_to_tool_call_message ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_attaches_trailing_reasoning_to_previous_assistant ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_attaches_trailing_reasoning_to_tool_call_message ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_canonicalizes_json_string_tool_payloads ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_disables_chat_template_enable_thinking_provider ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_does_not_pass_cache_options_for_auto_prefix_cache ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_downgrades_deepseekv4_images_even_with_false_override ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_downgrades_images_for_deepseekv4_aliases ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_downgrades_images_for_glm_5_text_models ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_downgrades_images_for_text_only_models ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_downgrades_images_with_text_only_override ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_drops_explicit_none_for_top_level_effort_provider ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_drops_parallel_tool_calls_when_no_tools ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_drops_responses_only_metadata_and_service_tier ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_drops_tool_choice_when_all_tools_filtered ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_drops_tool_choice_when_no_tools ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_drops_tool_choice_when_tools_empty_array ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_exposes_tool_search_and_loaded_namespace_tools ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_forces_chat_template_thinking_off_when_unsupported ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_injects_placeholder_reasoning_for_bare_tool_call ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_keeps_embedded_assistant_reasoning ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_keeps_explicit_large_output_budget ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_keeps_images_for_glm_5v_vision_models ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_keeps_images_without_text_only_override ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_keeps_missing_output_budget_unbounded_by_minimum ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_keeps_multiple_tool_calls_adjacent_to_outputs ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_keeps_tool_choice_function_when_tools_present ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_keeps_tool_choice_when_tools_present ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_maps_chat_template_enable_thinking_provider ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_maps_custom_tool_and_choice ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_maps_enable_thinking_provider ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_maps_messages_tools_and_limits ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_maps_openrouter_to_native_reasoning_object ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_maps_thinking_only_provider_without_effort ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_merges_mid_stream_system_into_head ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_no_tool_choice_no_tools_stays_clean ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_normalizes_codex_internal_roles ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_passes_explicit_none_through_for_openrouter ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_passes_openai_prompt_cache_options_when_capable ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_passes_reasoning_content_back_to_assistant_message ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_preserves_custom_tool_metadata_in_description ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_raises_small_explicit_budget_to_minimum ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_recovers_reasoning_from_function_call_item ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_sanitizes_malformed_tool_arguments ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_sanitizes_partial_json_tool_arguments ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_tool_choice_none_dropped_when_no_tools ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_tool_search_output_provides_tools_keeps_tool_choice ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_to_chat_uses_provider_reasoning_effort_for_deepseek_model ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_with_stream_injects_include_usage ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_with_temperature_preserves_explicit_temperature ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_without_stream_omits_stream_options ... ok -test proxy::providers::transform_codex_chat::tests::responses_request_without_temperature_does_not_default_temperature ... ok -test proxy::providers::transform_codex_chat::tests::top_level_user_content_part_clears_pending_reasoning ... ok -test proxy::providers::transform_gemini::tests::anthropic_to_gemini_maps_system_and_messages ... ok -test proxy::providers::transform_gemini::tests::anthropic_to_gemini_maps_tools_and_tool_results ... ok -test proxy::providers::transform_gemini::tests::anthropic_to_gemini_merges_system_messages_into_system_instruction ... ok -test proxy::providers::transform_gemini::tests::anthropic_to_gemini_rejects_tool_result_without_resolvable_name ... ok -test proxy::providers::transform_gemini::tests::anthropic_to_gemini_resolves_tool_result_name_from_shadow_content ... ok -test proxy::providers::transform_gemini::tests::anthropic_to_gemini_uses_parameters_json_schema_for_rich_tool_schema ... ok -test proxy::providers::transform_gemini::tests::gemini_to_anthropic_maps_blocked_prompt_to_refusal ... ok -test proxy::providers::transform_gemini::tests::gemini_to_anthropic_maps_function_calls_to_tool_use ... ok -test proxy::providers::transform_gemini::tests::gemini_to_anthropic_maps_text_and_usage ... ok -test proxy::providers::transform_gemini::tests::gemini_to_anthropic_preserves_legitimate_parameters_arg ... ok -test proxy::providers::transform_gemini::tests::gemini_to_anthropic_rectifies_tool_args_from_schema_hints ... ok -test proxy::providers::transform_gemini::tests::gemini_to_anthropic_synthesizes_unique_ids_for_missing_functioncall_ids ... ok -test proxy::providers::transform_gemini::tests::non_stream_missing_id_scenario_a_truncated_history_resolves ... ok -test proxy::providers::transform_gemini::tests::non_stream_missing_id_scenario_b_full_history_replay_resolves ... ok -test proxy::providers::transform_gemini::tests::non_stream_preserves_original_gemini_id_when_present ... ok -test proxy::providers::transform_gemini::tests::non_stream_shadow_id_matches_client_visible_id ... ok -test proxy::providers::transform_gemini::tests::non_stream_synthesized_id_not_leaked_to_gemini_via_shadow_replay ... ok -test proxy::providers::transform_gemini::tests::shadow_replay_aligns_to_latest_turns_after_client_truncation ... ok -test proxy::providers::transform_gemini::tests::shadow_replay_falls_back_to_name_when_ids_absent ... ok -test proxy::providers::transform_gemini::tests::shadow_replay_matches_tool_use_turn_by_id_when_position_drifts ... ok -test proxy::providers::transform_gemini::tests::shadow_replay_prefers_exact_id_match_over_normalized_name_collision ... ok -test proxy::providers::transform_gemini::tests::shadow_replay_strips_synthesized_id_from_function_call ... ok -test proxy::providers::transform_gemini::tests::tool_result_with_genuine_gemini_id_round_trips ... ok -test proxy::providers::transform_gemini::tests::tool_result_with_synthesized_id_omits_id_in_gemini_request ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_codex_oauth_fast_mode_can_be_disabled ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_codex_oauth_preserves_existing_include ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_codex_oauth_sets_store_and_include ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_codex_oauth_strips_max_output_tokens ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_image ... ok -test proxy::providers::codex_oauth_auth::tests::token_request_removes_account_when_refresh_token_is_invalid ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_keeps_non_leading_billing_header_text ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_non_codex_keeps_max_output_tokens ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_non_codex_omits_store_and_include ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_o_series_uses_max_output_tokens ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_preserves_prompt_after_billing_header_in_same_part ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_simple ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_strip_cache_control_on_text ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_strip_cache_control_on_tools ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_strips_billing_header_from_system_array_parts ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_strips_billing_header_with_crlf ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_strips_leading_billing_header_from_system_string ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_tool_choice_any_to_required ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_thinking_discarded ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_tool_choice_tool_to_function ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_tool_result_lifting ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_with_cache_key ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_tool_use_lifting ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_with_cache_retention ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_with_system_array ... ok -test proxy::providers::codex_oauth_auth::tests::token_request_refreshes_expired_default_account_when_token_is_valid ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_with_system_string ... ok -test proxy::providers::transform_responses::tests::test_anthropic_to_responses_with_tools ... ok -test proxy::providers::transform_responses::tests::test_build_usage_anthropic_names_precedence ... ok -test proxy::providers::transform_responses::tests::test_build_usage_cache_tokens_direct_override ... ok -test proxy::providers::transform_responses::tests::test_build_usage_cache_tokens_from_nested_details ... ok -test proxy::providers::transform_responses::tests::test_build_usage_cache_tokens_without_input_output ... ok -test proxy::providers::transform_responses::tests::test_build_usage_clamps_input_when_cache_exceeds_input ... ok -test proxy::providers::transform_responses::tests::test_build_usage_from_empty_object ... ok -test proxy::providers::transform_responses::tests::test_build_usage_from_null_json_value ... ok -test proxy::providers::transform_responses::tests::test_build_usage_from_null_parameter ... ok -test proxy::providers::transform_responses::tests::test_build_usage_from_partial_input_only ... ok -test proxy::providers::transform_responses::tests::test_build_usage_from_partial_output_only ... ok -test proxy::providers::transform_responses::tests::test_build_usage_with_openai_field_names ... ok -test proxy::providers::transform_responses::tests::test_codex_oauth_defaults_required_fields_when_absent ... ok -test proxy::providers::transform_responses::tests::test_codex_oauth_forces_stream_true_even_when_client_sends_false ... ok -test proxy::providers::transform_responses::tests::test_codex_oauth_preserves_existing_instructions_and_tools ... ok -test proxy::providers::transform_responses::tests::test_codex_oauth_strips_temperature ... ok -test proxy::providers::transform_responses::tests::test_model_passthrough ... ok -test proxy::providers::transform_responses::tests::test_codex_oauth_strips_top_p ... ok -test proxy::providers::transform_responses::tests::test_non_codex_does_not_inject_default_required_fields ... ok -test proxy::providers::transform_responses::tests::test_non_codex_keeps_temperature_and_top_p ... ok -test proxy::providers::transform_responses::tests::test_responses_non_reasoning_model_no_reasoning ... ok -test proxy::providers::transform_responses::tests::test_responses_output_config_takes_priority_over_thinking ... ok -test proxy::providers::transform_responses::tests::test_responses_output_config_max_sets_reasoning_xhigh ... ok -test proxy::providers::transform_responses::tests::test_responses_thinking_adaptive_sets_reasoning_xhigh ... ok -test proxy::providers::transform_responses::tests::test_responses_thinking_enabled_large_budget_sets_reasoning_high ... ok -test proxy::providers::transform_responses::tests::test_responses_thinking_enabled_medium_budget_sets_reasoning_medium ... ok -test proxy::providers::transform_responses::tests::test_responses_thinking_enabled_small_budget_sets_reasoning_low ... ok -test proxy::providers::transform_responses::tests::test_responses_to_anthropic_incomplete_non_token_reason ... ok -test proxy::providers::transform_responses::tests::test_responses_to_anthropic_incomplete_status ... ok -test proxy::providers::transform_responses::tests::test_responses_to_anthropic_preserves_empty_strings_for_other_tools ... ok -test proxy::providers::transform_responses::tests::test_responses_to_anthropic_read_drops_empty_pages ... ok -test proxy::providers::transform_responses::tests::test_responses_to_anthropic_simple ... ok -test proxy::providers::transform_responses::tests::test_responses_to_anthropic_with_cache_tokens ... ok -test proxy::providers::transform_responses::tests::test_responses_to_anthropic_with_direct_cache_fields ... ok -test proxy::providers::transform_responses::tests::test_responses_to_anthropic_with_function_call ... ok -test proxy::providers::transform_responses::tests::test_responses_to_anthropic_with_reasoning ... ok -test proxy::providers::transform_responses::tests::test_responses_to_anthropic_with_refusal_block ... ok -test proxy::response_handler::tests::test_response_type_detection ... ok -test proxy::response_handler::tests::test_stream_handler_creation ... ok -test proxy::response_handler::tests::test_strip_sse_field_accepts_optional_space ... ok -test proxy::response_processor::tests::test_log_usage_falls_back_to_global_defaults ... ok -test proxy::response_processor::tests::test_claude_desktop_inherits_claude_global_defaults ... ok -test proxy::response_processor::tests::test_strip_hop_by_hop_response_headers_removes_connection_listed_extensions ... ok -test proxy::response_processor::tests::test_strip_hop_by_hop_response_headers_removes_standard_headers ... ok -test proxy::response_processor::tests::test_log_usage_uses_provider_override_config ... ok -test proxy::response_processor::tests::test_strip_sse_field_accepts_optional_space ... ok -test proxy::server::tests::bind_error_for_addr_in_use_includes_actionable_port_diagnostic ... ok -test proxy::response_processor::tests::test_request_pricing_mode_anchors_to_outbound_model ... ok -test proxy::server::tests::external_only_v1_models_never_serves_codex_catalog_by_user_agent ... ok -test proxy::providers::codex_oauth_auth::tests::token_request_reloads_rotated_refresh_token_from_disk_before_refresh ... ok -test proxy::server::tests::v1_models_for_codex_client_returns_catalog_and_openai_data ... ok -test proxy::server::tests::v1_models_requires_external_api_key_for_non_codex_clients ... ok -test proxy::server::tests::v1_models_returns_profile_backend_models_with_valid_key ... ok -test proxy::server::tests::v1_chat_completions_forwards_to_profile_backend ... ok -test proxy::server::tests::v1_responses_requires_external_api_key_for_non_codex_clients ... ok -test proxy::server::tests::v1_responses_websocket_probe_returns_http_426 ... ok -test proxy::session::tests::test_client_format_as_str ... ok -test proxy::session::tests::test_client_format_from_body_claude ... ok -test proxy::session::tests::test_client_format_from_body_codex ... ok -test proxy::session::tests::test_client_format_from_body_gemini ... ok -test proxy::session::tests::test_client_format_from_path_claude ... ok -test proxy::session::tests::test_client_format_from_path_codex ... ok -test proxy::session::tests::test_client_format_from_path_gemini ... ok -test proxy::session::tests::test_client_format_from_path_gemini_cli ... ok -test proxy::session::tests::test_client_format_from_path_openai ... ok -test proxy::session::tests::test_codex_official_session_id_header_is_preserved ... ok -test proxy::session::tests::test_codex_previous_response_id_is_not_stable_session_identity ... ok -test proxy::session::tests::test_codex_window_id_header_extracts_thread_identity ... ok -test proxy::session::tests::test_extract_session_from_claude_header ... ok -test proxy::session::tests::test_extract_session_from_claude_header_precedes_metadata ... ok -test proxy::session::tests::test_extract_session_from_claude_metadata_session_id ... ok -test proxy::session::tests::test_extract_session_from_claude_metadata_user_id ... ok -test proxy::session::tests::test_extract_session_generates_new_when_not_found ... ok -test proxy::session::tests::test_parse_session_from_user_id ... ok -test proxy::session::tests::test_session_from_request ... ok -test proxy::session::tests::test_session_id_uniqueness ... ok -test proxy::session::tests::test_session_with_provider ... ok -test proxy::sse::tests::ascii_passthrough ... ok -test proxy::sse::tests::complete_multibyte_in_single_chunk ... ok -test proxy::sse::tests::defensive_guard_flushes_oversized_remainder ... ok -test proxy::sse::tests::empty_chunks_are_harmless ... ok -test proxy::sse::tests::invalid_byte_in_slow_path_flushed_immediately ... ok -test proxy::sse::tests::invalid_bytes_flushed_immediately_not_accumulated ... ok -test proxy::sse::tests::mixed_ascii_and_split_multibyte ... ok -test proxy::sse::tests::multiple_split_characters_in_sequence ... ok -test proxy::sse::tests::split_four_byte_char_across_chunks ... ok -test proxy::sse::tests::split_multibyte_across_two_chunks ... ok -test proxy::sse::tests::sse_json_with_chinese_split_at_boundary ... ok -test proxy::sse::tests::strip_sse_field_accepts_optional_space ... ok -test proxy::sse::tests::take_sse_block_supports_crlf_delimiters ... ok -test proxy::sse::tests::take_sse_block_supports_lf_delimiters ... ok -test proxy::thinking_budget_rectifier::tests::test_detect_budget_tokens_1024_error ... ok -test proxy::thinking_budget_rectifier::tests::test_detect_budget_tokens_max_tokens_error ... ok -test proxy::thinking_budget_rectifier::tests::test_detect_budget_tokens_thinking_error ... ok -test proxy::thinking_budget_rectifier::tests::test_detect_budget_tokens_with_thinking_and_1024_error ... ok -test proxy::thinking_budget_rectifier::tests::test_disabled_budget_config ... ok -test proxy::thinking_budget_rectifier::tests::test_master_disabled ... ok -test proxy::thinking_budget_rectifier::tests::test_no_trigger_for_unrelated_error ... ok -test proxy::thinking_budget_rectifier::tests::test_rectify_budget_basic ... ok -test proxy::thinking_budget_rectifier::tests::test_rectify_budget_creates_thinking_object_when_missing ... ok -test proxy::thinking_budget_rectifier::tests::test_rectify_budget_no_change_when_already_valid ... ok -test proxy::thinking_budget_rectifier::tests::test_rectify_budget_no_max_tokens ... ok -test proxy::thinking_budget_rectifier::tests::test_rectify_budget_normalizes_non_enabled_type ... ok -test proxy::thinking_budget_rectifier::tests::test_rectify_budget_preserves_large_max_tokens ... ok -test proxy::server::tests::v1_chat_completions_stream_forwards_sse_chunks ... ok -test proxy::server::tests::v1_chat_completions_preserves_chinese_for_profile_backend ... ok -test proxy::thinking_budget_rectifier::tests::test_rectify_budget_skips_adaptive ... ok -test proxy::thinking_optimizer::tests::test_adaptive_dedup_beta ... ok -test proxy::thinking_optimizer::tests::test_adaptive_opus_4_6 ... ok -test proxy::thinking_optimizer::tests::test_adaptive_opus_4_8 ... ok -test proxy::thinking_optimizer::tests::test_adaptive_sonnet_4_6 ... ok -test proxy::thinking_optimizer::tests::test_append_beta_null_field ... ok -test proxy::thinking_optimizer::tests::test_legacy_budget_too_small_upgraded ... ok -test proxy::thinking_optimizer::tests::test_legacy_disabled_thinking_injected ... ok -test proxy::thinking_optimizer::tests::test_legacy_default_max_tokens ... ok -test proxy::thinking_optimizer::tests::test_legacy_sonnet_4_5_thinking_null ... ok -test proxy::thinking_optimizer::tests::test_skip_haiku ... ok -test proxy::thinking_optimizer::tests::test_thinking_optimizer_disabled ... ok -test proxy::thinking_rectifier::tests::test_detect_invalid_request ... ok -test proxy::thinking_rectifier::tests::test_detect_invalid_signature ... ok -test proxy::thinking_rectifier::tests::test_detect_invalid_signature_nested_json ... ok -test proxy::thinking_rectifier::tests::test_detect_invalid_signature_no_backticks ... ok -test proxy::thinking_rectifier::tests::test_detect_invalid_thought_signature_nested_json ... ok -test proxy::thinking_rectifier::tests::test_detect_invalid_thought_signature_message ... ok -test proxy::thinking_rectifier::tests::test_detect_must_start_with_thinking ... ok -test proxy::thinking_rectifier::tests::test_detect_signature_extra_inputs ... ok -test proxy::thinking_rectifier::tests::test_detect_signature_field_required ... ok -test proxy::thinking_rectifier::tests::test_detect_thinking_cannot_be_modified ... ok -test proxy::thinking_rectifier::tests::test_detect_thinking_expected ... ok -test proxy::thinking_rectifier::tests::test_disabled_config ... ok -test proxy::thinking_rectifier::tests::test_do_not_detect_thinking_type_tag_mismatch ... ok -test proxy::thinking_rectifier::tests::test_master_disabled ... ok -test proxy::thinking_rectifier::tests::test_no_trigger_for_unrelated_error ... ok -test proxy::thinking_rectifier::tests::test_normalize_thinking_type_adaptive_unchanged ... ok -test proxy::thinking_rectifier::tests::test_no_detect_thinking_expected_without_tool_use ... ok -test proxy::thinking_rectifier::tests::test_normalize_thinking_type_disabled_unchanged ... ok -test proxy::thinking_rectifier::tests::test_normalize_thinking_type_enabled_unchanged ... ok -test proxy::thinking_rectifier::tests::test_normalize_thinking_type_no_thinking ... ok -test proxy::thinking_rectifier::tests::test_normalize_thinking_type_preserves_budget ... ok -test proxy::thinking_rectifier::tests::test_normalize_thinking_type_unknown_unchanged ... ok -test proxy::thinking_rectifier::tests::test_rectify_adaptive_preserves_existing_budget_tokens ... ok -test proxy::thinking_rectifier::tests::test_rectify_does_not_change_enabled_type ... ok -test proxy::thinking_rectifier::tests::test_rectify_adaptive_still_cleans_legacy_signature_blocks ... ok -test proxy::thinking_rectifier::tests::test_rectify_keeps_adaptive_when_no_legacy_blocks ... ok -test proxy::thinking_rectifier::tests::test_rectify_no_change_when_no_issues ... ok -test proxy::thinking_rectifier::tests::test_rectify_no_messages ... ok -test proxy::thinking_rectifier::tests::test_rectify_preserves_thinking_when_prefix_exists ... ok -test proxy::thinking_rectifier::tests::test_rectify_removes_thinking_blocks ... ok -test proxy::thinking_rectifier::tests::test_rectify_removes_top_level_thinking ... ok -test proxy::timeout_policy::tests::configured_timeouts_override_transport_header_safety_cap ... ok -test proxy::thinking_rectifier::tests::test_rectify_removes_top_level_thinking_adaptive ... ok -test proxy::timeout_policy::tests::zero_disables_failover_timeout_but_not_transport_safety ... ok -test proxy::types::tests::test_log_config_default ... ok -test proxy::types::tests::test_log_config_serde_default ... ok -test proxy::types::tests::test_log_config_serde_roundtrip ... ok -test proxy::types::tests::test_log_config_to_level_filter ... ok -test proxy::types::tests::test_rectifier_config_default_enabled ... ok -test proxy::types::tests::test_rectifier_config_serde_default ... ok -test proxy::types::tests::test_rectifier_config_serde_explicit_true ... ok -test proxy::types::tests::test_rectifier_config_serde_media_explicit_false ... ok -test proxy::types::tests::test_rectifier_config_serde_partial_fields ... ok -test proxy::usage::calculator::tests::test_cost_calculation ... ok -test proxy::usage::calculator::tests::test_cost_calculation_for_cache_inclusive_app ... ok -test proxy::usage::calculator::tests::test_cost_multiplier ... ok -test proxy::usage::calculator::tests::test_unknown_model_handling ... ok -test proxy::usage::calculator::tests::test_decimal_precision ... ok -test proxy::usage::parser::tests::test_claude_response_parsing ... ok -test proxy::usage::parser::tests::test_claude_response_parsing_no_model ... ok -test proxy::usage::parser::tests::test_claude_stream_cache_only_request_is_recorded ... ok -test proxy::usage::parser::tests::test_claude_stream_keeps_start_when_delta_input_is_larger ... ok -test proxy::usage::parser::tests::test_claude_stream_parsing ... ok -test proxy::usage::parser::tests::test_claude_stream_parsing_no_model ... ok -test proxy::usage::parser::tests::test_claude_stream_prefers_smaller_delta_input_and_cache_pair ... ok -test proxy::usage::parser::tests::test_claude_stream_updates_cache_pair_from_later_delta_input ... ok -test proxy::usage::parser::tests::test_codex_response_adjusted ... ok -test proxy::usage::parser::tests::test_codex_response_adjusted_cache_read_input_tokens ... ok -test proxy::usage::parser::tests::test_codex_response_adjusted_no_cache ... ok -test proxy::usage::parser::tests::test_codex_response_adjusted_saturating_sub ... ok -test proxy::usage::parser::tests::test_codex_response_auto_codex_format ... ok -test proxy::usage::parser::tests::test_codex_response_auto_openai_format ... ok -test proxy::usage::parser::tests::test_codex_response_auto_returns_some_for_synthetic_all_zero ... ok -test proxy::usage::parser::tests::test_codex_response_parsing_cached_tokens_in_details ... ok -test proxy::usage::parser::tests::test_codex_stream_events_auto_codex_format ... ok -test proxy::usage::parser::tests::test_codex_stream_events_auto_openai_format ... ok -test proxy::usage::parser::tests::test_gemini_response_parsing ... ok -test proxy::usage::parser::tests::test_gemini_response_parsing_no_model ... ok -test proxy::usage::parser::tests::test_gemini_response_with_thoughts ... ok -test proxy::usage::parser::tests::test_has_billable_tokens_gates_empty_usage ... ok -test proxy::usage::parser::tests::test_native_claude_stream_parsing ... ok -test proxy::usage::parser::tests::test_openai_response_parses_deepseek_context_cache_fields ... ok -test proxy::usage::parser::tests::test_openai_response_parses_qwen_cache_creation_details ... ok -test proxy::usage::parser::tests::test_openrouter_response_parsing ... ok -test proxy::usage::parser::tests::test_openrouter_stream_parsing ... ok -test sensitive_deeplink_boundary_tests::deep_link_log_redaction_keeps_keys_but_never_secret_values ... ok -test sensitive_deeplink_boundary_tests::malformed_deep_link_redaction_drops_query_values ... ok -test services::auto_sync_common::tests::config_tables_share_one_trigger_policy ... ok -test services::auto_sync_common::tests::full_queue_is_coalescing_but_closed_queue_is_worker_failure ... ok -test services::auto_sync_common::tests::max_wait_caps_flush_latency_for_continuous_events ... ok -test services::codex_oauth_models::tests::parse_codex_oauth_models_accepts_model_list_shape ... ok -test services::codex_oauth_models::tests::parse_codex_oauth_models_accepts_model_map_shape ... ok -test services::codex_oauth_models::tests::parse_codex_oauth_models_accepts_openai_style_data ... ok -test services::codex_oauth_models::tests::parse_codex_oauth_models_deduplicates_ids ... ok -test services::codex_oauth_models::tests::parse_codex_oauth_models_extracts_context_window ... ok -test services::coding_plan::tests::minimax_general_two_tiers_from_remaining_percent ... ok -test services::coding_plan::tests::minimax_missing_general_returns_empty ... ok -test services::coding_plan::tests::minimax_missing_percent_fields_skips_tier ... ok -test services::coding_plan::tests::minimax_negative_percent_passes_through ... ok -test services::coding_plan::tests::minimax_skips_video_and_finds_general_in_any_position ... ok -test services::coding_plan::tests::minimax_weekly_status_2_also_skips_weekly_tier ... ok -test services::coding_plan::tests::minimax_weekly_status_3_skips_weekly_tier ... ok -test services::coding_plan::tests::volcengine_afp_partial_windows_only_subscribed_ones ... ok -test services::coding_plan::tests::volcengine_afp_three_windows_from_official_example ... ok -test services::coding_plan::tests::volcengine_afp_zero_quota_windows_treated_as_unbound ... ok -test services::coding_plan::tests::volcengine_auth_error_code_detection_and_extraction ... ok -test services::coding_plan::tests::volcengine_canonical_query_is_sorted_and_encoded ... ok -test services::coding_plan::tests::volcengine_coding_plan_real_response_levels ... ok -test services::coding_plan::tests::volcengine_coding_plan_unknown_window_skipped_and_missing_array_empty ... ok -test services::coding_plan::tests::volcengine_region_derivation ... ok -test services::coding_plan::tests::volcengine_sign_structure_and_determinism ... ok -test services::coding_plan::tests::zhipu_duplicate_unit_classification_fills_other_slot ... ok -test services::coding_plan::tests::zhipu_extreme_percentage_values_pass_through ... ok -test services::coding_plan::tests::zhipu_invalid_percentage_falls_back_to_zero ... ok -test services::coding_plan::tests::zhipu_missing_reset_time_is_five_hour_when_weekly_has_reset ... ok -test services::coding_plan::tests::zhipu_more_than_two_token_limits_keeps_first_two ... ok -test services::coding_plan::tests::zhipu_new_plan_two_tiers_sorted_by_reset_time ... ok -test services::coding_plan::tests::zhipu_no_token_limits_returns_empty ... ok -test services::coding_plan::tests::zhipu_old_plan_single_tier_falls_back_to_five_hour ... ok -test services::coding_plan::tests::zhipu_partial_unit_fields_fill_remaining_slot ... ok -test services::coding_plan::tests::zhipu_quota_base_defaults_to_en_for_unknown_url ... ok -test services::coding_plan::tests::zhipu_quota_base_routes_bigmodel_url_to_cn_endpoint ... ok -test services::coding_plan::tests::zhipu_quota_base_routes_uppercase_cn_url_to_cn_endpoint ... ok -test services::coding_plan::tests::zhipu_quota_base_routes_z_ai_url_to_en_endpoint ... ok -test services::coding_plan::tests::zhipu_type_is_case_insensitive ... ok -test services::coding_plan::tests::zhipu_unit_field_overrides_reset_order_when_weekly_resets_sooner ... ok -test services::coding_plan::tests::zhipu_unknown_unit_values_fall_back_to_reset_order ... ok -test services::coding_plan::tests::zhipu_weekly_unit_six_number_one_variant ... ok -test services::env_checker::tests::test_get_keywords ... ok -test proxy::usage::logger::tests::test_log_error ... ok -test services::env_manager::tests::test_backup_dir_creation ... ok -test services::model_fetch::tests::test_apply_missing_context_windows_preserves_explicit_model_metadata ... ok -test services::model_fetch::tests::test_candidates_bailian_strip_apps_anthropic ... ok -test services::model_fetch::tests::test_candidates_deduplicate ... ok -test services::model_fetch::tests::test_candidates_deepseek_strip_anthropic ... ok -test services::model_fetch::tests::test_candidates_doubao_strip_api_coding ... ok -test services::model_fetch::tests::test_candidates_empty ... ok -test services::model_fetch::tests::test_candidates_full_url ... ok -test services::model_fetch::tests::test_candidates_longer_suffix_wins ... ok -test proxy::usage::logger::tests::test_log_request ... ok -test services::model_fetch::tests::test_candidates_no_suffix_no_strip ... ok -test services::model_fetch::tests::test_candidates_override_returns_single ... ok -test services::model_fetch::tests::test_candidates_override_empty_falls_through ... ok -test services::model_fetch::tests::test_candidates_plain_root ... ok -test services::model_fetch::tests::test_candidates_rightcode_strip_claude ... ok -test services::model_fetch::tests::test_candidates_stepfun_strip_step_plan ... ok -test services::model_fetch::tests::test_candidates_trailing_slash ... ok -test services::model_fetch::tests::test_candidates_with_v1 ... ok -test services::model_fetch::tests::test_candidates_zai_coding_paas_v4 ... ok -test services::model_fetch::tests::test_candidates_zhipu_coding_paas_v4 ... ok -test services::model_fetch::tests::test_ends_with_version_segment ... ok -test services::model_fetch::tests::test_candidates_zhipu_strip_api_anthropic ... ok -test services::model_fetch::tests::test_find_models_dev_provider_models_uses_api_prefix ... ok -test services::model_fetch::tests::test_models_dev_endpoint_matches_provider_api ... ok -test services::model_fetch::tests::test_lookup_models_dev_context_rejects_ambiguous_suffix_ids ... ok -test services::model_fetch::tests::test_lookup_models_dev_context_matches_exact_and_suffix_ids ... ok -test services::model_fetch::tests::test_normalize_volcengine_model_list_action_accepts_only_plan_actions ... ok -test services::model_fetch::tests::test_parse_response ... ok -test services::model_fetch::tests::test_parse_response_empty_data ... ok -test services::model_fetch::tests::test_parse_response_no_owned_by ... ok -test services::model_fetch::tests::test_parse_response_extracts_context_window ... ok -test services::model_fetch::tests::test_parse_volcengine_plan_models_accepts_string_entries_and_deduplicates ... ok -test services::model_fetch::tests::test_parse_zhipu_detail_context ... ok -test services::model_fetch::tests::test_parse_volcengine_plan_models_from_official_agentplan_shape ... ok -test services::model_fetch::tests::test_parse_zhipu_detail_context_ignores_other_cards ... ok -test services::model_fetch::tests::test_parse_zhipu_model_overview_contexts ... ok -test services::model_fetch::tests::test_parse_zhipu_model_overview_contexts_skips_unparseable_rows ... ok -test services::model_fetch::tests::test_zhipu_endpoint_and_glm_model_detection_boundaries ... ok -test services::model_fetch::tests::test_zhipu_model_id_normalization_and_slug ... ok -test services::omo::tests::test_build_config_empty ... ok -test services::omo::tests::test_build_config_ignores_non_object_other_fields ... ok -test services::omo::tests::test_build_config_slim_excludes_categories ... ok -test services::omo::tests::test_build_config_with_profile ... ok -test services::omo::tests::test_build_local_file_data_keeps_all_non_agent_category_fields_in_other ... ok -test services::omo::tests::test_strip_jsonc_comments ... ok -test services::omo::tests::test_find_existing_config_falls_back_to_old_name ... ok -test services::provider::live::tests::claude_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields ... ok -test services::omo::tests::test_find_existing_config_prefers_new_name_over_old ... ok -test services::provider::live::tests::claude_common_config_array_subset_detection_and_strip_preserve_extra_items ... ok -test services::provider::live::tests::codex_common_config_array_subset_detection_and_strip_preserve_extra_items ... ok -test services::provider::live::tests::codex_common_config_apply_and_remove_roundtrip_for_non_overlapping_fields ... ok -test services::provider::live::tests::codex_common_config_does_not_import_provider_owned_router_fields ... ok -test services::provider::live::tests::codex_common_config_provider_only_snippet_is_not_detected_or_removed ... ok -test services::provider::live::tests::codex_live_projection_keeps_legacy_catalog_without_new_flag ... ok -test services::provider::live::tests::codex_live_projection_removes_catalog_when_menu_mapping_is_disabled ... ok -test services::provider::live::tests::codex_live_snapshot_restore_empty_auth_deletes_auth_without_live_oauth_login ... ok -test services::provider::live::tests::codex_switch_backfill_keeps_live_catalog_when_db_has_none ... ok -test services::provider::live::tests::codex_switch_backfill_preserves_stored_codex_routing_when_live_lacks_it ... ok -test services::provider::live::tests::codex_switch_backfill_preserves_stored_model_catalog_when_live_lacks_it ... ok -test services::provider::live::tests::explicit_common_config_flag_overrides_legacy_subset_detection ... ok -test services::provider::live::tests::codex_live_snapshot_restore_empty_auth_preserves_live_oauth_login ... ok -test services::provider::live::tests::codex_live_snapshot_restore_stale_oauth_preserves_live_oauth_login ... ok -test services::provider::tests::add_clears_usage_credentials_that_match_provider_config ... ok -test proxy::server::tests::v1_responses_converts_to_chat_only_backend ... ok -test services::provider::tests::add_does_not_clear_token_plan_credentials ... ok -test services::provider::tests::extract_codex_common_config_preserves_mcp_servers_base_url ... ok -test services::provider::tests::extract_credentials_returns_expected_values ... ok -test services::provider::tests::add_preserves_distinct_usage_credentials ... ok -test services::provider::tests::copied_provider_uses_edited_credentials_after_add_clears_mirrored_usage_credentials ... ok -test services::provider::tests::db_only_additive_update_survives_live_config_parse_errors ... ok -test services::provider::tests::import_openclaw_providers_from_live_marks_provider_as_live_managed ... ok -test services::provider::tests::import_opencode_providers_from_live_marks_provider_as_live_managed ... ok -test services::provider::tests::legacy_additive_provider_still_errors_on_live_config_parse_failure ... ok -test services::provider::tests::rename_rejects_missing_original_provider ... ok -test services::provider::tests::switching_codex_chat_provider_auto_enables_local_proxy_takeover ... ok -test services::provider::tests::switching_codex_chat_provider_from_sync_command_has_tokio_reactor ... ok -test services::provider::tests::switching_codex_router_provider_auto_enables_dedicated_local_takeover ... ok -test services::provider::tests::sync_current_provider_for_app_preserves_legacy_live_opencode_provider ... ok -test services::provider::tests::sync_current_provider_for_app_restores_legacy_openclaw_provider_after_live_reset ... ok -test services::provider::tests::sync_current_provider_for_app_restores_legacy_opencode_provider_after_live_reset ... ok -test services::provider::tests::sync_current_provider_for_app_skips_db_only_openclaw_provider ... ok -test services::provider::tests::sync_current_provider_for_app_skips_db_only_opencode_provider ... ok -test services::provider::tests::update_clears_usage_credentials_that_match_current_config ... ok -test services::provider::tests::update_current_claude_provider_syncs_live_when_proxy_takeover_detected_without_backup ... ok -test services::provider::tests::update_current_omo_variant_does_not_persist_database_when_file_write_fails ... ok -test services::provider::tests::update_current_omo_variant_rewrites_config_from_saved_provider ... ok -test services::provider::tests::validate_provider_settings_rejects_missing_auth ... ok -test services::provider::tests::validate_provider_settings_rejects_negative_cost_multiplier ... ok -test services::provider::usage::tests::codex_fallback_reads_auth_and_config_toml ... ok -test services::provider::usage::tests::empty_script_values_fall_back_to_provider_credentials ... ok -test services::provider::usage::tests::script_values_override_provider_credentials ... ok -test services::proxy::tests::apply_codex_proxy_toml_config_keeps_upstream_model_for_chat_provider ... ok -test services::proxy::tests::apply_codex_proxy_toml_config_preserves_model_for_responses_provider ... ok -test services::proxy::tests::apply_codex_proxy_toml_config_restores_upstream_model_for_responses_provider ... ok -test services::proxy::tests::apply_codex_proxy_toml_config_uses_custom_local_proxy_provider ... ok -test services::provider::tests::update_current_omo_variant_rolls_back_file_when_plugin_sync_fails ... ok -test services::provider::tests::update_persists_non_current_omo_variants_in_database ... ok -test services::proxy::tests::codex_base_url_matching_checks_openai_base_url_for_builtin_openai ... ok -test services::provider::tests::update_preserves_usage_credentials_that_only_match_previous_config ... ok -test services::proxy::tests::backup_skips_when_live_is_already_proxy_placeholder ... ok -test services::proxy::tests::bulk_backup_skips_all_when_live_is_proxy_placeholder ... ok -test services::proxy::tests::codex_custom_provider_live_write_preserves_oauth_auth_even_when_preserve_disabled ... ok -test services::proxy::tests::codex_custom_provider_live_write_preserves_oauth_auth_json ... ok -test services::proxy::tests::codex_restore_empty_auth_backup_preserves_current_live_oauth_login ... ok -test services::proxy::tests::codex_restore_empty_auth_backup_still_projects_inline_catalog ... ok -test services::proxy::tests::codex_restore_from_backup_preserves_live_desktop_settings ... ok -test services::proxy::tests::codex_restore_from_backup_preserves_model_catalog_pointer ... ok -test services::proxy::tests::codex_restore_from_backup_projects_inline_model_catalog ... ok -test services::proxy::tests::codex_restore_stale_oauth_backup_preserves_current_live_oauth_login ... ok -test services::proxy::tests::codex_set_takeover_for_app_preserves_oauth_auth_json_when_preserve_enabled ... ok -test services::proxy::tests::codex_set_takeover_rebuilds_stale_enabled_state_without_overwriting_backup ... ok -test services::proxy::tests::codex_switch_to_official_during_takeover_exits_proxy_and_cleans_router_fields ... ok -test services::proxy::tests::codex_sync_current_to_live_during_takeover_activation_keeps_proxy_live_config ... ok -test services::proxy::tests::codex_sync_current_to_live_during_takeover_preserves_oauth_auth_json ... ok -test services::proxy::tests::codex_takeover_backup_preserves_oauth_auth_even_when_setting_disabled ... ok -test services::proxy::tests::codex_takeover_cleanup_removes_config_placeholder_without_touching_oauth_auth ... ok -test services::proxy::tests::codex_takeover_preserves_oauth_auth_json_even_when_provider_category_is_official ... ok -test services::proxy::tests::codex_takeover_preserves_oauth_auth_json_even_when_setting_disabled ... ok -test services::proxy::tests::codex_takeover_preserves_oauth_auth_json_when_preserve_enabled ... ok -test services::proxy::tests::hot_switch_codex_chat_provider_updates_live_provider_display ... ok -test services::proxy::tests::managed_account_claude_takeover_codex_by_base_url_keeps_auth_token ... ok -test services::proxy::tests::managed_account_claude_takeover_codex_injects_auth_token_without_preexisting_key ... ok -test services::proxy::tests::managed_account_claude_takeover_copilot_removes_stale_auth_token ... ok -test services::proxy::tests::managed_account_claude_takeover_sources_codex_models_from_provider ... ok -test services::proxy::tests::managed_account_claude_takeover_sources_copilot_models_from_provider ... ok -test services::proxy::tests::managed_account_claude_takeover_uses_api_key_placeholder ... ok -test services::proxy::tests::merge_codex_user_config_preserves_context_window_until_provider_overrides ... ok -test services::proxy::tests::normal_claude_takeover_without_token_keeps_auth_token_fallback ... ok -test services::proxy::tests::hot_switch_codex_provider_preserves_provider_model_provider_in_backup_and_restore ... ok -test services::proxy::tests::hot_switch_provider_serializes_same_app_switches ... ok -test services::proxy::tests::remove_local_toml_base_url_cleans_openai_base_url ... ok -test services::proxy::tests::hot_switch_provider_updates_claude_live_while_preserving_takeover_fields ... ok -test services::proxy::tests::provider_switch_with_restored_codex_backup_propagates_catalog_write_errors ... ok -test services::proxy::tests::provider_switch_with_restored_codex_backup_refreshes_catalog_and_common_config ... ok -test services::proxy::tests::restore_falls_through_to_ssot_when_backup_is_proxy_placeholder ... ok -test services::proxy::tests::restore_waits_for_hot_switch_and_restores_latest_backup ... ok -test services::proxy::tests::start_with_takeover_ephemeral_port_writes_actual_live_url ... ok -test services::proxy::tests::switch_proxy_target_updates_live_backup_when_taken_over ... ok -test services::proxy::tests::switching_codex_deepseek_from_sync_command_keeps_catalog_and_proxy_mapping ... ok -test services::proxy::tests::sync_claude_token_does_not_add_anthropic_api_key ... ok -test services::proxy::tests::sync_claude_token_respects_existing_api_key_field ... ok -test services::proxy::tests::update_live_backup_from_provider_applies_claude_common_config ... ok -test services::proxy::tests::update_toml_base_url_falls_back_to_top_level_base_url ... ok -test services::proxy::tests::update_toml_base_url_updates_active_model_provider_base_url ... ok -test services::proxy::tests::update_toml_base_url_uses_openai_base_url_for_builtin_openai ... ok -test services::s3::integration_tests::live_s3_connection ... ignored -test services::s3::integration_tests::live_s3_put_get_head_roundtrip ... ignored -test services::s3::tests::build_bucket_url_aws ... ok -test services::s3::tests::build_bucket_url_custom_endpoint ... ok -test services::s3::tests::build_bucket_url_preserves_http_scheme ... ok -test services::s3::tests::build_object_url_bare_endpoint_defaults_to_https ... ok -test services::s3::tests::build_object_url_endpoint_with_scheme_prefix ... ok -test services::s3::tests::build_object_url_endpoint_with_trailing_slash ... ok -test services::s3::tests::build_object_url_path_style_custom_endpoint ... ok -test services::s3::tests::build_object_url_preserves_http_scheme ... ok -test services::s3::tests::build_object_url_preserves_https_scheme ... ok -test services::s3::tests::build_object_url_strips_leading_slash_from_key ... ok -test services::s3::tests::build_object_url_virtual_hosted_explicit_aws_endpoint ... ok -test services::s3::tests::build_object_url_virtual_hosted_style_aws ... ok -test services::s3::tests::ensure_content_length_within_limit_accepts_within_bounds ... ok -test services::s3::tests::ensure_content_length_within_limit_rejects_oversized ... ok -test services::s3::tests::hmac_sha256_rfc2104_test_vector ... ok -test services::s3::tests::is_aws_endpoint_detection ... ok -test services::s3::tests::redact_url_preserves_path ... ok -test services::s3::tests::redact_url_strips_query_params ... ok -test services::s3::tests::sha256_hex_empty_body ... ok -test services::s3::tests::sha256_hex_known_value ... ok -test services::s3::tests::sig_v4_includes_content_type_when_present ... ok -test services::s3::tests::sig_v4_signing_against_aws_test_vector ... ok -test services::s3::tests::sig_v4_signing_key_derivation ... ok -test services::s3::tests::uri_encode_encodes_spaces_and_special_chars ... ok -test services::s3::tests::uri_encode_preserves_unreserved_chars ... ok -test services::s3::tests::uri_encode_slash_handling ... ok -test services::s3_auto_sync::tests::service_layer_does_not_depend_on_commands_layer ... ok -test services::s3_auto_sync::tests::should_run_auto_sync_requires_enabled_and_auto_sync_flag ... ok -test services::s3_auto_sync::tests::suppression_guard_enables_and_restores_state ... ok -test services::s3_sync::tests::creds_for_maps_all_fields ... ok -test services::s3_sync::tests::s3_key_matches_expected_pattern ... ok -test services::s3_sync::tests::s3_key_uses_v2_and_correct_format ... ok -test services::s3_sync::tests::s3_key_with_custom_profile ... ok -test services::s3_sync::tests::sync_mutex_is_singleton ... ok -test services::proxy::tests::update_live_backup_from_provider_applies_codex_common_config ... ok -test services::session_usage::tests::test_collect_jsonl_files_includes_subagents ... ok -test services::session_usage::tests::test_dedup_by_message_id ... ok -test services::session_usage::tests::test_collect_jsonl_files_includes_workflow_subagents ... ok -test services::session_usage::tests::test_parse_usage_from_jsonl_line ... ok -test services::session_usage::tests::test_insert_claude_session_skips_matching_proxy_log ... ok -test services::session_usage_codex::tests::test_cached_clamped_to_input ... ok -test services::session_usage_codex::tests::test_codex_rollout_thread_id_from_path_extracts_suffix ... ok -test services::session_usage_codex::tests::test_collect_codex_session_files_nonexistent ... ok -test services::session_usage_codex::tests::test_delta_first_event ... ok -test services::session_usage_codex::tests::test_delta_saturating_sub ... ok -test services::session_usage_codex::tests::test_delta_subsequent_event ... ok -test services::session_usage_codex::tests::test_delta_zero_at_task_boundary ... ok -test services::proxy::tests::update_live_backup_from_provider_keeps_new_codex_mcp_entries_on_conflict ... ok -test services::session_usage_codex::tests::test_normalize_codex_model_combined ... ok -test services::session_usage_codex::tests::test_normalize_codex_model_lowercase ... ok -test services::session_usage_codex::tests::test_normalize_codex_model_no_change ... ok -test services::session_usage_codex::tests::test_normalize_codex_model_strip_compact_date ... ok -test services::session_usage_codex::tests::test_normalize_codex_model_strip_iso_date ... ok -test services::session_usage_codex::tests::test_normalize_codex_model_strip_prefix ... ok -test services::session_usage_codex::tests::test_parse_cumulative_tokens_alt_field_names ... ok -test services::session_usage::tests::test_sync_imports_billable_message_without_stop_reason ... ok -test services::session_usage_codex::tests::test_parse_cumulative_tokens_null ... ok -test services::session_usage_codex::tests::test_parse_cumulative_tokens_valid ... ok -test services::session_usage_gemini::tests::test_collect_gemini_session_files_nonexistent ... ok -test services::session_usage_codex::tests::test_insert_codex_session_skips_matching_proxy_log ... ok -test services::session_usage_gemini::tests::test_parse_gemini_tokens ... ok -test services::session_usage_gemini::tests::test_parse_gemini_tokens_all_zero ... ok -test services::session_usage_gemini::tests::test_parse_gemini_tokens_cache_only_not_skipped ... ok -test services::session_usage_gemini::tests::test_parse_gemini_tokens_missing_fields ... ok -test services::session_usage_opencode::tests::test_parse_message_data_full ... ok -test services::session_usage_opencode::tests::test_parse_message_data_ignores_role ... ok -test services::session_usage_opencode::tests::test_parse_message_data_missing_cache ... ok -test services::session_usage_opencode::tests::test_parse_message_data_skips_zero_tokens ... ok -test services::session_usage_opencode::tests::test_query_assistant_messages_skips_incomplete ... ok -test services::session_usage_opencode::tests::test_query_sessions_uses_message_update_watermark ... ok -test services::skill::tests::replace_dest_with_copy_rejects_empty_source_without_touching_existing_dest ... ok -test services::skill::tests::resolve_skill_source_dir_falls_back_to_matching_install_name ... ok -test services::skill::tests::resolve_skill_source_dir_returns_direct_nested_directory_when_present ... ok -test services::skill::tests::resolve_skill_source_dir_returns_repo_root_for_root_level_skill ... ok -test services::speedtest::tests::sanitize_timeout_clamps_values ... ok -test services::speedtest::tests::test_endpoints_handles_empty_list ... ok -test services::speedtest::tests::test_endpoints_reports_invalid_url ... ok -test services::proxy::tests::update_live_backup_from_provider_preserves_codex_mcp_servers ... ok -test services::sql_helpers::tests::fresh_input_handles_codex_with_cache_exceeding_input ... ok -test services::sql_helpers::tests::fresh_input_with_alias_emits_prefixed_columns ... ok -test services::session_usage_gemini::tests::test_insert_gemini_session_skips_matching_proxy_log ... ok -test services::sql_helpers::tests::fresh_input_without_alias_uses_bare_columns ... ok -test services::stream_check::tests::test_build_result_any_http_status_is_reachable ... ok -test services::sql_helpers::tests::fresh_input_subtracts_cache_for_cache_inclusive_providers ... ok -test services::stream_check::tests::test_build_result_network_error_is_unreachable ... ok -test services::stream_check::tests::test_build_result_slow_response_is_degraded ... ok -test services::session_usage_codex::tests::test_sync_codex_subagent_uses_rollout_thread_id ... ok -test services::stream_check::tests::test_default_config_uses_reachability_friendly_values ... ok -test services::stream_check::tests::test_determine_status ... ok -test services::stream_check::tests::test_extract_openclaw_base_url_missing_errors ... ok -test services::stream_check::tests::test_merge_provider_config_override_and_default ... ok -test services::stream_check::tests::test_resolve_opencode_base_url_errors_for_openai_compatible_without_url ... ok -test services::stream_check::tests::test_resolve_base_url_uses_explicit_url_or_errors_when_missing ... ok -test services::stream_check::tests::test_resolve_opencode_base_url_explicit_wins ... ok -test services::stream_check::tests::test_resolve_opencode_base_url_falls_back_for_known_npm ... ok -test services::stream_check::tests::test_should_retry_only_on_timeout_like_errors ... ok -test services::subscription::tests::codex_reset_at_accepts_millisecond_epoch ... ok -test services::subscription::tests::codex_reset_at_rejects_implausible_epoch ... ok -test services::sync_protocol::tests::effective_db_compat_version_defaults_legacy_layout_to_v5 ... ok -test services::subscription::tests::codex_reset_credits_parser_redacts_ids_and_accepts_string_count ... ok -test services::sync_protocol::tests::normalize_device_name_collapses_whitespace_and_drops_control_chars ... ok -test services::sync_protocol::tests::manifest_serialization_uses_device_name_only ... ok -test services::sync_protocol::tests::normalize_device_name_returns_none_for_blank_input ... ok -test services::sync_protocol::tests::normalize_device_name_truncates_to_max_len ... ok -test services::sync_protocol::tests::persist_best_effort_returns_false_on_error ... ok -test services::sync_protocol::tests::persist_best_effort_returns_true_on_success ... ok -test services::sync_protocol::tests::sha256_hex_is_correct ... ok -test services::sync_protocol::tests::snapshot_id_changes_with_artifacts ... ok -test services::sync_protocol::tests::validate_artifact_size_limit_accepts_limit_boundary ... ok -test services::sync_protocol::tests::validate_artifact_size_limit_rejects_oversized_artifacts ... ok -test services::sync_protocol::tests::validate_manifest_compat_accepts_legacy_manifest_without_db_compat ... ok -test services::sync_protocol::tests::validate_manifest_compat_accepts_supported_manifest ... ok -test services::sync_protocol::tests::validate_manifest_compat_rejects_current_manifest_with_wrong_db_compat ... ok -test services::sync_protocol::tests::snapshot_id_is_stable ... ok -test services::sync_protocol::tests::validate_manifest_compat_rejects_legacy_manifest_from_newer_db_generation ... ok -test services::sync_protocol::tests::validate_manifest_compat_rejects_wrong_format ... ok -test services::sync_protocol::tests::validate_manifest_compat_rejects_wrong_version ... ok -test services::sync_protocol::tests::verify_artifact_accepts_matching_data ... ok -test services::sync_protocol::tests::verify_artifact_rejects_hash_mismatch ... ok -test services::sync_protocol::tests::verify_artifact_rejects_size_mismatch ... ok -test services::usage_cache::tests::script_round_trip_and_invalidate ... ok -test services::usage_cache::tests::subscription_round_trip ... ok -test services::usage_cache::tests::script_keys_isolated_by_app_type ... ok -test services::usage_stats::tests::test_backfill_missing_usage_costs_uses_stored_multiplier ... ok -test services::usage_stats::tests::test_backfill_missing_usage_costs_falls_back_to_request_model ... ok -test services::usage_stats::tests::test_backfill_missing_usage_costs_uses_new_gpt_5_5_pricing ... ok -test services::usage_stats::tests::test_backfill_missing_usage_costs_keeps_claude_fresh_input ... ok -test services::usage_stats::tests::test_clear_usage_logs_removes_details_and_rollups_only ... ok -test services::usage_stats::tests::test_backfill_skips_request_model_fallback_for_real_unpriced_model ... ok -test services::usage_stats::tests::test_backfill_uses_persisted_pricing_model ... ok -test services::usage_stats::tests::test_claude_desktop_folds_into_claude_for_display ... ok -test services::usage_stats::tests::test_codex_subagent_usage_stats_only_counts_subagent_session_rows ... ok -test services::usage_stats::tests::test_codex_subagent_model_stats_counts_agents_without_usage ... ok -test services::usage_stats::tests::test_codex_subagent_usage_stats_falls_back_to_rollout_token_count ... ok -test services::usage_stats::tests::test_effective_filter_keeps_legacy_null_data_source_proxy_rows ... ok -test services::usage_stats::tests::test_codex_subagent_usage_stats_queries_session_ids_in_chunks ... ok -test services::usage_stats::tests::test_codex_subagent_usage_stats_repairs_zero_token_db_rows_from_rollout ... ok -test services::usage_stats::tests::test_effective_usage_dedup_keeps_non_matching_session_rows ... ok -test services::usage_stats::tests::test_effective_usage_dedup_prefers_proxy_for_session_sources ... ok -test services::usage_stats::tests::test_get_daily_trends_groups_ranges_longer_than_24_hours_by_local_day ... ok -test services::usage_stats::tests::test_get_daily_trends_respects_shorter_than_24_hours ... ok -test services::usage_stats::tests::test_get_model_stats ... ok -test services::usage_stats::tests::test_get_model_stats_excludes_partial_rollup_boundary_days ... ok -test services::usage_stats::tests::test_get_provider_stats_excludes_partial_rollup_boundary_days ... ok -test services::usage_stats::tests::test_get_provider_stats_labels_opencode_session_provider ... ok -test services::usage_stats::tests::test_get_provider_stats_with_time_filter ... ok -test services::usage_stats::tests::test_matching_proxy_log_treats_legacy_null_data_source_as_proxy ... ok -test services::usage_stats::tests::test_get_usage_summary ... ok -test services::usage_stats::tests::test_get_usage_summary_excludes_partial_rollup_boundary_days ... ok -test services::usage_stats::tests::test_get_usage_summary_includes_end_day_rollup_for_minute_precision_end_time ... ok -test services::usage_stats::tests::test_model_pricing_matching ... ok -test services::usage_stats::tests::test_strip_model_date_suffix_is_utf8_safe ... ok -test services::usage_stats::tests::test_prefix_pricing_does_not_match_short_base_model_to_variant ... ok -test services::webdav::tests::auth_from_credentials_trims_and_rejects_blank ... ok -test services::webdav::tests::ensure_content_length_within_limit_accepts_missing_or_small_values ... ok -test services::webdav::tests::build_remote_url_encodes_path_segments ... ok -test services::webdav::tests::ensure_content_length_within_limit_rejects_oversized_values ... ok -test services::webdav::tests::is_jianguoyun_detects_correctly ... ok -test services::webdav::tests::redact_url_hides_credentials_and_query_values ... ok -test services::webdav::tests::path_segments_splits_correctly ... ok -test services::webdav_auto_sync::tests::service_layer_does_not_depend_on_commands_layer ... ok -test services::webdav_auto_sync::tests::should_run_auto_sync_requires_enabled_and_auto_sync_flag ... ok -test services::webdav_auto_sync::tests::suppression_guard_enables_and_restores_state ... ok -test services::webdav_sync::archive::tests::copy_entry_with_total_limit_rejects_oversized_stream_before_write ... ok -test services::webdav_sync::archive::tests::mark_visited_dir_tracks_canonical_duplicates ... ok -test services::webdav_sync::tests::remote_dir_segments_uses_current_layout ... ok -test services::webdav_sync::tests::remote_dir_segments_uses_legacy_layout ... ok -test session_manager::providers::claude::tests::load_messages_mixed_text_and_tool_use ... ok -test session_manager::providers::claude::tests::delete_session_removes_main_file_and_sidecar_directory ... ok -test session_manager::providers::claude::tests::load_messages_mixed_user_tool_result_and_text_stays_user ... ok -test session_manager::providers::claude::tests::load_messages_tool_use_shows_as_assistant ... ok -test session_manager::providers::claude::tests::parse_session_custom_title_overrides_first_message ... ok -test session_manager::providers::claude::tests::parse_session_falls_back_to_dir_basename ... ok -test session_manager::providers::claude::tests::parse_session_new_format_with_snapshot ... ok -test session_manager::providers::claude::tests::parse_session_truncates_long_title ... ok -test session_manager::providers::claude::tests::parse_session_skips_command_caveat_and_slash_commands ... ok -test session_manager::providers::claude::tests::parse_session_uses_first_user_message_as_title ... ok -test session_manager::providers::codex::tests::delete_session_removes_jsonl_file ... ok -test session_manager::providers::codex::tests::load_messages_includes_function_call_and_output ... ok -test session_manager::providers::codex::tests::parse_session_extracts_inline_vscode_ide_request_as_title ... ok -test session_manager::providers::codex::tests::parse_session_extracts_vscode_ide_request_as_title ... ok -test session_manager::providers::codex::tests::parse_session_falls_back_to_dir_basename ... ok -test session_manager::providers::codex::tests::parse_session_ignores_marker_mentions_before_request_heading ... ok -test session_manager::providers::codex::tests::parse_session_keeps_trailing_part_when_request_body_repeats_heading ... ok -test session_manager::providers::codex::tests::parse_session_skips_agents_md_injection ... ok -test session_manager::providers::codex::tests::parse_session_skips_environment_context_injection ... ok -test session_manager::providers::codex::tests::parse_session_skips_subagent_sessions ... ok -test session_manager::providers::codex::tests::parse_session_skips_vscode_ide_context_without_request ... ok -test session_manager::providers::codex::tests::parse_session_truncates_long_title ... ok -test session_manager::providers::codex::tests::parse_session_uses_first_user_message_as_title ... ok -test services::usage_stats::tests::test_scoped_backfill_matches_raw_alias_rows ... ok -test session_manager::providers::codex::tests::parse_session_uses_last_request_heading_when_selection_has_one ... ok -test session_manager::providers::codex::tests::scan_sessions_in_roots_includes_active_and_archived_files ... ok -test session_manager::providers::gemini::tests::delete_session_removes_json_file ... ok -test session_manager::providers::gemini::tests::load_messages_handles_array_content ... ok -test session_manager::providers::gemini::tests::load_messages_includes_tool_calls ... ok -test session_manager::providers::hermes::tests::delete_session_removes_file ... ok -test session_manager::providers::hermes::tests::load_messages_flat_format ... ok -test session_manager::providers::hermes::tests::load_messages_nested_format ... ok -test session_manager::providers::hermes::tests::parse_sqlite_source_invalid ... ok -test session_manager::providers::hermes::tests::parse_sqlite_source_valid ... ok -test services::usage_stats::tests::test_provider_and_model_filters_cover_detail_and_rollup ... ok -test session_manager::providers::hermes::tests::parse_jsonl_session_extracts_metadata ... ok -test session_manager::providers::hermes::tests::parse_jsonl_session_fallback_to_filename ... ok -test session_manager::providers::openclaw::tests::parse_session_falls_back_to_dir_basename ... ok -test session_manager::providers::openclaw::tests::parse_session_display_name_overrides_user_message ... ok -test session_manager::providers::openclaw::tests::parse_session_truncates_long_title ... ok -test session_manager::providers::openclaw::tests::parse_session_uses_first_user_message_as_title ... ok -test session_manager::providers::openclaw::tests::delete_session_updates_index_and_removes_jsonl ... ok -test session_manager::providers::opencode::tests::delete_session_removes_session_diff_messages_and_parts ... ok -test session_manager::providers::opencode::tests::load_messages_includes_tool_parts ... ok -test session_manager::providers::opencode::tests::parse_sqlite_source_accepts_valid_references ... ok -test session_manager::providers::opencode::tests::parse_sqlite_source_rejects_invalid_references ... ok -test session_manager::providers::opencode::tests::delete_session_sqlite_rejects_foreign_db_path ... ok -test session_manager::providers::utils::tests::parse_timestamp_to_ms_supports_integers_and_rfc3339 ... ok -test session_manager::terminal::tests::build_shell_command_keeps_command_without_cwd_prefix_when_not_provided ... ok -test session_manager::terminal::tests::ghostty_uses_working_directory_arg_for_cwd ... ok -test session_manager::terminal::tests::wezterm_compatible_terminals_use_start_and_cwd_arguments ... ok -test session_manager::tests::accepts_source_path_under_any_allowed_provider_root ... ok -test session_manager::tests::batch_delete_collects_successes_and_failures_in_order ... ok -test session_manager::tests::rejects_missing_source_path ... ok -test session_manager::tests::rejects_source_path_outside_provider_root ... ok -test settings::tests::corrupt_settings_are_backed_up_once_before_default_recovery ... ok -test settings::tests::settings_save_uses_common_atomic_persistence_boundary ... ok -test settings::tests::visible_apps_accepts_claude_desktop_aliases ... ok -test settings::tests::visible_apps_old_settings_default_claude_desktop_visible ... ok -test tests::no_code_keeps_app_alive_in_tray ... ok -test tests::restart_exit_code_defers_to_tauri_default_restart ... ok -test tests::user_exit_codes_run_cleanup_then_exit ... ok -test tray::tests::claude_summary_uses_h_and_w_labels ... ok -test tray::tests::failure_quota_returns_none ... ok -test tray::tests::gemini_summary_emoji_reflects_highest_tier_including_lite ... ok -test tray::tests::gemini_summary_includes_all_three_tiers ... ok -test tray::tests::gemini_summary_lite_only_still_renders ... ok -test tray::tests::gemini_summary_uses_p_and_f_labels ... ok -test tray::tests::gemini_without_any_known_tiers_returns_none ... ok -test tray::tests::script_summary_empty_data_returns_none ... ok -test tray::tests::script_summary_failure_returns_none ... ok -test tray::tests::script_summary_official_subscription_claude_uses_h_and_w_labels ... ok -test tray::tests::script_summary_official_subscription_gemini_uses_short_labels ... ok -test tray::tests::script_summary_single_bucket_fallback_with_plan_name ... ok -test tray::tests::script_summary_single_bucket_fallback_without_plan_name ... ok -test tray::tests::script_summary_token_plan_five_hour_only ... ok -test tray::tests::script_summary_token_plan_monthly_only_renders_label_not_raw_name ... ok -test tray::tests::script_summary_token_plan_two_tiers ... ok -test tray::tests::script_summary_token_plan_volcengine_three_tiers_with_monthly ... ok -test tray::tests::script_summary_token_plan_weekly_only ... ok -test tray::tests::script_summary_token_plan_worst_drives_emoji ... ok -test tray::tests::script_summary_week_aliases_use_highest_utilization ... ok -test tray::tests::subscription_summary_week_aliases_use_highest_utilization ... ok -test tray::tests::tray_id_is_unique_to_app ... ok -test tray::tests::unknown_tiers_return_none ... ok -test tray::tests::worst_emoji_reflects_highest_utilization ... ok -test usage_script::tests::test_custom_template_allows_http_lan_request_with_different_base_url ... ok -test usage_script::tests::test_https_bypass_prevention ... ok -test usage_script::tests::test_port_comparison ... ok -test session_manager::providers::opencode::tests::load_messages_sqlite_reads_messages_and_parts ... ok -test session_manager::providers::opencode::tests::delete_session_sqlite_removes_session ... ok -test session_manager::providers::opencode::tests::scan_sessions_sqlite_reads_temp_database ... ok - -failures: - ----- proxy::handlers::tests::upstream_body_parse_error_carries_field_diagnostics stdout ---- - -thread 'proxy::handlers::tests::upstream_body_parse_error_carries_field_diagnostics' (15646) panicked at src/proxy/handlers.rs:3413:17: -Failed to parse upstream response: expected value at line 1 column 1 (content-type: text/html; content-encoding: gzip; body-shape: markup; body: bytes=21, sha256=0892be486eabc667) - - -failures: - proxy::handlers::tests::upstream_body_parse_error_carries_field_diagnostics - -test result: FAILED. 2010 passed; 1 failed; 2 ignored; 0 measured; 0 filtered out; finished in 2.99s - -error: test failed, to rerun pass `--lib` diff --git a/.github/workflows/final-hardening-apply-once.yml b/.github/workflows/final-hardening-apply-once.yml deleted file mode 100644 index 0e899a28bfa..00000000000 --- a/.github/workflows/final-hardening-apply-once.yml +++ /dev/null @@ -1,167 +0,0 @@ -name: Final Hardening Apply Once - -on: - push: - branches: - - fix/global-hardening-20260904 - paths: - - .github/workflows/final-hardening-apply-once.yml - -permissions: - contents: write - -jobs: - apply-and-verify: - if: github.repository == 'z13321812367-sys/ccswitchmulti' - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v6 - with: - ref: fix/global-hardening-20260904 - fetch-depth: 0 - - - name: Apply exact global hardening patch - run: python scripts/apply_final_global_hardening_once.py - - - name: Normalize secure body diagnostics and stale test baselines - shell: bash - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - path = Path("src-tauri/src/proxy/handlers.rs") - text = path.read_text(encoding="utf-8") - - prod_start_marker = "/// 取 body 前 `max_chars` 个字符的单行摘要:" - prod_end_marker = "/// 解析单个 SSE 块的 event 名与 data 负载" - if text.count(prod_start_marker) != 1 or text.count(prod_end_marker) != 1: - raise SystemExit("body_snippet production markers are not unique") - start = text.index(prod_start_marker) - end = text.index(prod_end_marker, start) - block = text[start:end] - if block.count("fn body_snippet(") != 1: - raise SystemExit("expected exactly one body_snippet function in production block") - text = text[:start] + text[end:] - - test_marker = " #[test]\n fn body_snippet_sanitizes_controls_and_truncates() {" - if text.count(test_marker) != 1: - raise SystemExit("expected exactly one body_snippet regression test") - start = text.index(test_marker) - next_test = text.find("\n #[test]\n", start + len(test_marker)) - module_end = text.find("\n}", start + len(test_marker)) - candidates = [pos for pos in (next_test, module_end) if pos != -1] - if not candidates: - raise SystemExit("could not find end boundary for body_snippet regression test") - end = min(candidates) - text = text[:start] + text[end + 1:] - - stale_import = " body_looks_like_sse, body_snippet, chat_sse_to_response_value,\n" - if text.count(stale_import) != 1: - raise SystemExit("expected exactly one stale body_snippet test import") - text = text.replace( - stale_import, - " body_looks_like_sse, chat_sse_to_response_value,\n", - 1, - ) - - stale_assert = r' assert!(msg.contains("\\nblocked"), "{msg}");' + "\n" - secure_assert = ( - ' assert!(msg.contains("body-shape: markup"), "{msg}");\n' - ' assert!(msg.contains("body: bytes=21, sha256="), "{msg}");\n' - ' assert!(!msg.contains(""), "{msg}");\n' - ' assert!(!msg.contains("blocked"), "{msg}");\n' - ) - if text.count(stale_assert) != 1: - raise SystemExit( - f"expected one stale raw-body diagnostics assertion, found {text.count(stale_assert)}" - ) - text = text.replace(stale_assert, secure_assert, 1) - - if "body_snippet" in text: - raise SystemExit("body_snippet identifier remains after exact removal") - if stale_assert in text: - raise SystemExit("raw upstream body expectation remains in diagnostics test") - path.write_text(text, encoding="utf-8") - print("normalized secure body diagnostics implementation and regression expectations") - PY - - - name: Normalize all-target Clippy baselines - shell: bash - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - codex_path = Path("src-tauri/src/codex_config.rs") - codex = codex_path.read_text(encoding="utf-8") - codex_old = """ let Some(models) = value.as_object() else {\n return None;\n };\n""" - codex_new = """ let models = value.as_object()?;\n""" - if codex.count(codex_old) != 1: - raise SystemExit(f"expected one codex let-else baseline, found {codex.count(codex_old)}") - codex = codex.replace(codex_old, codex_new, 1) - if codex_old in codex: - raise SystemExit("codex let-else baseline remains") - codex_path.write_text(codex, encoding="utf-8") - - settings_path = Path("src-tauri/src/settings.rs") - settings = settings_path.read_text(encoding="utf-8") - settings_old = """ let mut settings = AppSettings::default();\n settings.show_in_tray = false;\n""" - settings_new = """ let settings = AppSettings {\n show_in_tray: false,\n ..Default::default()\n };\n""" - if settings.count(settings_old) != 1: - raise SystemExit( - f"expected one settings field-reassign baseline, found {settings.count(settings_old)}" - ) - settings = settings.replace(settings_old, settings_new, 1) - if settings_old in settings: - raise SystemExit("settings field-reassign baseline remains") - settings_path.write_text(settings, encoding="utf-8") - - print("normalized all-target Clippy baselines without lint suppression") - PY - - - name: Install Linux system dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential pkg-config libssl-dev \ - libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev - sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \ - || sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev - sudo apt-get install -y --no-install-recommends libsoup-3.0-dev \ - || sudo apt-get install -y --no-install-recommends libsoup2.4-dev - - - name: Format with repository Rust toolchain - run: cargo fmt --manifest-path src-tauri/Cargo.toml --all - - - name: Run permanent policy guards - run: | - python scripts/check_workflow_shell_interpolation.py - python scripts/check_rust_failure_boundaries.py - git diff --check - - - name: Create frontend dist placeholder - run: mkdir -p dist - - - name: Clippy all targets and features - run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --all-features -- -D warnings - - - name: Rust tests default feature set - run: cargo test --manifest-path src-tauri/Cargo.toml --all - - - name: Rust tests all features - run: cargo test --manifest-path src-tauri/Cargo.toml --all-features - - - name: Remove one-shot diagnostics and commit verified changes - run: | - git rm \ - scripts/apply_final_global_hardening_once.py \ - .github/workflows/final-hardening-apply-once.yml \ - .github/workflows/final-hardening-diagnose-once.yml \ - .ci-final-hardening-test-failure.txt - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git diff --cached --check - git commit -m "fix: close global diagnostics and rollback failure boundaries" - git push origin HEAD:fix/global-hardening-20260904 diff --git a/.github/workflows/final-hardening-diagnose-once.yml b/.github/workflows/final-hardening-diagnose-once.yml deleted file mode 100644 index 7fa8e5fec8e..00000000000 --- a/.github/workflows/final-hardening-diagnose-once.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Final Hardening Diagnose Once - -on: - push: - branches: - - fix/global-hardening-20260904 - paths: - - .github/workflows/final-hardening-diagnose-once.yml - -permissions: - contents: write - -jobs: - diagnose: - if: github.repository == 'z13321812367-sys/ccswitchmulti' - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v6 - with: - ref: fix/global-hardening-20260904 - fetch-depth: 0 - - - name: Install Linux system dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential pkg-config libssl-dev \ - libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev - sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \ - || sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev - sudo apt-get install -y --no-install-recommends libsoup-3.0-dev \ - || sudo apt-get install -y --no-install-recommends libsoup2.4-dev - - - name: Apply exact final hardening patch - run: python scripts/apply_final_global_hardening_once.py - - - name: Create frontend dist placeholder - run: mkdir -p dist - - - name: Run all-feature Rust tests and persist failure diagnostics - shell: bash - run: | - set +e - log=/tmp/final-hardening-rust-tests.log - cargo test --manifest-path src-tauri/Cargo.toml --all-features >"$log" 2>&1 - status=$? - set -e - - { - echo "Final hardening Rust test diagnostics" - echo "cargo_exit=$status" - echo - echo "=== failure markers ===" - grep -n -E -- 'FAILED|failures:|panicked at|assertion .*failed|test result:|left:|right:|error: test failed|---- .* stdout ----' "$log" || true - echo - echo "=== final 1200 lines ===" - tail -n 1200 "$log" || true - } > .ci-final-hardening-test-failure.txt - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -f .ci-final-hardening-test-failure.txt - git diff --cached --check - if ! git diff --cached --quiet; then - git commit -m "chore: capture final hardening test failure" - git push origin HEAD:fix/global-hardening-20260904 - fi - - echo "Captured cargo test exit status: $status" diff --git a/scripts/apply_final_global_hardening_once.py b/scripts/apply_final_global_hardening_once.py deleted file mode 100644 index ee357a9d190..00000000000 --- a/scripts/apply_final_global_hardening_once.py +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env python3 -import base64 -import gzip -import subprocess -from pathlib import Path - -PATCH_B64 = "H4sIAK/DmmoC/+19eXcTR7b4/3yKinKekcaSvIORYwgBMuE3JOQAM5nzgNNpSyW7Y7lbr7uFcWydA0lYwp4JhDV7SJiFZSYLBkP4LhO3bP+Vr/C7t6q6Vd1dLcmGzOS8Nz4JtlrVVbdu3b1u1c3lckTvcexiztVrtoF/9VSMsbztrOvu7iZj6q9efJHk+vqzG0g3/LuRvPjiOjJllUjRMsvG+Aj/UNJdfUx3qP+R0mrFMCdH1nWzj4Y+blqOaxQd0YDatmWLv8fplGEaWqg/8WyqWB1h4w9vzA4AAMPDAAYCgD/1dfjfulzZJDYt6UVXq9kVrWzZWsUaT+PfjmsXSBf8myG5zWSvaxvmOJldl8O3p3S3OEGgVaHwe/ynqtsO9d/K+K3wZ/ckPs6Q0c3yU/ypUJdM1Vxi1dwq/BolMDj0+1w6NVsv9PSksth/3ilO0CmazmRGwm8bZdbBXgu+nADswAisPf6NQKQz0fHwh4+Vr9acCdaIvRnpuR7+GH0FB6nq7gQHST2lSTrjFMgfaPEFjrbNArj/qVF7Rqvqhu2kM/kpvZqeS09miZaZI5N518L+oTX0nC9alQotuukocNhz3rHshG9KtFSrpuOAAbaeYw0MR6NTVXemM/SktuxnU0lFB1M17mIDvGUZZjqVTWXavJJef3C9AvMq3DefSUuzw7bTWhJZITNxlCNkeQfYyU2vf359Jm/Sw4C6fM2ctvWqZtkBzUZA4QSO/fC3Ncss0vT6LeuVeGNUmK7atGwcxuVkcDXJmX9R37KfcxotHUxlsvFeXrNMii+yUSVqyCbSp/gTfiEvk56eHrK08Oni/BHvq/eWLhwnxaIzbcBEgJ1I4/7fVz78sXHuJvn9nl2sLZdNAwMglrr7egeFdPJ/bOrWbJOU9QoTTEJm8AERx/5cUG4ArpOFCKC2O+Gloq27tFCQJFyh0LIjvsLWeKFgmGULcPvPGx+S7SAvCQpMnBlIT5fiEKRsW1Nk1rFqdpHWC2RWHrueCnVWomO18efSzcmnwn1OUVdHIR3ts0LNcXdidLaeDWY2Ghkny7DcP4Do7e7vHYpgOTqf0dFRshdWvULJTtNxdaA6sk2vVMb04iTZZxvj4xT6J9AsmEGoGzGT1FZ73AElUzNdmDlAQXR4kAd4mdgKvQdYJmmDtcgQw+QtDZeCCM1TszZFcZHU8iI0JME3988a9YNiSMVK4hg+PXTeUYdkIjonYWYh4QcgCUV3FWN8wp2m+C+g39GkzxpoUT5lXLxN/bhq3cObNojFW0dUPz4emboDPMLvBDySpB9kEUHsQmPoDldmCmGagDug1zaL4LOTaiE663SVfIuLkjxpWJEJ3SxVqOYbPthJukuvVjX+RdZHSpa4dg0+pixTs6rUxIaplhjFnzGb6pMjIPHI67ZVpI5DLLMyQ8qghd2QiETByGTihqE+JhQ39Pcr+BV/9tTMHYcocFdhN8ABHDnL1pvUuUZKmmhgsnCbiNMIA+SXJowYwDDZZ0giLbp/1sQSaHVXt11Hg9WbSKekZUxlWpNDV1fnEoAL7z4gg75+FN+bBrJ9fUIGPL+/WB5PuxQsyIPc7nao6RiucUii5DEQwSUdjD5s5/gytAbmiVOrUluFiWD+z+/Hlw7yD2CnY6ca6xVaafxFAzhhEp47Glpf2ljN1Ux6iNqaQ4ugwbVDeqVGnTBxMUWsTwMhyWg71NdjTFXBstxiU67gRqu2dcgoUbvL1KfoaIlOWcCUxu/ozKgzmeMDdNUcfZxuLSJb7bMmqTnq4r/i2+fLtj4+BWQhPqckapXNAbX9ADDK9Bd5oUOy4p1IFKU7DrXBLPO7AlPbdHXDdNIpPrlUSIm0aB6dOnuRE8zAJqbu+3o3SfIjup5TegVtRLAUmivbXNWSbcGqco9hLasoVsq1qgL5//Xf//3vw38Unc8p8NkEteMlCJnUZG6OKNsIHyYTmLE5hStftKamQNs4PT7vJjn2yobMzWZWQravl604GuOvo1tMdMa5TWsSXvata3eCEuY74xNYa1BIaEaAmWm61BRhAnQjgCJQ4jAoCgUBApBStTaGpMT6CKQOytKC8NiZ876HOrWK+wIatbvg+52MQvbwAbPE91GFcJLNUZwAuv2hCYBc903o7sgLTWpJfBVN4qBVh0TUheqSv+UvYnjG3GLAVuhVaxScwzk6R0JuVEZEPBRrD4Lu8EwPjDat2yDvklZe0YyxO/A7Y3f8LVY/+qOwSxX+nIz6/bOuPl4/SDZv3kyW795v/P0dCfckjfqpgs4GW0Smrir1TCrEsKp1YWuT2Hdyx9lwF6tfuCgW0BpCAFGXUVMfq9DSc2n2ZBcosEqhsB1Ni5hVpPbXWsxs8fGHjQ+uLH99tEDGZkAAM29tzCrNgHHpoO8GU1PRBMicQwbI9h6GAa1MQcAmEUZSW2ZMDnNbEn5JtOFHiio64BjItUB2V5HJpYARhgMCzYVSAX1m8Cu6isD6Brij1JF9spA5tv9VBORlhOMguI0zyIggT6qWwRzCEPvGsBqhldZd1ddIGcmE4aPGp8CxmlEBbkOVUzHAlgiven6curyz8OMJqsNL4AjX3AnLNt5mIhSs0SAq8xLVbeiVmagWw72TB52JdhSIimQpAdZRFdpSrcqdCauNuFC3Z5TRK0IvAxEnI6Bi7/4/GuduNi7+sDh/qjE/73141nt4EQi6QMD4dWuClgOq5nN2OE0HvfGm6DDU+jZgNKkZ4tGnNfY2DwxIcSaOJU10mO4Sf2RaS+7wW00hELwdrHWYCRg3MkDQEAmAKlaAA9LCpuobYk44/xVBVhxTje+Peo/Oc3yFkKVGUdE9nAeMt0JaEmr85c37DzJrQ5KqnxBrYAjvyyONT2/Kc2tcuetd+Hr5i796504vPfrzz49O7t27g6wcuebNzy/On/WfnvG+fqfxyQ3+0Tt5lWCr5W++bHxyYfHRNe+rvy9/d/PnR++vi7mp1CxaJWZFjBJgNNxmALsEJJZ4rgJbRE36+4eym1Ap4i9/wVAXg1dvVWGJ2a+AOwJS46wffiqRjCyocmEaiDCJd/yYd+dBVEQpmy7fPdX46LZoGqeKYBwunGENbWtKq7nlYVg6x5lJdzUpuM3qV/WZiqWXtDL0Q+0qdOeG346wCKw6XyfvwQ/Ld+55jy8tPn6ydPHWyonzPx15xzv27crl24Q5IoiacRTOjcs/LF3/Drhh+f63y09OwOqvHH3iHTu78vEnIdK59J135hj5f3t3v8aNKeiPLdzGTWDDAqvhb+V+UYQDxO8C6XqF/fWqXlXsGIlWTVyK0Jj0QOyH0JksYQ5PZk4V22dfiUAI+1vYeKGofuoF0zJzuEKbY3sXzbg8DFUfnQ06BJUohdZlyMRuTKHwAu7paJs3h+AWWx4EX8ctDEVkwPf+ScT7/81IC03Dg1+201q/yK1w+YaGh1mwgv0eVhqjuE1w7p534yGne5Aagq9z7kyV9iA5Em4zgVBA6XHhaxAa3gePF+dve5ePNc59sXzinaVr7wEdNk7e987/pXH6iXfynnKg5S/nl49e8y6cXLl4dfnu3Z+OHF2c/wIkDlBj0BUK7xvvQ4ccHu+LT7xHDwGqbRsDoST/6OPjNh3X0cuTjf1ZdSSMSQy2Y4p20baKXivRg1wAMri8Yze8h5e4DAQJQIV1CH8ytuR0kRAGk/tODpStbtSYQdXOuHLpYVchTzCWpu4o6ovE8KqVxY6DxuaWplnSFRXJ8Kg5TKyremZLor/j795R1e5d8ooJdSUJ7tWvWfv1Wu14iav11CulWiURNUDs1arwGtWnNNYF94b5aqkXN/WyboCLRVyLS3vidxAoW1n1heKlVOxh9Q4P+mHQXpVoYdgF19hkywZew+FA0d59wLUPd8mWn1z0rn/SuPGXxmcnQKkB4zPOAEUMwqZx5n2ybUJ3Y8wS2/BC6cK+X3zwKV8bUH0/P7q2+OTjpUtXvWOoJAkXZjfOeqc+X/rr3cblm96TyyjSHvywtHDVu/MFCDB4zoXTytX3vPmvI6JRIYSKAJ7mAMJB8wR8wdSItKLtZFOEyjm6nl4sdUTinQ/2r6XvZySBQtJHkjyJUke5FlwAcFpcu9hpvR5rGky5Jk+1HtG1WKOcSZYxyDEtBU0gZDZsGsI4SXf/xt6N/l5LyLGPGHl0s7/fUx9ZF7LvJPcgLds3iMER4j/xPRn+FCe5P5/v6+89WCDrZ+vrQ4Gv1fWTcyZ0v5W/chl57pyOeaKN3HEq06aRP1Yoi4QvrWlUq9RN44csgWlk2gRbGYkwQLUJpBB8saN3ZLISL3FSSo6yBlGyaTpW0g+1DaY1m7EAQB+Lr/NfMlWkU0uPP1hauMF1C2cYtMZN6k5b9mQzsM6IU6aWZjKLo5dpLJEl7eo2Or0YXZJyWKS2Hca6Qv2w0bdWqzuQiTD8WdQrxtu0JPETukDr4v7KG3Rs+9Y/kFlwm9+eqPte+8nZtydAC+mOZdZBUwGV+QDWkT645u7n2SfR3Ww2HRbpYBN3qnn+KS0LBJ4KJZpForEORgvg5VHw9vB7lGaFwrY9O7bu27Ed92NYxplTY5tk6ZYGnx/25rM8SF793bbdu4g1GdukTneVDJshM1H/temr0+C1NE4rYxbskK1TY8Z4zQIE/fPIRXKI2kZ5hsDr4DRa9gyBnmug0GYIPWygI3jI0Mnre3a//vLO17Z3gtBXd+x7Zfd27bXd+7Stu3btfmPHdrauA4MbcV35L3ldZUWXZKKpguc+vnzYQG7T4qTgHBaDnq0zXZRSpK9JKxQO7q4lMtzSVNg9mWZpaRlZyzOEbOAI2bAxyNTBbbiXMIJMdJO86fPdmwTTBogQNTmnSotG2SgSlIEOm6hgNpx6DTgjH2y1camkcX4QqtCq8kzZrGCTgrR6LHlFSqT1QQi22xLFz1PLHUng+KHWtyfkRNumRKmTwNLgU6iTdFOQZFLRfqiZ1A8nlk76eWt8BjoB+fCWoZvAPTM1U4CMaznY28uylvF3MwylEpwpoSgmXNcXYMDkb09kAcqMKs0ZN6XbpDZLWc24gx3JaEYM8G3bpAxUDWbGW+QdFP1AaZhEkU7FwlGK1lXdcUB1ldK4BZScZWyps6b9jlaROC1eaZs7varEaXkg3G+WBqqyBOZWg6TXF9YnZBBLGcfYT2iLt20qd/NlH5Q1pHKLV1tmc4MgsTRr2gT6fBbp3GtL5u48k3u1adytcrgjTdukbwN7+QnaW1QJ2sh+imzouoi1MsYWjqFrWVoFbS0hlgPBmwWuPswj7AVSc0BqREVxc82h4dQYLHLwBukhfb39g+JXKwOOia2BjSxIMtjLfstaOXBMwlpXCM/mZsQPx5afnAAncOXqBTTv6uTVl7hhV496ThzYiC5uqYfXpIMz2U4n4S8FD7vQw0VKSw6pGFOGC3oAZ5L5FcyDKQUWK+/vRXthcFNvKLM3mqgldTxhlKijFeEJOGIGGCGabpbiWVrts6zSKVRXDohsoJ4iLfDEpxfpYX2qWqEgMKYKw4ODAz2g2LawlLpRfazYVbas0b7U02dlPd3gkcwsjf5PMzkLHC+/b2V3XAxBV1nW8cFUp9lh0cywemsf0xlo61/yJnyvclMWOLwbf28Q2VvknxeP8P8IFxITtFKlttN8/u/6zzdr9zAkEZ0llaHNinYWMcxiBUScZWKuCJODZAqcL30cJFma7b86hNErqjEwSVzcX80/awuptXX0H+vlmVsv/2ql6wxorq2bDk4iFBAE/gbKWY92ODhUqHz5cxa1EOqYM91GlhjVP+Snv7CzGrzrX7N3hZ4jYFgI/Y59rb0DT+9n+X2097E4hnGfpg9jqf1yKPVpzZym1kcJGjO+UrFkGQ55ZwaObwtEl0oOSj5Fr2te2w5sIIDm12L/JM+jhTU03McOqW2MnHNqYQ1xdaL5jhCoE5X5E+a7iOmyVn6L4N63OaZmxmrFSermgTRrTo7qjpvry+tT+tuWqU87aIv0lIF58u5hd8sfc1un3s5tC4y50a2/27m9iz/da4ybwFs2RdNHXi+1vSIZKthJcBRgWJwd693YKU6rQENgoYCVyYV7G3Q2Z26YhpVnbFrY1Nvb28MR0YO99LgWn/VbjmXGzce1rsHaxlZaj5LZuPrOkjYc+KH45Dx+/1t+QpMlPcGvgT4/f5+bWYvzDxcfHl++82fyhmGWrGmHbNvZ07jxN+/Gvcb3p5fvXlp+9/HSrW+9O9dWrl1c+nph6can3p1PeG5M49K9xtk7uN98/u7KkWsgoMib27Zpe9/YuW/bK9q+HXv3aa/sfnXHm3ykxpUfvUfnl78+vnT9IzIBlgeGkEGWLS4cg+4WH57j4/Us33sXfnk3Hjau3gURh4lbORGdxH0GfFODN9NMI74OaHupVvbJSNg1YKJhM7RqHLdUKFDzUKFwSLfTKQV4qRgRAt9PTTGHA3vJ48dQmAJDFKJNiyiF2GYUEPK0urR4Te6tvq6bu/lW5RBtTg9ZSLMOgZEATlmQQI2mwOZscAw4eC5G2SwfiBDP5CMQ3XErk6MpNBqL9uAFCQUEN5MHmsRstjlsPEeeY0hpzjvj9ytNGhYgPG82js+aMGP+B7e1gzPNUj/cMoUuMggu/oEj6mOApBo7ZwpmHjPEoUVW+Ro0QIsw0GbhvO/Gh2cXH99Y+nZhaeFTnrABXLDy8SdLCx97dx9w8l6cX1i6fsd7fGn5/l3vx/dUe9MMsJIBxqYOmJAyvmWg/JPvCE6qcfmzxreXls/d985/FBnm50fXG6f/hBBc/2TlyBHv5D3v8Yfe+2e9+zcRVtHmzOLCTZHyeOzs0uM7PZwLvYcfrnx8dWXhinfyuHf+/VTI9M76kqQ7OOQOJAZCsMlMTfNYoxXweubmYun3Ivml5QzORGDnz0NxuTBhpPIpwBvKB2WPyP34LW8S+bJx5W4w+Z+OHF2+8yPgw/vTGe+ra4vzfyHbdu0kHEd8AZeuvecd+7t34UzjswfQr5gHS01oXP7Be3Js5fMFZnySYsUC/+PnR9f4sI3L3yx/fgYkZTA58mb+TbL46Br0hMNfuOudutX46AHIt8apUzDC4vwRDiuMKUA8fnX5yfXFhYXGe+fJtje2A6qWP7+1dO4eCuAfr2MyxKXjPXwKPIcQWvDhMQHo/F2Qw9j3idtBZ9CT99VHEp46kJKBRxBi+A7lZN6aTPs83FpeYWp5idq0DM5VjNTitAYvzcncH8pYmYU/wpknVd00ivI3fk4r05UsTtmHoY7uwYGBbP8QU3iryloV4sm3Y7qFHcOmwCZeY4cYkVYcjROLNj1BTc1yOEJAVk0ZjsO4LzQxwDwAzfejw/hDKZFlsiKTp4erIBFZflVK9MM15lTNcYlp4XZ/pULYtQmuRYrTpRB+fMMN3pcst7ayJRUX0O0wYNO3AFAHfld0djTYkgy77vgRR+WcmdCOSAW/wxy2BcAyTOHYdro9jIC8ilE0XHYomY/lkyXAOYXI0GCp0bXH7Ys4oMxmC8v5GOQM5hSOICD0l46TdjoTURSR6Ukvho4JdUsb8IwkkSvF9gqe4SkUXtGdib3UjR2jbhkuZLnUbSOGQSt/g1LsT/b1DsUTlaxJWVU0A114rwHLH9pSJ5zEAumGRzZuX/Fu3MI8SCZ8G+duLt35nOyChRaSOuUfDEFO9lcU7XNYuiIu4wwPmzjpKpgmuj1TIMZUtUJ2mq7l72Ghdx1qG9rhCof8QpwpusRYGP+L7XOlZdYCYyTSedgCDFsHvJfmwzpBhEWbqU0UZqbMii7qI8GwRK84VjNGokyXi4LI97vIHElFU+Mk2CJspTszZhGxXzZMFhvRXH2SWuz0/UTNBVfB1IyyVjOBTkvpLodWyrLxiYI/Ynf6aNbNGf9MJCoeeDEMVL40FnnAbi84JEGAp8gP0XRkNnl9WjfcyDM5UTUgU3HWiJHf0qkfGkeOSqmIQINbwksuQzwblQy+wZsOZewEeGR5LzDH+KxwM1w5LZH+sda5zR9bufqVd+yRd+cBn2HjsxPek8stZ8ggBDTbNdNkWouPF50ta+a4VjWtyIBRgdgCzBtHvAvnwFACU42Dyc/7wEPv6I3G7S8WF75cunA8Gep680+O/SQCFtyg6WXwYprIFskvEtsxIpbYqYWIkdYWL+oA/c+kpzZGYYLNFS2QMcuqiNYtJA8/FhZiWmANkFqFgkmnQxJIJKzhQoBWcnE0RkTc4XeSVo6jiO9YdCfc3eInVvn02o5C1SMlCSG2dRCh0mT6VCS/1TsHvEThMdXASOLwo6VUqyYip2OQ8doxpq28r04sLjxeLciRj3IiW7fidBFP9YcVtyepTboJnwW6gD9eX5w/3fjb59zzWFr4ZmnhduMoeA1nmSOwynmxF71zn3rfnJZ1sZ+Deca7/w8Oimj58EPQ4wmzrisZFCVMKz6JIkC5rlzuJK9g61kKoTL/Hfp6HG3X3lPJmI4mtBcAAo+4hWESs0KS5ZMlTgP4N9Jo/MKTADstRFRgbpGurdXqPvgrJL/oIcOqgYtSs228vigcRVI0FEouLrbkFKGoOhcgiFOL/kfpDqeYqFNLuGY7oH0UPQLsAC88rVEnePqR5ZrCl4TZXsQogdNOdegULx3Z/hIm3onXSbmCx1zlzqcnDJChQnzyVyp03HBh4VwaYIMHbnATisJikWkKLhf8Zqa/UcrkQ+QdI9d2No1qgmkZl9nY+smnA1LySewIM7TgckSNj5cArYn0X0+eogipwyxcIAqH/ZU8n/hcMqsAmoejVwe3UmGCZsDgCBdBXGGycD+AGUJ9sg5F6uUvJsltEXhvGrhR5hrpXI3VqngRhhpeAUVrhdaKEmblGYNnIgBWmcNPodmUECzfXfDOfxSFwJeDIfXjg5BNWF0l6hSGkY83zTBNiTAT8LcKvMUUZms+4j03VYEQST2Eh/5wT/oVdub550cnwVUGz3np4i3v5Megm70zH4HuWr73LjwJTu2JmB9yX/NKQe6OZcXNgwUiLlYK+g7dO1Ou4UYgcDA9TIs1F7fDxyoWqCPLTHMdxa88GNzQmx0m3UNDm5QnlzB7yMDrXqqY22LraHmhhNZYDgV4iialJfQU+XpkCR5jYNkm0ctNuBtBmsJNmVHMthHtkFEwotyA4aQRPbi2OqM7Qmk4KL6RUcOxeuOkcebo4oNjsMDcPln6613v/JecnJL8IR+G1iZLBA8tzBYaN1jk7czQVaNArkN5NE/BDuSnT72rt7w7nyz+eJpnHm4czg4NkO4Nw73ZwTCtwKsDeYKUffyqd0y44Jx9Fh+cxojksZPLFx4vXb8i+wYYipe7gJ/lJ1cbn933zn67+PgJdMdbLz85sXTr9OL87cZHt5cuft8zaVQqaC9LxvLig08xlP7lkcb3pzEM+uCH5RN/wfB9YD6v3S3C+zsTaKAzgkvSCmjbmE2K69TJUZ9F8vdxkt2ZYDj1Kdw1u0lyv22OHXXgMeRU94hqKm+htcoNc3MHjsazcTZaMLACH/VE2dbUqkyDJAe1RtYiFuKAdTpe+7hkpEVLeTOYJyJKdOE9rhTxYL2kfFFfMpEi0HzjnvfxEYw0Azn++SFhd1iGzroruTvg6ISYSi7ioguY/NOKYp/uyt2Vd2/hRiwDSFwzcu/j5buXZF/bh/v68umb4jmX+CevCnebyb7F+XOcjaCTpdvvez8ek8VZVHh1rxlAfre6AOTBD41TpxYXFhYfX1p8eAZHYz3gHQKnPgOxuTi/4F958s665JP/8QB/M6iQgA9BPcob6zuJesWkghz5yiVdP6ytNuo10llfbWS0opd6/BGSqD9rLqc2d3QfTBy5EvaPi4skGGktLtxc/PGJTFf8XXaxfDBwXXm+pt4u0/hZyvFcZzKRthB2cbEkyKpllJhmW88j4TYaDnc2+UIChV0l7BQmxgLrap2KFRhEEu1H6R5NbD9Vhv2dXcVFNQAMhyQgmjg9KcOWkbdCciUs+PCelAVxQ9vi/FmQTDw9rHHyo5VrF5Yvn08WMzFiF2I/PPbqhMxTCpqOhM2zFjjPRugkCJ5VCp9/kwBKAF7xaC3CaDUCqYX8pgoBoZZGv7BESpRKIls68R5f6ST6xv6NGAoY7tvYvDBeIQgGMwSzrW7f5AYAM8EIGA9B0hbP/uRpAhg4Vl6Ipt4LmDGLgmesZrgRqyq0YdAEXpH4RBUJTKC6VhonfIQt6vSFg5KdAZHkioTlTtOdbHHTWtSn3EwO0eJz+6M+nCrU1cKlO5gwYn0kyYd6BoGJRH+JZ4JtHOCkOtCbdF9gONksPOMO7ddAnZDVqZNIUDJMvmTN+gXNfh44P3nBO/Vp4/2zfD0DN4LnjwpvgyVag+TlWYfexbtYYen8Xa6bGx/fbDy8sGrV05adutcK+3X/I7MZHnzK0zbRrfnmncb3p5Xbny3TMGKysd0U2ryekAShTogIuGeuFXN3sOnY/mUWfPZvHc521j4kFDp7hWdXr0V8dNZ/opBJSGtSyiPMI1F/s450bgORTmwgLoQ29DEhtGEw6Qb9aNC7de6M2CtYxQ7OluBCKInRNoJ+5mlCl79ZObKw/OMHPz+6xkMCmPKUs8rED7+eDIx07/Hf8VTaKYyior7OrcmylSKlI7GYhRIuzPq+8al35FHjyl1SpXYOJu9PGcXC5W/8RIkA1JUrC+yWwzOLj4+3kxPPMtYbjbRylCZlZAFyI9PB+76XLn7auHSSH/Gj8TBsrPIXoG04Q96AAXEquKMd2y41HGKVy0bR0CskbRvOJHwkepHVUSNjuskubmKmZEYZiAbdw/a+/V1eo4SaiB8EG9iU7e8n3ZsGB2NVxNCpzRCMcV3+zDv2g3fnXY4DdCivf9f46B4snU9meHOlvNvB3d9QXyJ1j18N/uRK48b7vFO0HwnGBJs7r7hE4ZM9itTDXHSbPtdh6mFOwb+5Z5J6mAsfNQpnH66N3yKeZDxGk5j/p4oLNG5/1bj8Q+MfnwP2eZCQYd/76j08xiVNDVeULaeIY5z9FjjwmQR8QtU05fy/QNF3lLeakQVvvXkveQ9uUUmZBidPNM5+sXLxKF67ztMUz17CMyaC6AS1spn7N41v6hvK9vdhBQRlXUSVxFdTjHfrQ++D7xp/PRcV+CtnT//07s3GO2dXLlxZPvn9P9/52+Lj+Z+OfrD0pwtblHL/V8IC3td/BVhXzp/56d2vvfnvlhY++/nRe9438yvnlBP4dbPDr5V2Xxe0u5en9zOdsnC+ceYo8CznVp+m+R4VphmcvcS3VgIBjeL45Al02c+c8O5cCxgioPGhIVb8uXegT0XinSXbrJUv1Fkk8o5ycvIwT/cPJ+ZgVpNQbeEknkj5lgndEY6Bz0AkykBxNCS7EwyPG/vRQOzrHVQmVwTnBtQuDe825Fji0b+iG01zUZm9qwivxJNm/hNi+b8dYmn+yah4eIjdDtbXuwH+GIzLA7VBxI1AluMnQoEJGX5bQqUOO96/TWCGKPmG7sTxRYNRysgpfH4iZvN4Y1uVINRQp6meRkkV4vZ7XG3WZWwybW7hCUG+6iy6VeZfxVk2njfLyGY1CdLyT4AH9dctVrXdG0IxKCPoCediRn6ZpKywQhNsk6SWeF2uvl5emGtTv1S7KGjWKueUf1cSj9euuJXhi9Yu0TOKMDzTlK5fzsWvKwQd42WebdjU0zz50KZ6YA4iFfuSKWIqYFtBA1jzFw03ENVKSc0veY5gg1WZE9tR07bh0lB+t2BNEDKryuf8j9iVAyyh/PK1Jq3n2uakM/9npD0KO05A/49a+d+lVtZFzF4uNgbwqrVeEBuDQ4MoP2KBvoE84eYckUmGLH9+a3H+NgbnVDJWCjL7Ud+l90+G9oJ5ym/j21uN984HNXBCY+TD0e9I0i/mzUuJcqGAwqo4LykSIIBQTk/Nbh2wWiKL1eNHqgSmOzt7FJ/VWg8bcx+crxsPgrU7Tt0RptqcnG6Bq2dxalqO5iVupkSSU72Tn+H7YpfypLxjfP8fnOownsKTXebPesfPrtw4wmvQrYttUDJm27CRXffft3HDkMJOi/7wwl77BEKxLuzrVsUozhQKr4sr13bgiTlQX7vZtywZI5vcZyZxh6y9z89sA63IIGJiPt0lCfskL15pmrXpqpW/LptnY3jfBi3Daru+gOII4w53YswoAdT6anxj/BFnMwsFVhCJbx+yRd6I9hdKVLyXMHKjdlyLw7uBsgrsvIRFzMR2/FqtFes67DyjTg5sm9UvWdseE+cpr3B2DejIJaBDXS7waUiILea/mIJ+S/EKQZmE+gdFCe4NbeREPZmpOyeVcTb+M2HrxK6eYk0Eev41i6KxZaiL62g3sHUY6B0azg4nJMWxDRiQvuySpKDqMNJx8DSdUQdpsQESuTX2Fqu1wEoP4XVEullKz8HTOQL/RO7la7mowqvhOMe6wyxCihdQprsCcJJw1dnLLVIQ1TtDTIsKxsJuFBHApCkJVyKGWel5OokBIrPh5CkqgeGMpD6ypFgez6wjq5wYz6rgExO2WmxqrMhP7zDLFRkAva/aCljdWUuTTmvtzlsmxW5aeNjYbfvzl52E79dwDrPF6a0EuNZ43Yd/JnPlxFncH1OdyQSJs9pTXd2/WIg+lhPyG44Lbbym2+j1s7BEE0eZkYR7ZZtck3y7bLgNU0SDw+w+bvZb9g3x4sa//Q2PEd2+yROMfCfAtaYqQW4RFmFmOYdc1OEduNGq7YESac2gXcCh7MuQCMR90Uj/MT+QYYjXKodRrEqJyeWkbWYd4DeKIhTWlHpYupN1MRK6dCI+uevy0SqeoLQ4fy4487A4f2T5+/v8dlLmz+IBicVHf1n+8hgmR/puRSiViQVA/HP0o22nFSLGdtNZF9+PVKKlM0XSVmPU1WHbIGojeD6Y7WxiJKR5z/s27iu2uHGtjaAe4VopGDR8GZsMWzShMZMUuY+ISN/L4pxZtI2q6/SwSnWaXQOZL9L/tTGrZpZ026Agt2Z8Ju20OTvPz7JTBrND/HY/Ywp1A3FmHOTbPbt37wPk4rWFaY0tkqZl8uIaRJCrVR1DW87+voPrctte2bHtd3uh9X5OD2n2cg9JhWVGxRgDUcGKHeJF5VVceHv9dkqrpGKYk6zOxwGszXAgM5eq2ZXUAec3BfhfXMW+PpMlKfgWCIdWc+yNku7qzSsxi7bl8LtcpHu+e+ghzIETs58JaoomwOhHeOOAppqmcDz0eyCNF0CmxONcsA8j9DQ1i9S/84aBCw2qOlYCbg9PqFoosq2l4eZ8awBFlFqESfyafrinz0sqCHhFWSPsNMcuehBlMJPAHqNgYiJIGGnrGHRnYG1gY6GMGMh7B54C3O49v9+7TxOkHYM6JT6l8HqR58m+CQoiDsicOFbNLtJcBUipArCPAxewCjRMrfJ6ioLL8F5Zdp+Q3swu8eti0hKWrJmqVVyjWqE4ApAK1qmxsCpjHsebgRZ4pxF0zuppOkXdZARdrlUqZA/OybUpJdMTFMbCLxx9igbETbC5TfVqlep2fl33b3ftfmnrLu3l3Xte2rl9+47XGIcymZT+RVkwxn5OcBg8PPDrus2uti3JAOB4B2ZhxAN1Nl5zLHhxCpwdsbrgBxLLBCwF9aHg5aSRePVcVfdBEWd8O6CboE9+pgoHGxcliRW977NncBrULLHFjA6BRFHJlQxHXOD5FCPJxbMXH3+I2Yh3HgTLEit6j4U6QOI3FwgG9LmFFbKG0ZJQxoo6s5n4ZbXFdJ5ypIPIXi/v3LVDk/QGHz/17xC/wdC/akkbhfJXJFQjkPm3CCeBlN5SwBDQgXxp7EB+LrazOse/5Aa++IA+1YE0yCT+MbSBOJcciMiwyTF4+C1uPazXnsB488vfhqjWgTdNt4IFjkH+2iUsqh3MMuL3tJijbEzPyUAJnIcMzASIknCdNP760C3TMNCBfOpAhjEt3jXd448W3N8tJk7x6Gsw79DV4qhkAINY4BpvrWfF2w+ySlY+pMC+YAOiEuTOAvzrUtvM+nXZUOtxTi+s6/ab4cNAGeft8Yo1lk79BmeUKfBZovvGipKBt4TpDcyfS/u14UdTNbecG/avM7ZppdlW3ETuWswwES3EwDHIojqyEPI5xBt5BxRqcSLNXMpC5PJmv4Qyql2zlC6nZgEErCMtRqkjkHzm8IWWjCRJJooxRCirabX0BF2sSwCxHb4A/Fagq1EYmQ1B4wVNBUc3Ddd4m6WF4OkSnalo+FuyAALrJE/2Yh1qXmYLGlZRXZRso4wp6fBAx5xmLN2GvQdKv4crzzLFXE6EzkGqBMbAa/FpaYRMou2A9/PSKSBfBopDimha8ZHyqyE6v9QIFjPGKuUpaSLYrhDaxgSJVaNroFZ2h7i/YHbqwFjZPOB0S1WPthT8IkRz6tpEmS2omNPA/lGSjK5phFhbrDAuKIcBV9MvB8lNLQymG6FlRWxIN9fjRXYwLX90QWRVG6yhdIoZruKrXGCsVtn2JAEbucJXrQDTQQk56mDVYLeE/j33MGQLG8BojtKt+Nbh9/qDRkn7DUNUz4Eqp3JkVnxfVw3MXXPdANNm7wzowakdhw033ZcRxZh6SvRQjwlmuTI8FkYU87Z7s7jLl+3bxLYGunt6niN7saClzCo+0nFGzLBmjsSUPkNEFQcilWYl0AgLb7PvMM2Edcp73i516msSaAtuBBH05Ot3tFvRoOP1Rx2WxzVVxT8Y42GxxWrNzcIg1iQwbJb37xjjpjC4swiHbKOjiqnZXCZU9ZmKpZecPEpArGeAVbAKhVeAQ6j9ql4d4U+dCb2/UJjdboyDZs+SvfBxaEOd3QgL/QH4L+987bc79ry+Z+dr+7RXdvxR2wWCmlc6Bqbr28BaSjVDZSyhbOEFQXkBW7z7tYJmN+JTttoRiiqV2k+CP4aCAKGv1sbSzNDIROqZ+cXCEiqKSlWOsJJjIV5WVL7DPFRWVHl7HF+OziqLdidl2bWqLBoLiPERY8VFW9032Elx0VbjiPqibUFRlRhtBVf0/WiV0W41zldbI53OiCrpdCa5TnpopFCd9Pg3QZ30KJYVddLbIk0uld4ew7Fq6a3X7eD6jpZAeTFqs3yrgvZ9LVi29XHU8xj3bRZ5fT6pyGsEGM6I0b54L5oFng6rF6vCIqNlzOgsG4dxdRmcTR7kX9S37Pe9+YPK6+/8QmAxCEK1aNvcPs6KeLWWd2jAcxmG91nb8ByMr5rhohvBSyk35bcIJmGcpinxMOzFRwhbVliqDWseccVUspjPBE3A4Q5pslYSU11s8T/y83+B/Fw1pzNrP7QP3eToyBecv6N5gCFuV3e15Rl0FefPMBeyDRyH85ewecCzc3VmxPkWV9PkYE61aGa4LCEmyjDia60M7anNbVa2Cwdcsr82fFDBJkgzJWZC4VYvs6Gw5Ck+SIf2I9kZRsxCk7iAtyscbibHxPfIUqyX0dl6lllsQxtGw9WH2Nf5CjVDUqwLhtqfzytMOFGyKyNQGUaA2Nttzh4fJIoIFbrwBUyYYWCxI7bBim2zkHSD4GWO1TPm9h/zQlFGBvW/MaLlWqC7fQkHnq6/psL2ZrGiiuWAM5FXToT1rU0o5iHXEg/V5wtqkLJ54EeNXfelMXEIM1p/oDZbpuVyHbRf8+vATsBbIFQ1SpvLyZ4JL07s6MJL+1NIteiMpVjwnv1llNgvIGF7hv1VSB1s9pQ3sDyoxDR53ZxJz3GtOBeAweBzmAIQqtQ/9yxDBW59DCZVD+tfCKvqFN7+Uat29u4svDunhmz9/ki/uFcPzs1kBCy5CS5RzrJzY4aJm4xhEVHG2IjmUNMxmM89wbyfNCpaoAPZI3pNx2KsQBRYfSOk/agjMyK+GpTXkFgthXvTli0iMpH95zkRCs21acQ9vfhzcKhzSd8dzulVIwfmg+qrccsab/U9wpNzMR1Z+W0RY49J309PT7P30fopYkC/xZwjbRgBhDAp1Urkw0kU3aItd507bNw01VKyQHqZCVvCSUMEACQf3vdZqxW9iFKv2UtujOpMEHLvNiZ9uBQXNOcE5pb4DPQX+OIKqSpatWJ07vPgbLMchMycopZPEv0rLZmmUsIm9dEXfKN6c7R6nbqcni9BGTggP9lvocJDHkLqBSwkU3PLw5uV3hCsC8eOHzrA/VVYEAyP487wmAE6wjYqM6Si2+N0BL6vsto0wVwjNrH63LqJh4NKBR/3PsDFCZ15lZhCne7b0JvsPgYxRd4T18DkBdEP/5SQARfF9azfSf2fR75OKdI3k1HeujtlX8mujkxiYtqFwgvog2ubN4cIUDinJGCnUKHXbrnQK3spUujVf8TF8GzADVmx9H9AHNYTK8KiQxMETUG/l6ijSd6YcHI4+WCibuDyxcvCYhRutFknXq8YRVrgsuVFelhHPwx3egrDg4MDPSX90BYmo0b1sWJX2bJG+54Hq+cQirfILRfBBvNoQtwqXjmWF60X70m165Vg8HACgJBlAB1UVqJ9zu9NEoVshuGgQqvmvpjtuPuxYueNfdy1ry7LXWctvPAl26r6i41Lb9PKTNsVHqsVJ6mbF0jtwQj0lj/mtk69ndsWUNDo1t/t3N7Fn+6FkXUs1TIqUDHSUeHa9o53NObQCsKUqlxtAqoURjkWRsZy7zYm6aNYZEzhr4NmWpp4KY49PmlA4FjKmczxTzkerc4JMzxK+mXDdvi2TNw74B0k0D57MdvqPRVhsbdCtqTwmPoHA48pgST5qx0Se7QxR0In1ZuZSNOETcBJQ9bMXE7FUY+RE980GSWBjIynZ4tGeQALQE3LNmNIoPINao17PtiMJVSNMrmcU8092rFY8Bwew23ZtV7FYtDM1O1BQz7crayAA3c4Zix1iQeqxQiUrrx2/qRDpkuHb8szG20NvSTQ4kAoUZks1QL/VCvxXaGa4YDXofm+L/dgnUCICDATy2dHvV7uVJLZ+gHzgMkSINDPS2C/2MsvTLhTlc2sKhUtvdDDPrFOhM/XaT+zB1LW5IFUAS91qLMOms6dhKP6uv8PCAMNBA7FAAA=" - -patch = gzip.decompress(base64.b64decode(PATCH_B64)) -patch_path = Path("/tmp/global-hardening-final.patch") -patch_path.write_bytes(patch) -subprocess.run(["git", "apply", "--check", str(patch_path)], check=True) -subprocess.run(["git", "apply", str(patch_path)], check=True) -print(f"Applied final global hardening patch ({len(patch)} bytes)") diff --git a/scripts/check_rust_failure_boundaries.py b/scripts/check_rust_failure_boundaries.py index ce981b13842..9dc6223e53d 100644 --- a/scripts/check_rust_failure_boundaries.py +++ b/scripts/check_rust_failure_boundaries.py @@ -4,21 +4,55 @@ import sys ROOT = Path(__file__).resolve().parents[1] -CHECKS = [ - (ROOT / "src-tauri/src/lib.rs", re.compile(r'Deep link URL \(raw\)|"url"\s*:\s*url_str'), "raw deep-link data must not cross the diagnostics/event boundary"), - (ROOT / "src-tauri/src/settings.rs", re.compile(r"let _ = set_current_provider\("), "current-provider persistence errors must propagate"), - (ROOT / "src-tauri/src/services/webdav_auto_sync.rs", re.compile(r"let _ = settings::update_webdav_sync_status\("), "WebDAV auto-sync status persistence errors must be observable"), - (ROOT / "src-tauri/src/services/s3_auto_sync.rs", re.compile(r"let _ = settings::update_s3_sync_status\("), "S3 auto-sync status persistence errors must be observable"), +RUST_ROOT = ROOT / "src-tauri" / "src" + +# These are source-level regression guards for failure modes that previously existed in multiple +# entry points. They intentionally scan the full Rust tree where the same boundary can reappear. +GLOBAL_FORBIDDEN = [ + (re.compile(r'Deep link URL \(raw\)|"url"\s*:\s*url_str'), "raw deep-link data must not cross diagnostics/events"), + (re.compile(r'Parsing deep link URL:\s*\{url\}'), "deep-link commands must log only redacted URLs"), + (re.compile(r'请求 URL:\s*\{url\}'), "upstream URLs must be redacted before logging"), + (re.compile(r'Trying endpoint:\s*\{url\}'), "model-discovery URLs must be redacted before logging"), + (re.compile(r'上游响应体内容'), "raw upstream response bodies must not be persisted to logs"), + (re.compile(r'body:\s*\{body_str\}'), "raw upstream response bodies must not be persisted to logs"), +] + +FILE_CHECKS = [ + ("settings.rs", re.compile(r"let _ = set_current_provider\("), "current-provider persistence errors must propagate"), + ("services/webdav_auto_sync.rs", re.compile(r"let _ = settings::update_webdav_sync_status\("), "WebDAV auto-sync status persistence errors must be observable"), + ("services/s3_auto_sync.rs", re.compile(r"let _ = settings::update_s3_sync_status\("), "S3 auto-sync status persistence errors must be observable"), + ("services/proxy.rs", re.compile(r"let _ = (?:self\.db\.|crate::settings::|self\.write_|self\.stop\(\)|self\.restore_live_|crate::config::delete_file)"), "proxy state/write/rollback failures must not be silently discarded"), + ("codex_config.rs", re.compile(r"let _ = (?:atomic_write|delete_file)\("), "Codex config rollback failures must be observable"), + ("config.rs", re.compile(r'PathBuf::from\("\."\)'), "home/config resolution must never silently fall back to the process CWD"), ] failures = [] -for path, pattern, message in CHECKS: +for path in RUST_ROOT.rglob("*.rs"): + text = path.read_text(encoding="utf-8") + rel = path.relative_to(ROOT) + for pattern, message in GLOBAL_FORBIDDEN: + if pattern.search(text): + failures.append(f"{rel}: {message}") + +for rel_path, pattern, message in FILE_CHECKS: + path = RUST_ROOT / rel_path if pattern.search(path.read_text(encoding="utf-8")): failures.append(f"{path.relative_to(ROOT)}: {message}") +# URL sanitization is a common diagnostics boundary. Specialized copies drift and caused raw +# deep-link/model-fetch paths to be missed; keep implementations centralized. +for path in RUST_ROOT.rglob("*.rs"): + if path.name == "diagnostics.rs": + continue + text = path.read_text(encoding="utf-8") + if re.search(r"\bfn\s+redact_url(?:_for_log|_without_query_for_log)?\s*\(", text): + failures.append( + f"{path.relative_to(ROOT)}: URL redaction helpers must live in diagnostics.rs" + ) + if failures: print("Rust failure-boundary policy violations:", file=sys.stderr) - for failure in failures: + for failure in sorted(set(failures)): print(f"- {failure}", file=sys.stderr) raise SystemExit(1) diff --git a/src-tauri/src/codex_config.rs b/src-tauri/src/codex_config.rs index 9de8485c18e..403ca56d563 100644 --- a/src-tauri/src/codex_config.rs +++ b/src-tauri/src/codex_config.rs @@ -148,11 +148,16 @@ pub fn write_codex_live_atomic( // 第二步:写 config.toml(失败则回滚 auth.json) if let Err(e) = write_text_file(&config_path, &cfg_text) { - // 回滚 auth.json - if let Some(bytes) = old_auth { - let _ = atomic_write(&auth_path, &bytes); + // 回滚 auth.json;二次失败必须与主错误一起返回,不能伪装成已恢复。 + let rollback = if let Some(bytes) = old_auth { + atomic_write(&auth_path, &bytes) } else { - let _ = delete_file(&auth_path); + delete_file(&auth_path) + }; + if let Err(rollback_err) = rollback { + return Err(AppError::Config(format!( + "写入 Codex config 失败: {e}; auth rollback also failed: {rollback_err}" + ))); } return Err(e); } @@ -464,9 +469,7 @@ fn read_test_codex_oauth_context_window_override() -> Option Result { - log::info!("Parsing deep link URL: {url}"); + log::info!( + "Parsing deep link URL: {}", + crate::diagnostics::redact_url_for_log(&url) + ); parse_deeplink_url(&url).map_err(|e| e.to_string()) } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 69df1c7a89c..ea9920f90c4 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -23,17 +23,36 @@ const ATOMIC_TEMP_CREATE_ATTEMPTS: usize = 16; /// /// 为了让 Windows CI/本地测试能稳定隔离真实用户数据,可通过 `CC_SWITCH_TEST_HOME` /// 显式覆盖 home dir(仅用于测试/调试场景)。 -pub fn get_home_dir() -> PathBuf { - if let Ok(home) = std::env::var("CC_SWITCH_TEST_HOME") { - let trimmed = home.trim(); - if !trimmed.is_empty() { - return PathBuf::from(trimmed); +fn resolve_home_dir( + test_override: Option<&str>, + detected: Option, +) -> Result { + if let Some(home) = test_override.map(str::trim).filter(|home| !home.is_empty()) { + return Ok(PathBuf::from(home)); + } + + match detected { + Some(path) if path.is_absolute() => Ok(path), + Some(path) => Err(format!( + "操作系统返回了非绝对用户主目录路径: {}", + path.display() + )), + None => { + Err("无法获取用户主目录;拒绝回退到当前工作目录,以避免配置/数据库静默分叉".to_string()) } } +} - dirs::home_dir().unwrap_or_else(|| { - log::warn!("无法获取用户主目录,回退到当前目录"); - PathBuf::from(".") +/// 获取用户主目录。 +/// +/// 用户主目录是数据库、设置和多个 CLI 配置路径的共同根。无法解析时必须 fail closed: +/// 旧行为回退到 `.` 会根据启动方式把同一用户的数据写进任意 CWD,表现为供应商/设置丢失, +/// 也可能把凭据写进意外目录。 +pub fn get_home_dir() -> PathBuf { + let test_override = std::env::var("CC_SWITCH_TEST_HOME").ok(); + resolve_home_dir(test_override.as_deref(), dirs::home_dir()).unwrap_or_else(|err| { + log::error!("{err}"); + panic!("{err}"); }) } @@ -419,6 +438,25 @@ pub fn atomic_write(path: &Path, data: &[u8]) -> Result<(), AppError> { #[cfg(test)] mod tests { use super::*; + + #[test] + fn home_resolution_fails_closed_when_os_home_is_missing() { + let err = resolve_home_dir(None, None).expect_err("missing home must not fall back to cwd"); + assert!(err.contains("拒绝回退到当前工作目录")); + } + + #[test] + fn home_resolution_rejects_relative_os_path() { + assert!(resolve_home_dir(None, Some(PathBuf::from("relative-home"))).is_err()); + } + + #[test] + fn explicit_test_home_override_remains_supported() { + assert_eq!( + resolve_home_dir(Some("test-home"), None).unwrap(), + PathBuf::from("test-home") + ); + } use std::collections::HashSet; #[test] diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs new file mode 100644 index 00000000000..acf77bb86b1 --- /dev/null +++ b/src-tauri/src/diagnostics.rs @@ -0,0 +1,204 @@ +//! Safe diagnostics helpers for data that may contain credentials or user content. +//! +//! Diagnostics must be useful without persisting raw secrets, prompts, model output, cookies, +//! signed URLs, or deep-link configuration payloads. + +use http::HeaderMap; +use sha2::{Digest, Sha256}; + +const FINGERPRINT_HEX_LEN: usize = 16; + +/// Redact credentials and query values while retaining endpoint shape and query key names. +pub(crate) fn redact_url_for_log(raw: &str) -> String { + match url::Url::parse(raw) { + Ok(parsed) => { + let mut output = format!("{}://", parsed.scheme()); + if let Some(host) = parsed.host_str() { + output.push_str(host); + } + if let Some(port) = parsed.port() { + output.push(':'); + output.push_str(&port.to_string()); + } + output.push_str(parsed.path()); + + let mut keys: Vec = parsed + .query_pairs() + .map(|(key, _)| key.into_owned()) + .collect(); + keys.sort(); + keys.dedup(); + if !keys.is_empty() { + output.push_str("?[keys:"); + output.push_str(&keys.join(",")); + output.push(']'); + } + output + } + Err(_) => { + let without_fragment = raw.split('#').next().unwrap_or(raw); + match without_fragment.split_once('?') { + Some((prefix, _)) => format!("{prefix}?[redacted]"), + None => without_fragment.to_string(), + } + } + } +} + +/// Redact credentials and all query material. Suitable for signed URLs where even key names are +/// implementation details that do not improve diagnostics. +pub(crate) fn redact_url_without_query_for_log(raw: &str) -> String { + match url::Url::parse(raw) { + Ok(parsed) => { + let mut output = format!("{}://", parsed.scheme()); + if let Some(host) = parsed.host_str() { + output.push_str(host); + } + if let Some(port) = parsed.port() { + output.push(':'); + output.push_str(&port.to_string()); + } + output.push_str(parsed.path()); + output + } + Err(_) => raw + .split('#') + .next() + .unwrap_or(raw) + .split('?') + .next() + .unwrap_or(raw) + .to_string(), + } +} + +/// Return stable payload metadata without retaining the payload itself. +pub(crate) fn payload_fingerprint(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let hex = format!("{digest:x}"); + format!( + "bytes={}, sha256={}", + bytes.len(), + &hex[..FINGERPRINT_HEX_LEN] + ) +} + +pub(crate) fn text_fingerprint(text: &str) -> String { + payload_fingerprint(text.as_bytes()) +} + +/// Coarse response-body shape used for transport/protocol diagnosis without content disclosure. +pub(crate) fn text_shape_hint(text: &str) -> &'static str { + let trimmed = text.trim_start_matches('\u{feff}').trim_start(); + if trimmed.is_empty() { + "empty" + } else if ["data:", "event:", "id:", "retry:", ":"] + .iter() + .any(|prefix| trimmed.starts_with(prefix)) + { + "sse" + } else if trimmed.starts_with('<') { + "markup" + } else if trimmed.starts_with('{') || trimmed.starts_with('[') { + "json-like" + } else { + "text-or-binary" + } +} + +fn is_sensitive_header(name: &http::HeaderName) -> bool { + matches!( + name.as_str(), + "authorization" + | "proxy-authorization" + | "cookie" + | "set-cookie" + | "x-api-key" + | "x-goog-api-key" + | "x-auth-token" + | "x-access-token" + | "www-authenticate" + | "proxy-authenticate" + ) || name.as_str().contains("token") + || name.as_str().contains("secret") + || name.as_str().contains("credential") +} + +/// Format headers for diagnostics while replacing credential-bearing values. +pub(crate) fn format_headers_for_log(headers: &HeaderMap) -> String { + headers + .iter() + .map(|(name, value)| { + if is_sensitive_header(name) { + format!("{name}=") + } else { + let value = value.to_str().unwrap_or(""); + // Header values can still be arbitrarily large; cap non-sensitive diagnostics. + let rendered: String = value.chars().take(160).collect(); + if rendered.len() < value.len() { + format!("{name}={rendered}…") + } else { + format!("{name}={rendered}") + } + } + }) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + use http::{HeaderMap, HeaderValue}; + + #[test] + fn url_redaction_hides_credentials_query_values_and_fragment() { + let raw = "https://alice:secret@example.com:8443/dav?token=abc&foo=1#private"; + let redacted = redact_url_for_log(raw); + assert_eq!(redacted, "https://example.com:8443/dav?[keys:foo,token]"); + assert!(!redacted.contains("alice")); + assert!(!redacted.contains("secret")); + assert!(!redacted.contains("abc")); + assert!(!redacted.contains("private")); + } + + #[test] + fn signed_url_redaction_drops_query_entirely() { + let raw = "https://bucket.example/file?X-Amz-Credential=AKID&X-Amz-Signature=secret"; + assert_eq!( + redact_url_without_query_for_log(raw), + "https://bucket.example/file" + ); + } + + #[test] + fn payload_fingerprint_is_deterministic_and_contains_no_payload() { + let secret = b"sk-secret-prompt-content"; + let first = payload_fingerprint(secret); + assert_eq!(first, payload_fingerprint(secret)); + assert!(first.starts_with("bytes=24, sha256=")); + assert!(!first.contains("secret")); + assert!(!first.contains("prompt")); + } + + #[test] + fn header_format_redacts_sensitive_values() { + let mut headers = HeaderMap::new(); + headers.insert( + "set-cookie", + HeaderValue::from_static("session=super-secret"), + ); + headers.insert("content-type", HeaderValue::from_static("application/json")); + let rendered = format_headers_for_log(&headers); + assert!(rendered.contains("set-cookie=")); + assert!(rendered.contains("content-type=application/json")); + assert!(!rendered.contains("super-secret")); + } + + #[test] + fn shape_hint_distinguishes_protocol_shapes_without_content() { + assert_eq!(text_shape_hint("data: {}\n\n"), "sse"); + assert_eq!(text_shape_hint("blocked"), "markup"); + assert_eq!(text_shape_hint("{\"ok\":true}"), "json-like"); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 008c3c59f69..542432785a0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod commands; mod config; mod database; mod deeplink; +mod diagnostics; mod error; mod gemini_config; mod gemini_mcp; @@ -87,37 +88,6 @@ fn set_windows_app_user_model_id(app: &tauri::AppHandle) { } } -fn redact_url_for_log(url_str: &str) -> String { - match url::Url::parse(url_str) { - Ok(url) => { - let mut output = format!("{}://", url.scheme()); - if let Some(host) = url.host_str() { - output.push_str(host); - } - output.push_str(url.path()); - - let mut keys: Vec = url.query_pairs().map(|(k, _)| k.to_string()).collect(); - keys.sort(); - keys.dedup(); - - if !keys.is_empty() { - output.push_str("?[keys:"); - output.push_str(&keys.join(",")); - output.push(']'); - } - - output - } - Err(_) => { - let base = url_str.split('#').next().unwrap_or(url_str); - match base.split_once('?') { - Some((prefix, _)) => format!("{prefix}?[redacted]"), - None => base.to_string(), - } - } - } -} - /// 统一处理 ccswitch:// 深链接 URL /// /// - 解析 URL @@ -133,7 +103,7 @@ fn handle_deeplink_url( return false; } - let redacted_url = redact_url_for_log(url_str); + let redacted_url = crate::diagnostics::redact_url_for_log(url_str); log::info!("✓ Deep link URL detected from {source}: {redacted_url}"); log::debug!( "Deep link URL metadata from {source}: length={}, redacted={redacted_url}", @@ -234,7 +204,7 @@ pub fn run() { log::info!("=== Single Instance Callback Triggered ==="); log::debug!("Args count: {}", args.len()); for (i, arg) in args.iter().enumerate() { - log::debug!(" arg[{i}]: {}", redact_url_for_log(arg)); + log::debug!(" arg[{i}]: {}", crate::diagnostics::redact_url_for_log(arg)); } if crate::lightweight::is_lightweight_mode() { @@ -925,7 +895,7 @@ pub fn run() { for (i, url) in urls.iter().enumerate() { let url_str = url.as_str(); - log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str)); + log::debug!(" URL[{i}]: {}", crate::diagnostics::redact_url_for_log(url_str)); if handle_deeplink_url(&app_handle, url_str, true, "on_open_url") { break; // Process only first ccswitch:// URL @@ -1651,7 +1621,10 @@ pub fn run() { RunEvent::Opened { urls } => { if let Some(url) = urls.first() { let url_str = url.as_str(); - log::debug!("RunEvent::Opened URL: {}", redact_url_for_log(url_str)); + log::debug!( + "RunEvent::Opened URL: {}", + crate::diagnostics::redact_url_for_log(url_str) + ); if url_str.starts_with("ccswitch://") && crate::lightweight::is_lightweight_mode() @@ -2122,12 +2095,11 @@ mod tests { #[cfg(test)] mod sensitive_deeplink_boundary_tests { - use super::redact_url_for_log; #[test] fn deep_link_log_redaction_keeps_keys_but_never_secret_values() { let raw = "ccswitch://v1/import?resource=provider&name=demo&apiKey=sk-secret&usageAccessToken=token-secret#fragment-secret"; - let redacted = redact_url_for_log(raw); + let redacted = crate::diagnostics::redact_url_for_log(raw); assert!(redacted.contains("apiKey")); assert!(redacted.contains("usageAccessToken")); @@ -2139,7 +2111,7 @@ mod sensitive_deeplink_boundary_tests { #[test] fn malformed_deep_link_redaction_drops_query_values() { let raw = "ccswitch://v1/import?apiKey=top-secret%ZZ"; - let redacted = redact_url_for_log(raw); + let redacted = crate::diagnostics::redact_url_for_log(raw); assert!(!redacted.contains("top-secret")); assert!(redacted.contains("?[redacted]") || redacted.contains("?[keys:")); } diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index 90ad8f11af3..be60ca24984 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -2213,7 +2213,10 @@ impl RequestForwarder { ); } } - log::info!("[{tag}] >>> 请求 URL: {url} (model={request_model})"); + log::info!( + "[{tag}] >>> 请求 URL: {} (model={request_model})", + crate::diagnostics::redact_url_for_log(&url) + ); if log::log_enabled!(log::Level::Debug) { log::debug!( "[{tag}] >>> 请求体摘要: bytes={}, body_hash={}", diff --git a/src-tauri/src/proxy/handlers.rs b/src-tauri/src/proxy/handlers.rs index a88a76e7bfa..c42ee21ad3f 100644 --- a/src-tauri/src/proxy/handlers.rs +++ b/src-tauri/src/proxy/handlers.rs @@ -588,12 +588,18 @@ async fn handle_claude_transform( // 现场诊断(content-type/body 摘要),否则命中嗅探臂的用户只拿到 // 裸聚合错误、丢失非嗅探臂已有的诊断增强(C7) aggregated.map_err(|e| { - log::error!("[Claude] SSE 聚合兜底失败: {e}, body: {body_str}"); + log::error!( + "[Claude] SSE 聚合兜底失败: {e}, body: {}", + crate::diagnostics::text_fingerprint(&body_str) + ); aggregate_fallback_error(e, &response_headers, &body_str) })? } Err(e) => { - log::error!("[Claude] 解析上游响应失败: {e}, body: {body_str}"); + log::error!( + "[Claude] 解析上游响应失败: {e}, body: {}", + crate::diagnostics::text_fingerprint(&body_str) + ); return Err(upstream_body_parse_error( "Failed to parse upstream response", &e, @@ -2084,12 +2090,18 @@ async fn handle_codex_chat_to_responses_transform( log::warn!("[Codex] 上游对非流请求返回未标记的 SSE 体,按 Chat SSE 聚合兜底"); // 聚合也失败时:保留全量 body 服务端日志,并给客户端错误附带现场诊断(C7) chat_sse_to_response_value(&body_str).map_err(|e| { - log::error!("[Codex] SSE 聚合兜底失败: {e}, body: {body_str}"); + log::error!( + "[Codex] SSE 聚合兜底失败: {e}, body: {}", + crate::diagnostics::text_fingerprint(&body_str) + ); aggregate_fallback_error(e, &response_headers, &body_str) })? } Err(e) => { - log::error!("[Codex] 解析 Chat 上游响应失败: {e}, body: {body_str}"); + log::error!( + "[Codex] 解析 Chat 上游响应失败: {e}, body: {}", + crate::diagnostics::text_fingerprint(&body_str) + ); return Err(upstream_body_parse_error( "Failed to parse upstream chat response", &e, @@ -2695,10 +2707,11 @@ fn body_diagnostics_suffix(headers: &axum::http::HeaderMap, body: &str) -> Strin .unwrap_or("") }; format!( - "(content-type: {}; content-encoding: {}; body[..120]: '{}')", + "(content-type: {}; content-encoding: {}; body-shape: {}; body: {})", header_str("content-type"), header_str("content-encoding"), - body_snippet(body, 120), + crate::diagnostics::text_shape_hint(body), + crate::diagnostics::text_fingerprint(body), ) } @@ -2715,24 +2728,6 @@ fn error_event_message(error: &Value) -> Option { None } -/// 取 body 前 `max_chars` 个字符的单行摘要:\r 丢弃、\n 折叠为字面 \n、 -/// 其余控制字符替换为 �,超长加省略号。 -fn body_snippet(body: &str, max_chars: usize) -> String { - let mut snippet = String::new(); - for c in body.chars().take(max_chars) { - match c { - '\n' => snippet.push_str("\\n"), - '\r' => {} - c if c.is_control() => snippet.push('\u{FFFD}'), - c => snippet.push(c), - } - } - if body.chars().nth(max_chars).is_some() { - snippet.push('…'); - } - snippet -} - /// 解析单个 SSE 块的 event 名与 data 负载(多行 data 按规范以 \n 连接)。 /// 行首允许前导空白后再匹配字段名——与 body_looks_like_sse 的 trim 宽容度对齐, /// 否则缩进的 ` data:` 行被嗅探接受却在此静默丢失(C4)。返回 None 表示无 data 行。 @@ -3271,8 +3266,8 @@ async fn log_usage( #[cfg(test)] mod tests { use super::{ - body_looks_like_sse, body_snippet, chat_sse_to_response_value, - codex_catalog_models_response, codex_proxy_error_json, external_openai_api_models_response, + body_looks_like_sse, chat_sse_to_response_value, codex_catalog_models_response, + codex_proxy_error_json, external_openai_api_models_response, external_openai_api_unsupported_response, resolve_external_codex_router_target, resolve_forward_error_provider_for_logging, responses_sse_to_response_value, should_handle_as_codex_client, should_use_claude_transform_streaming, transform, @@ -3397,7 +3392,10 @@ mod tests { ProxyError::TransformError(msg) => { assert!(msg.contains("content-type: text/html"), "{msg}"); assert!(msg.contains("content-encoding: gzip"), "{msg}"); - assert!(msg.contains("\\nblocked"), "{msg}"); + assert!(msg.contains("body-shape: markup"), "{msg}"); + assert!(msg.contains("body: bytes=21, sha256="), "{msg}"); + assert!(!msg.contains(""), "{msg}"); + assert!(!msg.contains("blocked"), "{msg}"); } other => panic!("expected TransformError, got {other:?}"), } @@ -3558,18 +3556,6 @@ data: {\"id\":\"c1\",\"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant assert_eq!(response["choices"][0]["message"]["content"], "hi"); } - #[test] - fn body_snippet_sanitizes_controls_and_truncates() { - assert_eq!( - body_snippet("\r\nblocked\u{0}", 120), - "\\nblocked\u{FFFD}" - ); - let long = "a".repeat(200); - let snippet = body_snippet(&long, 120); - assert_eq!(snippet.chars().count(), 121); // 120 个字符 + 省略号 - assert!(snippet.ends_with('…')); - } - #[test] fn chat_sse_to_response_value_aggregates_text_finish_reason_and_usage() { let sse = "data: {\"id\":\"chatcmpl-1\",\"object\":\"chat.completion.chunk\",\"created\":123,\"model\":\"gpt-5.4\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"Hel\"},\"finish_reason\":null}]}\n\n\ diff --git a/src-tauri/src/proxy/response_processor.rs b/src-tauri/src/proxy/response_processor.rs index 96ab49ebf82..f0e144055e5 100644 --- a/src-tauri/src/proxy/response_processor.rs +++ b/src-tauri/src/proxy/response_processor.rs @@ -103,7 +103,7 @@ pub(crate) async fn read_decoded_body( "[{tag}] 已接收上游响应体: status={}, bytes={}, headers={}", status.as_u16(), raw_bytes.len(), - format_headers(&headers) + crate::diagnostics::format_headers_for_log(&headers) ); let mut body_bytes = raw_bytes.clone(); @@ -155,7 +155,7 @@ pub async fn handle_streaming( "[{}] 已接收上游流式响应: status={}, headers={}", ctx.tag, status.as_u16(), - format_headers(response.headers()) + crate::diagnostics::format_headers_for_log(response.headers()) ); // 检查流式响应是否被压缩(SSE 通常不压缩,如果压缩则 SSE 解析会失败) if let Some(encoding) = get_content_encoding(response.headers()) { @@ -225,9 +225,9 @@ pub async fn handle_non_streaming( strip_hop_by_hop_response_headers(&mut response_headers); log::debug!( - "[{}] 上游响应体内容: {}", + "[{}] 上游响应体诊断: {}", ctx.tag, - String::from_utf8_lossy(&body_bytes) + crate::diagnostics::payload_fingerprint(&body_bytes) ); // 解析并记录使用量。关闭 usage logging 时直接跳过,避免非流式响应整包 JSON parse。 @@ -798,17 +798,6 @@ pub fn create_logged_passthrough_stream( } } -fn format_headers(headers: &HeaderMap) -> String { - headers - .iter() - .map(|(key, value)| { - let value_str = value.to_str().unwrap_or(""); - format!("{key}={value_str}") - }) - .collect::>() - .join(", ") -} - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/services/model_fetch.rs b/src-tauri/src/services/model_fetch.rs index 38ee616a041..219c275825a 100644 --- a/src-tauri/src/services/model_fetch.rs +++ b/src-tauri/src/services/model_fetch.rs @@ -181,7 +181,10 @@ pub async fn fetch_models(options: FetchModelsRequest<'_>) -> Result = None; for url in &candidates { - log::debug!("[ModelFetch] Trying endpoint: {url}"); + log::debug!( + "[ModelFetch] Trying endpoint: {}", + crate::diagnostics::redact_url_for_log(url) + ); let mut request_builder = client .get(url) .header("Authorization", format!("Bearer {}", options.api_key)) diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs index 72127b0e53e..7499d6499b3 100644 --- a/src-tauri/src/services/proxy.rs +++ b/src-tauri/src/services/proxy.rs @@ -400,6 +400,105 @@ impl ProxyService { .ok_or_else(|| format!("{app_type:?} 当前供应商不存在,无法接管 Live 配置")) } + fn with_recovery_errors(primary: impl Into, recovery_errors: Vec) -> String { + let primary = primary.into(); + if recovery_errors.is_empty() { + primary + } else { + format!( + "{primary}; recovery also failed: {}", + recovery_errors.join(" | ") + ) + } + } + + async fn finalize_takeover_shutdown_if_unused(&self) -> Result<(), String> { + let any_enabled = self + .db + .is_live_takeover_active() + .await + .map_err(|e| format!("检查接管状态失败: {e}"))?; + if any_enabled { + return Ok(()); + } + + self.db + .set_live_takeover_active(false) + .await + .map_err(|e| format!("清除兼容接管标志失败: {e}"))?; + if self.is_running().await { + self.stop() + .await + .map_err(|e| format!("最后一个接管关闭后停止代理失败: {e}"))?; + } + Ok(()) + } + + async fn recover_after_takeover_failure( + &self, + primary: impl Into, + started_proxy_before_takeover: bool, + ) -> String { + let mut recovery_errors = Vec::new(); + match self.restore_live_configs().await { + Ok(()) => { + if let Err(e) = self.db.set_live_takeover_active(false).await { + recovery_errors.push(format!("清除接管标志失败: {e}")); + } + if let Err(e) = self.db.delete_all_live_backups().await { + recovery_errors.push(format!("清理 Live 备份失败: {e}")); + } + } + Err(e) => { + // 保留 marker + backup,供下次启动继续恢复。 + recovery_errors.push(format!("恢复原始 Live 配置失败,已保留恢复库存: {e}")); + } + } + if started_proxy_before_takeover { + if let Err(e) = self.stop().await { + recovery_errors.push(format!("停止临时启动的代理失败: {e}")); + } + } + Self::with_recovery_errors(primary, recovery_errors) + } + + async fn rollback_provider_switch_takeover( + &self, + app_type: &AppType, + previous_current: Option<&str>, + previous_enabled: bool, + ) -> Vec { + let app_type_str = app_type.as_str(); + let mut errors = Vec::new(); + + // set_current_provider with a non-existent empty id clears the DB is_current flag, + // which restores the legitimate previous None state as well as Some(id). + if let Err(e) = self + .db + .set_current_provider(app_type_str, previous_current.unwrap_or("")) + { + errors.push(format!("恢复 DB current provider 失败: {e}")); + } + if let Err(e) = crate::settings::set_current_provider(app_type, previous_current) { + errors.push(format!("恢复本地 current provider 失败: {e}")); + } + + match self.db.get_proxy_config_for_app(app_type_str).await { + Ok(mut config) => { + config.enabled = previous_enabled; + if let Err(e) = self.db.update_proxy_config_for_app(config).await { + errors.push(format!("恢复 {app_type_str} enabled 状态失败: {e}")); + } + } + Err(e) => errors.push(format!("读取 {app_type_str} rollback 配置失败: {e}")), + } + + if let Err(e) = self.restore_live_config_for_app_inner(app_type).await { + errors.push(format!("恢复 {app_type_str} Live 配置失败: {e}")); + } + errors + } + /// 设置 AppHandle(在应用初始化时调用) pub fn set_app_handle(&self, handle: tauri::AppHandle) { futures::executor::block_on(async { @@ -460,8 +559,11 @@ impl ProxyService { .persist_ephemeral_listen_port_if_needed(&config, info.port) .await { - let _ = server.stop().await; - return Err(e); + let mut recovery_errors = Vec::new(); + if let Err(stop_err) = server.stop().await { + recovery_errors.push(format!("持久化临时端口失败后停止代理失败: {stop_err}")); + } + return Err(Self::with_recovery_errors(e, recovery_errors)); } // 5. 保存服务器实例 @@ -578,53 +680,39 @@ impl ProxyService { // 3. 在写入接管配置之前先落盘接管标志: // 这样即使在接管过程中断电/kill,下次启动也能检测到并自动恢复。 if let Err(e) = self.db.set_live_takeover_active(true).await { + let mut recovery_errors = Vec::new(); if let Err(clean_err) = self.db.delete_all_live_backups().await { - log::warn!("清理 Live 备份失败: {clean_err}"); + recovery_errors.push(format!("清理 Live 备份失败: {clean_err}")); } if started_proxy_before_takeover { - let _ = self.stop().await; + if let Err(stop_err) = self.stop().await { + recovery_errors.push(format!("停止临时启动的代理失败: {stop_err}")); + } } - return Err(format!("设置接管状态失败: {e}")); + return Err(Self::with_recovery_errors( + format!("设置接管状态失败: {e}"), + recovery_errors, + )); } // 4. 接管各应用的 Live 配置(写入代理地址,清空 Token) if let Err(e) = self.takeover_live_configs().await { - // 接管失败(可能是部分写入),尝试恢复原始配置;若恢复失败则保留标志与备份,等待下次启动自动恢复。 + // 接管失败(可能是部分写入),统一恢复并把任何二次失败附加到主错误。 log::error!("接管 Live 配置失败,尝试恢复原始配置: {e}"); - match self.restore_live_configs().await { - Ok(()) => { - let _ = self.db.set_live_takeover_active(false).await; - let _ = self.db.delete_all_live_backups().await; - } - Err(restore_err) => { - log::error!("恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}"); - } - } - if started_proxy_before_takeover { - let _ = self.stop().await; - } - return Err(e); + return Err(self + .recover_after_takeover_failure(e, started_proxy_before_takeover) + .await); } // 5. 启动代理服务器 match self.start().await { Ok(info) => Ok(info), Err(e) => { - // 启动失败,恢复原始配置 + // 启动失败,统一恢复原始配置;回滚失败不能覆盖或隐藏主错误。 log::error!("代理启动失败,尝试恢复原始配置: {e}"); - match self.restore_live_configs().await { - Ok(()) => { - let _ = self.db.set_live_takeover_active(false).await; - let _ = self.db.delete_all_live_backups().await; - } - Err(restore_err) => { - log::error!("恢复原始配置失败,将保留备份以便下次启动恢复: {restore_err}"); - } - } - if started_proxy_before_takeover { - let _ = self.stop().await; - } - Err(e) + Err(self + .recover_after_takeover_failure(e, started_proxy_before_takeover) + .await) } } } @@ -727,8 +815,13 @@ impl ProxyService { // 4) 同步 Live Token 到数据库(仅当前 app) if let Err(e) = self.sync_live_to_provider(&app).await { - let _ = self.db.delete_live_backup(app_type_str).await; - return Err(e); + let recovery_errors = match self.db.delete_live_backup(app_type_str).await { + Ok(()) => Vec::new(), + Err(clean_err) => { + vec![format!("清理 {app_type_str} Live 备份失败: {clean_err}")] + } + }; + return Err(Self::with_recovery_errors(e, recovery_errors)); } } @@ -737,8 +830,16 @@ impl ProxyService { log::error!("{app_type_str} 接管 Live 配置失败,尝试恢复: {e}"); match self.restore_live_config_for_app_inner(&app).await { Ok(()) => { - // 恢复成功才清理备份,避免失败场景下丢失唯一可回滚来源 - let _ = self.db.delete_live_backup(app_type_str).await; + // 恢复成功才清理备份;清理失败也必须可观测。 + self.db + .delete_live_backup(app_type_str) + .await + .map_err(|clean_err| { + Self::with_recovery_errors( + e.clone(), + vec![format!("清理 {app_type_str} Live 备份失败: {clean_err}")], + ) + })?; } Err(restore_err) => { log::error!( @@ -761,8 +862,10 @@ impl ProxyService { .await .map_err(|e| format!("设置 {app_type_str} enabled 状态失败: {e}"))?; - // 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能) - let _ = self.db.set_live_takeover_active(true).await; + // 7) 兼容旧逻辑:主真值是 per-app enabled;旧 marker 失败不阻断,但必须可观测。 + if let Err(e) = self.db.set_live_takeover_active(true).await { + log::warn!("写入兼容接管标志失败(per-app enabled 已生效): {e}"); + } // 8) Warn if the current provider is official (risk of account ban via proxy) if let Ok(Some(current_id)) = @@ -839,22 +942,7 @@ impl ProxyService { // 5) 若无其它接管,更新旧标志,并停止代理服务 // 检查是否还有其它 app 的 enabled = true - let any_enabled = self - .db - .is_live_takeover_active() - .await - .map_err(|e| format!("检查接管状态失败: {e}"))?; - - if !any_enabled { - let _ = self.db.set_live_takeover_active(false).await; - - if self.is_running().await { - // 此时没有任何 app 处于接管状态,停止服务即可 - let _ = self.stop().await; - } - } - - Ok(()) + self.finalize_takeover_shutdown_if_unused().await } /// 在 provider 切换锁内关闭单个 app 的代理接管。 @@ -915,21 +1003,7 @@ impl ProxyService { .await .map_err(|e| format!("娓呴櫎 {app_type_str} 鍋ュ悍鐘舵€佸け璐? {e}"))?; - let any_enabled = self - .db - .is_live_takeover_active() - .await - .map_err(|e| format!("妫€鏌ユ帴绠$姸鎬佸け璐? {e}"))?; - - if !any_enabled { - let _ = self.db.set_live_takeover_active(false).await; - - if self.is_running().await { - let _ = self.stop().await; - } - } - - Ok(()) + self.finalize_takeover_shutdown_if_unused().await } /// 在 ProviderService 已经持有 app 切换锁时启用单应用接管,并切到指定 provider。 @@ -955,6 +1029,7 @@ impl ProxyService { .get_proxy_config_for_app(app_type_str) .await .map_err(|e| format!("读取 {app_type_str} 接管配置失败: {e}"))?; + let previous_enabled = current_config.enabled; let has_backup = self .db .get_live_backup(app_type_str) @@ -972,8 +1047,13 @@ impl ProxyService { } else { self.backup_live_config_strict(app_type).await?; if let Err(e) = self.sync_live_to_provider(app_type).await { - let _ = self.db.delete_live_backup(app_type_str).await; - return Err(e); + let recovery_errors = match self.db.delete_live_backup(app_type_str).await { + Ok(()) => Vec::new(), + Err(clean_err) => { + vec![format!("清理 {app_type_str} Live 备份失败: {clean_err}")] + } + }; + return Err(Self::with_recovery_errors(e, recovery_errors)); } } } @@ -985,12 +1065,14 @@ impl ProxyService { .map_err(|e| format!("更新本地当前 provider 失败: {e}"))?; if let Err(e) = self.takeover_live_config_strict(app_type).await { - if let Some(previous_id) = previous_current.as_deref() { - let _ = self.db.set_current_provider(app_type_str, previous_id); - let _ = crate::settings::set_current_provider(app_type, Some(previous_id)); - } - let _ = self.restore_live_config_for_app_inner(app_type).await; - return Err(e); + let recovery_errors = self + .rollback_provider_switch_takeover( + app_type, + previous_current.as_deref(), + previous_enabled, + ) + .await; + return Err(Self::with_recovery_errors(e, recovery_errors)); } let provider = self .db @@ -1010,7 +1092,9 @@ impl ProxyService { .update_proxy_config_for_app(updated_config) .await .map_err(|e| format!("设置 {app_type_str} 接管状态失败: {e}"))?; - let _ = self.db.set_live_takeover_active(true).await; + if let Err(e) = self.db.set_live_takeover_active(true).await { + log::warn!("写入兼容接管标志失败(per-app enabled 已生效): {e}"); + } if let Some(server) = self.server.read().await.as_ref() { server @@ -1022,16 +1106,14 @@ impl ProxyService { .verify_takeover_activation_after_write(app_type, provider_id) .await { - if let Some(previous_id) = previous_current.as_deref() { - let _ = self.db.set_current_provider(app_type_str, previous_id); - let _ = crate::settings::set_current_provider(app_type, Some(previous_id)); - } - if let Ok(mut config) = self.db.get_proxy_config_for_app(app_type_str).await { - config.enabled = false; - let _ = self.db.update_proxy_config_for_app(config).await; - } - let _ = self.restore_live_config_for_app_inner(app_type).await; - return Err(e); + let recovery_errors = self + .rollback_provider_switch_takeover( + app_type, + previous_current.as_deref(), + previous_enabled, + ) + .await; + return Err(Self::with_recovery_errors(e, recovery_errors)); } Ok(()) @@ -1372,10 +1454,16 @@ impl ProxyService { // 3. 更新 proxy_config 表中的 live_takeover_active 标志(兼容旧版) // 注意:保留 proxy_config.enabled 状态,下次启动时自动恢复 - if let Ok(mut config) = self.db.get_proxy_config().await { - config.live_takeover_active = false; - let _ = self.db.update_proxy_config(config).await; - } + let mut config = self + .db + .get_proxy_config() + .await + .map_err(|e| format!("读取兼容代理状态失败: {e}"))?; + config.live_takeover_active = false; + self.db + .update_proxy_config(config) + .await + .map_err(|e| format!("清除兼容代理接管状态失败: {e}"))?; // 4. 删除备份(Live 配置已恢复,备份不再需要) self.db @@ -1677,7 +1765,9 @@ impl ProxyService { ClaudeTakeoverAuthPolicy::PreserveExistingOrAuthToken, ); } - let _ = self.write_claude_live(&live_config); + if let Err(e) = self.write_claude_live(&live_config) { + log::warn!("best-effort 更新 Claude Live 接管配置失败: {e}"); + } } } AppType::Codex => { @@ -1706,10 +1796,12 @@ impl ProxyService { codex_provider.as_ref(), ); - let _ = self.write_codex_takeover_live_for_provider( + if let Err(e) = self.write_codex_takeover_live_for_provider( &live_config, codex_provider.as_ref(), - ); + ) { + log::warn!("best-effort 更新 Codex Live 接管配置失败: {e}"); + } } } AppType::Gemini => { @@ -1724,7 +1816,9 @@ impl ProxyService { }); } - let _ = self.write_gemini_live(&live_config); + if let Err(e) = self.write_gemini_live(&live_config) { + log::warn!("best-effort 更新 Gemini Live 接管配置失败: {e}"); + } } } _ => {} @@ -2964,7 +3058,8 @@ impl ProxyService { let auth_path = get_codex_auth_path(); if auth.as_object().is_some_and(|obj| obj.is_empty()) { - let _ = crate::config::delete_file(&auth_path); + crate::config::delete_file(&auth_path) + .map_err(|e| format!("删除 Codex auth 失败: {e}"))?; let config_path = get_codex_config_path(); crate::config::write_text_file(&config_path, cfg) .map_err(|e| format!("写入 Codex config 失败: {e}"))?; @@ -3081,8 +3176,11 @@ impl ProxyService { .persist_ephemeral_listen_port_if_needed(&new_config, info.port) .await { - let _ = new_server.stop().await; - return Err(e); + let mut recovery_errors = Vec::new(); + if let Err(stop_err) = new_server.stop().await { + recovery_errors.push(format!("持久化重启端口失败后停止新代理失败: {stop_err}")); + } + return Err(Self::with_recovery_errors(e, recovery_errors)); } *server_guard = Some(new_server); diff --git a/src-tauri/src/services/s3.rs b/src-tauri/src/services/s3.rs index c9d44dd146e..136fe5ca79c 100644 --- a/src-tauri/src/services/s3.rs +++ b/src-tauri/src/services/s3.rs @@ -229,24 +229,6 @@ fn sign_request( // ─── Error helpers ─────────────────────────────────────────── /// Redact a URL for safe inclusion in error messages (strips query parameters). -fn redact_url(raw: &str) -> String { - match Url::parse(raw) { - Ok(parsed) => { - let mut out = format!("{}://", parsed.scheme()); - if let Some(host) = parsed.host_str() { - out.push_str(host); - } - if let Some(port) = parsed.port() { - out.push(':'); - out.push_str(&port.to_string()); - } - out.push_str(parsed.path()); - out - } - Err(_) => raw.split('?').next().unwrap_or(raw).to_string(), - } -} - fn s3_transport_error( key: &'static str, op_zh: &str, @@ -271,7 +253,7 @@ fn s3_transport_error( } fn s3_status_error(op: &str, status: StatusCode, url: &str) -> AppError { - let safe_url = redact_url(url); + let safe_url = crate::diagnostics::redact_url_without_query_for_log(url); let mut zh = format!("S3 {op} 失败: {status} ({safe_url})"); let mut en = format!("S3 {op} failed: {status} ({safe_url})"); @@ -290,11 +272,15 @@ fn response_too_large_error(url: &str, max_bytes: usize) -> AppError { let max_mb = max_bytes / 1024 / 1024; AppError::localized( "s3.response_too_large", - format!("S3 响应体超过上限({} MB): {}", max_mb, redact_url(url)), + format!( + "S3 响应体超过上限({} MB): {}", + max_mb, + crate::diagnostics::redact_url_without_query_for_log(url) + ), format!( "S3 response body exceeds limit ({} MB): {}", max_mb, - redact_url(url) + crate::diagnostics::redact_url_without_query_for_log(url) ), ) } @@ -814,7 +800,7 @@ mod tests { #[test] fn redact_url_strips_query_params() { - let r = redact_url( + let r = crate::diagnostics::redact_url_without_query_for_log( "https://mybucket.s3.us-east-1.amazonaws.com/file.txt?X-Amz-Credential=AKID&X-Amz-Signature=abc", ); assert!(!r.contains("AKID")); @@ -825,7 +811,9 @@ mod tests { #[test] fn redact_url_preserves_path() { - let r = redact_url("https://minio.local:9000/bucket/path/to/file.json"); + let r = crate::diagnostics::redact_url_without_query_for_log( + "https://minio.local:9000/bucket/path/to/file.json", + ); assert_eq!(r, "https://minio.local:9000/bucket/path/to/file.json"); } diff --git a/src-tauri/src/services/webdav.rs b/src-tauri/src/services/webdav.rs index af2ff26c647..e6cbd74f720 100644 --- a/src-tauri/src/services/webdav.rs +++ b/src-tauri/src/services/webdav.rs @@ -118,7 +118,7 @@ fn webdav_transport_error( ("网络请求失败", "network request failed") }; - let safe_url = redact_url(target_url); + let safe_url = crate::diagnostics::redact_url_for_log(target_url); AppError::localized( key, format!("WebDAV {op_zh}失败({zh_reason}): {safe_url}"), @@ -202,7 +202,10 @@ pub async fn ensure_remote_directories( let status = resp.status(); match status { s if s == StatusCode::CREATED || s.is_success() => { - log::info!("[WebDAV] MKCOL ok: {}", redact_url(&dir_url)); + log::info!( + "[WebDAV] MKCOL ok: {}", + crate::diagnostics::redact_url_for_log(&dir_url) + ); } // Ambiguous — verify directory actually exists via PROPFIND s if s == StatusCode::METHOD_NOT_ALLOWED @@ -347,7 +350,7 @@ async fn propfind_exists( Err(e) => { log::warn!( "[WebDAV] PROPFIND check failed for {}: {e}", - redact_url(url) + crate::diagnostics::redact_url_for_log(url) ); Ok(false) } @@ -367,7 +370,7 @@ pub fn is_jianguoyun(url: &str) -> bool { /// Build an `AppError` with service-specific hints for WebDAV failures. pub fn webdav_status_error(op: &str, status: StatusCode, url: &str) -> AppError { - let safe_url = redact_url(url); + let safe_url = crate::diagnostics::redact_url_for_log(url); let mut zh = format!("WebDAV {op} 失败: {status} ({safe_url})"); let mut en = format!("WebDAV {op} failed: {status} ({safe_url})"); let jgy = is_jianguoyun(url); @@ -400,36 +403,6 @@ pub fn webdav_status_error(op: &str, status: StatusCode, url: &str) -> AppError AppError::localized("webdav.http.status", zh, en) } -fn redact_url(raw: &str) -> String { - match Url::parse(raw) { - Ok(mut parsed) => { - let _ = parsed.set_username(""); - let _ = parsed.set_password(None); - - let mut out = format!("{}://", parsed.scheme()); - if let Some(host) = parsed.host_str() { - out.push_str(host); - } - if let Some(port) = parsed.port() { - out.push(':'); - out.push_str(&port.to_string()); - } - out.push_str(parsed.path()); - - let mut keys: Vec = parsed.query_pairs().map(|(k, _)| k.into_owned()).collect(); - keys.sort(); - keys.dedup(); - if !keys.is_empty() { - out.push_str("?[keys:"); - out.push_str(&keys.join(",")); - out.push(']'); - } - out - } - Err(_) => raw.split('?').next().unwrap_or(raw).to_string(), - } -} - fn response_too_large_error(url: &str, max_bytes: usize) -> AppError { let max_mb = max_bytes / 1024 / 1024; AppError::localized( @@ -437,12 +410,12 @@ fn response_too_large_error(url: &str, max_bytes: usize) -> AppError { format!( "WebDAV 响应体超过上限({} MB): {}", max_mb, - redact_url(url) + crate::diagnostics::redact_url_for_log(url) ), format!( "WebDAV response body exceeds limit ({} MB): {}", max_mb, - redact_url(url) + crate::diagnostics::redact_url_for_log(url) ), ) } @@ -520,7 +493,9 @@ mod tests { #[test] fn redact_url_hides_credentials_and_query_values() { - let redacted = redact_url("https://alice:secret@example.com:8443/dav?token=abc&foo=1"); + let redacted = crate::diagnostics::redact_url_for_log( + "https://alice:secret@example.com:8443/dav?token=abc&foo=1", + ); assert_eq!(redacted, "https://example.com:8443/dav?[keys:foo,token]"); assert!(!redacted.contains("secret")); } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 71e772c5214..451c3a73bd7 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -1203,8 +1203,10 @@ mod tests { fn settings_save_uses_common_atomic_persistence_boundary() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("settings.json"); - let mut settings = AppSettings::default(); - settings.show_in_tray = false; + let settings = AppSettings { + show_in_tray: false, + ..Default::default() + }; save_settings_file_to_path(&settings, &path).expect("save settings"); let saved: AppSettings = From 1a28704b4f8f82ae3b235263af1db57798e3884b Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 12:09:01 +0800 Subject: [PATCH 051/112] ci: guard home and model-discovery failure boundaries --- scripts/check_rust_failure_boundaries.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/check_rust_failure_boundaries.py b/scripts/check_rust_failure_boundaries.py index 9dc6223e53d..85af791f93a 100644 --- a/scripts/check_rust_failure_boundaries.py +++ b/scripts/check_rust_failure_boundaries.py @@ -24,6 +24,9 @@ ("services/proxy.rs", re.compile(r"let _ = (?:self\.db\.|crate::settings::|self\.write_|self\.stop\(\)|self\.restore_live_|crate::config::delete_file)"), "proxy state/write/rollback failures must not be silently discarded"), ("codex_config.rs", re.compile(r"let _ = (?:atomic_write|delete_file)\("), "Codex config rollback failures must be observable"), ("config.rs", re.compile(r'PathBuf::from\("\."\)'), "home/config resolution must never silently fall back to the process CWD"), + ("config.rs", re.compile(r"return Ok\(PathBuf::from\(home\)\);"), "explicit home overrides must be validated as absolute before use"), + ("services/model_fetch.rs", re.compile(r'Err\(e\)\s*=>\s*\{\s*return Err\(format!\("Request failed:', re.S), "model discovery transport failures must advance to later compatibility candidates"), + ("services/model_fetch.rs", re.compile(r'\.json\(\)\s*\.await\s*\.map_err\(\|e\| format!\("Failed to parse response:', re.S), "invalid successful model payloads must not abort compatibility candidate discovery"), ] failures = [] From 498c2d12cf28b484fa2de9465b2bb65451218056 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 12:09:48 +0800 Subject: [PATCH 052/112] ci: apply final home and model candidate hardening --- .../workflows/home-model-hardening-once.yml | 345 ++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 .github/workflows/home-model-hardening-once.yml diff --git a/.github/workflows/home-model-hardening-once.yml b/.github/workflows/home-model-hardening-once.yml new file mode 100644 index 00000000000..e1ffe99707f --- /dev/null +++ b/.github/workflows/home-model-hardening-once.yml @@ -0,0 +1,345 @@ +name: Home Model Hardening Once + +on: + push: + branches: + - fix/global-hardening-20260904 + paths: + - .github/workflows/home-model-hardening-once.yml + +permissions: + contents: write + +jobs: + apply-and-verify: + if: github.repository == 'z13321812367-sys/ccswitchmulti' + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + with: + ref: fix/global-hardening-20260904 + fetch-depth: 0 + + - name: Apply exact HOME and model candidate fixes + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + + config_path = Path("src-tauri/src/config.rs") + config = config_path.read_text(encoding="utf-8") + old_home = ''' if let Some(home) = test_override.map(str::trim).filter(|home| !home.is_empty()) { + return Ok(PathBuf::from(home)); + } + ''' + new_home = ''' if let Some(home) = test_override.map(str::trim).filter(|home| !home.is_empty()) { + let path = PathBuf::from(home); + if path.is_absolute() { + return Ok(path); + } + return Err(format!( + "CC_SWITCH_TEST_HOME 必须是绝对路径,收到: {}", + path.display() + )); + } + ''' + if config.count(old_home) != 1: + raise SystemExit(f"expected one unvalidated test-home override, found {config.count(old_home)}") + config = config.replace(old_home, new_home, 1) + + old_test = ''' #[test] + fn explicit_test_home_override_remains_supported() { + assert_eq!( + resolve_home_dir(Some("test-home"), None).unwrap(), + PathBuf::from("test-home") + ); + } + ''' + new_test = ''' #[test] + fn explicit_absolute_test_home_override_remains_supported() { + let override_home = std::env::temp_dir().join("cc-switch-test-home"); + let override_text = override_home.to_string_lossy().to_string(); + assert_eq!( + resolve_home_dir(Some(&override_text), None).unwrap(), + override_home + ); + } + + #[test] + fn explicit_relative_test_home_override_is_rejected() { + assert!(resolve_home_dir(Some("relative-test-home"), None).is_err()); + } + ''' + if config.count(old_test) != 1: + raise SystemExit(f"expected one relative test-home regression test, found {config.count(old_test)}") + config = config.replace(old_test, new_test, 1) + config_path.write_text(config, encoding="utf-8") + + model_path = Path("src-tauri/src/services/model_fetch.rs") + model = model_path.read_text(encoding="utf-8") + + marker = 'const FETCH_TIMEOUT_SECS: u64 = 15;\n' + helpers = '''const FETCH_TIMEOUT_SECS: u64 = 15; + const MAX_CANDIDATE_FAILURE_DETAILS: usize = 8; + + fn should_try_next_models_candidate(status: StatusCode) -> bool { + status == StatusCode::NOT_FOUND + || status == StatusCode::METHOD_NOT_ALLOWED + || status.is_server_error() + } + + fn request_error_kind(error: &reqwest::Error) -> &'static str { + if error.is_timeout() { + "timeout" + } else if error.is_connect() { + "connect" + } else if error.is_request() { + "request" + } else if error.is_body() { + "body" + } else if error.is_decode() { + "decode" + } else { + "transport" + } + } + + fn record_candidate_failure(failures: &mut Vec, detail: String) { + if failures.len() < MAX_CANDIDATE_FAILURE_DETAILS { + failures.push(detail); + } + } + ''' + if model.count(marker) != 1: + raise SystemExit(f"expected one fetch-timeout marker, found {model.count(marker)}") + model = model.replace(marker, helpers, 1) + + if model.count(' let mut last_err: Option = None;\n') != 1: + raise SystemExit("expected legacy last_err accumulator") + model = model.replace( + ' let mut last_err: Option = None;\n', + ' let mut candidate_failures: Vec = Vec::new();\n', + 1, + ) + + if model.count(' for url in &candidates {\n') != 1: + raise SystemExit("expected one model candidate loop") + model = model.replace( + ' for url in &candidates {\n', + ' for (index, url) in candidates.iter().enumerate() {\n let ordinal = index + 1;\n', + 1, + ) + + old_send = ''' let response = match request_builder.send().await { + Ok(r) => r, + Err(e) => { + return Err(format!("Request failed: {e}")); + } + }; + ''' + new_send = ''' let response = match request_builder.send().await { + Ok(r) => r, + Err(error) => { + let kind = request_error_kind(&error); + log::debug!( + "[ModelFetch] candidate {ordinal}/{} transport failure: {kind}", + candidates.len() + ); + record_candidate_failure( + &mut candidate_failures, + format!("candidate {ordinal}: transport {kind}"), + ); + continue; + } + }; + ''' + if model.count(old_send) != 1: + raise SystemExit(f"expected one early transport return, found {model.count(old_send)}") + model = model.replace(old_send, new_send, 1) + + old_success = ''' if status.is_success() { + let resp: ModelsResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse response: {e}"))?; + + let mut models: Vec = resp + .data + .unwrap_or_default() + .into_iter() + .map(|m| FetchedModel { + context_window: extract_context_window(&m.extra), + id: m.id, + owned_by: m.owned_by, + }) + .collect(); + + enrich_missing_context_windows(&client, url, &mut models).await; + models.sort_by(|a, b| a.id.cmp(&b.id)); + return Ok(models); + } + + if status == StatusCode::NOT_FOUND || status == StatusCode::METHOD_NOT_ALLOWED { + let body = truncate_body(response.text().await.unwrap_or_default()); + last_err = Some(format!("HTTP {status}: {body}")); + continue; + } + ''' + new_success = ''' if status.is_success() { + let body = match response.bytes().await { + Ok(body) => body, + Err(error) => { + let kind = request_error_kind(&error); + record_candidate_failure( + &mut candidate_failures, + format!("candidate {ordinal}: response body {kind}"), + ); + continue; + } + }; + let resp: ModelsResponse = match serde_json::from_slice(&body) { + Ok(resp) => resp, + Err(error) => { + log::debug!( + "[ModelFetch] candidate {ordinal}/{} returned invalid JSON: {error}", + candidates.len() + ); + record_candidate_failure( + &mut candidate_failures, + format!("candidate {ordinal}: invalid JSON ({error})"), + ); + continue; + } + }; + let Some(data) = resp.data else { + record_candidate_failure( + &mut candidate_failures, + format!("candidate {ordinal}: response missing data array"), + ); + continue; + }; + + let mut models: Vec = data + .into_iter() + .map(|m| FetchedModel { + context_window: extract_context_window(&m.extra), + id: m.id, + owned_by: m.owned_by, + }) + .collect(); + + enrich_missing_context_windows(&client, url, &mut models).await; + models.sort_by(|a, b| a.id.cmp(&b.id)); + return Ok(models); + } + + if should_try_next_models_candidate(status) { + record_candidate_failure( + &mut candidate_failures, + format!("candidate {ordinal}: HTTP {status}"), + ); + continue; + } + ''' + if model.count(old_success) != 1: + raise SystemExit(f"expected one legacy success/retry block, found {model.count(old_success)}") + model = model.replace(old_success, new_success, 1) + + old_final = ''' Err(format!( + "All candidates failed: {}", + last_err.unwrap_or_else(|| "no candidates".to_string()) + )) + ''' + new_final = ''' let details = if candidate_failures.is_empty() { + "no candidate diagnostics".to_string() + } else { + candidate_failures.join("; ") + }; + Err(format!("All model endpoint candidates failed: {details}")) + ''' + if model.count(old_final) != 1: + raise SystemExit(f"expected one legacy final error block, found {model.count(old_final)}") + model = model.replace(old_final, new_final, 1) + + test_anchor = ''' #[test] + fn test_candidates_plain_root() { + ''' + tests = ''' #[test] + fn candidate_retry_policy_continues_discovery_only_for_compatible_failures() { + assert!(should_try_next_models_candidate(StatusCode::NOT_FOUND)); + assert!(should_try_next_models_candidate(StatusCode::METHOD_NOT_ALLOWED)); + assert!(should_try_next_models_candidate(StatusCode::INTERNAL_SERVER_ERROR)); + assert!(should_try_next_models_candidate(StatusCode::BAD_GATEWAY)); + assert!(!should_try_next_models_candidate(StatusCode::BAD_REQUEST)); + assert!(!should_try_next_models_candidate(StatusCode::UNAUTHORIZED)); + assert!(!should_try_next_models_candidate(StatusCode::FORBIDDEN)); + assert!(!should_try_next_models_candidate(StatusCode::TOO_MANY_REQUESTS)); + } + + #[test] + fn candidate_failure_details_are_bounded() { + let mut failures = Vec::new(); + for index in 0..(MAX_CANDIDATE_FAILURE_DETAILS + 3) { + record_candidate_failure(&mut failures, format!("candidate {index}")); + } + assert_eq!(failures.len(), MAX_CANDIDATE_FAILURE_DETAILS); + } + + #[test] + fn test_candidates_plain_root() { + ''' + if model.count(test_anchor) != 1: + raise SystemExit(f"expected one model test anchor, found {model.count(test_anchor)}") + model = model.replace(test_anchor, tests, 1) + + forbidden = [ + 'return Err(format!("Request failed: {e}"));', + '.json()\n .await\n .map_err(|e| format!("Failed to parse response: {e}"))?', + ] + for fragment in forbidden: + if fragment in model: + raise SystemExit(f"legacy model candidate failure boundary remains: {fragment}") + model_path.write_text(model, encoding="utf-8") + PY + + - name: Install Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential pkg-config libssl-dev \ + libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev + sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \ + || sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev + sudo apt-get install -y --no-install-recommends libsoup-3.0-dev \ + || sudo apt-get install -y --no-install-recommends libsoup2.4-dev + + - name: Format Rust + run: cargo fmt --manifest-path src-tauri/Cargo.toml --all + + - name: Run permanent policy guards + run: | + python scripts/check_workflow_shell_interpolation.py + python scripts/check_rust_failure_boundaries.py + git diff --check + + - name: Create frontend dist placeholder + run: mkdir -p dist + + - name: Clippy all targets and features + run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --all-features -- -D warnings + + - name: Rust tests default feature set + run: cargo test --manifest-path src-tauri/Cargo.toml --all + + - name: Rust tests all features + run: cargo test --manifest-path src-tauri/Cargo.toml --all-features + + - name: Commit verified source fixes + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add src-tauri/src/config.rs src-tauri/src/services/model_fetch.rs + git diff --cached --check + git commit -m "fix: fail closed home override and retry model candidates" + git push origin HEAD:fix/global-hardening-20260904 From 4023ced9278a9e5c7cd53ba95c05b26ee7faf738 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 12:11:08 +0800 Subject: [PATCH 053/112] ci: make home model hardening patch structure-aware --- .../workflows/home-model-hardening-once.yml | 194 +++++++----------- 1 file changed, 75 insertions(+), 119 deletions(-) diff --git a/.github/workflows/home-model-hardening-once.yml b/.github/workflows/home-model-hardening-once.yml index e1ffe99707f..f341e1e6ddc 100644 --- a/.github/workflows/home-model-hardening-once.yml +++ b/.github/workflows/home-model-hardening-once.yml @@ -20,7 +20,7 @@ jobs: ref: fix/global-hardening-20260904 fetch-depth: 0 - - name: Apply exact HOME and model candidate fixes + - name: Apply structure-aware HOME and model candidate fixes shell: bash run: | set -euo pipefail @@ -29,34 +29,26 @@ jobs: config_path = Path("src-tauri/src/config.rs") config = config_path.read_text(encoding="utf-8") - old_home = ''' if let Some(home) = test_override.map(str::trim).filter(|home| !home.is_empty()) { - return Ok(PathBuf::from(home)); - } - ''' - new_home = ''' if let Some(home) = test_override.map(str::trim).filter(|home| !home.is_empty()) { - let path = PathBuf::from(home); - if path.is_absolute() { - return Ok(path); - } - return Err(format!( - "CC_SWITCH_TEST_HOME 必须是绝对路径,收到: {}", - path.display() - )); - } - ''' - if config.count(old_home) != 1: - raise SystemExit(f"expected one unvalidated test-home override, found {config.count(old_home)}") - config = config.replace(old_home, new_home, 1) - old_test = ''' #[test] - fn explicit_test_home_override_remains_supported() { - assert_eq!( - resolve_home_dir(Some("test-home"), None).unwrap(), - PathBuf::from("test-home") - ); - } - ''' - new_test = ''' #[test] + old_return = " return Ok(PathBuf::from(home));" + new_return = """ let path = PathBuf::from(home); + if path.is_absolute() { + return Ok(path); + } + return Err(format!( + "CC_SWITCH_TEST_HOME 必须是绝对路径,收到: {}", + path.display() + ));""" + if config.count(old_return) != 1: + raise SystemExit(f"expected one unvalidated test-home return, found {config.count(old_return)}") + config = config.replace(old_return, new_return, 1) + + test_start_marker = " #[test]\n fn explicit_test_home_override_remains_supported() {" + if config.count(test_start_marker) != 1: + raise SystemExit("expected one legacy explicit test-home test") + test_start = config.index(test_start_marker) + test_end = config.index("\n\n #[test]", test_start + len(test_start_marker)) + replacement_tests = """ #[test] fn explicit_absolute_test_home_override_remains_supported() { let override_home = std::env::temp_dir().join("cc-switch-test-home"); let override_text = override_home.to_string_lossy().to_string(); @@ -69,19 +61,15 @@ jobs: #[test] fn explicit_relative_test_home_override_is_rejected() { assert!(resolve_home_dir(Some("relative-test-home"), None).is_err()); - } - ''' - if config.count(old_test) != 1: - raise SystemExit(f"expected one relative test-home regression test, found {config.count(old_test)}") - config = config.replace(old_test, new_test, 1) + }""" + config = config[:test_start] + replacement_tests + config[test_end:] config_path.write_text(config, encoding="utf-8") model_path = Path("src-tauri/src/services/model_fetch.rs") model = model_path.read_text(encoding="utf-8") - marker = 'const FETCH_TIMEOUT_SECS: u64 = 15;\n' - helpers = '''const FETCH_TIMEOUT_SECS: u64 = 15; - const MAX_CANDIDATE_FAILURE_DETAILS: usize = 8; + timeout_marker = "const FETCH_TIMEOUT_SECS: u64 = 15;\n" + helpers = """const MAX_CANDIDATE_FAILURE_DETAILS: usize = 8; fn should_try_next_models_candidate(status: StatusCode) -> bool { status == StatusCode::NOT_FOUND @@ -110,35 +98,37 @@ jobs: failures.push(detail); } } - ''' - if model.count(marker) != 1: - raise SystemExit(f"expected one fetch-timeout marker, found {model.count(marker)}") - model = model.replace(marker, helpers, 1) - if model.count(' let mut last_err: Option = None;\n') != 1: - raise SystemExit("expected legacy last_err accumulator") + """ + if model.count(timeout_marker) != 1: + raise SystemExit(f"expected one fetch-timeout marker, found {model.count(timeout_marker)}") + model = model.replace(timeout_marker, timeout_marker + helpers, 1) + + old_accumulator = " let mut last_err: Option = None;\n" + if model.count(old_accumulator) != 1: + raise SystemExit("expected one legacy last_err accumulator") model = model.replace( - ' let mut last_err: Option = None;\n', - ' let mut candidate_failures: Vec = Vec::new();\n', + old_accumulator, + " let mut candidate_failures: Vec = Vec::new();\n", 1, ) - if model.count(' for url in &candidates {\n') != 1: + old_loop = " for url in &candidates {\n" + if model.count(old_loop) != 1: raise SystemExit("expected one model candidate loop") model = model.replace( - ' for url in &candidates {\n', - ' for (index, url) in candidates.iter().enumerate() {\n let ordinal = index + 1;\n', + old_loop, + " for (index, url) in candidates.iter().enumerate() {\n let ordinal = index + 1;\n", 1, ) - old_send = ''' let response = match request_builder.send().await { - Ok(r) => r, - Err(e) => { - return Err(format!("Request failed: {e}")); - } - }; - ''' - new_send = ''' let response = match request_builder.send().await { + send_start_marker = " let response = match request_builder.send().await {" + send_end_marker = "\n\n let status = response.status();" + if model.count(send_start_marker) != 1: + raise SystemExit("expected one request send block") + send_start = model.index(send_start_marker) + send_end = model.index(send_end_marker, send_start) + new_send = """ let response = match request_builder.send().await { Ok(r) => r, Err(error) => { let kind = request_error_kind(&error); @@ -152,41 +142,16 @@ jobs: ); continue; } - }; - ''' - if model.count(old_send) != 1: - raise SystemExit(f"expected one early transport return, found {model.count(old_send)}") - model = model.replace(old_send, new_send, 1) - - old_success = ''' if status.is_success() { - let resp: ModelsResponse = response - .json() - .await - .map_err(|e| format!("Failed to parse response: {e}"))?; - - let mut models: Vec = resp - .data - .unwrap_or_default() - .into_iter() - .map(|m| FetchedModel { - context_window: extract_context_window(&m.extra), - id: m.id, - owned_by: m.owned_by, - }) - .collect(); - - enrich_missing_context_windows(&client, url, &mut models).await; - models.sort_by(|a, b| a.id.cmp(&b.id)); - return Ok(models); - } - - if status == StatusCode::NOT_FOUND || status == StatusCode::METHOD_NOT_ALLOWED { - let body = truncate_body(response.text().await.unwrap_or_default()); - last_err = Some(format!("HTTP {status}: {body}")); - continue; - } - ''' - new_success = ''' if status.is_success() { + };""" + model = model[:send_start] + new_send + model[send_end:] + + success_start_marker = " if status.is_success() {" + terminal_marker = "\n\n let body = truncate_body(response.text().await.unwrap_or_default());\n return Err(format!(\"HTTP {status}: {body}\"));" + if model.count(success_start_marker) != 1 or model.count(terminal_marker) != 1: + raise SystemExit("model success/terminal status boundaries are not unique") + success_start = model.index(success_start_marker) + terminal_start = model.index(terminal_marker, success_start) + new_success = """ if status.is_success() { let body = match response.bytes().await { Ok(body) => body, Err(error) => { @@ -240,32 +205,26 @@ jobs: format!("candidate {ordinal}: HTTP {status}"), ); continue; - } - ''' - if model.count(old_success) != 1: - raise SystemExit(f"expected one legacy success/retry block, found {model.count(old_success)}") - model = model.replace(old_success, new_success, 1) - - old_final = ''' Err(format!( - "All candidates failed: {}", - last_err.unwrap_or_else(|| "no candidates".to_string()) - )) - ''' - new_final = ''' let details = if candidate_failures.is_empty() { + }""" + model = model[:success_start] + new_success + model[terminal_start:] + + final_start_marker = " Err(format!(\n \"All candidates failed: {}\"," + if model.count(final_start_marker) != 1: + raise SystemExit("expected one legacy final candidate error") + final_start = model.index(final_start_marker) + final_end = model.index("\n}", final_start) + new_final = """ let details = if candidate_failures.is_empty() { "no candidate diagnostics".to_string() } else { candidate_failures.join("; ") }; - Err(format!("All model endpoint candidates failed: {details}")) - ''' - if model.count(old_final) != 1: - raise SystemExit(f"expected one legacy final error block, found {model.count(old_final)}") - model = model.replace(old_final, new_final, 1) - - test_anchor = ''' #[test] - fn test_candidates_plain_root() { - ''' - tests = ''' #[test] + Err(format!("All model endpoint candidates failed: {details}"))""" + model = model[:final_start] + new_final + model[final_end:] + + test_anchor = " #[test]\n fn test_candidates_plain_root() {" + if model.count(test_anchor) != 1: + raise SystemExit("expected one model candidate test anchor") + extra_tests = """ #[test] fn candidate_retry_policy_continues_discovery_only_for_compatible_failures() { assert!(should_try_next_models_candidate(StatusCode::NOT_FOUND)); assert!(should_try_next_models_candidate(StatusCode::METHOD_NOT_ALLOWED)); @@ -286,16 +245,13 @@ jobs: assert_eq!(failures.len(), MAX_CANDIDATE_FAILURE_DETAILS); } - #[test] - fn test_candidates_plain_root() { - ''' - if model.count(test_anchor) != 1: - raise SystemExit(f"expected one model test anchor, found {model.count(test_anchor)}") - model = model.replace(test_anchor, tests, 1) + """ + model = model.replace(test_anchor, extra_tests + test_anchor, 1) forbidden = [ 'return Err(format!("Request failed: {e}"));', - '.json()\n .await\n .map_err(|e| format!("Failed to parse response: {e}"))?', + 'last_err = Some(format!("HTTP {status}: {body}"));', + '"All candidates failed: {}"', ] for fragment in forbidden: if fragment in model: From 09a475c3d8114b9e4848fe2dd1c742e3fef445f7 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 12:16:58 +0800 Subject: [PATCH 054/112] ci: preserve config test imports during hardening --- .../workflows/home-model-hardening-once.yml | 80 ++++++++++--------- 1 file changed, 44 insertions(+), 36 deletions(-) diff --git a/.github/workflows/home-model-hardening-once.yml b/.github/workflows/home-model-hardening-once.yml index f341e1e6ddc..c1ef77887ec 100644 --- a/.github/workflows/home-model-hardening-once.yml +++ b/.github/workflows/home-model-hardening-once.yml @@ -32,13 +32,13 @@ jobs: old_return = " return Ok(PathBuf::from(home));" new_return = """ let path = PathBuf::from(home); - if path.is_absolute() { - return Ok(path); - } - return Err(format!( - "CC_SWITCH_TEST_HOME 必须是绝对路径,收到: {}", - path.display() - ));""" + if path.is_absolute() { + return Ok(path); + } + return Err(format!( + "CC_SWITCH_TEST_HOME 必须是绝对路径,收到: {}", + path.display() + ));""" if config.count(old_return) != 1: raise SystemExit(f"expected one unvalidated test-home return, found {config.count(old_return)}") config = config.replace(old_return, new_return, 1) @@ -48,20 +48,28 @@ jobs: raise SystemExit("expected one legacy explicit test-home test") test_start = config.index(test_start_marker) test_end = config.index("\n\n #[test]", test_start + len(test_start_marker)) + captured = config[test_start:test_end] + hashset_import = " use std::collections::HashSet;" + if captured.count(hashset_import) != 1: + raise SystemExit( + f"expected HashSet import inside replaced HOME-test segment, found {captured.count(hashset_import)}" + ) replacement_tests = """ #[test] - fn explicit_absolute_test_home_override_remains_supported() { - let override_home = std::env::temp_dir().join("cc-switch-test-home"); - let override_text = override_home.to_string_lossy().to_string(); - assert_eq!( - resolve_home_dir(Some(&override_text), None).unwrap(), - override_home - ); - } + fn explicit_absolute_test_home_override_remains_supported() { + let override_home = std::env::temp_dir().join("cc-switch-test-home"); + let override_text = override_home.to_string_lossy().to_string(); + assert_eq!( + resolve_home_dir(Some(&override_text), None).unwrap(), + override_home + ); + } - #[test] - fn explicit_relative_test_home_override_is_rejected() { - assert!(resolve_home_dir(Some("relative-test-home"), None).is_err()); - }""" + #[test] + fn explicit_relative_test_home_override_is_rejected() { + assert!(resolve_home_dir(Some("relative-test-home"), None).is_err()); + } + + use std::collections::HashSet;""" config = config[:test_start] + replacement_tests + config[test_end:] config_path.write_text(config, encoding="utf-8") @@ -225,25 +233,25 @@ jobs: if model.count(test_anchor) != 1: raise SystemExit("expected one model candidate test anchor") extra_tests = """ #[test] - fn candidate_retry_policy_continues_discovery_only_for_compatible_failures() { - assert!(should_try_next_models_candidate(StatusCode::NOT_FOUND)); - assert!(should_try_next_models_candidate(StatusCode::METHOD_NOT_ALLOWED)); - assert!(should_try_next_models_candidate(StatusCode::INTERNAL_SERVER_ERROR)); - assert!(should_try_next_models_candidate(StatusCode::BAD_GATEWAY)); - assert!(!should_try_next_models_candidate(StatusCode::BAD_REQUEST)); - assert!(!should_try_next_models_candidate(StatusCode::UNAUTHORIZED)); - assert!(!should_try_next_models_candidate(StatusCode::FORBIDDEN)); - assert!(!should_try_next_models_candidate(StatusCode::TOO_MANY_REQUESTS)); - } + fn candidate_retry_policy_continues_discovery_only_for_compatible_failures() { + assert!(should_try_next_models_candidate(StatusCode::NOT_FOUND)); + assert!(should_try_next_models_candidate(StatusCode::METHOD_NOT_ALLOWED)); + assert!(should_try_next_models_candidate(StatusCode::INTERNAL_SERVER_ERROR)); + assert!(should_try_next_models_candidate(StatusCode::BAD_GATEWAY)); + assert!(!should_try_next_models_candidate(StatusCode::BAD_REQUEST)); + assert!(!should_try_next_models_candidate(StatusCode::UNAUTHORIZED)); + assert!(!should_try_next_models_candidate(StatusCode::FORBIDDEN)); + assert!(!should_try_next_models_candidate(StatusCode::TOO_MANY_REQUESTS)); + } - #[test] - fn candidate_failure_details_are_bounded() { - let mut failures = Vec::new(); - for index in 0..(MAX_CANDIDATE_FAILURE_DETAILS + 3) { - record_candidate_failure(&mut failures, format!("candidate {index}")); - } - assert_eq!(failures.len(), MAX_CANDIDATE_FAILURE_DETAILS); + #[test] + fn candidate_failure_details_are_bounded() { + let mut failures = Vec::new(); + for index in 0..(MAX_CANDIDATE_FAILURE_DETAILS + 3) { + record_candidate_failure(&mut failures, format!("candidate {index}")); } + assert_eq!(failures.len(), MAX_CANDIDATE_FAILURE_DETAILS); + } """ model = model.replace(test_anchor, extra_tests + test_anchor, 1) From 5ce800239628f155c81cae861af62f2622fcabb8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:27:09 +0000 Subject: [PATCH 055/112] fix: fail closed home override and retry model candidates --- src-tauri/src/config.rs | 23 ++++- src-tauri/src/services/model_fetch.rs | 134 ++++++++++++++++++++++---- 2 files changed, 134 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index ea9920f90c4..f0e23d92f99 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -28,7 +28,14 @@ fn resolve_home_dir( detected: Option, ) -> Result { if let Some(home) = test_override.map(str::trim).filter(|home| !home.is_empty()) { - return Ok(PathBuf::from(home)); + let path = PathBuf::from(home); + if path.is_absolute() { + return Ok(path); + } + return Err(format!( + "CC_SWITCH_TEST_HOME 必须是绝对路径,收到: {}", + path.display() + )); } match detected { @@ -451,12 +458,20 @@ mod tests { } #[test] - fn explicit_test_home_override_remains_supported() { + fn explicit_absolute_test_home_override_remains_supported() { + let override_home = std::env::temp_dir().join("cc-switch-test-home"); + let override_text = override_home.to_string_lossy().to_string(); assert_eq!( - resolve_home_dir(Some("test-home"), None).unwrap(), - PathBuf::from("test-home") + resolve_home_dir(Some(&override_text), None).unwrap(), + override_home ); } + + #[test] + fn explicit_relative_test_home_override_is_rejected() { + assert!(resolve_home_dir(Some("relative-test-home"), None).is_err()); + } + use std::collections::HashSet; #[test] diff --git a/src-tauri/src/services/model_fetch.rs b/src-tauri/src/services/model_fetch.rs index 219c275825a..6aaff38566c 100644 --- a/src-tauri/src/services/model_fetch.rs +++ b/src-tauri/src/services/model_fetch.rs @@ -59,6 +59,35 @@ struct ModelEntry { } const FETCH_TIMEOUT_SECS: u64 = 15; +const MAX_CANDIDATE_FAILURE_DETAILS: usize = 8; + +fn should_try_next_models_candidate(status: StatusCode) -> bool { + status == StatusCode::NOT_FOUND + || status == StatusCode::METHOD_NOT_ALLOWED + || status.is_server_error() +} + +fn request_error_kind(error: &reqwest::Error) -> &'static str { + if error.is_timeout() { + "timeout" + } else if error.is_connect() { + "connect" + } else if error.is_request() { + "request" + } else if error.is_body() { + "body" + } else if error.is_decode() { + "decode" + } else { + "transport" + } +} + +fn record_candidate_failure(failures: &mut Vec, detail: String) { + if failures.len() < MAX_CANDIDATE_FAILURE_DETAILS { + failures.push(detail); + } +} /// 智谱官方模型概览 markdown。 /// @@ -178,9 +207,10 @@ pub async fn fetch_models(options: FetchModelsRequest<'_>) -> Result = None; + let mut candidate_failures: Vec = Vec::new(); - for url in &candidates { + for (index, url) in candidates.iter().enumerate() { + let ordinal = index + 1; log::debug!( "[ModelFetch] Trying endpoint: {}", crate::diagnostics::redact_url_for_log(url) @@ -196,22 +226,57 @@ pub async fn fetch_models(options: FetchModelsRequest<'_>) -> Result r, - Err(e) => { - return Err(format!("Request failed: {e}")); + Err(error) => { + let kind = request_error_kind(&error); + log::debug!( + "[ModelFetch] candidate {ordinal}/{} transport failure: {kind}", + candidates.len() + ); + record_candidate_failure( + &mut candidate_failures, + format!("candidate {ordinal}: transport {kind}"), + ); + continue; } }; let status = response.status(); if status.is_success() { - let resp: ModelsResponse = response - .json() - .await - .map_err(|e| format!("Failed to parse response: {e}"))?; - - let mut models: Vec = resp - .data - .unwrap_or_default() + let body = match response.bytes().await { + Ok(body) => body, + Err(error) => { + let kind = request_error_kind(&error); + record_candidate_failure( + &mut candidate_failures, + format!("candidate {ordinal}: response body {kind}"), + ); + continue; + } + }; + let resp: ModelsResponse = match serde_json::from_slice(&body) { + Ok(resp) => resp, + Err(error) => { + log::debug!( + "[ModelFetch] candidate {ordinal}/{} returned invalid JSON: {error}", + candidates.len() + ); + record_candidate_failure( + &mut candidate_failures, + format!("candidate {ordinal}: invalid JSON ({error})"), + ); + continue; + } + }; + let Some(data) = resp.data else { + record_candidate_failure( + &mut candidate_failures, + format!("candidate {ordinal}: response missing data array"), + ); + continue; + }; + + let mut models: Vec = data .into_iter() .map(|m| FetchedModel { context_window: extract_context_window(&m.extra), @@ -225,9 +290,11 @@ pub async fn fetch_models(options: FetchModelsRequest<'_>) -> Result) -> Result bool { mod tests { use super::*; + #[test] + fn candidate_retry_policy_continues_discovery_only_for_compatible_failures() { + assert!(should_try_next_models_candidate(StatusCode::NOT_FOUND)); + assert!(should_try_next_models_candidate( + StatusCode::METHOD_NOT_ALLOWED + )); + assert!(should_try_next_models_candidate( + StatusCode::INTERNAL_SERVER_ERROR + )); + assert!(should_try_next_models_candidate(StatusCode::BAD_GATEWAY)); + assert!(!should_try_next_models_candidate(StatusCode::BAD_REQUEST)); + assert!(!should_try_next_models_candidate(StatusCode::UNAUTHORIZED)); + assert!(!should_try_next_models_candidate(StatusCode::FORBIDDEN)); + assert!(!should_try_next_models_candidate( + StatusCode::TOO_MANY_REQUESTS + )); + } + + #[test] + fn candidate_failure_details_are_bounded() { + let mut failures = Vec::new(); + for index in 0..(MAX_CANDIDATE_FAILURE_DETAILS + 3) { + record_candidate_failure(&mut failures, format!("candidate {index}")); + } + assert_eq!(failures.len(), MAX_CANDIDATE_FAILURE_DETAILS); + } + #[test] fn test_candidates_plain_root() { let c = build_models_url_candidates("https://api.siliconflow.cn", false, None).unwrap(); From e05f6ca32943d93357438e684bb37239846c4949 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 12:28:23 +0800 Subject: [PATCH 056/112] ci: centralize home directory resolution --- scripts/check_rust_failure_boundaries.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/check_rust_failure_boundaries.py b/scripts/check_rust_failure_boundaries.py index 85af791f93a..bda3602702b 100644 --- a/scripts/check_rust_failure_boundaries.py +++ b/scripts/check_rust_failure_boundaries.py @@ -42,6 +42,18 @@ if pattern.search(path.read_text(encoding="utf-8")): failures.append(f"{path.relative_to(ROOT)}: {message}") +# User-home resolution is a persistence boundary shared by DB/config/backup/CLI paths. A direct +# dirs::home_dir() call elsewhere can silently re-introduce CWD/relative fallback semantics or +# diverge from the validated CC_SWITCH_TEST_HOME behavior, so keep one common implementation. +for path in RUST_ROOT.rglob("*.rs"): + if path.name == "config.rs": + continue + text = path.read_text(encoding="utf-8") + if "dirs::home_dir(" in text: + failures.append( + f"{path.relative_to(ROOT)}: direct home resolution must use the config common boundary" + ) + # URL sanitization is a common diagnostics boundary. Specialized copies drift and caused raw # deep-link/model-fetch paths to be missed; keep implementations centralized. for path in RUST_ROOT.rglob("*.rs"): From c40a5d150bdb2d0cbf86e7947dbec6abc10246a9 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 12:32:05 +0800 Subject: [PATCH 057/112] ci: unify remaining home resolution boundaries --- .../workflows/home-model-hardening-once.yml | 504 ++++++++++-------- 1 file changed, 291 insertions(+), 213 deletions(-) diff --git a/.github/workflows/home-model-hardening-once.yml b/.github/workflows/home-model-hardening-once.yml index c1ef77887ec..eccb7ac5153 100644 --- a/.github/workflows/home-model-hardening-once.yml +++ b/.github/workflows/home-model-hardening-once.yml @@ -1,4 +1,4 @@ -name: Home Model Hardening Once +name: Home Boundary Cleanup Once on: push: @@ -20,251 +20,317 @@ jobs: ref: fix/global-hardening-20260904 fetch-depth: 0 - - name: Apply structure-aware HOME and model candidate fixes + - name: Route all HOME resolution through config boundary shell: bash run: | set -euo pipefail python - <<'PY' from pathlib import Path - config_path = Path("src-tauri/src/config.rs") - config = config_path.read_text(encoding="utf-8") - - old_return = " return Ok(PathBuf::from(home));" - new_return = """ let path = PathBuf::from(home); - if path.is_absolute() { - return Ok(path); + def replace_exact(path_str: str, old: str, new: str, expected: int = 1) -> None: + path = Path(path_str) + text = path.read_text(encoding="utf-8") + count = text.count(old) + if count != expected: + raise SystemExit(f"{path_str}: expected {expected} matches, found {count}") + path.write_text(text.replace(old, new, expected), encoding="utf-8") + + replace_exact( + "src-tauri/src/config.rs", + '''pub fn get_home_dir() -> PathBuf { + let test_override = std::env::var("CC_SWITCH_TEST_HOME").ok(); + resolve_home_dir(test_override.as_deref(), dirs::home_dir()).unwrap_or_else(|err| { + log::error!("{err}"); + panic!("{err}"); + }) } - return Err(format!( - "CC_SWITCH_TEST_HOME 必须是绝对路径,收到: {}", - path.display() - ));""" - if config.count(old_return) != 1: - raise SystemExit(f"expected one unvalidated test-home return, found {config.count(old_return)}") - config = config.replace(old_return, new_return, 1) - - test_start_marker = " #[test]\n fn explicit_test_home_override_remains_supported() {" - if config.count(test_start_marker) != 1: - raise SystemExit("expected one legacy explicit test-home test") - test_start = config.index(test_start_marker) - test_end = config.index("\n\n #[test]", test_start + len(test_start_marker)) - captured = config[test_start:test_end] - hashset_import = " use std::collections::HashSet;" - if captured.count(hashset_import) != 1: - raise SystemExit( - f"expected HashSet import inside replaced HOME-test segment, found {captured.count(hashset_import)}" - ) - replacement_tests = """ #[test] - fn explicit_absolute_test_home_override_remains_supported() { - let override_home = std::env::temp_dir().join("cc-switch-test-home"); - let override_text = override_home.to_string_lossy().to_string(); - assert_eq!( - resolve_home_dir(Some(&override_text), None).unwrap(), - override_home - ); + ''', + '''pub fn try_get_home_dir() -> Result { + let test_override = std::env::var("CC_SWITCH_TEST_HOME").ok(); + resolve_home_dir(test_override.as_deref(), dirs::home_dir()) } - #[test] - fn explicit_relative_test_home_override_is_rejected() { - assert!(resolve_home_dir(Some("relative-test-home"), None).is_err()); + pub fn get_home_dir() -> PathBuf { + try_get_home_dir().unwrap_or_else(|err| { + log::error!("{err}"); + panic!("{err}"); + }) } - use std::collections::HashSet;""" - config = config[:test_start] + replacement_tests + config[test_end:] - config_path.write_text(config, encoding="utf-8") + /// 崩溃/退出诊断在 HOME 本身不可用时的最后观测目录。 + /// 该目录只允许用于故障证据,绝不能作为数据库、配置或用户数据的持久化根目录。 + pub fn emergency_observability_dir() -> PathBuf { + std::env::temp_dir().join("cc-switch-home-unavailable") + } + ''', + ) - model_path = Path("src-tauri/src/services/model_fetch.rs") - model = model_path.read_text(encoding="utf-8") + replace_exact( + "src-tauri/src/config.rs", + ''' #[test] + fn explicit_relative_test_home_override_is_rejected() { + assert!(resolve_home_dir(Some("relative-test-home"), None).is_err()); + } + ''', + ''' #[test] + fn explicit_relative_test_home_override_is_rejected() { + assert!(resolve_home_dir(Some("relative-test-home"), None).is_err()); + } - timeout_marker = "const FETCH_TIMEOUT_SECS: u64 = 15;\n" - helpers = """const MAX_CANDIDATE_FAILURE_DETAILS: usize = 8; + #[test] + fn emergency_observability_dir_is_named_temp_fallback() { + let path = emergency_observability_dir(); + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some("cc-switch-home-unavailable") + ); + assert!(path.is_absolute()); + } + ''', + ) - fn should_try_next_models_candidate(status: StatusCode) -> bool { - status == StatusCode::NOT_FOUND - || status == StatusCode::METHOD_NOT_ALLOWED - || status.is_server_error() + observability_old = '''fn default_app_config_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".cc-switch") } - - fn request_error_kind(error: &reqwest::Error) -> &'static str { - if error.is_timeout() { - "timeout" - } else if error.is_connect() { - "connect" - } else if error.is_request() { - "request" - } else if error.is_body() { - "body" - } else if error.is_decode() { - "decode" - } else { - "transport" + ''' + observability_new = '''fn default_app_config_dir() -> PathBuf { + match crate::config::try_get_home_dir() { + Ok(home) => home.join(".cc-switch"), + Err(err) => { + let fallback = crate::config::emergency_observability_dir(); + eprintln!( + "[CC-Switch] HOME unavailable for crash/exit diagnostics: {err}; using {}", + fallback.display() + ); + fallback + } } } + ''' + replace_exact("src-tauri/src/app_exit_monitor.rs", observability_old, observability_new) + replace_exact("src-tauri/src/panic_hook.rs", observability_old, observability_new) + + resolve_path_old = '''fn resolve_path(raw: &str) -> PathBuf { + if raw == "~" { + if let Some(home) = dirs::home_dir() { + return home; + } + } else if let Some(stripped) = raw.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(stripped); + } + } else if let Some(stripped) = raw.strip_prefix("~\\\\") { + if let Some(home) = dirs::home_dir() { + return home.join(stripped); + } + } - fn record_candidate_failure(failures: &mut Vec, detail: String) { - if failures.len() < MAX_CANDIDATE_FAILURE_DETAILS { - failures.push(detail); + PathBuf::from(raw) + } + ''' + resolve_path_new = '''fn resolve_path(raw: &str) -> PathBuf { + if raw == "~" { + return crate::config::get_home_dir(); } + if let Some(stripped) = raw.strip_prefix("~/") { + return crate::config::get_home_dir().join(stripped); + } + if let Some(stripped) = raw.strip_prefix("~\\\\") { + return crate::config::get_home_dir().join(stripped); + } + + PathBuf::from(raw) } + ''' + replace_exact("src-tauri/src/app_store.rs", resolve_path_old, resolve_path_new) - """ - if model.count(timeout_marker) != 1: - raise SystemExit(f"expected one fetch-timeout marker, found {model.count(timeout_marker)}") - model = model.replace(timeout_marker, timeout_marker + helpers, 1) - - old_accumulator = " let mut last_err: Option = None;\n" - if model.count(old_accumulator) != 1: - raise SystemExit("expected one legacy last_err accumulator") - model = model.replace( - old_accumulator, - " let mut candidate_failures: Vec = Vec::new();\n", - 1, + replace_exact( + "src-tauri/src/claude_plugin.rs", + ' let home = dirs::home_dir().ok_or_else(|| AppError::Config("无法获取用户主目录".into()))?;\n', + ' let home = crate::config::try_get_home_dir().map_err(AppError::Config)?;\n', ) - old_loop = " for url in &candidates {\n" - if model.count(old_loop) != 1: - raise SystemExit("expected one model candidate loop") - model = model.replace( - old_loop, - " for (index, url) in candidates.iter().enumerate() {\n let ordinal = index + 1;\n", - 1, + replace_exact( + "src-tauri/src/commands/misc.rs", + ' let home = dirs::home_dir().unwrap_or_default();\n', + ' let home = crate::config::get_home_dir();\n', ) - send_start_marker = " let response = match request_builder.send().await {" - send_end_marker = "\n\n let status = response.status();" - if model.count(send_start_marker) != 1: - raise SystemExit("expected one request send block") - send_start = model.index(send_start_marker) - send_end = model.index(send_end_marker, send_start) - new_send = """ let response = match request_builder.send().await { - Ok(r) => r, - Err(error) => { - let kind = request_error_kind(&error); - log::debug!( - "[ModelFetch] candidate {ordinal}/{} transport failure: {kind}", - candidates.len() - ); - record_candidate_failure( - &mut candidate_failures, - format!("candidate {ordinal}: transport {kind}"), - ); - continue; - } - };""" - model = model[:send_start] + new_send + model[send_end:] - - success_start_marker = " if status.is_success() {" - terminal_marker = "\n\n let body = truncate_body(response.text().await.unwrap_or_default());\n return Err(format!(\"HTTP {status}: {body}\"));" - if model.count(success_start_marker) != 1 or model.count(terminal_marker) != 1: - raise SystemExit("model success/terminal status boundaries are not unique") - success_start = model.index(success_start_marker) - terminal_start = model.index(terminal_marker, success_start) - new_success = """ if status.is_success() { - let body = match response.bytes().await { - Ok(body) => body, - Err(error) => { - let kind = request_error_kind(&error); - record_candidate_failure( - &mut candidate_failures, - format!("candidate {ordinal}: response body {kind}"), - ); - continue; + replace_exact( + "src-tauri/src/database/backup.rs", + '''fn current_home_string() -> Option { + dirs::home_dir().map(|path| path.to_string_lossy().to_string()) + } + ''', + '''fn current_home_string() -> Option { + crate::config::try_get_home_dir() + .ok() + .map(|path| path.to_string_lossy().to_string()) + } + ''', + ) + + replace_exact( + "src-tauri/src/prompt_files.rs", + ''' primary_path + .parent() + .map(|p| p.to_path_buf()) + .or_else(|| dirs::home_dir().map(|h| h.join(fallback_dir))) + .ok_or_else(|| { + ''', + ''' primary_path + .parent() + .map(|p| p.to_path_buf()) + .or_else(|| crate::config::try_get_home_dir().ok().map(|h| h.join(fallback_dir))) + .ok_or_else(|| { + ''', + ) + + replace_exact( + "src-tauri/src/services/env_manager.rs", + '''fn get_backup_dir() -> Result { + let home = dirs::home_dir().ok_or("无法获取用户主目录")?; + Ok(home.join(".cc-switch").join("backups")) + } + ''', + '''fn get_backup_dir() -> Result { + let home = crate::config::try_get_home_dir()?; + Ok(home.join(".cc-switch").join("backups")) + } + ''', + ) + + replace_exact( + "src-tauri/src/services/skill.rs", + '''fn get_agents_skills_dir() -> Option { + dirs::home_dir() + .map(|h| h.join(".agents").join("skills")) + .filter(|p| p.exists()) + } + ''', + '''fn get_agents_skills_dir() -> Option { + crate::config::try_get_home_dir() + .ok() + .map(|h| h.join(".agents").join("skills")) + .filter(|p| p.exists()) + } + ''', + ) + replace_exact( + "src-tauri/src/services/skill.rs", + ''' let path = match dirs::home_dir() { + Some(h) => h.join(".agents").join(".skill-lock.json"), + None => { + log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件"); + return HashMap::new(); } }; - let resp: ModelsResponse = match serde_json::from_slice(&body) { - Ok(resp) => resp, - Err(error) => { - log::debug!( - "[ModelFetch] candidate {ordinal}/{} returned invalid JSON: {error}", - candidates.len() - ); - record_candidate_failure( - &mut candidate_failures, - format!("candidate {ordinal}: invalid JSON ({error})"), - ); - continue; + ''', + ''' let path = match crate::config::try_get_home_dir() { + Ok(home) => home.join(".agents").join(".skill-lock.json"), + Err(err) => { + log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件: {err}"); + return HashMap::new(); } }; - let Some(data) = resp.data else { - record_candidate_failure( - &mut candidate_failures, - format!("candidate {ordinal}: response missing data array"), - ); - continue; - }; + ''', + ) + replace_exact( + "src-tauri/src/services/skill.rs", + ''' SkillStorageLocation::Unified => { + let home = dirs::home_dir().context("Cannot determine home directory")?; + home.join(".agents").join("skills") + } + ''', + ''' SkillStorageLocation::Unified => { + let home = crate::config::try_get_home_dir().map_err(|err| anyhow!(err))?; + home.join(".agents").join("skills") + } + ''', + ) + + replace_exact( + "src-tauri/src/session_manager/providers/opencode.rs", + ''' dirs::home_dir() + .map(|h| h.join(".local/share/opencode")) + .unwrap_or_else(|| PathBuf::from(".local/share/opencode")) + ''', + ''' crate::config::get_home_dir().join(".local/share/opencode") + ''', + ) - let mut models: Vec = data - .into_iter() - .map(|m| FetchedModel { - context_window: extract_context_window(&m.extra), - id: m.id, - owned_by: m.owned_by, - }) - .collect(); - - enrich_missing_context_windows(&client, url, &mut models).await; - models.sort_by(|a, b| a.id.cmp(&b.id)); - return Ok(models); + replace_exact( + "src-tauri/src/settings.rs", + '''fn resolve_override_path(raw: &str) -> PathBuf { + if raw == "~" { + if let Some(home) = dirs::home_dir() { + return home; + } + } else if let Some(stripped) = raw.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(stripped); + } + } else if let Some(stripped) = raw.strip_prefix("~\\\\") { + if let Some(home) = dirs::home_dir() { + return home.join(stripped); + } + } + + PathBuf::from(raw) } + ''', + '''fn resolve_override_path(raw: &str) -> PathBuf { + if raw == "~" { + return crate::config::get_home_dir(); + } + if let Some(stripped) = raw.strip_prefix("~/") { + return crate::config::get_home_dir().join(stripped); + } + if let Some(stripped) = raw.strip_prefix("~\\\\") { + return crate::config::get_home_dir().join(stripped); + } - if should_try_next_models_candidate(status) { - record_candidate_failure( - &mut candidate_failures, - format!("candidate {ordinal}: HTTP {status}"), - ); - continue; - }""" - model = model[:success_start] + new_success + model[terminal_start:] - - final_start_marker = " Err(format!(\n \"All candidates failed: {}\"," - if model.count(final_start_marker) != 1: - raise SystemExit("expected one legacy final candidate error") - final_start = model.index(final_start_marker) - final_end = model.index("\n}", final_start) - new_final = """ let details = if candidate_failures.is_empty() { - "no candidate diagnostics".to_string() - } else { - candidate_failures.join("; ") - }; - Err(format!("All model endpoint candidates failed: {details}"))""" - model = model[:final_start] + new_final + model[final_end:] - - test_anchor = " #[test]\n fn test_candidates_plain_root() {" - if model.count(test_anchor) != 1: - raise SystemExit("expected one model candidate test anchor") - extra_tests = """ #[test] - fn candidate_retry_policy_continues_discovery_only_for_compatible_failures() { - assert!(should_try_next_models_candidate(StatusCode::NOT_FOUND)); - assert!(should_try_next_models_candidate(StatusCode::METHOD_NOT_ALLOWED)); - assert!(should_try_next_models_candidate(StatusCode::INTERNAL_SERVER_ERROR)); - assert!(should_try_next_models_candidate(StatusCode::BAD_GATEWAY)); - assert!(!should_try_next_models_candidate(StatusCode::BAD_REQUEST)); - assert!(!should_try_next_models_candidate(StatusCode::UNAUTHORIZED)); - assert!(!should_try_next_models_candidate(StatusCode::FORBIDDEN)); - assert!(!should_try_next_models_candidate(StatusCode::TOO_MANY_REQUESTS)); + PathBuf::from(raw) } + ''', + ) - #[test] - fn candidate_failure_details_are_bounded() { - let mut failures = Vec::new(); - for index in 0..(MAX_CANDIDATE_FAILURE_DETAILS + 3) { - record_candidate_failure(&mut failures, format!("candidate {index}")); + # XDG_DATA_HOME is specified as an absolute path. Ignore malformed relative values so + # OpenCode discovery cannot bind to the process CWD. + replace_exact( + "src-tauri/src/session_manager/providers/opencode.rs", + ''' if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + if !xdg.is_empty() { + return PathBuf::from(xdg).join("opencode"); + } } - assert_eq!(failures.len(), MAX_CANDIDATE_FAILURE_DETAILS); - } + ''', + ''' if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + let xdg = PathBuf::from(xdg.trim()); + if xdg.is_absolute() { + return xdg.join("opencode"); + } + if !xdg.as_os_str().is_empty() { + log::warn!( + "Ignoring relative XDG_DATA_HOME for OpenCode discovery: {}", + xdg.display() + ); + } + } + ''', + ) - """ - model = model.replace(test_anchor, extra_tests + test_anchor, 1) - - forbidden = [ - 'return Err(format!("Request failed: {e}"));', - 'last_err = Some(format!("HTTP {status}: {body}"));', - '"All candidates failed: {}"', - ] - for fragment in forbidden: - if fragment in model: - raise SystemExit(f"legacy model candidate failure boundary remains: {fragment}") - model_path.write_text(model, encoding="utf-8") + # The permanent guard must pass after the common-boundary migration. + offenders = [] + for path in Path("src-tauri/src").rglob("*.rs"): + if path.name == "config.rs": + continue + if "dirs::home_dir(" in path.read_text(encoding="utf-8"): + offenders.append(str(path)) + if offenders: + raise SystemExit(f"direct dirs::home_dir remains: {offenders}") PY - name: Install Linux system dependencies @@ -299,11 +365,23 @@ jobs: - name: Rust tests all features run: cargo test --manifest-path src-tauri/Cargo.toml --all-features - - name: Commit verified source fixes + - name: Commit verified HOME boundary migration run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src-tauri/src/config.rs src-tauri/src/services/model_fetch.rs + git add \ + src-tauri/src/config.rs \ + src-tauri/src/app_exit_monitor.rs \ + src-tauri/src/panic_hook.rs \ + src-tauri/src/app_store.rs \ + src-tauri/src/claude_plugin.rs \ + src-tauri/src/commands/misc.rs \ + src-tauri/src/database/backup.rs \ + src-tauri/src/prompt_files.rs \ + src-tauri/src/services/env_manager.rs \ + src-tauri/src/services/skill.rs \ + src-tauri/src/session_manager/providers/opencode.rs \ + src-tauri/src/settings.rs git diff --cached --check - git commit -m "fix: fail closed home override and retry model candidates" + git commit -m "fix: centralize home resolution across persistence paths" git push origin HEAD:fix/global-hardening-20260904 From fd09e78854eff2e81c809c2a7ac3ede4bd738d29 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 12:44:35 +0800 Subject: [PATCH 058/112] ci: rerun unified home boundary migration --- .../workflows/home-model-hardening-once.yml | 244 ++++-------------- 1 file changed, 54 insertions(+), 190 deletions(-) diff --git a/.github/workflows/home-model-hardening-once.yml b/.github/workflows/home-model-hardening-once.yml index eccb7ac5153..52e19c61ac9 100644 --- a/.github/workflows/home-model-hardening-once.yml +++ b/.github/workflows/home-model-hardening-once.yml @@ -27,12 +27,12 @@ jobs: python - <<'PY' from pathlib import Path - def replace_exact(path_str: str, old: str, new: str, expected: int = 1) -> None: + def replace_exact(path_str, old, new, expected=1): path = Path(path_str) text = path.read_text(encoding="utf-8") count = text.count(old) if count != expected: - raise SystemExit(f"{path_str}: expected {expected} matches, found {count}") + raise SystemExit(f"{path_str}: expected {expected} matches, found {count}: {old!r}") path.write_text(text.replace(old, new, expected), encoding="utf-8") replace_exact( @@ -57,8 +57,23 @@ jobs: }) } - /// 崩溃/退出诊断在 HOME 本身不可用时的最后观测目录。 - /// 该目录只允许用于故障证据,绝不能作为数据库、配置或用户数据的持久化根目录。 + /// Expand `~`, `~/...`, and `~\\...` through the same validated HOME boundary. + /// Missing or malformed HOME is an error; callers must not preserve a literal relative `~` path. + pub fn expand_home_path(raw: &str) -> Result { + if raw == "~" { + return try_get_home_dir(); + } + if let Some(stripped) = raw.strip_prefix("~/") { + return Ok(try_get_home_dir()?.join(stripped)); + } + if let Some(stripped) = raw.strip_prefix("~\\\\") { + return Ok(try_get_home_dir()?.join(stripped)); + } + Ok(PathBuf::from(raw)) + } + + /// Last-resort crash/exit observability directory when HOME itself is unavailable. + /// This must never be used for the database, settings, provider config, or other user state. pub fn emergency_observability_dir() -> PathBuf { std::env::temp_dir().join("cc-switch-home-unavailable") } @@ -112,217 +127,78 @@ jobs: replace_exact("src-tauri/src/app_exit_monitor.rs", observability_old, observability_new) replace_exact("src-tauri/src/panic_hook.rs", observability_old, observability_new) - resolve_path_old = '''fn resolve_path(raw: &str) -> PathBuf { - if raw == "~" { - if let Some(home) = dirs::home_dir() { - return home; - } - } else if let Some(stripped) = raw.strip_prefix("~/") { - if let Some(home) = dirs::home_dir() { - return home.join(stripped); - } - } else if let Some(stripped) = raw.strip_prefix("~\\\\") { - if let Some(home) = dirs::home_dir() { - return home.join(stripped); - } - } - - PathBuf::from(raw) - } - ''' - resolve_path_new = '''fn resolve_path(raw: &str) -> PathBuf { - if raw == "~" { - return crate::config::get_home_dir(); - } - if let Some(stripped) = raw.strip_prefix("~/") { - return crate::config::get_home_dir().join(stripped); - } - if let Some(stripped) = raw.strip_prefix("~\\\\") { - return crate::config::get_home_dir().join(stripped); - } - - PathBuf::from(raw) - } - ''' - replace_exact("src-tauri/src/app_store.rs", resolve_path_old, resolve_path_new) + for path_str, fn_name in [ + ("src-tauri/src/app_store.rs", "resolve_path"), + ("src-tauri/src/settings.rs", "resolve_override_path"), + ]: + path = Path(path_str) + text = path.read_text(encoding="utf-8") + start = text.index(f"fn {fn_name}(raw: &str) -> PathBuf {{") + end = text.index("\n}\n", start) + 3 + block = text[start:end] + if block.count("dirs::home_dir()") != 3: + raise SystemExit(f"{path_str}: expected three legacy HOME lookups in {fn_name}") + replacement = f'''fn {fn_name}(raw: &str) -> PathBuf {{ + crate::config::expand_home_path(raw).unwrap_or_else(|err| {{ + log::error!("{{err}}"); + panic!("{{err}}"); + }}) + }}\n''' + path.write_text(text[:start] + replacement + text[end:], encoding="utf-8") replace_exact( "src-tauri/src/claude_plugin.rs", ' let home = dirs::home_dir().ok_or_else(|| AppError::Config("无法获取用户主目录".into()))?;\n', ' let home = crate::config::try_get_home_dir().map_err(AppError::Config)?;\n', ) - replace_exact( "src-tauri/src/commands/misc.rs", ' let home = dirs::home_dir().unwrap_or_default();\n', ' let home = crate::config::get_home_dir();\n', ) - replace_exact( "src-tauri/src/database/backup.rs", - '''fn current_home_string() -> Option { - dirs::home_dir().map(|path| path.to_string_lossy().to_string()) - } - ''', - '''fn current_home_string() -> Option { - crate::config::try_get_home_dir() - .ok() - .map(|path| path.to_string_lossy().to_string()) - } - ''', + ' dirs::home_dir().map(|path| path.to_string_lossy().to_string())\n', + ' crate::config::try_get_home_dir().ok().map(|path| path.to_string_lossy().to_string())\n', ) - replace_exact( "src-tauri/src/prompt_files.rs", - ''' primary_path - .parent() - .map(|p| p.to_path_buf()) - .or_else(|| dirs::home_dir().map(|h| h.join(fallback_dir))) - .ok_or_else(|| { - ''', - ''' primary_path - .parent() - .map(|p| p.to_path_buf()) - .or_else(|| crate::config::try_get_home_dir().ok().map(|h| h.join(fallback_dir))) - .ok_or_else(|| { - ''', + ' .or_else(|| dirs::home_dir().map(|h| h.join(fallback_dir)))\n', + ' .or_else(|| crate::config::try_get_home_dir().ok().map(|h| h.join(fallback_dir)))\n', ) - replace_exact( "src-tauri/src/services/env_manager.rs", - '''fn get_backup_dir() -> Result { - let home = dirs::home_dir().ok_or("无法获取用户主目录")?; - Ok(home.join(".cc-switch").join("backups")) - } - ''', - '''fn get_backup_dir() -> Result { - let home = crate::config::try_get_home_dir()?; - Ok(home.join(".cc-switch").join("backups")) - } - ''', + ' let home = dirs::home_dir().ok_or("无法获取用户主目录")?;\n', + ' let home = crate::config::try_get_home_dir()?;\n', ) replace_exact( "src-tauri/src/services/skill.rs", - '''fn get_agents_skills_dir() -> Option { - dirs::home_dir() - .map(|h| h.join(".agents").join("skills")) - .filter(|p| p.exists()) - } - ''', - '''fn get_agents_skills_dir() -> Option { - crate::config::try_get_home_dir() - .ok() - .map(|h| h.join(".agents").join("skills")) - .filter(|p| p.exists()) - } - ''', + ' dirs::home_dir()\n .map(|h| h.join(".agents").join("skills"))\n', + ' crate::config::try_get_home_dir()\n .ok()\n .map(|h| h.join(".agents").join("skills"))\n', ) replace_exact( "src-tauri/src/services/skill.rs", - ''' let path = match dirs::home_dir() { - Some(h) => h.join(".agents").join(".skill-lock.json"), - None => { - log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件"); - return HashMap::new(); - } - }; - ''', - ''' let path = match crate::config::try_get_home_dir() { - Ok(home) => home.join(".agents").join(".skill-lock.json"), - Err(err) => { - log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件: {err}"); - return HashMap::new(); - } - }; - ''', + ' let path = match dirs::home_dir() {\n Some(h) => h.join(".agents").join(".skill-lock.json"),\n None => {\n log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件");\n return HashMap::new();\n }\n };\n', + ' let path = match crate::config::try_get_home_dir() {\n Ok(home) => home.join(".agents").join(".skill-lock.json"),\n Err(err) => {\n log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件: {err}");\n return HashMap::new();\n }\n };\n', ) replace_exact( "src-tauri/src/services/skill.rs", - ''' SkillStorageLocation::Unified => { - let home = dirs::home_dir().context("Cannot determine home directory")?; - home.join(".agents").join("skills") - } - ''', - ''' SkillStorageLocation::Unified => { - let home = crate::config::try_get_home_dir().map_err(|err| anyhow!(err))?; - home.join(".agents").join("skills") - } - ''', + ' let home = dirs::home_dir().context("Cannot determine home directory")?;\n', + ' let home = crate::config::try_get_home_dir().map_err(|err| anyhow!(err))?;\n', ) replace_exact( "src-tauri/src/session_manager/providers/opencode.rs", - ''' dirs::home_dir() - .map(|h| h.join(".local/share/opencode")) - .unwrap_or_else(|| PathBuf::from(".local/share/opencode")) - ''', - ''' crate::config::get_home_dir().join(".local/share/opencode") - ''', + ' dirs::home_dir()\n .map(|h| h.join(".local/share/opencode"))\n .unwrap_or_else(|| PathBuf::from(".local/share/opencode"))\n', + ' crate::config::get_home_dir().join(".local/share/opencode")\n', ) - - replace_exact( - "src-tauri/src/settings.rs", - '''fn resolve_override_path(raw: &str) -> PathBuf { - if raw == "~" { - if let Some(home) = dirs::home_dir() { - return home; - } - } else if let Some(stripped) = raw.strip_prefix("~/") { - if let Some(home) = dirs::home_dir() { - return home.join(stripped); - } - } else if let Some(stripped) = raw.strip_prefix("~\\\\") { - if let Some(home) = dirs::home_dir() { - return home.join(stripped); - } - } - - PathBuf::from(raw) - } - ''', - '''fn resolve_override_path(raw: &str) -> PathBuf { - if raw == "~" { - return crate::config::get_home_dir(); - } - if let Some(stripped) = raw.strip_prefix("~/") { - return crate::config::get_home_dir().join(stripped); - } - if let Some(stripped) = raw.strip_prefix("~\\\\") { - return crate::config::get_home_dir().join(stripped); - } - - PathBuf::from(raw) - } - ''', - ) - - # XDG_DATA_HOME is specified as an absolute path. Ignore malformed relative values so - # OpenCode discovery cannot bind to the process CWD. replace_exact( "src-tauri/src/session_manager/providers/opencode.rs", - ''' if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { - if !xdg.is_empty() { - return PathBuf::from(xdg).join("opencode"); - } - } - ''', - ''' if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { - let xdg = PathBuf::from(xdg.trim()); - if xdg.is_absolute() { - return xdg.join("opencode"); - } - if !xdg.as_os_str().is_empty() { - log::warn!( - "Ignoring relative XDG_DATA_HOME for OpenCode discovery: {}", - xdg.display() - ); - } - } - ''', + ' if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {\n if !xdg.is_empty() {\n return PathBuf::from(xdg).join("opencode");\n }\n }\n', + ' if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {\n let xdg = PathBuf::from(xdg.trim());\n if xdg.is_absolute() {\n return xdg.join("opencode");\n }\n if !xdg.as_os_str().is_empty() {\n log::warn!("Ignoring relative XDG_DATA_HOME for OpenCode discovery: {}", xdg.display());\n }\n }\n', ) - # The permanent guard must pass after the common-boundary migration. offenders = [] for path in Path("src-tauri/src").rglob("*.rs"): if path.name == "config.rs": @@ -369,19 +245,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add \ - src-tauri/src/config.rs \ - src-tauri/src/app_exit_monitor.rs \ - src-tauri/src/panic_hook.rs \ - src-tauri/src/app_store.rs \ - src-tauri/src/claude_plugin.rs \ - src-tauri/src/commands/misc.rs \ - src-tauri/src/database/backup.rs \ - src-tauri/src/prompt_files.rs \ - src-tauri/src/services/env_manager.rs \ - src-tauri/src/services/skill.rs \ - src-tauri/src/session_manager/providers/opencode.rs \ - src-tauri/src/settings.rs + git add src-tauri/src git diff --cached --check git commit -m "fix: centralize home resolution across persistence paths" git push origin HEAD:fix/global-hardening-20260904 From b98f83ad1defc26c0e7c802731975e1e98a9808b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 04:54:33 +0000 Subject: [PATCH 059/112] fix: centralize home resolution across persistence paths --- src-tauri/src/app_exit_monitor.rs | 14 +++++-- src-tauri/src/app_store.rs | 19 ++------- src-tauri/src/claude_plugin.rs | 2 +- src-tauri/src/commands/misc.rs | 2 +- src-tauri/src/config.rs | 39 ++++++++++++++++++- src-tauri/src/database/backup.rs | 6 ++- src-tauri/src/panic_hook.rs | 14 +++++-- src-tauri/src/prompt_files.rs | 6 ++- src-tauri/src/services/env_manager.rs | 2 +- src-tauri/src/services/skill.rs | 13 ++++--- .../src/session_manager/providers/opencode.rs | 15 ++++--- src-tauri/src/settings.rs | 19 ++------- 12 files changed, 96 insertions(+), 55 deletions(-) diff --git a/src-tauri/src/app_exit_monitor.rs b/src-tauri/src/app_exit_monitor.rs index 96991b1ae01..0a30c587f8d 100644 --- a/src-tauri/src/app_exit_monitor.rs +++ b/src-tauri/src/app_exit_monitor.rs @@ -211,9 +211,17 @@ fn get_app_config_dir() -> PathBuf { } fn default_app_config_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".cc-switch") + match crate::config::try_get_home_dir() { + Ok(home) => home.join(".cc-switch"), + Err(err) => { + let fallback = crate::config::emergency_observability_dir(); + eprintln!( + "[CC-Switch] HOME unavailable for crash/exit diagnostics: {err}; using {}", + fallback.display() + ); + fallback + } + } } fn now_string() -> String { diff --git a/src-tauri/src/app_store.rs b/src-tauri/src/app_store.rs index 53e3d6ea67a..7cd500a9125 100644 --- a/src-tauri/src/app_store.rs +++ b/src-tauri/src/app_store.rs @@ -222,21 +222,10 @@ pub fn set_app_config_dir_to_store( /// 解析路径,支持 ~ 开头的相对路径 fn resolve_path(raw: &str) -> PathBuf { - if raw == "~" { - if let Some(home) = dirs::home_dir() { - return home; - } - } else if let Some(stripped) = raw.strip_prefix("~/") { - if let Some(home) = dirs::home_dir() { - return home.join(stripped); - } - } else if let Some(stripped) = raw.strip_prefix("~\\") { - if let Some(home) = dirs::home_dir() { - return home.join(stripped); - } - } - - PathBuf::from(raw) + crate::config::expand_home_path(raw).unwrap_or_else(|err| { + log::error!("{err}"); + panic!("{err}"); + }) } /// 从旧的 settings.json 迁移 app_config_dir 到 Store。 diff --git a/src-tauri/src/claude_plugin.rs b/src-tauri/src/claude_plugin.rs index d7294602e41..865fd89d353 100644 --- a/src-tauri/src/claude_plugin.rs +++ b/src-tauri/src/claude_plugin.rs @@ -11,7 +11,7 @@ fn claude_dir() -> Result { if let Some(dir) = crate::settings::get_claude_override_dir() { return Ok(dir); } - let home = dirs::home_dir().ok_or_else(|| AppError::Config("无法获取用户主目录".into()))?; + let home = crate::config::try_get_home_dir().map_err(AppError::Config)?; Ok(home.join(CLAUDE_DIR)) } diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index f9fb13deb31..701f48a8c2f 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -1492,7 +1492,7 @@ fn extend_mise_node_search_paths(paths: &mut Vec, home: &Pat /// 单探兜底 (`scan_cli_version`) 与全量枚举 (`enumerate_tool_installations`) 共用, /// 确保两条路径看到的是同一组安装位置。 fn build_tool_search_paths(tool: &str) -> Vec { - let home = dirs::home_dir().unwrap_or_default(); + let home = crate::config::get_home_dir(); // 常见的安装路径(原生安装优先) let mut search_paths: Vec = Vec::new(); diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index f0e23d92f99..4398bd2e7bf 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -55,14 +55,39 @@ fn resolve_home_dir( /// 用户主目录是数据库、设置和多个 CLI 配置路径的共同根。无法解析时必须 fail closed: /// 旧行为回退到 `.` 会根据启动方式把同一用户的数据写进任意 CWD,表现为供应商/设置丢失, /// 也可能把凭据写进意外目录。 -pub fn get_home_dir() -> PathBuf { +pub fn try_get_home_dir() -> Result { let test_override = std::env::var("CC_SWITCH_TEST_HOME").ok(); - resolve_home_dir(test_override.as_deref(), dirs::home_dir()).unwrap_or_else(|err| { + resolve_home_dir(test_override.as_deref(), dirs::home_dir()) +} + +pub fn get_home_dir() -> PathBuf { + try_get_home_dir().unwrap_or_else(|err| { log::error!("{err}"); panic!("{err}"); }) } +/// Expand `~`, `~/...`, and `~\...` through the same validated HOME boundary. +/// Missing or malformed HOME is an error; callers must not preserve a literal relative `~` path. +pub fn expand_home_path(raw: &str) -> Result { + if raw == "~" { + return try_get_home_dir(); + } + if let Some(stripped) = raw.strip_prefix("~/") { + return Ok(try_get_home_dir()?.join(stripped)); + } + if let Some(stripped) = raw.strip_prefix("~\\") { + return Ok(try_get_home_dir()?.join(stripped)); + } + Ok(PathBuf::from(raw)) +} + +/// Last-resort crash/exit observability directory when HOME itself is unavailable. +/// This must never be used for the database, settings, provider config, or other user state. +pub fn emergency_observability_dir() -> PathBuf { + std::env::temp_dir().join("cc-switch-home-unavailable") +} + /// 获取 Claude Code 配置目录路径 pub fn get_claude_config_dir() -> PathBuf { if let Some(custom) = crate::settings::get_claude_override_dir() { @@ -472,6 +497,16 @@ mod tests { assert!(resolve_home_dir(Some("relative-test-home"), None).is_err()); } + #[test] + fn emergency_observability_dir_is_named_temp_fallback() { + let path = emergency_observability_dir(); + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some("cc-switch-home-unavailable") + ); + assert!(path.is_absolute()); + } + use std::collections::HashSet; #[test] diff --git a/src-tauri/src/database/backup.rs b/src-tauri/src/database/backup.rs index 5be84029236..44cc9fac07f 100644 --- a/src-tauri/src/database/backup.rs +++ b/src-tauri/src/database/backup.rs @@ -1,4 +1,4 @@ -//! 数据库备份和恢复 +//! 数据库备份和恢复 //! //! 提供 SQL 导出/导入和二进制快照备份功能。 @@ -819,7 +819,9 @@ fn quote_sql_identifier(identifier: &str) -> String { /// 返回当前用户目录字符串;获取失败时跳过路径改写,避免生成错误路径。 fn current_home_string() -> Option { - dirs::home_dir().map(|path| path.to_string_lossy().to_string()) + crate::config::try_get_home_dir() + .ok() + .map(|path| path.to_string_lossy().to_string()) } /// 将本机用户目录替换为同步占位符,让远端快照不绑定上传设备。 diff --git a/src-tauri/src/panic_hook.rs b/src-tauri/src/panic_hook.rs index 8b4d83f8c91..072552adff0 100644 --- a/src-tauri/src/panic_hook.rs +++ b/src-tauri/src/panic_hook.rs @@ -20,9 +20,17 @@ pub fn init_app_config_dir(dir: PathBuf) { /// 获取默认应用配置目录(不会 panic) fn default_app_config_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".cc-switch") + match crate::config::try_get_home_dir() { + Ok(home) => home.join(".cc-switch"), + Err(err) => { + let fallback = crate::config::emergency_observability_dir(); + eprintln!( + "[CC-Switch] HOME unavailable for crash/exit diagnostics: {err}; using {}", + fallback.display() + ); + fallback + } + } } /// 获取应用配置目录(优先使用初始化时写入的值;不会 panic) diff --git a/src-tauri/src/prompt_files.rs b/src-tauri/src/prompt_files.rs index 70f350dc839..e03aba705a5 100644 --- a/src-tauri/src/prompt_files.rs +++ b/src-tauri/src/prompt_files.rs @@ -46,7 +46,11 @@ fn get_base_dir_with_fallback( primary_path .parent() .map(|p| p.to_path_buf()) - .or_else(|| dirs::home_dir().map(|h| h.join(fallback_dir))) + .or_else(|| { + crate::config::try_get_home_dir() + .ok() + .map(|h| h.join(fallback_dir)) + }) .ok_or_else(|| { AppError::localized( "home_dir_not_found", diff --git a/src-tauri/src/services/env_manager.rs b/src-tauri/src/services/env_manager.rs index 0ee46dab569..9fc941368c6 100644 --- a/src-tauri/src/services/env_manager.rs +++ b/src-tauri/src/services/env_manager.rs @@ -67,7 +67,7 @@ fn create_backup(conflicts: &[EnvConflict]) -> Result { /// Get backup directory path fn get_backup_dir() -> Result { - let home = dirs::home_dir().ok_or("无法获取用户主目录")?; + let home = crate::config::try_get_home_dir()?; Ok(home.join(".cc-switch").join("backups")) } diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 318bd4f1bfd..db60dd9118b 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -374,17 +374,18 @@ fn parse_branch_from_source_url(source_url: Option<&str>) -> Option { /// 获取 `~/.agents/skills/` 目录(存在时返回) fn get_agents_skills_dir() -> Option { - dirs::home_dir() + crate::config::try_get_home_dir() + .ok() .map(|h| h.join(".agents").join("skills")) .filter(|p| p.exists()) } /// 解析 `~/.agents/.skill-lock.json`,返回 skill_name -> 仓库信息 fn parse_agents_lock() -> HashMap { - let path = match dirs::home_dir() { - Some(h) => h.join(".agents").join(".skill-lock.json"), - None => { - log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件"); + let path = match crate::config::try_get_home_dir() { + Ok(home) => home.join(".agents").join(".skill-lock.json"), + Err(err) => { + log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件: {err}"); return HashMap::new(); } }; @@ -1159,7 +1160,7 @@ impl SkillService { let new_dir = match target { SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"), SkillStorageLocation::Unified => { - let home = dirs::home_dir().context("Cannot determine home directory")?; + let home = crate::config::try_get_home_dir().map_err(|err| anyhow!(err))?; home.join(".agents").join("skills") } }; diff --git a/src-tauri/src/session_manager/providers/opencode.rs b/src-tauri/src/session_manager/providers/opencode.rs index 2cfad00f8b7..86f539e31a6 100644 --- a/src-tauri/src/session_manager/providers/opencode.rs +++ b/src-tauri/src/session_manager/providers/opencode.rs @@ -15,13 +15,18 @@ const PROVIDER_ID: &str = "opencode"; /// `~/.local/share/opencode/`. pub(crate) fn get_opencode_base_dir() -> PathBuf { if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { - if !xdg.is_empty() { - return PathBuf::from(xdg).join("opencode"); + let xdg = PathBuf::from(xdg.trim()); + if xdg.is_absolute() { + return xdg.join("opencode"); + } + if !xdg.as_os_str().is_empty() { + log::warn!( + "Ignoring relative XDG_DATA_HOME for OpenCode discovery: {}", + xdg.display() + ); } } - dirs::home_dir() - .map(|h| h.join(".local/share/opencode")) - .unwrap_or_else(|| PathBuf::from(".local/share/opencode")) + crate::config::get_home_dir().join(".local/share/opencode") } /// Return the OpenCode JSON storage directory (legacy flat-file layout). diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 451c3a73bd7..5416cfcefe9 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -715,21 +715,10 @@ fn settings_store() -> &'static RwLock { } fn resolve_override_path(raw: &str) -> PathBuf { - if raw == "~" { - if let Some(home) = dirs::home_dir() { - return home; - } - } else if let Some(stripped) = raw.strip_prefix("~/") { - if let Some(home) = dirs::home_dir() { - return home.join(stripped); - } - } else if let Some(stripped) = raw.strip_prefix("~\\") { - if let Some(home) = dirs::home_dir() { - return home.join(stripped); - } - } - - PathBuf::from(raw) + crate::config::expand_home_path(raw).unwrap_or_else(|err| { + log::error!("{err}"); + panic!("{err}"); + }) } pub fn get_settings() -> AppSettings { From 0ba9387fff4a69571af189cc19ae0c7f39d24819 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 12:55:21 +0800 Subject: [PATCH 060/112] chore: remove temporary home hardening workflow --- .../workflows/home-model-hardening-once.yml | 251 ------------------ 1 file changed, 251 deletions(-) delete mode 100644 .github/workflows/home-model-hardening-once.yml diff --git a/.github/workflows/home-model-hardening-once.yml b/.github/workflows/home-model-hardening-once.yml deleted file mode 100644 index 52e19c61ac9..00000000000 --- a/.github/workflows/home-model-hardening-once.yml +++ /dev/null @@ -1,251 +0,0 @@ -name: Home Boundary Cleanup Once - -on: - push: - branches: - - fix/global-hardening-20260904 - paths: - - .github/workflows/home-model-hardening-once.yml - -permissions: - contents: write - -jobs: - apply-and-verify: - if: github.repository == 'z13321812367-sys/ccswitchmulti' - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v6 - with: - ref: fix/global-hardening-20260904 - fetch-depth: 0 - - - name: Route all HOME resolution through config boundary - shell: bash - run: | - set -euo pipefail - python - <<'PY' - from pathlib import Path - - def replace_exact(path_str, old, new, expected=1): - path = Path(path_str) - text = path.read_text(encoding="utf-8") - count = text.count(old) - if count != expected: - raise SystemExit(f"{path_str}: expected {expected} matches, found {count}: {old!r}") - path.write_text(text.replace(old, new, expected), encoding="utf-8") - - replace_exact( - "src-tauri/src/config.rs", - '''pub fn get_home_dir() -> PathBuf { - let test_override = std::env::var("CC_SWITCH_TEST_HOME").ok(); - resolve_home_dir(test_override.as_deref(), dirs::home_dir()).unwrap_or_else(|err| { - log::error!("{err}"); - panic!("{err}"); - }) - } - ''', - '''pub fn try_get_home_dir() -> Result { - let test_override = std::env::var("CC_SWITCH_TEST_HOME").ok(); - resolve_home_dir(test_override.as_deref(), dirs::home_dir()) - } - - pub fn get_home_dir() -> PathBuf { - try_get_home_dir().unwrap_or_else(|err| { - log::error!("{err}"); - panic!("{err}"); - }) - } - - /// Expand `~`, `~/...`, and `~\\...` through the same validated HOME boundary. - /// Missing or malformed HOME is an error; callers must not preserve a literal relative `~` path. - pub fn expand_home_path(raw: &str) -> Result { - if raw == "~" { - return try_get_home_dir(); - } - if let Some(stripped) = raw.strip_prefix("~/") { - return Ok(try_get_home_dir()?.join(stripped)); - } - if let Some(stripped) = raw.strip_prefix("~\\\\") { - return Ok(try_get_home_dir()?.join(stripped)); - } - Ok(PathBuf::from(raw)) - } - - /// Last-resort crash/exit observability directory when HOME itself is unavailable. - /// This must never be used for the database, settings, provider config, or other user state. - pub fn emergency_observability_dir() -> PathBuf { - std::env::temp_dir().join("cc-switch-home-unavailable") - } - ''', - ) - - replace_exact( - "src-tauri/src/config.rs", - ''' #[test] - fn explicit_relative_test_home_override_is_rejected() { - assert!(resolve_home_dir(Some("relative-test-home"), None).is_err()); - } - ''', - ''' #[test] - fn explicit_relative_test_home_override_is_rejected() { - assert!(resolve_home_dir(Some("relative-test-home"), None).is_err()); - } - - #[test] - fn emergency_observability_dir_is_named_temp_fallback() { - let path = emergency_observability_dir(); - assert_eq!( - path.file_name().and_then(|name| name.to_str()), - Some("cc-switch-home-unavailable") - ); - assert!(path.is_absolute()); - } - ''', - ) - - observability_old = '''fn default_app_config_dir() -> PathBuf { - dirs::home_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join(".cc-switch") - } - ''' - observability_new = '''fn default_app_config_dir() -> PathBuf { - match crate::config::try_get_home_dir() { - Ok(home) => home.join(".cc-switch"), - Err(err) => { - let fallback = crate::config::emergency_observability_dir(); - eprintln!( - "[CC-Switch] HOME unavailable for crash/exit diagnostics: {err}; using {}", - fallback.display() - ); - fallback - } - } - } - ''' - replace_exact("src-tauri/src/app_exit_monitor.rs", observability_old, observability_new) - replace_exact("src-tauri/src/panic_hook.rs", observability_old, observability_new) - - for path_str, fn_name in [ - ("src-tauri/src/app_store.rs", "resolve_path"), - ("src-tauri/src/settings.rs", "resolve_override_path"), - ]: - path = Path(path_str) - text = path.read_text(encoding="utf-8") - start = text.index(f"fn {fn_name}(raw: &str) -> PathBuf {{") - end = text.index("\n}\n", start) + 3 - block = text[start:end] - if block.count("dirs::home_dir()") != 3: - raise SystemExit(f"{path_str}: expected three legacy HOME lookups in {fn_name}") - replacement = f'''fn {fn_name}(raw: &str) -> PathBuf {{ - crate::config::expand_home_path(raw).unwrap_or_else(|err| {{ - log::error!("{{err}}"); - panic!("{{err}}"); - }}) - }}\n''' - path.write_text(text[:start] + replacement + text[end:], encoding="utf-8") - - replace_exact( - "src-tauri/src/claude_plugin.rs", - ' let home = dirs::home_dir().ok_or_else(|| AppError::Config("无法获取用户主目录".into()))?;\n', - ' let home = crate::config::try_get_home_dir().map_err(AppError::Config)?;\n', - ) - replace_exact( - "src-tauri/src/commands/misc.rs", - ' let home = dirs::home_dir().unwrap_or_default();\n', - ' let home = crate::config::get_home_dir();\n', - ) - replace_exact( - "src-tauri/src/database/backup.rs", - ' dirs::home_dir().map(|path| path.to_string_lossy().to_string())\n', - ' crate::config::try_get_home_dir().ok().map(|path| path.to_string_lossy().to_string())\n', - ) - replace_exact( - "src-tauri/src/prompt_files.rs", - ' .or_else(|| dirs::home_dir().map(|h| h.join(fallback_dir)))\n', - ' .or_else(|| crate::config::try_get_home_dir().ok().map(|h| h.join(fallback_dir)))\n', - ) - replace_exact( - "src-tauri/src/services/env_manager.rs", - ' let home = dirs::home_dir().ok_or("无法获取用户主目录")?;\n', - ' let home = crate::config::try_get_home_dir()?;\n', - ) - - replace_exact( - "src-tauri/src/services/skill.rs", - ' dirs::home_dir()\n .map(|h| h.join(".agents").join("skills"))\n', - ' crate::config::try_get_home_dir()\n .ok()\n .map(|h| h.join(".agents").join("skills"))\n', - ) - replace_exact( - "src-tauri/src/services/skill.rs", - ' let path = match dirs::home_dir() {\n Some(h) => h.join(".agents").join(".skill-lock.json"),\n None => {\n log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件");\n return HashMap::new();\n }\n };\n', - ' let path = match crate::config::try_get_home_dir() {\n Ok(home) => home.join(".agents").join(".skill-lock.json"),\n Err(err) => {\n log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件: {err}");\n return HashMap::new();\n }\n };\n', - ) - replace_exact( - "src-tauri/src/services/skill.rs", - ' let home = dirs::home_dir().context("Cannot determine home directory")?;\n', - ' let home = crate::config::try_get_home_dir().map_err(|err| anyhow!(err))?;\n', - ) - - replace_exact( - "src-tauri/src/session_manager/providers/opencode.rs", - ' dirs::home_dir()\n .map(|h| h.join(".local/share/opencode"))\n .unwrap_or_else(|| PathBuf::from(".local/share/opencode"))\n', - ' crate::config::get_home_dir().join(".local/share/opencode")\n', - ) - replace_exact( - "src-tauri/src/session_manager/providers/opencode.rs", - ' if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {\n if !xdg.is_empty() {\n return PathBuf::from(xdg).join("opencode");\n }\n }\n', - ' if let Ok(xdg) = std::env::var("XDG_DATA_HOME") {\n let xdg = PathBuf::from(xdg.trim());\n if xdg.is_absolute() {\n return xdg.join("opencode");\n }\n if !xdg.as_os_str().is_empty() {\n log::warn!("Ignoring relative XDG_DATA_HOME for OpenCode discovery: {}", xdg.display());\n }\n }\n', - ) - - offenders = [] - for path in Path("src-tauri/src").rglob("*.rs"): - if path.name == "config.rs": - continue - if "dirs::home_dir(" in path.read_text(encoding="utf-8"): - offenders.append(str(path)) - if offenders: - raise SystemExit(f"direct dirs::home_dir remains: {offenders}") - PY - - - name: Install Linux system dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential pkg-config libssl-dev \ - libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev - sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \ - || sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev - sudo apt-get install -y --no-install-recommends libsoup-3.0-dev \ - || sudo apt-get install -y --no-install-recommends libsoup2.4-dev - - - name: Format Rust - run: cargo fmt --manifest-path src-tauri/Cargo.toml --all - - - name: Run permanent policy guards - run: | - python scripts/check_workflow_shell_interpolation.py - python scripts/check_rust_failure_boundaries.py - git diff --check - - - name: Create frontend dist placeholder - run: mkdir -p dist - - - name: Clippy all targets and features - run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --all-features -- -D warnings - - - name: Rust tests default feature set - run: cargo test --manifest-path src-tauri/Cargo.toml --all - - - name: Rust tests all features - run: cargo test --manifest-path src-tauri/Cargo.toml --all-features - - - name: Commit verified HOME boundary migration - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add src-tauri/src - git diff --cached --check - git commit -m "fix: centralize home resolution across persistence paths" - git push origin HEAD:fix/global-hardening-20260904 From 69cd015cd15add2ace419299fbc80d6bd6eb8661 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 13:51:31 +0800 Subject: [PATCH 061/112] ci: add design invariant hardening driver --- .../apply_design_invariant_hardening_once.py | 735 ++++++++++++++++++ 1 file changed, 735 insertions(+) create mode 100644 scripts/apply_design_invariant_hardening_once.py diff --git a/scripts/apply_design_invariant_hardening_once.py b/scripts/apply_design_invariant_hardening_once.py new file mode 100644 index 00000000000..0d8b2f50190 --- /dev/null +++ b/scripts/apply_design_invariant_hardening_once.py @@ -0,0 +1,735 @@ +#!/usr/bin/env python3 +from pathlib import Path + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected 1 exact match, found {count}") + return text.replace(old, new, 1) + + +def replace_between(text: str, start: str, end: str, new: str, label: str) -> str: + if text.count(start) != 1: + raise SystemExit(f"{label}: start marker count={text.count(start)}") + start_idx = text.index(start) + end_idx = text.index(end, start_idx) + return text[:start_idx] + new + text[end_idx:] + + +# --------------------------------------------------------------------------- +# config.rs: make absolute persistence paths a common invariant, not a caller +# convention; expose fallible app-root resolution and validate Windows legacy +# HOME before compatibility fallback. +# --------------------------------------------------------------------------- +path = Path("src-tauri/src/config.rs") +text = path.read_text(encoding="utf-8") + +marker = "const ATOMIC_TEMP_CREATE_ATTEMPTS: usize = 16;\n" +helper = ''' + +fn require_absolute_path(path: PathBuf, label: &str) -> Result { + if path.is_absolute() { + Ok(path) + } else { + Err(format!("{label} 必须是绝对路径,收到: {}", path.display())) + } +} +''' +if "fn require_absolute_path(" not in text: + text = replace_once(text, marker, marker + helper, "config absolute-path helper") + +text = replace_between( + text, + "fn resolve_home_dir(\n", + "\n/// 获取用户主目录。", + '''fn resolve_home_dir( + test_override: Option<&str>, + detected: Option, +) -> Result { + if let Some(home) = test_override.map(str::trim).filter(|home| !home.is_empty()) { + return require_absolute_path(PathBuf::from(home), "CC_SWITCH_TEST_HOME"); + } + + let path = detected.ok_or_else(|| { + "无法获取用户主目录;拒绝回退到当前工作目录,以避免配置/数据库静默分叉".to_string() + })?; + require_absolute_path(path, "操作系统返回的用户主目录") +} +''', + "config home resolver", +) + +expand_start = "pub fn expand_home_path(raw: &str) -> Result {" +expand_end = "\n/// Last-resort crash/exit observability directory" +expand_new = '''pub fn expand_home_path(raw: &str) -> Result { + if raw == "~" { + return try_get_home_dir(); + } + if let Some(stripped) = raw.strip_prefix("~/") { + return Ok(try_get_home_dir()?.join(stripped)); + } + if let Some(stripped) = raw.strip_prefix("~\\\\") { + return Ok(try_get_home_dir()?.join(stripped)); + } + Ok(PathBuf::from(raw)) +} + +/// Resolve a user-configurable persistence/configuration root. +/// +/// Unlike generic path expansion, this contract never permits process-CWD-relative roots. +/// Callers may accept `~`, but the resolved value must be absolute before it can select a +/// database, backup, settings, or external CLI configuration tree. +pub fn resolve_persistence_path(raw: &str, label: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(format!("{label} 不能为空")); + } + let path = expand_home_path(trimmed)?; + require_absolute_path(path, label) +} +''' +text = replace_between(text, expand_start, expand_end, expand_new, "config persistence resolver") + +app_start = "pub fn get_app_config_dir() -> PathBuf {" +app_end = "\n/// 获取应用配置文件路径" +app_new = '''pub fn try_get_app_config_dir() -> Result { + if let Some(custom) = crate::app_store::get_app_config_dir_override() { + return require_absolute_path(custom, "app_config_dir override"); + } + + let default_dir = try_get_home_dir()?.join(".cc-switch"); + + // 兼容 v3.10.3:当用户环境存在 HOME 且与真实用户目录不同, + // v3.10.3 可能在 HOME/.cc-switch/ 下创建/使用了数据库。 + // 兼容候选本身也必须是绝对路径;相对 HOME 不能重新引入 CWD 绑定。 + #[cfg(windows)] + { + let default_db = default_dir.join("cc-switch.db"); + if !default_db.exists() { + if let Ok(home_env) = std::env::var("HOME") { + let trimmed = home_env.trim(); + if !trimmed.is_empty() { + let legacy_home = PathBuf::from(trimmed); + if legacy_home.is_absolute() { + let legacy_dir = legacy_home.join(".cc-switch"); + if legacy_dir.join("cc-switch.db").exists() { + log::info!( + "Detected v3.10.3 legacy database at {}, using it instead of {}", + legacy_dir.display(), + default_dir.display() + ); + return Ok(legacy_dir); + } + } else { + log::warn!( + "Ignoring relative legacy HOME while locating v3.10.3 database: {}", + legacy_home.display() + ); + } + } + } + } + } + + Ok(default_dir) +} + +/// Compatibility wrapper for legacy infallible path APIs. New fallible persistence operations +/// should call `try_get_app_config_dir` so configuration errors remain typed instead of panicking. +pub fn get_app_config_dir() -> PathBuf { + try_get_app_config_dir().unwrap_or_else(|err| { + log::error!("{err}"); + panic!("{err}"); + }) +} +''' +text = replace_between(text, app_start, app_end, app_new, "config app root") + +test_anchor = ''' #[test] + fn explicit_relative_test_home_override_is_rejected() { + assert!(resolve_home_dir(Some("relative-test-home"), None).is_err()); + } +''' +extra_tests = ''' #[test] + fn explicit_relative_test_home_override_is_rejected() { + assert!(resolve_home_dir(Some("relative-test-home"), None).is_err()); + } + + #[test] + fn persistence_roots_reject_process_relative_paths() { + assert!(resolve_persistence_path("relative/profile", "test root").is_err()); + } + + #[test] + fn persistence_roots_accept_absolute_paths() { + let path = std::env::temp_dir().join("cc-switch-persistence-root"); + let raw = path.to_string_lossy().to_string(); + assert_eq!(resolve_persistence_path(&raw, "test root").unwrap(), path); + } +''' +text = replace_once(text, test_anchor, extra_tests, "config persistence tests") +path.write_text(text, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# app_store.rs: Store access/migration failure is not equivalent to no override. +# Invalid/missing/non-directory roots fail closed, and user writes are validated +# before persistence. A successful no-op migration is marked complete as well. +# --------------------------------------------------------------------------- +path = Path("src-tauri/src/app_store.rs") +text = path.read_text(encoding="utf-8") + +text = replace_between( + text, + "fn read_override_from_store(app: &tauri::AppHandle) -> Option {", + "\nfn legacy_migration_completed", + '''fn read_override_from_store(app: &tauri::AppHandle) -> Result, AppError> { + let store = open_paths_store(app)?; + + match store.get(STORE_KEY_APP_CONFIG_DIR) { + Some(Value::String(path_str)) => { + let path_str = path_str.trim(); + if path_str.is_empty() { + return Ok(None); + } + + let path = resolve_path(path_str)?; + if !path.is_dir() { + return Err(AppError::Config(format!( + "Store 中配置的 app_config_dir 不是现有目录: {}", + path.display() + ))); + } + + log::info!("使用 Store 中的 app_config_dir: {path:?}"); + Ok(Some(path)) + } + Some(_) => Err(AppError::Config(format!( + "Store 中的 {STORE_KEY_APP_CONFIG_DIR} 类型不正确,应为字符串" + ))), + None => Ok(None), + } +} +''', + "app_store read override", +) + +text = replace_between( + text, + "fn legacy_migration_completed(app: &tauri::AppHandle) -> bool {", + "\n/// 从旧版", + '''fn legacy_migration_completed(app: &tauri::AppHandle) -> Result { + let store = open_paths_store(app)?; + match store.get(STORE_KEY_APP_CONFIG_DIR_LEGACY_MIGRATED) { + Some(Value::Bool(value)) => Ok(value), + Some(_) => Err(AppError::Config(format!( + "Store 中的 {STORE_KEY_APP_CONFIG_DIR_LEGACY_MIGRATED} 类型不正确,应为布尔值" + ))), + None => Ok(false), + } +} +''', + "app_store migration marker", +) + +text = replace_between( + text, + "fn read_legacy_override_from_settings() -> Option {", + "\nfn persist_override_and_migration_marker", + '''fn read_legacy_override_from_settings() -> Result, AppError> { + let settings_path = crate::config::try_get_home_dir() + .map_err(AppError::Config)? + .join(".cc-switch") + .join("settings.json"); + let content = match std::fs::read_to_string(&settings_path) { + Ok(content) => content, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(AppError::io(&settings_path, err)), + }; + + let root: Value = serde_json::from_str(&content) + .map_err(|err| AppError::json(&settings_path, err))?; + + for key in LEGACY_APP_CONFIG_DIR_KEYS { + let Some(raw) = root.get(*key).and_then(Value::as_str) else { + continue; + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + let resolved = resolve_path(trimmed)?; + if !resolved.is_dir() { + return Err(AppError::Config(format!( + "旧 settings.json 的 {key} 不是现有目录: {}", + resolved.display() + ))); + } + return Ok(Some(resolved)); + } + + Ok(None) +} +''', + "app_store legacy reader", +) + +text = replace_between( + text, + "fn migrate_legacy_override_if_needed(app: &tauri::AppHandle) -> Result, AppError> {", + "\n/// 从 Store 刷新", + '''fn migrate_legacy_override_if_needed(app: &tauri::AppHandle) -> Result, AppError> { + if legacy_migration_completed(app)? { + return Ok(None); + } + + if let Some(existing_path) = read_override_from_store(app)? { + // 已经存在新格式配置,只补迁移标记,绝不能用尚未初始化的缓存反写 Store。 + let path_string = existing_path.to_string_lossy().to_string(); + persist_override_and_migration_marker(app, Some(&path_string))?; + return Ok(Some(existing_path)); + } + + match read_legacy_override_from_settings()? { + Some(legacy_path) => { + let path_string = legacy_path.to_string_lossy().to_string(); + persist_override_and_migration_marker(app, Some(&path_string))?; + log::info!( + "已将旧 settings.json 的 app_config_dir 自动迁移到 Store: {}", + legacy_path.display() + ); + Ok(Some(legacy_path)) + } + None => { + // A successful scan with no legacy value is still a completed one-time migration. + // Persist the marker so a stale legacy field cannot unexpectedly resurrect later. + persist_override_and_migration_marker(app, None)?; + Ok(None) + } + } +} +''', + "app_store migration", +) + +text = replace_between( + text, + "pub fn refresh_app_config_dir_override(app: &tauri::AppHandle) -> Option {", + "\n/// 写入 app_config_dir", + '''pub fn refresh_app_config_dir_override( + app: &tauri::AppHandle, +) -> Result, AppError> { + let migrated = migrate_legacy_override_if_needed(app)?; + let value = match migrated { + Some(path) => Some(path), + None => read_override_from_store(app)?, + }; + update_cached_override(value.clone()); + Ok(value) +} +''', + "app_store refresh", +) + +text = replace_between( + text, + "pub fn set_app_config_dir_to_store(\n", + "\n/// 解析路径", + '''pub fn set_app_config_dir_to_store( + app: &tauri::AppHandle, + path: Option<&str>, +) -> Result<(), AppError> { + let resolved = match path.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => { + let path = resolve_path(value)?; + if !path.is_dir() { + return Err(AppError::InvalidInput(format!( + "app_config_dir 必须指向现有目录: {}", + path.display() + ))); + } + Some(path) + } + None => None, + }; + let serialized = resolved + .as_ref() + .map(|path| path.to_string_lossy().to_string()); + persist_override_and_migration_marker(app, serialized.as_deref())?; + update_cached_override(resolved.clone()); + + match resolved { + Some(value) => log::info!("已将 app_config_dir 写入 Store: {}", value.display()), + None => log::info!("已从 Store 中删除 app_config_dir 配置"), + } + Ok(()) +} +''', + "app_store setter", +) + +text = replace_between( + text, + "fn resolve_path(raw: &str) -> PathBuf {", + "\n/// 从旧的 settings.json", + '''fn resolve_path(raw: &str) -> Result { + crate::config::resolve_persistence_path(raw, "app_config_dir") + .map_err(AppError::InvalidInput) +} +''', + "app_store path resolver", +) + +text = replace_between( + text, + "pub fn migrate_app_config_dir_from_settings(app: &tauri::AppHandle) -> Result<(), AppError> {", + "\n#[cfg(test)]", + '''pub fn migrate_app_config_dir_from_settings(app: &tauri::AppHandle) -> Result<(), AppError> { + let migrated = migrate_legacy_override_if_needed(app)?; + let value = match migrated { + Some(path) => Some(path), + None => read_override_from_store(app)?, + }; + update_cached_override(value); + Ok(()) +} +''', + "app_store compatibility entrypoint", +) + +text = replace_once( + text, + " assert_eq!(resolve_path(input), PathBuf::from(input));", + " assert_eq!(resolve_path(input).unwrap(), PathBuf::from(input));", + "app_store absolute test", +) +legacy_test_anchor = ''' #[test] + fn legacy_keys_cover_camel_and_snake_case() {''' +relative_test = ''' #[test] + fn resolve_path_rejects_process_relative_app_root() { + assert!(resolve_path("relative/.cc-switch").is_err()); + } + +''' +text = replace_once(text, legacy_test_anchor, relative_test + legacy_test_anchor, "app_store relative test") +path.write_text(text, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# settings.rs: make the settings path fallible instead of fake-Optional; enforce +# an in-memory invariant that all per-CLI config-dir overrides are absolute. +# Dirty legacy relative values are quarantined (logged + ignored), while new +# frontend writes are rejected with InvalidInput instead of silently normalizing. +# --------------------------------------------------------------------------- +path = Path("src-tauri/src/settings.rs") +text = path.read_text(encoding="utf-8") + +insert_before = "impl AppSettings {\n" +settings_helpers = '''fn normalize_config_dir_override(field: &str, value: Option) -> Option { + let raw = value?.trim().to_string(); + if raw.is_empty() { + return None; + } + match crate::config::resolve_persistence_path(&raw, field) { + Ok(path) => Some(path.to_string_lossy().to_string()), + Err(err) => { + log::error!("Ignoring invalid persisted {field}: {err}"); + None + } + } +} + +fn validate_config_dir_overrides(settings: &AppSettings) -> Result<(), AppError> { + let values = [ + ("claude_config_dir", settings.claude_config_dir.as_deref()), + ("codex_config_dir", settings.codex_config_dir.as_deref()), + ("gemini_config_dir", settings.gemini_config_dir.as_deref()), + ("opencode_config_dir", settings.opencode_config_dir.as_deref()), + ("openclaw_config_dir", settings.openclaw_config_dir.as_deref()), + ("hermes_config_dir", settings.hermes_config_dir.as_deref()), + ]; + for (field, raw) in values { + if let Some(raw) = raw.map(str::trim).filter(|raw| !raw.is_empty()) { + crate::config::resolve_persistence_path(raw, field) + .map_err(AppError::InvalidInput)?; + } + } + Ok(()) +} + +''' +if "fn normalize_config_dir_override(" not in text: + text = replace_once(text, insert_before, settings_helpers + insert_before, "settings helpers") + +text = replace_between( + text, + " fn settings_path() -> Option {", + "\n fn normalize_paths(&mut self) {", + ''' fn settings_path() -> Result { + Ok(crate::config::try_get_home_dir() + .map_err(AppError::Config)? + .join(".cc-switch") + .join("settings.json")) + } +''', + "settings path", +) + +norm_start = " self.claude_config_dir = self\n" +norm_end = "\n self.language = self" +normalized_fields = ''' self.claude_config_dir = normalize_config_dir_override( + "claude_config_dir", + self.claude_config_dir.take(), + ); + self.codex_config_dir = normalize_config_dir_override( + "codex_config_dir", + self.codex_config_dir.take(), + ); + self.gemini_config_dir = normalize_config_dir_override( + "gemini_config_dir", + self.gemini_config_dir.take(), + ); + self.opencode_config_dir = normalize_config_dir_override( + "opencode_config_dir", + self.opencode_config_dir.take(), + ); + self.openclaw_config_dir = normalize_config_dir_override( + "openclaw_config_dir", + self.openclaw_config_dir.take(), + ); + self.hermes_config_dir = normalize_config_dir_override( + "hermes_config_dir", + self.hermes_config_dir.take(), + ); +''' +text = replace_between(text, norm_start, norm_end, normalized_fields, "settings path normalization") + +old_load = ''' fn load_from_file() -> Self { + let Some(path) = Self::settings_path() else { + return Self::default(); + }; + Self::load_from_path(&path) + } +''' +new_load = ''' fn load_from_file() -> Self { + match Self::settings_path() { + Ok(path) => Self::load_from_path(&path), + Err(err) => { + log::error!("无法解析 settings.json 路径,将使用内存默认设置且禁止持久化: {err}"); + Self::default() + } + } + } +''' +text = replace_once(text, old_load, new_load, "settings load path") + +old_save = '''fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> { + let Some(path) = AppSettings::settings_path() else { + return Err(AppError::Config("无法获取用户主目录".to_string())); + }; + save_settings_file_to_path(settings, &path) +} +''' +new_save = '''fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> { + let path = AppSettings::settings_path()?; + save_settings_file_to_path(settings, &path) +} +''' +text = replace_once(text, old_save, new_save, "settings save path") + +old_resolver = '''fn resolve_override_path(raw: &str) -> PathBuf { + crate::config::expand_home_path(raw).unwrap_or_else(|err| { + log::error!("{err}"); + panic!("{err}"); + }) +} +''' +new_resolver = '''fn resolve_override_path(raw: &str) -> Option { + let path = PathBuf::from(raw); + if path.is_absolute() { + Some(path) + } else { + log::error!("settings path invariant violated by relative override: {raw}"); + None + } +} +''' +text = replace_once(text, old_resolver, new_resolver, "settings getter resolver") + +if text.count(".map(|p| resolve_override_path(p))") != 6: + raise SystemExit( + f"settings override getter map count={text.count('.map(|p| resolve_override_path(p))')}" + ) +text = text.replace(".map(|p| resolve_override_path(p))", ".and_then(|p| resolve_override_path(p))") + +old_update = '''pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> { + new_settings.normalize_paths(); +''' +new_update = '''pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> { + validate_config_dir_overrides(&new_settings)?; + new_settings.normalize_paths(); +''' +text = replace_once(text, old_update, new_update, "settings update validation") +path.write_text(text, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Startup/DB: propagate Store and app-root resolution failures rather than +# treating them as absence or panicking during normal setup. +# --------------------------------------------------------------------------- +path = Path("src-tauri/src/lib.rs") +text = path.read_text(encoding="utf-8") +old_setup = ''' // 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等) + app_store::refresh_app_config_dir_override(app.handle()); + let app_config_dir = crate::config::get_app_config_dir(); + panic_hook::init_app_config_dir(app_config_dir.clone()); + app_exit_monitor::init_app_config_dir(app_config_dir); +''' +new_setup = ''' // 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等)。 + // Store/路径损坏不能伪装成“无 override”后切到另一个数据库根。 + app_store::refresh_app_config_dir_override(app.handle())?; + let app_config_dir = crate::config::try_get_app_config_dir() + .map_err(crate::error::AppError::Config)?; + panic_hook::init_app_config_dir(app_config_dir.clone()); + app_exit_monitor::init_app_config_dir(app_config_dir.clone()); +''' +text = replace_once(text, old_setup, new_setup, "startup app root") +text = replace_once( + text, + ''' // 初始化数据库 + let app_config_dir = crate::config::get_app_config_dir(); + let db_path = app_config_dir.join("cc-switch.db"); +''', + ''' // 初始化数据库 + let db_path = app_config_dir.join("cc-switch.db"); +''', + "startup reuse validated root", +) +path.write_text(text, encoding="utf-8") + +path = Path("src-tauri/src/database/mod.rs") +text = path.read_text(encoding="utf-8") +text = replace_once( + text, + "use crate::config::get_app_config_dir;", + "use crate::config::try_get_app_config_dir;", + "database import", +) +text = replace_once( + text, + ''' let db_path = get_app_config_dir().join("cc-switch.db");''', + ''' let db_path = try_get_app_config_dir() + .map_err(AppError::Config)? + .join("cc-switch.db");''', + "database app root", +) +path.write_text(text, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# model_fetch.rs: fail-fast HTTP classification must not imply raw-body +# disclosure. Use common payload shape/fingerprint diagnostics instead. +# --------------------------------------------------------------------------- +path = Path("src-tauri/src/services/model_fetch.rs") +text = path.read_text(encoding="utf-8") +text = text.replace( + '''/// 404/405 响应体截断长度:避免把几十 KB HTML 404 页整页保留到错误串里。 +const ERROR_BODY_MAX_CHARS: usize = 512; + +''', + "", +) +old_http_error = ''' let body = truncate_body(response.text().await.unwrap_or_default()); + return Err(format!("HTTP {status}: {body}")); +''' +new_http_error = ''' let body_detail = match response.bytes().await { + Ok(body) => { + let rendered = String::from_utf8_lossy(&body); + format!( + "body-shape={}, {}", + crate::diagnostics::text_shape_hint(&rendered), + crate::diagnostics::payload_fingerprint(&body) + ) + } + Err(error) => format!("body-unavailable={}", request_error_kind(&error)), + }; + return Err(format!("HTTP {status}: {body_detail}")); +''' +text = replace_once(text, old_http_error, new_http_error, "model safe fail-fast detail") +truncate_start = "/// 截断响应体到 [`ERROR_BODY_MAX_CHARS`] 字符,避免 HTML 404 页占用错误串。\nfn truncate_body(body: String) -> String {" +if truncate_start in text: + start = text.index(truncate_start) + # Function is immediately followed by a blank line + the next item. + next_item = text.find("\n\n", start) + # Advance until the function's closing brace is covered; exact body is stable here. + old_truncate = '''/// 截断响应体到 [`ERROR_BODY_MAX_CHARS`] 字符,避免 HTML 404 页占用错误串。 +fn truncate_body(body: String) -> String { + if body.chars().count() <= ERROR_BODY_MAX_CHARS { + body + } else { + let mut s: String = body.chars().take(ERROR_BODY_MAX_CHARS).collect(); + s.push('…'); + s + } +} + +''' + text = replace_once(text, old_truncate, "", "model obsolete raw-body helper") + +# Add a pure diagnostic regression next to retry-policy tests when present. +test_anchor = ''' #[test] + fn candidate_failure_details_are_bounded() {''' +if test_anchor in text and "fail_fast_http_diagnostics_do_not_require_raw_body" not in text: + safe_test = ''' #[test] + fn fail_fast_http_diagnostics_do_not_require_raw_body() { + let secret = b"{\\\"token\\\":\\\"super-secret\\\"}"; + let rendered = String::from_utf8_lossy(secret); + let detail = format!( + "body-shape={}, {}", + crate::diagnostics::text_shape_hint(&rendered), + crate::diagnostics::payload_fingerprint(secret) + ); + assert!(detail.contains("body-shape=json-like")); + assert!(detail.contains("bytes=")); + assert!(!detail.contains("super-secret")); + } + +''' + text = replace_once(text, test_anchor, safe_test + test_anchor, "model safe diagnostics test") +path.write_text(text, encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Permanent source-level guards encode the design invariants so future green +# tests cannot reintroduce the same architectural failure modes. +# --------------------------------------------------------------------------- +path = Path("scripts/check_rust_failure_boundaries.py") +text = path.read_text(encoding="utf-8") +old_checks_tail = ''' ("services/model_fetch.rs", re.compile(r'\\.json\\(\\)\\s*\\.await\\s*\\.map_err\\(\\|e\\| format!\\(\\"Failed to parse response:', re.S), "invalid successful model payloads must not abort compatibility candidate discovery"), +]''' +new_checks_tail = ''' ("services/model_fetch.rs", re.compile(r'\\.json\\(\\)\\s*\\.await\\s*\\.map_err\\(\\|e\\| format!\\(\\"Failed to parse response:', re.S), "invalid successful model payloads must not abort compatibility candidate discovery"), + ("services/model_fetch.rs", re.compile(r'HTTP \\{status\\}: \\{body\\}'), "model-discovery errors must not expose raw upstream response bodies"), + ("app_store.rs", re.compile(r"fn read_override_from_store\\([^)]*\\) -> Option"), "Store read failure must not collapse into an absent app_config_dir override"), + ("app_store.rs", re.compile(r"fn resolve_path\\(raw: &str\\) -> PathBuf"), "app_config_dir parsing must be fallible and reject relative persistence roots"), + ("settings.rs", re.compile(r"fn settings_path\\(\\) -> Option"), "settings path resolution must expose HOME failures instead of a fake Option contract"), +]''' +text = replace_once(text, old_checks_tail, new_checks_tail, "policy explicit checks") + +extra_guard_anchor = '''# User-home resolution is a persistence boundary shared by DB/config/backup/CLI paths. A direct +# dirs::home_dir() call elsewhere can silently re-introduce CWD/relative fallback semantics or +# diverge from the validated CC_SWITCH_TEST_HOME behavior, so keep one common implementation. +''' +extra_guard = '''# Persistence roots must never accept a process-relative Store/settings override. These patterns +# previously bypassed the HOME guard while still binding DB/config state to the launch CWD. +config_text = (RUST_ROOT / "config.rs").read_text(encoding="utf-8") +if 'let legacy_dir = PathBuf::from(trimmed).join(".cc-switch")' in config_text: + failures.append( + "src-tauri/src/config.rs: Windows legacy HOME must be checked as absolute before DB fallback" + ) + +''' +text = replace_once(text, extra_guard_anchor, extra_guard + extra_guard_anchor, "policy persistence guard") +path.write_text(text, encoding="utf-8") + +print("Applied design-invariant hardening patch") From 94af26ff60d68368b58f5cc49b23889dbf4019ea Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 13:51:45 +0800 Subject: [PATCH 062/112] ci: run design invariant hardening once --- .../design-invariant-hardening-once.yml | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .github/workflows/design-invariant-hardening-once.yml diff --git a/.github/workflows/design-invariant-hardening-once.yml b/.github/workflows/design-invariant-hardening-once.yml new file mode 100644 index 00000000000..2557f929814 --- /dev/null +++ b/.github/workflows/design-invariant-hardening-once.yml @@ -0,0 +1,73 @@ +name: Design Invariant Hardening Once + +on: + push: + branches: + - fix/global-hardening-20260904 + paths: + - .github/workflows/design-invariant-hardening-once.yml + - scripts/apply_design_invariant_hardening_once.py + +permissions: + contents: write + +jobs: + apply-and-verify: + if: github.repository == 'z13321812367-sys/ccswitchmulti' + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + with: + ref: fix/global-hardening-20260904 + fetch-depth: 0 + + - name: Apply design invariant hardening + run: python scripts/apply_design_invariant_hardening_once.py + + - name: Install Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential pkg-config libssl-dev \ + libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev + sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \ + || sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev + sudo apt-get install -y --no-install-recommends libsoup-3.0-dev \ + || sudo apt-get install -y --no-install-recommends libsoup2.4-dev + + - name: Format Rust + run: cargo fmt --manifest-path src-tauri/Cargo.toml --all + + - name: Run permanent policy guards + run: | + python scripts/check_workflow_shell_interpolation.py + python scripts/check_rust_failure_boundaries.py + git diff --check + + - name: Create frontend dist placeholder + run: mkdir -p dist + + - name: Clippy all targets and features + run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --all-features -- -D warnings + + - name: Rust tests default feature set + run: cargo test --manifest-path src-tauri/Cargo.toml --all + + - name: Rust tests all features + run: cargo test --manifest-path src-tauri/Cargo.toml --all-features + + - name: Commit verified design fixes + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add \ + src-tauri/src/config.rs \ + src-tauri/src/app_store.rs \ + src-tauri/src/settings.rs \ + src-tauri/src/lib.rs \ + src-tauri/src/database/mod.rs \ + src-tauri/src/services/model_fetch.rs \ + scripts/check_rust_failure_boundaries.py + git diff --cached --check + git commit -m "fix: enforce persistence and diagnostic invariants" + git push origin HEAD:fix/global-hardening-20260904 From fccb1abdb20d6ef5966eb015daecd0cdf45ab3cc Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 13:53:16 +0800 Subject: [PATCH 063/112] ci: make design guard patch structure-aware --- .../design-invariant-hardening-once.yml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.github/workflows/design-invariant-hardening-once.yml b/.github/workflows/design-invariant-hardening-once.yml index 2557f929814..df888fd9d15 100644 --- a/.github/workflows/design-invariant-hardening-once.yml +++ b/.github/workflows/design-invariant-hardening-once.yml @@ -21,6 +21,28 @@ jobs: ref: fix/global-hardening-20260904 fetch-depth: 0 + - name: Normalize design driver guard insertion + run: | + python - <<'PY' + from pathlib import Path + path = Path("scripts/apply_design_invariant_hardening_once.py") + text = path.read_text(encoding="utf-8") + start = text.index("old_checks_tail = '''") + end = text.index("extra_guard_anchor = '''", start) + replacement = '''checks_marker = "]\\n\\nfailures = []" + if text.count(checks_marker) != 1: + raise SystemExit(f"policy FILE_CHECKS terminator count={text.count(checks_marker)}") + extra_checks = r'''\n ("services/model_fetch.rs", re.compile(r'HTTP \\{status\\}: \\{body\\}'), "model-discovery errors must not expose raw upstream response bodies"), + ("app_store.rs", re.compile(r"fn read_override_from_store\\([^)]*\\) -> Option"), "Store read failure must not collapse into an absent app_config_dir override"), + ("app_store.rs", re.compile(r"fn resolve_path\\(raw: &str\\) -> PathBuf"), "app_config_dir parsing must be fallible and reject relative persistence roots"), + ("settings.rs", re.compile(r"fn settings_path\\(\\) -> Option"), "settings path resolution must expose HOME failures instead of a fake Option contract"),''' + text = text.replace(checks_marker, extra_checks + "\\n]\\n\\nfailures = []", 1) + + ''' + text = text[:start] + replacement + text[end:] + path.write_text(text, encoding="utf-8") + PY + - name: Apply design invariant hardening run: python scripts/apply_design_invariant_hardening_once.py From 062716d83387dda5e69e5f750b6ba9996a773440 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 13:55:07 +0800 Subject: [PATCH 064/112] ci: separate design patch from policy patch --- .../design-invariant-hardening-once.yml | 55 ++++++++++++++----- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/.github/workflows/design-invariant-hardening-once.yml b/.github/workflows/design-invariant-hardening-once.yml index df888fd9d15..ce0c9b13cae 100644 --- a/.github/workflows/design-invariant-hardening-once.yml +++ b/.github/workflows/design-invariant-hardening-once.yml @@ -21,31 +21,58 @@ jobs: ref: fix/global-hardening-20260904 fetch-depth: 0 - - name: Normalize design driver guard insertion + - name: Keep patch driver focused on product sources run: | python - <<'PY' from pathlib import Path path = Path("scripts/apply_design_invariant_hardening_once.py") text = path.read_text(encoding="utf-8") - start = text.index("old_checks_tail = '''") - end = text.index("extra_guard_anchor = '''", start) - replacement = '''checks_marker = "]\\n\\nfailures = []" - if text.count(checks_marker) != 1: - raise SystemExit(f"policy FILE_CHECKS terminator count={text.count(checks_marker)}") - extra_checks = r'''\n ("services/model_fetch.rs", re.compile(r'HTTP \\{status\\}: \\{body\\}'), "model-discovery errors must not expose raw upstream response bodies"), - ("app_store.rs", re.compile(r"fn read_override_from_store\\([^)]*\\) -> Option"), "Store read failure must not collapse into an absent app_config_dir override"), - ("app_store.rs", re.compile(r"fn resolve_path\\(raw: &str\\) -> PathBuf"), "app_config_dir parsing must be fallible and reject relative persistence roots"), - ("settings.rs", re.compile(r"fn settings_path\\(\\) -> Option"), "settings path resolution must expose HOME failures instead of a fake Option contract"),''' - text = text.replace(checks_marker, extra_checks + "\\n]\\n\\nfailures = []", 1) - - ''' - text = text[:start] + replacement + text[end:] + marker = "# ---------------------------------------------------------------------------\n# Permanent source-level guards encode the design invariants" + if text.count(marker) != 1: + raise SystemExit(f"design-driver policy boundary count={text.count(marker)}") + text = text[:text.index(marker)] + 'print("Applied design-invariant product patch")\n' path.write_text(text, encoding="utf-8") PY - name: Apply design invariant hardening run: python scripts/apply_design_invariant_hardening_once.py + - name: Encode design invariants in permanent policy + run: | + python - <<'PY' + from pathlib import Path + path = Path("scripts/check_rust_failure_boundaries.py") + text = path.read_text(encoding="utf-8") + + marker = "]\n\nfailures = []" + if text.count(marker) != 1: + raise SystemExit(f"FILE_CHECKS terminator count={text.count(marker)}") + extra_checks = """ ("services/model_fetch.rs", re.compile(r'HTTP \\{status\\}: \\{body\\}'), "model-discovery errors must not expose raw upstream response bodies"), + ("app_store.rs", re.compile(r"fn read_override_from_store\\([^)]*\\) -> Option"), "Store read failure must not collapse into an absent app_config_dir override"), + ("app_store.rs", re.compile(r"fn resolve_path\\(raw: &str\\) -> PathBuf"), "app_config_dir parsing must be fallible and reject relative persistence roots"), + ("settings.rs", re.compile(r"fn settings_path\\(\\) -> Option"), "settings path resolution must expose HOME failures instead of a fake Option contract"), + """ + text = text.replace(marker, extra_checks + "]\n\nfailures = []", 1) + + anchor = """# User-home resolution is a persistence boundary shared by DB/config/backup/CLI paths. A direct + # dirs::home_dir() call elsewhere can silently re-introduce CWD/relative fallback semantics or + # diverge from the validated CC_SWITCH_TEST_HOME behavior, so keep one common implementation. + """ + if text.count(anchor) != 1: + raise SystemExit(f"home policy anchor count={text.count(anchor)}") + extra_guard = """# Persistence roots must never accept a process-relative Store/settings override. These patterns + # previously bypassed the HOME guard while still binding DB/config state to the launch CWD. + config_text = (RUST_ROOT / "config.rs").read_text(encoding="utf-8") + if 'let legacy_dir = PathBuf::from(trimmed).join(".cc-switch")' in config_text: + failures.append( + "src-tauri/src/config.rs: Windows legacy HOME must be checked as absolute before DB fallback" + ) + + """ + text = text.replace(anchor, extra_guard + anchor, 1) + path.write_text(text, encoding="utf-8") + PY + - name: Install Linux system dependencies run: | sudo apt-get update From dcf430acf6dbf5d9b1ff06ed1725d6c5b579db36 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 14:04:50 +0800 Subject: [PATCH 065/112] ci: extend design invariant hardening across fallible callers --- .../design-invariant-hardening-once.yml | 436 ++++++++++++++++++ 1 file changed, 436 insertions(+) diff --git a/.github/workflows/design-invariant-hardening-once.yml b/.github/workflows/design-invariant-hardening-once.yml index ce0c9b13cae..9651bd40ef7 100644 --- a/.github/workflows/design-invariant-hardening-once.yml +++ b/.github/workflows/design-invariant-hardening-once.yml @@ -37,6 +37,432 @@ jobs: - name: Apply design invariant hardening run: python scripts/apply_design_invariant_hardening_once.py + - name: Propagate fallible path and worker contracts + run: | + python - <<'PY' + from pathlib import Path + + def replace_once(path_str, old, new, label): + path = Path(path_str) + text = path.read_text(encoding="utf-8") + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected 1 match, found {count}") + path.write_text(text.replace(old, new, 1), encoding="utf-8") + + def replace_between(path_str, start, end, new, label): + path = Path(path_str) + text = path.read_text(encoding="utf-8") + if text.count(start) != 1: + raise SystemExit(f"{label}: start count={text.count(start)}") + i = text.index(start) + j = text.index(end, i) + path.write_text(text[:i] + new + text[j:], encoding="utf-8") + + # Tauri command: Option means a genuine absence, Result means Store/path failure. + replace_once( + "src-tauri/src/commands/settings.rs", + '''pub async fn get_app_config_dir_override(app: AppHandle) -> Result, String> { + Ok(crate::app_store::refresh_app_config_dir_override(&app) + .map(|p| p.to_string_lossy().to_string())) + } + ''', + '''pub async fn get_app_config_dir_override(app: AppHandle) -> Result, String> { + let value = crate::app_store::refresh_app_config_dir_override(&app) + .map_err(|err| err.to_string())?; + Ok(value.map(|p| p.to_string_lossy().to_string())) + } + ''', + "app-config command error propagation", + ) + + # Skill APIs already return Result; do not hide a panic-capable path resolver inside them. + replace_once( + "src-tauri/src/services/skill.rs", + "use crate::config::get_app_config_dir;\n", + "", + "skill obsolete infallible import", + ) + replace_once( + "src-tauri/src/services/skill.rs", + ''' SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"), + SkillStorageLocation::Unified => { + let home = crate::config::get_home_dir(); + home.join(".agents").join("skills") + } + ''', + ''' SkillStorageLocation::CcSwitch => crate::config::try_get_app_config_dir() + .map_err(|err| anyhow!(err))? + .join("skills"), + SkillStorageLocation::Unified => { + let home = crate::config::try_get_home_dir().map_err(|err| anyhow!(err))?; + home.join(".agents").join("skills") + } + ''', + "skill ssot fallible roots", + ) + replace_once( + "src-tauri/src/services/skill.rs", + ''' let dir = get_app_config_dir().join("skill-backups");''', + ''' let dir = crate::config::try_get_app_config_dir() + .map_err(|err| anyhow!(err))? + .join("skill-backups");''', + "skill backup fallible root", + ) + replace_once( + "src-tauri/src/services/skill.rs", + ''' let home = crate::config::get_home_dir(); + + Ok(match app {''', + ''' let home = crate::config::try_get_home_dir().map_err(|err| anyhow!(err))?; + + Ok(match app {''', + "skill app-dir fallible home", + ) + replace_once( + "src-tauri/src/services/skill.rs", + ''' SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),''', + ''' SkillStorageLocation::CcSwitch => crate::config::try_get_app_config_dir() + .map_err(|err| anyhow!(err))? + .join("skills"),''', + "skill migration fallible app root", + ) + + # CLI discovery is best-effort. Missing HOME may remove home-specific candidates, but + # must not crash discovery or manufacture relative candidates under process CWD. + replace_between( + "src-tauri/src/commands/misc.rs", + "fn build_tool_search_paths(tool: &str) -> Vec {", + "\n#[cfg(target_os = \"windows\")]\nfn is_windows_command_script", + '''fn build_tool_search_paths(tool: &str) -> Vec { + let home = crate::config::try_get_home_dir().ok(); + let mut search_paths: Vec = Vec::new(); + + if let Some(home) = home.as_ref() { + push_unique_path(&mut search_paths, home.join(".local/bin")); + push_unique_path(&mut search_paths, home.join(".npm-global/bin")); + push_unique_path(&mut search_paths, home.join("n/bin")); + push_unique_path(&mut search_paths, home.join(".volta/bin")); + extend_mise_node_search_paths(&mut search_paths, home); + + let fnm_base = home.join(".local/state/fnm_multishells"); + if let Ok(entries) = std::fs::read_dir(&fnm_base) { + for entry in entries.flatten() { + let bin_path = entry.path().join("bin"); + if bin_path.exists() { + push_unique_path(&mut search_paths, bin_path); + } + } + } + + let nvm_base = home.join(".nvm/versions/node"); + if let Ok(entries) = std::fs::read_dir(&nvm_base) { + for entry in entries.flatten() { + let bin_path = entry.path().join("bin"); + if bin_path.exists() { + push_unique_path(&mut search_paths, bin_path); + } + } + } + } else { + log::warn!("HOME unavailable while discovering CLI tools; skipping home-scoped candidates"); + } + + #[cfg(target_os = "macos")] + { + push_unique_path(&mut search_paths, std::path::PathBuf::from("/opt/homebrew/bin")); + push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/local/bin")); + if tool == "hermes" { + if let Some(home) = home.as_ref() { + let python_base = home.join("Library").join("Python"); + if let Ok(entries) = std::fs::read_dir(&python_base) { + for entry in entries.flatten() { + let bin_path = entry.path().join("bin"); + if bin_path.exists() { + push_unique_path(&mut search_paths, bin_path); + } + } + } + } + } + } + + #[cfg(target_os = "linux")] + { + push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/local/bin")); + push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/bin")); + } + + #[cfg(target_os = "windows")] + { + if let Some(appdata) = dirs::data_dir() { + push_unique_path(&mut search_paths, appdata.join("npm")); + if tool == "hermes" { + let python_base = appdata.join("Python"); + if let Ok(entries) = std::fs::read_dir(&python_base) { + for entry in entries.flatten() { + let scripts_path = entry.path().join("Scripts"); + if scripts_path.exists() { + push_unique_path(&mut search_paths, scripts_path); + } + } + } + } + } + if tool == "hermes" { + if let Some(local_data) = dirs::data_local_dir() { + let programs_python = local_data.join("Programs").join("Python"); + if let Ok(entries) = std::fs::read_dir(&programs_python) { + for entry in entries.flatten() { + let scripts_path = entry.path().join("Scripts"); + if scripts_path.exists() { + push_unique_path(&mut search_paths, scripts_path); + } + } + } + } + } + push_unique_path(&mut search_paths, std::path::PathBuf::from("C:\\Program Files\\nodejs")); + if let Some(home) = home.as_ref() { + extend_windows_cli_manager_search_paths(&mut search_paths, home); + } + } + + if tool == "opencode" { + let empty_home = Path::new(""); + let extra_paths = opencode_extra_search_paths( + home.as_deref().unwrap_or(empty_home), + std::env::var_os("OPENCODE_INSTALL_DIR"), + std::env::var_os("XDG_BIN_DIR"), + std::env::var_os("GOPATH"), + ); + for path in extra_paths { + push_unique_path(&mut search_paths, path); + } + } + + extend_from_cli_path_env(&mut search_paths, std::env::var_os("PATH")); + search_paths + } + ''', + "CLI discovery HOME fallback", + ) + + # OpenCode session roots are fallible. Invalid/missing HOME must be surfaced through the + # session API rather than panicking. Existing SQLite errors are also observable. + replace_between( + "src-tauri/src/session_manager/providers/opencode.rs", + "pub(crate) fn get_opencode_base_dir() -> PathBuf {", + "\n/// Parse a SQLite source reference", + '''fn try_get_opencode_base_dir() -> Result { + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + let xdg = PathBuf::from(xdg.trim()); + if xdg.is_absolute() { + return Ok(xdg.join("opencode")); + } + if !xdg.as_os_str().is_empty() { + log::warn!( + "Ignoring relative XDG_DATA_HOME for OpenCode discovery: {}", + xdg.display() + ); + } + } + Ok(crate::config::try_get_home_dir()?.join(".local/share/opencode")) + } + + /// Return the OpenCode JSON storage directory (legacy flat-file layout). + pub(crate) fn get_opencode_data_dir() -> Result { + Ok(try_get_opencode_base_dir()?.join("storage")) + } + + fn get_opencode_db_path() -> Result { + Ok(try_get_opencode_base_dir()?.join("opencode.db")) + } + + /// Scan sessions from both the legacy JSON files and the newer SQLite database, + /// merging results with SQLite taking precedence on ID conflicts. + pub fn scan_sessions() -> Result, String> { + let json_sessions = scan_sessions_json()?; + let sqlite_sessions = scan_sessions_sqlite()?; + + if sqlite_sessions.is_empty() { + return Ok(json_sessions); + } + if json_sessions.is_empty() { + return Ok(sqlite_sessions); + } + + let sqlite_ids: std::collections::HashSet = sqlite_sessions + .iter() + .map(|s| s.session_id.clone()) + .collect(); + let mut merged = sqlite_sessions; + for session in json_sessions { + if !sqlite_ids.contains(&session.session_id) { + merged.push(session); + } + } + Ok(merged) + } + + fn scan_sessions_json() -> Result, String> { + let storage = get_opencode_data_dir()?; + let session_dir = storage.join("session"); + if !session_dir.exists() { + return Ok(Vec::new()); + } + + let mut json_files = Vec::new(); + collect_json_files(&session_dir, &mut json_files); + let mut sessions = Vec::new(); + for path in json_files { + if let Some(meta) = parse_session(&storage, &path) { + sessions.push(meta); + } + } + Ok(sessions) + } + ''', + "OpenCode fallible roots and JSON scan", + ) + replace_between( + "src-tauri/src/session_manager/providers/opencode.rs", + "fn scan_sessions_sqlite() -> Vec {", + "\npub fn load_messages(path: &Path)", + '''fn scan_sessions_sqlite() -> Result, String> { + let db_path = get_opencode_db_path()?; + if !db_path.exists() { + return Ok(Vec::new()); + } + + let conn = Connection::open_with_flags( + &db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|err| format!("Failed to open OpenCode session database {}: {err}", db_path.display()))?; + + let mut stmt = conn + .prepare( + "SELECT id, title, directory, time_created, time_updated FROM session ORDER BY time_updated DESC", + ) + .map_err(|err| format!("Failed to prepare OpenCode session query: {err}"))?; + + let db_display = db_path.display().to_string(); + let iter = stmt + .query_map([], |row| { + let session_id: String = row.get(0)?; + let title: String = row.get(1)?; + let directory: String = row.get(2)?; + let created: i64 = row.get(3)?; + let updated: i64 = row.get(4)?; + Ok((session_id, title, directory, created, updated)) + }) + .map_err(|err| format!("Failed to query OpenCode sessions: {err}"))?; + + let mut sessions = Vec::new(); + for row in iter { + let (session_id, title, directory, created, updated) = row + .map_err(|err| format!("Failed to decode OpenCode session row: {err}"))?; + let display_title = if title.is_empty() { + path_basename(&directory) + } else { + Some(title) + }; + sessions.push(SessionMeta { + provider_id: PROVIDER_ID.to_string(), + session_id: session_id.clone(), + title: display_title.clone(), + summary: display_title, + project_dir: if directory.is_empty() { None } else { Some(directory) }, + created_at: Some(created), + last_active_at: Some(updated), + source_path: Some(format!("sqlite:{db_display}:{session_id}")), + resume_command: Some(format!("opencode session resume {session_id}")), + }); + } + Ok(sessions) + } + ''', + "OpenCode observable SQLite scan", + ) + replace_once( + "src-tauri/src/session_manager/providers/opencode.rs", + ''' let expected_db_path = get_opencode_db_path() + .canonicalize() + ''', + ''' let expected_db_path = get_opencode_db_path()? + .canonicalize() + ''', + "OpenCode delete expected root", + ) + + # Session aggregation must not silently convert a provider panic into an empty provider. + replace_between( + "src-tauri/src/session_manager/mod.rs", + "pub fn scan_sessions() -> Vec {", + "\npub fn load_messages", + '''pub fn scan_sessions() -> Result, String> { + let (r1, r2, r3, r4, r5, r6) = std::thread::scope(|s| -> Result<_, String> { + let h1 = s.spawn(codex::scan_sessions); + let h2 = s.spawn(claude::scan_sessions); + let h3 = s.spawn(opencode::scan_sessions); + let h4 = s.spawn(openclaw::scan_sessions); + let h5 = s.spawn(gemini::scan_sessions); + let h6 = s.spawn(hermes::scan_sessions); + + let r1 = h1.join().map_err(|_| "Codex session scan panicked".to_string())?; + let r2 = h2.join().map_err(|_| "Claude session scan panicked".to_string())?; + let r3 = h3 + .join() + .map_err(|_| "OpenCode session scan panicked".to_string())??; + let r4 = h4.join().map_err(|_| "OpenClaw session scan panicked".to_string())?; + let r5 = h5.join().map_err(|_| "Gemini session scan panicked".to_string())?; + let r6 = h6.join().map_err(|_| "Hermes session scan panicked".to_string())?; + Ok((r1, r2, r3, r4, r5, r6)) + })?; + + let mut sessions = Vec::new(); + sessions.extend(r1); + sessions.extend(r2); + sessions.extend(r3); + sessions.extend(r4); + sessions.extend(r5); + sessions.extend(r6); + sessions.sort_by(|a, b| { + let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0); + let b_ts = b.last_active_at.or(b.created_at).unwrap_or(0); + b_ts.cmp(&a_ts) + }); + Ok(sessions) + } + ''', + "session panic propagation", + ) + replace_once( + "src-tauri/src/session_manager/mod.rs", + ''' "opencode" => vec![opencode::get_opencode_data_dir()],''', + ''' "opencode" => vec![opencode::get_opencode_data_dir()?],''', + "OpenCode deletion root propagation", + ) + replace_once( + "src-tauri/src/commands/session_manager.rs", + '''pub async fn list_sessions() -> Result, String> { + let sessions = tauri::async_runtime::spawn_blocking(session_manager::scan_sessions) + .await + .map_err(|e| format!("Failed to scan sessions: {e}"))?; + Ok(sessions) + } + ''', + '''pub async fn list_sessions() -> Result, String> { + tauri::async_runtime::spawn_blocking(session_manager::scan_sessions) + .await + .map_err(|e| format!("Failed to scan sessions task: {e}"))? + } + ''', + "session command error propagation", + ) + PY + - name: Encode design invariants in permanent policy run: | python - <<'PY' @@ -51,6 +477,10 @@ jobs: ("app_store.rs", re.compile(r"fn read_override_from_store\\([^)]*\\) -> Option"), "Store read failure must not collapse into an absent app_config_dir override"), ("app_store.rs", re.compile(r"fn resolve_path\\(raw: &str\\) -> PathBuf"), "app_config_dir parsing must be fallible and reject relative persistence roots"), ("settings.rs", re.compile(r"fn settings_path\\(\\) -> Option"), "settings path resolution must expose HOME failures instead of a fake Option contract"), + ("services/skill.rs", re.compile(r"(?:get_app_config_dir|crate::config::get_home_dir)\\(\\)"), "Skill Result APIs must propagate fallible persistence roots instead of panicking"), + ("commands/misc.rs", re.compile(r"let home = crate::config::get_home_dir\\(\\);"), "CLI discovery must degrade without HOME instead of panicking"), + ("session_manager/mod.rs", re.compile(r"join\\(\\)\\.unwrap_or_default\\(\\)"), "session worker panics must be observable, not converted to empty results"), + ("session_manager/providers/opencode.rs", re.compile(r"crate::config::get_home_dir\\(\\)"), "OpenCode session path resolution must be fallible"), """ text = text.replace(marker, extra_checks + "]\n\nfailures = []", 1) @@ -116,6 +546,12 @@ jobs: src-tauri/src/lib.rs \ src-tauri/src/database/mod.rs \ src-tauri/src/services/model_fetch.rs \ + src-tauri/src/commands/settings.rs \ + src-tauri/src/services/skill.rs \ + src-tauri/src/commands/misc.rs \ + src-tauri/src/session_manager/mod.rs \ + src-tauri/src/session_manager/providers/opencode.rs \ + src-tauri/src/commands/session_manager.rs \ scripts/check_rust_failure_boundaries.py git diff --cached --check git commit -m "fix: enforce persistence and diagnostic invariants" From 11f31de460fe9a819cccc4b72973453aba9973a9 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 14:14:56 +0800 Subject: [PATCH 066/112] ci: make design invariant follow-up structural --- .../apply_design_invariant_followup_once.py | 489 ++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 scripts/apply_design_invariant_followup_once.py diff --git a/scripts/apply_design_invariant_followup_once.py b/scripts/apply_design_invariant_followup_once.py new file mode 100644 index 00000000000..5c04a04b807 --- /dev/null +++ b/scripts/apply_design_invariant_followup_once.py @@ -0,0 +1,489 @@ +#!/usr/bin/env python3 +from pathlib import Path +import re + + +def read(path: str) -> str: + return Path(path).read_text(encoding="utf-8") + + +def write(path: str, text: str) -> None: + Path(path).write_text(text, encoding="utf-8") + + +def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) -> None: + text = read(path) + count = text.count(old) + if count != expected: + raise SystemExit(f"{label}: expected {expected} matches, found {count}") + write(path, text.replace(old, new)) + + +def replace_region(path: str, start: str, end: str, new: str, label: str) -> None: + text = read(path) + if text.count(start) != 1: + raise SystemExit(f"{label}: start count={text.count(start)}") + i = text.index(start) + try: + j = text.index(end, i) + except ValueError: + raise SystemExit(f"{label}: end marker missing") + write(path, text[:i] + new + text[j:]) + + +# Command boundary: Option means genuine absence; Result means Store/path failure. +replace_region( + "src-tauri/src/commands/settings.rs", + "pub async fn get_app_config_dir_override(app: AppHandle) -> Result, String> {", + "\n}\n\n/// 设置 app_config_dir 覆盖配置", + '''pub async fn get_app_config_dir_override(app: AppHandle) -> Result, String> { + let value = crate::app_store::refresh_app_config_dir_override(&app) + .map_err(|err| err.to_string())?; + Ok(value.map(|path| path.to_string_lossy().to_string())) +''', + "settings command propagation", +) + + +# Skill path APIs already return Result, so they must not hide panic-based path wrappers. +replace_exact( + "src-tauri/src/services/skill.rs", + "use crate::config::get_app_config_dir;\n", + "", + "remove infallible skill import", +) +replace_exact( + "src-tauri/src/services/skill.rs", + 'SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),', + '''SkillStorageLocation::CcSwitch => crate::config::try_get_app_config_dir() + .map_err(|err| anyhow!(err))? + .join("skills"),''', + "skill cc-switch roots", + expected=2, +) +replace_exact( + "src-tauri/src/services/skill.rs", + 'let dir = get_app_config_dir().join("skill-backups");', + '''let dir = crate::config::try_get_app_config_dir() + .map_err(|err| anyhow!(err))? + .join("skill-backups");''', + "skill backup root", +) +# The remaining infallible HOME call in this module is the default app skills root. +replace_exact( + "src-tauri/src/services/skill.rs", + "let home = crate::config::get_home_dir();", + "let home = crate::config::try_get_home_dir().map_err(|err| anyhow!(err))?;", + "skill app HOME", +) + + +# CLI discovery is best-effort: loss of HOME removes HOME-scoped candidates, not the feature. +replace_region( + "src-tauri/src/commands/misc.rs", + "fn build_tool_search_paths(tool: &str) -> Vec {", + '\n#[cfg(target_os = "windows")]\nfn is_windows_command_script', + '''fn build_tool_search_paths(tool: &str) -> Vec { + let home = crate::config::try_get_home_dir().ok(); + let mut search_paths: Vec = Vec::new(); + + if let Some(home) = home.as_ref() { + push_unique_path(&mut search_paths, home.join(".local/bin")); + push_unique_path(&mut search_paths, home.join(".npm-global/bin")); + push_unique_path(&mut search_paths, home.join("n/bin")); + push_unique_path(&mut search_paths, home.join(".volta/bin")); + extend_mise_node_search_paths(&mut search_paths, home); + + for base in [home.join(".local/state/fnm_multishells"), home.join(".nvm/versions/node")] { + if let Ok(entries) = std::fs::read_dir(&base) { + for entry in entries.flatten() { + let bin_path = entry.path().join("bin"); + if bin_path.exists() { + push_unique_path(&mut search_paths, bin_path); + } + } + } + } + } else { + log::warn!("HOME unavailable while discovering CLI tools; skipping home-scoped candidates"); + } + + #[cfg(target_os = "macos")] + { + push_unique_path(&mut search_paths, std::path::PathBuf::from("/opt/homebrew/bin")); + push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/local/bin")); + if tool == "hermes" { + if let Some(home) = home.as_ref() { + let python_base = home.join("Library").join("Python"); + if let Ok(entries) = std::fs::read_dir(&python_base) { + for entry in entries.flatten() { + let bin_path = entry.path().join("bin"); + if bin_path.exists() { + push_unique_path(&mut search_paths, bin_path); + } + } + } + } + } + } + + #[cfg(target_os = "linux")] + { + push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/local/bin")); + push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/bin")); + } + + #[cfg(target_os = "windows")] + { + if let Some(appdata) = dirs::data_dir() { + push_unique_path(&mut search_paths, appdata.join("npm")); + if tool == "hermes" { + let python_base = appdata.join("Python"); + if let Ok(entries) = std::fs::read_dir(&python_base) { + for entry in entries.flatten() { + let scripts_path = entry.path().join("Scripts"); + if scripts_path.exists() { + push_unique_path(&mut search_paths, scripts_path); + } + } + } + } + } + if tool == "hermes" { + if let Some(local_data) = dirs::data_local_dir() { + let programs_python = local_data.join("Programs").join("Python"); + if let Ok(entries) = std::fs::read_dir(&programs_python) { + for entry in entries.flatten() { + let scripts_path = entry.path().join("Scripts"); + if scripts_path.exists() { + push_unique_path(&mut search_paths, scripts_path); + } + } + } + } + } + push_unique_path(&mut search_paths, std::path::PathBuf::from("C:\\Program Files\\nodejs")); + if let Some(home) = home.as_ref() { + extend_windows_cli_manager_search_paths(&mut search_paths, home); + } + } + + if tool == "opencode" { + let empty_home = Path::new(""); + for path in opencode_extra_search_paths( + home.as_deref().unwrap_or(empty_home), + std::env::var_os("OPENCODE_INSTALL_DIR"), + std::env::var_os("XDG_BIN_DIR"), + std::env::var_os("GOPATH"), + ) { + push_unique_path(&mut search_paths, path); + } + } + + // PATH intentionally retains shell/OS semantics; explicit manager/install roots above do not. + extend_from_cli_path_env(&mut search_paths, std::env::var_os("PATH")); + search_paths +} +''', + "CLI discovery HOME semantics", +) +# Explicit installation-root environment variables are roots, not shell PATH entries: reject CWD-relative values. +replace_region( + "src-tauri/src/commands/misc.rs", + "fn push_env_single_dir(paths: &mut Vec, value: Option) {", + "\n}\n\nfn extend_from_path_list", + '''fn push_env_single_dir(paths: &mut Vec, value: Option) { + if let Some(raw) = value { + let path = std::path::PathBuf::from(raw); + if path.is_absolute() { + push_unique_path(paths, path); + } else if !path.as_os_str().is_empty() { + log::warn!("Ignoring relative CLI install root: {}", path.display()); + } + } +''', + "absolute CLI install roots", +) +replace_region( + "src-tauri/src/commands/misc.rs", + "fn extend_from_path_list(\n", + "\n}\n\nfn extend_from_cli_path_env", + '''fn extend_from_path_list( + paths: &mut Vec, + value: Option, + suffix: Option<&str>, +) { + if let Some(raw) = value { + for base in std::env::split_paths(&raw) { + if !base.is_absolute() { + if !base.as_os_str().is_empty() { + log::warn!("Ignoring relative CLI path-list root: {}", base.display()); + } + continue; + } + let dir = match suffix { + Some(suffix) => base.join(suffix), + None => base, + }; + push_unique_path(paths, dir); + } + } +''', + "absolute CLI path-list roots", +) + + +# OpenCode session roots are fallible and existing SQLite errors must be visible. +replace_region( + "src-tauri/src/session_manager/providers/opencode.rs", + "pub(crate) fn get_opencode_base_dir() -> PathBuf {", + "\n/// Parse a SQLite source reference", + '''fn try_get_opencode_base_dir() -> Result { + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + let xdg = PathBuf::from(xdg.trim()); + if xdg.is_absolute() { + return Ok(xdg.join("opencode")); + } + if !xdg.as_os_str().is_empty() { + log::warn!("Ignoring relative XDG_DATA_HOME for OpenCode discovery: {}", xdg.display()); + } + } + Ok(crate::config::try_get_home_dir()?.join(".local/share/opencode")) +} + +pub(crate) fn get_opencode_data_dir() -> Result { + Ok(try_get_opencode_base_dir()?.join("storage")) +} + +fn get_opencode_db_path() -> Result { + Ok(try_get_opencode_base_dir()?.join("opencode.db")) +} + +pub fn scan_sessions() -> Result, String> { + let json_sessions = scan_sessions_json()?; + let sqlite_sessions = scan_sessions_sqlite()?; + if sqlite_sessions.is_empty() { + return Ok(json_sessions); + } + if json_sessions.is_empty() { + return Ok(sqlite_sessions); + } + let sqlite_ids: std::collections::HashSet = sqlite_sessions + .iter().map(|session| session.session_id.clone()).collect(); + let mut merged = sqlite_sessions; + for session in json_sessions { + if !sqlite_ids.contains(&session.session_id) { + merged.push(session); + } + } + Ok(merged) +} + +fn scan_sessions_json() -> Result, String> { + let storage = get_opencode_data_dir()?; + let session_dir = storage.join("session"); + if !session_dir.exists() { + return Ok(Vec::new()); + } + let mut json_files = Vec::new(); + collect_json_files(&session_dir, &mut json_files); + let mut sessions = Vec::new(); + for path in json_files { + if let Some(meta) = parse_session(&storage, &path) { + sessions.push(meta); + } + } + Ok(sessions) +} +''', + "OpenCode fallible roots", +) +replace_region( + "src-tauri/src/session_manager/providers/opencode.rs", + "fn scan_sessions_sqlite() -> Vec {", + "\npub fn load_messages(path: &Path)", + '''fn scan_sessions_sqlite() -> Result, String> { + let db_path = get_opencode_db_path()?; + if !db_path.exists() { + return Ok(Vec::new()); + } + let conn = Connection::open_with_flags( + &db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ).map_err(|err| format!("Failed to open OpenCode session database {}: {err}", db_path.display()))?; + let mut stmt = conn.prepare( + "SELECT id, title, directory, time_created, time_updated FROM session ORDER BY time_updated DESC", + ).map_err(|err| format!("Failed to prepare OpenCode session query: {err}"))?; + let db_display = db_path.display().to_string(); + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + )) + }).map_err(|err| format!("Failed to query OpenCode sessions: {err}"))?; + let mut sessions = Vec::new(); + for row in rows { + let (session_id, title, directory, created, updated) = + row.map_err(|err| format!("Failed to decode OpenCode session row: {err}"))?; + let display_title = if title.is_empty() { path_basename(&directory) } else { Some(title) }; + sessions.push(SessionMeta { + provider_id: PROVIDER_ID.to_string(), + session_id: session_id.clone(), + title: display_title.clone(), + summary: display_title, + project_dir: if directory.is_empty() { None } else { Some(directory) }, + created_at: Some(created), + last_active_at: Some(updated), + source_path: Some(format!("sqlite:{db_display}:{session_id}")), + resume_command: Some(format!("opencode session resume {session_id}")), + }); + } + Ok(sessions) +} +''', + "OpenCode SQLite observability", +) +replace_exact( + "src-tauri/src/session_manager/providers/opencode.rs", + "let expected_db_path = get_opencode_db_path()\n", + "let expected_db_path = get_opencode_db_path()?\n", + "OpenCode delete root", +) + + +# Session worker panic is an error, not an empty provider result. +replace_region( + "src-tauri/src/session_manager/mod.rs", + "pub fn scan_sessions() -> Vec {", + "\npub fn load_messages", + '''pub fn scan_sessions() -> Result, String> { + let (r1, r2, r3, r4, r5, r6) = std::thread::scope(|scope| -> Result<_, String> { + let h1 = scope.spawn(codex::scan_sessions); + let h2 = scope.spawn(claude::scan_sessions); + let h3 = scope.spawn(opencode::scan_sessions); + let h4 = scope.spawn(openclaw::scan_sessions); + let h5 = scope.spawn(gemini::scan_sessions); + let h6 = scope.spawn(hermes::scan_sessions); + + let r1 = h1.join().map_err(|_| "Codex session scan panicked".to_string())?; + let r2 = h2.join().map_err(|_| "Claude session scan panicked".to_string())?; + let r3 = h3.join().map_err(|_| "OpenCode session scan panicked".to_string())??; + let r4 = h4.join().map_err(|_| "OpenClaw session scan panicked".to_string())?; + let r5 = h5.join().map_err(|_| "Gemini session scan panicked".to_string())?; + let r6 = h6.join().map_err(|_| "Hermes session scan panicked".to_string())?; + Ok((r1, r2, r3, r4, r5, r6)) + })?; + + let mut sessions = Vec::new(); + sessions.extend(r1); + sessions.extend(r2); + sessions.extend(r3); + sessions.extend(r4); + sessions.extend(r5); + sessions.extend(r6); + sessions.sort_by(|a, b| { + b.last_active_at.or(b.created_at).unwrap_or(0) + .cmp(&a.last_active_at.or(a.created_at).unwrap_or(0)) + }); + Ok(sessions) +} +''', + "session worker observability", +) +replace_exact( + "src-tauri/src/session_manager/mod.rs", + '"opencode" => vec![opencode::get_opencode_data_dir()],', + '"opencode" => vec![opencode::get_opencode_data_dir()?],', + "OpenCode provider deletion root", +) +replace_region( + "src-tauri/src/commands/session_manager.rs", + "pub async fn list_sessions() -> Result, String> {", + "\n}\n\n#[tauri::command]\npub async fn get_session_messages", + '''pub async fn list_sessions() -> Result, String> { + tauri::async_runtime::spawn_blocking(session_manager::scan_sessions) + .await + .map_err(|err| format!("Failed to scan sessions task: {err}"))? +''', + "session command propagation", +) + + +# Hermes configuration roots must not be process-relative even when supplied by environment. +path = "src-tauri/src/hermes_config.rs" +text = read(path) +old = ''' if let Some(raw) = std::env::var_os("HERMES_HOME") { + let value = raw.to_string_lossy(); + let trimmed = value.trim(); + if !trimmed.is_empty() { + return PathBuf::from(trimmed); + } + } +''' +new = ''' if let Some(raw) = std::env::var_os("HERMES_HOME") { + let value = raw.to_string_lossy(); + let trimmed = value.trim(); + if !trimmed.is_empty() { + let path = PathBuf::from(trimmed); + if path.is_absolute() { + return path; + } + log::warn!("Ignoring relative HERMES_HOME: {}", path.display()); + } + } +''' +if text.count(old) != 1: + raise SystemExit(f"Hermes HOME validation: count={text.count(old)}") +text = text.replace(old, new, 1) +old = ''' localappdata + .map(|value| value.to_string_lossy().trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData").join("Local")) + .join("hermes") +''' +new = ''' localappdata + .map(|value| PathBuf::from(value.to_string_lossy().trim().to_string())) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| home.join("AppData").join("Local")) + .join("hermes") +''' +if text.count(old) != 1: + raise SystemExit(f"Hermes LOCALAPPDATA validation: count={text.count(old)}") +text = text.replace(old, new, 1) +write(path, text) + + +# Permanent guards: encode semantics, not current test outcomes. +guard = "scripts/check_rust_failure_boundaries.py" +text = read(guard) +marker = "]\n\nfailures = []" +if text.count(marker) != 1: + raise SystemExit(f"guard FILE_CHECKS marker count={text.count(marker)}") +checks = ''' ("services/model_fetch.rs", re.compile(r'HTTP \\{status\\}: \\{body\\}'), "model-discovery errors must not expose raw upstream response bodies"), + ("app_store.rs", re.compile(r"fn read_override_from_store\\([^)]*\\) -> Option"), "Store read failure must not collapse into an absent app_config_dir override"), + ("app_store.rs", re.compile(r"fn resolve_path\\(raw: &str\\) -> PathBuf"), "app_config_dir parsing must be fallible and reject relative persistence roots"), + ("settings.rs", re.compile(r"fn settings_path\\(\\) -> Option"), "settings path resolution must expose HOME failures instead of a fake Option contract"), + ("services/skill.rs", re.compile(r"(?:get_app_config_dir|crate::config::get_home_dir)\\(\\)"), "Skill Result APIs must propagate fallible persistence roots instead of panicking"), + ("commands/misc.rs", re.compile(r"let home = crate::config::get_home_dir\\(\\);"), "CLI discovery must degrade without HOME instead of panicking"), + ("session_manager/mod.rs", re.compile(r"join\\(\\)\\.unwrap_or_default\\(\\)"), "session worker panics must be observable, not converted to empty results"), + ("session_manager/providers/opencode.rs", re.compile(r"crate::config::get_home_dir\\(\\)"), "OpenCode session path resolution must be fallible"), + ("hermes_config.rs", re.compile(r"return PathBuf::from\\(trimmed\\)"), "HERMES_HOME must not create a process-relative configuration root"), +''' +text = text.replace(marker, checks + marker, 1) +anchor = "# User-home resolution is a persistence boundary shared by DB/config/backup/CLI paths. A direct\n" +if text.count(anchor) != 1: + raise SystemExit(f"guard HOME anchor count={text.count(anchor)}") +extra = '''# Persistence compatibility must not reintroduce CWD-relative roots inside config.rs itself. +config_text = (RUST_ROOT / "config.rs").read_text(encoding="utf-8") +if 'let legacy_dir = PathBuf::from(trimmed).join(".cc-switch")' in config_text: + failures.append("src-tauri/src/config.rs: Windows legacy HOME must be validated before DB fallback") + +''' +text = text.replace(anchor, extra + anchor, 1) +write(guard, text) + +print("Applied structural design-invariant follow-up") From 4f9f3485846c72f201250c5040a0fcf75f32bd29 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 14:15:20 +0800 Subject: [PATCH 067/112] ci: run structural design invariant hardening --- .../design-invariant-hardening-once.yml | 498 +----------------- 1 file changed, 13 insertions(+), 485 deletions(-) diff --git a/.github/workflows/design-invariant-hardening-once.yml b/.github/workflows/design-invariant-hardening-once.yml index 9651bd40ef7..4c6af5465c6 100644 --- a/.github/workflows/design-invariant-hardening-once.yml +++ b/.github/workflows/design-invariant-hardening-once.yml @@ -7,6 +7,7 @@ on: paths: - .github/workflows/design-invariant-hardening-once.yml - scripts/apply_design_invariant_hardening_once.py + - scripts/apply_design_invariant_followup_once.py permissions: contents: write @@ -21,7 +22,7 @@ jobs: ref: fix/global-hardening-20260904 fetch-depth: 0 - - name: Keep patch driver focused on product sources + - name: Keep primary driver focused on product sources run: | python - <<'PY' from pathlib import Path @@ -30,478 +31,14 @@ jobs: marker = "# ---------------------------------------------------------------------------\n# Permanent source-level guards encode the design invariants" if text.count(marker) != 1: raise SystemExit(f"design-driver policy boundary count={text.count(marker)}") - text = text[:text.index(marker)] + 'print("Applied design-invariant product patch")\n' - path.write_text(text, encoding="utf-8") + path.write_text(text[:text.index(marker)] + 'print("Applied design-invariant product patch")\n', encoding="utf-8") PY - - name: Apply design invariant hardening + - name: Apply common product invariants run: python scripts/apply_design_invariant_hardening_once.py - - name: Propagate fallible path and worker contracts - run: | - python - <<'PY' - from pathlib import Path - - def replace_once(path_str, old, new, label): - path = Path(path_str) - text = path.read_text(encoding="utf-8") - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected 1 match, found {count}") - path.write_text(text.replace(old, new, 1), encoding="utf-8") - - def replace_between(path_str, start, end, new, label): - path = Path(path_str) - text = path.read_text(encoding="utf-8") - if text.count(start) != 1: - raise SystemExit(f"{label}: start count={text.count(start)}") - i = text.index(start) - j = text.index(end, i) - path.write_text(text[:i] + new + text[j:], encoding="utf-8") - - # Tauri command: Option means a genuine absence, Result means Store/path failure. - replace_once( - "src-tauri/src/commands/settings.rs", - '''pub async fn get_app_config_dir_override(app: AppHandle) -> Result, String> { - Ok(crate::app_store::refresh_app_config_dir_override(&app) - .map(|p| p.to_string_lossy().to_string())) - } - ''', - '''pub async fn get_app_config_dir_override(app: AppHandle) -> Result, String> { - let value = crate::app_store::refresh_app_config_dir_override(&app) - .map_err(|err| err.to_string())?; - Ok(value.map(|p| p.to_string_lossy().to_string())) - } - ''', - "app-config command error propagation", - ) - - # Skill APIs already return Result; do not hide a panic-capable path resolver inside them. - replace_once( - "src-tauri/src/services/skill.rs", - "use crate::config::get_app_config_dir;\n", - "", - "skill obsolete infallible import", - ) - replace_once( - "src-tauri/src/services/skill.rs", - ''' SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"), - SkillStorageLocation::Unified => { - let home = crate::config::get_home_dir(); - home.join(".agents").join("skills") - } - ''', - ''' SkillStorageLocation::CcSwitch => crate::config::try_get_app_config_dir() - .map_err(|err| anyhow!(err))? - .join("skills"), - SkillStorageLocation::Unified => { - let home = crate::config::try_get_home_dir().map_err(|err| anyhow!(err))?; - home.join(".agents").join("skills") - } - ''', - "skill ssot fallible roots", - ) - replace_once( - "src-tauri/src/services/skill.rs", - ''' let dir = get_app_config_dir().join("skill-backups");''', - ''' let dir = crate::config::try_get_app_config_dir() - .map_err(|err| anyhow!(err))? - .join("skill-backups");''', - "skill backup fallible root", - ) - replace_once( - "src-tauri/src/services/skill.rs", - ''' let home = crate::config::get_home_dir(); - - Ok(match app {''', - ''' let home = crate::config::try_get_home_dir().map_err(|err| anyhow!(err))?; - - Ok(match app {''', - "skill app-dir fallible home", - ) - replace_once( - "src-tauri/src/services/skill.rs", - ''' SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),''', - ''' SkillStorageLocation::CcSwitch => crate::config::try_get_app_config_dir() - .map_err(|err| anyhow!(err))? - .join("skills"),''', - "skill migration fallible app root", - ) - - # CLI discovery is best-effort. Missing HOME may remove home-specific candidates, but - # must not crash discovery or manufacture relative candidates under process CWD. - replace_between( - "src-tauri/src/commands/misc.rs", - "fn build_tool_search_paths(tool: &str) -> Vec {", - "\n#[cfg(target_os = \"windows\")]\nfn is_windows_command_script", - '''fn build_tool_search_paths(tool: &str) -> Vec { - let home = crate::config::try_get_home_dir().ok(); - let mut search_paths: Vec = Vec::new(); - - if let Some(home) = home.as_ref() { - push_unique_path(&mut search_paths, home.join(".local/bin")); - push_unique_path(&mut search_paths, home.join(".npm-global/bin")); - push_unique_path(&mut search_paths, home.join("n/bin")); - push_unique_path(&mut search_paths, home.join(".volta/bin")); - extend_mise_node_search_paths(&mut search_paths, home); - - let fnm_base = home.join(".local/state/fnm_multishells"); - if let Ok(entries) = std::fs::read_dir(&fnm_base) { - for entry in entries.flatten() { - let bin_path = entry.path().join("bin"); - if bin_path.exists() { - push_unique_path(&mut search_paths, bin_path); - } - } - } - - let nvm_base = home.join(".nvm/versions/node"); - if let Ok(entries) = std::fs::read_dir(&nvm_base) { - for entry in entries.flatten() { - let bin_path = entry.path().join("bin"); - if bin_path.exists() { - push_unique_path(&mut search_paths, bin_path); - } - } - } - } else { - log::warn!("HOME unavailable while discovering CLI tools; skipping home-scoped candidates"); - } - - #[cfg(target_os = "macos")] - { - push_unique_path(&mut search_paths, std::path::PathBuf::from("/opt/homebrew/bin")); - push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/local/bin")); - if tool == "hermes" { - if let Some(home) = home.as_ref() { - let python_base = home.join("Library").join("Python"); - if let Ok(entries) = std::fs::read_dir(&python_base) { - for entry in entries.flatten() { - let bin_path = entry.path().join("bin"); - if bin_path.exists() { - push_unique_path(&mut search_paths, bin_path); - } - } - } - } - } - } - - #[cfg(target_os = "linux")] - { - push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/local/bin")); - push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/bin")); - } - - #[cfg(target_os = "windows")] - { - if let Some(appdata) = dirs::data_dir() { - push_unique_path(&mut search_paths, appdata.join("npm")); - if tool == "hermes" { - let python_base = appdata.join("Python"); - if let Ok(entries) = std::fs::read_dir(&python_base) { - for entry in entries.flatten() { - let scripts_path = entry.path().join("Scripts"); - if scripts_path.exists() { - push_unique_path(&mut search_paths, scripts_path); - } - } - } - } - } - if tool == "hermes" { - if let Some(local_data) = dirs::data_local_dir() { - let programs_python = local_data.join("Programs").join("Python"); - if let Ok(entries) = std::fs::read_dir(&programs_python) { - for entry in entries.flatten() { - let scripts_path = entry.path().join("Scripts"); - if scripts_path.exists() { - push_unique_path(&mut search_paths, scripts_path); - } - } - } - } - } - push_unique_path(&mut search_paths, std::path::PathBuf::from("C:\\Program Files\\nodejs")); - if let Some(home) = home.as_ref() { - extend_windows_cli_manager_search_paths(&mut search_paths, home); - } - } - - if tool == "opencode" { - let empty_home = Path::new(""); - let extra_paths = opencode_extra_search_paths( - home.as_deref().unwrap_or(empty_home), - std::env::var_os("OPENCODE_INSTALL_DIR"), - std::env::var_os("XDG_BIN_DIR"), - std::env::var_os("GOPATH"), - ); - for path in extra_paths { - push_unique_path(&mut search_paths, path); - } - } - - extend_from_cli_path_env(&mut search_paths, std::env::var_os("PATH")); - search_paths - } - ''', - "CLI discovery HOME fallback", - ) - - # OpenCode session roots are fallible. Invalid/missing HOME must be surfaced through the - # session API rather than panicking. Existing SQLite errors are also observable. - replace_between( - "src-tauri/src/session_manager/providers/opencode.rs", - "pub(crate) fn get_opencode_base_dir() -> PathBuf {", - "\n/// Parse a SQLite source reference", - '''fn try_get_opencode_base_dir() -> Result { - if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { - let xdg = PathBuf::from(xdg.trim()); - if xdg.is_absolute() { - return Ok(xdg.join("opencode")); - } - if !xdg.as_os_str().is_empty() { - log::warn!( - "Ignoring relative XDG_DATA_HOME for OpenCode discovery: {}", - xdg.display() - ); - } - } - Ok(crate::config::try_get_home_dir()?.join(".local/share/opencode")) - } - - /// Return the OpenCode JSON storage directory (legacy flat-file layout). - pub(crate) fn get_opencode_data_dir() -> Result { - Ok(try_get_opencode_base_dir()?.join("storage")) - } - - fn get_opencode_db_path() -> Result { - Ok(try_get_opencode_base_dir()?.join("opencode.db")) - } - - /// Scan sessions from both the legacy JSON files and the newer SQLite database, - /// merging results with SQLite taking precedence on ID conflicts. - pub fn scan_sessions() -> Result, String> { - let json_sessions = scan_sessions_json()?; - let sqlite_sessions = scan_sessions_sqlite()?; - - if sqlite_sessions.is_empty() { - return Ok(json_sessions); - } - if json_sessions.is_empty() { - return Ok(sqlite_sessions); - } - - let sqlite_ids: std::collections::HashSet = sqlite_sessions - .iter() - .map(|s| s.session_id.clone()) - .collect(); - let mut merged = sqlite_sessions; - for session in json_sessions { - if !sqlite_ids.contains(&session.session_id) { - merged.push(session); - } - } - Ok(merged) - } - - fn scan_sessions_json() -> Result, String> { - let storage = get_opencode_data_dir()?; - let session_dir = storage.join("session"); - if !session_dir.exists() { - return Ok(Vec::new()); - } - - let mut json_files = Vec::new(); - collect_json_files(&session_dir, &mut json_files); - let mut sessions = Vec::new(); - for path in json_files { - if let Some(meta) = parse_session(&storage, &path) { - sessions.push(meta); - } - } - Ok(sessions) - } - ''', - "OpenCode fallible roots and JSON scan", - ) - replace_between( - "src-tauri/src/session_manager/providers/opencode.rs", - "fn scan_sessions_sqlite() -> Vec {", - "\npub fn load_messages(path: &Path)", - '''fn scan_sessions_sqlite() -> Result, String> { - let db_path = get_opencode_db_path()?; - if !db_path.exists() { - return Ok(Vec::new()); - } - - let conn = Connection::open_with_flags( - &db_path, - rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) - .map_err(|err| format!("Failed to open OpenCode session database {}: {err}", db_path.display()))?; - - let mut stmt = conn - .prepare( - "SELECT id, title, directory, time_created, time_updated FROM session ORDER BY time_updated DESC", - ) - .map_err(|err| format!("Failed to prepare OpenCode session query: {err}"))?; - - let db_display = db_path.display().to_string(); - let iter = stmt - .query_map([], |row| { - let session_id: String = row.get(0)?; - let title: String = row.get(1)?; - let directory: String = row.get(2)?; - let created: i64 = row.get(3)?; - let updated: i64 = row.get(4)?; - Ok((session_id, title, directory, created, updated)) - }) - .map_err(|err| format!("Failed to query OpenCode sessions: {err}"))?; - - let mut sessions = Vec::new(); - for row in iter { - let (session_id, title, directory, created, updated) = row - .map_err(|err| format!("Failed to decode OpenCode session row: {err}"))?; - let display_title = if title.is_empty() { - path_basename(&directory) - } else { - Some(title) - }; - sessions.push(SessionMeta { - provider_id: PROVIDER_ID.to_string(), - session_id: session_id.clone(), - title: display_title.clone(), - summary: display_title, - project_dir: if directory.is_empty() { None } else { Some(directory) }, - created_at: Some(created), - last_active_at: Some(updated), - source_path: Some(format!("sqlite:{db_display}:{session_id}")), - resume_command: Some(format!("opencode session resume {session_id}")), - }); - } - Ok(sessions) - } - ''', - "OpenCode observable SQLite scan", - ) - replace_once( - "src-tauri/src/session_manager/providers/opencode.rs", - ''' let expected_db_path = get_opencode_db_path() - .canonicalize() - ''', - ''' let expected_db_path = get_opencode_db_path()? - .canonicalize() - ''', - "OpenCode delete expected root", - ) - - # Session aggregation must not silently convert a provider panic into an empty provider. - replace_between( - "src-tauri/src/session_manager/mod.rs", - "pub fn scan_sessions() -> Vec {", - "\npub fn load_messages", - '''pub fn scan_sessions() -> Result, String> { - let (r1, r2, r3, r4, r5, r6) = std::thread::scope(|s| -> Result<_, String> { - let h1 = s.spawn(codex::scan_sessions); - let h2 = s.spawn(claude::scan_sessions); - let h3 = s.spawn(opencode::scan_sessions); - let h4 = s.spawn(openclaw::scan_sessions); - let h5 = s.spawn(gemini::scan_sessions); - let h6 = s.spawn(hermes::scan_sessions); - - let r1 = h1.join().map_err(|_| "Codex session scan panicked".to_string())?; - let r2 = h2.join().map_err(|_| "Claude session scan panicked".to_string())?; - let r3 = h3 - .join() - .map_err(|_| "OpenCode session scan panicked".to_string())??; - let r4 = h4.join().map_err(|_| "OpenClaw session scan panicked".to_string())?; - let r5 = h5.join().map_err(|_| "Gemini session scan panicked".to_string())?; - let r6 = h6.join().map_err(|_| "Hermes session scan panicked".to_string())?; - Ok((r1, r2, r3, r4, r5, r6)) - })?; - - let mut sessions = Vec::new(); - sessions.extend(r1); - sessions.extend(r2); - sessions.extend(r3); - sessions.extend(r4); - sessions.extend(r5); - sessions.extend(r6); - sessions.sort_by(|a, b| { - let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0); - let b_ts = b.last_active_at.or(b.created_at).unwrap_or(0); - b_ts.cmp(&a_ts) - }); - Ok(sessions) - } - ''', - "session panic propagation", - ) - replace_once( - "src-tauri/src/session_manager/mod.rs", - ''' "opencode" => vec![opencode::get_opencode_data_dir()],''', - ''' "opencode" => vec![opencode::get_opencode_data_dir()?],''', - "OpenCode deletion root propagation", - ) - replace_once( - "src-tauri/src/commands/session_manager.rs", - '''pub async fn list_sessions() -> Result, String> { - let sessions = tauri::async_runtime::spawn_blocking(session_manager::scan_sessions) - .await - .map_err(|e| format!("Failed to scan sessions: {e}"))?; - Ok(sessions) - } - ''', - '''pub async fn list_sessions() -> Result, String> { - tauri::async_runtime::spawn_blocking(session_manager::scan_sessions) - .await - .map_err(|e| format!("Failed to scan sessions task: {e}"))? - } - ''', - "session command error propagation", - ) - PY - - - name: Encode design invariants in permanent policy - run: | - python - <<'PY' - from pathlib import Path - path = Path("scripts/check_rust_failure_boundaries.py") - text = path.read_text(encoding="utf-8") - - marker = "]\n\nfailures = []" - if text.count(marker) != 1: - raise SystemExit(f"FILE_CHECKS terminator count={text.count(marker)}") - extra_checks = """ ("services/model_fetch.rs", re.compile(r'HTTP \\{status\\}: \\{body\\}'), "model-discovery errors must not expose raw upstream response bodies"), - ("app_store.rs", re.compile(r"fn read_override_from_store\\([^)]*\\) -> Option"), "Store read failure must not collapse into an absent app_config_dir override"), - ("app_store.rs", re.compile(r"fn resolve_path\\(raw: &str\\) -> PathBuf"), "app_config_dir parsing must be fallible and reject relative persistence roots"), - ("settings.rs", re.compile(r"fn settings_path\\(\\) -> Option"), "settings path resolution must expose HOME failures instead of a fake Option contract"), - ("services/skill.rs", re.compile(r"(?:get_app_config_dir|crate::config::get_home_dir)\\(\\)"), "Skill Result APIs must propagate fallible persistence roots instead of panicking"), - ("commands/misc.rs", re.compile(r"let home = crate::config::get_home_dir\\(\\);"), "CLI discovery must degrade without HOME instead of panicking"), - ("session_manager/mod.rs", re.compile(r"join\\(\\)\\.unwrap_or_default\\(\\)"), "session worker panics must be observable, not converted to empty results"), - ("session_manager/providers/opencode.rs", re.compile(r"crate::config::get_home_dir\\(\\)"), "OpenCode session path resolution must be fallible"), - """ - text = text.replace(marker, extra_checks + "]\n\nfailures = []", 1) - - anchor = """# User-home resolution is a persistence boundary shared by DB/config/backup/CLI paths. A direct - # dirs::home_dir() call elsewhere can silently re-introduce CWD/relative fallback semantics or - # diverge from the validated CC_SWITCH_TEST_HOME behavior, so keep one common implementation. - """ - if text.count(anchor) != 1: - raise SystemExit(f"home policy anchor count={text.count(anchor)}") - extra_guard = """# Persistence roots must never accept a process-relative Store/settings override. These patterns - # previously bypassed the HOME guard while still binding DB/config state to the launch CWD. - config_text = (RUST_ROOT / "config.rs").read_text(encoding="utf-8") - if 'let legacy_dir = PathBuf::from(trimmed).join(".cc-switch")' in config_text: - failures.append( - "src-tauri/src/config.rs: Windows legacy HOME must be checked as absolute before DB fallback" - ) - - """ - text = text.replace(anchor, extra_guard + anchor, 1) - path.write_text(text, encoding="utf-8") - PY + - name: Apply fallible caller and discovery invariants + run: python scripts/apply_design_invariant_followup_once.py - name: Install Linux system dependencies run: | @@ -517,7 +54,7 @@ jobs: - name: Format Rust run: cargo fmt --manifest-path src-tauri/Cargo.toml --all - - name: Run permanent policy guards + - name: Verify permanent design policies run: | python scripts/check_workflow_shell_interpolation.py python scripts/check_rust_failure_boundaries.py @@ -535,24 +72,15 @@ jobs: - name: Rust tests all features run: cargo test --manifest-path src-tauri/Cargo.toml --all-features - - name: Commit verified design fixes + - name: Commit verified design fixes and remove one-shot machinery run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add \ - src-tauri/src/config.rs \ - src-tauri/src/app_store.rs \ - src-tauri/src/settings.rs \ - src-tauri/src/lib.rs \ - src-tauri/src/database/mod.rs \ - src-tauri/src/services/model_fetch.rs \ - src-tauri/src/commands/settings.rs \ - src-tauri/src/services/skill.rs \ - src-tauri/src/commands/misc.rs \ - src-tauri/src/session_manager/mod.rs \ - src-tauri/src/session_manager/providers/opencode.rs \ - src-tauri/src/commands/session_manager.rs \ - scripts/check_rust_failure_boundaries.py + git rm \ + scripts/apply_design_invariant_hardening_once.py \ + scripts/apply_design_invariant_followup_once.py \ + .github/workflows/design-invariant-hardening-once.yml + git add -A git diff --cached --check git commit -m "fix: enforce persistence and diagnostic invariants" git push origin HEAD:fix/global-hardening-20260904 From 3cfef8b326f2bddec0c4199ef555d7fc34f41314 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 14:16:35 +0800 Subject: [PATCH 068/112] ci: align structural follow-up with both skill HOME callers --- .../workflows/design-invariant-hardening-once.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/design-invariant-hardening-once.yml b/.github/workflows/design-invariant-hardening-once.yml index 4c6af5465c6..60df47dcdbc 100644 --- a/.github/workflows/design-invariant-hardening-once.yml +++ b/.github/workflows/design-invariant-hardening-once.yml @@ -37,6 +37,19 @@ jobs: - name: Apply common product invariants run: python scripts/apply_design_invariant_hardening_once.py + - name: Align structural follow-up with both Skill HOME callers + run: | + python - <<'PY' + from pathlib import Path + path = Path("scripts/apply_design_invariant_followup_once.py") + text = path.read_text(encoding="utf-8") + old = ' "skill app HOME",\n)\n' + new = ' "skill app HOME",\n expected=2,\n)\n' + if text.count(old) != 1: + raise SystemExit(f"skill HOME expectation marker count={text.count(old)}") + path.write_text(text.replace(old, new, 1), encoding="utf-8") + PY + - name: Apply fallible caller and discovery invariants run: python scripts/apply_design_invariant_followup_once.py From 3235bc5ce32f63e5e1ccfccb7fbeed122e0c21c3 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 14:19:28 +0800 Subject: [PATCH 069/112] ci: normalize Windows raw path in design hardening --- .../workflows/design-invariant-hardening-once.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/design-invariant-hardening-once.yml b/.github/workflows/design-invariant-hardening-once.yml index 60df47dcdbc..ea147391792 100644 --- a/.github/workflows/design-invariant-hardening-once.yml +++ b/.github/workflows/design-invariant-hardening-once.yml @@ -53,6 +53,19 @@ jobs: - name: Apply fallible caller and discovery invariants run: python scripts/apply_design_invariant_followup_once.py + - name: Normalize Windows path literal emitted by follow-up + run: | + python - <<'PY' + from pathlib import Path + path = Path("src-tauri/src/commands/misc.rs") + text = path.read_text(encoding="utf-8") + old = r'std::path::PathBuf::from("C:\Program Files\nodejs")' + new = r'std::path::PathBuf::from(r"C:\Program Files\nodejs")' + if text.count(old) != 1: + raise SystemExit(f"Windows nodejs literal count={text.count(old)}") + path.write_text(text.replace(old, new, 1), encoding="utf-8") + PY + - name: Install Linux system dependencies run: | sudo apt-get update From e53c6fcd309f8968a6be418164179e2b14eec97b Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 14:21:46 +0800 Subject: [PATCH 070/112] ci: make Skill invariant guard exact --- .../workflows/design-invariant-hardening-once.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/design-invariant-hardening-once.yml b/.github/workflows/design-invariant-hardening-once.yml index ea147391792..d3bdec219ed 100644 --- a/.github/workflows/design-invariant-hardening-once.yml +++ b/.github/workflows/design-invariant-hardening-once.yml @@ -66,6 +66,19 @@ jobs: path.write_text(text.replace(old, new, 1), encoding="utf-8") PY + - name: Make Skill persistence guard reject only infallible wrappers + run: | + python - <<'PY' + from pathlib import Path + path = Path("scripts/check_rust_failure_boundaries.py") + text = path.read_text(encoding="utf-8") + old = r're.compile(r"(?:get_app_config_dir|crate::config::get_home_dir)\(\)")' + new = r're.compile(r"\bget_app_config_dir\(\)|crate::config::get_home_dir\(\)")' + if text.count(old) != 1: + raise SystemExit(f"Skill guard matcher count={text.count(old)}") + path.write_text(text.replace(old, new, 1), encoding="utf-8") + PY + - name: Install Linux system dependencies run: | sudo apt-get update From 4193589fdfe5e0ec3d8bf9412d00954cf0f2c726 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 14:27:10 +0800 Subject: [PATCH 071/112] ci: verify OpenCode scan failure propagation --- .../design-invariant-hardening-once.yml | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/.github/workflows/design-invariant-hardening-once.yml b/.github/workflows/design-invariant-hardening-once.yml index d3bdec219ed..4e8b77ae3ac 100644 --- a/.github/workflows/design-invariant-hardening-once.yml +++ b/.github/workflows/design-invariant-hardening-once.yml @@ -79,6 +79,54 @@ jobs: path.write_text(text.replace(old, new, 1), encoding="utf-8") PY + - name: Align OpenCode tests with fallible scan contract + run: | + python - <<'PY' + from pathlib import Path + path = Path("src-tauri/src/session_manager/providers/opencode.rs") + text = path.read_text(encoding="utf-8") + old = " let sessions = scan_sessions_sqlite();\n" + new = " let sessions = scan_sessions_sqlite().expect(\"scan sqlite sessions\");\n" + if text.count(old) != 1: + raise SystemExit(f"OpenCode successful scan test count={text.count(old)}") + text = text.replace(old, new, 1) + + anchor = " #[test]\n fn load_messages_sqlite_reads_messages_and_parts() {\n" + if text.count(anchor) != 1: + raise SystemExit(f"OpenCode failure-test anchor count={text.count(anchor)}") + failure_test = ''' #[test] + #[allow(deprecated)] + fn scan_sessions_sqlite_surfaces_schema_errors() { + let _guard = opencode_env_lock().lock().expect("lock"); + let temp = tempdir().expect("tempdir"); + let original_xdg = std::env::var_os("XDG_DATA_HOME"); + std::env::set_var("XDG_DATA_HOME", temp.path()); + + let base_dir = temp.path().join("opencode"); + std::fs::create_dir_all(&base_dir).expect("create base dir"); + let db_path = base_dir.join("opencode.db"); + let conn = Connection::open(&db_path).expect("open sqlite db"); + conn.execute_batch("CREATE TABLE unrelated (id TEXT PRIMARY KEY);") + .expect("create incompatible schema"); + drop(conn); + + let result = scan_sessions_sqlite(); + + if let Some(value) = original_xdg { + std::env::set_var("XDG_DATA_HOME", value); + } else { + std::env::remove_var("XDG_DATA_HOME"); + } + + let err = result.expect_err("missing session table must be observable"); + assert!(err.contains("Failed to prepare OpenCode session query"), "{err}"); + } + + ''' + text = text.replace(anchor, failure_test + anchor, 1) + path.write_text(text, encoding="utf-8") + PY + - name: Install Linux system dependencies run: | sudo apt-get update From 88f46dc771d44c7be250d104f33ddf5050ab4177 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 14:33:51 +0800 Subject: [PATCH 072/112] ci: encode Hermes fallible path boundary --- scripts/apply_hermes_failure_boundary_once.py | 365 ++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 scripts/apply_hermes_failure_boundary_once.py diff --git a/scripts/apply_hermes_failure_boundary_once.py b/scripts/apply_hermes_failure_boundary_once.py new file mode 100644 index 00000000000..0d88f2244f1 --- /dev/null +++ b/scripts/apply_hermes_failure_boundary_once.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +from pathlib import Path + + +def read(path: str) -> str: + return Path(path).read_text(encoding="utf-8") + + +def write(path: str, text: str) -> None: + Path(path).write_text(text, encoding="utf-8") + + +def replace_once(path: str, old: str, new: str, label: str, expected: int = 1) -> None: + text = read(path) + count = text.count(old) + if count != expected: + raise SystemExit(f"{label}: expected {expected}, found {count}") + write(path, text.replace(old, new)) + + +def replace_region(path: str, start: str, end: str, new: str, label: str) -> None: + text = read(path) + if text.count(start) != 1: + raise SystemExit(f"{label}: start count={text.count(start)}") + i = text.index(start) + try: + j = text.index(end, i) + except ValueError: + raise SystemExit(f"{label}: end marker missing") + write(path, text[:i] + new + text[j:]) + + +# --------------------------------------------------------------------------- +# Hermes configuration root: fallible API is the production boundary. +# Test-only compatibility wrappers may panic, production Result APIs may not. +# --------------------------------------------------------------------------- +path = "src-tauri/src/hermes_config.rs" +replace_once( + path, + "use crate::config::{atomic_write, get_app_config_dir};\n", + "use crate::config::atomic_write;\n", + "Hermes obsolete infallible app-root import", +) +replace_region( + path, + "/// 获取 Hermes 配置目录\n", + "fn hermes_write_lock() -> &'static Mutex<()> {", + '''/// Resolve the Hermes configuration root without hiding HOME failure behind a panic. +/// +/// Resolution order matches Hermes, but every accepted root is absolute: +/// 1. validated CC Switch `hermes_config_dir` override; +/// 2. absolute `HERMES_HOME`; +/// 3. platform default rooted in an absolute user home / LOCALAPPDATA. +pub fn try_get_hermes_dir() -> Result { + if let Some(override_dir) = get_hermes_override_dir() { + if override_dir.is_absolute() { + return Ok(override_dir); + } + return Err(AppError::Config(format!( + "hermes_config_dir must be absolute: {}", + override_dir.display() + ))); + } + + if let Some(raw) = std::env::var_os("HERMES_HOME") { + let value = raw.to_string_lossy(); + let trimmed = value.trim(); + if !trimmed.is_empty() { + let path = PathBuf::from(trimmed); + if path.is_absolute() { + return Ok(path); + } + log::warn!("Ignoring relative HERMES_HOME: {}", path.display()); + } + } + + default_hermes_dir() +} + +#[cfg(target_os = "windows")] +fn default_hermes_dir() -> Result { + let home = crate::config::try_get_home_dir().map_err(AppError::Config)?; + Ok(windows_local_hermes_dir( + std::env::var_os("LOCALAPPDATA").as_deref(), + &home, + )) +} + +#[cfg(not(target_os = "windows"))] +fn default_hermes_dir() -> Result { + Ok(crate::config::try_get_home_dir() + .map_err(AppError::Config)? + .join(".hermes")) +} + +#[cfg(any(target_os = "windows", test))] +fn windows_local_hermes_dir(localappdata: Option<&std::ffi::OsStr>, home: &Path) -> PathBuf { + localappdata + .map(|value| PathBuf::from(value.to_string_lossy().trim().to_string())) + .filter(|path| path.is_absolute()) + .unwrap_or_else(|| home.join("AppData").join("Local")) + .join("hermes") +} + +pub fn try_get_hermes_config_path() -> Result { + Ok(try_get_hermes_dir()?.join("config.yaml")) +} + +// Tests deliberately control CC_SWITCH_TEST_HOME and may use concise path helpers. +// Production code must use the fallible functions above. +#[cfg(test)] +pub fn get_hermes_dir() -> PathBuf { + try_get_hermes_dir().expect("Hermes test root") +} + +#[cfg(test)] +pub fn get_hermes_config_path() -> PathBuf { + try_get_hermes_config_path().expect("Hermes test config path") +} + +''', + "Hermes fallible root API", +) +replace_once( + path, + " let path = get_hermes_config_path();\n", + " let path = try_get_hermes_config_path()?;\n", + "Hermes read config path", +) +replace_once( + path, + ' let backup_dir = get_app_config_dir().join("backups").join("hermes");\n', + ''' let backup_dir = crate::config::try_get_app_config_dir() + .map_err(AppError::Config)? + .join("backups") + .join("hermes"); +''', + "Hermes backup app root", +) +replace_once( + path, + " let config_path = get_hermes_config_path();\n", + " let config_path = try_get_hermes_config_path()?;\n", + "Hermes write config path", +) +replace_region( + path, + "fn memories_dir() -> PathBuf {", + "\n/// Read a Hermes memory file as a markdown blob.", + '''fn memories_dir() -> Result { + Ok(try_get_hermes_dir()?.join("memories")) +} +''', + "Hermes memory root", +) +replace_once( + path, + " let path = memories_dir().join(kind.filename());\n", + " let path = memories_dir()?.join(kind.filename());\n", + "Hermes memory path propagation", + expected=2, +) + +# Permanent checks should make regression to production panic wrappers impossible. +guard = "scripts/check_rust_failure_boundaries.py" +text = read(guard) +marker = "]\n\nfailures = []" +if text.count(marker) != 1: + raise SystemExit(f"Hermes guard marker count={text.count(marker)}") +checks = ''' ("hermes_config.rs", re.compile(r"pub fn read_hermes_config\\([^)]*\\) -> Result[\\s\\S]{0,240}get_hermes_config_path\\(\\)"), "Hermes config reads must propagate root resolution errors"), + ("hermes_config.rs", re.compile(r"let config_path = get_hermes_config_path\\(\\);"), "Hermes config writes must propagate root resolution errors"), + ("hermes_config.rs", re.compile(r"let backup_dir = get_app_config_dir\\(\\)"), "Hermes backup persistence must not hide app-root failures"), + ("session_manager/providers/hermes.rs", re.compile(r"use crate::hermes_config::get_hermes_dir"), "Hermes session discovery must use the fallible root API"), +''' +text = text.replace(marker, checks + marker, 1) +write(guard, text) + + +# --------------------------------------------------------------------------- +# Skill is already a Result API: Hermes-specific root must propagate naturally. +# --------------------------------------------------------------------------- +replace_once( + "src-tauri/src/services/skill.rs", + 'AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"),', + '''AppType::Hermes => crate::hermes_config::try_get_hermes_dir() + .map_err(|err| anyhow!(err))? + .join("skills"),''', + "Skill Hermes root propagation", +) + + +# --------------------------------------------------------------------------- +# Hermes session discovery: no hidden HOME panic and no empty-list conversion +# for SQLite/open/query/read_dir failures. Missing DB/table/dir remain legitimate +# empty states; malformed individual JSONL files are warned and skipped. +# --------------------------------------------------------------------------- +path = "src-tauri/src/session_manager/providers/hermes.rs" +replace_once( + path, + "use crate::hermes_config::get_hermes_dir;\n", + "use crate::hermes_config::try_get_hermes_dir;\n", + "Hermes session fallible import", +) +replace_region( + path, + "fn get_hermes_db_path() -> PathBuf {", + "\nfn sqlite_row_to_session_meta", + '''fn get_hermes_db_path() -> Result { + Ok(try_get_hermes_dir() + .map_err(|err| err.to_string())? + .join("state.db")) +} + +/// Scan sessions from both SQLite database and JSONL transcript files, +/// with SQLite taking precedence on ID conflicts. +pub fn scan_sessions() -> Result, String> { + let root = try_get_hermes_dir().map_err(|err| err.to_string())?; + let sqlite_sessions = scan_sessions_sqlite(&root.join("state.db"))?; + let jsonl_sessions = scan_sessions_jsonl(&root.join("sessions"))?; + + if sqlite_sessions.is_empty() { + return Ok(jsonl_sessions); + } + if jsonl_sessions.is_empty() { + return Ok(sqlite_sessions); + } + + let sqlite_ids: std::collections::HashSet = sqlite_sessions + .iter() + .map(|session| session.session_id.clone()) + .collect(); + let mut merged = sqlite_sessions; + for session in jsonl_sessions { + if !sqlite_ids.contains(&session.session_id) { + merged.push(session); + } + } + Ok(merged) +} + +// ── SQLite scanning ───────────────────────────────────────────────── + +fn scan_sessions_sqlite(db_path: &Path) -> Result, String> { + if !db_path.exists() { + return Ok(Vec::new()); + } + + let conn = Connection::open_with_flags( + db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .map_err(|err| format!("Failed to open Hermes session database {}: {err}", db_path.display()))?; + + let has_sessions: bool = conn + .query_row( + "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='sessions'", + [], + |row| row.get(0), + ) + .map_err(|err| format!("Failed to inspect Hermes session schema: {err}"))?; + if !has_sessions { + return Ok(Vec::new()); + } + + let columns = get_table_columns(&conn, "sessions")?; + let mut stmt = conn + .prepare("SELECT * FROM sessions ORDER BY rowid DESC LIMIT 500") + .map_err(|err| format!("Failed to prepare Hermes session query: {err}"))?; + let rows = stmt + .query_map([], |row| Ok(row_to_json(row, &columns))) + .map_err(|err| format!("Failed to query Hermes sessions: {err}"))?; + + let db_source = format!("sqlite:{}", db_path.display()); + let mut sessions = Vec::new(); + for row_result in rows { + let row = row_result.map_err(|err| format!("Failed to decode Hermes session row: {err}"))?; + match sqlite_row_to_session_meta(&row, &db_source) { + Some(meta) => sessions.push(meta), + None => log::warn!("Skipping malformed Hermes SQLite session row without a usable id"), + } + } + Ok(sessions) +} + +''', + "Hermes session root and SQLite observability", +) +replace_region( + path, + "fn get_table_columns(conn: &Connection, table: &str) -> Vec {", + "\n/// Convert a SQLite row to a JSON Value", + '''fn get_table_columns(conn: &Connection, table: &str) -> Result, String> { + let query = format!("PRAGMA table_info({table})"); + let mut stmt = conn + .prepare(&query) + .map_err(|err| format!("Failed to inspect Hermes table columns: {err}"))?; + let rows = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|err| format!("Failed to query Hermes table columns: {err}"))?; + let mut columns = Vec::new(); + for row in rows { + columns.push(row.map_err(|err| format!("Failed to decode Hermes table column: {err}"))?); + } + Ok(columns) +} + +''', + "Hermes table-column observability", +) +replace_once( + path, + " let expected_db_path = get_hermes_db_path()\n", + " let expected_db_path = get_hermes_db_path()?\n", + "Hermes delete expected DB root", +) +replace_region( + path, + "fn scan_sessions_jsonl() -> Vec {", + "\nfn parse_jsonl_session(path: &Path)", + '''fn scan_sessions_jsonl(sessions_dir: &Path) -> Result, String> { + if !sessions_dir.exists() { + return Ok(Vec::new()); + } + + let entries = std::fs::read_dir(sessions_dir) + .map_err(|err| format!("Failed to read Hermes sessions directory {}: {err}", sessions_dir.display()))?; + let mut sessions = Vec::new(); + for entry in entries { + let entry = entry.map_err(|err| format!("Failed to enumerate Hermes session entry: {err}"))?; + let path = entry.path(); + let ext = path.extension().and_then(|ext| ext.to_str()); + if ext != Some("jsonl") && ext != Some("json") { + continue; + } + match parse_jsonl_session(&path) { + Some(meta) => sessions.push(meta), + None => log::warn!("Skipping malformed or unreadable Hermes session file: {}", path.display()), + } + } + Ok(sessions) +} + +''', + "Hermes JSONL discovery observability", +) + +# Aggregator already returns Result after the structural follow-up; Hermes now joins like OpenCode. +replace_once( + "src-tauri/src/session_manager/mod.rs", + ''' let r6 = h6.join().map_err(|_| "Hermes session scan panicked".to_string())?;''', + ''' let r6 = h6 + .join() + .map_err(|_| "Hermes session scan panicked".to_string())??;''', + "Hermes session Result propagation", +) +replace_once( + "src-tauri/src/session_manager/mod.rs", + '"hermes" => vec![crate::hermes_config::get_hermes_dir().join("sessions")],', + '''"hermes" => vec![crate::hermes_config::try_get_hermes_dir() + .map_err(|err| err.to_string())? + .join("sessions")],''', + "Hermes deletion root propagation", +) + +print("Applied Hermes fallible failure boundary") From 84b9c8fb2f52d9c0f1e4d99891a8eda5cab0ab7f Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 14:34:25 +0800 Subject: [PATCH 073/112] ci: verify Hermes fallible failure boundary --- .../workflows/design-invariant-hardening-once.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/design-invariant-hardening-once.yml b/.github/workflows/design-invariant-hardening-once.yml index 4e8b77ae3ac..967d5c2590d 100644 --- a/.github/workflows/design-invariant-hardening-once.yml +++ b/.github/workflows/design-invariant-hardening-once.yml @@ -8,6 +8,7 @@ on: - .github/workflows/design-invariant-hardening-once.yml - scripts/apply_design_invariant_hardening_once.py - scripts/apply_design_invariant_followup_once.py + - scripts/apply_hermes_failure_boundary_once.py permissions: contents: write @@ -90,7 +91,6 @@ jobs: if text.count(old) != 1: raise SystemExit(f"OpenCode successful scan test count={text.count(old)}") text = text.replace(old, new, 1) - anchor = " #[test]\n fn load_messages_sqlite_reads_messages_and_parts() {\n" if text.count(anchor) != 1: raise SystemExit(f"OpenCode failure-test anchor count={text.count(anchor)}") @@ -101,7 +101,6 @@ jobs: let temp = tempdir().expect("tempdir"); let original_xdg = std::env::var_os("XDG_DATA_HOME"); std::env::set_var("XDG_DATA_HOME", temp.path()); - let base_dir = temp.path().join("opencode"); std::fs::create_dir_all(&base_dir).expect("create base dir"); let db_path = base_dir.join("opencode.db"); @@ -109,24 +108,23 @@ jobs: conn.execute_batch("CREATE TABLE unrelated (id TEXT PRIMARY KEY);") .expect("create incompatible schema"); drop(conn); - let result = scan_sessions_sqlite(); - if let Some(value) = original_xdg { std::env::set_var("XDG_DATA_HOME", value); } else { std::env::remove_var("XDG_DATA_HOME"); } - let err = result.expect_err("missing session table must be observable"); assert!(err.contains("Failed to prepare OpenCode session query"), "{err}"); } ''' - text = text.replace(anchor, failure_test + anchor, 1) - path.write_text(text, encoding="utf-8") + path.write_text(text.replace(anchor, failure_test + anchor, 1), encoding="utf-8") PY + - name: Apply Hermes fallible failure boundary + run: python scripts/apply_hermes_failure_boundary_once.py + - name: Install Linux system dependencies run: | sudo apt-get update @@ -166,6 +164,7 @@ jobs: git rm \ scripts/apply_design_invariant_hardening_once.py \ scripts/apply_design_invariant_followup_once.py \ + scripts/apply_hermes_failure_boundary_once.py \ .github/workflows/design-invariant-hardening-once.yml git add -A git diff --cached --check From 128d8b7267bde5c4e4f6195bec362f2cfe05aafe Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 14:35:50 +0800 Subject: [PATCH 074/112] ci: scope Hermes write-path transform to production --- .../design-invariant-hardening-once.yml | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.github/workflows/design-invariant-hardening-once.yml b/.github/workflows/design-invariant-hardening-once.yml index 967d5c2590d..b1802c230a2 100644 --- a/.github/workflows/design-invariant-hardening-once.yml +++ b/.github/workflows/design-invariant-hardening-once.yml @@ -122,6 +122,39 @@ jobs: path.write_text(text.replace(anchor, failure_test + anchor, 1), encoding="utf-8") PY + - name: Scope Hermes config-write transform to production helper + run: | + python - <<'PY' + from pathlib import Path + path = Path("scripts/apply_hermes_failure_boundary_once.py") + text = path.read_text(encoding="utf-8") + old = '''replace_once( + path, + " let config_path = get_hermes_config_path();\\n", + " let config_path = try_get_hermes_config_path()?;\\n", + "Hermes write config path", + )''' + new = '''replace_once( + path, + """fn write_yaml_section_to_config_locked( + section_key: &str, + value: &serde_yaml::Value, + ) -> Result { + let config_path = get_hermes_config_path(); + """, + """fn write_yaml_section_to_config_locked( + section_key: &str, + value: &serde_yaml::Value, + ) -> Result { + let config_path = try_get_hermes_config_path()?; + """, + "Hermes write config path", + )''' + if text.count(old) != 1: + raise SystemExit(f"Hermes write-transform driver count={text.count(old)}") + path.write_text(text.replace(old, new, 1), encoding="utf-8") + PY + - name: Apply Hermes fallible failure boundary run: python scripts/apply_hermes_failure_boundary_once.py From bdeea57c4ffed1dd1ad8631c0ab17836c724fb48 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 14:38:51 +0800 Subject: [PATCH 075/112] ci: scope Hermes failure guards to production APIs --- .../design-invariant-hardening-once.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/design-invariant-hardening-once.yml b/.github/workflows/design-invariant-hardening-once.yml index b1802c230a2..487feb1c98d 100644 --- a/.github/workflows/design-invariant-hardening-once.yml +++ b/.github/workflows/design-invariant-hardening-once.yml @@ -158,6 +158,24 @@ jobs: - name: Apply Hermes fallible failure boundary run: python scripts/apply_hermes_failure_boundary_once.py + - name: Scope Hermes guards to production function bodies + run: | + python - <<'PY' + from pathlib import Path + path = Path("scripts/check_rust_failure_boundaries.py") + text = path.read_text(encoding="utf-8") + old_read = r're.compile(r"pub fn read_hermes_config\([^)]*\) -> Result[\s\S]{0,240}get_hermes_config_path\(\)")' + new_read = r're.compile(r"pub fn read_hermes_config\(\) -> Result \{\s*let path = get_hermes_config_path\(\);")' + old_write = r're.compile(r"let config_path = get_hermes_config_path\(\);")' + new_write = r're.compile(r"fn write_yaml_section_to_config_locked\([\s\S]{0,220}\) -> Result \{\s*let config_path = get_hermes_config_path\(\);")' + if text.count(old_read) != 1: + raise SystemExit(f"Hermes read guard count={text.count(old_read)}") + if text.count(old_write) != 1: + raise SystemExit(f"Hermes write guard count={text.count(old_write)}") + text = text.replace(old_read, new_read, 1).replace(old_write, new_write, 1) + path.write_text(text, encoding="utf-8") + PY + - name: Install Linux system dependencies run: | sudo apt-get update From a30d554c1edab1719b17aa577bad03e6330cd1e0 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 15:28:14 +0800 Subject: [PATCH 076/112] ci: migrate Hermes fallible path callers --- scripts/apply_hermes_callers_once.py | 121 +++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 scripts/apply_hermes_callers_once.py diff --git a/scripts/apply_hermes_callers_once.py b/scripts/apply_hermes_callers_once.py new file mode 100644 index 00000000000..149bc0d6fbd --- /dev/null +++ b/scripts/apply_hermes_callers_once.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +from pathlib import Path + + +def read(path: str) -> str: + return Path(path).read_text(encoding="utf-8") + + +def write(path: str, text: str) -> None: + Path(path).write_text(text, encoding="utf-8") + + +def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) -> None: + text = read(path) + count = text.count(old) + if count != expected: + raise SystemExit(f"{label}: expected {expected}, found {count}") + write(path, text.replace(old, new)) + + +# --------------------------------------------------------------------------- +# Tauri config commands already return Result; Hermes root failures +# must be visible to the caller rather than recovered through a panic wrapper. +# --------------------------------------------------------------------------- +path = "src-tauri/src/commands/config.rs" +replace_exact( + path, + ''' AppType::Hermes => { + let config_path = crate::hermes_config::get_hermes_config_path(); + let exists = config_path.exists(); + let path = crate::hermes_config::get_hermes_dir() + .to_string_lossy() + .to_string(); + + Ok(ConfigStatus { exists, path }) + } +''', + ''' AppType::Hermes => { + let dir = crate::hermes_config::try_get_hermes_dir().map_err(|e| e.to_string())?; + let exists = dir.join("config.yaml").exists(); + let path = dir.to_string_lossy().to_string(); + + Ok(ConfigStatus { exists, path }) + } +''', + "Hermes config status root propagation", +) +replace_exact( + path, + " AppType::Hermes => crate::hermes_config::get_hermes_dir(),\n", + ''' AppType::Hermes => { + crate::hermes_config::try_get_hermes_dir().map_err(|e| e.to_string())? + } +''', + "Hermes config command root propagation", + expected=2, +) + +# --------------------------------------------------------------------------- +# Prompt-file path derivation is already fallible, so propagate the Hermes root. +# --------------------------------------------------------------------------- +replace_exact( + "src-tauri/src/prompt_files.rs", + " AppType::Hermes => crate::hermes_config::get_hermes_dir(),\n", + " AppType::Hermes => crate::hermes_config::try_get_hermes_dir()?,\n", + "Hermes prompt-file root propagation", +) + +# --------------------------------------------------------------------------- +# MCP synchronization writes user configuration. A missing/invalid root is a +# sync failure, not an 'Hermes is absent' signal, so preserve the Result boundary. +# --------------------------------------------------------------------------- +path = "src-tauri/src/mcp/hermes.rs" +replace_exact( + path, + '''fn should_sync_hermes_mcp() -> bool { + hermes_config::get_hermes_dir().exists() +} +''', + '''fn should_sync_hermes_mcp() -> Result { + Ok(hermes_config::try_get_hermes_dir()?.exists()) +} +''', + "Hermes MCP root propagation", +) +replace_exact( + path, + " if !should_sync_hermes_mcp() {\n", + " if !should_sync_hermes_mcp()? {\n", + "Hermes MCP sync caller propagation", +) + +# --------------------------------------------------------------------------- +# Live provider read/remove APIs already return AppError. Keep root-resolution +# errors observable rather than converting them into missing-config behavior. +# --------------------------------------------------------------------------- +path = "src-tauri/src/services/provider/live.rs" +replace_exact( + path, + " let config_path = crate::hermes_config::get_hermes_config_path();\n", + " let config_path = crate::hermes_config::try_get_hermes_config_path()?;\n", + "Hermes live-read config path propagation", +) +replace_exact( + path, + " if !hermes_config::get_hermes_dir().exists() {\n", + " if !hermes_config::try_get_hermes_dir()?.exists() {\n", + "Hermes live-remove root propagation", +) + +# --------------------------------------------------------------------------- +# Test contract: the platform-default helper is now fallible by design. +# --------------------------------------------------------------------------- +replace_exact( + "src-tauri/src/hermes_config.rs", + " assert_eq!(dir, default_hermes_dir());\n", + " assert_eq!(dir, default_hermes_dir().expect(\"default Hermes dir\"));\n", + "Hermes default-dir test fallible contract", +) + +print("Applied Hermes production caller migration") From 5db72d6c889035341d2c22dd375ae655a1ed56ba Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 15:28:56 +0800 Subject: [PATCH 077/112] ci: guard Hermes fallible caller boundary --- scripts/apply_hermes_callers_once.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/scripts/apply_hermes_callers_once.py b/scripts/apply_hermes_callers_once.py index 149bc0d6fbd..676ce7a1f18 100644 --- a/scripts/apply_hermes_callers_once.py +++ b/scripts/apply_hermes_callers_once.py @@ -118,4 +118,30 @@ def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) "Hermes default-dir test fallible contract", ) +# --------------------------------------------------------------------------- +# Permanent global guard: production modules must never reintroduce the test-only +# Hermes panic wrappers. hermes_config.rs itself is excluded because it owns the +# #[cfg(test)] compatibility helpers. +# --------------------------------------------------------------------------- +guard = "scripts/check_rust_failure_boundaries.py" +text = read(guard) +marker = '''# URL sanitization is a common diagnostics boundary. Specialized copies drift and caused raw +# deep-link/model-fetch paths to be missed; keep implementations centralized. +''' +if text.count(marker) != 1: + raise SystemExit(f"Hermes global guard anchor count={text.count(marker)}") +hermes_guard = '''# Hermes persistence/session roots are fallible production boundaries. The only infallible +# wrappers are #[cfg(test)] helpers owned by hermes_config.rs; no other module may call/import them. +for path in RUST_ROOT.rglob("*.rs"): + if path.name == "hermes_config.rs": + continue + text = path.read_text(encoding="utf-8") + if re.search(r"(?:crate::)?hermes_config::get_hermes_(?:dir|config_path)\\b", text): + failures.append( + f"{path.relative_to(ROOT)}: Hermes roots must use fallible try_get_hermes_* APIs" + ) + +''' +write(guard, text.replace(marker, hermes_guard + marker, 1)) + print("Applied Hermes production caller migration") From 8aae1c52fe40d8afd993ee11b982f80b41d10611 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 15:29:26 +0800 Subject: [PATCH 078/112] ci: verify Hermes callers through fallible boundary --- .github/workflows/design-invariant-hardening-once.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/design-invariant-hardening-once.yml b/.github/workflows/design-invariant-hardening-once.yml index 487feb1c98d..0a4c125d15a 100644 --- a/.github/workflows/design-invariant-hardening-once.yml +++ b/.github/workflows/design-invariant-hardening-once.yml @@ -9,6 +9,7 @@ on: - scripts/apply_design_invariant_hardening_once.py - scripts/apply_design_invariant_followup_once.py - scripts/apply_hermes_failure_boundary_once.py + - scripts/apply_hermes_callers_once.py permissions: contents: write @@ -158,6 +159,9 @@ jobs: - name: Apply Hermes fallible failure boundary run: python scripts/apply_hermes_failure_boundary_once.py + - name: Migrate Hermes production callers + run: python scripts/apply_hermes_callers_once.py + - name: Scope Hermes guards to production function bodies run: | python - <<'PY' @@ -216,6 +220,7 @@ jobs: scripts/apply_design_invariant_hardening_once.py \ scripts/apply_design_invariant_followup_once.py \ scripts/apply_hermes_failure_boundary_once.py \ + scripts/apply_hermes_callers_once.py \ .github/workflows/design-invariant-hardening-once.yml git add -A git diff --cached --check From 23de258f57a82664899f97d4e70c31c635a60e2c Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 15:30:20 +0800 Subject: [PATCH 079/112] ci: cover both Hermes MCP write paths --- scripts/apply_hermes_callers_once.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/apply_hermes_callers_once.py b/scripts/apply_hermes_callers_once.py index 676ce7a1f18..a4c0199021d 100644 --- a/scripts/apply_hermes_callers_once.py +++ b/scripts/apply_hermes_callers_once.py @@ -88,6 +88,7 @@ def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) " if !should_sync_hermes_mcp() {\n", " if !should_sync_hermes_mcp()? {\n", "Hermes MCP sync caller propagation", + expected=2, ) # --------------------------------------------------------------------------- From 5600202730717f6347fa372677b2d0987e18e618 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Sat, 5 Sep 2026 17:53:36 +0800 Subject: [PATCH 080/112] ci: persist bounded default-test diagnostics --- .../design-invariant-hardening-once.yml | 43 ++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/.github/workflows/design-invariant-hardening-once.yml b/.github/workflows/design-invariant-hardening-once.yml index 0a4c125d15a..d54496662c1 100644 --- a/.github/workflows/design-invariant-hardening-once.yml +++ b/.github/workflows/design-invariant-hardening-once.yml @@ -207,7 +207,48 @@ jobs: run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --all-features -- -D warnings - name: Rust tests default feature set - run: cargo test --manifest-path src-tauri/Cargo.toml --all + run: | + set -o pipefail + log="$RUNNER_TEMP/default-rust-tests.log" + diagnostic="$RUNNER_TEMP/default-rust-tests-diagnostic.txt" + status=0 + cargo test --manifest-path src-tauri/Cargo.toml --all >"$log" 2>&1 || status=$? + if [ "$status" -ne 0 ]; then + python - "$log" "$diagnostic" <<'PY' + from pathlib import Path + import re + import sys + + source = Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines() + out = [] + headers = [i for i, line in enumerate(source) if re.match(r"^---- .+ stdout ----$", line)] + for start in headers[:4]: + end = min(len(source), start + 90) + block = source[start:end] + if any("panicked at" in line or "assertion" in line or "FAILED" in line for line in block): + out.extend(block) + out.append("") + failure_markers = [i for i, line in enumerate(source) if line.strip() == "failures:"] + if failure_markers: + start = failure_markers[-1] + out.extend(source[start:min(len(source), start + 80)]) + out.append("") + out.append("--- tail ---") + out.extend(source[-140:]) + Path(sys.argv[2]).write_text("\n".join(out[-420:]) + "\n", encoding="utf-8") + PY + cat "$diagnostic" + fi + exit "$status" + + - name: Upload bounded default-test diagnostic + if: failure() + uses: actions/upload-artifact@v4 + with: + name: design-invariant-default-test-diagnostic + path: ${{ runner.temp }}/default-rust-tests-diagnostic.txt + if-no-files-found: error + retention-days: 7 - name: Rust tests all features run: cargo test --manifest-path src-tauri/Cargo.toml --all-features From ee58f3d4a7666825936e1521ddaa37af0a7fb963 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 15:30:14 +0800 Subject: [PATCH 081/112] ci: encode root failure semantics before test alignment --- .../apply_failure_semantics_redesign_once.py | 477 ++++++++++++++++++ 1 file changed, 477 insertions(+) create mode 100644 scripts/apply_failure_semantics_redesign_once.py diff --git a/scripts/apply_failure_semantics_redesign_once.py b/scripts/apply_failure_semantics_redesign_once.py new file mode 100644 index 00000000000..51355c2e7a1 --- /dev/null +++ b/scripts/apply_failure_semantics_redesign_once.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +from pathlib import Path + + +def read(path: str) -> str: + return Path(path).read_text(encoding="utf-8") + + +def write(path: str, text: str) -> None: + Path(path).write_text(text, encoding="utf-8") + + +def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) -> None: + text = read(path) + count = text.count(old) + if count != expected: + raise SystemExit(f"{label}: expected {expected}, found {count}") + write(path, text.replace(old, new, expected)) + + +def replace_region(path: str, start: str, end: str, new: str, label: str) -> None: + text = read(path) + if text.count(start) != 1: + raise SystemExit(f"{label}: start count={text.count(start)}") + i = text.index(start) + try: + j = text.index(end, i) + except ValueError: + raise SystemExit(f"{label}: end marker missing") + write(path, text[:i] + new + text[j:]) + + +# --------------------------------------------------------------------------- +# Product-level failure semantics. Persistence-root resolution is strict; +# best-effort discovery is allowed to omit candidates but never invent roots. +# --------------------------------------------------------------------------- +semantics = Path("src-tauri/src/failure_semantics.rs") +if semantics.exists(): + raise SystemExit("failure_semantics.rs already exists; redesign driver is one-shot") +semantics.write_text( + '''use std::path::PathBuf; +use thiserror::Error; + +/// Failure to determine a persistent/configuration root. +/// +/// `None` is reserved for genuine absence at the API that owns optionality. +/// Invalid explicit values are errors and must not silently select another root. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum RootResolutionError { + #[error("{source} is unavailable: {detail}")] + Unavailable { source: String, detail: String }, + #[error("{source} must be an absolute path, got: {path}")] + Relative { source: String, path: String }, +} + +pub fn require_absolute_root( + path: PathBuf, + source: impl Into, +) -> Result { + if path.is_absolute() { + Ok(path) + } else { + Err(RootResolutionError::Relative { + source: source.into(), + path: path.display().to_string(), + }) + } +} + +/// Read an environment variable that selects a persistent root. +/// Missing/blank means "not configured"; a non-empty relative value is invalid. +pub fn optional_absolute_env_root( + name: &str, +) -> Result, RootResolutionError> { + let Some(raw) = std::env::var_os(name) else { + return Ok(None); + }; + let value = raw.to_string_lossy(); + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + require_absolute_root(PathBuf::from(trimmed), name).map(Some) +} +''', + encoding="utf-8", +) + +replace_exact( + "src-tauri/src/lib.rs", + "mod error;\n", + "mod error;\nmod failure_semantics;\n", + "register failure semantics module", +) +replace_exact( + "src-tauri/src/error.rs", + "use std::path::Path;\n", + "use std::path::Path;\n\nuse crate::failure_semantics::RootResolutionError;\n", + "error root import", +) +replace_exact( + "src-tauri/src/error.rs", + ''' #[error("配置错误: {0}")] + Config(String), +''', + ''' #[error("配置错误: {0}")] + Config(String), + #[error(transparent)] + Root(#[from] RootResolutionError), +''', + "typed root AppError", +) + +# --------------------------------------------------------------------------- +# config.rs: add typed APIs while retaining compatibility wrappers for callers +# that have not yet migrated. New persistence code must use the typed boundary. +# --------------------------------------------------------------------------- +path = "src-tauri/src/config.rs" +replace_exact( + path, + "use crate::error::AppError;\n", + "use crate::error::AppError;\nuse crate::failure_semantics::{require_absolute_root, RootResolutionError};\n", + "config typed root import", +) +replace_region( + path, + "pub fn try_get_home_dir() -> Result {", + "\npub fn get_home_dir() -> PathBuf {", + '''pub fn try_get_home_dir_typed() -> Result { + if let Ok(raw) = std::env::var("CC_SWITCH_TEST_HOME") { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + return require_absolute_root(PathBuf::from(trimmed), "CC_SWITCH_TEST_HOME"); + } + } + + let detected = dirs::home_dir().ok_or_else(|| RootResolutionError::Unavailable { + source: "user home".to_string(), + detail: "operating system did not provide a home directory; CWD fallback is forbidden" + .to_string(), + })?; + require_absolute_root(detected, "operating-system user home") +} + +/// Compatibility adapter. New persistence code should keep RootResolutionError typed. +pub fn try_get_home_dir() -> Result { + try_get_home_dir_typed().map_err(|err| err.to_string()) +} +''', + "typed home boundary", +) +replace_region( + path, + "pub fn resolve_persistence_path(raw: &str, label: &str) -> Result {", + "\n/// Last-resort crash/exit observability directory", + '''pub fn resolve_persistence_path_typed( + raw: &str, + label: &str, +) -> Result { + let trimmed = raw.trim(); + if trimmed == "~" { + return try_get_home_dir_typed(); + } + if let Some(stripped) = trimmed.strip_prefix("~/") { + return Ok(try_get_home_dir_typed()?.join(stripped)); + } + if let Some(stripped) = trimmed.strip_prefix("~\\\\") { + return Ok(try_get_home_dir_typed()?.join(stripped)); + } + require_absolute_root(PathBuf::from(trimmed), label) +} + +/// Compatibility adapter for legacy String-error APIs. +pub fn resolve_persistence_path(raw: &str, label: &str) -> Result { + resolve_persistence_path_typed(raw, label).map_err(|err| err.to_string()) +} +''', + "typed persistence boundary", +) +replace_region( + path, + "pub fn try_get_app_config_dir() -> Result {", + "\n/// Compatibility wrapper for legacy infallible path APIs.", + '''pub fn try_get_app_config_dir_app() -> Result { + if let Some(custom) = crate::app_store::try_get_app_config_dir_override()? { + return Ok(require_absolute_root(custom, "app_config_dir override")?); + } + + let default_dir = try_get_home_dir_typed()?.join(".cc-switch"); + + // v3.10.3 HOME is only a historical discovery candidate, not an active root selector. + // Invalid legacy candidates are ignored; they must never override a valid OS home root. + #[cfg(windows)] + { + let default_db = default_dir.join("cc-switch.db"); + if !default_db.exists() { + if let Ok(home_env) = std::env::var("HOME") { + let trimmed = home_env.trim(); + if !trimmed.is_empty() { + let legacy_home = PathBuf::from(trimmed); + if legacy_home.is_absolute() { + let legacy_dir = legacy_home.join(".cc-switch"); + if legacy_dir.join("cc-switch.db").exists() { + log::info!( + "Detected v3.10.3 legacy database at {}, using it instead of {}", + legacy_dir.display(), + default_dir.display() + ); + return Ok(legacy_dir); + } + } else { + log::warn!( + "Ignoring relative legacy HOME discovery candidate: {}", + legacy_home.display() + ); + } + } + } + } + } + + Ok(default_dir) +} + +pub fn try_get_app_config_dir() -> Result { + try_get_app_config_dir_app().map_err(|err| err.to_string()) +} +''', + "typed app root boundary", +) + +# --------------------------------------------------------------------------- +# app_store.rs: cache failure separately from genuine absence. A failed Store +# refresh can no longer leave None behind and silently redirect persistence. +# --------------------------------------------------------------------------- +path = "src-tauri/src/app_store.rs" +replace_region( + path, + "/// 缓存当前的 app_config_dir 覆盖路径,避免存储 AppHandle\n", + "\nfn open_paths_store(", + '''/// Cached Store outcome. `Ok(None)` is genuine absence; `Err` means the last +/// refresh failed and must remain observable to persistence-root callers. +static APP_CONFIG_DIR_OVERRIDE: OnceLock, String>>> = OnceLock::new(); + +fn override_cache() -> &'static RwLock, String>> { + APP_CONFIG_DIR_OVERRIDE.get_or_init(|| RwLock::new(Ok(None))) +} + +fn update_cached_override(value: Result, String>) { + match override_cache().write() { + Ok(mut guard) => *guard = value, + Err(err) => log::error!("app_config_dir override cache poisoned: {err}"), + } +} + +pub fn try_get_app_config_dir_override() -> Result, AppError> { + let guard = override_cache() + .read() + .map_err(|err| AppError::Lock(err.to_string()))?; + guard + .as_ref() + .map(Clone::clone) + .map_err(|err| AppError::Config(err.clone())) +} + +/// Legacy infallible adapter. It fails closed instead of turning a cached Store +/// error into absence. New code must use `try_get_app_config_dir_override`. +pub fn get_app_config_dir_override() -> Option { + try_get_app_config_dir_override().unwrap_or_else(|err| { + log::error!("{err}"); + panic!("{err}"); + }) +} +''', + "tri-state app root cache", +) +replace_exact( + path, + ''' let settings_path = crate::config::try_get_home_dir() + .map_err(AppError::Config)? +''', + ''' let settings_path = crate::config::try_get_home_dir_typed() + .map_err(AppError::from)? +''', + "legacy settings typed home", +) +replace_exact( + path, + '''fn resolve_path(raw: &str) -> Result { + crate::config::resolve_persistence_path(raw, "app_config_dir") + .map_err(AppError::InvalidInput) +} +''', + '''fn resolve_path(raw: &str) -> Result { + crate::config::resolve_persistence_path_typed(raw, "app_config_dir").map_err(AppError::from) +} +''', + "typed app_store path", +) +replace_region( + path, + "pub fn refresh_app_config_dir_override(\n", + "\n/// 写入 app_config_dir", + '''pub fn refresh_app_config_dir_override( + app: &tauri::AppHandle, +) -> Result, AppError> { + let result = (|| { + let migrated = migrate_legacy_override_if_needed(app)?; + match migrated { + Some(path) => Ok(Some(path)), + None => read_override_from_store(app), + } + })(); + + match result { + Ok(value) => { + update_cached_override(Ok(value.clone())); + Ok(value) + } + Err(err) => { + update_cached_override(Err(err.to_string())); + Err(err) + } + } +} +''', + "observable Store refresh failure", +) +replace_exact( + path, + " update_cached_override(resolved.clone());\n", + " update_cached_override(Ok(resolved.clone()));\n", + "cache successful Store write", +) +replace_exact( + path, + " update_cached_override(value);\n", + " update_cached_override(Ok(value));\n", + "cache successful migration", +) + +# --------------------------------------------------------------------------- +# Hermes: current explicit persistent roots are strict. Missing/blank env means +# absent; relative HERMES_HOME/LOCALAPPDATA is a configuration error, not fallback. +# --------------------------------------------------------------------------- +path = "src-tauri/src/hermes_config.rs" +replace_exact( + path, + "use crate::error::AppError;\n", + "use crate::error::AppError;\nuse crate::failure_semantics::{optional_absolute_env_root, require_absolute_root};\n", + "Hermes root helpers import", +) +replace_region( + path, + "pub fn try_get_hermes_dir() -> Result {", + "\npub fn try_get_hermes_config_path() -> Result {", + '''pub fn try_get_hermes_dir() -> Result { + if let Some(override_dir) = get_hermes_override_dir() { + return Ok(require_absolute_root(override_dir, "hermes_config_dir")?); + } + + if let Some(path) = optional_absolute_env_root("HERMES_HOME")? { + return Ok(path); + } + + default_hermes_dir() +} + +#[cfg(target_os = "windows")] +fn default_hermes_dir() -> Result { + if let Some(local_app_data) = optional_absolute_env_root("LOCALAPPDATA")? { + return Ok(local_app_data.join("hermes")); + } + Ok(crate::config::try_get_home_dir_typed()?.join("AppData").join("Local").join("hermes")) +} + +#[cfg(not(target_os = "windows"))] +fn default_hermes_dir() -> Result { + Ok(crate::config::try_get_home_dir_typed()?.join(".hermes")) +} + +#[cfg(test)] +fn windows_local_hermes_dir(localappdata: Option<&std::ffi::OsStr>, home: &Path) -> PathBuf { + localappdata + .map(|value| value.to_string_lossy().trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| home.join("AppData").join("Local")) + .join("hermes") +} + +''', + "strict Hermes persistent roots", +) + +# --------------------------------------------------------------------------- +# OpenCode persistence root: XDG_DATA_HOME is an explicit persistent root. +# Relative values are errors. This is intentionally different from CLI discovery. +# --------------------------------------------------------------------------- +path = "src-tauri/src/session_manager/providers/opencode.rs" +replace_region( + path, + "fn try_get_opencode_base_dir() -> Result {", + "\npub(crate) fn get_opencode_data_dir() -> Result {", + '''fn try_get_opencode_base_dir() -> Result { + match crate::failure_semantics::optional_absolute_env_root("XDG_DATA_HOME") { + Ok(Some(root)) => return Ok(root.join("opencode")), + Ok(None) => {} + Err(err) => return Err(err.to_string()), + } + Ok(crate::config::try_get_home_dir_typed() + .map_err(|err| err.to_string())? + .join(".local/share/opencode")) +} +''', + "strict OpenCode persistence root", +) + +# --------------------------------------------------------------------------- +# CLI discovery is intentionally best-effort. Make optional HOME explicit in +# the helper contract so callers cannot manufacture Path("") sentinels. +# --------------------------------------------------------------------------- +path = "src-tauri/src/commands/misc.rs" +replace_exact( + path, + "fn opencode_extra_search_paths(\n home: &Path,\n", + "fn opencode_extra_search_paths(\n home: Option<&Path>,\n", + "optional OpenCode discovery home", +) +replace_exact( + path, + ''' if !home.as_os_str().is_empty() { + push_unique_path(&mut paths, home.join("bin")); + push_unique_path(&mut paths, home.join(".opencode").join("bin")); + push_unique_path(&mut paths, home.join(".bun").join("bin")); + push_unique_path(&mut paths, home.join("go").join("bin")); + } +''', + ''' if let Some(home) = home { + push_unique_path(&mut paths, home.join("bin")); + push_unique_path(&mut paths, home.join(".opencode").join("bin")); + push_unique_path(&mut paths, home.join(".bun").join("bin")); + push_unique_path(&mut paths, home.join("go").join("bin")); + } +''', + "OpenCode discovery HOME body", +) +replace_exact( + path, + ''' if tool == "opencode" { + let empty_home = Path::new(""); + for path in opencode_extra_search_paths( + home.as_deref().unwrap_or(empty_home), + std::env::var_os("OPENCODE_INSTALL_DIR"), + std::env::var_os("XDG_BIN_DIR"), + std::env::var_os("GOPATH"), + ) { +''', + ''' if tool == "opencode" { + for path in opencode_extra_search_paths( + home.as_deref(), + std::env::var_os("OPENCODE_INSTALL_DIR"), + std::env::var_os("XDG_BIN_DIR"), + std::env::var_os("GOPATH"), + ) { +''', + "remove fake HOME discovery sentinel", +) +# Existing unit tests pass concrete Path references; preserve their intent explicitly. +replace_exact( + path, + "opencode_extra_search_paths(&home, None, None, None)", + "opencode_extra_search_paths(Some(&home), None, None, None)", + "OpenCode discovery test caller", +) + +print("Applied failure-semantics redesign before test alignment") From 7d15380ecda8ee29baae45f11864d4548410d91d Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 15:31:11 +0800 Subject: [PATCH 082/112] ci: apply failure-semantics redesign before verification --- .github/workflows/design-invariant-hardening-once.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/design-invariant-hardening-once.yml b/.github/workflows/design-invariant-hardening-once.yml index d54496662c1..62764d72acc 100644 --- a/.github/workflows/design-invariant-hardening-once.yml +++ b/.github/workflows/design-invariant-hardening-once.yml @@ -10,6 +10,7 @@ on: - scripts/apply_design_invariant_followup_once.py - scripts/apply_hermes_failure_boundary_once.py - scripts/apply_hermes_callers_once.py + - scripts/apply_failure_semantics_redesign_once.py permissions: contents: write @@ -64,7 +65,7 @@ jobs: old = r'std::path::PathBuf::from("C:\Program Files\nodejs")' new = r'std::path::PathBuf::from(r"C:\Program Files\nodejs")' if text.count(old) != 1: - raise SystemExit(f"Windows nodejs literal count={text.count(old)}") + raise SystemExit(f"Windows nodejs literal count={text.count(old)})") path.write_text(text.replace(old, new, 1), encoding="utf-8") PY @@ -162,6 +163,9 @@ jobs: - name: Migrate Hermes production callers run: python scripts/apply_hermes_callers_once.py + - name: Apply typed failure-semantics redesign + run: python scripts/apply_failure_semantics_redesign_once.py + - name: Scope Hermes guards to production function bodies run: | python - <<'PY' @@ -262,8 +266,9 @@ jobs: scripts/apply_design_invariant_followup_once.py \ scripts/apply_hermes_failure_boundary_once.py \ scripts/apply_hermes_callers_once.py \ + scripts/apply_failure_semantics_redesign_once.py \ .github/workflows/design-invariant-hardening-once.yml git add -A git diff --cached --check - git commit -m "fix: enforce persistence and diagnostic invariants" + git commit -m "fix: enforce typed persistence failure semantics" git push origin HEAD:fix/global-hardening-20260904 From 674be7ff9eec0d121aa224e7d1fe09eb52255475 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 15:42:09 +0800 Subject: [PATCH 083/112] ci: fix typed root implementation without weakening semantics --- .../design-invariant-hardening-once.yml | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/.github/workflows/design-invariant-hardening-once.yml b/.github/workflows/design-invariant-hardening-once.yml index 62764d72acc..9e8653e044d 100644 --- a/.github/workflows/design-invariant-hardening-once.yml +++ b/.github/workflows/design-invariant-hardening-once.yml @@ -65,7 +65,7 @@ jobs: old = r'std::path::PathBuf::from("C:\Program Files\nodejs")' new = r'std::path::PathBuf::from(r"C:\Program Files\nodejs")' if text.count(old) != 1: - raise SystemExit(f"Windows nodejs literal count={text.count(old)})") + raise SystemExit(f"Windows nodejs literal count={text.count(old)}") path.write_text(text.replace(old, new, 1), encoding="utf-8") PY @@ -166,6 +166,45 @@ jobs: - name: Apply typed failure-semantics redesign run: python scripts/apply_failure_semantics_redesign_once.py + - name: Complete typed-root implementation migration + run: | + python - <<'PY' + from pathlib import Path + + semantics = Path("src-tauri/src/failure_semantics.rs") + text = semantics.read_text(encoding="utf-8") + # `thiserror` reserves a field literally named `source` for chained errors. + # This field is a human-readable root origin, so name it `origin` instead. + text = text.replace("source", "origin") + semantics.write_text(text, encoding="utf-8") + + config = Path("src-tauri/src/config.rs") + text = config.read_text(encoding="utf-8") + old = ' source: "user home".to_string(),\n' + new = ' origin: "user home".to_string(),\n' + if text.count(old) != 1: + raise SystemExit(f"typed HOME origin field count={text.count(old)}") + config.write_text(text.replace(old, new, 1), encoding="utf-8") + + misc = Path("src-tauri/src/commands/misc.rs") + text = misc.read_text(encoding="utf-8") + replacements = [ + ( + "opencode_extra_search_paths(&home, install_dir, xdg_bin_dir, gopath)", + "opencode_extra_search_paths(Some(&home), install_dir, xdg_bin_dir, gopath)", + ), + ( + "opencode_extra_search_paths(&home, same_dir.clone(), same_dir, None)", + "opencode_extra_search_paths(Some(&home), same_dir.clone(), same_dir, None)", + ), + ] + for old, new in replacements: + if text.count(old) != 1: + raise SystemExit(f"OpenCode optional HOME caller count={text.count(old)}: {old}") + text = text.replace(old, new, 1) + misc.write_text(text, encoding="utf-8") + PY + - name: Scope Hermes guards to production function bodies run: | python - <<'PY' @@ -211,6 +250,7 @@ jobs: run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --all-features -- -D warnings - name: Rust tests default feature set + id: default_rust_tests run: | set -o pipefail log="$RUNNER_TEMP/default-rust-tests.log" @@ -246,7 +286,7 @@ jobs: exit "$status" - name: Upload bounded default-test diagnostic - if: failure() + if: steps.default_rust_tests.outcome == 'failure' uses: actions/upload-artifact@v4 with: name: design-invariant-default-test-diagnostic From 201b6b8e573d5b45b6bb875326586a5945bf2587 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 15:45:56 +0800 Subject: [PATCH 084/112] ci: encode session structural failure semantics --- scripts/apply_session_scan_semantics_once.py | 505 +++++++++++++++++++ 1 file changed, 505 insertions(+) create mode 100644 scripts/apply_session_scan_semantics_once.py diff --git a/scripts/apply_session_scan_semantics_once.py b/scripts/apply_session_scan_semantics_once.py new file mode 100644 index 00000000000..8cc749975d1 --- /dev/null +++ b/scripts/apply_session_scan_semantics_once.py @@ -0,0 +1,505 @@ +#!/usr/bin/env python3 +from pathlib import Path + + +def read(path: str) -> str: + return Path(path).read_text(encoding="utf-8") + + +def write(path: str, text: str) -> None: + Path(path).write_text(text, encoding="utf-8") + + +def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) -> None: + text = read(path) + count = text.count(old) + if count != expected: + raise SystemExit(f"{label}: expected {expected}, found {count}") + write(path, text.replace(old, new, expected)) + + +def replace_region(path: str, start: str, end: str, new: str, label: str) -> None: + text = read(path) + if text.count(start) != 1: + raise SystemExit(f"{label}: start count={text.count(start)}") + i = text.index(start) + try: + j = text.index(end, i) + except ValueError: + raise SystemExit(f"{label}: end marker missing") + write(path, text[:i] + new + text[j:]) + + +# --------------------------------------------------------------------------- +# Session-domain contract: +# - missing session storage is a valid empty result; +# - storage that exists but cannot be enumerated is an error; +# - provider worker panic is an error; +# - provider installation/availability is NOT inferred from session storage. +# Individual dirty history files are handled by the parse-policy layer separately. +# --------------------------------------------------------------------------- +path = "src-tauri/src/session_manager/mod.rs" +replace_region( + path, + "pub fn scan_sessions() -> Result, String> {", + "\npub fn load_messages", + '''#[derive(Debug, thiserror::Error)] +pub enum SessionScanError { + #[error("{provider} session storage error at {path}: {detail}")] + Storage { + provider: &'static str, + path: PathBuf, + detail: String, + }, + #[error("{provider} session scan failed: {detail}")] + Provider { + provider: &'static str, + detail: String, + }, + #[error("{provider} session worker panicked")] + WorkerPanic { provider: &'static str }, +} + +impl SessionScanError { + pub(crate) fn storage( + provider: &'static str, + path: impl Into, + err: impl std::fmt::Display, + ) -> Self { + Self::Storage { + provider, + path: path.into(), + detail: err.to_string(), + } + } + + fn provider(provider: &'static str, detail: impl Into) -> Self { + Self::Provider { + provider, + detail: detail.into(), + } + } +} + +pub fn scan_sessions() -> Result, SessionScanError> { + let (r1, r2, r3, r4, r5, r6) = + std::thread::scope(|scope| -> Result<_, SessionScanError> { + let h1 = scope.spawn(codex::scan_sessions); + let h2 = scope.spawn(claude::scan_sessions); + let h3 = scope.spawn(opencode::scan_sessions); + let h4 = scope.spawn(openclaw::scan_sessions); + let h5 = scope.spawn(gemini::scan_sessions); + let h6 = scope.spawn(hermes::scan_sessions); + + let r1 = h1 + .join() + .map_err(|_| SessionScanError::WorkerPanic { provider: "Codex" })??; + let r2 = h2 + .join() + .map_err(|_| SessionScanError::WorkerPanic { provider: "Claude" })??; + let r3 = h3 + .join() + .map_err(|_| SessionScanError::WorkerPanic { provider: "OpenCode" })? + .map_err(|err| SessionScanError::provider("OpenCode", err))?; + let r4 = h4 + .join() + .map_err(|_| SessionScanError::WorkerPanic { provider: "OpenClaw" })??; + let r5 = h5 + .join() + .map_err(|_| SessionScanError::WorkerPanic { provider: "Gemini" })??; + let r6 = h6 + .join() + .map_err(|_| SessionScanError::WorkerPanic { provider: "Hermes" })? + .map_err(|err| SessionScanError::provider("Hermes", err))?; + Ok((r1, r2, r3, r4, r5, r6)) + })?; + + let mut sessions = Vec::new(); + sessions.extend(r1); + sessions.extend(r2); + sessions.extend(r3); + sessions.extend(r4); + sessions.extend(r5); + sessions.extend(r6); + sessions.sort_by(|a, b| { + b.last_active_at + .or(b.created_at) + .unwrap_or(0) + .cmp(&a.last_active_at.or(a.created_at).unwrap_or(0)) + }); + Ok(sessions) +} +''', + "typed session aggregate boundary", +) + +path = "src-tauri/src/commands/session_manager.rs" +replace_exact( + path, + ''' tauri::async_runtime::spawn_blocking(session_manager::scan_sessions) + .await + .map_err(|err| format!("Failed to scan sessions task: {err}"))? +''', + ''' tauri::async_runtime::spawn_blocking(session_manager::scan_sessions) + .await + .map_err(|err| format!("Failed to scan sessions task: {err}"))? + .map_err(|err| err.to_string()) +''', + "session command typed error boundary", +) + + +# --------------------------------------------------------------------------- +# Codex: both active and archived roots are optional, but an existing root that +# cannot be enumerated is not equivalent to "no sessions". +# --------------------------------------------------------------------------- +path = "src-tauri/src/session_manager/providers/codex.rs" +replace_exact( + path, + "use crate::session_manager::{SessionMessage, SessionMeta};\n", + "use crate::session_manager::{SessionMessage, SessionMeta, SessionScanError};\n", + "Codex session error import", +) +replace_region( + path, + "pub fn scan_sessions() -> Vec {", + "\npub fn load_messages", + '''pub fn scan_sessions() -> Result, SessionScanError> { + let roots = session_roots(); + scan_sessions_in_roots(&roots) +} + +pub fn session_roots() -> Vec { + let config_dir = get_codex_config_dir(); + vec![ + config_dir.join("sessions"), + config_dir.join("archived_sessions"), + ] +} + +fn scan_sessions_in_roots(roots: &[PathBuf]) -> Result, SessionScanError> { + let mut files = Vec::new(); + for root in roots { + collect_jsonl_files(root, &mut files)?; + } + + let mut sessions = Vec::new(); + for path in files { + if let Some(meta) = parse_session(&path) { + sessions.push(meta); + } + } + Ok(sessions) +} +''', + "Codex structural scan result", +) +replace_region( + path, + "fn collect_jsonl_files(root: &Path, files: &mut Vec) {", + "\n}\n\n#[cfg(test)]", + '''fn collect_jsonl_files(root: &Path, files: &mut Vec) -> Result<(), SessionScanError> { + if !root.exists() { + return Ok(()); + } + + let entries = std::fs::read_dir(root) + .map_err(|err| SessionScanError::storage("Codex", root, err))?; + for entry in entries { + let entry = entry.map_err(|err| SessionScanError::storage("Codex", root, err))?; + let path = entry.path(); + if path.is_dir() { + collect_jsonl_files(&path, files)?; + } else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") { + files.push(path); + } + } + Ok(()) +''', + "Codex structural traversal", +) +replace_exact( + path, + " let sessions = scan_sessions_in_roots(&[active, archived]);\n", + " let sessions = scan_sessions_in_roots(&[active, archived]).expect(\"scan sessions\");\n", + "Codex scan test result contract", +) + + +# --------------------------------------------------------------------------- +# Claude: projects root may be absent, but read_dir/DirEntry failures are +# structural failures rather than empty history. +# --------------------------------------------------------------------------- +path = "src-tauri/src/session_manager/providers/claude.rs" +replace_exact( + path, + "use crate::session_manager::{SessionMessage, SessionMeta};\n", + "use crate::session_manager::{SessionMessage, SessionMeta, SessionScanError};\n", + "Claude session error import", +) +replace_region( + path, + "pub fn scan_sessions() -> Vec {", + "\npub fn load_messages", + '''pub fn scan_sessions() -> Result, SessionScanError> { + let root = get_claude_config_dir().join("projects"); + let mut files = Vec::new(); + collect_jsonl_files(&root, &mut files)?; + + let mut sessions = Vec::new(); + for path in files { + if let Some(meta) = parse_session(&path) { + sessions.push(meta); + } + } + Ok(sessions) +} +''', + "Claude structural scan result", +) +replace_region( + path, + "fn collect_jsonl_files(root: &Path, files: &mut Vec) {", + "\n}\n\nfn remove_path_if_exists", + '''fn collect_jsonl_files(root: &Path, files: &mut Vec) -> Result<(), SessionScanError> { + if !root.exists() { + return Ok(()); + } + + let entries = std::fs::read_dir(root) + .map_err(|err| SessionScanError::storage("Claude", root, err))?; + for entry in entries { + let entry = entry.map_err(|err| SessionScanError::storage("Claude", root, err))?; + let path = entry.path(); + if path.is_dir() { + collect_jsonl_files(&path, files)?; + } else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") { + files.push(path); + } + } + Ok(()) +} +''', + "Claude structural traversal", +) + + +# --------------------------------------------------------------------------- +# Gemini: tmp root and per-project chats directories are structural storage. +# Optional .project_root metadata can degrade with a warning. +# --------------------------------------------------------------------------- +path = "src-tauri/src/session_manager/providers/gemini.rs" +replace_exact( + path, + "use crate::session_manager::{SessionMessage, SessionMeta};\n", + "use crate::session_manager::{SessionMessage, SessionMeta, SessionScanError};\n", + "Gemini session error import", +) +replace_region( + path, + "pub fn scan_sessions() -> Vec {", + "\npub fn load_messages", + '''pub fn scan_sessions() -> Result, SessionScanError> { + let gemini_dir = crate::gemini_config::get_gemini_dir(); + let tmp_dir = gemini_dir.join("tmp"); + if !tmp_dir.exists() { + return Ok(Vec::new()); + } + + let project_dirs = std::fs::read_dir(&tmp_dir) + .map_err(|err| SessionScanError::storage("Gemini", &tmp_dir, err))?; + let mut sessions = Vec::new(); + for entry in project_dirs { + let entry = entry.map_err(|err| SessionScanError::storage("Gemini", &tmp_dir, err))?; + let chats_dir = entry.path().join("chats"); + if !chats_dir.is_dir() { + continue; + } + + let chat_files = std::fs::read_dir(&chats_dir) + .map_err(|err| SessionScanError::storage("Gemini", &chats_dir, err))?; + let project_root_file = entry.path().join(".project_root"); + let project_dir = match std::fs::read_to_string(&project_root_file) { + Ok(value) => Some(value), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, + Err(err) => { + log::warn!( + "Gemini optional project-root metadata unreadable at {}: {err}", + project_root_file.display() + ); + None + } + }; + + for file_entry in chat_files { + let file_entry = + file_entry.map_err(|err| SessionScanError::storage("Gemini", &chats_dir, err))?; + let path = file_entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { + continue; + } + if let Some(meta) = parse_session(&path) { + sessions.push(SessionMeta { + project_dir: project_dir.clone(), + ..meta + }); + } + } + } + Ok(sessions) +} +''', + "Gemini structural scan result", +) + + +# --------------------------------------------------------------------------- +# OpenClaw: agents/sessions trees are structural; sessions.json display-name +# metadata is optional and degrades observably rather than hiding an I/O error. +# --------------------------------------------------------------------------- +path = "src-tauri/src/session_manager/providers/openclaw.rs" +replace_exact( + path, + " session_manager::{SessionMessage, SessionMeta},\n", + " session_manager::{SessionMessage, SessionMeta, SessionScanError},\n", + "OpenClaw session error import", +) +replace_region( + path, + "pub fn scan_sessions() -> Vec {", + "\npub fn load_messages", + '''pub fn scan_sessions() -> Result, SessionScanError> { + let agents_dir = get_openclaw_dir().join("agents"); + if !agents_dir.exists() { + return Ok(Vec::new()); + } + + let agent_entries = std::fs::read_dir(&agents_dir) + .map_err(|err| SessionScanError::storage("OpenClaw", &agents_dir, err))?; + let mut sessions = Vec::new(); + for agent_entry in agent_entries { + let agent_entry = + agent_entry.map_err(|err| SessionScanError::storage("OpenClaw", &agents_dir, err))?; + let agent_path = agent_entry.path(); + if !agent_path.is_dir() { + continue; + } + + let sessions_dir = agent_path.join("sessions"); + if !sessions_dir.is_dir() { + continue; + } + let session_entries = std::fs::read_dir(&sessions_dir) + .map_err(|err| SessionScanError::storage("OpenClaw", &sessions_dir, err))?; + let display_names = load_display_names(&sessions_dir); + + for entry in session_entries { + let entry = entry + .map_err(|err| SessionScanError::storage("OpenClaw", &sessions_dir, err))?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { + continue; + } + if let Some(meta) = parse_session(&path, Some(&display_names)) { + sessions.push(meta); + } + } + } + Ok(sessions) +} +''', + "OpenClaw structural scan result", +) +replace_region( + path, + "fn load_display_names(sessions_dir: &Path) -> HashMap {", + "\n}\n\nfn parse_session(", + '''fn load_display_names(sessions_dir: &Path) -> HashMap { + let index_path = sessions_dir.join("sessions.json"); + let content = match std::fs::read_to_string(&index_path) { + Ok(content) => content, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return HashMap::new(), + Err(err) => { + log::warn!( + "OpenClaw optional session index unreadable at {}: {err}", + index_path.display() + ); + return HashMap::new(); + } + }; + let index: serde_json::Map = match serde_json::from_str(&content) { + Ok(index) => index, + Err(err) => { + log::warn!( + "OpenClaw optional session index malformed at {}: {err}", + index_path.display() + ); + return HashMap::new(); + } + }; + + let mut map = HashMap::new(); + for entry in index.values() { + if let (Some(id), Some(name)) = ( + entry.get("sessionId").and_then(Value::as_str), + entry.get("displayName").and_then(Value::as_str), + ) { + if !name.is_empty() { + map.insert(id.to_string(), name.to_string()); + } + } + } + map +} +''', + "OpenClaw optional index observability", +) + + +# --------------------------------------------------------------------------- +# OpenCode JSON session tree: follow-up already makes the public scan Result; +# make structural enumeration failures observable without changing permissive +# helpers used by message rendering/deletion yet. +# --------------------------------------------------------------------------- +path = "src-tauri/src/session_manager/providers/opencode.rs" +replace_region( + path, + "fn scan_sessions_json() -> Result, String> {", + "\n/// Parse a SQLite source reference", + '''fn scan_sessions_json() -> Result, String> { + let storage = get_opencode_data_dir()?; + let session_dir = storage.join("session"); + if !session_dir.exists() { + return Ok(Vec::new()); + } + let mut json_files = Vec::new(); + collect_json_files_strict(&session_dir, &mut json_files)?; + let mut sessions = Vec::new(); + for path in json_files { + if let Some(meta) = parse_session(&storage, &path) { + sessions.push(meta); + } + } + Ok(sessions) +} + +fn collect_json_files_strict(root: &Path, files: &mut Vec) -> Result<(), String> { + let entries = std::fs::read_dir(root) + .map_err(|err| format!("Failed to enumerate OpenCode session storage {}: {err}", root.display()))?; + for entry in entries { + let entry = entry + .map_err(|err| format!("Failed to enumerate OpenCode session entry in {}: {err}", root.display()))?; + let path = entry.path(); + if path.is_dir() { + collect_json_files_strict(&path, files)?; + } else if path.extension().and_then(|ext| ext.to_str()) == Some("json") { + files.push(path); + } + } + Ok(()) +} +''', + "OpenCode structural JSON scan", +) + +print("Applied session structural failure semantics") From 8d4fa4ffa33b2d30d69c06d8730930db7c9fad1d Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 15:48:22 +0800 Subject: [PATCH 085/112] ci: encode session dirty-history parse semantics --- scripts/apply_session_parse_semantics_once.py | 506 ++++++++++++++++++ 1 file changed, 506 insertions(+) create mode 100644 scripts/apply_session_parse_semantics_once.py diff --git a/scripts/apply_session_parse_semantics_once.py b/scripts/apply_session_parse_semantics_once.py new file mode 100644 index 00000000000..23cdd9ca255 --- /dev/null +++ b/scripts/apply_session_parse_semantics_once.py @@ -0,0 +1,506 @@ +#!/usr/bin/env python3 +from pathlib import Path + + +def read(path: str) -> str: + return Path(path).read_text(encoding="utf-8") + + +def write(path: str, text: str) -> None: + Path(path).write_text(text, encoding="utf-8") + + +def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) -> None: + text = read(path) + count = text.count(old) + if count != expected: + raise SystemExit(f"{label}: expected {expected}, found {count}") + write(path, text.replace(old, new, expected)) + + +# --------------------------------------------------------------------------- +# Shared file reader: a line-level I/O error is not EOF. Preserve it so the +# provider parser can classify the file as dirty/unreadable and warn+skip it. +# --------------------------------------------------------------------------- +path = "src-tauri/src/session_manager/providers/utils.rs" +replace_exact( + path, + " let all: Vec = reader.lines().map_while(Result::ok).collect();\n", + " let all: Vec = reader.lines().collect::>>()?;\n", + "small session file line I/O", +) +replace_exact( + path, + " let head: Vec = reader.lines().take(head_n).map_while(Result::ok).collect();\n", + " let head: Vec = reader.lines().take(head_n).collect::>>()?;\n", + "session head line I/O", +) +replace_exact( + path, + " let all_tail: Vec = tail_reader.lines().map_while(Result::ok).collect();\n", + " let all_tail: Vec = tail_reader.lines().collect::>>()?;\n", + "session tail line I/O", +) + + +# --------------------------------------------------------------------------- +# Codex: Ok(None) is reserved for explicitly filtered subagent sessions. +# I/O or structurally unusable history is Err and is warned+skipped by scanning. +# --------------------------------------------------------------------------- +path = "src-tauri/src/session_manager/providers/codex.rs" +replace_exact( + path, + ''' for path in files { + if let Some(meta) = parse_session(&path) { + sessions.push(meta); + } + } +''', + ''' for path in files { + match parse_session_checked(&path) { + Ok(Some(meta)) => sessions.push(meta), + Ok(None) => {} + Err(err) => log::warn!("Skipping unreadable Codex session {}: {err}", path.display()), + } + } +''', + "Codex dirty-session scan policy", +) +replace_exact( + path, + ''' let meta = parse_session(path) + .ok_or_else(|| format!("Failed to parse Codex session metadata: {}", path.display()))?; +''', + ''' let meta = parse_session_checked(path)? + .ok_or_else(|| format!("Codex session is intentionally filtered: {}", path.display()))?; +''', + "Codex delete parser boundary", +) +replace_exact( + path, + "fn parse_session(path: &Path) -> Option {\n", + "fn parse_session_checked(path: &Path) -> Result, String> {\n", + "Codex checked parser signature", +) +replace_exact( + path, + " let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;\n", + ''' let (head, tail) = read_head_tail_lines(path, 10, 30) + .map_err(|err| format!("Failed to read Codex session {}: {err}", path.display()))?; +''', + "Codex parser I/O propagation", +) +replace_exact( + path, + ''' if is_subagent_source(payload.get("source")) { + return None; + } +''', + ''' if is_subagent_source(payload.get("source")) { + return Ok(None); + } +''', + "Codex intentional subagent filter", +) +replace_exact( + path, + " let session_id = session_id?;\n", + ''' let session_id = session_id.ok_or_else(|| { + format!("Codex session has no usable session id: {}", path.display()) + })?; +''', + "Codex missing-id corruption", +) +replace_exact( + path, + " Some(SessionMeta {\n", + " Ok(Some(SessionMeta {\n", + "Codex checked parser result", +) +replace_exact( + path, + ''' resume_command: Some(format!("codex resume {session_id}")), + }) +} + +fn is_subagent_source''', + ''' resume_command: Some(format!("codex resume {session_id}")), + })) +} + +#[cfg(test)] +fn parse_session(path: &Path) -> Option { + parse_session_checked(path).expect("parse Codex test session") +} + +fn is_subagent_source''', + "Codex test compatibility wrapper", +) + + +# --------------------------------------------------------------------------- +# Claude: agent-* histories are explicit policy filters. Other unreadable or +# structurally unusable histories are dirty files and remain observable. +# --------------------------------------------------------------------------- +path = "src-tauri/src/session_manager/providers/claude.rs" +replace_exact( + path, + ''' for path in files { + if let Some(meta) = parse_session(&path) { + sessions.push(meta); + } + } +''', + ''' for path in files { + match parse_session_checked(&path) { + Ok(Some(meta)) => sessions.push(meta), + Ok(None) => {} + Err(err) => log::warn!("Skipping unreadable Claude session {}: {err}", path.display()), + } + } +''', + "Claude dirty-session scan policy", +) +replace_exact( + path, + ''' let meta = parse_session(path).ok_or_else(|| { + format!( + "Failed to parse Claude session metadata: {}", + path.display() + ) + })?; +''', + ''' let meta = parse_session_checked(path)? + .ok_or_else(|| format!("Claude agent session is intentionally filtered: {}", path.display()))?; +''', + "Claude delete parser boundary", +) +replace_exact( + path, + "fn parse_session(path: &Path) -> Option {\n", + "fn parse_session_checked(path: &Path) -> Result, String> {\n", + "Claude checked parser signature", +) +replace_exact( + path, + ''' if is_agent_session(path) { + return None; + } + + let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; +''', + ''' if is_agent_session(path) { + return Ok(None); + } + + let (head, tail) = read_head_tail_lines(path, 10, 30) + .map_err(|err| format!("Failed to read Claude session {}: {err}", path.display()))?; +''', + "Claude filter and I/O semantics", +) +replace_exact( + path, + " let session_id = session_id?;\n", + ''' let session_id = session_id.ok_or_else(|| { + format!("Claude session has no usable session id: {}", path.display()) + })?; +''', + "Claude missing-id corruption", +) +replace_exact( + path, + " Some(SessionMeta {\n", + " Ok(Some(SessionMeta {\n", + "Claude checked parser result", +) +replace_exact( + path, + ''' resume_command: Some(format!("claude --resume {session_id}")), + }) +} + +fn is_agent_session''', + ''' resume_command: Some(format!("claude --resume {session_id}")), + })) +} + +#[cfg(test)] +fn parse_session(path: &Path) -> Option { + parse_session_checked(path).expect("parse Claude test session") +} + +fn is_agent_session''', + "Claude test compatibility wrapper", +) + + +# --------------------------------------------------------------------------- +# Gemini: no intentional filter exists in metadata parsing. Any unreadable or +# structurally unusable session is dirty history; warn+skip during listing. +# --------------------------------------------------------------------------- +path = "src-tauri/src/session_manager/providers/gemini.rs" +replace_exact( + path, + ''' if let Some(meta) = parse_session(&path) { + sessions.push(SessionMeta { + project_dir: project_dir.clone(), + ..meta + }); + } +''', + ''' match parse_session_checked(&path) { + Ok(meta) => sessions.push(SessionMeta { + project_dir: project_dir.clone(), + ..meta + }), + Err(err) => log::warn!("Skipping unreadable Gemini session {}: {err}", path.display()), + } +''', + "Gemini dirty-session scan policy", +) +replace_exact( + path, + ''' let meta = parse_session(path).ok_or_else(|| { + format!( + "Failed to parse Gemini session metadata: {}", + path.display() + ) + })?; +''', + " let meta = parse_session_checked(path)?;\n", + "Gemini delete parser boundary", +) +replace_exact( + path, + "fn parse_session(path: &Path) -> Option {\n", + "fn parse_session_checked(path: &Path) -> Result {\n", + "Gemini checked parser signature", +) +replace_exact( + path, + ''' let data = std::fs::read_to_string(path).ok()?; + let value: Value = serde_json::from_str(&data).ok()?; + + let session_id = value.get("sessionId").and_then(Value::as_str)?.to_string(); +''', + ''' let data = std::fs::read_to_string(path) + .map_err(|err| format!("Failed to read Gemini session {}: {err}", path.display()))?; + let value: Value = serde_json::from_str(&data) + .map_err(|err| format!("Failed to parse Gemini session {}: {err}", path.display()))?; + + let session_id = value + .get("sessionId") + .and_then(Value::as_str) + .ok_or_else(|| format!("Gemini session has no sessionId: {}", path.display()))? + .to_string(); +''', + "Gemini parser corruption semantics", +) +replace_exact( + path, + " Some(SessionMeta {\n", + " Ok(SessionMeta {\n", + "Gemini checked parser result", +) +replace_exact( + path, + ''' resume_command: Some(format!("gemini --resume {session_id}")), + }) +} + +#[cfg(test)]''', + ''' resume_command: Some(format!("gemini --resume {session_id}")), + }) +} + +#[cfg(test)] +fn parse_session(path: &Path) -> Option { + parse_session_checked(path).ok() +} + +#[cfg(test)]''', + "Gemini test compatibility wrapper", +) + + +# --------------------------------------------------------------------------- +# OpenClaw: session file I/O is a dirty-file error. JSONL records inside an +# otherwise readable history may be malformed legacy lines and remain skippable. +# --------------------------------------------------------------------------- +path = "src-tauri/src/session_manager/providers/openclaw.rs" +replace_exact( + path, + ''' if let Some(meta) = parse_session(&path, Some(&display_names)) { + sessions.push(meta); + } +''', + ''' match parse_session_checked(&path, Some(&display_names)) { + Ok(meta) => sessions.push(meta), + Err(err) => log::warn!("Skipping unreadable OpenClaw session {}: {err}", path.display()), + } +''', + "OpenClaw dirty-session scan policy", +) +replace_exact( + path, + ''' let meta = parse_session(path, None).ok_or_else(|| { + format!( + "Failed to parse OpenClaw session metadata: {}", + path.display() + ) + })?; +''', + " let meta = parse_session_checked(path, None)?;\n", + "OpenClaw delete parser boundary", +) +replace_exact( + path, + "fn parse_session(\n", + "fn parse_session_checked(\n", + "OpenClaw checked parser name", +) +replace_exact( + path, + ") -> Option {\n let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;\n", + ''') -> Result { + let (head, tail) = read_head_tail_lines(path, 10, 30) + .map_err(|err| format!("Failed to read OpenClaw session {}: {err}", path.display()))?; +''', + "OpenClaw checked parser signature", +) +replace_exact( + path, + " let session_id = session_id?;\n", + ''' let session_id = session_id.ok_or_else(|| { + format!("OpenClaw session has no usable session id: {}", path.display()) + })?; +''', + "OpenClaw missing-id corruption", +) +replace_exact( + path, + " Some(SessionMeta {\n", + " Ok(SessionMeta {\n", + "OpenClaw checked parser result", +) +# Preserve existing tests that exercise the legacy Option shape without exposing it to production. +replace_exact( + path, + ''' resume_command: Some(format!("openclaw --session {session_id}")), + }) +} + +#[cfg(test)]''', + ''' resume_command: Some(format!("openclaw --session {session_id}")), + }) +} + +#[cfg(test)] +fn parse_session( + path: &Path, + display_names: Option<&HashMap>, +) -> Option { + parse_session_checked(path, display_names).ok() +} + +#[cfg(test)]''', + "OpenClaw test compatibility wrapper", +) + + +# --------------------------------------------------------------------------- +# OpenCode JSON metadata has no intentional ignore state. Existing dirty JSON +# is warned+skipped; structural tree/SQLite failures remain provider errors. +# --------------------------------------------------------------------------- +path = "src-tauri/src/session_manager/providers/opencode.rs" +replace_exact( + path, + ''' for path in json_files { + if let Some(meta) = parse_session(&storage, &path) { + sessions.push(meta); + } + } +''', + ''' for path in json_files { + match parse_session_checked(&storage, &path) { + Ok(meta) => sessions.push(meta), + Err(err) => log::warn!("Skipping unreadable OpenCode session {}: {err}", path.display()), + } + } +''', + "OpenCode dirty-session scan policy", +) +replace_exact( + path, + "fn parse_session(storage: &Path, path: &Path) -> Option {\n", + "fn parse_session_checked(storage: &Path, path: &Path) -> Result {\n", + "OpenCode checked parser signature", +) +replace_exact( + path, + ''' let data = std::fs::read_to_string(path).ok()?; + let value: Value = serde_json::from_str(&data).ok()?; + + let session_id = value.get("id").and_then(Value::as_str)?.to_string(); +''', + ''' let data = std::fs::read_to_string(path) + .map_err(|err| format!("Failed to read OpenCode session {}: {err}", path.display()))?; + let value: Value = serde_json::from_str(&data) + .map_err(|err| format!("Failed to parse OpenCode session {}: {err}", path.display()))?; + + let session_id = value + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| format!("OpenCode session has no id: {}", path.display()))? + .to_string(); +''', + "OpenCode parser corruption semantics", +) +replace_exact( + path, + " Some(SessionMeta {\n", + " Ok(SessionMeta {\n", + "OpenCode checked parser result", +) +replace_exact( + path, + ''' resume_command: Some(format!("opencode session resume {session_id}")), + }) +} + +/// Read the first user message''', + ''' resume_command: Some(format!("opencode session resume {session_id}")), + }) +} + +#[cfg(test)] +fn parse_session(storage: &Path, path: &Path) -> Option { + parse_session_checked(storage, path).ok() +} + +/// Read the first user message''', + "OpenCode test compatibility wrapper", +) + + +# --------------------------------------------------------------------------- +# Message streaming: line-level I/O failure is not a malformed JSON record. +# Propagate I/O errors; malformed historical JSON records remain skippable. +# --------------------------------------------------------------------------- +for provider in ("codex", "claude", "openclaw"): + path = f"src-tauri/src/session_manager/providers/{provider}.rs" + text = read(path) + old = ''' let line = match line { + Ok(value) => value, + Err(_) => continue, + }; +''' + new = f''' let line = line.map_err(|err| {{ + format!("Failed to read {provider} session line from {{}}: {{err}}", path.display()) + }})?; +''' + if text.count(old) != 1: + raise SystemExit(f"{provider} message line I/O count={text.count(old)}") + write(path, text.replace(old, new, 1)) + +print("Applied session dirty-history parse semantics") From d8bc9299a7a35143d6449c14c2f57526e71a4d27 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 16:22:56 +0800 Subject: [PATCH 086/112] chore: add final design convergence stage --- scripts/apply_final_convergence_once.py | 83 +++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 scripts/apply_final_convergence_once.py diff --git a/scripts/apply_final_convergence_once.py b/scripts/apply_final_convergence_once.py new file mode 100644 index 00000000000..98772def94a --- /dev/null +++ b/scripts/apply_final_convergence_once.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +from pathlib import Path +import runpy + + +def read(path: str) -> str: + return Path(path).read_text(encoding="utf-8") + + +def write(path: str, text: str) -> None: + Path(path).write_text(text, encoding="utf-8") + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected 1 exact match, found {count}") + return text.replace(old, new, 1) + + +def remove_region(text: str, start: str, end: str, label: str) -> str: + if text.count(start) != 1: + raise SystemExit(f"{label}: start count={text.count(start)}") + i = text.index(start) + try: + j = text.index(end, i) + except ValueError: + raise SystemExit(f"{label}: end marker missing") + return text[:i] + text[j:] + + +# The typed root APIs have replaced these production helpers. Keep the two +# deterministic pure helpers only for the tests that still exercise injected +# path inputs; remove the obsolete expansion and infallible Store adapter. +config_path = "src-tauri/src/config.rs" +text = read(config_path) +text = replace_once( + text, + "fn require_absolute_path(path: PathBuf, label: &str) -> Result {", + "#[cfg(test)]\nfn require_absolute_path(path: PathBuf, label: &str) -> Result {", + "gate legacy absolute-path helper to tests", +) +text = replace_once( + text, + "fn resolve_home_dir(\n", + "#[cfg(test)]\nfn resolve_home_dir(\n", + "gate injected HOME resolver to tests", +) +text = remove_region( + text, + "/// Expand `~`, `~/...`, and `~\\\\...` through the same validated HOME boundary.\n", + "/// Resolve a user-configurable persistence/configuration root.\n", + "remove obsolete expand_home_path compatibility helper", +) +write(config_path, text) + +app_store_path = "src-tauri/src/app_store.rs" +text = read(app_store_path) +text = remove_region( + text, + "/// Legacy infallible adapter. It fails closed instead of turning a cached Store\n", + "fn open_paths_store(\n", + "remove obsolete infallible app-root cache adapter", +) +write(app_store_path, text) + +# Session scanning is a separate domain from provider installation discovery. +# First make structural failures observable; then distinguish dirty individual +# history files from intentional filters without letting one dirty file take +# down the whole provider. +runpy.run_path("scripts/apply_session_scan_semantics_once.py", run_name="__main__") +runpy.run_path("scripts/apply_session_parse_semantics_once.py", run_name="__main__") + +# These are one-shot migration mechanics. On a successful verified run they +# should disappear from the resulting branch along with the existing drivers. +for temporary in [ + "scripts/apply_session_scan_semantics_once.py", + "scripts/apply_session_parse_semantics_once.py", + "scripts/apply_final_convergence_once.py", +]: + Path(temporary).unlink() + +print("Applied final root/session design convergence") From 49b8d3db568ac9405b5e7135adbe0acda9c6eae2 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 16:23:25 +0800 Subject: [PATCH 087/112] chore: chain final root and session convergence --- scripts/apply_hermes_callers_once.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/apply_hermes_callers_once.py b/scripts/apply_hermes_callers_once.py index a4c0199021d..18ec2d26d19 100644 --- a/scripts/apply_hermes_callers_once.py +++ b/scripts/apply_hermes_callers_once.py @@ -145,4 +145,18 @@ def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) ''' write(guard, text.replace(marker, hermes_guard + marker, 1)) +# Arm the final convergence stage only after the typed root redesign has run. +# This keeps root-contract migration and Session-domain migration independently +# attributable while still finishing them in the same verified one-shot run. +finalizer = Path("scripts/apply_failure_semantics_redesign_once.py") +finalizer_text = finalizer.read_text(encoding="utf-8") +hook_marker = "# chained-final-design-convergence" +if hook_marker in finalizer_text: + raise SystemExit("final design convergence hook already installed") +finalizer.write_text( + finalizer_text + + '''\n\n# chained-final-design-convergence\nimport runpy\nrunpy.run_path("scripts/apply_final_convergence_once.py", run_name="__main__")\n''', + encoding="utf-8", +) + print("Applied Hermes production caller migration") From e0ab03f2e6a066e501cbec8acf0a17534c31247b Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 16:24:20 +0800 Subject: [PATCH 088/112] fix: make final convergence matcher robust --- scripts/apply_final_convergence_once.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/apply_final_convergence_once.py b/scripts/apply_final_convergence_once.py index 98772def94a..f3c97473d1d 100644 --- a/scripts/apply_final_convergence_once.py +++ b/scripts/apply_final_convergence_once.py @@ -48,7 +48,7 @@ def remove_region(text: str, start: str, end: str, label: str) -> str: ) text = remove_region( text, - "/// Expand `~`, `~/...`, and `~\\\\...` through the same validated HOME boundary.\n", + "/// Expand `~`, `~/...`", "/// Resolve a user-configurable persistence/configuration root.\n", "remove obsolete expand_home_path compatibility helper", ) From 4b6e42b32932dac2597886d87327aff0cf3b082c Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 16:24:51 +0800 Subject: [PATCH 089/112] chore: retrigger final convergence workflow --- scripts/apply_hermes_callers_once.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/apply_hermes_callers_once.py b/scripts/apply_hermes_callers_once.py index 18ec2d26d19..efec331c4dd 100644 --- a/scripts/apply_hermes_callers_once.py +++ b/scripts/apply_hermes_callers_once.py @@ -159,4 +159,5 @@ def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) encoding="utf-8", ) +# This file is intentionally touched whenever the chained finalizer contract changes. print("Applied Hermes production caller migration") From 4bac7ae29822faa08903cffb72c52e2a678cd05a Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 16:26:02 +0800 Subject: [PATCH 090/112] fix: align OpenClaw session parser migration --- scripts/apply_final_convergence_once.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scripts/apply_final_convergence_once.py b/scripts/apply_final_convergence_once.py index f3c97473d1d..5e585969c07 100644 --- a/scripts/apply_final_convergence_once.py +++ b/scripts/apply_final_convergence_once.py @@ -69,6 +69,22 @@ def remove_region(text: str, start: str, end: str, label: str) -> str: # history files from intentional filters without letting one dirty file take # down the whole provider. runpy.run_path("scripts/apply_session_scan_semantics_once.py", run_name="__main__") + +# OpenClaw sessions are gateway-managed and deliberately have no CLI resume +# command. The first draft of the parse migration encoded a non-existent resume +# command in its exact-match anchor; correct the migration driver, not the product. +parse_driver = Path("scripts/apply_session_parse_semantics_once.py") +parse_text = parse_driver.read_text(encoding="utf-8") +old_openclaw_resume = 'resume_command: Some(format!("openclaw --session {session_id}")),' +new_openclaw_resume = 'resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume' +if parse_text.count(old_openclaw_resume) != 2: + raise SystemExit( + f"OpenClaw parse-driver resume anchor count={parse_text.count(old_openclaw_resume)}" + ) +parse_driver.write_text( + parse_text.replace(old_openclaw_resume, new_openclaw_resume), + encoding="utf-8", +) runpy.run_path("scripts/apply_session_parse_semantics_once.py", run_name="__main__") # These are one-shot migration mechanics. On a successful verified run they From 1726da0cdc90a0d52ffb517cf0d39a4eda6ee518 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 16:26:31 +0800 Subject: [PATCH 091/112] chore: rerun convergence after OpenClaw driver fix --- scripts/apply_hermes_callers_once.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/apply_hermes_callers_once.py b/scripts/apply_hermes_callers_once.py index efec331c4dd..e35f797cccf 100644 --- a/scripts/apply_hermes_callers_once.py +++ b/scripts/apply_hermes_callers_once.py @@ -159,5 +159,5 @@ def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) encoding="utf-8", ) -# This file is intentionally touched whenever the chained finalizer contract changes. +# Retrigger marker: OpenClaw parse-driver anchor now matches gateway-managed sessions. print("Applied Hermes production caller migration") From b07d62008b9e6e1c31ab53b5858ab2136a3edb7b Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 16:29:31 +0800 Subject: [PATCH 092/112] fix: insert OpenClaw test adapter before index helper --- scripts/apply_final_convergence_once.py | 56 ++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/scripts/apply_final_convergence_once.py b/scripts/apply_final_convergence_once.py index 5e585969c07..4dddb0f82f2 100644 --- a/scripts/apply_final_convergence_once.py +++ b/scripts/apply_final_convergence_once.py @@ -71,8 +71,8 @@ def remove_region(text: str, start: str, end: str, label: str) -> str: runpy.run_path("scripts/apply_session_scan_semantics_once.py", run_name="__main__") # OpenClaw sessions are gateway-managed and deliberately have no CLI resume -# command. The first draft of the parse migration encoded a non-existent resume -# command in its exact-match anchor; correct the migration driver, not the product. +# command. Also, parse_session is followed by prune_sessions_index(), not the +# test module. Correct the migration driver's exact anchors before executing it. parse_driver = Path("scripts/apply_session_parse_semantics_once.py") parse_text = parse_driver.read_text(encoding="utf-8") old_openclaw_resume = 'resume_command: Some(format!("openclaw --session {session_id}")),' @@ -81,10 +81,54 @@ def remove_region(text: str, start: str, end: str, label: str) -> str: raise SystemExit( f"OpenClaw parse-driver resume anchor count={parse_text.count(old_openclaw_resume)}" ) -parse_driver.write_text( - parse_text.replace(old_openclaw_resume, new_openclaw_resume), - encoding="utf-8", -) +parse_text = parse_text.replace(old_openclaw_resume, new_openclaw_resume) + +old_anchor = '''''' resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume + }) +} + +#[cfg(test)]''','''''' +new_anchor = '''''' resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume + }) +} + +fn prune_sessions_index('''''' +if parse_text.count(old_anchor) != 1: + raise SystemExit(f"OpenClaw parse-driver source anchor count={parse_text.count(old_anchor)}") +parse_text = parse_text.replace(old_anchor, new_anchor, 1) + +old_replacement = '''''' resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume + }) +} + +#[cfg(test)] +fn parse_session( + path: &Path, + display_names: Option<&HashMap>, +) -> Option { + parse_session_checked(path, display_names).ok() +} + +#[cfg(test)]''','''''' +new_replacement = '''''' resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume + }) +} + +#[cfg(test)] +fn parse_session( + path: &Path, + display_names: Option<&HashMap>, +) -> Option { + parse_session_checked(path, display_names).ok() +} + +fn prune_sessions_index('''''' +if parse_text.count(old_replacement) != 1: + raise SystemExit( + f"OpenClaw parse-driver replacement anchor count={parse_text.count(old_replacement)}" + ) +parse_text = parse_text.replace(old_replacement, new_replacement, 1) +parse_driver.write_text(parse_text, encoding="utf-8") runpy.run_path("scripts/apply_session_parse_semantics_once.py", run_name="__main__") # These are one-shot migration mechanics. On a successful verified run they From a4f413bcc963fb6f6ef65d0b014b28815abb4b3b Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 16:30:04 +0800 Subject: [PATCH 093/112] fix: correct finalizer string quoting --- scripts/apply_final_convergence_once.py | 64 +++++++++++-------------- 1 file changed, 28 insertions(+), 36 deletions(-) diff --git a/scripts/apply_final_convergence_once.py b/scripts/apply_final_convergence_once.py index 4dddb0f82f2..a20af8ca4e8 100644 --- a/scripts/apply_final_convergence_once.py +++ b/scripts/apply_final_convergence_once.py @@ -83,46 +83,38 @@ def remove_region(text: str, start: str, end: str, label: str) -> str: ) parse_text = parse_text.replace(old_openclaw_resume, new_openclaw_resume) -old_anchor = '''''' resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume - }) -} - -#[cfg(test)]''','''''' -new_anchor = '''''' resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume - }) -} - -fn prune_sessions_index('''''' +old_anchor = ( + "''' resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume\n" + " })\n}\n\n#[cfg(test)]'''," +) +new_anchor = ( + "''' resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume\n" + " })\n}\n\nfn prune_sessions_index('''," +) if parse_text.count(old_anchor) != 1: raise SystemExit(f"OpenClaw parse-driver source anchor count={parse_text.count(old_anchor)}") parse_text = parse_text.replace(old_anchor, new_anchor, 1) -old_replacement = '''''' resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume - }) -} - -#[cfg(test)] -fn parse_session( - path: &Path, - display_names: Option<&HashMap>, -) -> Option { - parse_session_checked(path, display_names).ok() -} - -#[cfg(test)]''','''''' -new_replacement = '''''' resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume - }) -} - -#[cfg(test)] -fn parse_session( - path: &Path, - display_names: Option<&HashMap>, -) -> Option { - parse_session_checked(path, display_names).ok() -} - -fn prune_sessions_index('''''' +old_replacement = ( + "''' resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume\n" + " })\n}\n\n#[cfg(test)]\n" + "fn parse_session(\n" + " path: &Path,\n" + " display_names: Option<&HashMap>,\n" + ") -> Option {\n" + " parse_session_checked(path, display_names).ok()\n" + "}\n\n#[cfg(test)]'''," +) +new_replacement = ( + "''' resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume\n" + " })\n}\n\n#[cfg(test)]\n" + "fn parse_session(\n" + " path: &Path,\n" + " display_names: Option<&HashMap>,\n" + ") -> Option {\n" + " parse_session_checked(path, display_names).ok()\n" + "}\n\nfn prune_sessions_index('''," +) if parse_text.count(old_replacement) != 1: raise SystemExit( f"OpenClaw parse-driver replacement anchor count={parse_text.count(old_replacement)}" From 8187223bd2b57ffecc6c61ab41156f95b1a61b15 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 16:30:39 +0800 Subject: [PATCH 094/112] chore: rerun convergence with corrected OpenClaw boundary --- scripts/apply_hermes_callers_once.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/apply_hermes_callers_once.py b/scripts/apply_hermes_callers_once.py index e35f797cccf..3447503c859 100644 --- a/scripts/apply_hermes_callers_once.py +++ b/scripts/apply_hermes_callers_once.py @@ -159,5 +159,5 @@ def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) encoding="utf-8", ) -# Retrigger marker: OpenClaw parse-driver anchor now matches gateway-managed sessions. +# Retrigger marker: insert the OpenClaw test adapter before prune_sessions_index. print("Applied Hermes production caller migration") From ca0a949904d5f1922dac76867e65dc69ab281b36 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 16:32:51 +0800 Subject: [PATCH 095/112] fix: consume structural scanner closing braces exactly once --- scripts/apply_final_convergence_once.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/scripts/apply_final_convergence_once.py b/scripts/apply_final_convergence_once.py index a20af8ca4e8..75144195dcd 100644 --- a/scripts/apply_final_convergence_once.py +++ b/scripts/apply_final_convergence_once.py @@ -64,6 +64,22 @@ def remove_region(text: str, start: str, end: str, label: str) -> str: ) write(app_store_path, text) +# Structural scanner replacements emit their own closing brace. Their original +# end markers must start AFTER that brace, otherwise replace_region preserves a +# second `}`. Fix the migration driver before applying it. +scan_driver = Path("scripts/apply_session_scan_semantics_once.py") +scan_text = scan_driver.read_text(encoding="utf-8") +for old, new, label in [ + ('"\\n}\\n\\n#[cfg(test)]"', '"\\n\\n#[cfg(test)]"', "Codex traversal boundary"), + ('"\\n}\\n\\nfn remove_path_if_exists"', '"\\n\\nfn remove_path_if_exists"', "Claude traversal boundary"), + ('"\\n}\\n\\nfn parse_session("', '"\\n\\nfn parse_session("', "OpenClaw index boundary"), +]: + count = scan_text.count(old) + if count != 1: + raise SystemExit(f"{label}: expected 1 driver marker, found {count}") + scan_text = scan_text.replace(old, new, 1) +scan_driver.write_text(scan_text, encoding="utf-8") + # Session scanning is a separate domain from provider installation discovery. # First make structural failures observable; then distinguish dirty individual # history files from intentional filters without letting one dirty file take From 9f92d3e61289d5968b8918d5426e9a2c48ccf2bd Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 16:33:20 +0800 Subject: [PATCH 096/112] chore: rerun convergence after structural brace fix --- scripts/apply_hermes_callers_once.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/apply_hermes_callers_once.py b/scripts/apply_hermes_callers_once.py index 3447503c859..dc1ea6b2650 100644 --- a/scripts/apply_hermes_callers_once.py +++ b/scripts/apply_hermes_callers_once.py @@ -159,5 +159,5 @@ def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) encoding="utf-8", ) -# Retrigger marker: insert the OpenClaw test adapter before prune_sessions_index. +# Retrigger marker: structural scanner end markers now consume closing braces once. print("Applied Hermes production caller migration") From d92743828c9adbec5f6135d5a3cfb1fab87c97db Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 16:39:18 +0800 Subject: [PATCH 097/112] chore: validate generated session function boundaries --- scripts/apply_final_convergence_once.py | 40 +++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/scripts/apply_final_convergence_once.py b/scripts/apply_final_convergence_once.py index 75144195dcd..d27fdcf10b4 100644 --- a/scripts/apply_final_convergence_once.py +++ b/scripts/apply_final_convergence_once.py @@ -29,6 +29,24 @@ def remove_region(text: str, start: str, end: str, label: str) -> str: return text[:i] + text[j:] +def assert_balanced_region(path: str, start: str, end: str, label: str) -> None: + text = read(path) + if start not in text: + raise SystemExit(f"{label}: start marker missing after migration") + i = text.index(start) + if end not in text[i:]: + raise SystemExit(f"{label}: end marker missing after migration") + j = text.index(end, i) + block = text[i:j] + opens = block.count("{") + closes = block.count("}") + if opens != closes: + print(f"--- generated {label} block ---") + print(block) + print(f"--- end generated {label} block; braces={opens}/{closes} ---") + raise SystemExit(f"{label}: generated brace imbalance {opens} open / {closes} close") + + # The typed root APIs have replaced these production helpers. Keep the two # deterministic pure helpers only for the tests that still exercise injected # path inputs; remove the obsolete expansion and infallible Store adapter. @@ -139,6 +157,28 @@ def remove_region(text: str, start: str, end: str, label: str) -> str: parse_driver.write_text(parse_text, encoding="utf-8") runpy.run_path("scripts/apply_session_parse_semantics_once.py", run_name="__main__") +# Validate generated traversal/helper function boundaries immediately, before a +# full runner spends time installing native dependencies. These checks are only +# diagnostics for the one-shot migration machinery, not product semantics. +assert_balanced_region( + "src-tauri/src/session_manager/providers/codex.rs", + "fn collect_jsonl_files(", + "#[cfg(test)]", + "Codex collect_jsonl_files", +) +assert_balanced_region( + "src-tauri/src/session_manager/providers/claude.rs", + "fn collect_jsonl_files(", + "fn remove_path_if_exists", + "Claude collect_jsonl_files", +) +assert_balanced_region( + "src-tauri/src/session_manager/providers/openclaw.rs", + "fn load_display_names(", + "fn parse_session_checked(", + "OpenClaw load_display_names", +) + # These are one-shot migration mechanics. On a successful verified run they # should disappear from the resulting branch along with the existing drivers. for temporary in [ From e2b5715d44b0a3bd38cbe4b827a3193177c6b92f Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 16:39:45 +0800 Subject: [PATCH 098/112] chore: rerun convergence with generated-boundary diagnostics --- scripts/apply_hermes_callers_once.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/apply_hermes_callers_once.py b/scripts/apply_hermes_callers_once.py index dc1ea6b2650..dbda625f15a 100644 --- a/scripts/apply_hermes_callers_once.py +++ b/scripts/apply_hermes_callers_once.py @@ -159,5 +159,5 @@ def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) encoding="utf-8", ) -# Retrigger marker: structural scanner end markers now consume closing braces once. +# Retrigger marker: validate generated Session boundaries before native dependency setup. print("Applied Hermes production caller migration") From 68bc059468ef03ea373078f2d4046a443cc2db29 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 16:41:49 +0800 Subject: [PATCH 099/112] fix: make structural region replacement consume old function close --- scripts/apply_final_convergence_once.py | 77 ++++++++++++------------- 1 file changed, 37 insertions(+), 40 deletions(-) diff --git a/scripts/apply_final_convergence_once.py b/scripts/apply_final_convergence_once.py index d27fdcf10b4..d36cd3c4a66 100644 --- a/scripts/apply_final_convergence_once.py +++ b/scripts/apply_final_convergence_once.py @@ -47,6 +47,27 @@ def assert_balanced_region(path: str, start: str, end: str, label: str) -> None: raise SystemExit(f"{label}: generated brace imbalance {opens} open / {closes} close") +def assert_traversal_boundaries(stage: str) -> None: + assert_balanced_region( + "src-tauri/src/session_manager/providers/codex.rs", + "fn collect_jsonl_files(", + "#[cfg(test)]", + f"Codex collect_jsonl_files ({stage})", + ) + assert_balanced_region( + "src-tauri/src/session_manager/providers/claude.rs", + "fn collect_jsonl_files(", + "fn remove_path_if_exists", + f"Claude collect_jsonl_files ({stage})", + ) + assert_balanced_region( + "src-tauri/src/session_manager/providers/openclaw.rs", + "fn load_display_names(", + "fn parse_session", + f"OpenClaw load_display_names ({stage})", + ) + + # The typed root APIs have replaced these production helpers. Keep the two # deterministic pure helpers only for the tests that still exercise injected # path inputs; remove the obsolete expansion and infallible Store adapter. @@ -82,31 +103,28 @@ def assert_balanced_region(path: str, start: str, end: str, label: str) -> None: ) write(app_store_path, text) -# Structural scanner replacements emit their own closing brace. Their original -# end markers must start AFTER that brace, otherwise replace_region preserves a -# second `}`. Fix the migration driver before applying it. +# The structural migration's replacement blocks include their own final `}`. +# Its generic replace_region originally retained an end marker beginning with +# `\n}`, duplicating that close. Consume exactly that old function close while +# retaining everything after it (test/module annotations or the next helper). scan_driver = Path("scripts/apply_session_scan_semantics_once.py") scan_text = scan_driver.read_text(encoding="utf-8") -for old, new, label in [ - ('"\\n}\\n\\n#[cfg(test)]"', '"\\n\\n#[cfg(test)]"', "Codex traversal boundary"), - ('"\\n}\\n\\nfn remove_path_if_exists"', '"\\n\\nfn remove_path_if_exists"', "Claude traversal boundary"), - ('"\\n}\\n\\nfn parse_session("', '"\\n\\nfn parse_session("', "OpenClaw index boundary"), -]: - count = scan_text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected 1 driver marker, found {count}") - scan_text = scan_text.replace(old, new, 1) -scan_driver.write_text(scan_text, encoding="utf-8") +old_replace = " write(path, text[:i] + new + text[j:])\n" +new_replace = ''' if end.startswith("\\n}") and new.rstrip().endswith("}"): + j += 2 + write(path, text[:i] + new + text[j:]) +''' +if scan_text.count(old_replace) != 1: + raise SystemExit(f"session structural replace_region body count={scan_text.count(old_replace)}") +scan_driver.write_text(scan_text.replace(old_replace, new_replace, 1), encoding="utf-8") # Session scanning is a separate domain from provider installation discovery. -# First make structural failures observable; then distinguish dirty individual -# history files from intentional filters without letting one dirty file take -# down the whole provider. runpy.run_path("scripts/apply_session_scan_semantics_once.py", run_name="__main__") +assert_traversal_boundaries("after structural migration") # OpenClaw sessions are gateway-managed and deliberately have no CLI resume # command. Also, parse_session is followed by prune_sessions_index(), not the -# test module. Correct the migration driver's exact anchors before executing it. +# test module. Correct the dirty-parser migration driver's exact anchors. parse_driver = Path("scripts/apply_session_parse_semantics_once.py") parse_text = parse_driver.read_text(encoding="utf-8") old_openclaw_resume = 'resume_command: Some(format!("openclaw --session {session_id}")),' @@ -156,31 +174,10 @@ def assert_balanced_region(path: str, start: str, end: str, label: str) -> None: parse_text = parse_text.replace(old_replacement, new_replacement, 1) parse_driver.write_text(parse_text, encoding="utf-8") runpy.run_path("scripts/apply_session_parse_semantics_once.py", run_name="__main__") - -# Validate generated traversal/helper function boundaries immediately, before a -# full runner spends time installing native dependencies. These checks are only -# diagnostics for the one-shot migration machinery, not product semantics. -assert_balanced_region( - "src-tauri/src/session_manager/providers/codex.rs", - "fn collect_jsonl_files(", - "#[cfg(test)]", - "Codex collect_jsonl_files", -) -assert_balanced_region( - "src-tauri/src/session_manager/providers/claude.rs", - "fn collect_jsonl_files(", - "fn remove_path_if_exists", - "Claude collect_jsonl_files", -) -assert_balanced_region( - "src-tauri/src/session_manager/providers/openclaw.rs", - "fn load_display_names(", - "fn parse_session_checked(", - "OpenClaw load_display_names", -) +assert_traversal_boundaries("after dirty-history migration") # These are one-shot migration mechanics. On a successful verified run they -# should disappear from the resulting branch along with the existing drivers. +# disappear from the resulting branch along with the existing drivers. for temporary in [ "scripts/apply_session_scan_semantics_once.py", "scripts/apply_session_parse_semantics_once.py", From 22837f3a5e54f03ee5be9640ec32e903e72a3034 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 16:42:17 +0800 Subject: [PATCH 100/112] chore: rerun convergence with deterministic structural replacement --- scripts/apply_hermes_callers_once.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/apply_hermes_callers_once.py b/scripts/apply_hermes_callers_once.py index dbda625f15a..db8a7ad0d02 100644 --- a/scripts/apply_hermes_callers_once.py +++ b/scripts/apply_hermes_callers_once.py @@ -159,5 +159,5 @@ def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) encoding="utf-8", ) -# Retrigger marker: validate generated Session boundaries before native dependency setup. +# Retrigger marker: structural replacement now consumes the old function close explicitly. print("Applied Hermes production caller migration") From 7938cf4dcb0ede1bce9f1cb2c72e1460c2e6685e Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 21:20:23 +0800 Subject: [PATCH 101/112] fix: remove obsolete session parser compatibility wrappers --- scripts/apply_final_convergence_once.py | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/scripts/apply_final_convergence_once.py b/scripts/apply_final_convergence_once.py index d36cd3c4a66..041fe899f7b 100644 --- a/scripts/apply_final_convergence_once.py +++ b/scripts/apply_final_convergence_once.py @@ -176,6 +176,34 @@ def assert_traversal_boundaries(stage: str) -> None: runpy.run_path("scripts/apply_session_parse_semantics_once.py", run_name="__main__") assert_traversal_boundaries("after dirty-history migration") +# Gemini and OpenCode no longer have any test consumers for the legacy Option +# parser shape. Remove those wrappers instead of suppressing dead-code warnings. +for path, wrapper, label in [ + ( + "src-tauri/src/session_manager/providers/gemini.rs", + '''#[cfg(test)] +fn parse_session(path: &Path) -> Option { + parse_session_checked(path).ok() +} + +''', + "remove obsolete Gemini parser compatibility wrapper", + ), + ( + "src-tauri/src/session_manager/providers/opencode.rs", + '''#[cfg(test)] +fn parse_session(storage: &Path, path: &Path) -> Option { + parse_session_checked(storage, path).ok() +} + +''', + "remove obsolete OpenCode parser compatibility wrapper", + ), +]: + text = read(path) + text = replace_once(text, wrapper, "", label) + write(path, text) + # These are one-shot migration mechanics. On a successful verified run they # disappear from the resulting branch along with the existing drivers. for temporary in [ From 7277e0bad74f40e2b16c00cbf6267d5a85126d61 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Mon, 7 Sep 2026 21:22:32 +0800 Subject: [PATCH 102/112] chore: retrigger verified convergence after session wrapper cleanup --- scripts/apply_hermes_callers_once.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/apply_hermes_callers_once.py b/scripts/apply_hermes_callers_once.py index db8a7ad0d02..db6c28afa05 100644 --- a/scripts/apply_hermes_callers_once.py +++ b/scripts/apply_hermes_callers_once.py @@ -159,5 +159,5 @@ def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) encoding="utf-8", ) -# Retrigger marker: structural replacement now consumes the old function close explicitly. +# Retrigger marker: rerun strict convergence after removing unused Gemini/OpenCode parser wrappers. print("Applied Hermes production caller migration") From 4adb1198b14e9e37b740d0b334e030e585bc09a0 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Tue, 8 Sep 2026 14:47:28 +0800 Subject: [PATCH 103/112] fix: make one-shot cleanup converge after verified migration --- scripts/apply_hermes_callers_once.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/apply_hermes_callers_once.py b/scripts/apply_hermes_callers_once.py index db6c28afa05..3c34a168a7d 100644 --- a/scripts/apply_hermes_callers_once.py +++ b/scripts/apply_hermes_callers_once.py @@ -155,7 +155,7 @@ def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) raise SystemExit("final design convergence hook already installed") finalizer.write_text( finalizer_text - + '''\n\n# chained-final-design-convergence\nimport runpy\nrunpy.run_path("scripts/apply_final_convergence_once.py", run_name="__main__")\n''', + + '''\n\n# chained-final-design-convergence\nimport runpy\nrunpy.run_path("scripts/apply_final_convergence_once.py", run_name="__main__")\n\n# Restore one-shot drivers that the workflow intentionally rewrites before final git rm.\n# Their migrations have already executed; keeping the tracked copies clean lets the\n# verified cleanup step remove them without forcing unrelated product files.\nimport subprocess\nsubprocess.run(\n [\n "git",\n "restore",\n "--source=HEAD",\n "--worktree",\n "--",\n "scripts/apply_design_invariant_hardening_once.py",\n "scripts/apply_design_invariant_followup_once.py",\n "scripts/apply_hermes_failure_boundary_once.py",\n "scripts/apply_failure_semantics_redesign_once.py",\n ],\n check=True,\n)\n\n# This helper is also one-shot machinery but was omitted from the workflow's git rm list.\nfrom pathlib import Path as _CleanupPath\n_CleanupPath("scripts/apply_final_convergence_once.py").unlink()\n''', encoding="utf-8", ) From e4ffb05ef75b8a7f0539e5516347dd1083c7784c Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Tue, 8 Sep 2026 14:48:42 +0800 Subject: [PATCH 104/112] fix: avoid duplicate one-shot helper cleanup --- scripts/apply_hermes_callers_once.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/apply_hermes_callers_once.py b/scripts/apply_hermes_callers_once.py index 3c34a168a7d..22ce42b00ea 100644 --- a/scripts/apply_hermes_callers_once.py +++ b/scripts/apply_hermes_callers_once.py @@ -155,7 +155,7 @@ def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) raise SystemExit("final design convergence hook already installed") finalizer.write_text( finalizer_text - + '''\n\n# chained-final-design-convergence\nimport runpy\nrunpy.run_path("scripts/apply_final_convergence_once.py", run_name="__main__")\n\n# Restore one-shot drivers that the workflow intentionally rewrites before final git rm.\n# Their migrations have already executed; keeping the tracked copies clean lets the\n# verified cleanup step remove them without forcing unrelated product files.\nimport subprocess\nsubprocess.run(\n [\n "git",\n "restore",\n "--source=HEAD",\n "--worktree",\n "--",\n "scripts/apply_design_invariant_hardening_once.py",\n "scripts/apply_design_invariant_followup_once.py",\n "scripts/apply_hermes_failure_boundary_once.py",\n "scripts/apply_failure_semantics_redesign_once.py",\n ],\n check=True,\n)\n\n# This helper is also one-shot machinery but was omitted from the workflow's git rm list.\nfrom pathlib import Path as _CleanupPath\n_CleanupPath("scripts/apply_final_convergence_once.py").unlink()\n''', + + '''\n\n# chained-final-design-convergence\nimport runpy\nrunpy.run_path("scripts/apply_final_convergence_once.py", run_name="__main__")\n\n# Restore one-shot drivers that the workflow intentionally rewrites before final git rm.\n# Their migrations have already executed; keeping the tracked copies clean lets the\n# verified cleanup step remove them without forcing unrelated product files.\nimport subprocess\nsubprocess.run(\n [\n "git",\n "restore",\n "--source=HEAD",\n "--worktree",\n "--",\n "scripts/apply_design_invariant_hardening_once.py",\n "scripts/apply_design_invariant_followup_once.py",\n "scripts/apply_hermes_failure_boundary_once.py",\n "scripts/apply_failure_semantics_redesign_once.py",\n ],\n check=True,\n)\n''', encoding="utf-8", ) From c3de758b59b5b498acf3f82af38b566568916bb5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:59:10 +0000 Subject: [PATCH 105/112] fix: enforce typed persistence failure semantics --- .../design-invariant-hardening-once.yml | 314 -------- .../apply_design_invariant_followup_once.py | 489 ------------ .../apply_design_invariant_hardening_once.py | 735 ------------------ .../apply_failure_semantics_redesign_once.py | 477 ------------ scripts/apply_final_convergence_once.py | 216 ----- scripts/apply_hermes_callers_once.py | 163 ---- scripts/apply_hermes_failure_boundary_once.py | 365 --------- scripts/apply_session_parse_semantics_once.py | 506 ------------ scripts/apply_session_scan_semantics_once.py | 505 ------------ scripts/check_rust_failure_boundaries.py | 29 + src-tauri/src/app_store.rs | 227 +++--- src-tauri/src/commands/config.rs | 12 +- src-tauri/src/commands/misc.rs | 122 ++- src-tauri/src/commands/session_manager.rs | 6 +- src-tauri/src/commands/settings.rs | 5 +- src-tauri/src/config.rs | 149 ++-- src-tauri/src/database/mod.rs | 6 +- src-tauri/src/error.rs | 4 + src-tauri/src/failure_semantics.rs | 42 + src-tauri/src/hermes_config.rs | 86 +- src-tauri/src/lib.rs | 12 +- src-tauri/src/mcp/hermes.rs | 8 +- src-tauri/src/prompt_files.rs | 2 +- src-tauri/src/services/model_fetch.rs | 42 +- src-tauri/src/services/provider/live.rs | 4 +- src-tauri/src/services/skill.rs | 21 +- src-tauri/src/session_manager/mod.rs | 105 ++- .../src/session_manager/providers/claude.rs | 72 +- .../src/session_manager/providers/codex.rs | 76 +- .../src/session_manager/providers/gemini.rs | 79 +- .../src/session_manager/providers/hermes.rs | 154 ++-- .../src/session_manager/providers/openclaw.rs | 107 ++- .../src/session_manager/providers/opencode.rs | 207 +++-- .../src/session_manager/providers/utils.rs | 9 +- src-tauri/src/settings.rs | 143 ++-- 35 files changed, 1040 insertions(+), 4459 deletions(-) delete mode 100644 .github/workflows/design-invariant-hardening-once.yml delete mode 100644 scripts/apply_design_invariant_followup_once.py delete mode 100644 scripts/apply_design_invariant_hardening_once.py delete mode 100644 scripts/apply_failure_semantics_redesign_once.py delete mode 100644 scripts/apply_final_convergence_once.py delete mode 100644 scripts/apply_hermes_callers_once.py delete mode 100644 scripts/apply_hermes_failure_boundary_once.py delete mode 100644 scripts/apply_session_parse_semantics_once.py delete mode 100644 scripts/apply_session_scan_semantics_once.py create mode 100644 src-tauri/src/failure_semantics.rs diff --git a/.github/workflows/design-invariant-hardening-once.yml b/.github/workflows/design-invariant-hardening-once.yml deleted file mode 100644 index 9e8653e044d..00000000000 --- a/.github/workflows/design-invariant-hardening-once.yml +++ /dev/null @@ -1,314 +0,0 @@ -name: Design Invariant Hardening Once - -on: - push: - branches: - - fix/global-hardening-20260904 - paths: - - .github/workflows/design-invariant-hardening-once.yml - - scripts/apply_design_invariant_hardening_once.py - - scripts/apply_design_invariant_followup_once.py - - scripts/apply_hermes_failure_boundary_once.py - - scripts/apply_hermes_callers_once.py - - scripts/apply_failure_semantics_redesign_once.py - -permissions: - contents: write - -jobs: - apply-and-verify: - if: github.repository == 'z13321812367-sys/ccswitchmulti' - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v6 - with: - ref: fix/global-hardening-20260904 - fetch-depth: 0 - - - name: Keep primary driver focused on product sources - run: | - python - <<'PY' - from pathlib import Path - path = Path("scripts/apply_design_invariant_hardening_once.py") - text = path.read_text(encoding="utf-8") - marker = "# ---------------------------------------------------------------------------\n# Permanent source-level guards encode the design invariants" - if text.count(marker) != 1: - raise SystemExit(f"design-driver policy boundary count={text.count(marker)}") - path.write_text(text[:text.index(marker)] + 'print("Applied design-invariant product patch")\n', encoding="utf-8") - PY - - - name: Apply common product invariants - run: python scripts/apply_design_invariant_hardening_once.py - - - name: Align structural follow-up with both Skill HOME callers - run: | - python - <<'PY' - from pathlib import Path - path = Path("scripts/apply_design_invariant_followup_once.py") - text = path.read_text(encoding="utf-8") - old = ' "skill app HOME",\n)\n' - new = ' "skill app HOME",\n expected=2,\n)\n' - if text.count(old) != 1: - raise SystemExit(f"skill HOME expectation marker count={text.count(old)}") - path.write_text(text.replace(old, new, 1), encoding="utf-8") - PY - - - name: Apply fallible caller and discovery invariants - run: python scripts/apply_design_invariant_followup_once.py - - - name: Normalize Windows path literal emitted by follow-up - run: | - python - <<'PY' - from pathlib import Path - path = Path("src-tauri/src/commands/misc.rs") - text = path.read_text(encoding="utf-8") - old = r'std::path::PathBuf::from("C:\Program Files\nodejs")' - new = r'std::path::PathBuf::from(r"C:\Program Files\nodejs")' - if text.count(old) != 1: - raise SystemExit(f"Windows nodejs literal count={text.count(old)}") - path.write_text(text.replace(old, new, 1), encoding="utf-8") - PY - - - name: Make Skill persistence guard reject only infallible wrappers - run: | - python - <<'PY' - from pathlib import Path - path = Path("scripts/check_rust_failure_boundaries.py") - text = path.read_text(encoding="utf-8") - old = r're.compile(r"(?:get_app_config_dir|crate::config::get_home_dir)\(\)")' - new = r're.compile(r"\bget_app_config_dir\(\)|crate::config::get_home_dir\(\)")' - if text.count(old) != 1: - raise SystemExit(f"Skill guard matcher count={text.count(old)}") - path.write_text(text.replace(old, new, 1), encoding="utf-8") - PY - - - name: Align OpenCode tests with fallible scan contract - run: | - python - <<'PY' - from pathlib import Path - path = Path("src-tauri/src/session_manager/providers/opencode.rs") - text = path.read_text(encoding="utf-8") - old = " let sessions = scan_sessions_sqlite();\n" - new = " let sessions = scan_sessions_sqlite().expect(\"scan sqlite sessions\");\n" - if text.count(old) != 1: - raise SystemExit(f"OpenCode successful scan test count={text.count(old)}") - text = text.replace(old, new, 1) - anchor = " #[test]\n fn load_messages_sqlite_reads_messages_and_parts() {\n" - if text.count(anchor) != 1: - raise SystemExit(f"OpenCode failure-test anchor count={text.count(anchor)}") - failure_test = ''' #[test] - #[allow(deprecated)] - fn scan_sessions_sqlite_surfaces_schema_errors() { - let _guard = opencode_env_lock().lock().expect("lock"); - let temp = tempdir().expect("tempdir"); - let original_xdg = std::env::var_os("XDG_DATA_HOME"); - std::env::set_var("XDG_DATA_HOME", temp.path()); - let base_dir = temp.path().join("opencode"); - std::fs::create_dir_all(&base_dir).expect("create base dir"); - let db_path = base_dir.join("opencode.db"); - let conn = Connection::open(&db_path).expect("open sqlite db"); - conn.execute_batch("CREATE TABLE unrelated (id TEXT PRIMARY KEY);") - .expect("create incompatible schema"); - drop(conn); - let result = scan_sessions_sqlite(); - if let Some(value) = original_xdg { - std::env::set_var("XDG_DATA_HOME", value); - } else { - std::env::remove_var("XDG_DATA_HOME"); - } - let err = result.expect_err("missing session table must be observable"); - assert!(err.contains("Failed to prepare OpenCode session query"), "{err}"); - } - - ''' - path.write_text(text.replace(anchor, failure_test + anchor, 1), encoding="utf-8") - PY - - - name: Scope Hermes config-write transform to production helper - run: | - python - <<'PY' - from pathlib import Path - path = Path("scripts/apply_hermes_failure_boundary_once.py") - text = path.read_text(encoding="utf-8") - old = '''replace_once( - path, - " let config_path = get_hermes_config_path();\\n", - " let config_path = try_get_hermes_config_path()?;\\n", - "Hermes write config path", - )''' - new = '''replace_once( - path, - """fn write_yaml_section_to_config_locked( - section_key: &str, - value: &serde_yaml::Value, - ) -> Result { - let config_path = get_hermes_config_path(); - """, - """fn write_yaml_section_to_config_locked( - section_key: &str, - value: &serde_yaml::Value, - ) -> Result { - let config_path = try_get_hermes_config_path()?; - """, - "Hermes write config path", - )''' - if text.count(old) != 1: - raise SystemExit(f"Hermes write-transform driver count={text.count(old)}") - path.write_text(text.replace(old, new, 1), encoding="utf-8") - PY - - - name: Apply Hermes fallible failure boundary - run: python scripts/apply_hermes_failure_boundary_once.py - - - name: Migrate Hermes production callers - run: python scripts/apply_hermes_callers_once.py - - - name: Apply typed failure-semantics redesign - run: python scripts/apply_failure_semantics_redesign_once.py - - - name: Complete typed-root implementation migration - run: | - python - <<'PY' - from pathlib import Path - - semantics = Path("src-tauri/src/failure_semantics.rs") - text = semantics.read_text(encoding="utf-8") - # `thiserror` reserves a field literally named `source` for chained errors. - # This field is a human-readable root origin, so name it `origin` instead. - text = text.replace("source", "origin") - semantics.write_text(text, encoding="utf-8") - - config = Path("src-tauri/src/config.rs") - text = config.read_text(encoding="utf-8") - old = ' source: "user home".to_string(),\n' - new = ' origin: "user home".to_string(),\n' - if text.count(old) != 1: - raise SystemExit(f"typed HOME origin field count={text.count(old)}") - config.write_text(text.replace(old, new, 1), encoding="utf-8") - - misc = Path("src-tauri/src/commands/misc.rs") - text = misc.read_text(encoding="utf-8") - replacements = [ - ( - "opencode_extra_search_paths(&home, install_dir, xdg_bin_dir, gopath)", - "opencode_extra_search_paths(Some(&home), install_dir, xdg_bin_dir, gopath)", - ), - ( - "opencode_extra_search_paths(&home, same_dir.clone(), same_dir, None)", - "opencode_extra_search_paths(Some(&home), same_dir.clone(), same_dir, None)", - ), - ] - for old, new in replacements: - if text.count(old) != 1: - raise SystemExit(f"OpenCode optional HOME caller count={text.count(old)}: {old}") - text = text.replace(old, new, 1) - misc.write_text(text, encoding="utf-8") - PY - - - name: Scope Hermes guards to production function bodies - run: | - python - <<'PY' - from pathlib import Path - path = Path("scripts/check_rust_failure_boundaries.py") - text = path.read_text(encoding="utf-8") - old_read = r're.compile(r"pub fn read_hermes_config\([^)]*\) -> Result[\s\S]{0,240}get_hermes_config_path\(\)")' - new_read = r're.compile(r"pub fn read_hermes_config\(\) -> Result \{\s*let path = get_hermes_config_path\(\);")' - old_write = r're.compile(r"let config_path = get_hermes_config_path\(\);")' - new_write = r're.compile(r"fn write_yaml_section_to_config_locked\([\s\S]{0,220}\) -> Result \{\s*let config_path = get_hermes_config_path\(\);")' - if text.count(old_read) != 1: - raise SystemExit(f"Hermes read guard count={text.count(old_read)}") - if text.count(old_write) != 1: - raise SystemExit(f"Hermes write guard count={text.count(old_write)}") - text = text.replace(old_read, new_read, 1).replace(old_write, new_write, 1) - path.write_text(text, encoding="utf-8") - PY - - - name: Install Linux system dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential pkg-config libssl-dev \ - libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev - sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \ - || sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev - sudo apt-get install -y --no-install-recommends libsoup-3.0-dev \ - || sudo apt-get install -y --no-install-recommends libsoup2.4-dev - - - name: Format Rust - run: cargo fmt --manifest-path src-tauri/Cargo.toml --all - - - name: Verify permanent design policies - run: | - python scripts/check_workflow_shell_interpolation.py - python scripts/check_rust_failure_boundaries.py - git diff --check - - - name: Create frontend dist placeholder - run: mkdir -p dist - - - name: Clippy all targets and features - run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --all-features -- -D warnings - - - name: Rust tests default feature set - id: default_rust_tests - run: | - set -o pipefail - log="$RUNNER_TEMP/default-rust-tests.log" - diagnostic="$RUNNER_TEMP/default-rust-tests-diagnostic.txt" - status=0 - cargo test --manifest-path src-tauri/Cargo.toml --all >"$log" 2>&1 || status=$? - if [ "$status" -ne 0 ]; then - python - "$log" "$diagnostic" <<'PY' - from pathlib import Path - import re - import sys - - source = Path(sys.argv[1]).read_text(encoding="utf-8", errors="replace").splitlines() - out = [] - headers = [i for i, line in enumerate(source) if re.match(r"^---- .+ stdout ----$", line)] - for start in headers[:4]: - end = min(len(source), start + 90) - block = source[start:end] - if any("panicked at" in line or "assertion" in line or "FAILED" in line for line in block): - out.extend(block) - out.append("") - failure_markers = [i for i, line in enumerate(source) if line.strip() == "failures:"] - if failure_markers: - start = failure_markers[-1] - out.extend(source[start:min(len(source), start + 80)]) - out.append("") - out.append("--- tail ---") - out.extend(source[-140:]) - Path(sys.argv[2]).write_text("\n".join(out[-420:]) + "\n", encoding="utf-8") - PY - cat "$diagnostic" - fi - exit "$status" - - - name: Upload bounded default-test diagnostic - if: steps.default_rust_tests.outcome == 'failure' - uses: actions/upload-artifact@v4 - with: - name: design-invariant-default-test-diagnostic - path: ${{ runner.temp }}/default-rust-tests-diagnostic.txt - if-no-files-found: error - retention-days: 7 - - - name: Rust tests all features - run: cargo test --manifest-path src-tauri/Cargo.toml --all-features - - - name: Commit verified design fixes and remove one-shot machinery - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git rm \ - scripts/apply_design_invariant_hardening_once.py \ - scripts/apply_design_invariant_followup_once.py \ - scripts/apply_hermes_failure_boundary_once.py \ - scripts/apply_hermes_callers_once.py \ - scripts/apply_failure_semantics_redesign_once.py \ - .github/workflows/design-invariant-hardening-once.yml - git add -A - git diff --cached --check - git commit -m "fix: enforce typed persistence failure semantics" - git push origin HEAD:fix/global-hardening-20260904 diff --git a/scripts/apply_design_invariant_followup_once.py b/scripts/apply_design_invariant_followup_once.py deleted file mode 100644 index 5c04a04b807..00000000000 --- a/scripts/apply_design_invariant_followup_once.py +++ /dev/null @@ -1,489 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import re - - -def read(path: str) -> str: - return Path(path).read_text(encoding="utf-8") - - -def write(path: str, text: str) -> None: - Path(path).write_text(text, encoding="utf-8") - - -def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) -> None: - text = read(path) - count = text.count(old) - if count != expected: - raise SystemExit(f"{label}: expected {expected} matches, found {count}") - write(path, text.replace(old, new)) - - -def replace_region(path: str, start: str, end: str, new: str, label: str) -> None: - text = read(path) - if text.count(start) != 1: - raise SystemExit(f"{label}: start count={text.count(start)}") - i = text.index(start) - try: - j = text.index(end, i) - except ValueError: - raise SystemExit(f"{label}: end marker missing") - write(path, text[:i] + new + text[j:]) - - -# Command boundary: Option means genuine absence; Result means Store/path failure. -replace_region( - "src-tauri/src/commands/settings.rs", - "pub async fn get_app_config_dir_override(app: AppHandle) -> Result, String> {", - "\n}\n\n/// 设置 app_config_dir 覆盖配置", - '''pub async fn get_app_config_dir_override(app: AppHandle) -> Result, String> { - let value = crate::app_store::refresh_app_config_dir_override(&app) - .map_err(|err| err.to_string())?; - Ok(value.map(|path| path.to_string_lossy().to_string())) -''', - "settings command propagation", -) - - -# Skill path APIs already return Result, so they must not hide panic-based path wrappers. -replace_exact( - "src-tauri/src/services/skill.rs", - "use crate::config::get_app_config_dir;\n", - "", - "remove infallible skill import", -) -replace_exact( - "src-tauri/src/services/skill.rs", - 'SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),', - '''SkillStorageLocation::CcSwitch => crate::config::try_get_app_config_dir() - .map_err(|err| anyhow!(err))? - .join("skills"),''', - "skill cc-switch roots", - expected=2, -) -replace_exact( - "src-tauri/src/services/skill.rs", - 'let dir = get_app_config_dir().join("skill-backups");', - '''let dir = crate::config::try_get_app_config_dir() - .map_err(|err| anyhow!(err))? - .join("skill-backups");''', - "skill backup root", -) -# The remaining infallible HOME call in this module is the default app skills root. -replace_exact( - "src-tauri/src/services/skill.rs", - "let home = crate::config::get_home_dir();", - "let home = crate::config::try_get_home_dir().map_err(|err| anyhow!(err))?;", - "skill app HOME", -) - - -# CLI discovery is best-effort: loss of HOME removes HOME-scoped candidates, not the feature. -replace_region( - "src-tauri/src/commands/misc.rs", - "fn build_tool_search_paths(tool: &str) -> Vec {", - '\n#[cfg(target_os = "windows")]\nfn is_windows_command_script', - '''fn build_tool_search_paths(tool: &str) -> Vec { - let home = crate::config::try_get_home_dir().ok(); - let mut search_paths: Vec = Vec::new(); - - if let Some(home) = home.as_ref() { - push_unique_path(&mut search_paths, home.join(".local/bin")); - push_unique_path(&mut search_paths, home.join(".npm-global/bin")); - push_unique_path(&mut search_paths, home.join("n/bin")); - push_unique_path(&mut search_paths, home.join(".volta/bin")); - extend_mise_node_search_paths(&mut search_paths, home); - - for base in [home.join(".local/state/fnm_multishells"), home.join(".nvm/versions/node")] { - if let Ok(entries) = std::fs::read_dir(&base) { - for entry in entries.flatten() { - let bin_path = entry.path().join("bin"); - if bin_path.exists() { - push_unique_path(&mut search_paths, bin_path); - } - } - } - } - } else { - log::warn!("HOME unavailable while discovering CLI tools; skipping home-scoped candidates"); - } - - #[cfg(target_os = "macos")] - { - push_unique_path(&mut search_paths, std::path::PathBuf::from("/opt/homebrew/bin")); - push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/local/bin")); - if tool == "hermes" { - if let Some(home) = home.as_ref() { - let python_base = home.join("Library").join("Python"); - if let Ok(entries) = std::fs::read_dir(&python_base) { - for entry in entries.flatten() { - let bin_path = entry.path().join("bin"); - if bin_path.exists() { - push_unique_path(&mut search_paths, bin_path); - } - } - } - } - } - } - - #[cfg(target_os = "linux")] - { - push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/local/bin")); - push_unique_path(&mut search_paths, std::path::PathBuf::from("/usr/bin")); - } - - #[cfg(target_os = "windows")] - { - if let Some(appdata) = dirs::data_dir() { - push_unique_path(&mut search_paths, appdata.join("npm")); - if tool == "hermes" { - let python_base = appdata.join("Python"); - if let Ok(entries) = std::fs::read_dir(&python_base) { - for entry in entries.flatten() { - let scripts_path = entry.path().join("Scripts"); - if scripts_path.exists() { - push_unique_path(&mut search_paths, scripts_path); - } - } - } - } - } - if tool == "hermes" { - if let Some(local_data) = dirs::data_local_dir() { - let programs_python = local_data.join("Programs").join("Python"); - if let Ok(entries) = std::fs::read_dir(&programs_python) { - for entry in entries.flatten() { - let scripts_path = entry.path().join("Scripts"); - if scripts_path.exists() { - push_unique_path(&mut search_paths, scripts_path); - } - } - } - } - } - push_unique_path(&mut search_paths, std::path::PathBuf::from("C:\\Program Files\\nodejs")); - if let Some(home) = home.as_ref() { - extend_windows_cli_manager_search_paths(&mut search_paths, home); - } - } - - if tool == "opencode" { - let empty_home = Path::new(""); - for path in opencode_extra_search_paths( - home.as_deref().unwrap_or(empty_home), - std::env::var_os("OPENCODE_INSTALL_DIR"), - std::env::var_os("XDG_BIN_DIR"), - std::env::var_os("GOPATH"), - ) { - push_unique_path(&mut search_paths, path); - } - } - - // PATH intentionally retains shell/OS semantics; explicit manager/install roots above do not. - extend_from_cli_path_env(&mut search_paths, std::env::var_os("PATH")); - search_paths -} -''', - "CLI discovery HOME semantics", -) -# Explicit installation-root environment variables are roots, not shell PATH entries: reject CWD-relative values. -replace_region( - "src-tauri/src/commands/misc.rs", - "fn push_env_single_dir(paths: &mut Vec, value: Option) {", - "\n}\n\nfn extend_from_path_list", - '''fn push_env_single_dir(paths: &mut Vec, value: Option) { - if let Some(raw) = value { - let path = std::path::PathBuf::from(raw); - if path.is_absolute() { - push_unique_path(paths, path); - } else if !path.as_os_str().is_empty() { - log::warn!("Ignoring relative CLI install root: {}", path.display()); - } - } -''', - "absolute CLI install roots", -) -replace_region( - "src-tauri/src/commands/misc.rs", - "fn extend_from_path_list(\n", - "\n}\n\nfn extend_from_cli_path_env", - '''fn extend_from_path_list( - paths: &mut Vec, - value: Option, - suffix: Option<&str>, -) { - if let Some(raw) = value { - for base in std::env::split_paths(&raw) { - if !base.is_absolute() { - if !base.as_os_str().is_empty() { - log::warn!("Ignoring relative CLI path-list root: {}", base.display()); - } - continue; - } - let dir = match suffix { - Some(suffix) => base.join(suffix), - None => base, - }; - push_unique_path(paths, dir); - } - } -''', - "absolute CLI path-list roots", -) - - -# OpenCode session roots are fallible and existing SQLite errors must be visible. -replace_region( - "src-tauri/src/session_manager/providers/opencode.rs", - "pub(crate) fn get_opencode_base_dir() -> PathBuf {", - "\n/// Parse a SQLite source reference", - '''fn try_get_opencode_base_dir() -> Result { - if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { - let xdg = PathBuf::from(xdg.trim()); - if xdg.is_absolute() { - return Ok(xdg.join("opencode")); - } - if !xdg.as_os_str().is_empty() { - log::warn!("Ignoring relative XDG_DATA_HOME for OpenCode discovery: {}", xdg.display()); - } - } - Ok(crate::config::try_get_home_dir()?.join(".local/share/opencode")) -} - -pub(crate) fn get_opencode_data_dir() -> Result { - Ok(try_get_opencode_base_dir()?.join("storage")) -} - -fn get_opencode_db_path() -> Result { - Ok(try_get_opencode_base_dir()?.join("opencode.db")) -} - -pub fn scan_sessions() -> Result, String> { - let json_sessions = scan_sessions_json()?; - let sqlite_sessions = scan_sessions_sqlite()?; - if sqlite_sessions.is_empty() { - return Ok(json_sessions); - } - if json_sessions.is_empty() { - return Ok(sqlite_sessions); - } - let sqlite_ids: std::collections::HashSet = sqlite_sessions - .iter().map(|session| session.session_id.clone()).collect(); - let mut merged = sqlite_sessions; - for session in json_sessions { - if !sqlite_ids.contains(&session.session_id) { - merged.push(session); - } - } - Ok(merged) -} - -fn scan_sessions_json() -> Result, String> { - let storage = get_opencode_data_dir()?; - let session_dir = storage.join("session"); - if !session_dir.exists() { - return Ok(Vec::new()); - } - let mut json_files = Vec::new(); - collect_json_files(&session_dir, &mut json_files); - let mut sessions = Vec::new(); - for path in json_files { - if let Some(meta) = parse_session(&storage, &path) { - sessions.push(meta); - } - } - Ok(sessions) -} -''', - "OpenCode fallible roots", -) -replace_region( - "src-tauri/src/session_manager/providers/opencode.rs", - "fn scan_sessions_sqlite() -> Vec {", - "\npub fn load_messages(path: &Path)", - '''fn scan_sessions_sqlite() -> Result, String> { - let db_path = get_opencode_db_path()?; - if !db_path.exists() { - return Ok(Vec::new()); - } - let conn = Connection::open_with_flags( - &db_path, - rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, - ).map_err(|err| format!("Failed to open OpenCode session database {}: {err}", db_path.display()))?; - let mut stmt = conn.prepare( - "SELECT id, title, directory, time_created, time_updated FROM session ORDER BY time_updated DESC", - ).map_err(|err| format!("Failed to prepare OpenCode session query: {err}"))?; - let db_display = db_path.display().to_string(); - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, i64>(3)?, - row.get::<_, i64>(4)?, - )) - }).map_err(|err| format!("Failed to query OpenCode sessions: {err}"))?; - let mut sessions = Vec::new(); - for row in rows { - let (session_id, title, directory, created, updated) = - row.map_err(|err| format!("Failed to decode OpenCode session row: {err}"))?; - let display_title = if title.is_empty() { path_basename(&directory) } else { Some(title) }; - sessions.push(SessionMeta { - provider_id: PROVIDER_ID.to_string(), - session_id: session_id.clone(), - title: display_title.clone(), - summary: display_title, - project_dir: if directory.is_empty() { None } else { Some(directory) }, - created_at: Some(created), - last_active_at: Some(updated), - source_path: Some(format!("sqlite:{db_display}:{session_id}")), - resume_command: Some(format!("opencode session resume {session_id}")), - }); - } - Ok(sessions) -} -''', - "OpenCode SQLite observability", -) -replace_exact( - "src-tauri/src/session_manager/providers/opencode.rs", - "let expected_db_path = get_opencode_db_path()\n", - "let expected_db_path = get_opencode_db_path()?\n", - "OpenCode delete root", -) - - -# Session worker panic is an error, not an empty provider result. -replace_region( - "src-tauri/src/session_manager/mod.rs", - "pub fn scan_sessions() -> Vec {", - "\npub fn load_messages", - '''pub fn scan_sessions() -> Result, String> { - let (r1, r2, r3, r4, r5, r6) = std::thread::scope(|scope| -> Result<_, String> { - let h1 = scope.spawn(codex::scan_sessions); - let h2 = scope.spawn(claude::scan_sessions); - let h3 = scope.spawn(opencode::scan_sessions); - let h4 = scope.spawn(openclaw::scan_sessions); - let h5 = scope.spawn(gemini::scan_sessions); - let h6 = scope.spawn(hermes::scan_sessions); - - let r1 = h1.join().map_err(|_| "Codex session scan panicked".to_string())?; - let r2 = h2.join().map_err(|_| "Claude session scan panicked".to_string())?; - let r3 = h3.join().map_err(|_| "OpenCode session scan panicked".to_string())??; - let r4 = h4.join().map_err(|_| "OpenClaw session scan panicked".to_string())?; - let r5 = h5.join().map_err(|_| "Gemini session scan panicked".to_string())?; - let r6 = h6.join().map_err(|_| "Hermes session scan panicked".to_string())?; - Ok((r1, r2, r3, r4, r5, r6)) - })?; - - let mut sessions = Vec::new(); - sessions.extend(r1); - sessions.extend(r2); - sessions.extend(r3); - sessions.extend(r4); - sessions.extend(r5); - sessions.extend(r6); - sessions.sort_by(|a, b| { - b.last_active_at.or(b.created_at).unwrap_or(0) - .cmp(&a.last_active_at.or(a.created_at).unwrap_or(0)) - }); - Ok(sessions) -} -''', - "session worker observability", -) -replace_exact( - "src-tauri/src/session_manager/mod.rs", - '"opencode" => vec![opencode::get_opencode_data_dir()],', - '"opencode" => vec![opencode::get_opencode_data_dir()?],', - "OpenCode provider deletion root", -) -replace_region( - "src-tauri/src/commands/session_manager.rs", - "pub async fn list_sessions() -> Result, String> {", - "\n}\n\n#[tauri::command]\npub async fn get_session_messages", - '''pub async fn list_sessions() -> Result, String> { - tauri::async_runtime::spawn_blocking(session_manager::scan_sessions) - .await - .map_err(|err| format!("Failed to scan sessions task: {err}"))? -''', - "session command propagation", -) - - -# Hermes configuration roots must not be process-relative even when supplied by environment. -path = "src-tauri/src/hermes_config.rs" -text = read(path) -old = ''' if let Some(raw) = std::env::var_os("HERMES_HOME") { - let value = raw.to_string_lossy(); - let trimmed = value.trim(); - if !trimmed.is_empty() { - return PathBuf::from(trimmed); - } - } -''' -new = ''' if let Some(raw) = std::env::var_os("HERMES_HOME") { - let value = raw.to_string_lossy(); - let trimmed = value.trim(); - if !trimmed.is_empty() { - let path = PathBuf::from(trimmed); - if path.is_absolute() { - return path; - } - log::warn!("Ignoring relative HERMES_HOME: {}", path.display()); - } - } -''' -if text.count(old) != 1: - raise SystemExit(f"Hermes HOME validation: count={text.count(old)}") -text = text.replace(old, new, 1) -old = ''' localappdata - .map(|value| value.to_string_lossy().trim().to_string()) - .filter(|value| !value.is_empty()) - .map(PathBuf::from) - .unwrap_or_else(|| home.join("AppData").join("Local")) - .join("hermes") -''' -new = ''' localappdata - .map(|value| PathBuf::from(value.to_string_lossy().trim().to_string())) - .filter(|path| path.is_absolute()) - .unwrap_or_else(|| home.join("AppData").join("Local")) - .join("hermes") -''' -if text.count(old) != 1: - raise SystemExit(f"Hermes LOCALAPPDATA validation: count={text.count(old)}") -text = text.replace(old, new, 1) -write(path, text) - - -# Permanent guards: encode semantics, not current test outcomes. -guard = "scripts/check_rust_failure_boundaries.py" -text = read(guard) -marker = "]\n\nfailures = []" -if text.count(marker) != 1: - raise SystemExit(f"guard FILE_CHECKS marker count={text.count(marker)}") -checks = ''' ("services/model_fetch.rs", re.compile(r'HTTP \\{status\\}: \\{body\\}'), "model-discovery errors must not expose raw upstream response bodies"), - ("app_store.rs", re.compile(r"fn read_override_from_store\\([^)]*\\) -> Option"), "Store read failure must not collapse into an absent app_config_dir override"), - ("app_store.rs", re.compile(r"fn resolve_path\\(raw: &str\\) -> PathBuf"), "app_config_dir parsing must be fallible and reject relative persistence roots"), - ("settings.rs", re.compile(r"fn settings_path\\(\\) -> Option"), "settings path resolution must expose HOME failures instead of a fake Option contract"), - ("services/skill.rs", re.compile(r"(?:get_app_config_dir|crate::config::get_home_dir)\\(\\)"), "Skill Result APIs must propagate fallible persistence roots instead of panicking"), - ("commands/misc.rs", re.compile(r"let home = crate::config::get_home_dir\\(\\);"), "CLI discovery must degrade without HOME instead of panicking"), - ("session_manager/mod.rs", re.compile(r"join\\(\\)\\.unwrap_or_default\\(\\)"), "session worker panics must be observable, not converted to empty results"), - ("session_manager/providers/opencode.rs", re.compile(r"crate::config::get_home_dir\\(\\)"), "OpenCode session path resolution must be fallible"), - ("hermes_config.rs", re.compile(r"return PathBuf::from\\(trimmed\\)"), "HERMES_HOME must not create a process-relative configuration root"), -''' -text = text.replace(marker, checks + marker, 1) -anchor = "# User-home resolution is a persistence boundary shared by DB/config/backup/CLI paths. A direct\n" -if text.count(anchor) != 1: - raise SystemExit(f"guard HOME anchor count={text.count(anchor)}") -extra = '''# Persistence compatibility must not reintroduce CWD-relative roots inside config.rs itself. -config_text = (RUST_ROOT / "config.rs").read_text(encoding="utf-8") -if 'let legacy_dir = PathBuf::from(trimmed).join(".cc-switch")' in config_text: - failures.append("src-tauri/src/config.rs: Windows legacy HOME must be validated before DB fallback") - -''' -text = text.replace(anchor, extra + anchor, 1) -write(guard, text) - -print("Applied structural design-invariant follow-up") diff --git a/scripts/apply_design_invariant_hardening_once.py b/scripts/apply_design_invariant_hardening_once.py deleted file mode 100644 index 0d8b2f50190..00000000000 --- a/scripts/apply_design_invariant_hardening_once.py +++ /dev/null @@ -1,735 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected 1 exact match, found {count}") - return text.replace(old, new, 1) - - -def replace_between(text: str, start: str, end: str, new: str, label: str) -> str: - if text.count(start) != 1: - raise SystemExit(f"{label}: start marker count={text.count(start)}") - start_idx = text.index(start) - end_idx = text.index(end, start_idx) - return text[:start_idx] + new + text[end_idx:] - - -# --------------------------------------------------------------------------- -# config.rs: make absolute persistence paths a common invariant, not a caller -# convention; expose fallible app-root resolution and validate Windows legacy -# HOME before compatibility fallback. -# --------------------------------------------------------------------------- -path = Path("src-tauri/src/config.rs") -text = path.read_text(encoding="utf-8") - -marker = "const ATOMIC_TEMP_CREATE_ATTEMPTS: usize = 16;\n" -helper = ''' - -fn require_absolute_path(path: PathBuf, label: &str) -> Result { - if path.is_absolute() { - Ok(path) - } else { - Err(format!("{label} 必须是绝对路径,收到: {}", path.display())) - } -} -''' -if "fn require_absolute_path(" not in text: - text = replace_once(text, marker, marker + helper, "config absolute-path helper") - -text = replace_between( - text, - "fn resolve_home_dir(\n", - "\n/// 获取用户主目录。", - '''fn resolve_home_dir( - test_override: Option<&str>, - detected: Option, -) -> Result { - if let Some(home) = test_override.map(str::trim).filter(|home| !home.is_empty()) { - return require_absolute_path(PathBuf::from(home), "CC_SWITCH_TEST_HOME"); - } - - let path = detected.ok_or_else(|| { - "无法获取用户主目录;拒绝回退到当前工作目录,以避免配置/数据库静默分叉".to_string() - })?; - require_absolute_path(path, "操作系统返回的用户主目录") -} -''', - "config home resolver", -) - -expand_start = "pub fn expand_home_path(raw: &str) -> Result {" -expand_end = "\n/// Last-resort crash/exit observability directory" -expand_new = '''pub fn expand_home_path(raw: &str) -> Result { - if raw == "~" { - return try_get_home_dir(); - } - if let Some(stripped) = raw.strip_prefix("~/") { - return Ok(try_get_home_dir()?.join(stripped)); - } - if let Some(stripped) = raw.strip_prefix("~\\\\") { - return Ok(try_get_home_dir()?.join(stripped)); - } - Ok(PathBuf::from(raw)) -} - -/// Resolve a user-configurable persistence/configuration root. -/// -/// Unlike generic path expansion, this contract never permits process-CWD-relative roots. -/// Callers may accept `~`, but the resolved value must be absolute before it can select a -/// database, backup, settings, or external CLI configuration tree. -pub fn resolve_persistence_path(raw: &str, label: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(format!("{label} 不能为空")); - } - let path = expand_home_path(trimmed)?; - require_absolute_path(path, label) -} -''' -text = replace_between(text, expand_start, expand_end, expand_new, "config persistence resolver") - -app_start = "pub fn get_app_config_dir() -> PathBuf {" -app_end = "\n/// 获取应用配置文件路径" -app_new = '''pub fn try_get_app_config_dir() -> Result { - if let Some(custom) = crate::app_store::get_app_config_dir_override() { - return require_absolute_path(custom, "app_config_dir override"); - } - - let default_dir = try_get_home_dir()?.join(".cc-switch"); - - // 兼容 v3.10.3:当用户环境存在 HOME 且与真实用户目录不同, - // v3.10.3 可能在 HOME/.cc-switch/ 下创建/使用了数据库。 - // 兼容候选本身也必须是绝对路径;相对 HOME 不能重新引入 CWD 绑定。 - #[cfg(windows)] - { - let default_db = default_dir.join("cc-switch.db"); - if !default_db.exists() { - if let Ok(home_env) = std::env::var("HOME") { - let trimmed = home_env.trim(); - if !trimmed.is_empty() { - let legacy_home = PathBuf::from(trimmed); - if legacy_home.is_absolute() { - let legacy_dir = legacy_home.join(".cc-switch"); - if legacy_dir.join("cc-switch.db").exists() { - log::info!( - "Detected v3.10.3 legacy database at {}, using it instead of {}", - legacy_dir.display(), - default_dir.display() - ); - return Ok(legacy_dir); - } - } else { - log::warn!( - "Ignoring relative legacy HOME while locating v3.10.3 database: {}", - legacy_home.display() - ); - } - } - } - } - } - - Ok(default_dir) -} - -/// Compatibility wrapper for legacy infallible path APIs. New fallible persistence operations -/// should call `try_get_app_config_dir` so configuration errors remain typed instead of panicking. -pub fn get_app_config_dir() -> PathBuf { - try_get_app_config_dir().unwrap_or_else(|err| { - log::error!("{err}"); - panic!("{err}"); - }) -} -''' -text = replace_between(text, app_start, app_end, app_new, "config app root") - -test_anchor = ''' #[test] - fn explicit_relative_test_home_override_is_rejected() { - assert!(resolve_home_dir(Some("relative-test-home"), None).is_err()); - } -''' -extra_tests = ''' #[test] - fn explicit_relative_test_home_override_is_rejected() { - assert!(resolve_home_dir(Some("relative-test-home"), None).is_err()); - } - - #[test] - fn persistence_roots_reject_process_relative_paths() { - assert!(resolve_persistence_path("relative/profile", "test root").is_err()); - } - - #[test] - fn persistence_roots_accept_absolute_paths() { - let path = std::env::temp_dir().join("cc-switch-persistence-root"); - let raw = path.to_string_lossy().to_string(); - assert_eq!(resolve_persistence_path(&raw, "test root").unwrap(), path); - } -''' -text = replace_once(text, test_anchor, extra_tests, "config persistence tests") -path.write_text(text, encoding="utf-8") - - -# --------------------------------------------------------------------------- -# app_store.rs: Store access/migration failure is not equivalent to no override. -# Invalid/missing/non-directory roots fail closed, and user writes are validated -# before persistence. A successful no-op migration is marked complete as well. -# --------------------------------------------------------------------------- -path = Path("src-tauri/src/app_store.rs") -text = path.read_text(encoding="utf-8") - -text = replace_between( - text, - "fn read_override_from_store(app: &tauri::AppHandle) -> Option {", - "\nfn legacy_migration_completed", - '''fn read_override_from_store(app: &tauri::AppHandle) -> Result, AppError> { - let store = open_paths_store(app)?; - - match store.get(STORE_KEY_APP_CONFIG_DIR) { - Some(Value::String(path_str)) => { - let path_str = path_str.trim(); - if path_str.is_empty() { - return Ok(None); - } - - let path = resolve_path(path_str)?; - if !path.is_dir() { - return Err(AppError::Config(format!( - "Store 中配置的 app_config_dir 不是现有目录: {}", - path.display() - ))); - } - - log::info!("使用 Store 中的 app_config_dir: {path:?}"); - Ok(Some(path)) - } - Some(_) => Err(AppError::Config(format!( - "Store 中的 {STORE_KEY_APP_CONFIG_DIR} 类型不正确,应为字符串" - ))), - None => Ok(None), - } -} -''', - "app_store read override", -) - -text = replace_between( - text, - "fn legacy_migration_completed(app: &tauri::AppHandle) -> bool {", - "\n/// 从旧版", - '''fn legacy_migration_completed(app: &tauri::AppHandle) -> Result { - let store = open_paths_store(app)?; - match store.get(STORE_KEY_APP_CONFIG_DIR_LEGACY_MIGRATED) { - Some(Value::Bool(value)) => Ok(value), - Some(_) => Err(AppError::Config(format!( - "Store 中的 {STORE_KEY_APP_CONFIG_DIR_LEGACY_MIGRATED} 类型不正确,应为布尔值" - ))), - None => Ok(false), - } -} -''', - "app_store migration marker", -) - -text = replace_between( - text, - "fn read_legacy_override_from_settings() -> Option {", - "\nfn persist_override_and_migration_marker", - '''fn read_legacy_override_from_settings() -> Result, AppError> { - let settings_path = crate::config::try_get_home_dir() - .map_err(AppError::Config)? - .join(".cc-switch") - .join("settings.json"); - let content = match std::fs::read_to_string(&settings_path) { - Ok(content) => content, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(AppError::io(&settings_path, err)), - }; - - let root: Value = serde_json::from_str(&content) - .map_err(|err| AppError::json(&settings_path, err))?; - - for key in LEGACY_APP_CONFIG_DIR_KEYS { - let Some(raw) = root.get(*key).and_then(Value::as_str) else { - continue; - }; - let trimmed = raw.trim(); - if trimmed.is_empty() { - continue; - } - let resolved = resolve_path(trimmed)?; - if !resolved.is_dir() { - return Err(AppError::Config(format!( - "旧 settings.json 的 {key} 不是现有目录: {}", - resolved.display() - ))); - } - return Ok(Some(resolved)); - } - - Ok(None) -} -''', - "app_store legacy reader", -) - -text = replace_between( - text, - "fn migrate_legacy_override_if_needed(app: &tauri::AppHandle) -> Result, AppError> {", - "\n/// 从 Store 刷新", - '''fn migrate_legacy_override_if_needed(app: &tauri::AppHandle) -> Result, AppError> { - if legacy_migration_completed(app)? { - return Ok(None); - } - - if let Some(existing_path) = read_override_from_store(app)? { - // 已经存在新格式配置,只补迁移标记,绝不能用尚未初始化的缓存反写 Store。 - let path_string = existing_path.to_string_lossy().to_string(); - persist_override_and_migration_marker(app, Some(&path_string))?; - return Ok(Some(existing_path)); - } - - match read_legacy_override_from_settings()? { - Some(legacy_path) => { - let path_string = legacy_path.to_string_lossy().to_string(); - persist_override_and_migration_marker(app, Some(&path_string))?; - log::info!( - "已将旧 settings.json 的 app_config_dir 自动迁移到 Store: {}", - legacy_path.display() - ); - Ok(Some(legacy_path)) - } - None => { - // A successful scan with no legacy value is still a completed one-time migration. - // Persist the marker so a stale legacy field cannot unexpectedly resurrect later. - persist_override_and_migration_marker(app, None)?; - Ok(None) - } - } -} -''', - "app_store migration", -) - -text = replace_between( - text, - "pub fn refresh_app_config_dir_override(app: &tauri::AppHandle) -> Option {", - "\n/// 写入 app_config_dir", - '''pub fn refresh_app_config_dir_override( - app: &tauri::AppHandle, -) -> Result, AppError> { - let migrated = migrate_legacy_override_if_needed(app)?; - let value = match migrated { - Some(path) => Some(path), - None => read_override_from_store(app)?, - }; - update_cached_override(value.clone()); - Ok(value) -} -''', - "app_store refresh", -) - -text = replace_between( - text, - "pub fn set_app_config_dir_to_store(\n", - "\n/// 解析路径", - '''pub fn set_app_config_dir_to_store( - app: &tauri::AppHandle, - path: Option<&str>, -) -> Result<(), AppError> { - let resolved = match path.map(str::trim).filter(|value| !value.is_empty()) { - Some(value) => { - let path = resolve_path(value)?; - if !path.is_dir() { - return Err(AppError::InvalidInput(format!( - "app_config_dir 必须指向现有目录: {}", - path.display() - ))); - } - Some(path) - } - None => None, - }; - let serialized = resolved - .as_ref() - .map(|path| path.to_string_lossy().to_string()); - persist_override_and_migration_marker(app, serialized.as_deref())?; - update_cached_override(resolved.clone()); - - match resolved { - Some(value) => log::info!("已将 app_config_dir 写入 Store: {}", value.display()), - None => log::info!("已从 Store 中删除 app_config_dir 配置"), - } - Ok(()) -} -''', - "app_store setter", -) - -text = replace_between( - text, - "fn resolve_path(raw: &str) -> PathBuf {", - "\n/// 从旧的 settings.json", - '''fn resolve_path(raw: &str) -> Result { - crate::config::resolve_persistence_path(raw, "app_config_dir") - .map_err(AppError::InvalidInput) -} -''', - "app_store path resolver", -) - -text = replace_between( - text, - "pub fn migrate_app_config_dir_from_settings(app: &tauri::AppHandle) -> Result<(), AppError> {", - "\n#[cfg(test)]", - '''pub fn migrate_app_config_dir_from_settings(app: &tauri::AppHandle) -> Result<(), AppError> { - let migrated = migrate_legacy_override_if_needed(app)?; - let value = match migrated { - Some(path) => Some(path), - None => read_override_from_store(app)?, - }; - update_cached_override(value); - Ok(()) -} -''', - "app_store compatibility entrypoint", -) - -text = replace_once( - text, - " assert_eq!(resolve_path(input), PathBuf::from(input));", - " assert_eq!(resolve_path(input).unwrap(), PathBuf::from(input));", - "app_store absolute test", -) -legacy_test_anchor = ''' #[test] - fn legacy_keys_cover_camel_and_snake_case() {''' -relative_test = ''' #[test] - fn resolve_path_rejects_process_relative_app_root() { - assert!(resolve_path("relative/.cc-switch").is_err()); - } - -''' -text = replace_once(text, legacy_test_anchor, relative_test + legacy_test_anchor, "app_store relative test") -path.write_text(text, encoding="utf-8") - - -# --------------------------------------------------------------------------- -# settings.rs: make the settings path fallible instead of fake-Optional; enforce -# an in-memory invariant that all per-CLI config-dir overrides are absolute. -# Dirty legacy relative values are quarantined (logged + ignored), while new -# frontend writes are rejected with InvalidInput instead of silently normalizing. -# --------------------------------------------------------------------------- -path = Path("src-tauri/src/settings.rs") -text = path.read_text(encoding="utf-8") - -insert_before = "impl AppSettings {\n" -settings_helpers = '''fn normalize_config_dir_override(field: &str, value: Option) -> Option { - let raw = value?.trim().to_string(); - if raw.is_empty() { - return None; - } - match crate::config::resolve_persistence_path(&raw, field) { - Ok(path) => Some(path.to_string_lossy().to_string()), - Err(err) => { - log::error!("Ignoring invalid persisted {field}: {err}"); - None - } - } -} - -fn validate_config_dir_overrides(settings: &AppSettings) -> Result<(), AppError> { - let values = [ - ("claude_config_dir", settings.claude_config_dir.as_deref()), - ("codex_config_dir", settings.codex_config_dir.as_deref()), - ("gemini_config_dir", settings.gemini_config_dir.as_deref()), - ("opencode_config_dir", settings.opencode_config_dir.as_deref()), - ("openclaw_config_dir", settings.openclaw_config_dir.as_deref()), - ("hermes_config_dir", settings.hermes_config_dir.as_deref()), - ]; - for (field, raw) in values { - if let Some(raw) = raw.map(str::trim).filter(|raw| !raw.is_empty()) { - crate::config::resolve_persistence_path(raw, field) - .map_err(AppError::InvalidInput)?; - } - } - Ok(()) -} - -''' -if "fn normalize_config_dir_override(" not in text: - text = replace_once(text, insert_before, settings_helpers + insert_before, "settings helpers") - -text = replace_between( - text, - " fn settings_path() -> Option {", - "\n fn normalize_paths(&mut self) {", - ''' fn settings_path() -> Result { - Ok(crate::config::try_get_home_dir() - .map_err(AppError::Config)? - .join(".cc-switch") - .join("settings.json")) - } -''', - "settings path", -) - -norm_start = " self.claude_config_dir = self\n" -norm_end = "\n self.language = self" -normalized_fields = ''' self.claude_config_dir = normalize_config_dir_override( - "claude_config_dir", - self.claude_config_dir.take(), - ); - self.codex_config_dir = normalize_config_dir_override( - "codex_config_dir", - self.codex_config_dir.take(), - ); - self.gemini_config_dir = normalize_config_dir_override( - "gemini_config_dir", - self.gemini_config_dir.take(), - ); - self.opencode_config_dir = normalize_config_dir_override( - "opencode_config_dir", - self.opencode_config_dir.take(), - ); - self.openclaw_config_dir = normalize_config_dir_override( - "openclaw_config_dir", - self.openclaw_config_dir.take(), - ); - self.hermes_config_dir = normalize_config_dir_override( - "hermes_config_dir", - self.hermes_config_dir.take(), - ); -''' -text = replace_between(text, norm_start, norm_end, normalized_fields, "settings path normalization") - -old_load = ''' fn load_from_file() -> Self { - let Some(path) = Self::settings_path() else { - return Self::default(); - }; - Self::load_from_path(&path) - } -''' -new_load = ''' fn load_from_file() -> Self { - match Self::settings_path() { - Ok(path) => Self::load_from_path(&path), - Err(err) => { - log::error!("无法解析 settings.json 路径,将使用内存默认设置且禁止持久化: {err}"); - Self::default() - } - } - } -''' -text = replace_once(text, old_load, new_load, "settings load path") - -old_save = '''fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> { - let Some(path) = AppSettings::settings_path() else { - return Err(AppError::Config("无法获取用户主目录".to_string())); - }; - save_settings_file_to_path(settings, &path) -} -''' -new_save = '''fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> { - let path = AppSettings::settings_path()?; - save_settings_file_to_path(settings, &path) -} -''' -text = replace_once(text, old_save, new_save, "settings save path") - -old_resolver = '''fn resolve_override_path(raw: &str) -> PathBuf { - crate::config::expand_home_path(raw).unwrap_or_else(|err| { - log::error!("{err}"); - panic!("{err}"); - }) -} -''' -new_resolver = '''fn resolve_override_path(raw: &str) -> Option { - let path = PathBuf::from(raw); - if path.is_absolute() { - Some(path) - } else { - log::error!("settings path invariant violated by relative override: {raw}"); - None - } -} -''' -text = replace_once(text, old_resolver, new_resolver, "settings getter resolver") - -if text.count(".map(|p| resolve_override_path(p))") != 6: - raise SystemExit( - f"settings override getter map count={text.count('.map(|p| resolve_override_path(p))')}" - ) -text = text.replace(".map(|p| resolve_override_path(p))", ".and_then(|p| resolve_override_path(p))") - -old_update = '''pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> { - new_settings.normalize_paths(); -''' -new_update = '''pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> { - validate_config_dir_overrides(&new_settings)?; - new_settings.normalize_paths(); -''' -text = replace_once(text, old_update, new_update, "settings update validation") -path.write_text(text, encoding="utf-8") - - -# --------------------------------------------------------------------------- -# Startup/DB: propagate Store and app-root resolution failures rather than -# treating them as absence or panicking during normal setup. -# --------------------------------------------------------------------------- -path = Path("src-tauri/src/lib.rs") -text = path.read_text(encoding="utf-8") -old_setup = ''' // 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等) - app_store::refresh_app_config_dir_override(app.handle()); - let app_config_dir = crate::config::get_app_config_dir(); - panic_hook::init_app_config_dir(app_config_dir.clone()); - app_exit_monitor::init_app_config_dir(app_config_dir); -''' -new_setup = ''' // 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等)。 - // Store/路径损坏不能伪装成“无 override”后切到另一个数据库根。 - app_store::refresh_app_config_dir_override(app.handle())?; - let app_config_dir = crate::config::try_get_app_config_dir() - .map_err(crate::error::AppError::Config)?; - panic_hook::init_app_config_dir(app_config_dir.clone()); - app_exit_monitor::init_app_config_dir(app_config_dir.clone()); -''' -text = replace_once(text, old_setup, new_setup, "startup app root") -text = replace_once( - text, - ''' // 初始化数据库 - let app_config_dir = crate::config::get_app_config_dir(); - let db_path = app_config_dir.join("cc-switch.db"); -''', - ''' // 初始化数据库 - let db_path = app_config_dir.join("cc-switch.db"); -''', - "startup reuse validated root", -) -path.write_text(text, encoding="utf-8") - -path = Path("src-tauri/src/database/mod.rs") -text = path.read_text(encoding="utf-8") -text = replace_once( - text, - "use crate::config::get_app_config_dir;", - "use crate::config::try_get_app_config_dir;", - "database import", -) -text = replace_once( - text, - ''' let db_path = get_app_config_dir().join("cc-switch.db");''', - ''' let db_path = try_get_app_config_dir() - .map_err(AppError::Config)? - .join("cc-switch.db");''', - "database app root", -) -path.write_text(text, encoding="utf-8") - - -# --------------------------------------------------------------------------- -# model_fetch.rs: fail-fast HTTP classification must not imply raw-body -# disclosure. Use common payload shape/fingerprint diagnostics instead. -# --------------------------------------------------------------------------- -path = Path("src-tauri/src/services/model_fetch.rs") -text = path.read_text(encoding="utf-8") -text = text.replace( - '''/// 404/405 响应体截断长度:避免把几十 KB HTML 404 页整页保留到错误串里。 -const ERROR_BODY_MAX_CHARS: usize = 512; - -''', - "", -) -old_http_error = ''' let body = truncate_body(response.text().await.unwrap_or_default()); - return Err(format!("HTTP {status}: {body}")); -''' -new_http_error = ''' let body_detail = match response.bytes().await { - Ok(body) => { - let rendered = String::from_utf8_lossy(&body); - format!( - "body-shape={}, {}", - crate::diagnostics::text_shape_hint(&rendered), - crate::diagnostics::payload_fingerprint(&body) - ) - } - Err(error) => format!("body-unavailable={}", request_error_kind(&error)), - }; - return Err(format!("HTTP {status}: {body_detail}")); -''' -text = replace_once(text, old_http_error, new_http_error, "model safe fail-fast detail") -truncate_start = "/// 截断响应体到 [`ERROR_BODY_MAX_CHARS`] 字符,避免 HTML 404 页占用错误串。\nfn truncate_body(body: String) -> String {" -if truncate_start in text: - start = text.index(truncate_start) - # Function is immediately followed by a blank line + the next item. - next_item = text.find("\n\n", start) - # Advance until the function's closing brace is covered; exact body is stable here. - old_truncate = '''/// 截断响应体到 [`ERROR_BODY_MAX_CHARS`] 字符,避免 HTML 404 页占用错误串。 -fn truncate_body(body: String) -> String { - if body.chars().count() <= ERROR_BODY_MAX_CHARS { - body - } else { - let mut s: String = body.chars().take(ERROR_BODY_MAX_CHARS).collect(); - s.push('…'); - s - } -} - -''' - text = replace_once(text, old_truncate, "", "model obsolete raw-body helper") - -# Add a pure diagnostic regression next to retry-policy tests when present. -test_anchor = ''' #[test] - fn candidate_failure_details_are_bounded() {''' -if test_anchor in text and "fail_fast_http_diagnostics_do_not_require_raw_body" not in text: - safe_test = ''' #[test] - fn fail_fast_http_diagnostics_do_not_require_raw_body() { - let secret = b"{\\\"token\\\":\\\"super-secret\\\"}"; - let rendered = String::from_utf8_lossy(secret); - let detail = format!( - "body-shape={}, {}", - crate::diagnostics::text_shape_hint(&rendered), - crate::diagnostics::payload_fingerprint(secret) - ); - assert!(detail.contains("body-shape=json-like")); - assert!(detail.contains("bytes=")); - assert!(!detail.contains("super-secret")); - } - -''' - text = replace_once(text, test_anchor, safe_test + test_anchor, "model safe diagnostics test") -path.write_text(text, encoding="utf-8") - - -# --------------------------------------------------------------------------- -# Permanent source-level guards encode the design invariants so future green -# tests cannot reintroduce the same architectural failure modes. -# --------------------------------------------------------------------------- -path = Path("scripts/check_rust_failure_boundaries.py") -text = path.read_text(encoding="utf-8") -old_checks_tail = ''' ("services/model_fetch.rs", re.compile(r'\\.json\\(\\)\\s*\\.await\\s*\\.map_err\\(\\|e\\| format!\\(\\"Failed to parse response:', re.S), "invalid successful model payloads must not abort compatibility candidate discovery"), -]''' -new_checks_tail = ''' ("services/model_fetch.rs", re.compile(r'\\.json\\(\\)\\s*\\.await\\s*\\.map_err\\(\\|e\\| format!\\(\\"Failed to parse response:', re.S), "invalid successful model payloads must not abort compatibility candidate discovery"), - ("services/model_fetch.rs", re.compile(r'HTTP \\{status\\}: \\{body\\}'), "model-discovery errors must not expose raw upstream response bodies"), - ("app_store.rs", re.compile(r"fn read_override_from_store\\([^)]*\\) -> Option"), "Store read failure must not collapse into an absent app_config_dir override"), - ("app_store.rs", re.compile(r"fn resolve_path\\(raw: &str\\) -> PathBuf"), "app_config_dir parsing must be fallible and reject relative persistence roots"), - ("settings.rs", re.compile(r"fn settings_path\\(\\) -> Option"), "settings path resolution must expose HOME failures instead of a fake Option contract"), -]''' -text = replace_once(text, old_checks_tail, new_checks_tail, "policy explicit checks") - -extra_guard_anchor = '''# User-home resolution is a persistence boundary shared by DB/config/backup/CLI paths. A direct -# dirs::home_dir() call elsewhere can silently re-introduce CWD/relative fallback semantics or -# diverge from the validated CC_SWITCH_TEST_HOME behavior, so keep one common implementation. -''' -extra_guard = '''# Persistence roots must never accept a process-relative Store/settings override. These patterns -# previously bypassed the HOME guard while still binding DB/config state to the launch CWD. -config_text = (RUST_ROOT / "config.rs").read_text(encoding="utf-8") -if 'let legacy_dir = PathBuf::from(trimmed).join(".cc-switch")' in config_text: - failures.append( - "src-tauri/src/config.rs: Windows legacy HOME must be checked as absolute before DB fallback" - ) - -''' -text = replace_once(text, extra_guard_anchor, extra_guard + extra_guard_anchor, "policy persistence guard") -path.write_text(text, encoding="utf-8") - -print("Applied design-invariant hardening patch") diff --git a/scripts/apply_failure_semantics_redesign_once.py b/scripts/apply_failure_semantics_redesign_once.py deleted file mode 100644 index 51355c2e7a1..00000000000 --- a/scripts/apply_failure_semantics_redesign_once.py +++ /dev/null @@ -1,477 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - - -def read(path: str) -> str: - return Path(path).read_text(encoding="utf-8") - - -def write(path: str, text: str) -> None: - Path(path).write_text(text, encoding="utf-8") - - -def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) -> None: - text = read(path) - count = text.count(old) - if count != expected: - raise SystemExit(f"{label}: expected {expected}, found {count}") - write(path, text.replace(old, new, expected)) - - -def replace_region(path: str, start: str, end: str, new: str, label: str) -> None: - text = read(path) - if text.count(start) != 1: - raise SystemExit(f"{label}: start count={text.count(start)}") - i = text.index(start) - try: - j = text.index(end, i) - except ValueError: - raise SystemExit(f"{label}: end marker missing") - write(path, text[:i] + new + text[j:]) - - -# --------------------------------------------------------------------------- -# Product-level failure semantics. Persistence-root resolution is strict; -# best-effort discovery is allowed to omit candidates but never invent roots. -# --------------------------------------------------------------------------- -semantics = Path("src-tauri/src/failure_semantics.rs") -if semantics.exists(): - raise SystemExit("failure_semantics.rs already exists; redesign driver is one-shot") -semantics.write_text( - '''use std::path::PathBuf; -use thiserror::Error; - -/// Failure to determine a persistent/configuration root. -/// -/// `None` is reserved for genuine absence at the API that owns optionality. -/// Invalid explicit values are errors and must not silently select another root. -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum RootResolutionError { - #[error("{source} is unavailable: {detail}")] - Unavailable { source: String, detail: String }, - #[error("{source} must be an absolute path, got: {path}")] - Relative { source: String, path: String }, -} - -pub fn require_absolute_root( - path: PathBuf, - source: impl Into, -) -> Result { - if path.is_absolute() { - Ok(path) - } else { - Err(RootResolutionError::Relative { - source: source.into(), - path: path.display().to_string(), - }) - } -} - -/// Read an environment variable that selects a persistent root. -/// Missing/blank means "not configured"; a non-empty relative value is invalid. -pub fn optional_absolute_env_root( - name: &str, -) -> Result, RootResolutionError> { - let Some(raw) = std::env::var_os(name) else { - return Ok(None); - }; - let value = raw.to_string_lossy(); - let trimmed = value.trim(); - if trimmed.is_empty() { - return Ok(None); - } - require_absolute_root(PathBuf::from(trimmed), name).map(Some) -} -''', - encoding="utf-8", -) - -replace_exact( - "src-tauri/src/lib.rs", - "mod error;\n", - "mod error;\nmod failure_semantics;\n", - "register failure semantics module", -) -replace_exact( - "src-tauri/src/error.rs", - "use std::path::Path;\n", - "use std::path::Path;\n\nuse crate::failure_semantics::RootResolutionError;\n", - "error root import", -) -replace_exact( - "src-tauri/src/error.rs", - ''' #[error("配置错误: {0}")] - Config(String), -''', - ''' #[error("配置错误: {0}")] - Config(String), - #[error(transparent)] - Root(#[from] RootResolutionError), -''', - "typed root AppError", -) - -# --------------------------------------------------------------------------- -# config.rs: add typed APIs while retaining compatibility wrappers for callers -# that have not yet migrated. New persistence code must use the typed boundary. -# --------------------------------------------------------------------------- -path = "src-tauri/src/config.rs" -replace_exact( - path, - "use crate::error::AppError;\n", - "use crate::error::AppError;\nuse crate::failure_semantics::{require_absolute_root, RootResolutionError};\n", - "config typed root import", -) -replace_region( - path, - "pub fn try_get_home_dir() -> Result {", - "\npub fn get_home_dir() -> PathBuf {", - '''pub fn try_get_home_dir_typed() -> Result { - if let Ok(raw) = std::env::var("CC_SWITCH_TEST_HOME") { - let trimmed = raw.trim(); - if !trimmed.is_empty() { - return require_absolute_root(PathBuf::from(trimmed), "CC_SWITCH_TEST_HOME"); - } - } - - let detected = dirs::home_dir().ok_or_else(|| RootResolutionError::Unavailable { - source: "user home".to_string(), - detail: "operating system did not provide a home directory; CWD fallback is forbidden" - .to_string(), - })?; - require_absolute_root(detected, "operating-system user home") -} - -/// Compatibility adapter. New persistence code should keep RootResolutionError typed. -pub fn try_get_home_dir() -> Result { - try_get_home_dir_typed().map_err(|err| err.to_string()) -} -''', - "typed home boundary", -) -replace_region( - path, - "pub fn resolve_persistence_path(raw: &str, label: &str) -> Result {", - "\n/// Last-resort crash/exit observability directory", - '''pub fn resolve_persistence_path_typed( - raw: &str, - label: &str, -) -> Result { - let trimmed = raw.trim(); - if trimmed == "~" { - return try_get_home_dir_typed(); - } - if let Some(stripped) = trimmed.strip_prefix("~/") { - return Ok(try_get_home_dir_typed()?.join(stripped)); - } - if let Some(stripped) = trimmed.strip_prefix("~\\\\") { - return Ok(try_get_home_dir_typed()?.join(stripped)); - } - require_absolute_root(PathBuf::from(trimmed), label) -} - -/// Compatibility adapter for legacy String-error APIs. -pub fn resolve_persistence_path(raw: &str, label: &str) -> Result { - resolve_persistence_path_typed(raw, label).map_err(|err| err.to_string()) -} -''', - "typed persistence boundary", -) -replace_region( - path, - "pub fn try_get_app_config_dir() -> Result {", - "\n/// Compatibility wrapper for legacy infallible path APIs.", - '''pub fn try_get_app_config_dir_app() -> Result { - if let Some(custom) = crate::app_store::try_get_app_config_dir_override()? { - return Ok(require_absolute_root(custom, "app_config_dir override")?); - } - - let default_dir = try_get_home_dir_typed()?.join(".cc-switch"); - - // v3.10.3 HOME is only a historical discovery candidate, not an active root selector. - // Invalid legacy candidates are ignored; they must never override a valid OS home root. - #[cfg(windows)] - { - let default_db = default_dir.join("cc-switch.db"); - if !default_db.exists() { - if let Ok(home_env) = std::env::var("HOME") { - let trimmed = home_env.trim(); - if !trimmed.is_empty() { - let legacy_home = PathBuf::from(trimmed); - if legacy_home.is_absolute() { - let legacy_dir = legacy_home.join(".cc-switch"); - if legacy_dir.join("cc-switch.db").exists() { - log::info!( - "Detected v3.10.3 legacy database at {}, using it instead of {}", - legacy_dir.display(), - default_dir.display() - ); - return Ok(legacy_dir); - } - } else { - log::warn!( - "Ignoring relative legacy HOME discovery candidate: {}", - legacy_home.display() - ); - } - } - } - } - } - - Ok(default_dir) -} - -pub fn try_get_app_config_dir() -> Result { - try_get_app_config_dir_app().map_err(|err| err.to_string()) -} -''', - "typed app root boundary", -) - -# --------------------------------------------------------------------------- -# app_store.rs: cache failure separately from genuine absence. A failed Store -# refresh can no longer leave None behind and silently redirect persistence. -# --------------------------------------------------------------------------- -path = "src-tauri/src/app_store.rs" -replace_region( - path, - "/// 缓存当前的 app_config_dir 覆盖路径,避免存储 AppHandle\n", - "\nfn open_paths_store(", - '''/// Cached Store outcome. `Ok(None)` is genuine absence; `Err` means the last -/// refresh failed and must remain observable to persistence-root callers. -static APP_CONFIG_DIR_OVERRIDE: OnceLock, String>>> = OnceLock::new(); - -fn override_cache() -> &'static RwLock, String>> { - APP_CONFIG_DIR_OVERRIDE.get_or_init(|| RwLock::new(Ok(None))) -} - -fn update_cached_override(value: Result, String>) { - match override_cache().write() { - Ok(mut guard) => *guard = value, - Err(err) => log::error!("app_config_dir override cache poisoned: {err}"), - } -} - -pub fn try_get_app_config_dir_override() -> Result, AppError> { - let guard = override_cache() - .read() - .map_err(|err| AppError::Lock(err.to_string()))?; - guard - .as_ref() - .map(Clone::clone) - .map_err(|err| AppError::Config(err.clone())) -} - -/// Legacy infallible adapter. It fails closed instead of turning a cached Store -/// error into absence. New code must use `try_get_app_config_dir_override`. -pub fn get_app_config_dir_override() -> Option { - try_get_app_config_dir_override().unwrap_or_else(|err| { - log::error!("{err}"); - panic!("{err}"); - }) -} -''', - "tri-state app root cache", -) -replace_exact( - path, - ''' let settings_path = crate::config::try_get_home_dir() - .map_err(AppError::Config)? -''', - ''' let settings_path = crate::config::try_get_home_dir_typed() - .map_err(AppError::from)? -''', - "legacy settings typed home", -) -replace_exact( - path, - '''fn resolve_path(raw: &str) -> Result { - crate::config::resolve_persistence_path(raw, "app_config_dir") - .map_err(AppError::InvalidInput) -} -''', - '''fn resolve_path(raw: &str) -> Result { - crate::config::resolve_persistence_path_typed(raw, "app_config_dir").map_err(AppError::from) -} -''', - "typed app_store path", -) -replace_region( - path, - "pub fn refresh_app_config_dir_override(\n", - "\n/// 写入 app_config_dir", - '''pub fn refresh_app_config_dir_override( - app: &tauri::AppHandle, -) -> Result, AppError> { - let result = (|| { - let migrated = migrate_legacy_override_if_needed(app)?; - match migrated { - Some(path) => Ok(Some(path)), - None => read_override_from_store(app), - } - })(); - - match result { - Ok(value) => { - update_cached_override(Ok(value.clone())); - Ok(value) - } - Err(err) => { - update_cached_override(Err(err.to_string())); - Err(err) - } - } -} -''', - "observable Store refresh failure", -) -replace_exact( - path, - " update_cached_override(resolved.clone());\n", - " update_cached_override(Ok(resolved.clone()));\n", - "cache successful Store write", -) -replace_exact( - path, - " update_cached_override(value);\n", - " update_cached_override(Ok(value));\n", - "cache successful migration", -) - -# --------------------------------------------------------------------------- -# Hermes: current explicit persistent roots are strict. Missing/blank env means -# absent; relative HERMES_HOME/LOCALAPPDATA is a configuration error, not fallback. -# --------------------------------------------------------------------------- -path = "src-tauri/src/hermes_config.rs" -replace_exact( - path, - "use crate::error::AppError;\n", - "use crate::error::AppError;\nuse crate::failure_semantics::{optional_absolute_env_root, require_absolute_root};\n", - "Hermes root helpers import", -) -replace_region( - path, - "pub fn try_get_hermes_dir() -> Result {", - "\npub fn try_get_hermes_config_path() -> Result {", - '''pub fn try_get_hermes_dir() -> Result { - if let Some(override_dir) = get_hermes_override_dir() { - return Ok(require_absolute_root(override_dir, "hermes_config_dir")?); - } - - if let Some(path) = optional_absolute_env_root("HERMES_HOME")? { - return Ok(path); - } - - default_hermes_dir() -} - -#[cfg(target_os = "windows")] -fn default_hermes_dir() -> Result { - if let Some(local_app_data) = optional_absolute_env_root("LOCALAPPDATA")? { - return Ok(local_app_data.join("hermes")); - } - Ok(crate::config::try_get_home_dir_typed()?.join("AppData").join("Local").join("hermes")) -} - -#[cfg(not(target_os = "windows"))] -fn default_hermes_dir() -> Result { - Ok(crate::config::try_get_home_dir_typed()?.join(".hermes")) -} - -#[cfg(test)] -fn windows_local_hermes_dir(localappdata: Option<&std::ffi::OsStr>, home: &Path) -> PathBuf { - localappdata - .map(|value| value.to_string_lossy().trim().to_string()) - .filter(|value| !value.is_empty()) - .map(PathBuf::from) - .unwrap_or_else(|| home.join("AppData").join("Local")) - .join("hermes") -} - -''', - "strict Hermes persistent roots", -) - -# --------------------------------------------------------------------------- -# OpenCode persistence root: XDG_DATA_HOME is an explicit persistent root. -# Relative values are errors. This is intentionally different from CLI discovery. -# --------------------------------------------------------------------------- -path = "src-tauri/src/session_manager/providers/opencode.rs" -replace_region( - path, - "fn try_get_opencode_base_dir() -> Result {", - "\npub(crate) fn get_opencode_data_dir() -> Result {", - '''fn try_get_opencode_base_dir() -> Result { - match crate::failure_semantics::optional_absolute_env_root("XDG_DATA_HOME") { - Ok(Some(root)) => return Ok(root.join("opencode")), - Ok(None) => {} - Err(err) => return Err(err.to_string()), - } - Ok(crate::config::try_get_home_dir_typed() - .map_err(|err| err.to_string())? - .join(".local/share/opencode")) -} -''', - "strict OpenCode persistence root", -) - -# --------------------------------------------------------------------------- -# CLI discovery is intentionally best-effort. Make optional HOME explicit in -# the helper contract so callers cannot manufacture Path("") sentinels. -# --------------------------------------------------------------------------- -path = "src-tauri/src/commands/misc.rs" -replace_exact( - path, - "fn opencode_extra_search_paths(\n home: &Path,\n", - "fn opencode_extra_search_paths(\n home: Option<&Path>,\n", - "optional OpenCode discovery home", -) -replace_exact( - path, - ''' if !home.as_os_str().is_empty() { - push_unique_path(&mut paths, home.join("bin")); - push_unique_path(&mut paths, home.join(".opencode").join("bin")); - push_unique_path(&mut paths, home.join(".bun").join("bin")); - push_unique_path(&mut paths, home.join("go").join("bin")); - } -''', - ''' if let Some(home) = home { - push_unique_path(&mut paths, home.join("bin")); - push_unique_path(&mut paths, home.join(".opencode").join("bin")); - push_unique_path(&mut paths, home.join(".bun").join("bin")); - push_unique_path(&mut paths, home.join("go").join("bin")); - } -''', - "OpenCode discovery HOME body", -) -replace_exact( - path, - ''' if tool == "opencode" { - let empty_home = Path::new(""); - for path in opencode_extra_search_paths( - home.as_deref().unwrap_or(empty_home), - std::env::var_os("OPENCODE_INSTALL_DIR"), - std::env::var_os("XDG_BIN_DIR"), - std::env::var_os("GOPATH"), - ) { -''', - ''' if tool == "opencode" { - for path in opencode_extra_search_paths( - home.as_deref(), - std::env::var_os("OPENCODE_INSTALL_DIR"), - std::env::var_os("XDG_BIN_DIR"), - std::env::var_os("GOPATH"), - ) { -''', - "remove fake HOME discovery sentinel", -) -# Existing unit tests pass concrete Path references; preserve their intent explicitly. -replace_exact( - path, - "opencode_extra_search_paths(&home, None, None, None)", - "opencode_extra_search_paths(Some(&home), None, None, None)", - "OpenCode discovery test caller", -) - -print("Applied failure-semantics redesign before test alignment") diff --git a/scripts/apply_final_convergence_once.py b/scripts/apply_final_convergence_once.py deleted file mode 100644 index 041fe899f7b..00000000000 --- a/scripts/apply_final_convergence_once.py +++ /dev/null @@ -1,216 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path -import runpy - - -def read(path: str) -> str: - return Path(path).read_text(encoding="utf-8") - - -def write(path: str, text: str) -> None: - Path(path).write_text(text, encoding="utf-8") - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - raise SystemExit(f"{label}: expected 1 exact match, found {count}") - return text.replace(old, new, 1) - - -def remove_region(text: str, start: str, end: str, label: str) -> str: - if text.count(start) != 1: - raise SystemExit(f"{label}: start count={text.count(start)}") - i = text.index(start) - try: - j = text.index(end, i) - except ValueError: - raise SystemExit(f"{label}: end marker missing") - return text[:i] + text[j:] - - -def assert_balanced_region(path: str, start: str, end: str, label: str) -> None: - text = read(path) - if start not in text: - raise SystemExit(f"{label}: start marker missing after migration") - i = text.index(start) - if end not in text[i:]: - raise SystemExit(f"{label}: end marker missing after migration") - j = text.index(end, i) - block = text[i:j] - opens = block.count("{") - closes = block.count("}") - if opens != closes: - print(f"--- generated {label} block ---") - print(block) - print(f"--- end generated {label} block; braces={opens}/{closes} ---") - raise SystemExit(f"{label}: generated brace imbalance {opens} open / {closes} close") - - -def assert_traversal_boundaries(stage: str) -> None: - assert_balanced_region( - "src-tauri/src/session_manager/providers/codex.rs", - "fn collect_jsonl_files(", - "#[cfg(test)]", - f"Codex collect_jsonl_files ({stage})", - ) - assert_balanced_region( - "src-tauri/src/session_manager/providers/claude.rs", - "fn collect_jsonl_files(", - "fn remove_path_if_exists", - f"Claude collect_jsonl_files ({stage})", - ) - assert_balanced_region( - "src-tauri/src/session_manager/providers/openclaw.rs", - "fn load_display_names(", - "fn parse_session", - f"OpenClaw load_display_names ({stage})", - ) - - -# The typed root APIs have replaced these production helpers. Keep the two -# deterministic pure helpers only for the tests that still exercise injected -# path inputs; remove the obsolete expansion and infallible Store adapter. -config_path = "src-tauri/src/config.rs" -text = read(config_path) -text = replace_once( - text, - "fn require_absolute_path(path: PathBuf, label: &str) -> Result {", - "#[cfg(test)]\nfn require_absolute_path(path: PathBuf, label: &str) -> Result {", - "gate legacy absolute-path helper to tests", -) -text = replace_once( - text, - "fn resolve_home_dir(\n", - "#[cfg(test)]\nfn resolve_home_dir(\n", - "gate injected HOME resolver to tests", -) -text = remove_region( - text, - "/// Expand `~`, `~/...`", - "/// Resolve a user-configurable persistence/configuration root.\n", - "remove obsolete expand_home_path compatibility helper", -) -write(config_path, text) - -app_store_path = "src-tauri/src/app_store.rs" -text = read(app_store_path) -text = remove_region( - text, - "/// Legacy infallible adapter. It fails closed instead of turning a cached Store\n", - "fn open_paths_store(\n", - "remove obsolete infallible app-root cache adapter", -) -write(app_store_path, text) - -# The structural migration's replacement blocks include their own final `}`. -# Its generic replace_region originally retained an end marker beginning with -# `\n}`, duplicating that close. Consume exactly that old function close while -# retaining everything after it (test/module annotations or the next helper). -scan_driver = Path("scripts/apply_session_scan_semantics_once.py") -scan_text = scan_driver.read_text(encoding="utf-8") -old_replace = " write(path, text[:i] + new + text[j:])\n" -new_replace = ''' if end.startswith("\\n}") and new.rstrip().endswith("}"): - j += 2 - write(path, text[:i] + new + text[j:]) -''' -if scan_text.count(old_replace) != 1: - raise SystemExit(f"session structural replace_region body count={scan_text.count(old_replace)}") -scan_driver.write_text(scan_text.replace(old_replace, new_replace, 1), encoding="utf-8") - -# Session scanning is a separate domain from provider installation discovery. -runpy.run_path("scripts/apply_session_scan_semantics_once.py", run_name="__main__") -assert_traversal_boundaries("after structural migration") - -# OpenClaw sessions are gateway-managed and deliberately have no CLI resume -# command. Also, parse_session is followed by prune_sessions_index(), not the -# test module. Correct the dirty-parser migration driver's exact anchors. -parse_driver = Path("scripts/apply_session_parse_semantics_once.py") -parse_text = parse_driver.read_text(encoding="utf-8") -old_openclaw_resume = 'resume_command: Some(format!("openclaw --session {session_id}")),' -new_openclaw_resume = 'resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume' -if parse_text.count(old_openclaw_resume) != 2: - raise SystemExit( - f"OpenClaw parse-driver resume anchor count={parse_text.count(old_openclaw_resume)}" - ) -parse_text = parse_text.replace(old_openclaw_resume, new_openclaw_resume) - -old_anchor = ( - "''' resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume\n" - " })\n}\n\n#[cfg(test)]'''," -) -new_anchor = ( - "''' resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume\n" - " })\n}\n\nfn prune_sessions_index('''," -) -if parse_text.count(old_anchor) != 1: - raise SystemExit(f"OpenClaw parse-driver source anchor count={parse_text.count(old_anchor)}") -parse_text = parse_text.replace(old_anchor, new_anchor, 1) - -old_replacement = ( - "''' resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume\n" - " })\n}\n\n#[cfg(test)]\n" - "fn parse_session(\n" - " path: &Path,\n" - " display_names: Option<&HashMap>,\n" - ") -> Option {\n" - " parse_session_checked(path, display_names).ok()\n" - "}\n\n#[cfg(test)]'''," -) -new_replacement = ( - "''' resume_command: None, // OpenClaw sessions are gateway-managed, no CLI resume\n" - " })\n}\n\n#[cfg(test)]\n" - "fn parse_session(\n" - " path: &Path,\n" - " display_names: Option<&HashMap>,\n" - ") -> Option {\n" - " parse_session_checked(path, display_names).ok()\n" - "}\n\nfn prune_sessions_index('''," -) -if parse_text.count(old_replacement) != 1: - raise SystemExit( - f"OpenClaw parse-driver replacement anchor count={parse_text.count(old_replacement)}" - ) -parse_text = parse_text.replace(old_replacement, new_replacement, 1) -parse_driver.write_text(parse_text, encoding="utf-8") -runpy.run_path("scripts/apply_session_parse_semantics_once.py", run_name="__main__") -assert_traversal_boundaries("after dirty-history migration") - -# Gemini and OpenCode no longer have any test consumers for the legacy Option -# parser shape. Remove those wrappers instead of suppressing dead-code warnings. -for path, wrapper, label in [ - ( - "src-tauri/src/session_manager/providers/gemini.rs", - '''#[cfg(test)] -fn parse_session(path: &Path) -> Option { - parse_session_checked(path).ok() -} - -''', - "remove obsolete Gemini parser compatibility wrapper", - ), - ( - "src-tauri/src/session_manager/providers/opencode.rs", - '''#[cfg(test)] -fn parse_session(storage: &Path, path: &Path) -> Option { - parse_session_checked(storage, path).ok() -} - -''', - "remove obsolete OpenCode parser compatibility wrapper", - ), -]: - text = read(path) - text = replace_once(text, wrapper, "", label) - write(path, text) - -# These are one-shot migration mechanics. On a successful verified run they -# disappear from the resulting branch along with the existing drivers. -for temporary in [ - "scripts/apply_session_scan_semantics_once.py", - "scripts/apply_session_parse_semantics_once.py", - "scripts/apply_final_convergence_once.py", -]: - Path(temporary).unlink() - -print("Applied final root/session design convergence") diff --git a/scripts/apply_hermes_callers_once.py b/scripts/apply_hermes_callers_once.py deleted file mode 100644 index 22ce42b00ea..00000000000 --- a/scripts/apply_hermes_callers_once.py +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - - -def read(path: str) -> str: - return Path(path).read_text(encoding="utf-8") - - -def write(path: str, text: str) -> None: - Path(path).write_text(text, encoding="utf-8") - - -def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) -> None: - text = read(path) - count = text.count(old) - if count != expected: - raise SystemExit(f"{label}: expected {expected}, found {count}") - write(path, text.replace(old, new)) - - -# --------------------------------------------------------------------------- -# Tauri config commands already return Result; Hermes root failures -# must be visible to the caller rather than recovered through a panic wrapper. -# --------------------------------------------------------------------------- -path = "src-tauri/src/commands/config.rs" -replace_exact( - path, - ''' AppType::Hermes => { - let config_path = crate::hermes_config::get_hermes_config_path(); - let exists = config_path.exists(); - let path = crate::hermes_config::get_hermes_dir() - .to_string_lossy() - .to_string(); - - Ok(ConfigStatus { exists, path }) - } -''', - ''' AppType::Hermes => { - let dir = crate::hermes_config::try_get_hermes_dir().map_err(|e| e.to_string())?; - let exists = dir.join("config.yaml").exists(); - let path = dir.to_string_lossy().to_string(); - - Ok(ConfigStatus { exists, path }) - } -''', - "Hermes config status root propagation", -) -replace_exact( - path, - " AppType::Hermes => crate::hermes_config::get_hermes_dir(),\n", - ''' AppType::Hermes => { - crate::hermes_config::try_get_hermes_dir().map_err(|e| e.to_string())? - } -''', - "Hermes config command root propagation", - expected=2, -) - -# --------------------------------------------------------------------------- -# Prompt-file path derivation is already fallible, so propagate the Hermes root. -# --------------------------------------------------------------------------- -replace_exact( - "src-tauri/src/prompt_files.rs", - " AppType::Hermes => crate::hermes_config::get_hermes_dir(),\n", - " AppType::Hermes => crate::hermes_config::try_get_hermes_dir()?,\n", - "Hermes prompt-file root propagation", -) - -# --------------------------------------------------------------------------- -# MCP synchronization writes user configuration. A missing/invalid root is a -# sync failure, not an 'Hermes is absent' signal, so preserve the Result boundary. -# --------------------------------------------------------------------------- -path = "src-tauri/src/mcp/hermes.rs" -replace_exact( - path, - '''fn should_sync_hermes_mcp() -> bool { - hermes_config::get_hermes_dir().exists() -} -''', - '''fn should_sync_hermes_mcp() -> Result { - Ok(hermes_config::try_get_hermes_dir()?.exists()) -} -''', - "Hermes MCP root propagation", -) -replace_exact( - path, - " if !should_sync_hermes_mcp() {\n", - " if !should_sync_hermes_mcp()? {\n", - "Hermes MCP sync caller propagation", - expected=2, -) - -# --------------------------------------------------------------------------- -# Live provider read/remove APIs already return AppError. Keep root-resolution -# errors observable rather than converting them into missing-config behavior. -# --------------------------------------------------------------------------- -path = "src-tauri/src/services/provider/live.rs" -replace_exact( - path, - " let config_path = crate::hermes_config::get_hermes_config_path();\n", - " let config_path = crate::hermes_config::try_get_hermes_config_path()?;\n", - "Hermes live-read config path propagation", -) -replace_exact( - path, - " if !hermes_config::get_hermes_dir().exists() {\n", - " if !hermes_config::try_get_hermes_dir()?.exists() {\n", - "Hermes live-remove root propagation", -) - -# --------------------------------------------------------------------------- -# Test contract: the platform-default helper is now fallible by design. -# --------------------------------------------------------------------------- -replace_exact( - "src-tauri/src/hermes_config.rs", - " assert_eq!(dir, default_hermes_dir());\n", - " assert_eq!(dir, default_hermes_dir().expect(\"default Hermes dir\"));\n", - "Hermes default-dir test fallible contract", -) - -# --------------------------------------------------------------------------- -# Permanent global guard: production modules must never reintroduce the test-only -# Hermes panic wrappers. hermes_config.rs itself is excluded because it owns the -# #[cfg(test)] compatibility helpers. -# --------------------------------------------------------------------------- -guard = "scripts/check_rust_failure_boundaries.py" -text = read(guard) -marker = '''# URL sanitization is a common diagnostics boundary. Specialized copies drift and caused raw -# deep-link/model-fetch paths to be missed; keep implementations centralized. -''' -if text.count(marker) != 1: - raise SystemExit(f"Hermes global guard anchor count={text.count(marker)}") -hermes_guard = '''# Hermes persistence/session roots are fallible production boundaries. The only infallible -# wrappers are #[cfg(test)] helpers owned by hermes_config.rs; no other module may call/import them. -for path in RUST_ROOT.rglob("*.rs"): - if path.name == "hermes_config.rs": - continue - text = path.read_text(encoding="utf-8") - if re.search(r"(?:crate::)?hermes_config::get_hermes_(?:dir|config_path)\\b", text): - failures.append( - f"{path.relative_to(ROOT)}: Hermes roots must use fallible try_get_hermes_* APIs" - ) - -''' -write(guard, text.replace(marker, hermes_guard + marker, 1)) - -# Arm the final convergence stage only after the typed root redesign has run. -# This keeps root-contract migration and Session-domain migration independently -# attributable while still finishing them in the same verified one-shot run. -finalizer = Path("scripts/apply_failure_semantics_redesign_once.py") -finalizer_text = finalizer.read_text(encoding="utf-8") -hook_marker = "# chained-final-design-convergence" -if hook_marker in finalizer_text: - raise SystemExit("final design convergence hook already installed") -finalizer.write_text( - finalizer_text - + '''\n\n# chained-final-design-convergence\nimport runpy\nrunpy.run_path("scripts/apply_final_convergence_once.py", run_name="__main__")\n\n# Restore one-shot drivers that the workflow intentionally rewrites before final git rm.\n# Their migrations have already executed; keeping the tracked copies clean lets the\n# verified cleanup step remove them without forcing unrelated product files.\nimport subprocess\nsubprocess.run(\n [\n "git",\n "restore",\n "--source=HEAD",\n "--worktree",\n "--",\n "scripts/apply_design_invariant_hardening_once.py",\n "scripts/apply_design_invariant_followup_once.py",\n "scripts/apply_hermes_failure_boundary_once.py",\n "scripts/apply_failure_semantics_redesign_once.py",\n ],\n check=True,\n)\n''', - encoding="utf-8", -) - -# Retrigger marker: rerun strict convergence after removing unused Gemini/OpenCode parser wrappers. -print("Applied Hermes production caller migration") diff --git a/scripts/apply_hermes_failure_boundary_once.py b/scripts/apply_hermes_failure_boundary_once.py deleted file mode 100644 index 0d88f2244f1..00000000000 --- a/scripts/apply_hermes_failure_boundary_once.py +++ /dev/null @@ -1,365 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - - -def read(path: str) -> str: - return Path(path).read_text(encoding="utf-8") - - -def write(path: str, text: str) -> None: - Path(path).write_text(text, encoding="utf-8") - - -def replace_once(path: str, old: str, new: str, label: str, expected: int = 1) -> None: - text = read(path) - count = text.count(old) - if count != expected: - raise SystemExit(f"{label}: expected {expected}, found {count}") - write(path, text.replace(old, new)) - - -def replace_region(path: str, start: str, end: str, new: str, label: str) -> None: - text = read(path) - if text.count(start) != 1: - raise SystemExit(f"{label}: start count={text.count(start)}") - i = text.index(start) - try: - j = text.index(end, i) - except ValueError: - raise SystemExit(f"{label}: end marker missing") - write(path, text[:i] + new + text[j:]) - - -# --------------------------------------------------------------------------- -# Hermes configuration root: fallible API is the production boundary. -# Test-only compatibility wrappers may panic, production Result APIs may not. -# --------------------------------------------------------------------------- -path = "src-tauri/src/hermes_config.rs" -replace_once( - path, - "use crate::config::{atomic_write, get_app_config_dir};\n", - "use crate::config::atomic_write;\n", - "Hermes obsolete infallible app-root import", -) -replace_region( - path, - "/// 获取 Hermes 配置目录\n", - "fn hermes_write_lock() -> &'static Mutex<()> {", - '''/// Resolve the Hermes configuration root without hiding HOME failure behind a panic. -/// -/// Resolution order matches Hermes, but every accepted root is absolute: -/// 1. validated CC Switch `hermes_config_dir` override; -/// 2. absolute `HERMES_HOME`; -/// 3. platform default rooted in an absolute user home / LOCALAPPDATA. -pub fn try_get_hermes_dir() -> Result { - if let Some(override_dir) = get_hermes_override_dir() { - if override_dir.is_absolute() { - return Ok(override_dir); - } - return Err(AppError::Config(format!( - "hermes_config_dir must be absolute: {}", - override_dir.display() - ))); - } - - if let Some(raw) = std::env::var_os("HERMES_HOME") { - let value = raw.to_string_lossy(); - let trimmed = value.trim(); - if !trimmed.is_empty() { - let path = PathBuf::from(trimmed); - if path.is_absolute() { - return Ok(path); - } - log::warn!("Ignoring relative HERMES_HOME: {}", path.display()); - } - } - - default_hermes_dir() -} - -#[cfg(target_os = "windows")] -fn default_hermes_dir() -> Result { - let home = crate::config::try_get_home_dir().map_err(AppError::Config)?; - Ok(windows_local_hermes_dir( - std::env::var_os("LOCALAPPDATA").as_deref(), - &home, - )) -} - -#[cfg(not(target_os = "windows"))] -fn default_hermes_dir() -> Result { - Ok(crate::config::try_get_home_dir() - .map_err(AppError::Config)? - .join(".hermes")) -} - -#[cfg(any(target_os = "windows", test))] -fn windows_local_hermes_dir(localappdata: Option<&std::ffi::OsStr>, home: &Path) -> PathBuf { - localappdata - .map(|value| PathBuf::from(value.to_string_lossy().trim().to_string())) - .filter(|path| path.is_absolute()) - .unwrap_or_else(|| home.join("AppData").join("Local")) - .join("hermes") -} - -pub fn try_get_hermes_config_path() -> Result { - Ok(try_get_hermes_dir()?.join("config.yaml")) -} - -// Tests deliberately control CC_SWITCH_TEST_HOME and may use concise path helpers. -// Production code must use the fallible functions above. -#[cfg(test)] -pub fn get_hermes_dir() -> PathBuf { - try_get_hermes_dir().expect("Hermes test root") -} - -#[cfg(test)] -pub fn get_hermes_config_path() -> PathBuf { - try_get_hermes_config_path().expect("Hermes test config path") -} - -''', - "Hermes fallible root API", -) -replace_once( - path, - " let path = get_hermes_config_path();\n", - " let path = try_get_hermes_config_path()?;\n", - "Hermes read config path", -) -replace_once( - path, - ' let backup_dir = get_app_config_dir().join("backups").join("hermes");\n', - ''' let backup_dir = crate::config::try_get_app_config_dir() - .map_err(AppError::Config)? - .join("backups") - .join("hermes"); -''', - "Hermes backup app root", -) -replace_once( - path, - " let config_path = get_hermes_config_path();\n", - " let config_path = try_get_hermes_config_path()?;\n", - "Hermes write config path", -) -replace_region( - path, - "fn memories_dir() -> PathBuf {", - "\n/// Read a Hermes memory file as a markdown blob.", - '''fn memories_dir() -> Result { - Ok(try_get_hermes_dir()?.join("memories")) -} -''', - "Hermes memory root", -) -replace_once( - path, - " let path = memories_dir().join(kind.filename());\n", - " let path = memories_dir()?.join(kind.filename());\n", - "Hermes memory path propagation", - expected=2, -) - -# Permanent checks should make regression to production panic wrappers impossible. -guard = "scripts/check_rust_failure_boundaries.py" -text = read(guard) -marker = "]\n\nfailures = []" -if text.count(marker) != 1: - raise SystemExit(f"Hermes guard marker count={text.count(marker)}") -checks = ''' ("hermes_config.rs", re.compile(r"pub fn read_hermes_config\\([^)]*\\) -> Result[\\s\\S]{0,240}get_hermes_config_path\\(\\)"), "Hermes config reads must propagate root resolution errors"), - ("hermes_config.rs", re.compile(r"let config_path = get_hermes_config_path\\(\\);"), "Hermes config writes must propagate root resolution errors"), - ("hermes_config.rs", re.compile(r"let backup_dir = get_app_config_dir\\(\\)"), "Hermes backup persistence must not hide app-root failures"), - ("session_manager/providers/hermes.rs", re.compile(r"use crate::hermes_config::get_hermes_dir"), "Hermes session discovery must use the fallible root API"), -''' -text = text.replace(marker, checks + marker, 1) -write(guard, text) - - -# --------------------------------------------------------------------------- -# Skill is already a Result API: Hermes-specific root must propagate naturally. -# --------------------------------------------------------------------------- -replace_once( - "src-tauri/src/services/skill.rs", - 'AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"),', - '''AppType::Hermes => crate::hermes_config::try_get_hermes_dir() - .map_err(|err| anyhow!(err))? - .join("skills"),''', - "Skill Hermes root propagation", -) - - -# --------------------------------------------------------------------------- -# Hermes session discovery: no hidden HOME panic and no empty-list conversion -# for SQLite/open/query/read_dir failures. Missing DB/table/dir remain legitimate -# empty states; malformed individual JSONL files are warned and skipped. -# --------------------------------------------------------------------------- -path = "src-tauri/src/session_manager/providers/hermes.rs" -replace_once( - path, - "use crate::hermes_config::get_hermes_dir;\n", - "use crate::hermes_config::try_get_hermes_dir;\n", - "Hermes session fallible import", -) -replace_region( - path, - "fn get_hermes_db_path() -> PathBuf {", - "\nfn sqlite_row_to_session_meta", - '''fn get_hermes_db_path() -> Result { - Ok(try_get_hermes_dir() - .map_err(|err| err.to_string())? - .join("state.db")) -} - -/// Scan sessions from both SQLite database and JSONL transcript files, -/// with SQLite taking precedence on ID conflicts. -pub fn scan_sessions() -> Result, String> { - let root = try_get_hermes_dir().map_err(|err| err.to_string())?; - let sqlite_sessions = scan_sessions_sqlite(&root.join("state.db"))?; - let jsonl_sessions = scan_sessions_jsonl(&root.join("sessions"))?; - - if sqlite_sessions.is_empty() { - return Ok(jsonl_sessions); - } - if jsonl_sessions.is_empty() { - return Ok(sqlite_sessions); - } - - let sqlite_ids: std::collections::HashSet = sqlite_sessions - .iter() - .map(|session| session.session_id.clone()) - .collect(); - let mut merged = sqlite_sessions; - for session in jsonl_sessions { - if !sqlite_ids.contains(&session.session_id) { - merged.push(session); - } - } - Ok(merged) -} - -// ── SQLite scanning ───────────────────────────────────────────────── - -fn scan_sessions_sqlite(db_path: &Path) -> Result, String> { - if !db_path.exists() { - return Ok(Vec::new()); - } - - let conn = Connection::open_with_flags( - db_path, - rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) - .map_err(|err| format!("Failed to open Hermes session database {}: {err}", db_path.display()))?; - - let has_sessions: bool = conn - .query_row( - "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='sessions'", - [], - |row| row.get(0), - ) - .map_err(|err| format!("Failed to inspect Hermes session schema: {err}"))?; - if !has_sessions { - return Ok(Vec::new()); - } - - let columns = get_table_columns(&conn, "sessions")?; - let mut stmt = conn - .prepare("SELECT * FROM sessions ORDER BY rowid DESC LIMIT 500") - .map_err(|err| format!("Failed to prepare Hermes session query: {err}"))?; - let rows = stmt - .query_map([], |row| Ok(row_to_json(row, &columns))) - .map_err(|err| format!("Failed to query Hermes sessions: {err}"))?; - - let db_source = format!("sqlite:{}", db_path.display()); - let mut sessions = Vec::new(); - for row_result in rows { - let row = row_result.map_err(|err| format!("Failed to decode Hermes session row: {err}"))?; - match sqlite_row_to_session_meta(&row, &db_source) { - Some(meta) => sessions.push(meta), - None => log::warn!("Skipping malformed Hermes SQLite session row without a usable id"), - } - } - Ok(sessions) -} - -''', - "Hermes session root and SQLite observability", -) -replace_region( - path, - "fn get_table_columns(conn: &Connection, table: &str) -> Vec {", - "\n/// Convert a SQLite row to a JSON Value", - '''fn get_table_columns(conn: &Connection, table: &str) -> Result, String> { - let query = format!("PRAGMA table_info({table})"); - let mut stmt = conn - .prepare(&query) - .map_err(|err| format!("Failed to inspect Hermes table columns: {err}"))?; - let rows = stmt - .query_map([], |row| row.get::<_, String>(1)) - .map_err(|err| format!("Failed to query Hermes table columns: {err}"))?; - let mut columns = Vec::new(); - for row in rows { - columns.push(row.map_err(|err| format!("Failed to decode Hermes table column: {err}"))?); - } - Ok(columns) -} - -''', - "Hermes table-column observability", -) -replace_once( - path, - " let expected_db_path = get_hermes_db_path()\n", - " let expected_db_path = get_hermes_db_path()?\n", - "Hermes delete expected DB root", -) -replace_region( - path, - "fn scan_sessions_jsonl() -> Vec {", - "\nfn parse_jsonl_session(path: &Path)", - '''fn scan_sessions_jsonl(sessions_dir: &Path) -> Result, String> { - if !sessions_dir.exists() { - return Ok(Vec::new()); - } - - let entries = std::fs::read_dir(sessions_dir) - .map_err(|err| format!("Failed to read Hermes sessions directory {}: {err}", sessions_dir.display()))?; - let mut sessions = Vec::new(); - for entry in entries { - let entry = entry.map_err(|err| format!("Failed to enumerate Hermes session entry: {err}"))?; - let path = entry.path(); - let ext = path.extension().and_then(|ext| ext.to_str()); - if ext != Some("jsonl") && ext != Some("json") { - continue; - } - match parse_jsonl_session(&path) { - Some(meta) => sessions.push(meta), - None => log::warn!("Skipping malformed or unreadable Hermes session file: {}", path.display()), - } - } - Ok(sessions) -} - -''', - "Hermes JSONL discovery observability", -) - -# Aggregator already returns Result after the structural follow-up; Hermes now joins like OpenCode. -replace_once( - "src-tauri/src/session_manager/mod.rs", - ''' let r6 = h6.join().map_err(|_| "Hermes session scan panicked".to_string())?;''', - ''' let r6 = h6 - .join() - .map_err(|_| "Hermes session scan panicked".to_string())??;''', - "Hermes session Result propagation", -) -replace_once( - "src-tauri/src/session_manager/mod.rs", - '"hermes" => vec![crate::hermes_config::get_hermes_dir().join("sessions")],', - '''"hermes" => vec![crate::hermes_config::try_get_hermes_dir() - .map_err(|err| err.to_string())? - .join("sessions")],''', - "Hermes deletion root propagation", -) - -print("Applied Hermes fallible failure boundary") diff --git a/scripts/apply_session_parse_semantics_once.py b/scripts/apply_session_parse_semantics_once.py deleted file mode 100644 index 23cdd9ca255..00000000000 --- a/scripts/apply_session_parse_semantics_once.py +++ /dev/null @@ -1,506 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - - -def read(path: str) -> str: - return Path(path).read_text(encoding="utf-8") - - -def write(path: str, text: str) -> None: - Path(path).write_text(text, encoding="utf-8") - - -def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) -> None: - text = read(path) - count = text.count(old) - if count != expected: - raise SystemExit(f"{label}: expected {expected}, found {count}") - write(path, text.replace(old, new, expected)) - - -# --------------------------------------------------------------------------- -# Shared file reader: a line-level I/O error is not EOF. Preserve it so the -# provider parser can classify the file as dirty/unreadable and warn+skip it. -# --------------------------------------------------------------------------- -path = "src-tauri/src/session_manager/providers/utils.rs" -replace_exact( - path, - " let all: Vec = reader.lines().map_while(Result::ok).collect();\n", - " let all: Vec = reader.lines().collect::>>()?;\n", - "small session file line I/O", -) -replace_exact( - path, - " let head: Vec = reader.lines().take(head_n).map_while(Result::ok).collect();\n", - " let head: Vec = reader.lines().take(head_n).collect::>>()?;\n", - "session head line I/O", -) -replace_exact( - path, - " let all_tail: Vec = tail_reader.lines().map_while(Result::ok).collect();\n", - " let all_tail: Vec = tail_reader.lines().collect::>>()?;\n", - "session tail line I/O", -) - - -# --------------------------------------------------------------------------- -# Codex: Ok(None) is reserved for explicitly filtered subagent sessions. -# I/O or structurally unusable history is Err and is warned+skipped by scanning. -# --------------------------------------------------------------------------- -path = "src-tauri/src/session_manager/providers/codex.rs" -replace_exact( - path, - ''' for path in files { - if let Some(meta) = parse_session(&path) { - sessions.push(meta); - } - } -''', - ''' for path in files { - match parse_session_checked(&path) { - Ok(Some(meta)) => sessions.push(meta), - Ok(None) => {} - Err(err) => log::warn!("Skipping unreadable Codex session {}: {err}", path.display()), - } - } -''', - "Codex dirty-session scan policy", -) -replace_exact( - path, - ''' let meta = parse_session(path) - .ok_or_else(|| format!("Failed to parse Codex session metadata: {}", path.display()))?; -''', - ''' let meta = parse_session_checked(path)? - .ok_or_else(|| format!("Codex session is intentionally filtered: {}", path.display()))?; -''', - "Codex delete parser boundary", -) -replace_exact( - path, - "fn parse_session(path: &Path) -> Option {\n", - "fn parse_session_checked(path: &Path) -> Result, String> {\n", - "Codex checked parser signature", -) -replace_exact( - path, - " let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;\n", - ''' let (head, tail) = read_head_tail_lines(path, 10, 30) - .map_err(|err| format!("Failed to read Codex session {}: {err}", path.display()))?; -''', - "Codex parser I/O propagation", -) -replace_exact( - path, - ''' if is_subagent_source(payload.get("source")) { - return None; - } -''', - ''' if is_subagent_source(payload.get("source")) { - return Ok(None); - } -''', - "Codex intentional subagent filter", -) -replace_exact( - path, - " let session_id = session_id?;\n", - ''' let session_id = session_id.ok_or_else(|| { - format!("Codex session has no usable session id: {}", path.display()) - })?; -''', - "Codex missing-id corruption", -) -replace_exact( - path, - " Some(SessionMeta {\n", - " Ok(Some(SessionMeta {\n", - "Codex checked parser result", -) -replace_exact( - path, - ''' resume_command: Some(format!("codex resume {session_id}")), - }) -} - -fn is_subagent_source''', - ''' resume_command: Some(format!("codex resume {session_id}")), - })) -} - -#[cfg(test)] -fn parse_session(path: &Path) -> Option { - parse_session_checked(path).expect("parse Codex test session") -} - -fn is_subagent_source''', - "Codex test compatibility wrapper", -) - - -# --------------------------------------------------------------------------- -# Claude: agent-* histories are explicit policy filters. Other unreadable or -# structurally unusable histories are dirty files and remain observable. -# --------------------------------------------------------------------------- -path = "src-tauri/src/session_manager/providers/claude.rs" -replace_exact( - path, - ''' for path in files { - if let Some(meta) = parse_session(&path) { - sessions.push(meta); - } - } -''', - ''' for path in files { - match parse_session_checked(&path) { - Ok(Some(meta)) => sessions.push(meta), - Ok(None) => {} - Err(err) => log::warn!("Skipping unreadable Claude session {}: {err}", path.display()), - } - } -''', - "Claude dirty-session scan policy", -) -replace_exact( - path, - ''' let meta = parse_session(path).ok_or_else(|| { - format!( - "Failed to parse Claude session metadata: {}", - path.display() - ) - })?; -''', - ''' let meta = parse_session_checked(path)? - .ok_or_else(|| format!("Claude agent session is intentionally filtered: {}", path.display()))?; -''', - "Claude delete parser boundary", -) -replace_exact( - path, - "fn parse_session(path: &Path) -> Option {\n", - "fn parse_session_checked(path: &Path) -> Result, String> {\n", - "Claude checked parser signature", -) -replace_exact( - path, - ''' if is_agent_session(path) { - return None; - } - - let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; -''', - ''' if is_agent_session(path) { - return Ok(None); - } - - let (head, tail) = read_head_tail_lines(path, 10, 30) - .map_err(|err| format!("Failed to read Claude session {}: {err}", path.display()))?; -''', - "Claude filter and I/O semantics", -) -replace_exact( - path, - " let session_id = session_id?;\n", - ''' let session_id = session_id.ok_or_else(|| { - format!("Claude session has no usable session id: {}", path.display()) - })?; -''', - "Claude missing-id corruption", -) -replace_exact( - path, - " Some(SessionMeta {\n", - " Ok(Some(SessionMeta {\n", - "Claude checked parser result", -) -replace_exact( - path, - ''' resume_command: Some(format!("claude --resume {session_id}")), - }) -} - -fn is_agent_session''', - ''' resume_command: Some(format!("claude --resume {session_id}")), - })) -} - -#[cfg(test)] -fn parse_session(path: &Path) -> Option { - parse_session_checked(path).expect("parse Claude test session") -} - -fn is_agent_session''', - "Claude test compatibility wrapper", -) - - -# --------------------------------------------------------------------------- -# Gemini: no intentional filter exists in metadata parsing. Any unreadable or -# structurally unusable session is dirty history; warn+skip during listing. -# --------------------------------------------------------------------------- -path = "src-tauri/src/session_manager/providers/gemini.rs" -replace_exact( - path, - ''' if let Some(meta) = parse_session(&path) { - sessions.push(SessionMeta { - project_dir: project_dir.clone(), - ..meta - }); - } -''', - ''' match parse_session_checked(&path) { - Ok(meta) => sessions.push(SessionMeta { - project_dir: project_dir.clone(), - ..meta - }), - Err(err) => log::warn!("Skipping unreadable Gemini session {}: {err}", path.display()), - } -''', - "Gemini dirty-session scan policy", -) -replace_exact( - path, - ''' let meta = parse_session(path).ok_or_else(|| { - format!( - "Failed to parse Gemini session metadata: {}", - path.display() - ) - })?; -''', - " let meta = parse_session_checked(path)?;\n", - "Gemini delete parser boundary", -) -replace_exact( - path, - "fn parse_session(path: &Path) -> Option {\n", - "fn parse_session_checked(path: &Path) -> Result {\n", - "Gemini checked parser signature", -) -replace_exact( - path, - ''' let data = std::fs::read_to_string(path).ok()?; - let value: Value = serde_json::from_str(&data).ok()?; - - let session_id = value.get("sessionId").and_then(Value::as_str)?.to_string(); -''', - ''' let data = std::fs::read_to_string(path) - .map_err(|err| format!("Failed to read Gemini session {}: {err}", path.display()))?; - let value: Value = serde_json::from_str(&data) - .map_err(|err| format!("Failed to parse Gemini session {}: {err}", path.display()))?; - - let session_id = value - .get("sessionId") - .and_then(Value::as_str) - .ok_or_else(|| format!("Gemini session has no sessionId: {}", path.display()))? - .to_string(); -''', - "Gemini parser corruption semantics", -) -replace_exact( - path, - " Some(SessionMeta {\n", - " Ok(SessionMeta {\n", - "Gemini checked parser result", -) -replace_exact( - path, - ''' resume_command: Some(format!("gemini --resume {session_id}")), - }) -} - -#[cfg(test)]''', - ''' resume_command: Some(format!("gemini --resume {session_id}")), - }) -} - -#[cfg(test)] -fn parse_session(path: &Path) -> Option { - parse_session_checked(path).ok() -} - -#[cfg(test)]''', - "Gemini test compatibility wrapper", -) - - -# --------------------------------------------------------------------------- -# OpenClaw: session file I/O is a dirty-file error. JSONL records inside an -# otherwise readable history may be malformed legacy lines and remain skippable. -# --------------------------------------------------------------------------- -path = "src-tauri/src/session_manager/providers/openclaw.rs" -replace_exact( - path, - ''' if let Some(meta) = parse_session(&path, Some(&display_names)) { - sessions.push(meta); - } -''', - ''' match parse_session_checked(&path, Some(&display_names)) { - Ok(meta) => sessions.push(meta), - Err(err) => log::warn!("Skipping unreadable OpenClaw session {}: {err}", path.display()), - } -''', - "OpenClaw dirty-session scan policy", -) -replace_exact( - path, - ''' let meta = parse_session(path, None).ok_or_else(|| { - format!( - "Failed to parse OpenClaw session metadata: {}", - path.display() - ) - })?; -''', - " let meta = parse_session_checked(path, None)?;\n", - "OpenClaw delete parser boundary", -) -replace_exact( - path, - "fn parse_session(\n", - "fn parse_session_checked(\n", - "OpenClaw checked parser name", -) -replace_exact( - path, - ") -> Option {\n let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?;\n", - ''') -> Result { - let (head, tail) = read_head_tail_lines(path, 10, 30) - .map_err(|err| format!("Failed to read OpenClaw session {}: {err}", path.display()))?; -''', - "OpenClaw checked parser signature", -) -replace_exact( - path, - " let session_id = session_id?;\n", - ''' let session_id = session_id.ok_or_else(|| { - format!("OpenClaw session has no usable session id: {}", path.display()) - })?; -''', - "OpenClaw missing-id corruption", -) -replace_exact( - path, - " Some(SessionMeta {\n", - " Ok(SessionMeta {\n", - "OpenClaw checked parser result", -) -# Preserve existing tests that exercise the legacy Option shape without exposing it to production. -replace_exact( - path, - ''' resume_command: Some(format!("openclaw --session {session_id}")), - }) -} - -#[cfg(test)]''', - ''' resume_command: Some(format!("openclaw --session {session_id}")), - }) -} - -#[cfg(test)] -fn parse_session( - path: &Path, - display_names: Option<&HashMap>, -) -> Option { - parse_session_checked(path, display_names).ok() -} - -#[cfg(test)]''', - "OpenClaw test compatibility wrapper", -) - - -# --------------------------------------------------------------------------- -# OpenCode JSON metadata has no intentional ignore state. Existing dirty JSON -# is warned+skipped; structural tree/SQLite failures remain provider errors. -# --------------------------------------------------------------------------- -path = "src-tauri/src/session_manager/providers/opencode.rs" -replace_exact( - path, - ''' for path in json_files { - if let Some(meta) = parse_session(&storage, &path) { - sessions.push(meta); - } - } -''', - ''' for path in json_files { - match parse_session_checked(&storage, &path) { - Ok(meta) => sessions.push(meta), - Err(err) => log::warn!("Skipping unreadable OpenCode session {}: {err}", path.display()), - } - } -''', - "OpenCode dirty-session scan policy", -) -replace_exact( - path, - "fn parse_session(storage: &Path, path: &Path) -> Option {\n", - "fn parse_session_checked(storage: &Path, path: &Path) -> Result {\n", - "OpenCode checked parser signature", -) -replace_exact( - path, - ''' let data = std::fs::read_to_string(path).ok()?; - let value: Value = serde_json::from_str(&data).ok()?; - - let session_id = value.get("id").and_then(Value::as_str)?.to_string(); -''', - ''' let data = std::fs::read_to_string(path) - .map_err(|err| format!("Failed to read OpenCode session {}: {err}", path.display()))?; - let value: Value = serde_json::from_str(&data) - .map_err(|err| format!("Failed to parse OpenCode session {}: {err}", path.display()))?; - - let session_id = value - .get("id") - .and_then(Value::as_str) - .ok_or_else(|| format!("OpenCode session has no id: {}", path.display()))? - .to_string(); -''', - "OpenCode parser corruption semantics", -) -replace_exact( - path, - " Some(SessionMeta {\n", - " Ok(SessionMeta {\n", - "OpenCode checked parser result", -) -replace_exact( - path, - ''' resume_command: Some(format!("opencode session resume {session_id}")), - }) -} - -/// Read the first user message''', - ''' resume_command: Some(format!("opencode session resume {session_id}")), - }) -} - -#[cfg(test)] -fn parse_session(storage: &Path, path: &Path) -> Option { - parse_session_checked(storage, path).ok() -} - -/// Read the first user message''', - "OpenCode test compatibility wrapper", -) - - -# --------------------------------------------------------------------------- -# Message streaming: line-level I/O failure is not a malformed JSON record. -# Propagate I/O errors; malformed historical JSON records remain skippable. -# --------------------------------------------------------------------------- -for provider in ("codex", "claude", "openclaw"): - path = f"src-tauri/src/session_manager/providers/{provider}.rs" - text = read(path) - old = ''' let line = match line { - Ok(value) => value, - Err(_) => continue, - }; -''' - new = f''' let line = line.map_err(|err| {{ - format!("Failed to read {provider} session line from {{}}: {{err}}", path.display()) - }})?; -''' - if text.count(old) != 1: - raise SystemExit(f"{provider} message line I/O count={text.count(old)}") - write(path, text.replace(old, new, 1)) - -print("Applied session dirty-history parse semantics") diff --git a/scripts/apply_session_scan_semantics_once.py b/scripts/apply_session_scan_semantics_once.py deleted file mode 100644 index 8cc749975d1..00000000000 --- a/scripts/apply_session_scan_semantics_once.py +++ /dev/null @@ -1,505 +0,0 @@ -#!/usr/bin/env python3 -from pathlib import Path - - -def read(path: str) -> str: - return Path(path).read_text(encoding="utf-8") - - -def write(path: str, text: str) -> None: - Path(path).write_text(text, encoding="utf-8") - - -def replace_exact(path: str, old: str, new: str, label: str, expected: int = 1) -> None: - text = read(path) - count = text.count(old) - if count != expected: - raise SystemExit(f"{label}: expected {expected}, found {count}") - write(path, text.replace(old, new, expected)) - - -def replace_region(path: str, start: str, end: str, new: str, label: str) -> None: - text = read(path) - if text.count(start) != 1: - raise SystemExit(f"{label}: start count={text.count(start)}") - i = text.index(start) - try: - j = text.index(end, i) - except ValueError: - raise SystemExit(f"{label}: end marker missing") - write(path, text[:i] + new + text[j:]) - - -# --------------------------------------------------------------------------- -# Session-domain contract: -# - missing session storage is a valid empty result; -# - storage that exists but cannot be enumerated is an error; -# - provider worker panic is an error; -# - provider installation/availability is NOT inferred from session storage. -# Individual dirty history files are handled by the parse-policy layer separately. -# --------------------------------------------------------------------------- -path = "src-tauri/src/session_manager/mod.rs" -replace_region( - path, - "pub fn scan_sessions() -> Result, String> {", - "\npub fn load_messages", - '''#[derive(Debug, thiserror::Error)] -pub enum SessionScanError { - #[error("{provider} session storage error at {path}: {detail}")] - Storage { - provider: &'static str, - path: PathBuf, - detail: String, - }, - #[error("{provider} session scan failed: {detail}")] - Provider { - provider: &'static str, - detail: String, - }, - #[error("{provider} session worker panicked")] - WorkerPanic { provider: &'static str }, -} - -impl SessionScanError { - pub(crate) fn storage( - provider: &'static str, - path: impl Into, - err: impl std::fmt::Display, - ) -> Self { - Self::Storage { - provider, - path: path.into(), - detail: err.to_string(), - } - } - - fn provider(provider: &'static str, detail: impl Into) -> Self { - Self::Provider { - provider, - detail: detail.into(), - } - } -} - -pub fn scan_sessions() -> Result, SessionScanError> { - let (r1, r2, r3, r4, r5, r6) = - std::thread::scope(|scope| -> Result<_, SessionScanError> { - let h1 = scope.spawn(codex::scan_sessions); - let h2 = scope.spawn(claude::scan_sessions); - let h3 = scope.spawn(opencode::scan_sessions); - let h4 = scope.spawn(openclaw::scan_sessions); - let h5 = scope.spawn(gemini::scan_sessions); - let h6 = scope.spawn(hermes::scan_sessions); - - let r1 = h1 - .join() - .map_err(|_| SessionScanError::WorkerPanic { provider: "Codex" })??; - let r2 = h2 - .join() - .map_err(|_| SessionScanError::WorkerPanic { provider: "Claude" })??; - let r3 = h3 - .join() - .map_err(|_| SessionScanError::WorkerPanic { provider: "OpenCode" })? - .map_err(|err| SessionScanError::provider("OpenCode", err))?; - let r4 = h4 - .join() - .map_err(|_| SessionScanError::WorkerPanic { provider: "OpenClaw" })??; - let r5 = h5 - .join() - .map_err(|_| SessionScanError::WorkerPanic { provider: "Gemini" })??; - let r6 = h6 - .join() - .map_err(|_| SessionScanError::WorkerPanic { provider: "Hermes" })? - .map_err(|err| SessionScanError::provider("Hermes", err))?; - Ok((r1, r2, r3, r4, r5, r6)) - })?; - - let mut sessions = Vec::new(); - sessions.extend(r1); - sessions.extend(r2); - sessions.extend(r3); - sessions.extend(r4); - sessions.extend(r5); - sessions.extend(r6); - sessions.sort_by(|a, b| { - b.last_active_at - .or(b.created_at) - .unwrap_or(0) - .cmp(&a.last_active_at.or(a.created_at).unwrap_or(0)) - }); - Ok(sessions) -} -''', - "typed session aggregate boundary", -) - -path = "src-tauri/src/commands/session_manager.rs" -replace_exact( - path, - ''' tauri::async_runtime::spawn_blocking(session_manager::scan_sessions) - .await - .map_err(|err| format!("Failed to scan sessions task: {err}"))? -''', - ''' tauri::async_runtime::spawn_blocking(session_manager::scan_sessions) - .await - .map_err(|err| format!("Failed to scan sessions task: {err}"))? - .map_err(|err| err.to_string()) -''', - "session command typed error boundary", -) - - -# --------------------------------------------------------------------------- -# Codex: both active and archived roots are optional, but an existing root that -# cannot be enumerated is not equivalent to "no sessions". -# --------------------------------------------------------------------------- -path = "src-tauri/src/session_manager/providers/codex.rs" -replace_exact( - path, - "use crate::session_manager::{SessionMessage, SessionMeta};\n", - "use crate::session_manager::{SessionMessage, SessionMeta, SessionScanError};\n", - "Codex session error import", -) -replace_region( - path, - "pub fn scan_sessions() -> Vec {", - "\npub fn load_messages", - '''pub fn scan_sessions() -> Result, SessionScanError> { - let roots = session_roots(); - scan_sessions_in_roots(&roots) -} - -pub fn session_roots() -> Vec { - let config_dir = get_codex_config_dir(); - vec![ - config_dir.join("sessions"), - config_dir.join("archived_sessions"), - ] -} - -fn scan_sessions_in_roots(roots: &[PathBuf]) -> Result, SessionScanError> { - let mut files = Vec::new(); - for root in roots { - collect_jsonl_files(root, &mut files)?; - } - - let mut sessions = Vec::new(); - for path in files { - if let Some(meta) = parse_session(&path) { - sessions.push(meta); - } - } - Ok(sessions) -} -''', - "Codex structural scan result", -) -replace_region( - path, - "fn collect_jsonl_files(root: &Path, files: &mut Vec) {", - "\n}\n\n#[cfg(test)]", - '''fn collect_jsonl_files(root: &Path, files: &mut Vec) -> Result<(), SessionScanError> { - if !root.exists() { - return Ok(()); - } - - let entries = std::fs::read_dir(root) - .map_err(|err| SessionScanError::storage("Codex", root, err))?; - for entry in entries { - let entry = entry.map_err(|err| SessionScanError::storage("Codex", root, err))?; - let path = entry.path(); - if path.is_dir() { - collect_jsonl_files(&path, files)?; - } else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") { - files.push(path); - } - } - Ok(()) -''', - "Codex structural traversal", -) -replace_exact( - path, - " let sessions = scan_sessions_in_roots(&[active, archived]);\n", - " let sessions = scan_sessions_in_roots(&[active, archived]).expect(\"scan sessions\");\n", - "Codex scan test result contract", -) - - -# --------------------------------------------------------------------------- -# Claude: projects root may be absent, but read_dir/DirEntry failures are -# structural failures rather than empty history. -# --------------------------------------------------------------------------- -path = "src-tauri/src/session_manager/providers/claude.rs" -replace_exact( - path, - "use crate::session_manager::{SessionMessage, SessionMeta};\n", - "use crate::session_manager::{SessionMessage, SessionMeta, SessionScanError};\n", - "Claude session error import", -) -replace_region( - path, - "pub fn scan_sessions() -> Vec {", - "\npub fn load_messages", - '''pub fn scan_sessions() -> Result, SessionScanError> { - let root = get_claude_config_dir().join("projects"); - let mut files = Vec::new(); - collect_jsonl_files(&root, &mut files)?; - - let mut sessions = Vec::new(); - for path in files { - if let Some(meta) = parse_session(&path) { - sessions.push(meta); - } - } - Ok(sessions) -} -''', - "Claude structural scan result", -) -replace_region( - path, - "fn collect_jsonl_files(root: &Path, files: &mut Vec) {", - "\n}\n\nfn remove_path_if_exists", - '''fn collect_jsonl_files(root: &Path, files: &mut Vec) -> Result<(), SessionScanError> { - if !root.exists() { - return Ok(()); - } - - let entries = std::fs::read_dir(root) - .map_err(|err| SessionScanError::storage("Claude", root, err))?; - for entry in entries { - let entry = entry.map_err(|err| SessionScanError::storage("Claude", root, err))?; - let path = entry.path(); - if path.is_dir() { - collect_jsonl_files(&path, files)?; - } else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") { - files.push(path); - } - } - Ok(()) -} -''', - "Claude structural traversal", -) - - -# --------------------------------------------------------------------------- -# Gemini: tmp root and per-project chats directories are structural storage. -# Optional .project_root metadata can degrade with a warning. -# --------------------------------------------------------------------------- -path = "src-tauri/src/session_manager/providers/gemini.rs" -replace_exact( - path, - "use crate::session_manager::{SessionMessage, SessionMeta};\n", - "use crate::session_manager::{SessionMessage, SessionMeta, SessionScanError};\n", - "Gemini session error import", -) -replace_region( - path, - "pub fn scan_sessions() -> Vec {", - "\npub fn load_messages", - '''pub fn scan_sessions() -> Result, SessionScanError> { - let gemini_dir = crate::gemini_config::get_gemini_dir(); - let tmp_dir = gemini_dir.join("tmp"); - if !tmp_dir.exists() { - return Ok(Vec::new()); - } - - let project_dirs = std::fs::read_dir(&tmp_dir) - .map_err(|err| SessionScanError::storage("Gemini", &tmp_dir, err))?; - let mut sessions = Vec::new(); - for entry in project_dirs { - let entry = entry.map_err(|err| SessionScanError::storage("Gemini", &tmp_dir, err))?; - let chats_dir = entry.path().join("chats"); - if !chats_dir.is_dir() { - continue; - } - - let chat_files = std::fs::read_dir(&chats_dir) - .map_err(|err| SessionScanError::storage("Gemini", &chats_dir, err))?; - let project_root_file = entry.path().join(".project_root"); - let project_dir = match std::fs::read_to_string(&project_root_file) { - Ok(value) => Some(value), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, - Err(err) => { - log::warn!( - "Gemini optional project-root metadata unreadable at {}: {err}", - project_root_file.display() - ); - None - } - }; - - for file_entry in chat_files { - let file_entry = - file_entry.map_err(|err| SessionScanError::storage("Gemini", &chats_dir, err))?; - let path = file_entry.path(); - if path.extension().and_then(|ext| ext.to_str()) != Some("json") { - continue; - } - if let Some(meta) = parse_session(&path) { - sessions.push(SessionMeta { - project_dir: project_dir.clone(), - ..meta - }); - } - } - } - Ok(sessions) -} -''', - "Gemini structural scan result", -) - - -# --------------------------------------------------------------------------- -# OpenClaw: agents/sessions trees are structural; sessions.json display-name -# metadata is optional and degrades observably rather than hiding an I/O error. -# --------------------------------------------------------------------------- -path = "src-tauri/src/session_manager/providers/openclaw.rs" -replace_exact( - path, - " session_manager::{SessionMessage, SessionMeta},\n", - " session_manager::{SessionMessage, SessionMeta, SessionScanError},\n", - "OpenClaw session error import", -) -replace_region( - path, - "pub fn scan_sessions() -> Vec {", - "\npub fn load_messages", - '''pub fn scan_sessions() -> Result, SessionScanError> { - let agents_dir = get_openclaw_dir().join("agents"); - if !agents_dir.exists() { - return Ok(Vec::new()); - } - - let agent_entries = std::fs::read_dir(&agents_dir) - .map_err(|err| SessionScanError::storage("OpenClaw", &agents_dir, err))?; - let mut sessions = Vec::new(); - for agent_entry in agent_entries { - let agent_entry = - agent_entry.map_err(|err| SessionScanError::storage("OpenClaw", &agents_dir, err))?; - let agent_path = agent_entry.path(); - if !agent_path.is_dir() { - continue; - } - - let sessions_dir = agent_path.join("sessions"); - if !sessions_dir.is_dir() { - continue; - } - let session_entries = std::fs::read_dir(&sessions_dir) - .map_err(|err| SessionScanError::storage("OpenClaw", &sessions_dir, err))?; - let display_names = load_display_names(&sessions_dir); - - for entry in session_entries { - let entry = entry - .map_err(|err| SessionScanError::storage("OpenClaw", &sessions_dir, err))?; - let path = entry.path(); - if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { - continue; - } - if let Some(meta) = parse_session(&path, Some(&display_names)) { - sessions.push(meta); - } - } - } - Ok(sessions) -} -''', - "OpenClaw structural scan result", -) -replace_region( - path, - "fn load_display_names(sessions_dir: &Path) -> HashMap {", - "\n}\n\nfn parse_session(", - '''fn load_display_names(sessions_dir: &Path) -> HashMap { - let index_path = sessions_dir.join("sessions.json"); - let content = match std::fs::read_to_string(&index_path) { - Ok(content) => content, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return HashMap::new(), - Err(err) => { - log::warn!( - "OpenClaw optional session index unreadable at {}: {err}", - index_path.display() - ); - return HashMap::new(); - } - }; - let index: serde_json::Map = match serde_json::from_str(&content) { - Ok(index) => index, - Err(err) => { - log::warn!( - "OpenClaw optional session index malformed at {}: {err}", - index_path.display() - ); - return HashMap::new(); - } - }; - - let mut map = HashMap::new(); - for entry in index.values() { - if let (Some(id), Some(name)) = ( - entry.get("sessionId").and_then(Value::as_str), - entry.get("displayName").and_then(Value::as_str), - ) { - if !name.is_empty() { - map.insert(id.to_string(), name.to_string()); - } - } - } - map -} -''', - "OpenClaw optional index observability", -) - - -# --------------------------------------------------------------------------- -# OpenCode JSON session tree: follow-up already makes the public scan Result; -# make structural enumeration failures observable without changing permissive -# helpers used by message rendering/deletion yet. -# --------------------------------------------------------------------------- -path = "src-tauri/src/session_manager/providers/opencode.rs" -replace_region( - path, - "fn scan_sessions_json() -> Result, String> {", - "\n/// Parse a SQLite source reference", - '''fn scan_sessions_json() -> Result, String> { - let storage = get_opencode_data_dir()?; - let session_dir = storage.join("session"); - if !session_dir.exists() { - return Ok(Vec::new()); - } - let mut json_files = Vec::new(); - collect_json_files_strict(&session_dir, &mut json_files)?; - let mut sessions = Vec::new(); - for path in json_files { - if let Some(meta) = parse_session(&storage, &path) { - sessions.push(meta); - } - } - Ok(sessions) -} - -fn collect_json_files_strict(root: &Path, files: &mut Vec) -> Result<(), String> { - let entries = std::fs::read_dir(root) - .map_err(|err| format!("Failed to enumerate OpenCode session storage {}: {err}", root.display()))?; - for entry in entries { - let entry = entry - .map_err(|err| format!("Failed to enumerate OpenCode session entry in {}: {err}", root.display()))?; - let path = entry.path(); - if path.is_dir() { - collect_json_files_strict(&path, files)?; - } else if path.extension().and_then(|ext| ext.to_str()) == Some("json") { - files.push(path); - } - } - Ok(()) -} -''', - "OpenCode structural JSON scan", -) - -print("Applied session structural failure semantics") diff --git a/scripts/check_rust_failure_boundaries.py b/scripts/check_rust_failure_boundaries.py index bda3602702b..005f197ab0e 100644 --- a/scripts/check_rust_failure_boundaries.py +++ b/scripts/check_rust_failure_boundaries.py @@ -27,6 +27,19 @@ ("config.rs", re.compile(r"return Ok\(PathBuf::from\(home\)\);"), "explicit home overrides must be validated as absolute before use"), ("services/model_fetch.rs", re.compile(r'Err\(e\)\s*=>\s*\{\s*return Err\(format!\("Request failed:', re.S), "model discovery transport failures must advance to later compatibility candidates"), ("services/model_fetch.rs", re.compile(r'\.json\(\)\s*\.await\s*\.map_err\(\|e\| format!\("Failed to parse response:', re.S), "invalid successful model payloads must not abort compatibility candidate discovery"), + ("services/model_fetch.rs", re.compile(r'HTTP \{status\}: \{body\}'), "model-discovery errors must not expose raw upstream response bodies"), + ("app_store.rs", re.compile(r"fn read_override_from_store\([^)]*\) -> Option"), "Store read failure must not collapse into an absent app_config_dir override"), + ("app_store.rs", re.compile(r"fn resolve_path\(raw: &str\) -> PathBuf"), "app_config_dir parsing must be fallible and reject relative persistence roots"), + ("settings.rs", re.compile(r"fn settings_path\(\) -> Option"), "settings path resolution must expose HOME failures instead of a fake Option contract"), + ("services/skill.rs", re.compile(r"\bget_app_config_dir\(\)|crate::config::get_home_dir\(\)"), "Skill Result APIs must propagate fallible persistence roots instead of panicking"), + ("commands/misc.rs", re.compile(r"let home = crate::config::get_home_dir\(\);"), "CLI discovery must degrade without HOME instead of panicking"), + ("session_manager/mod.rs", re.compile(r"join\(\)\.unwrap_or_default\(\)"), "session worker panics must be observable, not converted to empty results"), + ("session_manager/providers/opencode.rs", re.compile(r"crate::config::get_home_dir\(\)"), "OpenCode session path resolution must be fallible"), + ("hermes_config.rs", re.compile(r"return PathBuf::from\(trimmed\)"), "HERMES_HOME must not create a process-relative configuration root"), + ("hermes_config.rs", re.compile(r"pub fn read_hermes_config\(\) -> Result \{\s*let path = get_hermes_config_path\(\);"), "Hermes config reads must propagate root resolution errors"), + ("hermes_config.rs", re.compile(r"fn write_yaml_section_to_config_locked\([\s\S]{0,220}\) -> Result \{\s*let config_path = get_hermes_config_path\(\);"), "Hermes config writes must propagate root resolution errors"), + ("hermes_config.rs", re.compile(r"let backup_dir = get_app_config_dir\(\)"), "Hermes backup persistence must not hide app-root failures"), + ("session_manager/providers/hermes.rs", re.compile(r"use crate::hermes_config::get_hermes_dir"), "Hermes session discovery must use the fallible root API"), ] failures = [] @@ -42,6 +55,11 @@ if pattern.search(path.read_text(encoding="utf-8")): failures.append(f"{path.relative_to(ROOT)}: {message}") +# Persistence compatibility must not reintroduce CWD-relative roots inside config.rs itself. +config_text = (RUST_ROOT / "config.rs").read_text(encoding="utf-8") +if 'let legacy_dir = PathBuf::from(trimmed).join(".cc-switch")' in config_text: + failures.append("src-tauri/src/config.rs: Windows legacy HOME must be validated before DB fallback") + # User-home resolution is a persistence boundary shared by DB/config/backup/CLI paths. A direct # dirs::home_dir() call elsewhere can silently re-introduce CWD/relative fallback semantics or # diverge from the validated CC_SWITCH_TEST_HOME behavior, so keep one common implementation. @@ -54,6 +72,17 @@ f"{path.relative_to(ROOT)}: direct home resolution must use the config common boundary" ) +# Hermes persistence/session roots are fallible production boundaries. The only infallible +# wrappers are #[cfg(test)] helpers owned by hermes_config.rs; no other module may call/import them. +for path in RUST_ROOT.rglob("*.rs"): + if path.name == "hermes_config.rs": + continue + text = path.read_text(encoding="utf-8") + if re.search(r"(?:crate::)?hermes_config::get_hermes_(?:dir|config_path)\b", text): + failures.append( + f"{path.relative_to(ROOT)}: Hermes roots must use fallible try_get_hermes_* APIs" + ) + # URL sanitization is a common diagnostics boundary. Specialized copies drift and caused raw # deep-link/model-fetch paths to be missed; keep implementations centralized. for path in RUST_ROOT.rglob("*.rs"): diff --git a/src-tauri/src/app_store.rs b/src-tauri/src/app_store.rs index 7cd500a9125..c869274d8d4 100644 --- a/src-tauri/src/app_store.rs +++ b/src-tauri/src/app_store.rs @@ -15,22 +15,29 @@ const STORE_KEY_APP_CONFIG_DIR_LEGACY_MIGRATED: &str = "app_config_dir_legacy_mi const LEGACY_APP_CONFIG_DIR_KEYS: &[&str] = &["appConfigDir", "app_config_dir", "app_config_dir_override"]; -/// 缓存当前的 app_config_dir 覆盖路径,避免存储 AppHandle -static APP_CONFIG_DIR_OVERRIDE: OnceLock>> = OnceLock::new(); +/// Cached Store outcome. `Ok(None)` is genuine absence; `Err` means the last +/// refresh failed and must remain observable to persistence-root callers. +static APP_CONFIG_DIR_OVERRIDE: OnceLock, String>>> = OnceLock::new(); -fn override_cache() -> &'static RwLock> { - APP_CONFIG_DIR_OVERRIDE.get_or_init(|| RwLock::new(None)) +fn override_cache() -> &'static RwLock, String>> { + APP_CONFIG_DIR_OVERRIDE.get_or_init(|| RwLock::new(Ok(None))) } -fn update_cached_override(value: Option) { - if let Ok(mut guard) = override_cache().write() { - *guard = value; +fn update_cached_override(value: Result, String>) { + match override_cache().write() { + Ok(mut guard) => *guard = value, + Err(err) => log::error!("app_config_dir override cache poisoned: {err}"), } } -/// 获取缓存中的 app_config_dir 覆盖路径 -pub fn get_app_config_dir_override() -> Option { - override_cache().read().ok()?.clone() +pub fn try_get_app_config_dir_override() -> Result, AppError> { + let guard = override_cache() + .read() + .map_err(|err| AppError::Lock(err.to_string()))?; + guard + .as_ref() + .map(Clone::clone) + .map_err(|err| AppError::Config(err.clone())) } fn open_paths_store( @@ -41,49 +48,43 @@ fn open_paths_store( .map_err(|e| AppError::Message(format!("创建 Store 失败: {e}"))) } -fn read_override_from_store(app: &tauri::AppHandle) -> Option { - let store = match open_paths_store(app) { - Ok(store) => store, - Err(e) => { - log::warn!("无法创建 Store: {e}"); - return None; - } - }; +fn read_override_from_store(app: &tauri::AppHandle) -> Result, AppError> { + let store = open_paths_store(app)?; match store.get(STORE_KEY_APP_CONFIG_DIR) { Some(Value::String(path_str)) => { let path_str = path_str.trim(); if path_str.is_empty() { - return None; + return Ok(None); } - let path = resolve_path(path_str); - - if !path.exists() { - log::warn!( - "Store 中配置的 app_config_dir 不存在: {path:?}\n\ - 将使用默认路径。" - ); - return None; + let path = resolve_path(path_str)?; + if !path.is_dir() { + return Err(AppError::Config(format!( + "Store 中配置的 app_config_dir 不是现有目录: {}", + path.display() + ))); } log::info!("使用 Store 中的 app_config_dir: {path:?}"); - Some(path) - } - Some(_) => { - log::warn!("Store 中的 {STORE_KEY_APP_CONFIG_DIR} 类型不正确,应为字符串"); - None + Ok(Some(path)) } - None => None, + Some(_) => Err(AppError::Config(format!( + "Store 中的 {STORE_KEY_APP_CONFIG_DIR} 类型不正确,应为字符串" + ))), + None => Ok(None), } } -fn legacy_migration_completed(app: &tauri::AppHandle) -> bool { - open_paths_store(app) - .ok() - .and_then(|store| store.get(STORE_KEY_APP_CONFIG_DIR_LEGACY_MIGRATED)) - .and_then(|value| value.as_bool()) - .unwrap_or(false) +fn legacy_migration_completed(app: &tauri::AppHandle) -> Result { + let store = open_paths_store(app)?; + match store.get(STORE_KEY_APP_CONFIG_DIR_LEGACY_MIGRATED) { + Some(Value::Bool(value)) => Ok(value), + Some(_) => Err(AppError::Config(format!( + "Store 中的 {STORE_KEY_APP_CONFIG_DIR_LEGACY_MIGRATED} 类型不正确,应为布尔值" + ))), + None => Ok(false), + } } /// 从旧版 `~/.cc-switch/settings.json` 读取 app_config_dir。 @@ -91,32 +92,19 @@ fn legacy_migration_completed(app: &tauri::AppHandle) -> bool { /// 这里故意读取原始 JSON,而不是反序列化为当前 AppSettings:当前结构已经删除了 /// 这个字段,直接按新结构读取会静默丢失迁移信息。兼容 snake_case / camelCase 以及 /// 早期实验版的 override 键名。 -fn read_legacy_override_from_settings() -> Option { - let settings_path = crate::config::get_home_dir() +fn read_legacy_override_from_settings() -> Result, AppError> { + let settings_path = crate::config::try_get_home_dir_typed() + .map_err(AppError::from)? .join(".cc-switch") .join("settings.json"); let content = match std::fs::read_to_string(&settings_path) { Ok(content) => content, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None, - Err(err) => { - log::warn!( - "读取旧 settings.json 以迁移 app_config_dir 失败: path={}, error={err}", - settings_path.display() - ); - return None; - } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(AppError::io(&settings_path, err)), }; - let root: Value = match serde_json::from_str(&content) { - Ok(value) => value, - Err(err) => { - log::warn!( - "旧 settings.json 无法解析,跳过 app_config_dir 自动迁移: path={}, error={err}", - settings_path.display() - ); - return None; - } - }; + let root: Value = + serde_json::from_str(&content).map_err(|err| AppError::json(&settings_path, err))?; for key in LEGACY_APP_CONFIG_DIR_KEYS { let Some(raw) = root.get(*key).and_then(Value::as_str) else { @@ -126,18 +114,17 @@ fn read_legacy_override_from_settings() -> Option { if trimmed.is_empty() { continue; } - let resolved = resolve_path(trimmed); - if !resolved.exists() { - log::warn!( - "旧 settings.json 的 {key} 指向不存在的目录,跳过自动迁移: {}", + let resolved = resolve_path(trimmed)?; + if !resolved.is_dir() { + return Err(AppError::Config(format!( + "旧 settings.json 的 {key} 不是现有目录: {}", resolved.display() - ); - return None; + ))); } - return Some(resolved); + return Ok(Some(resolved)); } - None + Ok(None) } fn persist_override_and_migration_marker( @@ -164,43 +151,60 @@ fn persist_override_and_migration_marker( } fn migrate_legacy_override_if_needed(app: &tauri::AppHandle) -> Result, AppError> { - if legacy_migration_completed(app) { + if legacy_migration_completed(app)? { return Ok(None); } - if let Some(existing_path) = read_override_from_store(app) { + if let Some(existing_path) = read_override_from_store(app)? { // 已经存在新格式配置,只补迁移标记,绝不能用尚未初始化的缓存反写 Store。 let path_string = existing_path.to_string_lossy().to_string(); persist_override_and_migration_marker(app, Some(&path_string))?; return Ok(Some(existing_path)); } - let Some(legacy_path) = read_legacy_override_from_settings() else { - return Ok(None); - }; - let path_string = legacy_path.to_string_lossy().to_string(); - persist_override_and_migration_marker(app, Some(&path_string))?; - log::info!( - "已将旧 settings.json 的 app_config_dir 自动迁移到 Store: {}", - legacy_path.display() - ); - Ok(Some(legacy_path)) + match read_legacy_override_from_settings()? { + Some(legacy_path) => { + let path_string = legacy_path.to_string_lossy().to_string(); + persist_override_and_migration_marker(app, Some(&path_string))?; + log::info!( + "已将旧 settings.json 的 app_config_dir 自动迁移到 Store: {}", + legacy_path.display() + ); + Ok(Some(legacy_path)) + } + None => { + // A successful scan with no legacy value is still a completed one-time migration. + // Persist the marker so a stale legacy field cannot unexpectedly resurrect later. + persist_override_and_migration_marker(app, None)?; + Ok(None) + } + } } /// 从 Store 刷新 app_config_dir 覆盖值并更新缓存。 /// /// 启动阶段会顺带执行一次旧 settings.json 兼容迁移;迁移成功后 Store 成为唯一事实源。 -pub fn refresh_app_config_dir_override(app: &tauri::AppHandle) -> Option { - let migrated = match migrate_legacy_override_if_needed(app) { - Ok(value) => value, +pub fn refresh_app_config_dir_override( + app: &tauri::AppHandle, +) -> Result, AppError> { + let result = (|| { + let migrated = migrate_legacy_override_if_needed(app)?; + match migrated { + Some(path) => Ok(Some(path)), + None => read_override_from_store(app), + } + })(); + + match result { + Ok(value) => { + update_cached_override(Ok(value.clone())); + Ok(value) + } Err(err) => { - log::warn!("app_config_dir 旧配置迁移失败,将继续读取 Store: {err}"); - None + update_cached_override(Err(err.to_string())); + Err(err) } - }; - let value = migrated.or_else(|| read_override_from_store(app)); - update_cached_override(value.clone()); - value + } } /// 写入 app_config_dir 到 Tauri Store @@ -208,24 +212,35 @@ pub fn set_app_config_dir_to_store( app: &tauri::AppHandle, path: Option<&str>, ) -> Result<(), AppError> { - let normalized = path.map(str::trim).filter(|value| !value.is_empty()); - persist_override_and_migration_marker(app, normalized)?; + let resolved = match path.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => { + let path = resolve_path(value)?; + if !path.is_dir() { + return Err(AppError::InvalidInput(format!( + "app_config_dir 必须指向现有目录: {}", + path.display() + ))); + } + Some(path) + } + None => None, + }; + let serialized = resolved + .as_ref() + .map(|path| path.to_string_lossy().to_string()); + persist_override_and_migration_marker(app, serialized.as_deref())?; + update_cached_override(Ok(resolved.clone())); - match normalized { - Some(value) => log::info!("已将 app_config_dir 写入 Store: {value}"), + match resolved { + Some(value) => log::info!("已将 app_config_dir 写入 Store: {}", value.display()), None => log::info!("已从 Store 中删除 app_config_dir 配置"), } - - refresh_app_config_dir_override(app); Ok(()) } /// 解析路径,支持 ~ 开头的相对路径 -fn resolve_path(raw: &str) -> PathBuf { - crate::config::expand_home_path(raw).unwrap_or_else(|err| { - log::error!("{err}"); - panic!("{err}"); - }) +fn resolve_path(raw: &str) -> Result { + crate::config::resolve_persistence_path_typed(raw, "app_config_dir").map_err(AppError::from) } /// 从旧的 settings.json 迁移 app_config_dir 到 Store。 @@ -234,8 +249,11 @@ fn resolve_path(raw: &str) -> PathBuf { /// 之前就能得到正确目录。 pub fn migrate_app_config_dir_from_settings(app: &tauri::AppHandle) -> Result<(), AppError> { let migrated = migrate_legacy_override_if_needed(app)?; - let value = migrated.or_else(|| read_override_from_store(app)); - update_cached_override(value); + let value = match migrated { + Some(path) => Some(path), + None => read_override_from_store(app)?, + }; + update_cached_override(Ok(value)); Ok(()) } @@ -250,7 +268,12 @@ mod tests { } else { "/tmp/.cc-switch" }; - assert_eq!(resolve_path(input), PathBuf::from(input)); + assert_eq!(resolve_path(input).unwrap(), PathBuf::from(input)); + } + + #[test] + fn resolve_path_rejects_process_relative_app_root() { + assert!(resolve_path("relative/.cc-switch").is_err()); } #[test] diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index dea8befde6f..529f611c3f3 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -118,11 +118,9 @@ pub async fn get_config_status( Ok(ConfigStatus { exists, path }) } AppType::Hermes => { - let config_path = crate::hermes_config::get_hermes_config_path(); - let exists = config_path.exists(); - let path = crate::hermes_config::get_hermes_dir() - .to_string_lossy() - .to_string(); + let dir = crate::hermes_config::try_get_hermes_dir().map_err(|e| e.to_string())?; + let exists = dir.join("config.yaml").exists(); + let path = dir.to_string_lossy().to_string(); Ok(ConfigStatus { exists, path }) } @@ -145,7 +143,7 @@ pub async fn get_config_dir(app: String) -> Result { AppType::Gemini => crate::gemini_config::get_gemini_dir(), AppType::OpenCode => crate::opencode_config::get_opencode_dir(), AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(), - AppType::Hermes => crate::hermes_config::get_hermes_dir(), + AppType::Hermes => crate::hermes_config::try_get_hermes_dir().map_err(|e| e.to_string())?, }; Ok(dir.to_string_lossy().to_string()) @@ -162,7 +160,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result crate::gemini_config::get_gemini_dir(), AppType::OpenCode => crate::opencode_config::get_opencode_dir(), AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(), - AppType::Hermes => crate::hermes_config::get_hermes_dir(), + AppType::Hermes => crate::hermes_config::try_get_hermes_dir().map_err(|e| e.to_string())?, }; if !config_dir.exists() { diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 701f48a8c2f..3ada9b0b9bf 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -1296,7 +1296,12 @@ fn push_unique_path(paths: &mut Vec, path: std::path::PathBu fn push_env_single_dir(paths: &mut Vec, value: Option) { if let Some(raw) = value { - push_unique_path(paths, std::path::PathBuf::from(raw)); + let path = std::path::PathBuf::from(raw); + if path.is_absolute() { + push_unique_path(paths, path); + } else if !path.as_os_str().is_empty() { + log::warn!("Ignoring relative CLI install root: {}", path.display()); + } } } @@ -1306,10 +1311,16 @@ fn extend_from_path_list( suffix: Option<&str>, ) { if let Some(raw) = value { - for p in std::env::split_paths(&raw) { + for base in std::env::split_paths(&raw) { + if !base.is_absolute() { + if !base.as_os_str().is_empty() { + log::warn!("Ignoring relative CLI path-list root: {}", base.display()); + } + continue; + } let dir = match suffix { - Some(s) => p.join(s), - None => p, + Some(suffix) => base.join(suffix), + None => base, }; push_unique_path(paths, dir); } @@ -1429,7 +1440,7 @@ fn extend_windows_cli_manager_search_paths(paths: &mut Vec, /// 额外扫描 Bun 默认全局安装路径(~/.bun/bin) /// 和 Go 安装路径(~/go/bin、$GOPATH/*/bin)。 fn opencode_extra_search_paths( - home: &Path, + home: Option<&Path>, opencode_install_dir: Option, xdg_bin_dir: Option, gopath: Option, @@ -1439,7 +1450,7 @@ fn opencode_extra_search_paths( push_env_single_dir(&mut paths, opencode_install_dir); push_env_single_dir(&mut paths, xdg_bin_dir); - if !home.as_os_str().is_empty() { + if let Some(home) = home { push_unique_path(&mut paths, home.join("bin")); push_unique_path(&mut paths, home.join(".opencode").join("bin")); push_unique_path(&mut paths, home.join(".bun").join("bin")); @@ -1492,16 +1503,31 @@ fn extend_mise_node_search_paths(paths: &mut Vec, home: &Pat /// 单探兜底 (`scan_cli_version`) 与全量枚举 (`enumerate_tool_installations`) 共用, /// 确保两条路径看到的是同一组安装位置。 fn build_tool_search_paths(tool: &str) -> Vec { - let home = crate::config::get_home_dir(); - - // 常见的安装路径(原生安装优先) + let home = crate::config::try_get_home_dir().ok(); let mut search_paths: Vec = Vec::new(); - if !home.as_os_str().is_empty() { + + if let Some(home) = home.as_ref() { push_unique_path(&mut search_paths, home.join(".local/bin")); push_unique_path(&mut search_paths, home.join(".npm-global/bin")); push_unique_path(&mut search_paths, home.join("n/bin")); push_unique_path(&mut search_paths, home.join(".volta/bin")); - extend_mise_node_search_paths(&mut search_paths, &home); + extend_mise_node_search_paths(&mut search_paths, home); + + for base in [ + home.join(".local/state/fnm_multishells"), + home.join(".nvm/versions/node"), + ] { + if let Ok(entries) = std::fs::read_dir(&base) { + for entry in entries.flatten() { + let bin_path = entry.path().join("bin"); + if bin_path.exists() { + push_unique_path(&mut search_paths, bin_path); + } + } + } + } + } else { + log::warn!("HOME unavailable while discovering CLI tools; skipping home-scoped candidates"); } #[cfg(target_os = "macos")] @@ -1515,8 +1541,8 @@ fn build_tool_search_paths(tool: &str) -> Vec { std::path::PathBuf::from("/usr/local/bin"), ); if tool == "hermes" { - let python_base = home.join("Library").join("Python"); - if python_base.exists() { + if let Some(home) = home.as_ref() { + let python_base = home.join("Library").join("Python"); if let Ok(entries) = std::fs::read_dir(&python_base) { for entry in entries.flatten() { let bin_path = entry.path().join("bin"); @@ -1544,13 +1570,11 @@ fn build_tool_search_paths(tool: &str) -> Vec { push_unique_path(&mut search_paths, appdata.join("npm")); if tool == "hermes" { let python_base = appdata.join("Python"); - if python_base.exists() { - if let Ok(entries) = std::fs::read_dir(&python_base) { - for entry in entries.flatten() { - let scripts_path = entry.path().join("Scripts"); - if scripts_path.exists() { - push_unique_path(&mut search_paths, scripts_path); - } + if let Ok(entries) = std::fs::read_dir(&python_base) { + for entry in entries.flatten() { + let scripts_path = entry.path().join("Scripts"); + if scripts_path.exists() { + push_unique_path(&mut search_paths, scripts_path); } } } @@ -1559,13 +1583,11 @@ fn build_tool_search_paths(tool: &str) -> Vec { if tool == "hermes" { if let Some(local_data) = dirs::data_local_dir() { let programs_python = local_data.join("Programs").join("Python"); - if programs_python.exists() { - if let Ok(entries) = std::fs::read_dir(&programs_python) { - for entry in entries.flatten() { - let scripts_path = entry.path().join("Scripts"); - if scripts_path.exists() { - push_unique_path(&mut search_paths, scripts_path); - } + if let Ok(entries) = std::fs::read_dir(&programs_python) { + for entry in entries.flatten() { + let scripts_path = entry.path().join("Scripts"); + if scripts_path.exists() { + push_unique_path(&mut search_paths, scripts_path); } } } @@ -1573,50 +1595,26 @@ fn build_tool_search_paths(tool: &str) -> Vec { } push_unique_path( &mut search_paths, - std::path::PathBuf::from("C:\\Program Files\\nodejs"), + std::path::PathBuf::from(r"C:\Program Files\nodejs"), ); - extend_windows_cli_manager_search_paths(&mut search_paths, &home); - } - - let fnm_base = home.join(".local/state/fnm_multishells"); - if fnm_base.exists() { - if let Ok(entries) = std::fs::read_dir(&fnm_base) { - for entry in entries.flatten() { - let bin_path = entry.path().join("bin"); - if bin_path.exists() { - push_unique_path(&mut search_paths, bin_path); - } - } - } - } - - let nvm_base = home.join(".nvm/versions/node"); - if nvm_base.exists() { - if let Ok(entries) = std::fs::read_dir(&nvm_base) { - for entry in entries.flatten() { - let bin_path = entry.path().join("bin"); - if bin_path.exists() { - push_unique_path(&mut search_paths, bin_path); - } - } + if let Some(home) = home.as_ref() { + extend_windows_cli_manager_search_paths(&mut search_paths, home); } } if tool == "opencode" { - let extra_paths = opencode_extra_search_paths( - &home, + for path in opencode_extra_search_paths( + home.as_deref(), std::env::var_os("OPENCODE_INSTALL_DIR"), std::env::var_os("XDG_BIN_DIR"), std::env::var_os("GOPATH"), - ); - - for path in extra_paths { + ) { push_unique_path(&mut search_paths, path); } } - let path_env = std::env::var_os("PATH"); - extend_from_cli_path_env(&mut search_paths, path_env); + // PATH intentionally retains shell/OS semantics; explicit manager/install roots above do not. + extend_from_cli_path_env(&mut search_paths, std::env::var_os("PATH")); search_paths } @@ -5053,7 +5051,7 @@ mod tests { let gopath = std::env::join_paths([PathBuf::from("/go/path1"), PathBuf::from("/go/path2")]).ok(); - let paths = opencode_extra_search_paths(&home, install_dir, xdg_bin_dir, gopath); + let paths = opencode_extra_search_paths(Some(&home), install_dir, xdg_bin_dir, gopath); assert_eq!(paths[0], PathBuf::from("/custom/opencode/bin")); assert_eq!(paths[1], PathBuf::from("/xdg/bin")); @@ -5070,7 +5068,7 @@ mod tests { let home = PathBuf::from("/home/tester"); let same_dir = Some(std::ffi::OsString::from("/same/path")); - let paths = opencode_extra_search_paths(&home, same_dir.clone(), same_dir, None); + let paths = opencode_extra_search_paths(Some(&home), same_dir.clone(), same_dir, None); let count = paths .iter() @@ -5082,7 +5080,7 @@ mod tests { #[test] fn opencode_extra_search_paths_deduplicates_bun_default_dir() { let home = PathBuf::from("/home/tester"); - let paths = opencode_extra_search_paths(&home, None, None, None); + let paths = opencode_extra_search_paths(Some(&home), None, None, None); let count = paths .iter() diff --git a/src-tauri/src/commands/session_manager.rs b/src-tauri/src/commands/session_manager.rs index 434cd4265d1..4ab96b2c38d 100644 --- a/src-tauri/src/commands/session_manager.rs +++ b/src-tauri/src/commands/session_manager.rs @@ -4,10 +4,10 @@ use crate::session_manager; #[tauri::command] pub async fn list_sessions() -> Result, String> { - let sessions = tauri::async_runtime::spawn_blocking(session_manager::scan_sessions) + tauri::async_runtime::spawn_blocking(session_manager::scan_sessions) .await - .map_err(|e| format!("Failed to scan sessions: {e}"))?; - Ok(sessions) + .map_err(|err| format!("Failed to scan sessions task: {err}"))? + .map_err(|err| err.to_string()) } #[tauri::command] diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 47c5cd37738..d6b1b20e02e 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -289,8 +289,9 @@ pub async fn check_app_update_available(app: AppHandle) -> Result /// 获取 app_config_dir 覆盖配置 (从 Store) #[tauri::command] pub async fn get_app_config_dir_override(app: AppHandle) -> Result, String> { - Ok(crate::app_store::refresh_app_config_dir_override(&app) - .map(|p| p.to_string_lossy().to_string())) + let value = + crate::app_store::refresh_app_config_dir_override(&app).map_err(|err| err.to_string())?; + Ok(value.map(|path| path.to_string_lossy().to_string())) } /// 设置 app_config_dir 覆盖配置 (到 Store) diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 4398bd2e7bf..8638c169302 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -6,10 +6,20 @@ use std::path::{Component, Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use crate::error::AppError; +use crate::failure_semantics::{require_absolute_root, RootResolutionError}; static ATOMIC_WRITE_COUNTER: AtomicU64 = AtomicU64::new(0); const ATOMIC_TEMP_CREATE_ATTEMPTS: usize = 16; +#[cfg(test)] +fn require_absolute_path(path: PathBuf, label: &str) -> Result { + if path.is_absolute() { + Ok(path) + } else { + Err(format!("{label} 必须是绝对路径,收到: {}", path.display())) + } +} + /// 获取用户主目录,带回退和日志 /// /// ## Windows 注意事项 @@ -23,31 +33,19 @@ const ATOMIC_TEMP_CREATE_ATTEMPTS: usize = 16; /// /// 为了让 Windows CI/本地测试能稳定隔离真实用户数据,可通过 `CC_SWITCH_TEST_HOME` /// 显式覆盖 home dir(仅用于测试/调试场景)。 +#[cfg(test)] fn resolve_home_dir( test_override: Option<&str>, detected: Option, ) -> Result { if let Some(home) = test_override.map(str::trim).filter(|home| !home.is_empty()) { - let path = PathBuf::from(home); - if path.is_absolute() { - return Ok(path); - } - return Err(format!( - "CC_SWITCH_TEST_HOME 必须是绝对路径,收到: {}", - path.display() - )); + return require_absolute_path(PathBuf::from(home), "CC_SWITCH_TEST_HOME"); } - match detected { - Some(path) if path.is_absolute() => Ok(path), - Some(path) => Err(format!( - "操作系统返回了非绝对用户主目录路径: {}", - path.display() - )), - None => { - Err("无法获取用户主目录;拒绝回退到当前工作目录,以避免配置/数据库静默分叉".to_string()) - } - } + let path = detected.ok_or_else(|| { + "无法获取用户主目录;拒绝回退到当前工作目录,以避免配置/数据库静默分叉".to_string() + })?; + require_absolute_path(path, "操作系统返回的用户主目录") } /// 获取用户主目录。 @@ -55,9 +53,25 @@ fn resolve_home_dir( /// 用户主目录是数据库、设置和多个 CLI 配置路径的共同根。无法解析时必须 fail closed: /// 旧行为回退到 `.` 会根据启动方式把同一用户的数据写进任意 CWD,表现为供应商/设置丢失, /// 也可能把凭据写进意外目录。 +pub fn try_get_home_dir_typed() -> Result { + if let Ok(raw) = std::env::var("CC_SWITCH_TEST_HOME") { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + return require_absolute_root(PathBuf::from(trimmed), "CC_SWITCH_TEST_HOME"); + } + } + + let detected = dirs::home_dir().ok_or_else(|| RootResolutionError::Unavailable { + origin: "user home".to_string(), + detail: "operating system did not provide a home directory; CWD fallback is forbidden" + .to_string(), + })?; + require_absolute_root(detected, "operating-system user home") +} + +/// Compatibility adapter. New persistence code should keep RootResolutionError typed. pub fn try_get_home_dir() -> Result { - let test_override = std::env::var("CC_SWITCH_TEST_HOME").ok(); - resolve_home_dir(test_override.as_deref(), dirs::home_dir()) + try_get_home_dir_typed().map_err(|err| err.to_string()) } pub fn get_home_dir() -> PathBuf { @@ -67,19 +81,31 @@ pub fn get_home_dir() -> PathBuf { }) } -/// Expand `~`, `~/...`, and `~\...` through the same validated HOME boundary. -/// Missing or malformed HOME is an error; callers must not preserve a literal relative `~` path. -pub fn expand_home_path(raw: &str) -> Result { - if raw == "~" { - return try_get_home_dir(); +/// Resolve a user-configurable persistence/configuration root. +/// +/// Unlike generic path expansion, this contract never permits process-CWD-relative roots. +/// Callers may accept `~`, but the resolved value must be absolute before it can select a +/// database, backup, settings, or external CLI configuration tree. +pub fn resolve_persistence_path_typed( + raw: &str, + label: &str, +) -> Result { + let trimmed = raw.trim(); + if trimmed == "~" { + return try_get_home_dir_typed(); } - if let Some(stripped) = raw.strip_prefix("~/") { - return Ok(try_get_home_dir()?.join(stripped)); + if let Some(stripped) = trimmed.strip_prefix("~/") { + return Ok(try_get_home_dir_typed()?.join(stripped)); } - if let Some(stripped) = raw.strip_prefix("~\\") { - return Ok(try_get_home_dir()?.join(stripped)); + if let Some(stripped) = trimmed.strip_prefix("~\\") { + return Ok(try_get_home_dir_typed()?.join(stripped)); } - Ok(PathBuf::from(raw)) + require_absolute_root(PathBuf::from(trimmed), label) +} + +/// Compatibility adapter for legacy String-error APIs. +pub fn resolve_persistence_path(raw: &str, label: &str) -> Result { + resolve_persistence_path_typed(raw, label).map_err(|err| err.to_string()) } /// Last-resort crash/exit observability directory when HOME itself is unavailable. @@ -235,17 +261,15 @@ pub fn get_claude_settings_path() -> PathBuf { } /// 获取应用配置目录路径 (~/.cc-switch) -pub fn get_app_config_dir() -> PathBuf { - if let Some(custom) = crate::app_store::get_app_config_dir_override() { - return custom; +pub fn try_get_app_config_dir_app() -> Result { + if let Some(custom) = crate::app_store::try_get_app_config_dir_override()? { + return Ok(require_absolute_root(custom, "app_config_dir override")?); } - let default_dir = get_home_dir().join(".cc-switch"); + let default_dir = try_get_home_dir_typed()?.join(".cc-switch"); - // 兼容 v3.10.3:当用户环境存在 `HOME` 且与真实用户目录不同, - // v3.10.3 可能在 `HOME/.cc-switch/` 下创建/使用了数据库。 - // 这里仅在“默认位置没有数据库”时回退到旧位置,避免再次出现“供应商消失”问题, - // 同时也避免新安装因为 `HOME` 被设置而写入非预期路径。 + // v3.10.3 HOME is only a historical discovery candidate, not an active root selector. + // Invalid legacy candidates are ignored; they must never override a valid OS home root. #[cfg(windows)] { let default_db = default_dir.join("cc-switch.db"); @@ -253,21 +277,42 @@ pub fn get_app_config_dir() -> PathBuf { if let Ok(home_env) = std::env::var("HOME") { let trimmed = home_env.trim(); if !trimmed.is_empty() { - let legacy_dir = PathBuf::from(trimmed).join(".cc-switch"); - if legacy_dir.join("cc-switch.db").exists() { - log::info!( - "Detected v3.10.3 legacy database at {}, using it instead of {}", - legacy_dir.display(), - default_dir.display() + let legacy_home = PathBuf::from(trimmed); + if legacy_home.is_absolute() { + let legacy_dir = legacy_home.join(".cc-switch"); + if legacy_dir.join("cc-switch.db").exists() { + log::info!( + "Detected v3.10.3 legacy database at {}, using it instead of {}", + legacy_dir.display(), + default_dir.display() + ); + return Ok(legacy_dir); + } + } else { + log::warn!( + "Ignoring relative legacy HOME discovery candidate: {}", + legacy_home.display() ); - return legacy_dir; } } } } } - default_dir + Ok(default_dir) +} + +pub fn try_get_app_config_dir() -> Result { + try_get_app_config_dir_app().map_err(|err| err.to_string()) +} + +/// Compatibility wrapper for legacy infallible path APIs. New fallible persistence operations +/// should call `try_get_app_config_dir` so configuration errors remain typed instead of panicking. +pub fn get_app_config_dir() -> PathBuf { + try_get_app_config_dir().unwrap_or_else(|err| { + log::error!("{err}"); + panic!("{err}"); + }) } /// 获取应用配置文件路径 @@ -497,6 +542,18 @@ mod tests { assert!(resolve_home_dir(Some("relative-test-home"), None).is_err()); } + #[test] + fn persistence_roots_reject_process_relative_paths() { + assert!(resolve_persistence_path("relative/profile", "test root").is_err()); + } + + #[test] + fn persistence_roots_accept_absolute_paths() { + let path = std::env::temp_dir().join("cc-switch-persistence-root"); + let raw = path.to_string_lossy().to_string(); + assert_eq!(resolve_persistence_path(&raw, "test root").unwrap(), path); + } + #[test] fn emergency_observability_dir_is_named_temp_fallback() { let path = emergency_observability_dir(); diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index a92713210e1..d90b8a7d1c6 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -39,7 +39,7 @@ pub(crate) use dao::proxy::{ }; pub use dao::FailoverQueueItem; -use crate::config::get_app_config_dir; +use crate::config::try_get_app_config_dir; use crate::error::AppError; use rusqlite::{hooks::Action, Connection}; use serde::Serialize; @@ -94,7 +94,9 @@ impl Database { /// /// 数据库文件位于 `~/.cc-switch/cc-switch.db` pub fn init() -> Result { - let db_path = get_app_config_dir().join("cc-switch.db"); + let db_path = try_get_app_config_dir() + .map_err(AppError::Config)? + .join("cc-switch.db"); let db_exists = db_path.exists(); // 确保父目录存在 diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 04509626ac0..1b530bf623c 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -1,4 +1,6 @@ use std::path::Path; + +use crate::failure_semantics::RootResolutionError; use std::sync::PoisonError; use thiserror::Error; @@ -7,6 +9,8 @@ use thiserror::Error; pub enum AppError { #[error("配置错误: {0}")] Config(String), + #[error(transparent)] + Root(#[from] RootResolutionError), #[error("无效输入: {0}")] InvalidInput(String), #[error("IO 错误: {path}: {source}")] diff --git a/src-tauri/src/failure_semantics.rs b/src-tauri/src/failure_semantics.rs new file mode 100644 index 00000000000..f3db6f868bf --- /dev/null +++ b/src-tauri/src/failure_semantics.rs @@ -0,0 +1,42 @@ +use std::path::PathBuf; +use thiserror::Error; + +/// Failure to determine a persistent/configuration root. +/// +/// `None` is reserved for genuine absence at the API that owns optionality. +/// Invalid explicit values are errors and must not silently select another root. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum RootResolutionError { + #[error("{origin} is unavailable: {detail}")] + Unavailable { origin: String, detail: String }, + #[error("{origin} must be an absolute path, got: {path}")] + Relative { origin: String, path: String }, +} + +pub fn require_absolute_root( + path: PathBuf, + origin: impl Into, +) -> Result { + if path.is_absolute() { + Ok(path) + } else { + Err(RootResolutionError::Relative { + origin: origin.into(), + path: path.display().to_string(), + }) + } +} + +/// Read an environment variable that selects a persistent root. +/// Missing/blank means "not configured"; a non-empty relative value is invalid. +pub fn optional_absolute_env_root(name: &str) -> Result, RootResolutionError> { + let Some(raw) = std::env::var_os(name) else { + return Ok(None); + }; + let value = raw.to_string_lossy(); + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + require_absolute_root(PathBuf::from(trimmed), name).map(Some) +} diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index b906da5a671..f77878d49e2 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -30,8 +30,9 @@ //! args: ["-y", "@modelcontextprotocol/server-filesystem"] //! ``` -use crate::config::{atomic_write, get_app_config_dir}; +use crate::config::atomic_write; use crate::error::AppError; +use crate::failure_semantics::{optional_absolute_env_root, require_absolute_root}; use crate::settings::{effective_backup_retain_count, get_hermes_override_dir}; use chrono::Local; use serde::{Deserialize, Serialize}; @@ -44,48 +45,41 @@ use std::sync::{Mutex, OnceLock}; // Path Functions // ============================================================================ -/// 获取 Hermes 配置目录 +/// Resolve the Hermes configuration root without hiding HOME failure behind a panic. /// -/// 解析顺序对齐 Hermes 自身的 `get_hermes_home()`: -/// 1. CCS 设置 `hermes_config_dir`(显式覆盖) -/// 2. `HERMES_HOME` 环境变量(trim 后非空;按原样,不展开 `~`,与 Hermes `Path(val)` 一致) -/// 3. 平台默认(Windows: `%LOCALAPPDATA%\hermes`,Mac/Linux: `~/.hermes`) -pub fn get_hermes_dir() -> PathBuf { +/// Resolution order matches Hermes, but every accepted root is absolute: +/// 1. validated CC Switch `hermes_config_dir` override; +/// 2. absolute `HERMES_HOME`; +/// 3. platform default rooted in an absolute user home / LOCALAPPDATA. +pub fn try_get_hermes_dir() -> Result { if let Some(override_dir) = get_hermes_override_dir() { - return override_dir; + return Ok(require_absolute_root(override_dir, "hermes_config_dir")?); } - if let Some(raw) = std::env::var_os("HERMES_HOME") { - let value = raw.to_string_lossy(); - let trimmed = value.trim(); - if !trimmed.is_empty() { - return PathBuf::from(trimmed); - } + if let Some(path) = optional_absolute_env_root("HERMES_HOME")? { + return Ok(path); } default_hermes_dir() } -/// 平台默认 Hermes 目录(Windows):对齐 Hermes `_get_platform_default_hermes_home()`—— -/// 读 `LOCALAPPDATA` 环境变量,缺失/空时回退 `~\AppData\Local`,再拼 `hermes`。 #[cfg(target_os = "windows")] -fn default_hermes_dir() -> PathBuf { - windows_local_hermes_dir( - std::env::var_os("LOCALAPPDATA").as_deref(), - &crate::config::get_home_dir(), - ) +fn default_hermes_dir() -> Result { + if let Some(local_app_data) = optional_absolute_env_root("LOCALAPPDATA")? { + return Ok(local_app_data.join("hermes")); + } + Ok(crate::config::try_get_home_dir_typed()? + .join("AppData") + .join("Local") + .join("hermes")) } -/// 平台默认 Hermes 目录(Mac/Linux):`~/.hermes`。 #[cfg(not(target_os = "windows"))] -fn default_hermes_dir() -> PathBuf { - crate::config::get_home_dir().join(".hermes") +fn default_hermes_dir() -> Result { + Ok(crate::config::try_get_home_dir_typed()?.join(".hermes")) } -/// Windows `%LOCALAPPDATA%\hermes` 路径计算(纯函数,便于跨平台单测)。 -/// 对齐 Hermes 的 `os.environ.get("LOCALAPPDATA", "").strip()`:trim 后为空 -/// (缺失/空/纯空白)则回退 `\AppData\Local\hermes`。 -#[cfg(any(target_os = "windows", test))] +#[cfg(test)] fn windows_local_hermes_dir(localappdata: Option<&std::ffi::OsStr>, home: &Path) -> PathBuf { localappdata .map(|value| value.to_string_lossy().trim().to_string()) @@ -95,11 +89,20 @@ fn windows_local_hermes_dir(localappdata: Option<&std::ffi::OsStr>, home: &Path) .join("hermes") } -/// 获取 Hermes 配置文件路径 -/// -/// 返回 `~/.hermes/config.yaml` +pub fn try_get_hermes_config_path() -> Result { + Ok(try_get_hermes_dir()?.join("config.yaml")) +} + +// Tests deliberately control CC_SWITCH_TEST_HOME and may use concise path helpers. +// Production code must use the fallible functions above. +#[cfg(test)] +pub fn get_hermes_dir() -> PathBuf { + try_get_hermes_dir().expect("Hermes test root") +} + +#[cfg(test)] pub fn get_hermes_config_path() -> PathBuf { - get_hermes_dir().join("config.yaml") + try_get_hermes_config_path().expect("Hermes test config path") } fn hermes_write_lock() -> &'static Mutex<()> { @@ -145,7 +148,7 @@ pub struct HermesModelConfig { /// /// 如果文件不存在,返回空 Mapping pub fn read_hermes_config() -> Result { - let path = get_hermes_config_path(); + let path = try_get_hermes_config_path()?; if !path.exists() { return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); } @@ -361,7 +364,10 @@ fn replace_yaml_section( // ============================================================================ fn create_hermes_backup(source: &str) -> Result { - let backup_dir = get_app_config_dir().join("backups").join("hermes"); + let backup_dir = crate::config::try_get_app_config_dir() + .map_err(AppError::Config)? + .join("backups") + .join("hermes"); fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; let base_id = format!("hermes_{}", Local::now().format("%Y%m%d_%H%M%S")); @@ -433,7 +439,7 @@ fn write_yaml_section_to_config_locked( section_key: &str, value: &serde_yaml::Value, ) -> Result { - let config_path = get_hermes_config_path(); + let config_path = try_get_hermes_config_path()?; let raw = if config_path.exists() { fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))? } else { @@ -1035,14 +1041,14 @@ impl MemoryKind { } } -fn memories_dir() -> PathBuf { - get_hermes_dir().join("memories") +fn memories_dir() -> Result { + Ok(try_get_hermes_dir()?.join("memories")) } /// Read a Hermes memory file as a markdown blob. Returns an empty string /// when the file doesn't exist yet (first-run case). pub fn read_memory(kind: MemoryKind) -> Result { - let path = memories_dir().join(kind.filename()); + let path = memories_dir()?.join(kind.filename()); match fs::read_to_string(&path) { Ok(content) => Ok(content), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), @@ -1054,7 +1060,7 @@ pub fn read_memory(kind: MemoryKind) -> Result { /// directories as needed, so `~/.hermes/memories/` is materialized on first /// write without a separate `create_dir_all` call. pub fn write_memory(kind: MemoryKind, content: &str) -> Result<(), AppError> { - let path = memories_dir().join(kind.filename()); + let path = memories_dir()?.join(kind.filename()); atomic_write(&path, content.as_bytes()) } @@ -2329,7 +2335,7 @@ user_profile_enabled: false // Blank HERMES_HOME is ignored (matches Hermes' `.strip()` non-empty check), // so resolution must reach the platform default, never the literal blank path. assert_ne!(dir, PathBuf::from(" ")); - assert_eq!(dir, default_hermes_dir()); + assert_eq!(dir, default_hermes_dir().expect("default Hermes dir")); }); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 542432785a0..595fd11a45e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,6 +14,7 @@ mod database; mod deeplink; mod diagnostics; mod error; +mod failure_semantics; mod gemini_config; mod gemini_mcp; pub mod hermes_config; @@ -288,11 +289,13 @@ pub fn run() { .setup(|app| { let _ = rustls::crypto::ring::default_provider().install_default(); - // 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等) - app_store::refresh_app_config_dir_override(app.handle()); - let app_config_dir = crate::config::get_app_config_dir(); + // 预先刷新 Store 覆盖配置,确保后续路径读取正确(日志/数据库等)。 + // Store/路径损坏不能伪装成“无 override”后切到另一个数据库根。 + app_store::refresh_app_config_dir_override(app.handle())?; + let app_config_dir = crate::config::try_get_app_config_dir() + .map_err(crate::error::AppError::Config)?; panic_hook::init_app_config_dir(app_config_dir.clone()); - app_exit_monitor::init_app_config_dir(app_config_dir); + app_exit_monitor::init_app_config_dir(app_config_dir.clone()); #[cfg(target_os = "windows")] set_windows_app_user_model_id(app.handle()); @@ -356,7 +359,6 @@ pub fn run() { } // 初始化数据库 - let app_config_dir = crate::config::get_app_config_dir(); let db_path = app_config_dir.join("cc-switch.db"); let json_path = app_config_dir.join("config.json"); diff --git a/src-tauri/src/mcp/hermes.rs b/src-tauri/src/mcp/hermes.rs index 612a1097318..8ca7c58678a 100644 --- a/src-tauri/src/mcp/hermes.rs +++ b/src-tauri/src/mcp/hermes.rs @@ -44,8 +44,8 @@ const HERMES_EXTRA_FIELDS: &[&str] = &[ // ============================================================================ /// Check if Hermes MCP sync should proceed -fn should_sync_hermes_mcp() -> bool { - hermes_config::get_hermes_dir().exists() +fn should_sync_hermes_mcp() -> Result { + Ok(hermes_config::try_get_hermes_dir()?.exists()) } // ============================================================================ @@ -178,7 +178,7 @@ pub fn sync_single_server_to_hermes( id: &str, server_spec: &Value, ) -> Result<(), AppError> { - if !should_sync_hermes_mcp() { + if !should_sync_hermes_mcp()? { return Ok(()); } @@ -234,7 +234,7 @@ fn merge_hermes_spec(existing: &Value, new_spec: &Value) -> Value { /// Remove a single MCP server from Hermes live config pub fn remove_server_from_hermes(id: &str) -> Result<(), AppError> { - if !should_sync_hermes_mcp() { + if !should_sync_hermes_mcp()? { return Ok(()); } diff --git a/src-tauri/src/prompt_files.rs b/src-tauri/src/prompt_files.rs index e03aba705a5..cd8bed6e92b 100644 --- a/src-tauri/src/prompt_files.rs +++ b/src-tauri/src/prompt_files.rs @@ -24,7 +24,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Gemini => get_gemini_dir(), AppType::OpenCode => get_opencode_dir(), AppType::OpenClaw => get_openclaw_dir(), - AppType::Hermes => crate::hermes_config::get_hermes_dir(), + AppType::Hermes => crate::hermes_config::try_get_hermes_dir()?, AppType::ClaudeDesktop => unreachable!("handled above"), }; diff --git a/src-tauri/src/services/model_fetch.rs b/src-tauri/src/services/model_fetch.rs index 6aaff38566c..6c61a167bbb 100644 --- a/src-tauri/src/services/model_fetch.rs +++ b/src-tauri/src/services/model_fetch.rs @@ -102,9 +102,6 @@ const ZHIPU_MODEL_OVERVIEW_MD_URL: &str = /// 仅当 provider 的 `api` 前缀能匹配当前成功的 `/models` endpoint 时才使用。 const MODELS_DEV_API_URL: &str = "https://models.dev/api.json"; -/// 404/405 响应体截断长度:避免把几十 KB HTML 404 页整页保留到错误串里。 -const ERROR_BODY_MAX_CHARS: usize = 512; - /// 已知的「Anthropic 协议兼容子路径」后缀;按长度降序,最长前缀优先匹配。 /// baseURL 命中这些后缀时,候选列表会追加「剥离后缀再拼 /v1/models / /models」的版本。 const KNOWN_COMPAT_SUFFIXES: &[&str] = &[ @@ -298,8 +295,18 @@ pub async fn fetch_models(options: FetchModelsRequest<'_>) -> Result { + let rendered = String::from_utf8_lossy(&body); + format!( + "body-shape={}, {}", + crate::diagnostics::text_shape_hint(&rendered), + crate::diagnostics::payload_fingerprint(&body) + ) + } + Err(error) => format!("body-unavailable={}", request_error_kind(&error)), + }; + return Err(format!("HTTP {status}: {body_detail}")); } let details = if candidate_failures.is_empty() { @@ -928,17 +935,6 @@ pub fn build_models_url_candidates( Ok(unique) } -/// 截断响应体到 [`ERROR_BODY_MAX_CHARS`] 字符,避免 HTML 404 页占用错误串。 -fn truncate_body(body: String) -> String { - if body.chars().count() <= ERROR_BODY_MAX_CHARS { - body - } else { - let mut s: String = body.chars().take(ERROR_BODY_MAX_CHARS).collect(); - s.push('…'); - s - } -} - /// 若 baseURL 以任一已知兼容子路径结尾,返回剥离后的剩余部分;否则 `None`。 /// /// 依赖 [`KNOWN_COMPAT_SUFFIXES`] 按长度降序排列,确保最长前缀优先命中 @@ -983,6 +979,20 @@ mod tests { )); } + #[test] + fn fail_fast_http_diagnostics_do_not_require_raw_body() { + let secret = b"{\"token\":\"super-secret\"}"; + let rendered = String::from_utf8_lossy(secret); + let detail = format!( + "body-shape={}, {}", + crate::diagnostics::text_shape_hint(&rendered), + crate::diagnostics::payload_fingerprint(secret) + ); + assert!(detail.contains("body-shape=json-like")); + assert!(detail.contains("bytes=")); + assert!(!detail.contains("super-secret")); + } + #[test] fn candidate_failure_details_are_bounded() { let mut failures = Vec::new(); diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 0e1cbb47755..f8375450932 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -1219,7 +1219,7 @@ pub fn read_live_settings(app_type: AppType) -> Result { Ok(config) } AppType::Hermes => { - let config_path = crate::hermes_config::get_hermes_config_path(); + let config_path = crate::hermes_config::try_get_hermes_config_path()?; if !config_path.exists() { return Err(AppError::localized( "hermes.config.missing", @@ -1672,7 +1672,7 @@ pub fn remove_hermes_provider_from_live(provider_id: &str) -> Result<(), AppErro use crate::hermes_config; // Check if Hermes config directory exists - if !hermes_config::get_hermes_dir().exists() { + if !hermes_config::try_get_hermes_dir()?.exists() { log::debug!("Hermes config directory doesn't exist, skipping removal of '{provider_id}'"); return Ok(()); } diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index db60dd9118b..3f64ec893d8 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -16,7 +16,6 @@ use std::time::{SystemTime, UNIX_EPOCH}; use tokio::time::timeout; use crate::app_config::{AppType, InstalledSkill, SkillApps, UnmanagedSkill}; -use crate::config::get_app_config_dir; use crate::database::Database; use crate::error::format_skill_error; @@ -481,9 +480,11 @@ impl SkillService { pub fn get_ssot_dir() -> Result { let location = crate::settings::get_skill_storage_location(); let dir = match location { - SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"), + SkillStorageLocation::CcSwitch => crate::config::try_get_app_config_dir() + .map_err(|err| anyhow!(err))? + .join("skills"), SkillStorageLocation::Unified => { - let home = crate::config::get_home_dir(); + let home = crate::config::try_get_home_dir().map_err(|err| anyhow!(err))?; home.join(".agents").join("skills") } }; @@ -493,7 +494,9 @@ impl SkillService { /// 获取 Skill 卸载备份目录(~/.cc-switch/skill-backups/) fn get_backup_dir() -> Result { - let dir = get_app_config_dir().join("skill-backups"); + let dir = crate::config::try_get_app_config_dir() + .map_err(|err| anyhow!(err))? + .join("skill-backups"); fs::create_dir_all(&dir)?; Ok(dir) } @@ -536,7 +539,7 @@ impl SkillService { } // 默认路径:回退到用户主目录下的标准位置 - let home = crate::config::get_home_dir(); + let home = crate::config::try_get_home_dir().map_err(|err| anyhow!(err))?; Ok(match app { AppType::Claude => home.join(".claude").join("skills"), @@ -545,7 +548,9 @@ impl SkillService { AppType::Gemini => home.join(".gemini").join("skills"), AppType::OpenCode => home.join(".config").join("opencode").join("skills"), AppType::OpenClaw => home.join(".openclaw").join("skills"), - AppType::Hermes => crate::hermes_config::get_hermes_dir().join("skills"), + AppType::Hermes => crate::hermes_config::try_get_hermes_dir() + .map_err(|err| anyhow!(err))? + .join("skills"), }) } @@ -1158,7 +1163,9 @@ impl SkillService { // 1. 解析旧目录和新目录(不改设置) let old_dir = Self::get_ssot_dir()?; let new_dir = match target { - SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"), + SkillStorageLocation::CcSwitch => crate::config::try_get_app_config_dir() + .map_err(|err| anyhow!(err))? + .join("skills"), SkillStorageLocation::Unified => { let home = crate::config::try_get_home_dir().map_err(|err| anyhow!(err))?; home.join(".agents").join("skills") diff --git a/src-tauri/src/session_manager/mod.rs b/src-tauri/src/session_manager/mod.rs index c7ca1f714df..b4fed087ccb 100644 --- a/src-tauri/src/session_manager/mod.rs +++ b/src-tauri/src/session_manager/mod.rs @@ -55,23 +55,77 @@ pub struct DeleteSessionOutcome { pub error: Option, } -pub fn scan_sessions() -> Vec { - let (r1, r2, r3, r4, r5, r6) = std::thread::scope(|s| { - let h1 = s.spawn(codex::scan_sessions); - let h2 = s.spawn(claude::scan_sessions); - let h3 = s.spawn(opencode::scan_sessions); - let h4 = s.spawn(openclaw::scan_sessions); - let h5 = s.spawn(gemini::scan_sessions); - let h6 = s.spawn(hermes::scan_sessions); - ( - h1.join().unwrap_or_default(), - h2.join().unwrap_or_default(), - h3.join().unwrap_or_default(), - h4.join().unwrap_or_default(), - h5.join().unwrap_or_default(), - h6.join().unwrap_or_default(), - ) - }); +#[derive(Debug, thiserror::Error)] +pub enum SessionScanError { + #[error("{provider} session storage error at {path}: {detail}")] + Storage { + provider: &'static str, + path: PathBuf, + detail: String, + }, + #[error("{provider} session scan failed: {detail}")] + Provider { + provider: &'static str, + detail: String, + }, + #[error("{provider} session worker panicked")] + WorkerPanic { provider: &'static str }, +} + +impl SessionScanError { + pub(crate) fn storage( + provider: &'static str, + path: impl Into, + err: impl std::fmt::Display, + ) -> Self { + Self::Storage { + provider, + path: path.into(), + detail: err.to_string(), + } + } + + fn provider(provider: &'static str, detail: impl Into) -> Self { + Self::Provider { + provider, + detail: detail.into(), + } + } +} + +pub fn scan_sessions() -> Result, SessionScanError> { + let (r1, r2, r3, r4, r5, r6) = std::thread::scope(|scope| -> Result<_, SessionScanError> { + let h1 = scope.spawn(codex::scan_sessions); + let h2 = scope.spawn(claude::scan_sessions); + let h3 = scope.spawn(opencode::scan_sessions); + let h4 = scope.spawn(openclaw::scan_sessions); + let h5 = scope.spawn(gemini::scan_sessions); + let h6 = scope.spawn(hermes::scan_sessions); + + let r1 = h1 + .join() + .map_err(|_| SessionScanError::WorkerPanic { provider: "Codex" })??; + let r2 = h2 + .join() + .map_err(|_| SessionScanError::WorkerPanic { provider: "Claude" })??; + let r3 = h3 + .join() + .map_err(|_| SessionScanError::WorkerPanic { + provider: "OpenCode", + })? + .map_err(|err| SessionScanError::provider("OpenCode", err))?; + let r4 = h4.join().map_err(|_| SessionScanError::WorkerPanic { + provider: "OpenClaw", + })??; + let r5 = h5 + .join() + .map_err(|_| SessionScanError::WorkerPanic { provider: "Gemini" })??; + let r6 = h6 + .join() + .map_err(|_| SessionScanError::WorkerPanic { provider: "Hermes" })? + .map_err(|err| SessionScanError::provider("Hermes", err))?; + Ok((r1, r2, r3, r4, r5, r6)) + })?; let mut sessions = Vec::new(); sessions.extend(r1); @@ -80,14 +134,13 @@ pub fn scan_sessions() -> Vec { sessions.extend(r4); sessions.extend(r5); sessions.extend(r6); - sessions.sort_by(|a, b| { - let a_ts = a.last_active_at.or(a.created_at).unwrap_or(0); - let b_ts = b.last_active_at.or(b.created_at).unwrap_or(0); - b_ts.cmp(&a_ts) + b.last_active_at + .or(b.created_at) + .unwrap_or(0) + .cmp(&a.last_active_at.or(a.created_at).unwrap_or(0)) }); - - sessions + Ok(sessions) } pub fn load_messages(provider_id: &str, source_path: &str) -> Result, String> { @@ -191,10 +244,12 @@ fn provider_roots(provider_id: &str) -> Result, String> { let roots = match provider_id { "codex" => codex::session_roots(), "claude" => vec![crate::config::get_claude_config_dir().join("projects")], - "opencode" => vec![opencode::get_opencode_data_dir()], + "opencode" => vec![opencode::get_opencode_data_dir()?], "openclaw" => vec![crate::openclaw_config::get_openclaw_dir().join("agents")], "gemini" => vec![crate::gemini_config::get_gemini_dir().join("tmp")], - "hermes" => vec![crate::hermes_config::get_hermes_dir().join("sessions")], + "hermes" => vec![crate::hermes_config::try_get_hermes_dir() + .map_err(|err| err.to_string())? + .join("sessions")], _ => return Err(format!("Unsupported provider: {provider_id}")), }; diff --git a/src-tauri/src/session_manager/providers/claude.rs b/src-tauri/src/session_manager/providers/claude.rs index d02eaba2207..19c14abd63d 100644 --- a/src-tauri/src/session_manager/providers/claude.rs +++ b/src-tauri/src/session_manager/providers/claude.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use serde_json::Value; use crate::config::get_claude_config_dir; -use crate::session_manager::{SessionMessage, SessionMeta}; +use crate::session_manager::{SessionMessage, SessionMeta, SessionScanError}; use super::utils::{ extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary, @@ -14,19 +14,23 @@ use super::utils::{ const PROVIDER_ID: &str = "claude"; -pub fn scan_sessions() -> Vec { +pub fn scan_sessions() -> Result, SessionScanError> { let root = get_claude_config_dir().join("projects"); let mut files = Vec::new(); - collect_jsonl_files(&root, &mut files); + collect_jsonl_files(&root, &mut files)?; let mut sessions = Vec::new(); for path in files { - if let Some(meta) = parse_session(&path) { - sessions.push(meta); + match parse_session_checked(&path) { + Ok(Some(meta)) => sessions.push(meta), + Ok(None) => {} + Err(err) => log::warn!( + "Skipping unreadable Claude session {}: {err}", + path.display() + ), } } - - sessions + Ok(sessions) } pub fn load_messages(path: &Path) -> Result, String> { @@ -35,10 +39,12 @@ pub fn load_messages(path: &Path) -> Result, String> { let mut messages = Vec::new(); for line in reader.lines() { - let line = match line { - Ok(value) => value, - Err(_) => continue, - }; + let line = line.map_err(|err| { + format!( + "Failed to read claude session line from {}: {err}", + path.display() + ) + })?; let value: Value = match serde_json::from_str(&line) { Ok(parsed) => parsed, Err(_) => continue, @@ -86,9 +92,9 @@ pub fn load_messages(path: &Path) -> Result, String> { } pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result { - let meta = parse_session(path).ok_or_else(|| { + let meta = parse_session_checked(path)?.ok_or_else(|| { format!( - "Failed to parse Claude session metadata: {}", + "Claude agent session is intentionally filtered: {}", path.display() ) })?; @@ -120,12 +126,13 @@ pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result Option { +fn parse_session_checked(path: &Path) -> Result, String> { if is_agent_session(path) { - return None; + return Ok(None); } - let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; + let (head, tail) = read_head_tail_lines(path, 10, 30) + .map_err(|err| format!("Failed to read Claude session {}: {err}", path.display()))?; let mut session_id: Option = None; let mut project_dir: Option = None; @@ -224,7 +231,12 @@ fn parse_session(path: &Path) -> Option { } let session_id = session_id.or_else(|| infer_session_id_from_filename(path)); - let session_id = session_id?; + let session_id = session_id.ok_or_else(|| { + format!( + "Claude session has no usable session id: {}", + path.display() + ) + })?; // Title priority: custom-title > first user message > directory basename let title = custom_title @@ -239,7 +251,7 @@ fn parse_session(path: &Path) -> Option { let summary = summary.map(|text| truncate_summary(&text, 160)); - Some(SessionMeta { + Ok(Some(SessionMeta { provider_id: PROVIDER_ID.to_string(), session_id: session_id.clone(), title, @@ -249,7 +261,12 @@ fn parse_session(path: &Path) -> Option { last_active_at, source_path: Some(path.to_string_lossy().to_string()), resume_command: Some(format!("claude --resume {session_id}")), - }) + })) +} + +#[cfg(test)] +fn parse_session(path: &Path) -> Option { + parse_session_checked(path).expect("parse Claude test session") } fn is_agent_session(path: &Path) -> bool { @@ -265,24 +282,23 @@ fn infer_session_id_from_filename(path: &Path) -> Option { .map(|stem| stem.to_string()) } -fn collect_jsonl_files(root: &Path, files: &mut Vec) { +fn collect_jsonl_files(root: &Path, files: &mut Vec) -> Result<(), SessionScanError> { if !root.exists() { - return; + return Ok(()); } - let entries = match std::fs::read_dir(root) { - Ok(entries) => entries, - Err(_) => return, - }; - - for entry in entries.flatten() { + let entries = + std::fs::read_dir(root).map_err(|err| SessionScanError::storage("Claude", root, err))?; + for entry in entries { + let entry = entry.map_err(|err| SessionScanError::storage("Claude", root, err))?; let path = entry.path(); if path.is_dir() { - collect_jsonl_files(&path, files); + collect_jsonl_files(&path, files)?; } else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") { files.push(path); } } + Ok(()) } fn remove_path_if_exists(path: &Path) -> std::io::Result<()> { diff --git a/src-tauri/src/session_manager/providers/codex.rs b/src-tauri/src/session_manager/providers/codex.rs index 616a30458b4..be5b6ea2ac1 100644 --- a/src-tauri/src/session_manager/providers/codex.rs +++ b/src-tauri/src/session_manager/providers/codex.rs @@ -7,7 +7,7 @@ use regex::Regex; use serde_json::Value; use crate::codex_config::get_codex_config_dir; -use crate::session_manager::{SessionMessage, SessionMeta}; +use crate::session_manager::{SessionMessage, SessionMeta, SessionScanError}; use super::utils::{ extract_text, parse_timestamp_to_ms, path_basename, read_head_tail_lines, truncate_summary, @@ -23,7 +23,7 @@ static UUID_RE: LazyLock = LazyLock::new(|| { .unwrap() }); -pub fn scan_sessions() -> Vec { +pub fn scan_sessions() -> Result, SessionScanError> { let roots = session_roots(); scan_sessions_in_roots(&roots) } @@ -36,20 +36,24 @@ pub fn session_roots() -> Vec { ] } -fn scan_sessions_in_roots(roots: &[PathBuf]) -> Vec { +fn scan_sessions_in_roots(roots: &[PathBuf]) -> Result, SessionScanError> { let mut files = Vec::new(); for root in roots { - collect_jsonl_files(root, &mut files); + collect_jsonl_files(root, &mut files)?; } let mut sessions = Vec::new(); for path in files { - if let Some(meta) = parse_session(&path) { - sessions.push(meta); + match parse_session_checked(&path) { + Ok(Some(meta)) => sessions.push(meta), + Ok(None) => {} + Err(err) => log::warn!( + "Skipping unreadable Codex session {}: {err}", + path.display() + ), } } - - sessions + Ok(sessions) } pub fn load_messages(path: &Path) -> Result, String> { @@ -58,10 +62,12 @@ pub fn load_messages(path: &Path) -> Result, String> { let mut messages = Vec::new(); for line in reader.lines() { - let line = match line { - Ok(value) => value, - Err(_) => continue, - }; + let line = line.map_err(|err| { + format!( + "Failed to read codex session line from {}: {err}", + path.display() + ) + })?; let value: Value = match serde_json::from_str(&line) { Ok(parsed) => parsed, Err(_) => continue, @@ -120,8 +126,12 @@ pub fn load_messages(path: &Path) -> Result, String> { } pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result { - let meta = parse_session(path) - .ok_or_else(|| format!("Failed to parse Codex session metadata: {}", path.display()))?; + let meta = parse_session_checked(path)?.ok_or_else(|| { + format!( + "Codex session is intentionally filtered: {}", + path.display() + ) + })?; if meta.session_id != session_id { return Err(format!( @@ -140,8 +150,9 @@ pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result Option { - let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; +fn parse_session_checked(path: &Path) -> Result, String> { + let (head, tail) = read_head_tail_lines(path, 10, 30) + .map_err(|err| format!("Failed to read Codex session {}: {err}", path.display()))?; let mut session_id: Option = None; let mut project_dir: Option = None; @@ -160,7 +171,7 @@ fn parse_session(path: &Path) -> Option { if value.get("type").and_then(Value::as_str) == Some("session_meta") { if let Some(payload) = value.get("payload") { if is_subagent_source(payload.get("source")) { - return None; + return Ok(None); } if session_id.is_none() { session_id = payload @@ -231,7 +242,8 @@ fn parse_session(path: &Path) -> Option { } let session_id = session_id.or_else(|| infer_session_id_from_filename(path)); - let session_id = session_id?; + let session_id = session_id + .ok_or_else(|| format!("Codex session has no usable session id: {}", path.display()))?; let title = first_user_message .map(|t| truncate_summary(&t, TITLE_MAX_CHARS)) @@ -244,7 +256,7 @@ fn parse_session(path: &Path) -> Option { let summary = summary.map(|text| truncate_summary(&text, 160)); - Some(SessionMeta { + Ok(Some(SessionMeta { provider_id: PROVIDER_ID.to_string(), session_id: session_id.clone(), title, @@ -254,7 +266,12 @@ fn parse_session(path: &Path) -> Option { last_active_at, source_path: Some(path.to_string_lossy().to_string()), resume_command: Some(format!("codex resume {session_id}")), - }) + })) +} + +#[cfg(test)] +fn parse_session(path: &Path) -> Option { + parse_session_checked(path).expect("parse Codex test session") } fn is_subagent_source(source: Option<&Value>) -> bool { @@ -343,24 +360,23 @@ fn infer_session_id_from_filename(path: &Path) -> Option { UUID_RE.find(&file_name).map(|mat| mat.as_str().to_string()) } -fn collect_jsonl_files(root: &Path, files: &mut Vec) { +fn collect_jsonl_files(root: &Path, files: &mut Vec) -> Result<(), SessionScanError> { if !root.exists() { - return; + return Ok(()); } - let entries = match std::fs::read_dir(root) { - Ok(entries) => entries, - Err(_) => return, - }; - - for entry in entries.flatten() { + let entries = + std::fs::read_dir(root).map_err(|err| SessionScanError::storage("Codex", root, err))?; + for entry in entries { + let entry = entry.map_err(|err| SessionScanError::storage("Codex", root, err))?; let path = entry.path(); if path.is_dir() { - collect_jsonl_files(&path, files); + collect_jsonl_files(&path, files)?; } else if path.extension().and_then(|ext| ext.to_str()) == Some("jsonl") { files.push(path); } } + Ok(()) } #[cfg(test)] @@ -394,7 +410,7 @@ mod tests { "Archived session", ); - let sessions = scan_sessions_in_roots(&[active, archived]); + let sessions = scan_sessions_in_roots(&[active, archived]).expect("scan sessions"); let ids = sessions .into_iter() .map(|session| session.session_id) diff --git a/src-tauri/src/session_manager/providers/gemini.rs b/src-tauri/src/session_manager/providers/gemini.rs index 82cb6fcd825..e18c2e746aa 100644 --- a/src-tauri/src/session_manager/providers/gemini.rs +++ b/src-tauri/src/session_manager/providers/gemini.rs @@ -2,56 +2,64 @@ use std::path::Path; use serde_json::Value; -use crate::session_manager::{SessionMessage, SessionMeta}; +use crate::session_manager::{SessionMessage, SessionMeta, SessionScanError}; use super::utils::{parse_timestamp_to_ms, truncate_summary}; const PROVIDER_ID: &str = "gemini"; -pub fn scan_sessions() -> Vec { +pub fn scan_sessions() -> Result, SessionScanError> { let gemini_dir = crate::gemini_config::get_gemini_dir(); let tmp_dir = gemini_dir.join("tmp"); if !tmp_dir.exists() { - return Vec::new(); + return Ok(Vec::new()); } + let project_dirs = std::fs::read_dir(&tmp_dir) + .map_err(|err| SessionScanError::storage("Gemini", &tmp_dir, err))?; let mut sessions = Vec::new(); - - // Iterate over project directories: tmp//chats/session-*.json - let project_dirs = match std::fs::read_dir(&tmp_dir) { - Ok(entries) => entries, - Err(_) => return Vec::new(), - }; - - for entry in project_dirs.flatten() { + for entry in project_dirs { + let entry = entry.map_err(|err| SessionScanError::storage("Gemini", &tmp_dir, err))?; let chats_dir = entry.path().join("chats"); if !chats_dir.is_dir() { continue; } - let chat_files = match std::fs::read_dir(&chats_dir) { - Ok(entries) => entries, - Err(_) => continue, - }; - + let chat_files = std::fs::read_dir(&chats_dir) + .map_err(|err| SessionScanError::storage("Gemini", &chats_dir, err))?; let project_root_file = entry.path().join(".project_root"); - let project_dir = std::fs::read_to_string(project_root_file).ok(); + let project_dir = match std::fs::read_to_string(&project_root_file) { + Ok(value) => Some(value), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, + Err(err) => { + log::warn!( + "Gemini optional project-root metadata unreadable at {}: {err}", + project_root_file.display() + ); + None + } + }; - for file_entry in chat_files.flatten() { + for file_entry in chat_files { + let file_entry = + file_entry.map_err(|err| SessionScanError::storage("Gemini", &chats_dir, err))?; let path = file_entry.path(); - if path.extension().and_then(|e| e.to_str()) != Some("json") { + if path.extension().and_then(|ext| ext.to_str()) != Some("json") { continue; } - if let Some(meta) = parse_session(&path) { - sessions.push(SessionMeta { + match parse_session_checked(&path) { + Ok(meta) => sessions.push(SessionMeta { project_dir: project_dir.clone(), ..meta - }); + }), + Err(err) => log::warn!( + "Skipping unreadable Gemini session {}: {err}", + path.display() + ), } } } - - sessions + Ok(sessions) } pub fn load_messages(path: &Path) -> Result, String> { @@ -113,12 +121,7 @@ pub fn load_messages(path: &Path) -> Result, String> { } pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result { - let meta = parse_session(path).ok_or_else(|| { - format!( - "Failed to parse Gemini session metadata: {}", - path.display() - ) - })?; + let meta = parse_session_checked(path)?; if meta.session_id != session_id { return Err(format!( @@ -137,11 +140,17 @@ pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result Option { - let data = std::fs::read_to_string(path).ok()?; - let value: Value = serde_json::from_str(&data).ok()?; +fn parse_session_checked(path: &Path) -> Result { + let data = std::fs::read_to_string(path) + .map_err(|err| format!("Failed to read Gemini session {}: {err}", path.display()))?; + let value: Value = serde_json::from_str(&data) + .map_err(|err| format!("Failed to parse Gemini session {}: {err}", path.display()))?; - let session_id = value.get("sessionId").and_then(Value::as_str)?.to_string(); + let session_id = value + .get("sessionId") + .and_then(Value::as_str) + .ok_or_else(|| format!("Gemini session has no sessionId: {}", path.display()))? + .to_string(); let created_at = value.get("startTime").and_then(parse_timestamp_to_ms); let last_active_at = value.get("lastUpdated").and_then(parse_timestamp_to_ms); @@ -160,7 +169,7 @@ fn parse_session(path: &Path) -> Option { let source_path = path.to_string_lossy().to_string(); - Some(SessionMeta { + Ok(SessionMeta { provider_id: PROVIDER_ID.to_string(), session_id: session_id.clone(), title: title.clone(), diff --git a/src-tauri/src/session_manager/providers/hermes.rs b/src-tauri/src/session_manager/providers/hermes.rs index 837e0d6dc1b..96bd85af615 100644 --- a/src-tauri/src/session_manager/providers/hermes.rs +++ b/src-tauri/src/session_manager/providers/hermes.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use rusqlite::Connection; use serde_json::Value; -use crate::hermes_config::get_hermes_dir; +use crate::hermes_config::try_get_hermes_dir; use crate::session_manager::{SessionMessage, SessionMeta}; use super::utils::{ @@ -14,94 +14,87 @@ use super::utils::{ const PROVIDER_ID: &str = "hermes"; -fn get_hermes_db_path() -> PathBuf { - get_hermes_dir().join("state.db") -} - -fn get_hermes_sessions_dir() -> PathBuf { - get_hermes_dir().join("sessions") +fn get_hermes_db_path() -> Result { + Ok(try_get_hermes_dir() + .map_err(|err| err.to_string())? + .join("state.db")) } /// Scan sessions from both SQLite database and JSONL transcript files, /// with SQLite taking precedence on ID conflicts. -pub fn scan_sessions() -> Vec { - let sqlite_sessions = scan_sessions_sqlite(); - let jsonl_sessions = scan_sessions_jsonl(); +pub fn scan_sessions() -> Result, String> { + let root = try_get_hermes_dir().map_err(|err| err.to_string())?; + let sqlite_sessions = scan_sessions_sqlite(&root.join("state.db"))?; + let jsonl_sessions = scan_sessions_jsonl(&root.join("sessions"))?; if sqlite_sessions.is_empty() { - return jsonl_sessions; + return Ok(jsonl_sessions); } if jsonl_sessions.is_empty() { - return sqlite_sessions; + return Ok(sqlite_sessions); } let sqlite_ids: std::collections::HashSet = sqlite_sessions .iter() - .map(|s| s.session_id.clone()) + .map(|session| session.session_id.clone()) .collect(); - let mut merged = sqlite_sessions; - for s in jsonl_sessions { - if !sqlite_ids.contains(&s.session_id) { - merged.push(s); + for session in jsonl_sessions { + if !sqlite_ids.contains(&session.session_id) { + merged.push(session); } } - merged + Ok(merged) } // ── SQLite scanning ───────────────────────────────────────────────── -fn scan_sessions_sqlite() -> Vec { - let db_path = get_hermes_db_path(); +fn scan_sessions_sqlite(db_path: &Path) -> Result, String> { if !db_path.exists() { - return Vec::new(); + return Ok(Vec::new()); } - let conn = match Connection::open_with_flags( - &db_path, + let conn = Connection::open_with_flags( + db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) { - Ok(c) => c, - Err(_) => return Vec::new(), - }; + ) + .map_err(|err| { + format!( + "Failed to open Hermes session database {}: {err}", + db_path.display() + ) + })?; - // Check if sessions table exists let has_sessions: bool = conn .query_row( "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='sessions'", [], |row| row.get(0), ) - .unwrap_or(false); - + .map_err(|err| format!("Failed to inspect Hermes session schema: {err}"))?; if !has_sessions { - return Vec::new(); + return Ok(Vec::new()); } - // Query sessions — use flexible column access via pragma - let columns = get_table_columns(&conn, "sessions"); - - let query = "SELECT * FROM sessions ORDER BY rowid DESC LIMIT 500"; - let mut stmt = match conn.prepare(query) { - Ok(s) => s, - Err(_) => return Vec::new(), - }; - - let mut sessions = Vec::new(); - let rows = match stmt.query_map([], |row| Ok(row_to_json(row, &columns))) { - Ok(r) => r, - Err(_) => return Vec::new(), - }; + let columns = get_table_columns(&conn, "sessions")?; + let mut stmt = conn + .prepare("SELECT * FROM sessions ORDER BY rowid DESC LIMIT 500") + .map_err(|err| format!("Failed to prepare Hermes session query: {err}"))?; + let rows = stmt + .query_map([], |row| Ok(row_to_json(row, &columns))) + .map_err(|err| format!("Failed to query Hermes sessions: {err}"))?; let db_source = format!("sqlite:{}", db_path.display()); - - for row_result in rows.flatten() { - if let Some(meta) = sqlite_row_to_session_meta(&row_result, &db_source) { - sessions.push(meta); + let mut sessions = Vec::new(); + for row_result in rows { + let row = + row_result.map_err(|err| format!("Failed to decode Hermes session row: {err}"))?; + match sqlite_row_to_session_meta(&row, &db_source) { + Some(meta) => sessions.push(meta), + None => log::warn!("Skipping malformed Hermes SQLite session row without a usable id"), } } - - sessions + Ok(sessions) } fn sqlite_row_to_session_meta(row: &Value, db_source: &str) -> Option { @@ -148,20 +141,19 @@ fn sqlite_row_to_session_meta(row: &Value, db_source: &str) -> Option Vec { +fn get_table_columns(conn: &Connection, table: &str) -> Result, String> { let query = format!("PRAGMA table_info({table})"); - let mut stmt = match conn.prepare(&query) { - Ok(s) => s, - Err(_) => return Vec::new(), - }; - let rows = match stmt.query_map([], |row| { - let name: String = row.get(1)?; - Ok(name) - }) { - Ok(r) => r, - Err(_) => return Vec::new(), - }; - rows.flatten().collect() + let mut stmt = conn + .prepare(&query) + .map_err(|err| format!("Failed to inspect Hermes table columns: {err}"))?; + let rows = stmt + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|err| format!("Failed to query Hermes table columns: {err}"))?; + let mut columns = Vec::new(); + for row in rows { + columns.push(row.map_err(|err| format!("Failed to decode Hermes table column: {err}"))?); + } + Ok(columns) } /// Convert a SQLite row to a JSON Value using known column names. @@ -236,7 +228,7 @@ pub fn delete_session_sqlite(session_id: &str, source: &str) -> Result Option<(PathBuf, String)> { // ── JSONL scanning ────────────────────────────────────────────────── -fn scan_sessions_jsonl() -> Vec { - let sessions_dir = get_hermes_sessions_dir(); +fn scan_sessions_jsonl(sessions_dir: &Path) -> Result, String> { if !sessions_dir.exists() { - return Vec::new(); + return Ok(Vec::new()); } - let entries = match std::fs::read_dir(&sessions_dir) { - Ok(e) => e, - Err(_) => return Vec::new(), - }; - + let entries = std::fs::read_dir(sessions_dir).map_err(|err| { + format!( + "Failed to read Hermes sessions directory {}: {err}", + sessions_dir.display() + ) + })?; let mut sessions = Vec::new(); - for entry in entries.flatten() { + for entry in entries { + let entry = + entry.map_err(|err| format!("Failed to enumerate Hermes session entry: {err}"))?; let path = entry.path(); - let ext = path.extension().and_then(|e| e.to_str()); + let ext = path.extension().and_then(|ext| ext.to_str()); if ext != Some("jsonl") && ext != Some("json") { continue; } - if let Some(meta) = parse_jsonl_session(&path) { - sessions.push(meta); + match parse_jsonl_session(&path) { + Some(meta) => sessions.push(meta), + None => log::warn!( + "Skipping malformed or unreadable Hermes session file: {}", + path.display() + ), } } - sessions + Ok(sessions) } fn parse_jsonl_session(path: &Path) -> Option { diff --git a/src-tauri/src/session_manager/providers/openclaw.rs b/src-tauri/src/session_manager/providers/openclaw.rs index 3a1e58511ce..1523a451c8c 100644 --- a/src-tauri/src/session_manager/providers/openclaw.rs +++ b/src-tauri/src/session_manager/providers/openclaw.rs @@ -8,7 +8,7 @@ use serde_json::Value; use crate::openclaw_config::get_openclaw_dir; use crate::{ config::write_json_file, - session_manager::{SessionMessage, SessionMeta}, + session_manager::{SessionMessage, SessionMeta, SessionScanError}, }; use super::utils::{ @@ -27,21 +27,18 @@ fn strip_message_id_suffix(text: &str) -> &str { } } -pub fn scan_sessions() -> Vec { +pub fn scan_sessions() -> Result, SessionScanError> { let agents_dir = get_openclaw_dir().join("agents"); if !agents_dir.exists() { - return Vec::new(); + return Ok(Vec::new()); } + let agent_entries = std::fs::read_dir(&agents_dir) + .map_err(|err| SessionScanError::storage("OpenClaw", &agents_dir, err))?; let mut sessions = Vec::new(); - - // Traverse each agent directory - let agent_entries = match std::fs::read_dir(&agents_dir) { - Ok(entries) => entries, - Err(_) => return sessions, - }; - - for agent_entry in agent_entries.flatten() { + for agent_entry in agent_entries { + let agent_entry = + agent_entry.map_err(|err| SessionScanError::storage("OpenClaw", &agents_dir, err))?; let agent_path = agent_entry.path(); if !agent_path.is_dir() { continue; @@ -51,27 +48,27 @@ pub fn scan_sessions() -> Vec { if !sessions_dir.is_dir() { continue; } - - let session_entries = match std::fs::read_dir(&sessions_dir) { - Ok(entries) => entries, - Err(_) => continue, - }; - + let session_entries = std::fs::read_dir(&sessions_dir) + .map_err(|err| SessionScanError::storage("OpenClaw", &sessions_dir, err))?; let display_names = load_display_names(&sessions_dir); - for entry in session_entries.flatten() { + for entry in session_entries { + let entry = + entry.map_err(|err| SessionScanError::storage("OpenClaw", &sessions_dir, err))?; let path = entry.path(); if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") { continue; } - - if let Some(meta) = parse_session(&path, Some(&display_names)) { - sessions.push(meta); + match parse_session_checked(&path, Some(&display_names)) { + Ok(meta) => sessions.push(meta), + Err(err) => log::warn!( + "Skipping unreadable OpenClaw session {}: {err}", + path.display() + ), } } } - - sessions + Ok(sessions) } pub fn load_messages(path: &Path) -> Result, String> { @@ -80,10 +77,12 @@ pub fn load_messages(path: &Path) -> Result, String> { let mut messages = Vec::new(); for line in reader.lines() { - let line = match line { - Ok(value) => value, - Err(_) => continue, - }; + let line = line.map_err(|err| { + format!( + "Failed to read openclaw session line from {}: {err}", + path.display() + ) + })?; let value: Value = match serde_json::from_str(&line) { Ok(parsed) => parsed, Err(_) => continue, @@ -123,12 +122,7 @@ pub fn load_messages(path: &Path) -> Result, String> { } pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result { - let meta = parse_session(path, None).ok_or_else(|| { - format!( - "Failed to parse OpenClaw session metadata: {}", - path.display() - ) - })?; + let meta = parse_session_checked(path, None)?; if meta.session_id != session_id { return Err(format!( @@ -158,16 +152,29 @@ pub fn delete_session(_root: &Path, path: &Path, session_id: &str) -> Result HashMap { let index_path = sessions_dir.join("sessions.json"); let content = match std::fs::read_to_string(&index_path) { - Ok(c) => c, - Err(_) => return HashMap::new(), + Ok(content) => content, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return HashMap::new(), + Err(err) => { + log::warn!( + "OpenClaw optional session index unreadable at {}: {err}", + index_path.display() + ); + return HashMap::new(); + } }; let index: serde_json::Map = match serde_json::from_str(&content) { - Ok(m) => m, - Err(_) => return HashMap::new(), + Ok(index) => index, + Err(err) => { + log::warn!( + "OpenClaw optional session index malformed at {}: {err}", + index_path.display() + ); + return HashMap::new(); + } }; let mut map = HashMap::new(); - for (_key, entry) in &index { + for entry in index.values() { if let (Some(id), Some(name)) = ( entry.get("sessionId").and_then(Value::as_str), entry.get("displayName").and_then(Value::as_str), @@ -180,11 +187,12 @@ fn load_display_names(sessions_dir: &Path) -> HashMap { map } -fn parse_session( +fn parse_session_checked( path: &Path, display_names: Option<&HashMap>, -) -> Option { - let (head, tail) = read_head_tail_lines(path, 10, 30).ok()?; +) -> Result { + let (head, tail) = read_head_tail_lines(path, 10, 30) + .map_err(|err| format!("Failed to read OpenClaw session {}: {err}", path.display()))?; let mut session_id: Option = None; let mut cwd: Option = None; @@ -270,7 +278,12 @@ fn parse_session( .and_then(|s| s.to_str()) .map(|s| s.to_string()) }); - let session_id = session_id?; + let session_id = session_id.ok_or_else(|| { + format!( + "OpenClaw session has no usable session id: {}", + path.display() + ) + })?; // Title priority: displayName (from sessions.json) > first user message > dir basename let title = display_names @@ -286,7 +299,7 @@ fn parse_session( let summary = summary.map(|text| truncate_summary(&text, 160)); - Some(SessionMeta { + Ok(SessionMeta { provider_id: PROVIDER_ID.to_string(), session_id: session_id.clone(), title, @@ -299,6 +312,14 @@ fn parse_session( }) } +#[cfg(test)] +fn parse_session( + path: &Path, + display_names: Option<&HashMap>, +) -> Option { + parse_session_checked(path, display_names).ok() +} + fn prune_sessions_index( index_path: &Path, session_id: &str, diff --git a/src-tauri/src/session_manager/providers/opencode.rs b/src-tauri/src/session_manager/providers/opencode.rs index 86f539e31a6..84cad8acceb 100644 --- a/src-tauri/src/session_manager/providers/opencode.rs +++ b/src-tauri/src/session_manager/providers/opencode.rs @@ -13,76 +13,90 @@ const PROVIDER_ID: &str = "opencode"; /// /// Respects `XDG_DATA_HOME` on all platforms; falls back to /// `~/.local/share/opencode/`. -pub(crate) fn get_opencode_base_dir() -> PathBuf { - if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { - let xdg = PathBuf::from(xdg.trim()); - if xdg.is_absolute() { - return xdg.join("opencode"); - } - if !xdg.as_os_str().is_empty() { - log::warn!( - "Ignoring relative XDG_DATA_HOME for OpenCode discovery: {}", - xdg.display() - ); - } +fn try_get_opencode_base_dir() -> Result { + match crate::failure_semantics::optional_absolute_env_root("XDG_DATA_HOME") { + Ok(Some(root)) => return Ok(root.join("opencode")), + Ok(None) => {} + Err(err) => return Err(err.to_string()), } - crate::config::get_home_dir().join(".local/share/opencode") + Ok(crate::config::try_get_home_dir_typed() + .map_err(|err| err.to_string())? + .join(".local/share/opencode")) } -/// Return the OpenCode JSON storage directory (legacy flat-file layout). -pub(crate) fn get_opencode_data_dir() -> PathBuf { - get_opencode_base_dir().join("storage") +pub(crate) fn get_opencode_data_dir() -> Result { + Ok(try_get_opencode_base_dir()?.join("storage")) } -fn get_opencode_db_path() -> PathBuf { - get_opencode_base_dir().join("opencode.db") +fn get_opencode_db_path() -> Result { + Ok(try_get_opencode_base_dir()?.join("opencode.db")) } -/// Scan sessions from both the legacy JSON files and the newer SQLite database, -/// merging results with SQLite taking precedence on ID conflicts. -pub fn scan_sessions() -> Vec { - let json_sessions = scan_sessions_json(); - let sqlite_sessions = scan_sessions_sqlite(); - +pub fn scan_sessions() -> Result, String> { + let json_sessions = scan_sessions_json()?; + let sqlite_sessions = scan_sessions_sqlite()?; if sqlite_sessions.is_empty() { - return json_sessions; + return Ok(json_sessions); } if json_sessions.is_empty() { - return sqlite_sessions; + return Ok(sqlite_sessions); } - - // Deduplicate: keep SQLite version when the same session_id exists in both let sqlite_ids: std::collections::HashSet = sqlite_sessions .iter() - .map(|s| s.session_id.clone()) + .map(|session| session.session_id.clone()) .collect(); - let mut merged = sqlite_sessions; - for s in json_sessions { - if !sqlite_ids.contains(&s.session_id) { - merged.push(s); + for session in json_sessions { + if !sqlite_ids.contains(&session.session_id) { + merged.push(session); } } - merged + Ok(merged) } -fn scan_sessions_json() -> Vec { - let storage = get_opencode_data_dir(); +fn scan_sessions_json() -> Result, String> { + let storage = get_opencode_data_dir()?; let session_dir = storage.join("session"); if !session_dir.exists() { - return Vec::new(); + return Ok(Vec::new()); } - let mut json_files = Vec::new(); - collect_json_files(&session_dir, &mut json_files); - + collect_json_files_strict(&session_dir, &mut json_files)?; let mut sessions = Vec::new(); for path in json_files { - if let Some(meta) = parse_session(&storage, &path) { - sessions.push(meta); + match parse_session_checked(&storage, &path) { + Ok(meta) => sessions.push(meta), + Err(err) => log::warn!( + "Skipping unreadable OpenCode session {}: {err}", + path.display() + ), + } + } + Ok(sessions) +} + +fn collect_json_files_strict(root: &Path, files: &mut Vec) -> Result<(), String> { + let entries = std::fs::read_dir(root).map_err(|err| { + format!( + "Failed to enumerate OpenCode session storage {}: {err}", + root.display() + ) + })?; + for entry in entries { + let entry = entry.map_err(|err| { + format!( + "Failed to enumerate OpenCode session entry in {}: {err}", + root.display() + ) + })?; + let path = entry.path(); + if path.is_dir() { + collect_json_files_strict(&path, files)?; + } else if path.extension().and_then(|ext| ext.to_str()) == Some("json") { + files.push(path); } } - sessions + Ok(()) } /// Parse a SQLite source reference in the format `sqlite::`. @@ -98,44 +112,40 @@ fn parse_sqlite_source(source: &str) -> Option<(PathBuf, String)> { Some((db_path, session_id)) } -fn scan_sessions_sqlite() -> Vec { - let db_path = get_opencode_db_path(); +fn scan_sessions_sqlite() -> Result, String> { + let db_path = get_opencode_db_path()?; if !db_path.exists() { - return Vec::new(); + return Ok(Vec::new()); } - - let conn = match Connection::open_with_flags( + let conn = Connection::open_with_flags( &db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, - ) { - Ok(c) => c, - Err(_) => return Vec::new(), - }; - - let mut stmt = match conn.prepare( + ) + .map_err(|err| { + format!( + "Failed to open OpenCode session database {}: {err}", + db_path.display() + ) + })?; + let mut stmt = conn.prepare( "SELECT id, title, directory, time_created, time_updated FROM session ORDER BY time_updated DESC", - ) { - Ok(s) => s, - Err(_) => return Vec::new(), - }; - + ).map_err(|err| format!("Failed to prepare OpenCode session query: {err}"))?; let db_display = db_path.display().to_string(); - - let iter = match stmt.query_map([], |row| { - let session_id: String = row.get(0)?; - let title: String = row.get(1)?; - let directory: String = row.get(2)?; - let created: i64 = row.get(3)?; - let updated: i64 = row.get(4)?; - Ok((session_id, title, directory, created, updated)) - }) { - Ok(rows) => rows, - Err(_) => return Vec::new(), - }; - + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + )) + }) + .map_err(|err| format!("Failed to query OpenCode sessions: {err}"))?; let mut sessions = Vec::new(); - for row in iter.flatten() { - let (session_id, title, directory, created, updated) = row; + for row in rows { + let (session_id, title, directory, created, updated) = + row.map_err(|err| format!("Failed to decode OpenCode session row: {err}"))?; let display_title = if title.is_empty() { path_basename(&directory) } else { @@ -157,7 +167,7 @@ fn scan_sessions_sqlite() -> Vec { resume_command: Some(format!("opencode session resume {session_id}")), }); } - sessions + Ok(sessions) } pub fn load_messages(path: &Path) -> Result, String> { @@ -390,7 +400,7 @@ pub fn delete_session_sqlite(session_id: &str, source: &str) -> Result Result 0) } -fn parse_session(storage: &Path, path: &Path) -> Option { - let data = std::fs::read_to_string(path).ok()?; - let value: Value = serde_json::from_str(&data).ok()?; +fn parse_session_checked(storage: &Path, path: &Path) -> Result { + let data = std::fs::read_to_string(path) + .map_err(|err| format!("Failed to read OpenCode session {}: {err}", path.display()))?; + let value: Value = serde_json::from_str(&data) + .map_err(|err| format!("Failed to parse OpenCode session {}: {err}", path.display()))?; - let session_id = value.get("id").and_then(Value::as_str)?.to_string(); + let session_id = value + .get("id") + .and_then(Value::as_str) + .ok_or_else(|| format!("OpenCode session has no id: {}", path.display()))? + .to_string(); let title = value .get("title") .and_then(Value::as_str) @@ -469,7 +485,7 @@ fn parse_session(storage: &Path, path: &Path) -> Option { get_first_user_summary(storage, &session_id) }; - Some(SessionMeta { + Ok(SessionMeta { provider_id: PROVIDER_ID.to_string(), session_id: session_id.clone(), title: display_title, @@ -810,7 +826,7 @@ mod tests { .expect("insert session 2"); drop(conn); - let sessions = scan_sessions_sqlite(); + let sessions = scan_sessions_sqlite().expect("scan sqlite sessions"); #[allow(deprecated)] if let Some(value) = original_xdg { @@ -832,6 +848,33 @@ mod tests { ); } + #[test] + #[allow(deprecated)] + fn scan_sessions_sqlite_surfaces_schema_errors() { + let _guard = opencode_env_lock().lock().expect("lock"); + let temp = tempdir().expect("tempdir"); + let original_xdg = std::env::var_os("XDG_DATA_HOME"); + std::env::set_var("XDG_DATA_HOME", temp.path()); + let base_dir = temp.path().join("opencode"); + std::fs::create_dir_all(&base_dir).expect("create base dir"); + let db_path = base_dir.join("opencode.db"); + let conn = Connection::open(&db_path).expect("open sqlite db"); + conn.execute_batch("CREATE TABLE unrelated (id TEXT PRIMARY KEY);") + .expect("create incompatible schema"); + drop(conn); + let result = scan_sessions_sqlite(); + if let Some(value) = original_xdg { + std::env::set_var("XDG_DATA_HOME", value); + } else { + std::env::remove_var("XDG_DATA_HOME"); + } + let err = result.expect_err("missing session table must be observable"); + assert!( + err.contains("Failed to prepare OpenCode session query"), + "{err}" + ); + } + #[test] fn load_messages_sqlite_reads_messages_and_parts() { let temp = tempdir().expect("tempdir"); diff --git a/src-tauri/src/session_manager/providers/utils.rs b/src-tauri/src/session_manager/providers/utils.rs index 4339ae3e70a..9c30efd250b 100644 --- a/src-tauri/src/session_manager/providers/utils.rs +++ b/src-tauri/src/session_manager/providers/utils.rs @@ -21,7 +21,7 @@ pub fn read_head_tail_lines( // For small files, read all lines once and split if file_len < 16_384 { let reader = BufReader::new(file); - let all: Vec = reader.lines().map_while(Result::ok).collect(); + let all: Vec = reader.lines().collect::>>()?; let head = all.iter().take(head_n).cloned().collect(); let skip = all.len().saturating_sub(tail_n); let tail = all.into_iter().skip(skip).collect(); @@ -30,14 +30,17 @@ pub fn read_head_tail_lines( // Read head lines from the beginning let reader = BufReader::new(file); - let head: Vec = reader.lines().take(head_n).map_while(Result::ok).collect(); + let head: Vec = reader + .lines() + .take(head_n) + .collect::>>()?; // Seek to last ~16 KB for tail lines let seek_pos = file_len.saturating_sub(16_384); let mut file2 = File::open(path)?; file2.seek(SeekFrom::Start(seek_pos))?; let tail_reader = BufReader::new(file2); - let all_tail: Vec = tail_reader.lines().map_while(Result::ok).collect(); + let all_tail: Vec = tail_reader.lines().collect::>>()?; // Skip first partial line if we seeked into the middle of a line let skip_first = if seek_pos > 0 { 1 } else { 0 }; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 5416cfcefe9..3ef62502795 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -546,58 +546,64 @@ impl Default for AppSettings { } } -impl AppSettings { - fn settings_path() -> Option { - // settings.json 保留用于旧版本迁移和无数据库场景 - Some( - crate::config::get_home_dir() - .join(".cc-switch") - .join("settings.json"), - ) +fn normalize_config_dir_override(field: &str, value: Option) -> Option { + let raw = value?.trim().to_string(); + if raw.is_empty() { + return None; } + match crate::config::resolve_persistence_path(&raw, field) { + Ok(path) => Some(path.to_string_lossy().to_string()), + Err(err) => { + log::error!("Ignoring invalid persisted {field}: {err}"); + None + } + } +} - fn normalize_paths(&mut self) { - self.claude_config_dir = self - .claude_config_dir - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - - self.codex_config_dir = self - .codex_config_dir - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - - self.gemini_config_dir = self - .gemini_config_dir - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); - - self.opencode_config_dir = self - .opencode_config_dir - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); +fn validate_config_dir_overrides(settings: &AppSettings) -> Result<(), AppError> { + let values = [ + ("claude_config_dir", settings.claude_config_dir.as_deref()), + ("codex_config_dir", settings.codex_config_dir.as_deref()), + ("gemini_config_dir", settings.gemini_config_dir.as_deref()), + ( + "opencode_config_dir", + settings.opencode_config_dir.as_deref(), + ), + ( + "openclaw_config_dir", + settings.openclaw_config_dir.as_deref(), + ), + ("hermes_config_dir", settings.hermes_config_dir.as_deref()), + ]; + for (field, raw) in values { + if let Some(raw) = raw.map(str::trim).filter(|raw| !raw.is_empty()) { + crate::config::resolve_persistence_path(raw, field).map_err(AppError::InvalidInput)?; + } + } + Ok(()) +} - self.openclaw_config_dir = self - .openclaw_config_dir - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); +impl AppSettings { + fn settings_path() -> Result { + Ok(crate::config::try_get_home_dir() + .map_err(AppError::Config)? + .join(".cc-switch") + .join("settings.json")) + } - self.hermes_config_dir = self - .hermes_config_dir - .as_ref() - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()); + fn normalize_paths(&mut self) { + self.claude_config_dir = + normalize_config_dir_override("claude_config_dir", self.claude_config_dir.take()); + self.codex_config_dir = + normalize_config_dir_override("codex_config_dir", self.codex_config_dir.take()); + self.gemini_config_dir = + normalize_config_dir_override("gemini_config_dir", self.gemini_config_dir.take()); + self.opencode_config_dir = + normalize_config_dir_override("opencode_config_dir", self.opencode_config_dir.take()); + self.openclaw_config_dir = + normalize_config_dir_override("openclaw_config_dir", self.openclaw_config_dir.take()); + self.hermes_config_dir = + normalize_config_dir_override("hermes_config_dir", self.hermes_config_dir.take()); self.language = self .language @@ -622,10 +628,13 @@ impl AppSettings { } fn load_from_file() -> Self { - let Some(path) = Self::settings_path() else { - return Self::default(); - }; - Self::load_from_path(&path) + match Self::settings_path() { + Ok(path) => Self::load_from_path(&path), + Err(err) => { + log::error!("无法解析 settings.json 路径,将使用内存默认设置且禁止持久化: {err}"); + Self::default() + } + } } fn load_from_path(path: &Path) -> Self { @@ -689,9 +698,7 @@ fn preserve_corrupt_settings_file(path: &Path, content: &str) { } fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> { - let Some(path) = AppSettings::settings_path() else { - return Err(AppError::Config("无法获取用户主目录".to_string())); - }; + let path = AppSettings::settings_path()?; save_settings_file_to_path(settings, &path) } @@ -714,11 +721,14 @@ fn settings_store() -> &'static RwLock { SETTINGS_STORE.get_or_init(|| RwLock::new(AppSettings::load_from_file())) } -fn resolve_override_path(raw: &str) -> PathBuf { - crate::config::expand_home_path(raw).unwrap_or_else(|err| { - log::error!("{err}"); - panic!("{err}"); - }) +fn resolve_override_path(raw: &str) -> Option { + let path = PathBuf::from(raw); + if path.is_absolute() { + Some(path) + } else { + log::error!("settings path invariant violated by relative override: {raw}"); + None + } } pub fn get_settings() -> AppSettings { @@ -744,6 +754,7 @@ pub fn get_settings_for_frontend() -> AppSettings { } pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> { + validate_config_dir_overrides(&new_settings)?; new_settings.normalize_paths(); save_settings_file(&new_settings)?; @@ -899,7 +910,7 @@ pub fn get_claude_override_dir() -> Option { settings .claude_config_dir .as_ref() - .map(|p| resolve_override_path(p)) + .and_then(|p| resolve_override_path(p)) } pub fn get_codex_override_dir() -> Option { @@ -907,7 +918,7 @@ pub fn get_codex_override_dir() -> Option { settings .codex_config_dir .as_ref() - .map(|p| resolve_override_path(p)) + .and_then(|p| resolve_override_path(p)) } pub fn get_gemini_override_dir() -> Option { @@ -915,7 +926,7 @@ pub fn get_gemini_override_dir() -> Option { settings .gemini_config_dir .as_ref() - .map(|p| resolve_override_path(p)) + .and_then(|p| resolve_override_path(p)) } pub fn get_opencode_override_dir() -> Option { @@ -923,7 +934,7 @@ pub fn get_opencode_override_dir() -> Option { settings .opencode_config_dir .as_ref() - .map(|p| resolve_override_path(p)) + .and_then(|p| resolve_override_path(p)) } pub fn get_openclaw_override_dir() -> Option { @@ -931,7 +942,7 @@ pub fn get_openclaw_override_dir() -> Option { settings .openclaw_config_dir .as_ref() - .map(|p| resolve_override_path(p)) + .and_then(|p| resolve_override_path(p)) } pub fn get_hermes_override_dir() -> Option { @@ -939,7 +950,7 @@ pub fn get_hermes_override_dir() -> Option { settings .hermes_config_dir .as_ref() - .map(|p| resolve_override_path(p)) + .and_then(|p| resolve_override_path(p)) } pub fn preserve_codex_official_auth_on_switch() -> bool { From 110e5fc08ed7d901872932c44662549d7e852df5 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Tue, 8 Sep 2026 15:01:46 +0800 Subject: [PATCH 106/112] ci: verify cleaned hardening head From 628fd276a0e4ca94bb4eb7672c5b82af3cc899db Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Wed, 9 Sep 2026 14:58:45 +0800 Subject: [PATCH 107/112] ci: validate final durability boundaries once --- .../final-durability-hardening-once.yml | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 .github/workflows/final-durability-hardening-once.yml diff --git a/.github/workflows/final-durability-hardening-once.yml b/.github/workflows/final-durability-hardening-once.yml new file mode 100644 index 00000000000..00d517c02f7 --- /dev/null +++ b/.github/workflows/final-durability-hardening-once.yml @@ -0,0 +1,165 @@ +name: Final Durability Hardening Once + +on: + push: + branches: [fix/global-hardening-20260904] + paths: + - ".github/workflows/final-durability-hardening-once.yml" + +permissions: + contents: write + +jobs: + harden: + runs-on: ubuntu-22.04 + steps: + - name: Checkout hardening branch + uses: actions/checkout@v6 + with: + ref: fix/global-hardening-20260904 + fetch-depth: 0 + + - name: Apply durability and cross-platform CI invariants + shell: bash + run: | + set -euo pipefail + python - <<'PY' + from pathlib import Path + import re + + config_path = Path("src-tauri/src/config.rs") + config = config_path.read_text(encoding="utf-8") + + old_contract = "/// - 任何写入/替换错误都会清理临时文件,旧目标保持不变(文件系统自身故障除外)。" + new_contract = """/// - 替换前的写入/替换失败会清理临时文件并保留旧目标; + /// - Unix 替换后必须同步父目录;目录同步失败返回错误,因为新内容可能已可见但持久化尚未确认。""" + if config.count(old_contract) != 1: + raise SystemExit(f"expected one atomic-write contract marker, found {config.count(old_contract)}") + config = config.replace(old_contract, new_contract) + + old_sync = ''' #[cfg(unix)] + if let Ok(directory) = fs::File::open(parent) { + if let Err(err) = directory.sync_all() { + // 文件本身已经成功替换;目录 fsync 在部分文件系统上可能不支持,因此只记录。 + log::debug!( + "目录元数据同步失败(文件内容已成功替换): path={}, error={err}", + parent.display() + ); + } + } + ''' + new_sync = ''' #[cfg(unix)] + { + let directory = fs::File::open(parent).map_err(|err| AppError::IoContext { + context: format!( + "无法打开父目录以确认原子写入持久化: {}", + parent.display() + ), + source: err, + })?; + directory.sync_all().map_err(|err| AppError::IoContext { + context: format!( + "父目录同步失败,无法确认原子写入持久化(新内容可能已可见): {}", + parent.display() + ), + source: err, + })?; + } + ''' + if config.count(old_sync) != 1: + raise SystemExit(f"expected one swallow-directory-sync block, found {config.count(old_sync)}") + config = config.replace(old_sync, new_sync) + config_path.write_text(config, encoding="utf-8") + + policy_path = Path("scripts/check_rust_failure_boundaries.py") + policy = policy_path.read_text(encoding="utf-8") + marker = '''if 'let legacy_dir = PathBuf::from(trimmed).join(".cc-switch")' in config_text: + failures.append("src-tauri/src/config.rs: Windows legacy HOME must be validated before DB fallback") + ''' + guard = '''if 'let legacy_dir = PathBuf::from(trimmed).join(".cc-switch")' in config_text: + failures.append("src-tauri/src/config.rs: Windows legacy HOME must be validated before DB fallback") + + # A successful rename is not a durable commit on Unix until the parent directory entry is + # synced. Returning success after a directory-sync failure would recreate the same + # acknowledged-but-not-durable persistence failure class this hardening pass removes. + if not re.search(r"directory\\.sync_all\\(\\)\\.map_err", config_text): + failures.append( + "src-tauri/src/config.rs: atomic writes must propagate parent-directory durability sync failures" + ) + ''' + if policy.count(marker) != 1: + raise SystemExit(f"expected one policy insertion marker, found {policy.count(marker)}") + policy = policy.replace(marker, guard) + policy_path.write_text(policy, encoding="utf-8") + + ci_path = Path(".github/workflows/ci.yml") + ci = ci_path.read_text(encoding="utf-8") + if "backend-windows:" in ci: + raise SystemExit("Windows backend gate already exists unexpectedly") + windows_job = ''' + + backend-windows: + name: Backend Windows Durability + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Create frontend dist placeholder + shell: pwsh + run: New-Item -ItemType Directory -Force dist | Out-Null + + - name: Compile and link all Windows targets and features + run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets --all-features --no-run + + - name: Exercise Windows atomic replacement + run: cargo test --manifest-path src-tauri/Cargo.toml --all-features atomic_write_replaces_complete_file + ''' + ci = ci.rstrip() + windows_job + "\n" + ci_path.write_text(ci, encoding="utf-8") + PY + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Install Linux system deps + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential pkg-config libssl-dev \ + libgtk-3-dev librsvg2-dev libayatana-appindicator3-dev + sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev \ + || sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.0-dev + sudo apt-get install -y --no-install-recommends libsoup-3.0-dev \ + || sudo apt-get install -y --no-install-recommends libsoup2.4-dev + + - name: Create frontend dist placeholder + run: mkdir -p dist + + - name: Check permanent failure boundaries + run: python scripts/check_rust_failure_boundaries.py + + - name: Check Rust formatting + run: cargo fmt --check --manifest-path src-tauri/Cargo.toml + + - name: Strict Clippy + run: cargo clippy --manifest-path src-tauri/Cargo.toml --all-targets --all-features -- -D warnings + + - name: Run all-feature Rust tests + run: cargo test --manifest-path src-tauri/Cargo.toml --all-features + + - name: Commit verified durability fixes and remove one-shot workflow + shell: bash + run: | + set -euo pipefail + git rm .github/workflows/final-durability-hardening-once.yml + git add src-tauri/src/config.rs scripts/check_rust_failure_boundaries.py .github/workflows/ci.yml + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git commit -m "fix: enforce durable atomic commit boundaries" + git push origin HEAD:fix/global-hardening-20260904 From c4e086550b7bc65d678ce3ca05c37978dc05a236 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Wed, 9 Sep 2026 15:08:20 +0800 Subject: [PATCH 108/112] ci: retrigger registered durability hardening --- .github/workflows/final-durability-hardening-once.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/final-durability-hardening-once.yml b/.github/workflows/final-durability-hardening-once.yml index 00d517c02f7..290a2a33992 100644 --- a/.github/workflows/final-durability-hardening-once.yml +++ b/.github/workflows/final-durability-hardening-once.yml @@ -1,4 +1,5 @@ name: Final Durability Hardening Once +# Retrigger after the workflow has been registered on the branch. on: push: From 06684160818f626f6e5f7ace4d76fd0f016448d3 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Wed, 9 Sep 2026 15:09:20 +0800 Subject: [PATCH 109/112] ci: make durability transform boundary-based --- .../final-durability-hardening-once.yml | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/.github/workflows/final-durability-hardening-once.yml b/.github/workflows/final-durability-hardening-once.yml index 290a2a33992..52b73d7fbb6 100644 --- a/.github/workflows/final-durability-hardening-once.yml +++ b/.github/workflows/final-durability-hardening-once.yml @@ -1,5 +1,4 @@ name: Final Durability Hardening Once -# Retrigger after the workflow has been registered on the branch. on: push: @@ -38,17 +37,14 @@ jobs: raise SystemExit(f"expected one atomic-write contract marker, found {config.count(old_contract)}") config = config.replace(old_contract, new_contract) - old_sync = ''' #[cfg(unix)] - if let Ok(directory) = fs::File::open(parent) { - if let Err(err) = directory.sync_all() { - // 文件本身已经成功替换;目录 fsync 在部分文件系统上可能不支持,因此只记录。 - log::debug!( - "目录元数据同步失败(文件内容已成功替换): path={}, error={err}", - parent.display() - ); - } - } - ''' + start_marker = " #[cfg(unix)]\n if let Ok(directory) = fs::File::open(parent) {" + end_marker = "\n\n Ok(())\n}" + start = config.find(start_marker) + if start < 0: + raise SystemExit("atomic-write parent-sync start marker not found") + end = config.find(end_marker, start) + if end < 0: + raise SystemExit("atomic-write parent-sync end marker not found") new_sync = ''' #[cfg(unix)] { let directory = fs::File::open(parent).map_err(|err| AppError::IoContext { @@ -65,11 +61,8 @@ jobs: ), source: err, })?; - } - ''' - if config.count(old_sync) != 1: - raise SystemExit(f"expected one swallow-directory-sync block, found {config.count(old_sync)}") - config = config.replace(old_sync, new_sync) + }''' + config = config[:start] + new_sync + config[end:] config_path.write_text(config, encoding="utf-8") policy_path = Path("scripts/check_rust_failure_boundaries.py") From 3f3b98f4c8f6b2886470fb747697fdcc226a6f25 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Wed, 9 Sep 2026 15:11:25 +0800 Subject: [PATCH 110/112] ci: format generated durability changes before verification --- .github/workflows/final-durability-hardening-once.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/final-durability-hardening-once.yml b/.github/workflows/final-durability-hardening-once.yml index 52b73d7fbb6..8f10ef70b90 100644 --- a/.github/workflows/final-durability-hardening-once.yml +++ b/.github/workflows/final-durability-hardening-once.yml @@ -138,6 +138,9 @@ jobs: - name: Check permanent failure boundaries run: python scripts/check_rust_failure_boundaries.py + - name: Format generated Rust + run: cargo fmt --manifest-path src-tauri/Cargo.toml + - name: Check Rust formatting run: cargo fmt --check --manifest-path src-tauri/Cargo.toml From 84ad33eaacc06007c14989b80f60aea96127a40d Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Wed, 9 Sep 2026 16:15:04 +0800 Subject: [PATCH 111/112] ci: separate durability promotion from one-shot cleanup --- .github/workflows/final-durability-hardening-once.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/final-durability-hardening-once.yml b/.github/workflows/final-durability-hardening-once.yml index 8f10ef70b90..e55bfcd9339 100644 --- a/.github/workflows/final-durability-hardening-once.yml +++ b/.github/workflows/final-durability-hardening-once.yml @@ -150,11 +150,10 @@ jobs: - name: Run all-feature Rust tests run: cargo test --manifest-path src-tauri/Cargo.toml --all-features - - name: Commit verified durability fixes and remove one-shot workflow + - name: Commit verified durability fixes shell: bash run: | set -euo pipefail - git rm .github/workflows/final-durability-hardening-once.yml git add src-tauri/src/config.rs scripts/check_rust_failure_boundaries.py .github/workflows/ci.yml git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" From 6706b63366f99b8d780b3a0283b18693542193b7 Mon Sep 17 00:00:00 2001 From: z13321812367-sys Date: Thu, 10 Sep 2026 17:42:47 +0800 Subject: [PATCH 112/112] ci: export verified durability candidate --- .../final-durability-hardening-once.yml | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/final-durability-hardening-once.yml b/.github/workflows/final-durability-hardening-once.yml index e55bfcd9339..711b602df85 100644 --- a/.github/workflows/final-durability-hardening-once.yml +++ b/.github/workflows/final-durability-hardening-once.yml @@ -7,7 +7,7 @@ on: - ".github/workflows/final-durability-hardening-once.yml" permissions: - contents: write + contents: read jobs: harden: @@ -150,12 +150,13 @@ jobs: - name: Run all-feature Rust tests run: cargo test --manifest-path src-tauri/Cargo.toml --all-features - - name: Commit verified durability fixes - shell: bash - run: | - set -euo pipefail - git add src-tauri/src/config.rs scripts/check_rust_failure_boundaries.py .github/workflows/ci.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git commit -m "fix: enforce durable atomic commit boundaries" - git push origin HEAD:fix/global-hardening-20260904 + - name: Export verified durability candidate + uses: actions/upload-artifact@v4 + with: + name: verified-durability-candidate + path: | + src-tauri/src/config.rs + scripts/check_rust_failure_boundaries.py + .github/workflows/ci.yml + if-no-files-found: error + retention-days: 3