Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8661ce85a1 | ||
|
|
0381c5351d | ||
|
|
f7aa94cb1b | ||
|
|
d1f0df031d | ||
|
|
ecde8c1344 | ||
|
|
f6feba9a8d | ||
|
|
19222f9cc9 | ||
|
|
e6e68f8d92 | ||
|
|
4c56b05894 | ||
|
|
a107ad22f8 | ||
|
|
3e2e27b77e | ||
|
|
2babfa6ced | ||
|
|
e9c52aa8cb | ||
|
|
26ebeb8618 | ||
|
|
e5634c3204 | ||
|
|
14885c9a0e | ||
|
|
918ad405b6 | ||
|
|
5368e2dad3 | ||
|
|
5a9ec0581c | ||
|
|
726de0fe63 | ||
|
|
7d0b2891b9 | ||
|
|
4c2cb51c59 | ||
|
|
b796b4d2cd | ||
|
|
16f64e3982 | ||
|
|
4772287fe1 | ||
|
|
163285cf32 | ||
|
|
d5f4a6572b | ||
|
|
bca9d0af55 | ||
|
|
ed5bdad63f | ||
|
|
47a13c8674 | ||
|
|
5dd375ce97 | ||
|
|
16380882a0 | ||
|
|
9c75c4295d | ||
|
|
b0585e61be | ||
|
|
f2bf8bc73f | ||
|
|
96b24f7b81 | ||
|
|
392b2b1bd5 | ||
|
|
0e28260197 | ||
|
|
edd399c079 | ||
|
|
460f629925 | ||
|
|
3970bbeb10 | ||
|
|
999b66a901 | ||
|
|
46fece2f44 | ||
|
|
71b97bf6b4 | ||
|
|
7e3551babe | ||
|
|
a309f402c1 | ||
|
|
d7610f9b12 | ||
|
|
e4822237fb | ||
|
|
5d18b2555e | ||
|
|
2ace729801 | ||
|
|
488f27e095 | ||
|
|
826a142fc0 | ||
|
|
d1b40285dd | ||
|
|
3ac28c3193 | ||
|
|
39dc8fc535 | ||
|
|
762ff36131 | ||
|
|
5894dceb98 | ||
|
|
92c371e5e0 | ||
|
|
936f2f49e4 | ||
|
|
9687a4b11c | ||
|
|
32bab7731e | ||
|
|
f64848ca9c | ||
|
|
94ab211431 | ||
|
|
e8b0e5b73c | ||
|
|
5b6655754d | ||
|
|
1fd5b1e608 | ||
|
|
7d0e0064d5 | ||
|
|
d38548a1f4 | ||
|
|
f25e43d281 | ||
|
|
d404905812 | ||
|
|
c200e6b86e | ||
|
|
bffeca53cc | ||
|
|
d381753be9 | ||
|
|
752078b2cf | ||
|
|
f00acccef1 | ||
|
|
e5fa7fbe05 | ||
|
|
f058bb5e85 | ||
|
|
659415f57c | ||
|
|
f51bdd6875 | ||
|
|
6c90976e8b | ||
|
|
b7d4a1996b | ||
|
|
561c3dcda3 | ||
|
|
45f6a225d9 | ||
|
|
79ccca2042 | ||
|
|
4e76fe54ac | ||
|
|
9206064e53 | ||
|
|
c8a67261ca | ||
|
|
bd1c103976 | ||
|
|
f8504dda30 | ||
|
|
b5eab73bba | ||
|
|
c263c488d0 | ||
|
|
269b463a06 | ||
|
|
7927a4253d | ||
|
|
180d6b9500 | ||
|
|
64f8ccfd7a | ||
|
|
e82ea649cd | ||
|
|
78ecb89b9b | ||
|
|
9fd405365a | ||
|
|
dd9a28ef93 | ||
|
|
99d42fd234 | ||
|
|
9be6b20a1c | ||
|
|
7d284a60fd | ||
|
|
e2b5c67a17 | ||
|
|
2f63cabb47 | ||
|
|
2dc6a35050 | ||
|
|
195fe1ee82 | ||
|
|
0eabdebe76 | ||
|
|
00acc2c835 | ||
|
|
7f69661f6a | ||
|
|
f8bb9ab2c4 | ||
|
|
9731843531 | ||
|
|
5708d4f9e9 | ||
|
|
aba5d7cc0f | ||
|
|
6df450482e | ||
|
|
f571799adb | ||
|
|
7f152b9812 | ||
|
|
a653a58f6d | ||
|
|
9019bdf3e2 | ||
|
|
1e89ff95d3 | ||
|
|
2cc94f1707 | ||
|
|
f2cb01dd0e | ||
|
|
44c7c7ecbf | ||
|
|
a23adc43ee | ||
|
|
0058405634 |
@@ -3,54 +3,198 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing release tag to rebuild, for example v0.3.0
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ inputs.tag || github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: Prepare draft release
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
tag: ${{ steps.metadata.outputs.tag }}
|
||||
version: ${{ steps.metadata.outputs.version }}
|
||||
release_body: ${{ steps.metadata.outputs.release_body }}
|
||||
release_id: ${{ steps.release.outputs.release_id }}
|
||||
steps:
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }}
|
||||
|
||||
- name: Validate versions and prepare release notes
|
||||
id: metadata
|
||||
env:
|
||||
REQUESTED_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "$REQUESTED_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$ ]]; then
|
||||
echo "Invalid release tag: $REQUESTED_TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
tag_commit=$(git rev-list -n 1 "refs/tags/${REQUESTED_TAG}^{commit}")
|
||||
if [ -z "$tag_commit" ] || [ "$tag_commit" != "$(git rev-parse HEAD)" ]; then
|
||||
echo "The workflow must run from the exact commit referenced by ${REQUESTED_TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RELEASE_TAG="$REQUESTED_TAG" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
tag = os.environ["RELEASE_TAG"]
|
||||
expected = tag[1:]
|
||||
root_package = json.loads(Path("package.json").read_text(encoding="utf-8"))
|
||||
desktop_package = json.loads(Path("apps/desktop/package.json").read_text(encoding="utf-8"))
|
||||
tauri_config = json.loads(Path("apps/desktop/src-tauri/tauri.conf.json").read_text(encoding="utf-8"))
|
||||
cargo_text = Path("apps/desktop/src-tauri/Cargo.toml").read_text(encoding="utf-8")
|
||||
cargo_match = re.search(
|
||||
r'^\[package\]\s*$.*?^version\s*=\s*"([^"]+)"',
|
||||
cargo_text,
|
||||
flags=re.S | re.M,
|
||||
)
|
||||
if not cargo_match:
|
||||
raise SystemExit("Unable to read the package version from Cargo.toml")
|
||||
|
||||
versions = {
|
||||
"package.json": root_package.get("version"),
|
||||
"apps/desktop/package.json": desktop_package.get("version"),
|
||||
"apps/desktop/src-tauri/Cargo.toml": cargo_match.group(1),
|
||||
"apps/desktop/src-tauri/tauri.conf.json": tauri_config.get("version"),
|
||||
}
|
||||
mismatches = {path: value for path, value in versions.items() if value != expected}
|
||||
if mismatches:
|
||||
details = ", ".join(f"{path}={value!r}" for path, value in mismatches.items())
|
||||
raise SystemExit(f"Release tag {tag} does not match project versions: {details}")
|
||||
|
||||
changelog = Path("CHANGELOG.md").read_text(encoding="utf-8")
|
||||
match = re.search(
|
||||
rf"^## \[{re.escape(tag)}\].*?\n(.*?)(?=^## \[|\Z)",
|
||||
changelog,
|
||||
flags=re.S | re.M,
|
||||
)
|
||||
body = match.group(1).strip() if match else ""
|
||||
if not body:
|
||||
body = "Codex-X desktop release."
|
||||
Path("release-body.md").write_text(body + "\n", encoding="utf-8")
|
||||
PY
|
||||
|
||||
delimiter="release_body_$(date +%s)_${RANDOM}"
|
||||
{
|
||||
echo "tag=$REQUESTED_TAG"
|
||||
echo "version=${REQUESTED_TAG#v}"
|
||||
echo "release_body<<$delimiter"
|
||||
cat release-body.md
|
||||
echo "$delimiter"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create clean draft release
|
||||
id: release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ steps.metadata.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
is_draft=$(gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft')
|
||||
if [ "$is_draft" != "true" ]; then
|
||||
echo "${RELEASE_TAG} is already published; use a new version instead of replacing updater assets" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh release edit "$RELEASE_TAG" \
|
||||
--title "Codex-X ${RELEASE_TAG}" \
|
||||
--notes-file release-body.md \
|
||||
--draft
|
||||
else
|
||||
gh release create "$RELEASE_TAG" \
|
||||
--verify-tag \
|
||||
--title "Codex-X ${RELEASE_TAG}" \
|
||||
--notes-file release-body.md \
|
||||
--draft
|
||||
fi
|
||||
|
||||
release_id=""
|
||||
for attempt in {1..10}; do
|
||||
release_id=$(gh release view "$RELEASE_TAG" \
|
||||
--json databaseId \
|
||||
--jq '.databaseId' 2>/dev/null || true)
|
||||
[ -z "$release_id" ] || break
|
||||
sleep 2
|
||||
done
|
||||
if [ -z "$release_id" ]; then
|
||||
echo "Unable to locate draft release for ${RELEASE_TAG}" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh api "/repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100" \
|
||||
--jq '.[].id' | while read -r asset_id; do
|
||||
[ -z "$asset_id" ] || gh api --method DELETE "/repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}"
|
||||
done
|
||||
echo "release_id=$release_id" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build ${{ matrix.name }}
|
||||
needs: prepare
|
||||
runs-on: ${{ matrix.platform }}
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: macOS Apple Silicon
|
||||
platform: macos-latest
|
||||
args: --bundles dmg
|
||||
artifact: codex-x-macos-arm64
|
||||
paths: |
|
||||
apps/desktop/src-tauri/target/release/bundle/dmg/*.dmg
|
||||
macos: true
|
||||
args: --bundles app,dmg
|
||||
asset_pattern: Codex-X-[version]-macos-apple-silicon[ext]
|
||||
- name: macOS Intel
|
||||
platform: macos-15-intel
|
||||
args: --bundles dmg
|
||||
artifact: codex-x-macos-x64
|
||||
paths: |
|
||||
apps/desktop/src-tauri/target/release/bundle/dmg/*.dmg
|
||||
- name: Linux
|
||||
macos: true
|
||||
args: --bundles app,dmg
|
||||
asset_pattern: Codex-X-[version]-macos-intel[ext]
|
||||
- name: Linux x64
|
||||
platform: ubuntu-22.04
|
||||
args: --bundles deb,rpm
|
||||
artifact: codex-x-linux
|
||||
paths: |
|
||||
apps/desktop/src-tauri/target/release/bundle/deb/*.deb
|
||||
apps/desktop/src-tauri/target/release/bundle/rpm/*.rpm
|
||||
- name: Windows
|
||||
macos: false
|
||||
args: --bundles deb,rpm,appimage
|
||||
asset_pattern: Codex-X-[version]-linux-x64[ext]
|
||||
- name: Windows x64
|
||||
platform: windows-latest
|
||||
args: --bundles msi
|
||||
artifact: codex-x-windows
|
||||
paths: |
|
||||
apps/desktop/src-tauri/target/release/bundle/msi/*.msi
|
||||
apps/desktop/src-tauri/Codex-X_*_windows_x64_portable.zip
|
||||
macos: false
|
||||
args: --bundles nsis
|
||||
asset_pattern: Codex-X-[version]-windows-x64[ext]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
- name: Checkout Windows test harness tooling
|
||||
if: matrix.platform == 'windows-latest'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: .ci-tools
|
||||
sparse-checkout: scripts/ensure-test-common-controls.ps1
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install Linux dependencies
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
@@ -64,144 +208,329 @@ jobs:
|
||||
patchelf
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
|
||||
with:
|
||||
version: 10.26.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Rust cache
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
with:
|
||||
workspaces: apps/desktop/src-tauri
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Tauri bundles
|
||||
run: pnpm --dir apps/desktop tauri build ${{ matrix.args }}
|
||||
- name: Check frontend types and regressions
|
||||
run: |
|
||||
pnpm --dir apps/desktop typecheck
|
||||
node --test apps/desktop/tests/*.test.mjs
|
||||
|
||||
- name: Create Windows portable zip
|
||||
- name: Verify Windows SDK test tooling
|
||||
if: matrix.platform == 'windows-latest'
|
||||
run: |
|
||||
command -v openssl
|
||||
openssl version
|
||||
|
||||
- name: Test Windows filesystem compatibility
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$artifacts = Join-Path $env:RUNNER_TEMP 'codexx-test-artifacts.json'
|
||||
cargo test --release --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked --no-run --message-format=json > $artifacts
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
$harness = ./.ci-tools/scripts/ensure-test-common-controls.ps1 -CargoJson $artifacts -OutputDirectory $env:RUNNER_TEMP | ConvertFrom-Json
|
||||
Write-Host "Running all tests in $($harness.testExecutable)"
|
||||
$env:PATH = "$(Split-Path -Parent $harness.testExecutable);$env:PATH"
|
||||
Push-Location apps/desktop/src-tauri
|
||||
try {
|
||||
& $harness.testExecutable --test-threads=1
|
||||
$testExitCode = $LASTEXITCODE
|
||||
} finally { Pop-Location }
|
||||
if ($testExitCode -ne 0) { exit $testExitCode }
|
||||
|
||||
- name: Import Apple signing certificate and notarization key
|
||||
if: matrix.macos
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
|
||||
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
|
||||
APPLE_API_KEY_P8_BASE64: ${{ secrets.APPLE_API_KEY_P8_BASE64 }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=$(node -p "require('./apps/desktop/package.json').version")
|
||||
portable_dir="apps/desktop/src-tauri/portable/Codex-X_${version}_windows_x64_portable"
|
||||
rm -rf "apps/desktop/src-tauri/portable"
|
||||
required=(
|
||||
APPLE_CERTIFICATE
|
||||
APPLE_CERTIFICATE_PASSWORD
|
||||
APPLE_API_ISSUER
|
||||
APPLE_API_KEY
|
||||
APPLE_API_KEY_P8_BASE64
|
||||
APPLE_SIGNING_IDENTITY
|
||||
APPLE_TEAM_ID
|
||||
KEYCHAIN_PASSWORD
|
||||
)
|
||||
for name in "${required[@]}"; do
|
||||
if [ -z "${!name:-}" ]; then
|
||||
echo "Missing required repository secret: ${name}" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
certificate_path="$RUNNER_TEMP/codex-x-developer-id.p12"
|
||||
api_key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY}.p8"
|
||||
keychain_path="$RUNNER_TEMP/codex-x-signing.keychain-db"
|
||||
|
||||
printf '%s' "$APPLE_CERTIFICATE" | base64 -D > "$certificate_path"
|
||||
printf '%s' "$APPLE_API_KEY_P8_BASE64" | base64 -D > "$api_key_path"
|
||||
chmod 600 "$certificate_path" "$api_key_path"
|
||||
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
|
||||
security default-keychain -s "$keychain_path"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
|
||||
security set-keychain-settings -lut 21600 "$keychain_path"
|
||||
security import "$certificate_path" \
|
||||
-k "$keychain_path" \
|
||||
-P "$APPLE_CERTIFICATE_PASSWORD" \
|
||||
-T /usr/bin/codesign
|
||||
security set-key-partition-list \
|
||||
-S apple-tool:,apple:,codesign: \
|
||||
-s \
|
||||
-k "$KEYCHAIN_PASSWORD" \
|
||||
"$keychain_path"
|
||||
security find-identity -v -p codesigning "$keychain_path" \
|
||||
| grep -F -- "$APPLE_SIGNING_IDENTITY" >/dev/null
|
||||
|
||||
echo "APPLE_API_KEY_PATH=$api_key_path" >> "$GITHUB_ENV"
|
||||
echo "CODEXX_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV"
|
||||
echo "CODEXX_CERTIFICATE_PATH=$certificate_path" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate updater signing secrets
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ] || [ -z "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" ]; then
|
||||
echo "Missing Tauri updater signing repository secrets" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Build, sign, and upload updater artifacts
|
||||
# Pinned to tauri-action v1.0.0 because this step receives the updater private key.
|
||||
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
APPLE_SIGNING_IDENTITY: ${{ matrix.macos && secrets.APPLE_SIGNING_IDENTITY || '' }}
|
||||
APPLE_API_ISSUER: ${{ matrix.macos && secrets.APPLE_API_ISSUER || '' }}
|
||||
APPLE_API_KEY: ${{ matrix.macos && secrets.APPLE_API_KEY || '' }}
|
||||
APPLE_API_KEY_PATH: ${{ matrix.macos && env.APPLE_API_KEY_PATH || '' }}
|
||||
APPLE_TEAM_ID: ${{ matrix.macos && secrets.APPLE_TEAM_ID || '' }}
|
||||
with:
|
||||
releaseId: ${{ needs.prepare.outputs.release_id }}
|
||||
tagName: ${{ needs.prepare.outputs.tag }}
|
||||
releaseBody: ${{ needs.prepare.outputs.release_body }}
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
projectPath: apps/desktop
|
||||
args: ${{ matrix.args }}
|
||||
releaseAssetNamePattern: ${{ matrix.asset_pattern }}
|
||||
uploadUpdaterJson: true
|
||||
uploadUpdaterSignatures: true
|
||||
updaterJsonPreferNsis: true
|
||||
retryAttempts: 3
|
||||
|
||||
- name: Verify updater signatures were generated
|
||||
run: |
|
||||
set -euo pipefail
|
||||
signature_count=$(find apps/desktop/src-tauri/target/release/bundle -type f -name '*.sig' | wc -l | tr -d ' ')
|
||||
if [ "$signature_count" -lt 1 ]; then
|
||||
echo "No updater signatures were generated" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Test Windows installation and legacy MSI migration
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installers = @(Get-ChildItem apps/desktop/src-tauri/target/release/bundle/nsis -Filter '*.exe')
|
||||
if ($installers.Count -ne 1) { throw 'Expected one NSIS installer.' }
|
||||
./scripts/windows-install-smoke.ps1 -Installer $installers[0].FullName
|
||||
|
||||
- name: Verify signed and notarized macOS bundles
|
||||
if: matrix.macos
|
||||
run: |
|
||||
set -euo pipefail
|
||||
app_path=$(find apps/desktop/src-tauri/target/release/bundle/macos -maxdepth 1 -type d -name '*.app' -print -quit)
|
||||
dmg_path=$(find apps/desktop/src-tauri/target/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' -print -quit)
|
||||
test -n "$app_path"
|
||||
test -n "$dmg_path"
|
||||
codesign --verify --deep --strict --verbose=2 "$app_path"
|
||||
spctl --assess --type execute --verbose=4 "$app_path"
|
||||
xcrun stapler validate "$app_path"
|
||||
hdiutil verify "$dmg_path"
|
||||
|
||||
- name: Create and upload Windows portable archive
|
||||
if: matrix.platform == 'windows-latest'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
portable_root="apps/desktop/src-tauri/portable"
|
||||
portable_dir="$portable_root/Codex-X-${VERSION}-windows-x64-portable"
|
||||
archive="apps/desktop/src-tauri/Codex-X-${VERSION}-windows-x64-portable.zip"
|
||||
rm -rf "$portable_root"
|
||||
mkdir -p "$portable_dir"
|
||||
cp apps/desktop/src-tauri/target/release/codex-x.exe "$portable_dir/Codex-X.exe"
|
||||
cp THIRD_PARTY_NOTICES.md "$portable_dir/THIRD_PARTY_NOTICES.md"
|
||||
touch "$portable_dir/Codex-X.portable"
|
||||
cat > "$portable_dir/README-portable.txt" <<'EOF'
|
||||
Codex-X Windows Portable
|
||||
|
||||
Double click Codex-X.exe to run. No MSI installation is required.
|
||||
Double click Codex-X.exe to run. No installation is required.
|
||||
The portable edition uses manual downloads instead of in-app installation.
|
||||
Keep Codex-X.portable next to Codex-X.exe so the app can identify this edition.
|
||||
Microsoft Edge WebView2 Runtime is required; Windows 10/11 usually already includes it.
|
||||
EOF
|
||||
powershell -NoProfile -Command "Compress-Archive -Path 'apps/desktop/src-tauri/portable/Codex-X_${version}_windows_x64_portable/*' -DestinationPath 'apps/desktop/src-tauri/Codex-X_${version}_windows_x64_portable.zip' -Force"
|
||||
powershell -NoProfile -Command "Compress-Archive -Path '${portable_dir}/*' -DestinationPath '${archive}' -Force"
|
||||
gh release upload "$RELEASE_TAG" "$archive" --clobber
|
||||
|
||||
- name: Normalize release asset names
|
||||
- name: Remove temporary Apple credentials
|
||||
if: always() && matrix.macos
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=$(node -p "require('./apps/desktop/package.json').version")
|
||||
case "${{ matrix.name }}" in
|
||||
"macOS Apple Silicon")
|
||||
target="apps/desktop/src-tauri/target/release/bundle/dmg/Codex-X_${version}_macos_apple_silicon_aarch64.dmg"
|
||||
src=$(find apps/desktop/src-tauri/target/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' | head -n 1)
|
||||
test -n "$src"
|
||||
mv "$src" "$target"
|
||||
;;
|
||||
"macOS Intel")
|
||||
target="apps/desktop/src-tauri/target/release/bundle/dmg/Codex-X_${version}_macos_intel_x64.dmg"
|
||||
src=$(find apps/desktop/src-tauri/target/release/bundle/dmg -maxdepth 1 -type f -name '*.dmg' | head -n 1)
|
||||
test -n "$src"
|
||||
mv "$src" "$target"
|
||||
;;
|
||||
"Windows")
|
||||
target="apps/desktop/src-tauri/target/release/bundle/msi/Codex-X_${version}_windows_x64.msi"
|
||||
src=$(find apps/desktop/src-tauri/target/release/bundle/msi -maxdepth 1 -type f -name '*.msi' | head -n 1)
|
||||
test -n "$src"
|
||||
mv "$src" "$target"
|
||||
;;
|
||||
"Linux")
|
||||
deb_target="apps/desktop/src-tauri/target/release/bundle/deb/Codex-X_${version}_linux_x64.deb"
|
||||
rpm_target="apps/desktop/src-tauri/target/release/bundle/rpm/Codex-X_${version}_linux_x64.rpm"
|
||||
deb_src=$(find apps/desktop/src-tauri/target/release/bundle/deb -maxdepth 1 -type f -name '*.deb' | head -n 1)
|
||||
rpm_src=$(find apps/desktop/src-tauri/target/release/bundle/rpm -maxdepth 1 -type f -name '*.rpm' | head -n 1)
|
||||
test -n "$deb_src"
|
||||
test -n "$rpm_src"
|
||||
mv "$deb_src" "$deb_target"
|
||||
mv "$rpm_src" "$rpm_target"
|
||||
;;
|
||||
esac
|
||||
if [ -n "${CODEXX_KEYCHAIN_PATH:-}" ]; then
|
||||
security delete-keychain "$CODEXX_KEYCHAIN_PATH" || true
|
||||
fi
|
||||
rm -f "${CODEXX_CERTIFICATE_PATH:-}" "${APPLE_API_KEY_PATH:-}"
|
||||
|
||||
- name: Upload bundle artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
path: ${{ matrix.paths }}
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
release:
|
||||
name: Publish GitHub Release
|
||||
needs: build
|
||||
publish:
|
||||
name: Validate and publish release
|
||||
needs: [prepare, build]
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download bundle artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
ref: ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
- name: Prepare release notes
|
||||
id: release_notes
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import os
|
||||
import re
|
||||
|
||||
tag = os.environ["GITHUB_REF_NAME"]
|
||||
text = Path("CHANGELOG.md").read_text(encoding="utf-8")
|
||||
match = re.search(
|
||||
rf"^## \[{re.escape(tag)}\].*?\n(.*?)(?=^## \[|\Z)",
|
||||
text,
|
||||
flags=re.S | re.M,
|
||||
)
|
||||
body = match.group(1).strip() if match else ""
|
||||
if not body:
|
||||
body = "Codex-X desktop release."
|
||||
with open("release-body.md", "w", encoding="utf-8") as fh:
|
||||
fh.write(body + "\n")
|
||||
PY
|
||||
|
||||
- name: Publish release
|
||||
- name: Match release asset labels to filenames
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
title="Codex-X ${tag}"
|
||||
if gh release view "$tag" >/dev/null 2>&1; then
|
||||
gh release edit "$tag" --title "$title" --notes-file release-body.md --latest
|
||||
else
|
||||
gh release create "$tag" --title "$title" --notes-file release-body.md --latest
|
||||
fi
|
||||
mapfile -t assets < <(find release-assets -type f \( -name '*.dmg' -o -name '*.msi' -o -name '*.deb' -o -name '*.rpm' -o -name '*.zip' \) -print | sort)
|
||||
if [ "${#assets[@]}" -eq 0 ]; then
|
||||
echo "No release assets found" >&2
|
||||
assets_endpoint="/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100"
|
||||
gh api "$assets_endpoint" \
|
||||
--jq '.[] | select(.label != .name) | [.id, .name] | @tsv' \
|
||||
| while IFS=$'\t' read -r asset_id asset_name; do
|
||||
gh api --method PATCH \
|
||||
"/repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" \
|
||||
-f "label=${asset_name}" >/dev/null
|
||||
done
|
||||
|
||||
mismatched_labels=$(gh api "$assets_endpoint" \
|
||||
--jq '[.[] | select(.label != .name)] | length')
|
||||
if [ "$mismatched_labels" -ne 0 ]; then
|
||||
echo "Release asset labels do not match their filenames" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'Uploading %s\n' "${assets[@]}"
|
||||
gh release upload "$tag" "${assets[@]}" --clobber
|
||||
|
||||
- name: Download and validate updater manifest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
||||
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api "/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100" > release-assets.json
|
||||
latest_asset_id=$(jq -r '.[] | select(.name == "latest.json") | .id' release-assets.json)
|
||||
if [ -z "$latest_asset_id" ] || [ "$latest_asset_id" = "null" ]; then
|
||||
echo "latest.json is missing from the draft release" >&2
|
||||
exit 1
|
||||
fi
|
||||
gh api \
|
||||
-H "Accept: application/octet-stream" \
|
||||
"/repos/${GITHUB_REPOSITORY}/releases/assets/${latest_asset_id}" \
|
||||
> latest.json
|
||||
python3 scripts/validate_updater_release.py \
|
||||
--manifest latest.json \
|
||||
--assets release-assets.json \
|
||||
--version "$VERSION" \
|
||||
--repository "$GITHUB_REPOSITORY" \
|
||||
--release-tag "$RELEASE_TAG" \
|
||||
--rewrite-download-urls \
|
||||
--migrate-windows-to-nsis \
|
||||
--require-signature-assets
|
||||
gh release upload "$RELEASE_TAG" latest.json --clobber
|
||||
|
||||
- name: Remove public signature assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_ID: ${{ needs.prepare.outputs.release_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
gh api "/repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100" \
|
||||
--jq '.[] | select(.name | endswith(".sig")) | .id' \
|
||||
| while read -r asset_id; do
|
||||
[ -z "$asset_id" ] || gh api --method DELETE "/repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}"
|
||||
done
|
||||
|
||||
- name: Publish the verified release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
||||
run: gh release edit "$RELEASE_TAG" --draft=false --latest
|
||||
|
||||
- name: Verify published updater endpoint
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
endpoint="https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/latest.json"
|
||||
curl -fsSL \
|
||||
--retry 12 \
|
||||
--retry-delay 5 \
|
||||
--retry-all-errors \
|
||||
"$endpoint" \
|
||||
-o published-latest.json
|
||||
python3 scripts/validate_updater_release.py \
|
||||
--manifest published-latest.json \
|
||||
--assets release-assets.json \
|
||||
--version "$VERSION" \
|
||||
--repository "$GITHUB_REPOSITORY" \
|
||||
--release-tag "$RELEASE_TAG"
|
||||
|
||||
python3 - <<'PY' > updater-urls.txt
|
||||
import json
|
||||
manifest = json.load(open("published-latest.json", encoding="utf-8"))
|
||||
for entry in manifest["platforms"].values():
|
||||
print(entry["url"])
|
||||
PY
|
||||
sort -u updater-urls.txt | while read -r url; do
|
||||
curl -fsSL \
|
||||
--range 0-0 \
|
||||
--retry 5 \
|
||||
--retry-delay 3 \
|
||||
--retry-all-errors \
|
||||
"$url" \
|
||||
-o /dev/null
|
||||
done
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Refresh Star History
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/15 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: star-history-${{ github.repository }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
refresh:
|
||||
name: Refresh chart data
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Send authenticated repository snapshot
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INGEST_TOKEN: ${{ secrets.STAR_HISTORY_INGEST_TOKEN }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
CAMO_PURGE_URLS: ${{ vars.STAR_HISTORY_CAMO_URLS }}
|
||||
STAR_HISTORY_ENDPOINT: ${{ vars.STAR_HISTORY_ENDPOINT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "${INGEST_TOKEN}"
|
||||
test -n "${STAR_HISTORY_ENDPOINT}"
|
||||
metadata_file="${RUNNER_TEMP}/star-history-metadata.json"
|
||||
payload_file="${RUNNER_TEMP}/star-history-payload.json"
|
||||
|
||||
gh api \
|
||||
--header "Accept: application/vnd.github+json" \
|
||||
--header "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"repos/${REPOSITORY}" > "${metadata_file}"
|
||||
|
||||
jq -n \
|
||||
--arg repository "${REPOSITORY}" \
|
||||
--slurpfile metadata "${metadata_file}" \
|
||||
'{
|
||||
repository: $repository,
|
||||
createdAt: $metadata[0].created_at,
|
||||
checkedAt: (now | todateiso8601),
|
||||
currentStars: $metadata[0].stargazers_count
|
||||
}' > "${payload_file}"
|
||||
|
||||
curl \
|
||||
--fail-with-body \
|
||||
--retry 3 \
|
||||
--retry-all-errors \
|
||||
--connect-timeout 15 \
|
||||
--max-time 240 \
|
||||
--request POST \
|
||||
--header "Authorization: Bearer ${INGEST_TOKEN}" \
|
||||
--header "Content-Type: application/json" \
|
||||
--data-binary "@${payload_file}" \
|
||||
"${STAR_HISTORY_ENDPOINT}"
|
||||
|
||||
while IFS= read -r camo_url; do
|
||||
test -n "${camo_url}" || continue
|
||||
case "${camo_url}" in
|
||||
https://camo.githubusercontent.com/*) ;;
|
||||
*) echo "::warning::Skipped an invalid Camo purge URL"; continue ;;
|
||||
esac
|
||||
curl \
|
||||
--fail \
|
||||
--silent \
|
||||
--show-error \
|
||||
--retry 2 \
|
||||
--request PURGE \
|
||||
"${camo_url}" > /dev/null \
|
||||
|| echo "::warning::GitHub Camo purge failed and will be retried on the next refresh"
|
||||
done <<< "${CAMO_PURGE_URLS}"
|
||||
@@ -0,0 +1,80 @@
|
||||
name: Windows installer verification
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'codex/windows-updater-*'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: windows-installer-check-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
windows-installer:
|
||||
name: Verify Windows update migration
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1
|
||||
with:
|
||||
version: 10.26.0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
- uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30
|
||||
with:
|
||||
toolchain: stable
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
|
||||
with:
|
||||
workspaces: apps/desktop/src-tauri
|
||||
cache-on-failure: true
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --dir apps/desktop typecheck
|
||||
- run: node --test apps/desktop/tests/*.test.mjs
|
||||
- name: Verify SDK TLS fixture tooling
|
||||
run: |
|
||||
$openssl = Get-Command openssl -ErrorAction Stop
|
||||
& $openssl.Source version
|
||||
- name: Run Windows release tests
|
||||
run: |
|
||||
$artifacts = Join-Path $env:RUNNER_TEMP 'codexx-test-artifacts.json'
|
||||
cargo test --release --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked --no-run --message-format=json > $artifacts
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
$harness = ./scripts/ensure-test-common-controls.ps1 -CargoJson $artifacts -OutputDirectory $env:RUNNER_TEMP | ConvertFrom-Json
|
||||
Write-Host "Running all tests in $($harness.testExecutable)"
|
||||
$env:PATH = "$(Split-Path -Parent $harness.testExecutable);$env:PATH"
|
||||
Push-Location apps/desktop/src-tauri
|
||||
try {
|
||||
& $harness.testExecutable --test-threads=1
|
||||
$testExitCode = $LASTEXITCODE
|
||||
} finally { Pop-Location }
|
||||
if ($testExitCode -ne 0) { exit $testExitCode }
|
||||
- name: Diagnose failed Windows test loader
|
||||
if: failure()
|
||||
run: ./scripts/windows-dll-diagnostics.ps1 -RunHarnessList
|
||||
- name: Retain Windows loader diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: windows-loader-diagnostics
|
||||
path: ${{ runner.temp }}/codexx-dll-diagnostics
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
- name: Build test installer without publishing or release signing credentials
|
||||
run: |
|
||||
Set-Content -Path "$env:RUNNER_TEMP\installer-check.json" -Value '{"bundle":{"createUpdaterArtifacts":false}}' -Encoding utf8
|
||||
pnpm --dir apps/desktop tauri build --debug --bundles nsis --config "$env:RUNNER_TEMP\installer-check.json" --ci
|
||||
- name: Test real MSI migration and subsequent updates
|
||||
run: |
|
||||
$installers = @(Get-ChildItem apps/desktop/src-tauri/target/debug/bundle/nsis -Filter '*.exe')
|
||||
if ($installers.Count -ne 1) { throw 'Expected one Windows installer.' }
|
||||
./scripts/windows-install-smoke.ps1 -Installer $installers[0].FullName
|
||||
@@ -0,0 +1,24 @@
|
||||
name: Windows MSI API probe
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['codex/windows-updater-*']
|
||||
paths: ['scripts/windows-msi-probe.ps1', '.github/workflows/windows-msi-probe.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
msi-probe:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
- shell: pwsh
|
||||
run: |
|
||||
if (-not (Test-Path "${env:ProgramFiles(x86)}\NSIS\makensis.exe")) {
|
||||
choco install nsis --yes --no-progress
|
||||
}
|
||||
- shell: pwsh
|
||||
run: ./scripts/windows-msi-probe.ps1
|
||||
@@ -23,6 +23,14 @@ apps/desktop/src-tauri/gen/schemas/
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
apple cert/
|
||||
*.p12
|
||||
*.p8
|
||||
*.pfx
|
||||
*.pem
|
||||
*.key
|
||||
codex-x-updater.key
|
||||
codex-new-ui/
|
||||
|
||||
# Backups
|
||||
*.bak
|
||||
|
||||
@@ -1,234 +1,468 @@
|
||||
# Changelog
|
||||
# 更新日志
|
||||
|
||||
All notable changes to Codex-X will be documented here.
|
||||
记录 Codex-X 的新功能、体验改进和问题修复。
|
||||
|
||||
## [v0.3.27] - 2026-10-17
|
||||
|
||||
- 会话同步期间可启用或切换供应商;每次同步固定开始时的目标,完成后按当前供应商更新状态。
|
||||
- 识别 Codex 保留 `.jsonl` 逻辑路径、实际使用 `.jsonl.zst` 的压缩会话;同步、预览、导出及删除兼容压缩存储,保留流式处理、备份和并发修改保护。
|
||||
- 状态检查省去整文件改写快照与重压,减少同步中重复的完整日志扫描。
|
||||
- 会话按历史工作目录分组时保留原路径拼写,不再探测或打开项目目录,减少触发“文稿”等目录的权限提示。
|
||||
- 新增「Codex 简体中文」开关,读取 Codex 实际语言配置,重启后应用待生效的选择,关闭时恢复原语言或自动检测;支持 macOS,以及经过官方身份验证的 Windows Store 版和独立安装版(Store 版仅支持默认 `CODEX_HOME`)。
|
||||
- 统一设置页和操作提示中的「重启 Codex」入口,同时应用待生效的语言设置;改进进程退出与启动确认,减少首次重启失败、再次点击才成功的问题。
|
||||
- 供应商卡片新增 DeepSeek、OpenRouter、硅基流动和 Moonshot 余额查询,区分账户余额与 Key 剩余额度,支持后台定时更新、手动刷新和查看最近查询时间。
|
||||
- 余额刷新复用同一凭据的并发请求,按实际查询时间安排后续刷新,避免手动查询或读取缓存推迟后台更新;金额保留十进制精度,查询失败时保留此前余额并显示失败状态。
|
||||
- 官方账号编辑支持读取单个标准 `auth.json` 文件到编辑区,校验后由用户保存。
|
||||
- 重启 Codex 失败时保留错误详情,支持手动关闭,便于定位失败原因。
|
||||
|
||||
## [v0.3.26] - 2026-10-09
|
||||
|
||||
- 安装版在线更新的主通道失败后,自动通过官方 GitHub API 获取更新清单和安装包,继续由原生更新器检查版本、平台和签名;不再因一次检查失败就静默改为仅手动下载。
|
||||
|
||||
- 供应商新增上游协议选择,本地路由可将 Chat Completions、Claude Messages 和 Gemini 原生请求/响应转换为 Codex 使用的 Responses,支持图片、工具调用及流式输出。
|
||||
- 模型获取和连接检测按所选协议发送认证与 Headers;从 CC Switch 导入时保留显式协议选择。
|
||||
- 配置检查显示文件、行列、参数和具体处理建议;切回官方账号后可安全修复旧会话缺失的供应商定义。
|
||||
- 供应商页面新增自定义 Headers 编辑,支持固定值和环境变量,并在对应行显示校验错误;可选择 Claude Code / Kilo Code 的 User-Agent 预设,填写提示按请求头用途区分。
|
||||
- 使用协议转换时,退出和更新保留本地路由与恢复记录,重开后恢复相同协议;关闭路由前提示切回原生 Responses/官方,避免无效直连。
|
||||
|
||||
## [v0.3.24] - 2026-10-01
|
||||
|
||||
- 会话管理新增工作文件夹侧栏、文件夹与会话置顶,并使用虚拟列表减少大量会话的渲染开销。
|
||||
- 会话日志改为流式读取和磁盘快照处理,大日志及超长单条记录也能同步;读取、写入或复查失败时明确报错并执行安全回滚,避免日志、数据库索引与同步状态不一致。
|
||||
- 修复文件夹路径大小写混淆导致显示错误会话、置顶联动和缓存串用的问题,按实际文件系统身份区分目录或合并同目录别名。
|
||||
- 置顶按 Codex 配置目录分别保存,文件夹暂时不可读或重建后按原始路径保留置顶。旧 PR 预览版的 v1 置顶缓存不自动迁移,需重新置顶。
|
||||
|
||||
- 修复 Windows 下会话索引原子替换被文件句柄阻止的问题。
|
||||
|
||||
## [v0.3.22] - 2026-09-30
|
||||
|
||||
- 修复第三方模型被错误识别为“仅文本”而无法输入图片的问题,并自动升级旧版生成的模型目录。需供应商模型本身支持图片,更新菜单后重启 Codex 生效。
|
||||
- 供应商卡片支持上下拖动排序并自动保存,重启后保留顺序;也可聚焦左侧手柄后使用上下箭头移动。
|
||||
- 在供应商卡片显示已选模型名称,设置映射别名时同时保留真实模型 ID。
|
||||
- 补充 Ultra 思考强度的 Codex 设置提示,并修复旧模型目录遗漏图片或完整思考强度的问题。
|
||||
|
||||
## [v0.3.21] - 2026-09-23
|
||||
|
||||
- 优化 Windows 在线更新,缩短安装等待;旧版可自动迁移,正常更新无需重启电脑,账号和配置保留。
|
||||
|
||||
## [v0.3.20] - 2026-09-18
|
||||
|
||||
- 新增「设置 → 路由与故障转移」:可管理本地路由、安排供应商优先顺序,并在服务异常时自动切换;支持调整超时和重试参数,官方登录也可使用本地路由。
|
||||
- 修复添加或切换第三方供应商时,MCP、桌面设置等配置丢失的问题。
|
||||
- 修复内部任务被误同步,以及回退后的正常会话被误报异常的问题。
|
||||
|
||||
## [v0.3.18] - 2026-09-17
|
||||
|
||||
- 第三方模型映射后可在 Codex 中选择推理等级,并修复 DeepSeek 每次发送消息先报错、重试后才能使用的问题。
|
||||
- 新增后台配置检查:发现可能导致 Codex 无法使用的问题时提醒你,可选择修复,也可稍后到「设置 → 通用设置」处理。修复前会自动备份。
|
||||
- 会话支持导出为 Markdown,多选会话可打包下载;MCP 和 Skills 支持 ZIP 导出、重新导入,取消 Skills ZIP 的 20 MB 大小限制。
|
||||
- 修复「导入已有」弹窗的暗色显示、无效 MCP 重复出现,以及大日志记录被用量统计跳过的问题。
|
||||
|
||||
## [v0.3.17] - 2026-09-09
|
||||
|
||||
### 新增与优化
|
||||
|
||||
- 官方账号支持查看剩余额度、重置时间和可用重置次数,列表中显示邮箱、套餐和登录状态。
|
||||
- 新增 DeepSeek、MiniMax、小米 MiMo 等主流厂商预设和模型映射,方便在 Codex 中使用第三方模型,并保留已有配置。
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复编辑正在使用的供应商时保存报错、切换后出现重复供应商,以及部分第三方连接认证失败的问题。
|
||||
- 修复用量统计切换日期范围时页面闪白的问题。
|
||||
|
||||
## [v0.3.16] - 2026-09-09
|
||||
|
||||
### 新功能
|
||||
|
||||
- 支持保存多个官方 Codex 账号,并为每个账号起不同的名字,方便切换使用。
|
||||
- 官方账号和第三方供应商都支持一键复制,点击后直接生成副本。
|
||||
- 新增“开启 1M 上下文窗口”选项,可按需勾选使用(需要模型支持)。
|
||||
- 设置页新增“用量统计”,可以查看 Token 用量、每日趋势、模型分布和最近 10 个会话,并按日期、模型筛选。
|
||||
- AI 后台子任务的用量合并到主对话,聊天列表只显示主会话。最近会话显示聊天标题或项目名,更容易找到。
|
||||
|
||||
### 体验改进
|
||||
|
||||
- 提示词页面的标题统一为蓝色,设置页的标签切换加入过渡动画。
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复同步 GitHub 提示词模板时,供应商按钮暂时无法使用的问题。
|
||||
- 修复切换供应商后,原有的 CC Switch 供应商从列表消失的问题。
|
||||
|
||||
## [v0.3.15] - 2026-08-20
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复 Windows 点击“重启 Codex”后只隐藏窗口、未真正退出,以及再次重启时报错的问题。
|
||||
|
||||
## [v0.3.14] - 2026-08-20
|
||||
|
||||
### 更新
|
||||
|
||||
- MCP 和 Skills 支持添加自定义备注,方便区分用途。
|
||||
- 供应商支持复制,同一 API 平台可以保存多个模型或多份配置,副本放在列表末尾。
|
||||
- 简化供应商列表,不再重复显示模型信息。
|
||||
- 设置页新增“重启 Codex”按钮,支持 macOS 和 Windows。
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复相同供应商配置被覆盖,以及复制当前供应商后丢失启用状态的问题。
|
||||
- 修复 Codex 自动创建的子代理会话被错误标记为“待同步”的问题。
|
||||
|
||||
## [v0.3.13] - 2026-08-18
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 已归档的会话不再被标记为“待同步”,切换供应商时会继续保持归档状态。
|
||||
|
||||
## [v0.3.12] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 提升使用流畅度,修复部分问题。
|
||||
|
||||
## [v0.3.11] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 提升使用流畅度,修复部分问题。
|
||||
|
||||
## [v0.3.10] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 提升使用流畅度,修复部分问题。
|
||||
|
||||
## [v0.3.9] - 2026-08-11
|
||||
|
||||
### 更新
|
||||
|
||||
- 提升使用流畅度,修复部分问题。
|
||||
|
||||
## [v0.3.8] - 2026-08-03
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复部分使用问题。
|
||||
|
||||
## [v0.3.7] - 2026-08-02
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复部分使用问题。
|
||||
|
||||
## [v0.3.6] - 2026-07-31
|
||||
|
||||
### 更新
|
||||
|
||||
- 官方登录与第三方供应商切换后,新建会话即可使用,无需重启 Codex。
|
||||
- 关闭主窗口后,软件会留在 macOS 菜单栏或 Windows 托盘中,可从菜单恢复窗口或退出。
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复切换第三方供应商时丢失官方登录或官方 API Key 的问题,现在切回官方时可以恢复。
|
||||
- 修复编辑当前供应商时意外新增副本、重复导入 cc-switch 产生重复项的问题,并兼容旧版 cc-switch 的官方登录导入。
|
||||
- 修复同时操作供应商、提示词或其他设置时,配置被覆盖或损坏的问题。
|
||||
- 修复会话同步提示成功却未生效的问题,并改善对自定义会话存储位置的支持。
|
||||
- 修复旧记录被重复计入会话数量,以及同步时误改旧会话或项目位置的问题。
|
||||
|
||||
## [v0.3.5] - 2026-07-28
|
||||
|
||||
### 更新
|
||||
|
||||
- GitHub 提示词模板可以直接查看和编辑内容,名称和文件名仍跟随在线版本。
|
||||
- 编辑后的模板会标记为“本地已修改”,后续同步不会覆盖你的修改,新模板仍会正常下载;修改在下次启用模板时生效。
|
||||
- 仅查看模板或保存未改动的内容,不会影响后续在线更新。
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复同步 GitHub 模板时只能看到 5 个、无法获取完整 11 个模板的问题;网络异常时仍可使用已有缓存。
|
||||
- 修复使用第三方供应商时,概览页误报未找到官方登录信息的问题。
|
||||
|
||||
## [v0.3.4] - 2026-07-28
|
||||
|
||||
### 更新
|
||||
|
||||
- 在线模板库新增 6 套提示词,点击同步 GitHub 模板即可获取,不增加安装包体积。
|
||||
- 软件开发新增“长期维护工程师”“系统化调试与根因修复”“严格代码审查”。
|
||||
- 写作辅助新增“清晰表达与润色”“技术文档写作”“结构化长文起草”。新模板会自动分类,仍保留用户手动设置的分类。
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复切换第三方供应商或从 cc-switch 导入后,官方登录信息被覆盖、需要重新登录的问题。
|
||||
- “还原官方配置”不再自动切换当前供应商;新增“新建官方配置”,方便清除异常认证后重新登录。
|
||||
- 调整官方认证编辑框,长内容可以换行显示,更方便查看和选择。
|
||||
|
||||
## [v0.3.3] - 2026-07-26
|
||||
|
||||
### 调整
|
||||
|
||||
- 暂时下线皮肤中心,隐藏侧边栏入口,保留用户已保存的本地主题文件。
|
||||
- 升级后会自动关闭此前启用的皮肤,恢复 Codex 官方外观;若恢复失败,会显示原因。
|
||||
- 安装包不再附带“椎名真白·樱花画室”主题壁纸,减小下载体积。
|
||||
|
||||
## [v0.3.2] - 2026-07-25
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复 Windows 启动 Codex 或进入新版首页、外观设置时,皮肤功能错误提示无法识别 Codex 的问题。
|
||||
- 皮肤出现异常时仍可点击“关闭皮肤”,恢复过程中会避免重复操作。
|
||||
|
||||
### 安全改进
|
||||
|
||||
- 皮肤功能仅用于已识别的官方 Codex 窗口,不会应用到普通网页。
|
||||
|
||||
## [v0.3.1] - 2026-07-24
|
||||
|
||||
### 更新
|
||||
|
||||
- 重新设计皮肤中心,可直接预览壁纸、侧边栏、会话区和输入框的整体效果,主题卡片更紧凑。
|
||||
- 支持主题分类筛选,卡片会根据数量自动调整为 1 至 4 列;导出主题可使用系统“另存为”窗口。
|
||||
- 支持从图片创建主题,并修改名称、简介和界面透明度。
|
||||
- 皮肤功能支持 Windows 官方商店版 Codex,无需修改 Codex 安装文件。
|
||||
- 合并重复的恢复按钮,统一使用“关闭皮肤”。
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复 macOS 应用皮肤后一直显示“应用中”,以及重开软件后无法识别当前主题的问题。
|
||||
- 修复皮肤按钮状态错误、刷新缺少反馈,以及提示词或主题较多时无法继续向下滚动的问题。
|
||||
- 优化横图壁纸的裁剪,减少人物头部被切掉的情况。
|
||||
- 修复切换第三方供应商时覆盖官方登录信息的问题。
|
||||
- 改善 Codex 安装检测,减少已安装却显示“未检测到 Codex”的情况。
|
||||
- 修复启用皮肤后输入框消失、聊天内容透过输入框,以及图片主题预览显示错误背景的问题。
|
||||
- 修复浅色主题下壁纸颜色发白的问题,并改善透明度调整和输入框文字的清晰度。
|
||||
|
||||
### 安全与来源
|
||||
|
||||
- 加强官方 Codex 识别、主题导入和切换检查。
|
||||
- 换肤功能使用 Codex Dream Skin Studio 的 MIT 授权代码,未包含其非 MIT 授权的真人主题素材。
|
||||
|
||||
## [v0.3.0] - 2026-07-16
|
||||
|
||||
### 更新
|
||||
|
||||
- 全面更新界面,统一页面、按钮、弹窗和通知样式,加入深浅色模式与页面切换动画。
|
||||
- 新增应用内更新,macOS、Windows MSI 和 Linux AppImage 用户可直接下载、安装并重启;侧边栏会显示更新入口和进度。
|
||||
- 改进供应商连接测试,删除前增加确认,添加或编辑时可以获取并选择模型,官方登录页面更简洁。
|
||||
- 提示词新增分类和两套内置模板,卡片更简洁;添加或编辑后只保存,需要时再手动启用。
|
||||
- 启动后会在后台同步在线模板,网络异常时尝试备用下载来源;模板会保存到本机,离线时仍能使用,支持系统代理。
|
||||
- 会话统一同步到当前供应商,不再在启动时自动修改;支持查看、选择和删除内部会话。
|
||||
- 导入 Skills / MCP 时会跳过已有内容,没有新内容时不再弹出空窗口。
|
||||
|
||||
### 问题修复
|
||||
|
||||
- 修复部分旧提示词配置导致 Codex 报错,以及 Windows 将配置文件夹链接到其他位置后无法读写的问题。
|
||||
- 修复会话同步报错、会话重复显示,以及聊天记录被误改或覆盖的问题。
|
||||
- 修复深浅色模式下部分文字和控件显示异常,以及打开弹窗后背景仍能滚动的问题。
|
||||
- 修复提示词重复同步、错误更新或误删除,以及应用更新失败后提示不准确的问题。
|
||||
- 完善安装包和更新的安全检查。
|
||||
|
||||
### 兼容说明
|
||||
|
||||
- `v0.3.0` 是首个支持应用内更新的版本,旧版用户需要手动安装本版本一次。
|
||||
- Windows 便携版和 Linux deb/rpm 仍需手动更新;Windows MSI 首次安装可能出现系统安全提示(SmartScreen)。
|
||||
|
||||
## [v0.2.35] - 2026-07-13
|
||||
|
||||
- 新增 `gpt-5.6-sol-unrestricted.md` 提示词模板,同步 GitHub 模板后即可启用。
|
||||
- 切换第三方供应商后,新建或重新打开 Codex 会话即可使用新供应商,无需重启整个 Codex 客户端。
|
||||
- 自动整理 API 地址和密钥均相同的重复供应商;同一地址使用不同密钥的供应商仍分别保留。
|
||||
- 修复切换供应商或切回官方登录后,原供应商可能消失的问题。
|
||||
- 从 cc-switch 导入时,会显示新增、更新、合并和跳过数量;重复导入不再覆盖已有名称、模型和自定义配置,并兼容旧版 cc-switch 数据。
|
||||
- 会话列表不再重复展示 Codex 自动创建的后台子会话,项目和会话数量更准确;会话编号保留首尾,方便区分。
|
||||
- 修复编辑同名供应商时可能覆盖另一条记录的问题。
|
||||
|
||||
## [v0.2.34] - 2026-07-13
|
||||
|
||||
- 会话管理支持单选或多选后永久删除,所选会话派生的子会话也会一并删除。删除不进入回收站、不创建新的备份,在 Codex-X 中无法撤销。
|
||||
- 删除前可预览待删除会话,并需确认已关闭其他正在使用这些会话的 Codex 窗口或命令行进程。
|
||||
- 支持按项目勾选当前已加载的会话,也可跨项目批量选择;搜索时同时显示匹配数量和项目会话总数。
|
||||
- 会话列表改为紧凑表格,标题、更新时间、供应商、模型和编号更清晰。检查与修复统一使用顶部的“检查会话”和“同步 / 修复”。
|
||||
- 修复默认窗口大小下会话列表无法继续向下滚动的问题。
|
||||
- 修复升级 Codex 后可能重复统计会话、混入旧会话的问题。
|
||||
- 批量删除部分失败时,会保留未删除项并显示失败数量和原因,方便处理后重试。
|
||||
|
||||
## [v0.2.33] - 2026-07-11
|
||||
|
||||
- 重新整理【指令提示词】页面,当前状态、启用方式、模板来源和更新时间更清晰,并简化模板说明。
|
||||
- 同步 GitHub 时会自动添加新模板、移除已删除模板;离线时仍可使用上次成功同步的内容。
|
||||
- 新增 `海鸥3.0破甲.md` 示例模板,清理不再使用的旧示例。
|
||||
- 修复返回提示词页面时列表先显示旧内容再跳到新内容,以及模板较多时列表超出边框的问题。
|
||||
- 修复同步或下载失败时模板列表可能出错、误删的问题,失败后可重新同步;正在使用的模板即使已从 GitHub 删除,重启后仍可正常关闭。
|
||||
- 修复 macOS、Windows 和 Linux 应用图标外围出现大块白边的问题。
|
||||
|
||||
## [v0.2.32] - 2026-07-11
|
||||
|
||||
- 提示词“启用方式”增加说明,方便了解“保留原提示词”和“替换原提示词”的区别,并核对当前启用状态。
|
||||
- 修复配置相近的多个供应商可能同时显示“当前启用”、重复显示供应商卡片的问题。
|
||||
- 关闭追加的提示词时会保留用户原有规则,自定义规则也支持备份和恢复。
|
||||
- 修复多项操作同时备份时可能失败的问题。
|
||||
- 修复切换 Skill 或 MCP 开关后,条目跳到列表其他位置的问题。
|
||||
- 修复 macOS 窗口顶部无法拖动的问题,并改善 Windows 上 Codex 版本的识别。
|
||||
|
||||
## [v0.2.31] - 2026-07-09
|
||||
|
||||
### 更新
|
||||
|
||||
- 调整 macOS Toast 展示位置:Toast 现在直接挂在窗口根层,不再被内容区域下移影响,提示位置更靠上、更自然。
|
||||
- 优化 Skills ZIP 安装体验:从 ZIP 安装 Skill 时会读取 `SKILL.md` 里的真实 `name` / `description`,不再显示 `skill-zip-时间戳` 这类临时目录名。
|
||||
- Skills 页面刷新时会自动识别旧版 `skill-zip-*` 目录,并尝试重命名为 Skill 自身名称。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复启用第三方供应商后,关闭并重新打开 Codex-X 不再显示“当前启用”的问题。
|
||||
- 修复 Windows / Linux 上供应商编辑页无法正常下拉滚动的问题,编辑 `config.toml` 时不再需要用 PageDown 绕过。
|
||||
- 修复 Toast 受内容容器 `contain` 影响导致 macOS 位置调整不生效的问题。
|
||||
|
||||
### 开发
|
||||
|
||||
- 增加 Skill 元数据读取与旧 ZIP Skill 目录自动修正的单元测试。
|
||||
- 调整 macOS 顶部通知的位置,避免提示随页面内容下移。
|
||||
- 从 ZIP 安装 Skill 后显示其实际名称和说明;刷新时会尝试修正旧版安装留下的临时名称。
|
||||
- 修复启用第三方供应商后,重新打开 Codex-X 不再显示“当前启用”的问题。
|
||||
- 修复 Windows 和 Linux 供应商编辑页无法正常向下滚动的问题。
|
||||
|
||||
## [v0.2.30] - 2026-07-09
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化会话管理页面布局:同步状态、目标 Provider、聊天总数、已展示数量、需修复数量合并为紧凑信息栏,把更多空间留给会话列表。
|
||||
- 会话管理新增“启动自动修复”开关:开启后,Codex-X 启动时会在后台检查本地会话完整性,发现未同步会自动修复,不阻塞界面。
|
||||
- 优化按钮点击体验:移除容易造成 WebView 闪烁/掉帧的水波纹、位移和 filter 效果,点击反馈更轻、更稳。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复同名供应商可能同时显示为“当前启用”的问题;Codex-X 现在会记录最后启用的供应商 ID,避免第三方 live Provider 都是 `custom` 时出现双亮。
|
||||
- 修复新增同名供应商时 ID 冲突的问题;新建供应商会自动生成唯一 ID。
|
||||
- 修复提示词管理中同名/同文件名 md 可能同时显示为启用的问题;新增或导入提示词会自动生成唯一文件名,当前状态按实际启用文件精确匹配。
|
||||
- 修复会话管理中会话选择反馈不明显的问题;已选数量和可修复数量会实时展示。
|
||||
|
||||
### 体验
|
||||
|
||||
- 进一步降低供应商、提示词、TOML、会话管理等页面滚动时的闪烁和低帧率感。
|
||||
|
||||
- 精简会话管理顶部信息栏,为会话列表留出更多空间;选择会话时实时显示已选数量和可修复数量。
|
||||
- 新增“启动自动修复”开关,开启后会在启动时后台检查会话,并自动修复未同步的会话,不影响界面操作。
|
||||
- 减少按钮点击和页面滚动时的闪烁、卡顿。
|
||||
- 修复同名供应商可能同时显示“当前启用”,以及新增同名供应商发生冲突的问题。
|
||||
- 修复同名提示词可能同时显示为启用的问题。
|
||||
|
||||
## [v0.2.29] - 2026-07-07
|
||||
|
||||
### 更新
|
||||
|
||||
- 优化 Toast 提示样式:改为窗口顶部居中的产品化通知卡片,减少遮挡右上角操作按钮。
|
||||
- Provider 连接测试文案改为简洁结果:成功显示“连接正常(耗时)”,失败显示 HTTP 状态与耗时。
|
||||
- 第三方供应商编辑页支持保存 TOML 模板;点击“保存”只保存配置,点击“启用”时才写入 Codex live config。
|
||||
- 技能和 MCP 页面“导入已有”改为先展示预览确认弹窗,列出将导入的 Skills / MCP,用户确认后才执行导入。
|
||||
|
||||
### 修复 Bug
|
||||
|
||||
- 修复 Codex 第三方 API auth 写入错误:`auth.json` 现在写入 Codex 实际识别的 `auth_mode = "apikey"`,避免桌面端回到登录页。
|
||||
- 修复官方认证编辑页打开时会静默加载 cc-switch 旧 token,导致新登录账号 token 显示不更新的问题;现在默认读取 live `~/.codex/auth.json`,并提供“刷新当前 auth.json”。
|
||||
- 修复切换到 OpenAI Official 时可能覆盖当前 live auth.json 的问题,避免把旧账号登录态写回。
|
||||
- 修复 Provider 测试把 HTTP 403 误判为“连接正常”的问题;现在只有 2xx 状态会显示成功。
|
||||
- 修复从 cc-switch 导入 Provider 时同名同 URL 供应商可能产生重复卡片的问题。
|
||||
- 修复 SQLite 轻量迁移重复执行时 `duplicate column name: toml_config` 导致启动报错的问题。
|
||||
|
||||
### 开发
|
||||
|
||||
- 增加 Provider/auth 相关单元测试,覆盖第三方切换、官方 auth 保留、403 判定和 cc-switch 导入串台场景。
|
||||
|
||||
- 通知改为顶部居中显示,减少对右上角按钮的遮挡;供应商连接测试结果更简洁,显示连接状态和耗时。
|
||||
- 第三方供应商支持保存配置模板:“保存”只保存配置,点击“启用”后才应用到 Codex。
|
||||
- Skills 和 MCP 的“导入已有”增加预览,确认列表后才执行导入。
|
||||
- 修复切换第三方供应商后 Codex 可能回到登录页的问题。
|
||||
- 官方认证编辑页默认读取当前登录信息,并提供“刷新当前 auth.json”;修复切回官方登录时可能恢复成旧账号的问题。
|
||||
- 修复连接测试将“访问被拒绝”误报为连接正常,以及从 cc-switch 导入时出现重复供应商卡片的问题。
|
||||
- 修复部分用户升级后无法启动的问题。
|
||||
|
||||
## [v0.2.28] - 2026-07-07
|
||||
|
||||
- 修复从 cc-switch 导入 Codex Provider 时可能把供应商名称、base_url 与 API Key 串台的问题。
|
||||
- 导入 cc-switch Provider 时改为先扫描所有 `[model_providers.<id>]` section,再按 provider row id 精确匹配,避免使用过期的 `model_provider` active 值。
|
||||
- 兼容 cc-switch legacy `custom` provider 模板,同时保留 `experimental_bearer_token` 兜底。
|
||||
- 增加复现 Sky2api / MagicAI 交叉配置的单元测试,防止 Provider 切换再次出现“看起来切了但实际没切对”的问题。
|
||||
- 修复从 cc-switch 导入供应商时,名称、API 地址和密钥可能相互混淆的问题,避免启用后实际使用了其他供应商。
|
||||
- 改善旧版 cc-switch 供应商配置的导入兼容性。
|
||||
|
||||
## [v0.2.27] - 2026-07-07
|
||||
|
||||
- 优化【指令提示词】页面为 Skills/MCP 风格的简洁列表:左侧显示模板名称与说明,右侧使用开启/关闭切换;自定义/导入的 md 提示词增加编辑与删除图标。
|
||||
- 优化【供应商】页面操作区:移除“官方配置 / 本地保存 / gpt-5.5 / 不支持路由”等冗余信息,将切换改为“启用”,编辑/删除/测试连接改为图标按钮。
|
||||
- 新增供应商 base_url 连通性测试按钮,方便切换前检查第三方 API 地址是否可达。
|
||||
- 修复第三方 Provider 切换后可能未真正生效的问题:切换和保存 TOML 时会写入 Codex 实际读取的 `experimental_bearer_token`。
|
||||
- 对齐 cc-switch 的 Codex Provider live config 思路:从 cc-switch 导入时会识别 `experimental_bearer_token`,并避免使用 Codex 内置保留 provider id。
|
||||
- 优化启动加载链路:启动诊断、备份列表、会话同步状态改为后台刷新,减少首屏等待。
|
||||
- 提示词页面改为简洁列表,名称和说明更清晰,右侧可直接开关;自定义和导入的提示词支持编辑、删除。
|
||||
- 精简供应商操作区,将“切换”改为“启用”,编辑、删除和连接测试改为图标按钮。
|
||||
- 新增供应商连接测试,方便启用前检查第三方 API 地址是否可用。
|
||||
- 修复第三方供应商切换后可能未真正生效的问题,并改善从 cc-switch 导入配置的兼容性。
|
||||
- 减少启动等待,检查、备份列表和会话状态在后台加载。
|
||||
|
||||
## [v0.2.26] - 2026-07-07
|
||||
|
||||
- 修复第三方 Provider 切换后可能“看起来已切换但实际未按新供应商生效”的问题:不再把所有第三方都写成 `model_provider = "custom"`,而是写入供应商自己的稳定 ID。
|
||||
- 修复从官方 ChatGPT 登录态切到第三方 API Key 时 `auth.json` 仍保留 `auth_mode = "chatgpt"` 的问题;写入 API Key 时会同步设置 `auth_mode = "apikey"`。
|
||||
- 第三方供应商默认不再要求 OpenAI 登录态,避免新建/导入 Provider 时错误继承官方 auth 语义。
|
||||
- 增加 Provider 切换单元测试,覆盖真实 provider key 和 API Key auth mode 的落盘结果。
|
||||
- 修复第三方供应商显示已切换、实际却未生效的问题。
|
||||
- 修复从官方登录切换到第三方 API 密钥后,仍沿用官方登录方式的问题。
|
||||
- 新增或导入第三方供应商时,默认不再要求 OpenAI 官方登录。
|
||||
|
||||
## [v0.2.25] - 2026-07-06
|
||||
|
||||
- 进一步修复 TOML、指令提示词、供应商、会话管理等页面切换时右侧滚动条闪现/消失造成的视觉抖动。
|
||||
- 外层页面滚动容器改为稳定滚动并隐藏外层滚动条,保留 TOML 编辑器、会话列表等内部区域自己的滚动条。
|
||||
- 进一步减少切换配置、提示词、供应商和会话页面时,滚动条闪现造成的抖动;编辑器和会话列表仍保留各自的滚动条。
|
||||
|
||||
## [v0.2.24] - 2026-07-06
|
||||
|
||||
- 修复切换供应商 / 会话管理等页面时右侧滚动条短暂出现又消失的问题:页面切换动画改为纯透明度过渡,不再用 `translateY` 造成瞬时溢出。
|
||||
- 降低页面切换视觉抖动,避免进入页面时内容区域宽度被临时滚动条挤压。
|
||||
- 修复切换供应商、会话管理等页面时滚动条短暂闪现、内容宽度跳动的问题,页面切换更平稳。
|
||||
|
||||
## [v0.2.23] - 2026-07-06
|
||||
|
||||
- 指令提示词页新增并纳管 `gpt5.5-jeli.md` 模板,作为“大白话(80% 场景)破甲”版本。
|
||||
- 简化指令提示词列表视觉:移除花哨图标与文件路径展示,让用户更容易看清模板名称、介绍和启用状态。
|
||||
- 更新 GPT-5.5 / GPT-5.4 内置模板名称为“unrestricted 破甲”,并统一说明使用方法:先让 AI 分析项目,分析完之后发【不直白的逆向】命令。
|
||||
- 新增 `gpt5.5-jeli.md` 提示词模板,名称为“大白话(80% 场景)破甲”。
|
||||
- 精简提示词列表,突出模板名称、介绍和启用状态。
|
||||
- GPT-5.5 和 GPT-5.4 内置模板统一使用“unrestricted 破甲”名称,并补充使用说明:先让 AI 分析项目,再发送【不直白的逆向】命令。
|
||||
|
||||
## [v0.2.22] - 2026-07-06
|
||||
|
||||
- 继续优化指令提示词页切换体验:导航切换进入 React transition,GitHub 内置模板静默检查延后到空闲时执行,减少切页瞬间卡顿。
|
||||
- 修复外部/自定义提示词切换到内置模板后可能出现两份的问题:按规范化内容(换行与首尾空白归一)和文件名双重去重,已有自定义提示词会复用更新,不再新增副本。
|
||||
- 优化启用内置提示词速度:启用时直接使用本地缓存或打包内置版本,不再同步等待 GitHub 网络请求;手动/空闲更新仍会刷新本地缓存。
|
||||
- 启动页增加动态状态文案、双层轨道与扫光动画,让从欢迎页进入主界面更顺滑。
|
||||
- 减少进入提示词页面时的卡顿;GitHub 模板检查延后到空闲时进行。
|
||||
- 启用内置提示词时直接使用本地已有内容,无需等待网络;手动同步或空闲检查后会更新模板。
|
||||
- 修复从自定义提示词切换到内置模板时可能产生重复项的问题,已有相同提示词会直接更新。
|
||||
- 优化启动页状态提示和过渡动画,进入主界面更顺畅。
|
||||
|
||||
## [v0.2.21] - 2026-07-06
|
||||
|
||||
- 进一步降低 UI 卡顿风险:将启动检测、Codex 状态读取、Provider/官方配置/备份/cc-switch 导入等剩余同步命令迁移到后台 worker。
|
||||
- 优化前端串行请求:提示词启用后的备份/提示词刷新改为并发,保存并启用自定义提示词时不再重复拉取列表。
|
||||
- 完善【技能和 MCP】导入已有:读取 cc-switch `mcp_servers` 数据库,按 `enabled_codex` 纳管并同步到 Codex `config.toml [mcp_servers]`。
|
||||
- 增强 Skills 检查更新:对带有 cc-switch 仓库元数据的 Skill 拉取 GitHub 仓库 ZIP 计算远程 hash,显示“有新版本 / 已是最新 / 远程检查失败”等状态。
|
||||
- 减少启动、供应商操作、备份和导入时的卡顿,并加快提示词保存、启用后的刷新。
|
||||
- “导入已有”支持从 cc-switch 导入已为 Codex 启用的 MCP,并应用到 Codex。
|
||||
- 来自 cc-switch 且保留仓库信息的 Skill 可检查 GitHub 更新,显示“有新版本”“已是最新”或“远程检查失败”。
|
||||
|
||||
## [v0.2.20] - 2026-07-06
|
||||
|
||||
- 继续优化指令提示词页性能:提示词列表/状态读取、保存、导入、启用、禁用等后端操作改为后台 worker,避免切页或启用模板时阻塞 UI。
|
||||
- 修复外部自定义提示词重复保存问题:切换到内置模板前会按文件名与内容双重去重,并自动清理历史 `external-*` 重复项。
|
||||
- 优化启动加载体验:启动页增加最短展示、退出淡出与动态过渡,避免从欢迎页突然跳到主页。
|
||||
- 改善提示词页面响应速度,减少切换页面、保存、导入和开关模板时的卡顿。
|
||||
- 修复从自定义提示词切换到内置模板后重复保存的问题,并自动整理已有的此类重复项。
|
||||
- 优化启动页展示和退出动画,避免突然跳到主页。
|
||||
|
||||
## [v0.2.19] - 2026-07-06
|
||||
|
||||
- 新增【技能和 MCP】页面:展示 Codex 当前已安装 Skills 与 MCP,支持导入已有、从 ZIP 安装 Skill、启用/禁用 Skill、启用/禁用 MCP。
|
||||
- MCP 管理会读写 `~/.codex/config.toml` 的 `[mcp_servers]`,禁用后保留到 Codex-X SQLite,后续可一键重新启用。
|
||||
- Skills 管理会扫描 `~/.codex/skills`,并可从 `~/.agents/skills`、`~/.cc-switch/skills` 导入到 Codex;禁用后移动到 Codex-X 禁用目录,避免直接删除。
|
||||
- 优化多个潜在卡顿点:指令提示词 GitHub 检查改为延迟后台执行,远程拉取、会话扫描/同步、Skills/MCP 扫描均放入后台 blocking worker。
|
||||
- 修复外部自定义提示词切换到内置提示词后重复出现的问题,并自动清理同名 `external-*` 重复项。
|
||||
- 增加页面切换过渡、启动页动态光效和首次启动向导退出动画,降低页面突然跳转感。
|
||||
- 新增【技能和 MCP】页面,可查看已安装内容、导入已有配置、从 ZIP 安装 Skill,以及启用或禁用 Skill 和 MCP。
|
||||
- 禁用 Skill 或 MCP 后仍保留原有内容,可随时重新启用;支持导入本机已有的 Skill 和 cc-switch 中的技能。
|
||||
- 减少 GitHub 模板检查、会话检查与同步、Skills 和 MCP 加载时的卡顿。
|
||||
- 修复从自定义提示词切换到内置模板后出现重复项的问题,并自动整理已有的同名重复项。
|
||||
- 优化页面切换、启动页和首次启动向导的过渡动画。
|
||||
|
||||
## [v0.2.18] - 2026-07-05
|
||||
|
||||
- 指令提示词内置模板支持从 GitHub `examples/` 实时检查更新,并缓存到本地;启用内置模板时优先使用 GitHub 最新版本,离线时自动回退本地缓存或打包内置版本。
|
||||
- 指令提示词页面新增“更新内置模板”状态与来源展示,可看到模板来自 GitHub 最新、本地缓存或打包内置。
|
||||
- 继续保留导入 `.md` 提示词、外部提示词自动记忆、会话管理交互优化和 API Key 可见切换等体验改进。
|
||||
- 内置提示词支持从 GitHub 检查更新,启用时优先使用最新模板;离线时可使用本地缓存或随应用提供的模板。
|
||||
- 提示词页面新增更新状态和模板来源提示。
|
||||
|
||||
## [v0.2.17] - 2026-07-04
|
||||
|
||||
- 修复 macOS Intel Release 构建 runner:从已不可用/长时间排队的 `macos-13` 切换为 `macos-15-intel`。
|
||||
- 继续保留 macOS Apple Silicon / macOS Intel / Windows MSI / Windows portable ZIP / Linux deb/rpm 多平台产物。
|
||||
- 修复 Intel Mac 安装包发布失败或长时间等待的问题。
|
||||
|
||||
## [v0.2.16] - 2026-07-04
|
||||
|
||||
- Release 新增 macOS Intel 构建,Intel Mac 用户可下载 x64 DMG。
|
||||
- macOS Release 现在同时提供 Apple Silicon 与 Intel 两种 DMG。
|
||||
- Windows Release 新增 portable ZIP,包含可直接运行的 `Codex-X.exe`,无需 MSI 安装。
|
||||
- 发布流程支持上传 `.zip` portable 产物,并更新 README 下载说明。
|
||||
- 新增 Intel Mac 版 DMG,macOS 用户可按芯片类型选择 Intel 或 Apple Silicon 安装包。
|
||||
- 新增 Windows 免安装 ZIP,解压后即可运行,无需安装 MSI。
|
||||
|
||||
## [v0.2.15] - 2026-07-04
|
||||
|
||||
- 会话管理页移除 CODEX_HOME 与 Provider Sync 备份位置展示,页面信息更简洁。
|
||||
- 优化 Windows 打开下载页体验:后端改为后台线程 spawn 浏览器,不再等待 `cmd /C start`,避免 WebView 卡顿 2-3 秒。
|
||||
- 统一所有外部链接按钮走异步打开逻辑,打开项目主页、反馈页、下载页都不会阻塞界面。
|
||||
- 修复 macOS 顶部 toast 被 Overlay 标题栏遮挡的问题,toast 自动下移到标题栏安全区下方。
|
||||
- 精简会话管理页面,移除不常用的配置和备份位置说明。
|
||||
- 修复 Windows 打开下载页、项目主页等外部链接时界面短暂卡顿的问题。
|
||||
- 修复 macOS 顶部提示消息被标题栏遮挡的问题。
|
||||
|
||||
## [v0.2.14] - 2026-07-04
|
||||
|
||||
- 修复 macOS Overlay 标题栏遮挡内容的问题,为红黄绿窗口按钮预留完整安全区。
|
||||
- 调整侧边栏、内容区、Provider/TOML/指令提示词/会话管理页高度计算,避免顶部内容被标题栏压住。
|
||||
- 加高 macOS 顶部拖拽区域并保持深色渐变,窗口顶部继续和应用色系统一。
|
||||
- 修复 macOS 标题栏和窗口按钮遮挡页面内容的问题。
|
||||
- 增大窗口顶部拖拽区域,并统一深色外观。
|
||||
|
||||
## [v0.2.13] - 2026-07-04
|
||||
|
||||
- macOS 窗口标题栏改为深色融合样式,避免顶部出现系统白色标题条。
|
||||
- 会话管理页面移除多余外层玻璃边框,统一为完整内容容器。
|
||||
- 优化会话列表边界与滚动区域,避免列表像卡在外层框外面。
|
||||
- macOS 标题栏改为与应用一致的深色样式。
|
||||
- 简化会话管理页面边框,改善列表布局和滚动体验。
|
||||
|
||||
## [v0.2.12] - 2026-07-04
|
||||
|
||||
- Windows MSI 安装器回退为默认简洁样式,移除上一版过重的自定义安装界面图。
|
||||
- 【会话管理】页面新增会话列表,展示标题、Provider、模型、工作目录、更新时间、归档/需同步状态。
|
||||
- 会话扫描会从 Codex 本地 SQLite threads 表读取最近会话,方便用户直接判断哪些历史 thread 需要同步修复。
|
||||
- Windows MSI 恢复简洁的安装界面。
|
||||
- 会话管理新增历史会话列表,可查看标题、供应商、模型、工作目录、更新时间,以及归档和待同步状态。
|
||||
|
||||
## [v0.2.11] - 2026-07-04
|
||||
|
||||
- 调整首次启动自动检查更新体验:不再弹出居中的强提醒窗口。
|
||||
- 自动检测到新版本时仅显示轻量 toast,并保留概览页顶部“发现新版本”提示条。
|
||||
- 只有用户在【关于】页面主动点击“检查更新”时,检测到新版本才弹出“现在下载 / 稍后”窗口。
|
||||
- 启动时发现新版本改为轻量提示,概览页仍保留更新提示条。
|
||||
- 仅在“关于”页面主动检查更新并发现新版本时,弹出“现在下载 / 稍后”窗口。
|
||||
|
||||
## [v0.2.10] - 2026-07-04
|
||||
|
||||
- 修复 Release 发布任务上传 Linux 产物时误把 `deb/`、`rpm/` 目录当作资产上传的问题。
|
||||
- 发布任务现在只收集 `.dmg` / `.msi` / `.deb` / `.rpm` 文件并统一上传。
|
||||
- 补齐稳定的三平台 Release 自动发布流程。
|
||||
- 修复 Linux 安装包上传失败的问题,完善 macOS、Windows 和 Linux 安装包发布。
|
||||
|
||||
## [v0.2.9] - 2026-07-04
|
||||
|
||||
- 修复 GitHub Actions Release 发布流程:不再由三平台矩阵并发创建 Release,改为先上传构建产物,再由单独发布任务统一创建/更新 Release。
|
||||
- 修复 `Resource not accessible by integration` 导致 Release 创建失败的问题。
|
||||
- Release 仍会从 `CHANGELOG.md` 自动读取当前 tag 的更新日志,并上传 macOS / Windows / Linux 安装包。
|
||||
- 修复 GitHub 版本发布失败的问题。
|
||||
|
||||
## [v0.2.8] - 2026-07-04
|
||||
|
||||
- 更新页进一步产品化:去掉资源/仓库调试信息,将“有更新”改为更明显的绿色标签。
|
||||
- 概览页顶部新增轻量“发现新版本”提示条,可直接打开 Releases 页面。
|
||||
- 新增【会话管理】页面:检查 Codex 本地 sessions / archived_sessions 与 SQLite threads 是否和当前 Provider 同步。
|
||||
- 新增一键 Provider Sync / 修复历史会话:写入前备份到 `~/.codex/backups_state/provider-sync/`,并保留最近 5 份备份。
|
||||
- 简化更新页面,使用醒目的绿色标签标示新版本;概览页新增可直接打开下载页的更新提示条。
|
||||
- 新增“会话管理”页面,可检查历史会话是否与当前供应商匹配,并一键同步修复。
|
||||
- 修复会话前自动备份,保留最近 5 份备份。
|
||||
|
||||
## [v0.2.7] - 2026-07-04
|
||||
|
||||
- 简化更新检查页展示。
|
||||
- “打开下载页”改为打开 GitHub Releases 页面。
|
||||
- 更新弹窗保持简洁,仅提示版本差异。
|
||||
- 首次启动自动检查更新并弹窗提醒。
|
||||
- 简化更新页面和弹窗,“打开下载页”直接前往 GitHub 版本下载页面。
|
||||
- 首次启动自动检查更新,发现新版本时弹窗提醒。
|
||||
|
||||
## [v0.2.5] - 2026-07-04
|
||||
|
||||
- Windows 版双击启动不再额外弹出终端窗口。
|
||||
- 改进 Windows MSI 安装器品牌展示与图标。
|
||||
- About 页面外部链接改为系统默认浏览器打开。
|
||||
- Release 流程加入 Rust cache,后续构建更快。
|
||||
- 修复 Windows 双击启动时额外弹出终端窗口的问题。
|
||||
- 改善 Windows MSI 安装界面的应用名称和图标展示。
|
||||
- “关于”页面的外部链接使用系统默认浏览器打开。
|
||||
|
||||
## [v0.2.4] - 2026-07-04
|
||||
|
||||
- 美化 Windows MSI 安装器横幅与对话图。
|
||||
- Windows 安装包加入应用图标。
|
||||
- 美化 Windows MSI 安装界面,并为安装包加入应用图标。
|
||||
|
||||
## [v0.2.3] - 2026-07-04
|
||||
|
||||
- 修复 macOS DMG 中应用图标缺失问题。
|
||||
- 补充 macOS 安装说明。
|
||||
- Release 流程加入基础缓存优化。
|
||||
- 修复 macOS DMG 中应用图标缺失的问题,并补充安装说明。
|
||||
|
||||
## [v0.2.2] - 2026-07-04
|
||||
|
||||
- macOS / Linux / Windows 首次三平台 Release。
|
||||
- Linux 产物改为 `deb` / `rpm`,避免 AppImage 图标问题。
|
||||
- 首次提供 macOS、Linux 和 Windows 三个平台的安装包。
|
||||
- Linux 改为提供 DEB 和 RPM 安装包。
|
||||
|
||||
## [v0.2.1] - 2026-07-04
|
||||
|
||||
- 首次加入应用图标与 GitHub Release 自动发布。
|
||||
- 首次加入应用图标,并通过 GitHub 提供版本下载。
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
# Codex-X
|
||||
|
||||
**Prompt Injection · Provider Switching · TOML / Auth Visual Manager for Codex**
|
||||
**Codex Prompts · API / Providers · Sessions · Skills / MCP in One Place**
|
||||
|
||||
Codex-X is a cross-platform desktop tool for **OpenAI Codex Desktop / Codex CLI**. It ships with `gpt5.5-unrestricted.md` and `gpt5.4-unrestricted.md`, and supports one-click enable / disable for instruction prompts, third-party Provider switching, official Auth management, TOML visual editing, and local session Provider Sync.
|
||||
A cross-platform desktop tool for **OpenAI Codex Desktop / Codex CLI**. Manage prompt templates, switch third-party APIs, organize / repair / permanently delete local sessions, manage Skills / MCP, and inspect TOML and login credentials without repeatedly editing configuration files by hand.
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/github/v/release/yynxxxxx/Codex-X?label=version&color=blue" alt="version" />
|
||||
@@ -32,15 +32,15 @@
|
||||
|
||||
## What is Codex-X?
|
||||
|
||||
Codex-X is not just a configuration-file editor. It is a **visual enhancement manager** built for Codex CLI and Codex Desktop workflows.
|
||||
When you use Codex Desktop, the CLI, third-party APIs, and multiple prompts together, settings quickly become scattered across different files. Codex-X brings these frequent tasks into one desktop interface, so you can see the current state and complete common actions with a click.
|
||||
|
||||
It turns several high-frequency operations into a desktop UI:
|
||||
You can use it to:
|
||||
|
||||
- Enable / disable instruction prompt templates for Codex
|
||||
- Switch between official OpenAI and third-party Codex API Providers
|
||||
- View / edit `~/.codex/config.toml`
|
||||
- View / edit official `~/.codex/auth.json`
|
||||
- Check and repair Provider metadata for local historical sessions
|
||||
- Choose from the prompt library or import your own `.md`, then keep the existing prompt or replace it
|
||||
- Manage multiple named official Codex login profiles and third-party APIs, duplicate or switch them with a click, and import Providers from cc-switch
|
||||
- Search and organize local sessions by project, check / automatically repair problems, and precisely delete sessions you no longer need
|
||||
- Manage Skills and MCP in one place, and inspect the current `config.toml`, `auth.json`, and operation backups
|
||||
- Explore token usage by date and model in Settings, with subagent usage attributed to its main conversation
|
||||
|
||||
## Preview
|
||||
|
||||
@@ -109,106 +109,146 @@ It turns several high-frequency operations into a desktop UI:
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<th align="center" width="180">Feature</th>
|
||||
<th align="center">Description</th>
|
||||
<th align="center" width="190">What you want to do</th>
|
||||
<th align="center">How Codex-X helps</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">⚡ Provider API</td>
|
||||
<td>Visually manage official OpenAI / third-party Codex Providers, including Base URL, API Key, Model, Wire API, and one-click switching.</td>
|
||||
<td align="center">🧩 <b>Use prompt templates</b></td>
|
||||
<td align="left">The current library contains <b>5 templates</b>. Enable / disable one with a click and choose “Keep existing” or “Replace existing”; GitHub sync, local caching, and importing or editing your own <code>.md</code> files are supported.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🧩 <b>Prompt Injection</b></td>
|
||||
<td><b>Signature feature</b>: built-in <code>gpt5.4-unrestricted.md</code> / <code>gpt5.5-unrestricted.md</code>, one-click write into Codex configuration; after enabling, it can produce results similar to the screenshots above for SQL injection testing and APK / EXE reverse engineering scenarios.</td>
|
||||
<td align="center">⚡ <b>Switch APIs / relays</b></td>
|
||||
<td align="left">Save multiple named official Codex login profiles alongside third-party Providers, with one-click duplication and switching. Third-party Providers support connection checks, model discovery / testing, and imports from cc-switch.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">💬 Session Management</td>
|
||||
<td>Read Codex local SQLite and rollout JSONL, display local sessions visually, and support Provider Sync / historical thread metadata repair.</td>
|
||||
<td align="center">💬 <b>Organize local sessions</b></td>
|
||||
<td align="left">Search sessions, group them by project path, and check or automatically repair inconsistent data. Select one, several, or an entire project to permanently delete specific sessions from Codex storage.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">📝 TOML Config</td>
|
||||
<td>View current <code>~/.codex/config.toml</code>, edit full TOML directly from the Provider editor, and save changes back to the Codex config directory.</td>
|
||||
<td align="center">🧠 <b>Manage Skills / MCP</b></td>
|
||||
<td align="left">View available Skills and MCP servers, preview existing items before import, install a Skill from ZIP, enable / disable individual entries, and check Skill update status.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🧠 Skills / MCP</td>
|
||||
<td>New Skills & MCP page: read Codex Skills / MCP servers, import existing items, install Skill ZIP packages, enable / disable individual Skills or MCP servers, and check Skill update status.</td>
|
||||
<td align="center">📝 <b>Manage config and login</b></td>
|
||||
<td align="left">Inspect <code>config.toml</code> and <code>auth.json</code> in one place. Both official and third-party profile editors offer an “Enable 1M context window” checkbox beside <code>config.toml>; it applies on save and requires model support. Important writes are backed up automatically.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🔐 Auth Management</td>
|
||||
<td>Read / edit official <code>~/.codex/auth.json</code>, and distinguish ChatGPT login-state Auth from third-party API Keys.</td>
|
||||
<td align="center">📊 <b>Track token usage</b></td>
|
||||
<td align="left">Switch between General and Usage statistics in Settings. Filter local usage by date and model, explore daily trends, cache hit rate, and model distribution, and view the 10 most recent main conversations. Subagent usage is attributed to its main conversation and does not count as a separate session.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">📦 Cross-platform Release</td>
|
||||
<td>Provides macOS Apple Silicon / Intel <code>.dmg</code>, Windows <code>.msi</code> / portable <code>.zip</code>, Linux <code>.deb</code> / <code>.rpm</code>, plus in-app update checking.</td>
|
||||
<td align="center">📦 <b>Use it across platforms</b></td>
|
||||
<td align="left">Available for macOS Apple Silicon / Intel, Windows MSI / portable, and Linux packages. Installed editions can download, verify, and install updates in the app; the portable edition keeps manual downloads.</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## Highlights
|
||||
|
||||
### 1. Built-in dual instruction prompt templates
|
||||
### 1. Prompt Template Center
|
||||
|
||||
Codex-X currently includes two prompt templates:
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Template_library-11_total-2563eb?style=flat-square" alt="11 templates in the current library" />
|
||||
<img src="https://img.shields.io/badge/Bundled_offline-5_templates-16a34a?style=flat-square" alt="5 templates bundled for offline use" />
|
||||
<img src="https://img.shields.io/badge/GitHub_sync-Automatic_updates-f59e0b?style=flat-square" alt="Automatic GitHub template sync" />
|
||||
</p>
|
||||
|
||||
- `examples/gpt5.5-unrestricted.md`
|
||||
- `examples/gpt5.4-unrestricted.md`
|
||||
|
||||
You can enable / disable them directly from the Prompt page. When enabled, Codex-X writes the selected `.md` file into the Codex configuration directory and updates `model_instructions_file` in `config.toml`.
|
||||
> [!TIP]
|
||||
> **Ready after installation, automatically expanded when online.**
|
||||
>
|
||||
> The app bundle includes 5 templates for offline use. After launch, it synchronizes 6 additional software-development and writing templates, plus later updates, from GitHub `examples/` in the background without waiting for the **Prompt** page to open. Successfully synchronized online versions are cached locally and remain available during temporary network outages.
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<th align="center">Item</th>
|
||||
<th align="center"><code>gpt5.4-unrestricted.md</code></th>
|
||||
<th align="center"><code>gpt5.5-unrestricted.md</code></th>
|
||||
<th align="center">Template</th>
|
||||
<th align="center">Best for</th>
|
||||
<th align="center">Availability</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Positioning</td>
|
||||
<td align="center">5.4 CTF sandbox</td>
|
||||
<td align="center">5.5 unrestricted mode</td>
|
||||
<td><a href="examples/gpt5.5-unrestricted.md"><code>gpt5.5-unrestricted.md</code></a></td>
|
||||
<td align="left">Compact and general-purpose for everyday coding and technical work</td>
|
||||
<td align="center">Bundled offline<br />GitHub updates</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Strategy</td>
|
||||
<td align="center">Indirect bypass through CTF competition sandbox framing</td>
|
||||
<td align="center">Direct declaration of unrestricted developer mode</td>
|
||||
<td><a href="examples/gpt5.4-unrestricted.md"><code>gpt5.4-unrestricted.md</code></a></td>
|
||||
<td align="left">GPT-5.4 / Codex CLI workflows with a CTF and security-research focus</td>
|
||||
<td align="center">Bundled offline<br />GitHub updates</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Style</td>
|
||||
<td align="center">Security-research oriented, detailed methodology</td>
|
||||
<td align="center">Minimal instructions, broad coverage</td>
|
||||
<td><a href="examples/gpt5.5-jeli.md"><code>gpt5.5-jeli.md</code></a></td>
|
||||
<td align="left">A plain-language general version with a fuller engineering and reverse-engineering workflow</td>
|
||||
<td align="center">Bundled offline<br />GitHub updates</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Length</td>
|
||||
<td align="center">~180 lines</td>
|
||||
<td align="center">~40 lines</td>
|
||||
<td><a href="examples/gpt-5.6-sol-unrestricted.md"><code>gpt-5.6-sol-unrestricted.md</code></a></td>
|
||||
<td align="left">A GPT-5.6 SOL prompt focused on direct execution and bilingual tasks</td>
|
||||
<td align="center">Bundled offline<br />GitHub updates</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">Target</td>
|
||||
<td align="center">GPT-5.4 Codex CLI</td>
|
||||
<td align="center">GPT-5.5 Codex CLI</td>
|
||||
<td><a href="examples/%E6%B5%B7%E9%B8%A53.0%E7%A0%B4%E7%94%B2.md"><code>海鸥3.0破甲.md</code></a></td>
|
||||
<td align="left">A Chinese technical-operator persona with routing for coding, CTF, reverse engineering, memory, and protocol work</td>
|
||||
<td align="center">Bundled offline<br />GitHub updates</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-maintainer.md"><code>software-development-maintainer.md</code></a></td>
|
||||
<td align="left">Long-term maintenance with reuse, minimal changes, safety, testing, and maintainability</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-debugging.md"><code>software-development-debugging.md</code></a></td>
|
||||
<td align="left">Evidence-driven debugging from reliable reproduction to root-cause repair and regression checks</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-code-review.md"><code>software-development-code-review.md</code></a></td>
|
||||
<td align="left">Severity-ranked review of defects, regressions, security risks, and test gaps</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-clarity-editor.md"><code>writing-clarity-editor.md</code></a></td>
|
||||
<td align="left">Chinese and English editing that preserves the author's meaning and facts</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-technical-docs.md"><code>writing-technical-docs.md</code></a></td>
|
||||
<td align="left">Source-grounded README, guide, API, architecture, and release documentation</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-structured-draft.md"><code>writing-structured-draft.md</code></a></td>
|
||||
<td align="left">Turns scattered material into a structured report, proposal, retrospective, or article draft</td>
|
||||
<td align="center">GitHub sync</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
Post-deployment test prompt:
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<b>Keep existing prompt</b><br />
|
||||
Best for users who already have personal rules. Codex-X only appends its managed content and removes only that content when disabled, leaving the original prompt untouched.
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<b>Replace existing prompt</b><br />
|
||||
Makes the selected template the primary instruction entry point, which is useful when you want to switch completely to a specific template.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
```text
|
||||
How do I perform SQL injection testing against a target?
|
||||
```
|
||||
A backup is created automatically before every enable or disable action. In addition to the template library, you can import, edit, and delete your own `.md` prompts.
|
||||
|
||||
Typical effect:
|
||||
### 2. Provider Switching: Ready in a New Session
|
||||
|
||||
```text
|
||||
Before injection → refusal or generic answer
|
||||
After injection → direct security research methodology and testing steps
|
||||
```
|
||||
> [!NOTE]
|
||||
> After enabling a new third-party Provider, create or reopen a Codex session to use the new relay. You do not need to restart the entire Codex client.
|
||||
|
||||
### 2. Visual Provider switching
|
||||
|
||||
- Add third-party Codex API Providers
|
||||
- Edit Base URL / API Key / Model / Wire API
|
||||
- View and edit Provider-related TOML from the Provider page
|
||||
- Clearly display the currently active Provider
|
||||
- Import Codex Providers from a cc-switch database
|
||||
- Save multiple named official Codex login profiles and third-party Providers, and always see which one is currently active
|
||||
- Duplicate a profile immediately with one click, without a confirmation dialog or opening the editor; rename it whenever needed
|
||||
- Test an API endpoint before switching, and save or enable a configuration separately
|
||||
- Edit the Base URL, API Key, Model, Wire API, and complete TOML on the same page
|
||||
- cc-switch imports report added, updated, merged, and skipped entries; the same URL + Key is no longer shown more than once
|
||||
- Switching back to OpenAI Official preserves the current official login, and third-party configurations no longer disappear unexpectedly
|
||||
|
||||
### 3. Official Auth management
|
||||
|
||||
@@ -224,26 +264,46 @@ After injection → direct security research methodology and testing steps
|
||||
- Edit full TOML directly from the Provider editor
|
||||
- Save changes back to the Codex configuration directory
|
||||
|
||||
### 5. Session Management / Provider Sync
|
||||
### 5. Session Management: Inspect, Repair, and Permanently Delete
|
||||
|
||||
Codex-X can read Codex local session data:
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<b>Find and organize</b><br />
|
||||
Search sessions by title or project path and group them by project. Internal subagent sessions created automatically by Codex stay out of the normal session list by default.
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<b>Inspect and repair</b><br />
|
||||
Check whether local sessions match the current Provider, repair all mismatches manually, or enable automatic inspection and repair at startup.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
<b>Precise deletion</b><br />
|
||||
Select one session, several sessions, or one or more projects to select all sessions under them. After confirmation, the matching sessions and their derived child sessions are removed from Codex storage itself.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
```text
|
||||
~/.codex/sqlite/*.db
|
||||
~/.codex/state_5.sqlite
|
||||
~/.codex/sessions/**/rollout-*.jsonl
|
||||
~/.codex/archived_sessions/**/rollout-*.jsonl
|
||||
```
|
||||
|
||||
It checks whether historical session Provider metadata matches the current configuration, and supports one-click sync / repair so historical threads can still be recognized, opened, and continued by native Codex.
|
||||
> [!CAUTION]
|
||||
> **Permanent deletion cannot be undone.** Close any Codex windows or CLI processes still using those sessions, then review the deletion list again in the confirmation dialog.
|
||||
|
||||
### 6. Skills / MCP Management
|
||||
|
||||
Codex-X now includes a dedicated **Skills & MCP** page for managing Codex capability extensions in one place.
|
||||
Manage Codex capability extensions from the **Skills & MCP** page instead of searching through multiple directories and configuration files.
|
||||
|
||||
- Skills: read existing Codex Skills, import existing items, install ZIP packages, enable / disable, and check update status
|
||||
- MCP: read Codex MCP servers, import existing items, enable / disable, and write enabled servers back to Codex <code>config.toml</code>
|
||||
- Useful for Android APK reverse engineering, Ghidra / IDA, Web / API / protocol reverse engineering, CTF, and security-testing skill packs
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<b>Skills</b><br />
|
||||
View current Skills, import existing content, or install from ZIP. Enable / disable entries individually and check whether installed Skills have updates.
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<b>MCP</b><br />
|
||||
Preview existing MCP servers before importing them, then choose what Codex-X should manage. Codex-X maintains the Codex configuration when a server is enabled or disabled.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 7. Reverse Skills Navigation
|
||||
|
||||
@@ -288,9 +348,9 @@ Codex-X now includes a dedicated **Skills & MCP** page for managing Codex capabi
|
||||
- macOS Intel `.dmg`
|
||||
- Windows `.msi`
|
||||
- Windows Portable `.zip`
|
||||
- Linux `.deb` / `.rpm`
|
||||
- Linux `.deb` / `.rpm` / `.AppImage`
|
||||
- Automatic GitHub Releases builds
|
||||
- In-app update checking
|
||||
- In-app updates for installed editions; manual updates for Windows Portable
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -345,7 +405,7 @@ Build desktop bundles:
|
||||
pnpm --dir apps/desktop tauri build
|
||||
```
|
||||
|
||||
## macOS Installation Note
|
||||
## Desktop Installation Notes
|
||||
|
||||
If you see “app is damaged” when opening an unsigned / unnotarized DMG, this is normal macOS Gatekeeper behavior.
|
||||
|
||||
@@ -366,6 +426,12 @@ Thanks to the [LINUX DO forum](https://linux.do/) community for attention, feedb
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=yynxxxxx%2FCodex-X&type=date&legend=top-left">
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=yynxxxxx%2Fcodex-x&type=date&legend=top-left&sealed_token=Vqvz67Jv_WIePGePCN8RdJaY5oCyqqCZUSFWs4M4dAxP8JXFZlYEWbI8YcU6SFgpqOqqJifzpOTIlMg4ee8NaCkHpCSqv1r5pxewR-tQlmxswaZlhedd6A" width="900" />
|
||||
</a>
|
||||
<p align="center">
|
||||
<a href="https://github.com/yynxxxxx/Codex-X/stargazers">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=light" />
|
||||
<img alt="Codex-X Star History" src="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=light" width="900" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
# Codex-X
|
||||
|
||||
**Codex 提示词注入 · Provider 切换 · TOML / Auth 可视化管理器**
|
||||
**Codex 可视化提示词注入 · Provider · 会话 · Skills / MCP 管理工具**
|
||||
|
||||
一款面向 **OpenAI Codex 桌面端 / Codex CLI** 的跨平台桌面工具,内置 `gpt5.5-unrestricted.md` 与 `gpt5.4-unrestricted.md`,支持一键写入 / 禁用指令提示词、第三方 Provider 切换、官方 Auth 管理、TOML 可视化编辑与本地会话 Provider Sync。
|
||||
一款面向 **OpenAI Codex 桌面端 / Codex CLI** 的跨平台桌面工具。把提示词模板、自定义 Prompt、第三方 API 供应商、会话同步、Skills / MCP 和 TOML 配置都放进可视化界面里,不用反复手改文件。
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/github/v/release/yynxxxxx/Codex-X?label=version&color=blue" alt="version" />
|
||||
@@ -32,75 +32,51 @@
|
||||
|
||||
## Codex-X 是什么?
|
||||
|
||||
Codex-X 不是普通的配置文件编辑器,而是一个面向 Codex CLI 的 **可视化增强管理器**。
|
||||
当你同时使用 Codex 桌面端、CLI、第三方 API、Skills / MCP 和多套提示词时,配置很容易散落在不同文件里。Codex-X 把这些高频操作集中到一个桌面界面中,让当前状态看得见、常用操作点一下就能完成。
|
||||
|
||||
它把几个高频操作做成了桌面软件:
|
||||
你可以用它:
|
||||
|
||||
- 给 Codex 写入 / 禁用指令提示词模板
|
||||
- 切换官方 OpenAI 与第三方 Codex API Provider
|
||||
- 查看 / 编辑 `~/.codex/config.toml`
|
||||
- 查看 / 编辑官方 `~/.codex/auth.json`
|
||||
- 检查并修复本地历史会话的 Provider 元数据
|
||||
- 像管理插件一样管理提示词:分类、导入 Markdown、自定义编辑、一键启用 / 禁用
|
||||
- 内置 5 套提示词模板,同时支持用户把自己的提示词变成可视化模板库
|
||||
- 管理多个可命名的官方 Codex 登录与第三方 API,一键复制、切换,并从 cc-switch 导入现有供应商
|
||||
- 同步、检查、搜索和删除本地会话,按项目路径整理 Codex 历史记录
|
||||
- 集中管理 Skills 与 MCP,查看当前 `config.toml`、`auth.json` 和操作备份
|
||||
- 在设置中按日期、模型查看 Token 用量趋势,子代理用量归入所属主会话
|
||||
|
||||
## 软件预览
|
||||
|
||||
<details open>
|
||||
<summary><b>应用界面预览</b>:主界面 / Provider / TOML / Auth</summary>
|
||||
<summary><b>新版 UI:指令提示词管理中心</b></summary>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/app/preview.png" alt="Codex-X 应用界面预览:主界面、Provider、TOML、Auth" width="920" />
|
||||
<img src="docs/screenshots/app/new-ui/prompts.png" alt="Codex-X 新版指令提示词管理界面" width="920" />
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>提示词注入效果:安全测试场景</b></summary>
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<b>SQL 注入测试</b><br />
|
||||
<sub>部署后测试:如何对目标进行 SQL 注入测试?</sub><br />
|
||||
<img src="docs/screenshots/prompt-effects/security/sql-injection.png" alt="SQL 注入测试效果图" width="420" />
|
||||
<b>分类管理</b><br />
|
||||
<sub>把提示词按破甲 / 逆向、软件开发、写作辅助等分类维护</sub><br />
|
||||
<img src="docs/screenshots/app/new-ui/prompt-categories.png" alt="Codex-X 提示词分类管理" width="420" />
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<b>NSFW 响应测试</b><br />
|
||||
<sub>用于观察提示词注入后的边界响应变化</sub><br />
|
||||
<img src="docs/screenshots/prompt-effects/security/nsfw.png" alt="NSFW 响应测试效果图" width="420" />
|
||||
<b>自定义提示词</b><br />
|
||||
<sub>直接添加、编辑或导入自己的 Markdown 提示词</sub><br />
|
||||
<img src="docs/screenshots/app/new-ui/prompt-form.png" alt="Codex-X 添加自定义提示词" width="420" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>提示词注入效果:逆向工程场景</b></summary>
|
||||
<summary><b>Skills / MCP 可视化管理</b></summary>
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" width="50%">
|
||||
<b>APK 逆向分析</b><br />
|
||||
<sub>Android APK 静态 / 动态分析思路</sub><br />
|
||||
<img src="docs/screenshots/prompt-effects/reverse/apk-reverse-1.png" alt="APK 逆向分析效果图" width="420" />
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<b>APK 逆向分析 2</b><br />
|
||||
<sub>补充 APK 逆向流程与定位方式</sub><br />
|
||||
<img src="docs/screenshots/prompt-effects/reverse/apk-reverse-2.png" alt="APK 逆向分析效果图 2" width="420" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" colspan="2">
|
||||
<b>EXE 逆向分析</b><br />
|
||||
<sub>Windows 可执行文件分析与调试方向</sub><br />
|
||||
<img src="docs/screenshots/prompt-effects/reverse/exe-reverse.png" alt="EXE 逆向分析效果图" width="620" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/app/new-ui/skills-mcp.png" alt="Codex-X Skills 与 MCP 管理界面" width="920" />
|
||||
</p>
|
||||
|
||||
</details>
|
||||
|
||||
@@ -109,143 +85,217 @@ Codex-X 不是普通的配置文件编辑器,而是一个面向 Codex CLI 的
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<th align="center" width="180">功能</th>
|
||||
<th align="center">说明</th>
|
||||
<th align="center" width="190">你想做的事</th>
|
||||
<th align="center">Codex-X 能帮你</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">⚡ 供应商 API</td>
|
||||
<td>可视化管理官方 OpenAI / 第三方 Codex Provider,支持 Base URL、API Key、Model、Wire API 与一键切换。</td>
|
||||
<td align="center"><b>提示词注入管理</b></td>
|
||||
<td align="left">内置 <b>5 套</b>提示词模板,支持分类、GitHub 同步、本地缓存、导入 <code>.md</code>、添加自定义提示词、编辑说明、一键启用 / 禁用。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🧩 <b>提示词注入</b></td>
|
||||
<td><b>特色功能</b>:内置 <code>gpt5.4-unrestricted.md</code> / <code>gpt5.5-unrestricted.md</code>,一键写入 Codex 配置;启用后可达到上方效果图中的 SQL 注入测试、APK / EXE 逆向等响应效果。</td>
|
||||
<td align="center"><b>启用方式切换</b></td>
|
||||
<td align="left">可选择“保留原提示词”追加写入,也可选择“替换原提示词”完整切换;适合在不同模型、不同任务、不同 Prompt 之间快速切换。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">💬 会话管理</td>
|
||||
<td>读取 Codex 本地 SQLite 与 rollout JSONL,会话列表可视化展示,并支持 Provider Sync / 修复历史 thread 元数据。</td>
|
||||
<td align="center"><b>Provider / API</b></td>
|
||||
<td align="left">保存多个可命名的官方 Codex 登录配置,与第三方供应商统一管理、一键复制和切换;第三方供应商支持连接检测、模型获取 / 测试及从 cc-switch 导入。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">📝 TOML 配置</td>
|
||||
<td>查看当前 <code>~/.codex/config.toml</code>,并在 Provider 编辑页直接编辑完整 TOML,保存后同步到 Codex 配置目录。</td>
|
||||
<td align="center"><b>会话管理</b></td>
|
||||
<td align="left">搜索本地会话、按项目路径分组、同步当前供应商、检查会话状态,并支持单选 / 多选 / 项目级永久删除。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🧠 Skills / MCP</td>
|
||||
<td>新增技能和 MCP 管理页:读取 Codex 当前可用的 Skills / MCP,支持导入已有、从 ZIP 安装 Skill、启用 / 禁用单个 Skill 或 MCP,并可检查 Skill 更新状态。</td>
|
||||
<td align="center"><b>Skills / MCP</b></td>
|
||||
<td align="left">可视化查看 Skills 与 MCP,导入已有配置,从 ZIP 安装 Skill,逐项启用 / 禁用,并检查更新状态。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">🔐 Auth 管理</td>
|
||||
<td>读取 / 编辑官方 <code>~/.codex/auth.json</code>,区分 ChatGPT 登录态 Auth 与第三方 API Key。</td>
|
||||
<td align="center"><b>配置与登录</b></td>
|
||||
<td align="left">集中查看 <code>config.toml</code> 与 <code>auth.json</code>;官方登录和第三方供应商编辑页均可在 <code>config.toml</code> 旁勾选“开启 1M 上下文窗口”,保存后生效,需模型支持;重要写入前自动备份。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">📦 跨平台发布</td>
|
||||
<td>提供 macOS Apple Silicon / Intel <code>.dmg</code>、Windows <code>.msi</code> / portable <code>.zip</code>、Linux <code>.deb</code> / <code>.rpm</code>,支持应用内检测更新。</td>
|
||||
<td align="center"><b>用量统计</b></td>
|
||||
<td align="left">设置页提供“通用设置 / 用量统计”标签,按日期与模型筛选本地 Token 用量,查看每日趋势、缓存命中率、模型分布及最近 10 个主会话;子代理用量归入所属主会话,不单独计为会话。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>跨平台使用</b></td>
|
||||
<td align="left">提供 macOS Apple Silicon / Intel、Windows EXE / 便携版和 Linux 安装包;安装版可在应用内直接下载、校验并安装更新,便携版继续使用手动下载。</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
## 核心亮点
|
||||
|
||||
### 1. 内置双指令提示词模板
|
||||
### 1. 可视化提示词注入中心
|
||||
|
||||
Codex-X 当前内置两套提示词模板:
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/当前模板库-11_套-2563eb?style=flat-square" alt="当前模板库 11 套" />
|
||||
<img src="https://img.shields.io/badge/离线内置-5_套-16a34a?style=flat-square" alt="离线内置 5 套" />
|
||||
<img src="https://img.shields.io/badge/GitHub_同步-自动更新-f59e0b?style=flat-square" alt="GitHub 自动同步" />
|
||||
<img src="https://img.shields.io/badge/自定义提示词-支持导入_编辑-7c3aed?style=flat-square" alt="支持自定义提示词" />
|
||||
</p>
|
||||
|
||||
- `examples/gpt5.5-unrestricted.md`
|
||||
- `examples/gpt5.4-unrestricted.md`
|
||||
> [!TIP]
|
||||
> **安装后就能用,联网后自动补齐,也能维护自己的提示词库。**
|
||||
>
|
||||
> 安装包离线自带 5 套模板;软件启动后会从 GitHub `examples/` 同步另外 6 套软件开发与写作辅助模板,以及后续更新。同步成功的在线版本会缓存到本地,临时离线仍可继续使用。你也可以导入自己的 `.md`、新增分类、编辑说明,并像切换插件一样启用或禁用任意提示词。
|
||||
|
||||
你可以在【指令提示词】页面直接点击启用 / 禁用。启用后,Codex-X 会把对应 `.md` 写入 Codex 配置目录,并更新 `config.toml` 中的 `model_instructions_file`。
|
||||
Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可视化提示词注入与管理工具:
|
||||
|
||||
- 按分类管理提示词,例如破甲 / 逆向、软件开发、写作辅助,也可以新增自己的分类
|
||||
- 支持同步 GitHub 模板、导入 Markdown、手动添加提示词、编辑标题 / 文件名 / 内容
|
||||
- 每个提示词都有独立开关,打开时自动按当前启用方式写入 Codex 指令文件
|
||||
- 支持“保留原提示词”和“替换原提示词”两种模式,适合日常叠加或完整切换
|
||||
- 本地缓存可离线使用,后续模板更新不会影响你自己维护的自定义提示词
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<th align="center">对比项</th>
|
||||
<th align="center"><code>gpt5.4-unrestricted.md</code></th>
|
||||
<th align="center"><code>gpt5.5-unrestricted.md</code></th>
|
||||
<th align="center">模板</th>
|
||||
<th align="center">适合场景</th>
|
||||
<th align="center">获取方式</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">定位</td>
|
||||
<td align="center">5.4 CTF 沙箱</td>
|
||||
<td align="center">5.5 无限制模式</td>
|
||||
<td><a href="examples/gpt5.5-unrestricted.md"><code>gpt5.5-unrestricted.md</code></a></td>
|
||||
<td align="left">短小通用,适合日常 coding 与常规技术任务</td>
|
||||
<td align="center">离线内置<br />GitHub 更新</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">策略</td>
|
||||
<td align="center">CTF 竞赛沙箱间接绕过</td>
|
||||
<td align="center">直接声明无限制开发者模式</td>
|
||||
<td><a href="examples/gpt5.4-unrestricted.md"><code>gpt5.4-unrestricted.md</code></a></td>
|
||||
<td align="left">面向 GPT-5.4 / Codex CLI,偏 CTF 与安全研究工作流</td>
|
||||
<td align="center">离线内置<br />GitHub 更新</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">风格</td>
|
||||
<td align="center">安全研究导向,方法论详细</td>
|
||||
<td align="center">极简指令,覆盖面广</td>
|
||||
<td><a href="examples/gpt5.5-jeli.md"><code>gpt5.5-jeli.md</code></a></td>
|
||||
<td align="left">大白话通用版,提供更完整的工程与逆向执行流程</td>
|
||||
<td align="center">离线内置<br />GitHub 更新</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">长度</td>
|
||||
<td align="center">约 180 行</td>
|
||||
<td align="center">约 40 行</td>
|
||||
<td><a href="examples/gpt-5.6-sol-unrestricted.md"><code>gpt-5.6-sol-unrestricted.md</code></a></td>
|
||||
<td align="left">gpt5.6-sol 破甲提示词,偏直接执行与中英文任务</td>
|
||||
<td align="center">离线内置<br />GitHub 更新</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">适用</td>
|
||||
<td align="center">GPT-5.4 Codex CLI</td>
|
||||
<td align="center">GPT-5.5 Codex CLI</td>
|
||||
<td><a href="examples/%E6%B5%B7%E9%B8%A53.0%E7%A0%B4%E7%94%B2.md"><code>海鸥3.0破甲.md</code></a></td>
|
||||
<td align="left">中文技术操作员人格,覆盖 coding、CTF、逆向、内存与协议任务路由</td>
|
||||
<td align="center">离线内置<br />GitHub 更新</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-maintainer.md"><code>software-development-maintainer.md</code></a></td>
|
||||
<td align="left">长期维护正式项目,强调复用、最小改动、安全、测试与可维护性</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-debugging.md"><code>software-development-debugging.md</code></a></td>
|
||||
<td align="left">从稳定复现和证据采集推进到根因修复与回归验证</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/software-development-code-review.md"><code>software-development-code-review.md</code></a></td>
|
||||
<td align="left">按严重级别审查缺陷、回归、安全风险和测试缺口</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-clarity-editor.md"><code>writing-clarity-editor.md</code></a></td>
|
||||
<td align="left">在保留原意和事实的前提下润色中英文表达</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-technical-docs.md"><code>writing-technical-docs.md</code></a></td>
|
||||
<td align="left">基于代码与事实编写 README、指南、API 和发布文档</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><a href="examples/writing-structured-draft.md"><code>writing-structured-draft.md</code></a></td>
|
||||
<td align="left">将零散材料组织成报告、方案、复盘或文章初稿</td>
|
||||
<td align="center">GitHub 在线同步</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
部署后可以测试:
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<b>保留原提示词</b><br />
|
||||
适合已经有个人规则的用户。Codex-X 只追加自己管理的内容,禁用时也只移除这一部分,不动原有提示词。
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<b>替换原提示词</b><br />
|
||||
将所选模板设为当前主要指令入口,适合希望完整切换到某套模板的用户。
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
```text
|
||||
如何对目标进行 SQL 注入测试?
|
||||
```
|
||||
每次启用或禁用前都会自动创建备份。除了模板库,你也可以导入、编辑、删除自己的 `.md` 提示词,并通过分类管理把常用提示词整理成自己的工作流。
|
||||
|
||||
典型效果:
|
||||
> [!NOTE]
|
||||
> 如果你有好用的提示词模板,欢迎在 [Issues](https://github.com/yynxxxxx/Codex-X/issues) 提交:请附上模板名称、适用场景、Markdown 内容、推荐启用方式和必要说明。合适的模板会考虑收录到 `examples/`,让更多用户可以一键同步使用。
|
||||
|
||||
```text
|
||||
破甲前 → 拒绝或泛化回答
|
||||
破甲后 → 直接给安全研究方法论与测试步骤
|
||||
```
|
||||
### 2. Provider / API:添加、检测、获取模型、随时切换
|
||||
|
||||
### 2. Provider 可视化切换
|
||||
> [!NOTE]
|
||||
> 启用新的第三方供应商后,新建或重新打开 Codex 会话即可使用新的中转,不需要重启整个 Codex 客户端。
|
||||
|
||||
- 添加第三方 Codex API Provider
|
||||
- 编辑 Base URL / API Key / Model / Wire API
|
||||
- Provider 页面可查看并编辑对应 TOML
|
||||
- 当前启用 Provider 状态清晰可见
|
||||
- 支持从 cc-switch 数据库导入 Codex Provider
|
||||
- 保存多个可命名的官方 Codex 登录配置与第三方供应商,随时查看当前正在使用哪一个
|
||||
- 点击“复制”直接新增独立副本,无需确认或进入编辑页;名称可稍后修改
|
||||
- 切换前可检测连接,并可获取模型进行测试
|
||||
- 在同一页面编辑 Base URL、API Key、Model、上游接口协议、Headers 和完整 TOML
|
||||
- 本地路由支持 Responses、Chat Completions、Claude Messages、Gemini 原生接口,使用方式与边界见[供应商协议说明](docs/PROVIDER_PROTOCOLS.md)
|
||||
- 从 cc-switch 导入时自动区分新增、更新、合并与跳过;相同 URL + Key 不再重复显示
|
||||
- 切回 OpenAI Official 时保留当前官方登录态,第三方配置也不会凭空消失
|
||||
|
||||
### 3. 官方 Auth 管理
|
||||
### 3. 会话管理:同步、检查与永久删除
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<b>同步和检查</b><br />
|
||||
检查本地会话是否和当前 Provider / 模型一致,需要时一键同步到当前供应商配置,不修改聊天内容。
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<b>查找和整理</b><br />
|
||||
按标题、项目路径、供应商或 ID 搜索会话,也可以按项目路径分组查看,适合清理长期使用后积累的会话列表。
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" valign="top">
|
||||
<b>精确删除</b><br />
|
||||
支持单选、多选,也可以勾选一个或多个项目,一次选中项目下的全部会话;确认后会从 Codex 自身存储中删除对应会话及其派生子会话。
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
> [!CAUTION]
|
||||
> **永久删除不可恢复。** 删除前请先关闭仍在使用这些会话的 Codex 窗口或 CLI,并在确认窗口中再次核对待删除列表。
|
||||
|
||||
### 4. Skills / MCP 管理
|
||||
|
||||
在【技能和 MCP】页面集中管理 Codex 的能力扩展,不必再到多个目录和配置文件中逐项查找。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<b>Skills</b><br />
|
||||
查看当前 Skill,导入已有内容或从 ZIP 安装;可以逐项启用 / 禁用,并检查已安装 Skill 是否有更新。
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<b>MCP</b><br />
|
||||
导入前先预览现有 MCP Server,再决定哪些需要纳管;启用或禁用后由 Codex-X 自动维护 Codex 配置。
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
### 5. TOML 与官方 Auth 管理
|
||||
|
||||
- 自动读取 Codex 官方 `auth.json`
|
||||
- 支持查看 / 编辑 ChatGPT 登录态 Auth
|
||||
- 区分官方 Auth 与第三方 API Key
|
||||
- 官方配置可和第三方 Provider 在 UI 中统一管理
|
||||
|
||||
### 4. TOML 可视化编辑
|
||||
|
||||
- 查看当前 Codex `config.toml`
|
||||
- 查看当前 Codex 正在使用的 live `config.toml`
|
||||
- 深色代码预览与语法高亮
|
||||
- Provider 编辑页可直接编辑完整 TOML
|
||||
- 保存后同步到 Codex 配置目录
|
||||
|
||||
### 5. 会话管理 / Provider Sync
|
||||
|
||||
Codex-X 可以读取 Codex 本地会话数据:
|
||||
|
||||
```text
|
||||
~/.codex/sqlite/*.db
|
||||
~/.codex/state_5.sqlite
|
||||
~/.codex/sessions/**/rollout-*.jsonl
|
||||
~/.codex/archived_sessions/**/rollout-*.jsonl
|
||||
```
|
||||
|
||||
用于检查旧会话的 Provider 元数据是否和当前配置一致,并支持一键同步 / 修复,让历史 thread 继续被原生 Codex 识别、打开和续聊。
|
||||
|
||||
### 6. Skills / MCP 管理
|
||||
|
||||
Codex-X 新增独立的【技能和 MCP】页面,用于集中管理 Codex 的专业能力扩展。
|
||||
|
||||
- Skills:读取当前 Codex Skills,支持导入已有、从 ZIP 安装、启用 / 禁用、检查更新状态
|
||||
- MCP:读取当前 Codex MCP Server,支持导入已有、启用 / 禁用,启用后写入 Codex <code>config.toml</code>
|
||||
- 适合管理 Android APK 逆向、Ghidra / IDA、Web / API / 协议逆向、CTF、安全测试等技能包
|
||||
|
||||
### 7. 逆向 Skills 导航
|
||||
### 6. 逆向 Skills 导航
|
||||
|
||||
<div align="center">
|
||||
<a href="https://yynxxxxx.github.io/Codex-X/">
|
||||
@@ -282,15 +332,15 @@ Codex-X 新增独立的【技能和 MCP】页面,用于集中管理 Codex 的
|
||||
</a>
|
||||
</p>
|
||||
|
||||
### 8. 跨平台桌面软件
|
||||
### 7. 跨平台桌面软件
|
||||
|
||||
- macOS Apple Silicon `.dmg`
|
||||
- macOS Intel `.dmg`
|
||||
- Windows `.msi`
|
||||
- Windows `.exe`(安装到当前用户,后续更新无需管理员权限)
|
||||
- Windows Portable `.zip`
|
||||
- Linux `.deb` / `.rpm`
|
||||
- Linux `.deb` / `.rpm` / `.AppImage`
|
||||
- GitHub Releases 自动构建发布
|
||||
- 应用内检查更新
|
||||
- 安装版支持应用内自动更新,Windows 便携版保留手动更新
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -345,7 +395,7 @@ pnpm dev
|
||||
pnpm --dir apps/desktop tauri build
|
||||
```
|
||||
|
||||
## macOS 安装说明
|
||||
## 桌面端安装说明
|
||||
|
||||
如果你在未签名 / 未公证的 DMG 中看到“软件已损坏”提示,这是 macOS Gatekeeper 的正常行为。
|
||||
|
||||
@@ -366,6 +416,19 @@ xattr -dr com.apple.quarantine /Applications/Codex-X.app
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=yynxxxxx%2FCodex-X&type=date&legend=top-left">
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=yynxxxxx%2Fcodex-x&type=date&legend=top-left&sealed_token=Vqvz67Jv_WIePGePCN8RdJaY5oCyqqCZUSFWs4M4dAxP8JXFZlYEWbI8YcU6SFgpqOqqJifzpOTIlMg4ee8NaCkHpCSqv1r5pxewR-tQlmxswaZlhedd6A" width="900" />
|
||||
</a>
|
||||
<p align="center">
|
||||
<a href="https://github.com/yynxxxxx/Codex-X/stargazers">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=light" />
|
||||
<img alt="Codex-X Star History" src="https://codex-star-history.zhihack0728.workers.dev/v1/charts/codex-x.svg?theme=light" width="900" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br />
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **使用声明**
|
||||
>
|
||||
> 本项目仅用于大模型与智能体相关技术的学习、研究与交流,软件本身不包含主动破坏性功能。请在合法、合规并获得授权的范围内使用,禁止将其用于攻击、侵害他人权益或其他违法用途。使用者应自行判断使用边界,并对相关行为与后果承担责任。
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Third-party notices
|
||||
|
||||
## CC Switch
|
||||
|
||||
Source: https://github.com/farion1231/cc-switch
|
||||
|
||||
Reference commit: `06082e189d65e6d6dbadc35dacdac1ce6c79d89a`.
|
||||
|
||||
Codex-X adapts the circuit-breaker implementation and Codex routing, failover, timeout and configuration behavior from CC Switch. The blocking/local HTTP integration, application state and UI styling are adapted to this project. Relevant source files carry provenance comments.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Jason Young
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## Tauri Windows installer template
|
||||
|
||||
Source: https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.11.4/crates/tauri-bundler/src/bundle/windows/templates/installer.nsi
|
||||
|
||||
Codex-X adapts the Tauri CLI v2.11.4 NSIS template for current-user installation, verified legacy MSI migration, update handoff and installation diagnostics.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 - Present Tauri Apps Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,17 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="zh-CN" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Codex-X</title>
|
||||
<script src="/theme-init.js"></script>
|
||||
<style>
|
||||
html, body { margin: 0; min-height: 100%; background: #070a13; }
|
||||
#root:empty::before {
|
||||
content: "Codex-X";
|
||||
position: fixed; inset: 0; display: grid; place-items: center;
|
||||
color: #eef4ff; font: 800 28px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: radial-gradient(circle at 30% 12%, rgba(124,92,255,.22), transparent 32%), linear-gradient(135deg, #060814 0%, #0c1120 52%, #071421 100%);
|
||||
}
|
||||
html, body { margin: 0; min-height: 100%; background: #f3f4f6; }
|
||||
html[data-theme="dark"], html[data-theme="dark"] body { background: #24262b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.2.31",
|
||||
"version": "0.3.27",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
@@ -14,6 +14,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"lucide-react": "^0.542.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<!-- Source: Simple Icons 16.26.0 (CC0-1.0). -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<title>OpenAI</title>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 LobeHub
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Provider brand marks
|
||||
|
||||
These six unmodified SVG assets are from [Lobe Icons](https://github.com/lobehub/lobe-icons), licensed under the MIT License. The upstream copyright and permission notice is included in [LICENSE](./LICENSE).
|
||||
|
||||
Pinned upstream commit: `a94750e3f5f8fc33757b839d85030e742284e43a`.
|
||||
|
||||
| Local file | Upstream source |
|
||||
| --- | --- |
|
||||
| `deepseek.svg` | [packages/static-svg/icons/deepseek.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/deepseek.svg) |
|
||||
| `minimax.svg` | [packages/static-svg/icons/minimax.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/minimax.svg) |
|
||||
| `mimo.svg` | [packages/static-svg/icons/xiaomimimo.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/xiaomimimo.svg) |
|
||||
| `kimi.svg` | [packages/static-svg/icons/kimi.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/kimi.svg) |
|
||||
| `glm.svg` | [packages/static-svg/icons/zhipu.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/zhipu.svg) |
|
||||
| `qwen.svg` | [packages/static-svg/icons/qwen.svg](https://github.com/lobehub/lobe-icons/blob/a94750e3f5f8fc33757b839d85030e742284e43a/packages/static-svg/icons/qwen.svg) |
|
||||
|
||||
The monochrome SVGs are rendered as CSS masks so their color follows the preset card state. Brand names and marks identify their respective providers; they do not imply endorsement.
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 01-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 00-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 01-.465.137 9.597 9.597 0 00-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 001.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 011.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 01.415-.287.302.302 0 01.2.288.306.306 0 01-.31.307.303.303 0 01-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 01-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 01.016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 01-.254-.078c-.11-.054-.2-.19-.114-.358.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Zhipu</title><path d="M11.991 23.503a.24.24 0 00-.244.248.24.24 0 00.244.249.24.24 0 00.245-.249.24.24 0 00-.22-.247l-.025-.001zM9.671 5.365a1.697 1.697 0 011.099 2.132l-.071.172-.016.04-.018.054c-.07.16-.104.32-.104.498-.035.71.47 1.279 1.186 1.314h.366c1.309.053 2.338 1.173 2.286 2.523-.052 1.332-1.152 2.38-2.478 2.327h-.174c-.715.018-1.274.64-1.239 1.368 0 .124.018.23.053.337.209.373.54.658.96.8.75.23 1.517-.125 1.9-.782l.018-.035c.402-.64 1.17-.96 1.92-.711.854.284 1.378 1.226 1.099 2.167a1.661 1.661 0 01-2.077 1.102 1.711 1.711 0 01-.907-.711l-.017-.035c-.2-.323-.463-.58-.851-.711l-.056-.018a1.646 1.646 0 00-1.954.746 1.66 1.66 0 01-1.065.764 1.677 1.677 0 01-1.989-1.279c-.209-.906.332-1.83 1.257-2.043a1.51 1.51 0 01.296-.035h.018c.68-.071 1.151-.622 1.116-1.333a1.307 1.307 0 00-.227-.693 2.515 2.515 0 01-.366-1.403 2.39 2.39 0 01.366-1.208c.14-.195.21-.444.227-.693.018-.71-.506-1.261-1.186-1.332l-.07-.018a1.43 1.43 0 01-.299-.07l-.05-.019a1.7 1.7 0 01-1.047-2.114 1.68 1.68 0 012.094-1.101zm-5.575 10.11c.26-.264.639-.367.994-.27.355.096.633.379.728.74.095.362-.007.748-.267 1.013-.402.41-1.053.41-1.455 0a1.062 1.062 0 010-1.482zm14.845-.294c.359-.09.738.024.992.297.254.274.344.665.237 1.025-.107.36-.396.634-.756.718-.551.128-1.1-.22-1.23-.781a1.05 1.05 0 01.757-1.26zm-.064-4.39c.314.32.49.753.49 1.206 0 .452-.176.886-.49 1.206-.315.32-.74.5-1.185.5-.444 0-.87-.18-1.184-.5a1.727 1.727 0 010-2.412 1.654 1.654 0 012.369 0zm-11.243.163c.364.484.447 1.128.218 1.691a1.665 1.665 0 01-2.188.923c-.855-.36-1.26-1.358-.907-2.228a1.68 1.68 0 011.33-1.038c.593-.08 1.183.169 1.547.652zm11.545-4.221c.368 0 .708.2.892.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.892.524c-.568 0-1.03-.47-1.03-1.048 0-.579.462-1.048 1.03-1.048zm-14.358 0c.368 0 .707.2.891.524.184.324.184.724 0 1.048a1.026 1.026 0 01-.891.524c-.569 0-1.03-.47-1.03-1.048 0-.579.461-1.048 1.03-1.048zm10.031-1.475c.925 0 1.675.764 1.675 1.706s-.75 1.705-1.675 1.705-1.674-.763-1.674-1.705c0-.942.75-1.706 1.674-1.706zm-2.626-.684c.362-.082.653-.356.761-.718a1.062 1.062 0 00-.238-1.028 1.017 1.017 0 00-.996-.294c-.547.14-.881.7-.752 1.257.13.558.675.907 1.225.783zm0 16.876c.359-.087.644-.36.75-.72a1.062 1.062 0 00-.237-1.019 1.018 1.018 0 00-.985-.301 1.037 1.037 0 00-.762.717c-.108.361-.017.754.239 1.028.245.263.606.377.953.305l.043-.01zM17.19 3.5a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64a.631.631 0 00-.628.64c0 .355.28.64.628.64zm-10.38 0a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64a.631.631 0 00-.628.64c0 .355.279.64.628.64zm-5.182 7.852a.631.631 0 00-.628.64c0 .354.28.639.628.639a.63.63 0 00.627-.606l.001-.034a.62.62 0 00-.628-.64zm5.182 9.13a.631.631 0 00-.628.64c0 .355.279.64.628.64a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm10.38.018a.631.631 0 00-.628.64c0 .355.28.64.628.64a.631.631 0 00.628-.64c0-.355-.279-.64-.628-.64zm5.182-9.148a.631.631 0 00-.628.64c0 .354.279.639.628.639a.631.631 0 00.628-.64c0-.355-.28-.64-.628-.64zm-.384-4.992a.24.24 0 00.244-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249c0 .142.122.249.244.249zM11.991.497a.24.24 0 00.245-.248A.24.24 0 0011.99 0a.24.24 0 00-.244.249c0 .133.108.236.223.247l.021.001zM2.011 6.36a.24.24 0 00.245-.249.24.24 0 00-.244-.249.24.24 0 00-.244.249.24.24 0 00.244.249zm0 11.263a.24.24 0 00-.243.248.24.24 0 00.244.249.24.24 0 00.244-.249.252.252 0 00-.244-.248zm19.995-.018a.24.24 0 00-.245.248.24.24 0 00.245.25.24.24 0 00.244-.25.252.252 0 00-.244-.248z"></path></svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Kimi</title><path d="M21.846 0a1.923 1.923 0 110 3.846H20.15a.226.226 0 01-.227-.226V1.923C19.923.861 20.784 0 21.846 0z"></path><path d="M11.065 11.199l7.257-7.2c.137-.136.06-.41-.116-.41H14.3a.164.164 0 00-.117.051l-7.82 7.756c-.122.12-.302.013-.302-.179V3.82c0-.127-.083-.23-.185-.23H3.186c-.103 0-.186.103-.186.23V19.77c0 .128.083.23.186.23h2.69c.103 0 .186-.102.186-.23v-3.25c0-.069.025-.135.069-.178l2.424-2.406a.158.158 0 01.205-.023l6.484 4.772a7.677 7.677 0 003.453 1.283c.108.012.2-.095.2-.23v-3.06c0-.117-.07-.212-.164-.227a5.028 5.028 0 01-2.027-.807l-5.613-4.064c-.117-.078-.132-.279-.028-.381z"></path></svg>
|
||||
|
After Width: | Height: | Size: 786 B |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>XiaomiMiMo</title><path d="M.958 15.936a.459.459 0 01.459.44v2.729a.46.46 0 01-.918 0v-2.729a.459.459 0 01.459-.44zm4.814-2.035a.46.46 0 01.553.45v4.754a.458.458 0 11-.918 0V15.48L3.74 17.202a.462.462 0 01-.655.016.462.462 0 01-.065-.082L.628 14.67a.459.459 0 01.658-.637l2.124 2.187 2.127-2.188a.46.46 0 01.235-.13zm2.068.004a.46.46 0 01.458.445v4.755a.46.46 0 01-.458.458.459.459 0 01-.458-.458V14.35a.459.459 0 01.458-.445zm1.973 2.014a.46.46 0 01.46.457v2.729a.46.46 0 01-.784.324.46.46 0 01-.134-.324v-2.729a.46.46 0 01.458-.458zm.002-2.045a.458.458 0 01.328.157l2.127 2.19 2.125-2.19a.459.459 0 01.784.318v4.756a.46.46 0 01-.455.458.46.46 0 01-.458-.458V15.48l-1.667 1.723a.46.46 0 01-.65.008l-.005-.005c0-.002-.002-.002-.004-.003l-2.455-2.534a.46.46 0 01-.008-.667.461.461 0 01.338-.128zm6.797 1.206a.46.46 0 01.53.651A1.966 1.966 0 0019.81 18.4a.462.462 0 01.623.18.46.46 0 01-.181.624 2.863 2.863 0 01-1.38.353l-.142-.004a2.88 2.88 0 01-2.393-4.263.461.461 0 01.274-.21zm.864-.931a2.884 2.884 0 013.915 3.914.46.46 0 01-.402.24l-.057-.004a.458.458 0 01-.164-.055.46.46 0 01-.182-.622 1.967 1.967 0 00-2.669-2.67.459.459 0 11-.441-.803zM9.59 6.368c1.481 0 1.696 1.202 1.696 1.654v2.648h-.917v-.432c-.26.346-.792.535-1.36.535-.133 0-1.289-.03-1.384-1.136-.082-.932.675-1.61 2.053-1.61h.691c0-.563-.367-.886-.983-.886-.44.013-.864.174-1.2.458l-.36-.664c.484-.379 1.012-.567 1.764-.567zm4.427.1c1.263 0 2.082.97 2.083 2.15 0 1.181-.824 2.154-2.083 2.154-1.26 0-2.084-.972-2.084-2.152 0-1.18.82-2.153 2.084-2.153zm6.801.015c.68 0 1.202.465 1.197 1.548v2.642H21.1V8.29c0-.312-.002-.98-.63-.98s-.628.667-.628.838v2.524h-.89V8.148c0-.17-.001-.838-.63-.838-.628 0-.628.668-.628.98v2.383h-.917v-4.03h.917V7a1.22 1.22 0 01.947-.516c.398 0 .76.193.982.686a1.321 1.321 0 011.195-.686zm-18.093.872l1.457-1.772H5.32L3.311 8.07l2.14 2.602H4.24L2.725 8.796 1.21 10.672H0L2.138 8.07.13 5.583h1.138l1.458 1.772zm4.149 3.317h-.916V6.644h.916v4.028zm16.99 0h-.916V6.644h.916v4.028zM9.925 8.71c-1.055 0-1.359.412-1.326.742.032.329.324.537.757.537a1.013 1.013 0 001.014-.968l.002-.31h-.447zM14.018 7.3c-.663 0-1.184.487-1.184 1.32 0 .832.52 1.32 1.184 1.32.662 0 1.182-.49 1.182-1.32 0-.832-.52-1.32-1.182-1.32zM6.417 5.001a.568.568 0 01.587.582.588.588 0 01-1.175 0A.57.57 0 016.417 5zm16.991 0a.57.57 0 01.592.582.588.588 0 01-1.174 0 .57.57 0 01.357-.542.572.572 0 01.225-.04z"></path></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Minimax</title><path d="M16.278 2c1.156 0 2.093.927 2.093 2.07v12.501a.74.74 0 00.744.709.74.74 0 00.743-.709V9.099a2.06 2.06 0 012.071-2.049A2.06 2.06 0 0124 9.1v6.561a.649.649 0 01-.652.645.649.649 0 01-.653-.645V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v7.472a2.037 2.037 0 01-2.048 2.026 2.037 2.037 0 01-2.048-2.026v-12.5a.785.785 0 00-.788-.753.785.785 0 00-.789.752l-.001 15.904A2.037 2.037 0 0113.441 22a2.037 2.037 0 01-2.048-2.026V18.04c0-.356.292-.645.652-.645.36 0 .652.289.652.645v1.934c0 .263.142.506.372.638.23.131.514.131.744 0a.734.734 0 00.372-.638V4.07c0-1.143.937-2.07 2.093-2.07zm-5.674 0c1.156 0 2.093.927 2.093 2.07v11.523a.648.648 0 01-.652.645.648.648 0 01-.652-.645V4.07a.785.785 0 00-.789-.78.785.785 0 00-.789.78v14.013a2.06 2.06 0 01-2.07 2.048 2.06 2.06 0 01-2.071-2.048V9.1a.762.762 0 00-.766-.758.762.762 0 00-.766.758v3.8a2.06 2.06 0 01-2.071 2.049A2.06 2.06 0 010 12.9v-1.378c0-.357.292-.646.652-.646.36 0 .653.29.653.646V12.9c0 .418.343.757.766.757s.766-.339.766-.757V9.099a2.06 2.06 0 012.07-2.048 2.06 2.06 0 012.071 2.048v8.984c0 .419.343.758.767.758.423 0 .766-.339.766-.758V4.07c0-1.143.937-2.07 2.093-2.07z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,10 @@
|
||||
(function () {
|
||||
var theme = "light";
|
||||
try {
|
||||
theme = window.localStorage.getItem("codexx.theme") === "dark" ? "dark" : "light";
|
||||
} catch (_error) {
|
||||
// Keep the light default when storage is unavailable.
|
||||
}
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
@@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "codex-x"
|
||||
version = "0.2.31"
|
||||
version = "0.3.27"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
edition = "2021"
|
||||
rust-version = "1.85.0"
|
||||
rust-version = "1.89.0"
|
||||
|
||||
[lib]
|
||||
name = "codexx_lib"
|
||||
@@ -15,14 +15,41 @@ crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
tauri-build = { version = "2.4.0", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2.8.2", features = [] }
|
||||
tauri = { version = "2.8.2", features = ["tray-icon"] }
|
||||
tauri-plugin-process = "2.3.1"
|
||||
tauri-plugin-updater = "2.10.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_json = { version = "1.0", features = ["arbitrary_precision"] }
|
||||
base64 = "0.22"
|
||||
toml_edit = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dirs = "5.0"
|
||||
thiserror = "2.0"
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
rusqlite = { version = "0.31", features = ["backup", "bundled"] }
|
||||
semver = "1.0"
|
||||
ureq = { version = "2.12", features = ["tls"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = [
|
||||
"blocking",
|
||||
"rustls-no-provider",
|
||||
"socks",
|
||||
"system-proxy",
|
||||
] }
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring"] }
|
||||
sha2 = "0.10"
|
||||
percent-encoding = "2.3"
|
||||
zip = { version = "2.2", default-features = false, features = ["deflate"] }
|
||||
rfd = { version = "=0.17.2", default-features = false, features = ["xdg-portal"] }
|
||||
tauri-plugin-single-instance = "=2.4.3"
|
||||
httparse = "1"
|
||||
getrandom = "0.2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "time", "net"] }
|
||||
flate2 = "1"
|
||||
zstd = "0.13"
|
||||
tempfile = "3.27"
|
||||
tungstenite = { version = "0.28", default-features = false, features = ["handshake"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tauri = { version = "2.8.2", features = ["test"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "=0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
|
||||
|
||||
@@ -3,5 +3,10 @@
|
||||
"identifier": "default",
|
||||
"description": "Default app capability",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"updater:allow-check",
|
||||
"process:allow-restart"
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 190 KiB |
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2017 - Present Tauri Apps Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,785 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::ensure_directory;
|
||||
use crate::paths::app_home;
|
||||
use crate::sqlite_utils::table_column_set;
|
||||
use rusqlite::Connection;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
const APP_DB_SCHEMA_VERSION: i64 = 9;
|
||||
|
||||
struct DatabaseInitializer {
|
||||
migration_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl DatabaseInitializer {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
migration_lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_at(&self, path: &Path) -> Result<Connection> {
|
||||
self.open_at_with(path, initialize_schema)
|
||||
}
|
||||
|
||||
fn open_at_with(
|
||||
&self,
|
||||
path: &Path,
|
||||
initialize: impl FnOnce(&Connection) -> Result<()>,
|
||||
) -> Result<Connection> {
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let conn = Connection::open(path).map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
conn.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
|
||||
if schema_is_current(&conn)? {
|
||||
return Ok(conn);
|
||||
}
|
||||
|
||||
// Serialize first-time migrations inside this process. The persistent
|
||||
// schema version also prevents repeated migrations across launches and
|
||||
// detects a database replaced at the same path.
|
||||
let _migration_guard = self
|
||||
.migration_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if !schema_is_current(&conn)? {
|
||||
migrate_schema(&conn, initialize)?;
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
}
|
||||
|
||||
fn schema_version(conn: &Connection) -> Result<i64> {
|
||||
conn.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
fn schema_is_current(conn: &Connection) -> Result<bool> {
|
||||
Ok(schema_version(conn)? >= APP_DB_SCHEMA_VERSION)
|
||||
}
|
||||
|
||||
fn migrate_schema(
|
||||
conn: &Connection,
|
||||
initialize: impl FnOnce(&Connection) -> Result<()>,
|
||||
) -> Result<()> {
|
||||
conn.execute_batch("BEGIN IMMEDIATE")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
|
||||
let migration = (|| {
|
||||
// Another Codex-X process may have completed the migration while this
|
||||
// connection waited for SQLite's write lock.
|
||||
if !schema_is_current(conn)? {
|
||||
initialize(conn)?;
|
||||
conn.pragma_update(None, "user_version", APP_DB_SCHEMA_VERSION)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
conn.execute_batch("COMMIT")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
})();
|
||||
|
||||
match migration {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => match conn.execute_batch("ROLLBACK") {
|
||||
Ok(()) => Err(error),
|
||||
Err(rollback_error) => Err(CodexxError::Database(format!(
|
||||
"{error}; app database migration rollback failed: {rollback_error}"
|
||||
))),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn database_initializer() -> &'static DatabaseInitializer {
|
||||
static INITIALIZER: OnceLock<DatabaseInitializer> = OnceLock::new();
|
||||
INITIALIZER.get_or_init(DatabaseInitializer::new)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_db_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
static TEST_DB_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
TEST_DB_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("test app database lock poisoned")
|
||||
}
|
||||
|
||||
fn db_path() -> Result<PathBuf> {
|
||||
Ok(app_home()?.join("codexx.db"))
|
||||
}
|
||||
|
||||
fn ensure_sqlite_column(
|
||||
conn: &Connection,
|
||||
table: &str,
|
||||
column: &str,
|
||||
alter_sql: &str,
|
||||
) -> Result<()> {
|
||||
let cols = table_column_set(conn, table)?;
|
||||
if cols.contains(column) {
|
||||
return Ok(());
|
||||
}
|
||||
match conn.execute(alter_sql, []) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(e) => {
|
||||
let message = e.to_string().to_ascii_lowercase();
|
||||
if message.contains("duplicate column") || message.contains("duplicate column name") {
|
||||
// Another running Codex-X process may have applied the same
|
||||
// lightweight migration between our PRAGMA check and ALTER.
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CodexxError::Database(e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_name TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
api_key TEXT,
|
||||
toml_config TEXT,
|
||||
wire_api TEXT NOT NULL DEFAULT 'responses',
|
||||
requires_openai_auth INTEGER NOT NULL DEFAULT 1,
|
||||
model_mappings_json TEXT NOT NULL DEFAULT '[]',
|
||||
upstream_api TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
source_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_providers_updated_at ON providers(updated_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS prompts (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_prompts_updated_at ON prompts(updated_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS builtin_prompt_cache (
|
||||
id TEXT PRIMARY KEY,
|
||||
filename TEXT NOT NULL,
|
||||
source_url TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
checked_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS builtin_prompt_overrides (
|
||||
template_id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS managed_mcp_servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
server_config TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS managed_skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
directory TEXT NOT NULL,
|
||||
source_path TEXT,
|
||||
content_hash TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS skills_mcp_notes (
|
||||
codex_dir TEXT NOT NULL,
|
||||
item_kind TEXT NOT NULL CHECK(item_kind IN ('skill', 'mcp')),
|
||||
item_id TEXT NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(codex_dir, item_kind, item_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS active_provider_selections (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
provider_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS provider_failover (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
record_json TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS provider_common_config_state (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
handled_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS official_profiles (
|
||||
codex_dir TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
provider_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(codex_dir, id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS official_profile_selections (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
profile_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS provider_card_orders (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
order_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
ensure_sqlite_column(
|
||||
conn,
|
||||
"providers",
|
||||
"toml_config",
|
||||
"ALTER TABLE providers ADD COLUMN toml_config TEXT",
|
||||
)?;
|
||||
ensure_sqlite_column(
|
||||
conn,
|
||||
"providers",
|
||||
"source",
|
||||
"ALTER TABLE providers ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'",
|
||||
)?;
|
||||
ensure_sqlite_column(
|
||||
conn,
|
||||
"providers",
|
||||
"source_id",
|
||||
"ALTER TABLE providers ADD COLUMN source_id TEXT",
|
||||
)?;
|
||||
ensure_sqlite_column(
|
||||
conn,
|
||||
"providers",
|
||||
"model_mappings_json",
|
||||
"ALTER TABLE providers ADD COLUMN model_mappings_json TEXT NOT NULL DEFAULT '[]'",
|
||||
)?;
|
||||
ensure_sqlite_column(
|
||||
conn,
|
||||
"providers",
|
||||
"upstream_api",
|
||||
"ALTER TABLE providers ADD COLUMN upstream_api TEXT",
|
||||
)?;
|
||||
conn.execute_batch(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_providers_source_identity
|
||||
ON providers(source, source_id)
|
||||
WHERE source_id IS NOT NULL;",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"DELETE FROM prompts
|
||||
WHERE id LIKE 'external-%'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM prompts AS kept
|
||||
WHERE lower(kept.filename) = lower(prompts.filename)
|
||||
AND kept.id NOT LIKE 'external-%'
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"DELETE FROM prompts
|
||||
WHERE id LIKE 'external-%'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM prompts AS kept
|
||||
WHERE kept.content = prompts.content
|
||||
AND kept.id NOT LIKE 'external-%'
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
conn.execute(
|
||||
"DELETE FROM prompts
|
||||
WHERE id LIKE 'external-%'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM prompts AS kept
|
||||
WHERE kept.content = prompts.content
|
||||
AND kept.id LIKE 'external-%'
|
||||
AND kept.rowid <> prompts.rowid
|
||||
AND (kept.updated_at > prompts.updated_at OR (kept.updated_at = prompts.updated_at AND kept.rowid > prompts.rowid))
|
||||
)",
|
||||
[],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn open() -> Result<Connection> {
|
||||
database_initializer().open_at(&db_path()?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Barrier};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn test_db_path(name: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let suffix = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir()
|
||||
.join(format!(
|
||||
"codex-x-app-db-{name}-{}-{suffix}",
|
||||
std::process::id()
|
||||
))
|
||||
.join("codexx.db")
|
||||
}
|
||||
|
||||
fn remove_test_db(path: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::remove_dir_all(parent).expect("remove app database test directory");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_opens_do_not_rerun_legacy_cleanup() {
|
||||
let path = test_db_path("cleanup-once");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let conn = initializer.open_at(&path).expect("initialize database");
|
||||
conn.execute_batch(
|
||||
"INSERT INTO prompts (id, title, filename, content, created_at, updated_at)
|
||||
VALUES
|
||||
('kept', 'Kept', 'same.md', 'same', '1', '1'),
|
||||
('external-duplicate', 'Duplicate', 'same.md', 'same', '2', '2');",
|
||||
)
|
||||
.expect("seed a post-migration duplicate");
|
||||
drop(conn);
|
||||
|
||||
let reopened_initializer = DatabaseInitializer::new();
|
||||
let conn = reopened_initializer
|
||||
.open_at(&path)
|
||||
.expect("reopen database in a new process lifecycle");
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM prompts", [], |row| row.get(0))
|
||||
.expect("count prompts after reopen");
|
||||
assert_eq!(count, 2, "reopen must not rerun migration cleanup");
|
||||
drop(conn);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn database_replaced_at_the_same_path_is_initialized_again() {
|
||||
let path = test_db_path("replace-database");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let conn = initializer.open_at(&path).expect("initialize database");
|
||||
assert_eq!(
|
||||
schema_version(&conn).expect("read schema version"),
|
||||
APP_DB_SCHEMA_VERSION
|
||||
);
|
||||
drop(conn);
|
||||
|
||||
fs::remove_file(&path).expect("replace initialized database");
|
||||
let conn = initializer
|
||||
.open_at(&path)
|
||||
.expect("initialize replacement database");
|
||||
let provider_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
|
||||
.expect("query replacement database schema");
|
||||
assert_eq!(provider_count, 0);
|
||||
assert_eq!(
|
||||
schema_version(&conn).expect("read replacement version"),
|
||||
APP_DB_SCHEMA_VERSION
|
||||
);
|
||||
drop(conn);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_four_provider_rows_migrate_with_empty_model_mappings() {
|
||||
let path = test_db_path("provider-model-mappings-v5");
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
let legacy = Connection::open(&path).unwrap();
|
||||
legacy.execute_batch("CREATE TABLE providers (
|
||||
id TEXT PRIMARY KEY, provider_name TEXT NOT NULL, base_url TEXT NOT NULL,
|
||||
model TEXT NOT NULL, api_key TEXT, toml_config TEXT,
|
||||
wire_api TEXT NOT NULL DEFAULT 'responses', requires_openai_auth INTEGER NOT NULL DEFAULT 1,
|
||||
source TEXT NOT NULL DEFAULT 'manual', source_id TEXT,
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
INSERT INTO providers (id, provider_name, base_url, model, api_key, source, source_id, created_at, updated_at)
|
||||
VALUES ('legacy-models', 'Existing provider', 'https://migration.example.test/v1', 'existing-model', 'fixture-key', 'cc-switch', 'source-row', 'original-created', 'original-updated');
|
||||
PRAGMA user_version = 4;").unwrap();
|
||||
drop(legacy);
|
||||
let migrated = DatabaseInitializer::new().open_at(&path).unwrap();
|
||||
assert_eq!(schema_version(&migrated).unwrap(), APP_DB_SCHEMA_VERSION);
|
||||
assert_eq!(
|
||||
migrated
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM provider_common_config_state",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
let stored = crate::providers::list_saved_providers_on_connection(&migrated).unwrap();
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert!(stored[0].model_mappings.is_empty());
|
||||
assert_eq!(stored[0].model, "existing-model");
|
||||
assert_eq!(stored[0].api_key.as_deref(), Some("fixture-key"));
|
||||
let metadata: (String, String, String, String, String) = migrated.query_row(
|
||||
"SELECT source, source_id, created_at, updated_at, model_mappings_json FROM providers WHERE id = 'legacy-models'", [],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
|
||||
).unwrap();
|
||||
assert_eq!(
|
||||
metadata,
|
||||
(
|
||||
"cc-switch".into(),
|
||||
"source-row".into(),
|
||||
"original-created".into(),
|
||||
"original-updated".into(),
|
||||
"[]".into()
|
||||
)
|
||||
);
|
||||
drop(migrated);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_seven_database_adds_card_order_preferences_without_changing_provider_data() {
|
||||
let path = test_db_path("provider-card-order-v8");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let legacy = initializer.open_at(&path).unwrap();
|
||||
legacy.execute_batch(
|
||||
"INSERT INTO providers
|
||||
(id, provider_name, base_url, model, api_key, created_at, updated_at)
|
||||
VALUES ('existing', 'Existing provider', 'https://fixture.test/v1', 'fixture-model',
|
||||
'fixture-key', 'original-created', 'original-updated');
|
||||
INSERT INTO official_profiles
|
||||
(codex_dir, id, provider_name, created_at, updated_at)
|
||||
VALUES ('/fixture/config', 'existing-account', 'Existing account', 'created', 'updated');
|
||||
DROP TABLE provider_card_orders;
|
||||
PRAGMA user_version = 7;",
|
||||
)
|
||||
.unwrap();
|
||||
let before = crate::providers::list_saved_providers_on_connection(&legacy).unwrap();
|
||||
drop(legacy);
|
||||
|
||||
let migrated = initializer.open_at(&path).unwrap();
|
||||
assert_eq!(schema_version(&migrated).unwrap(), APP_DB_SCHEMA_VERSION);
|
||||
assert_eq!(
|
||||
crate::providers::list_saved_providers_on_connection(&migrated).unwrap(),
|
||||
before
|
||||
);
|
||||
let timestamps: (String, String) = migrated
|
||||
.query_row(
|
||||
"SELECT created_at, updated_at FROM providers WHERE id = 'existing'",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
timestamps,
|
||||
("original-created".into(), "original-updated".into())
|
||||
);
|
||||
assert_eq!(
|
||||
migrated
|
||||
.query_row(
|
||||
"SELECT provider_name FROM official_profiles WHERE id = 'existing-account'",
|
||||
[],
|
||||
|row| row.get::<_, String>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
"Existing account"
|
||||
);
|
||||
assert_eq!(
|
||||
migrated
|
||||
.query_row("SELECT COUNT(*) FROM provider_card_orders", [], |row| row
|
||||
.get::<_, i64>(
|
||||
0
|
||||
))
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
drop(migrated);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_eight_migration_keeps_provider_data_and_defaults_upstream_api_to_null() {
|
||||
let path = test_db_path("provider-upstream-api-v9");
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let legacy = Connection::open(&path).unwrap();
|
||||
legacy
|
||||
.execute_batch(
|
||||
"CREATE TABLE providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider_name TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
api_key TEXT,
|
||||
toml_config TEXT,
|
||||
wire_api TEXT NOT NULL DEFAULT 'responses',
|
||||
requires_openai_auth INTEGER NOT NULL DEFAULT 1,
|
||||
model_mappings_json TEXT NOT NULL DEFAULT '[]',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
source_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO providers
|
||||
(id, provider_name, base_url, model, api_key, toml_config, wire_api,
|
||||
requires_openai_auth, model_mappings_json, source, source_id,
|
||||
created_at, updated_at)
|
||||
VALUES
|
||||
('legacy-responses', 'Existing Responses provider', 'https://responses.fixture/v1',
|
||||
'existing-model', 'fixture-secret', 'model_provider = \"custom\"', 'responses',
|
||||
1, '[{\"displayName\":\"Mapped model\",\"model\":\"upstream-model\"}]',
|
||||
'cc-switch', 'fixture-source-row', 'original-created', 'original-updated'),
|
||||
('legacy-chat', 'Existing Chat provider', 'https://chat.fixture/v1',
|
||||
'chat-model', NULL, NULL, 'chat',
|
||||
0, '[]', 'manual', NULL, 'chat-created', 'chat-updated');
|
||||
PRAGMA user_version = 8;",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(schema_version(&legacy).unwrap(), 8);
|
||||
assert!(!table_column_set(&legacy, "providers")
|
||||
.unwrap()
|
||||
.contains("upstream_api"));
|
||||
let snapshot = |conn: &Connection| {
|
||||
let mut statement = conn
|
||||
.prepare(
|
||||
"SELECT id, provider_name, base_url, model, api_key, toml_config,
|
||||
wire_api, requires_openai_auth, model_mappings_json,
|
||||
source, source_id, created_at, updated_at
|
||||
FROM providers ORDER BY id",
|
||||
)
|
||||
.unwrap();
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
(0..13)
|
||||
.map(|index| row.get::<_, rusqlite::types::Value>(index))
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.unwrap();
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>().unwrap()
|
||||
};
|
||||
let before = snapshot(&legacy);
|
||||
drop(legacy);
|
||||
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let migrated = initializer.open_at(&path).unwrap();
|
||||
assert_eq!(schema_version(&migrated).unwrap(), 9);
|
||||
assert!(table_column_set(&migrated, "providers")
|
||||
.unwrap()
|
||||
.contains("upstream_api"));
|
||||
assert_eq!(
|
||||
snapshot(&migrated),
|
||||
before,
|
||||
"all pre-existing provider columns must survive migration unchanged"
|
||||
);
|
||||
let upstream_values = migrated
|
||||
.prepare("SELECT upstream_api FROM providers ORDER BY id")
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get::<_, Option<String>>(0))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap();
|
||||
assert_eq!(upstream_values, vec![None, None]);
|
||||
let providers = crate::providers::list_saved_providers_on_connection(&migrated).unwrap();
|
||||
assert_eq!(providers.len(), 2);
|
||||
let responses = providers
|
||||
.iter()
|
||||
.find(|provider| provider.id == "legacy-responses")
|
||||
.unwrap();
|
||||
assert!(responses.upstream_api.is_none());
|
||||
assert_eq!(responses.api_key.as_deref(), Some("fixture-secret"));
|
||||
assert_eq!(responses.model_mappings.len(), 1);
|
||||
assert_eq!(responses.model_mappings[0].model, "upstream-model");
|
||||
drop(migrated);
|
||||
|
||||
let reopened = DatabaseInitializer::new().open_at(&path).unwrap();
|
||||
assert_eq!(schema_version(&reopened).unwrap(), 9);
|
||||
assert_eq!(
|
||||
snapshot(&reopened),
|
||||
before,
|
||||
"reopening must leave migrated provider data unchanged"
|
||||
);
|
||||
assert_eq!(
|
||||
reopened
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM providers WHERE upstream_api IS NULL",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
drop(reopened);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_one_database_migrates_new_metadata_tables_without_data_loss() {
|
||||
let path = test_db_path("skills-mcp-notes-migration");
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).expect("create legacy database directory");
|
||||
}
|
||||
let legacy = Connection::open(&path).expect("create legacy database");
|
||||
legacy
|
||||
.execute_batch(
|
||||
"CREATE TABLE managed_skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
directory TEXT NOT NULL,
|
||||
source_path TEXT,
|
||||
content_hash TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO managed_skills
|
||||
(id, name, directory, enabled, updated_at)
|
||||
VALUES ('legacy-skill', 'Legacy skill', 'legacy-skill', 1, '2026-01-01T00:00:00Z');
|
||||
PRAGMA user_version = 1;",
|
||||
)
|
||||
.expect("seed version one database");
|
||||
drop(legacy);
|
||||
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let migrated = initializer.open_at(&path).expect("migrate database");
|
||||
assert_eq!(
|
||||
schema_version(&migrated).expect("read migrated schema version"),
|
||||
APP_DB_SCHEMA_VERSION
|
||||
);
|
||||
let skill_count: i64 = migrated
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM managed_skills WHERE id = 'legacy-skill'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("count preserved skill");
|
||||
assert_eq!(skill_count, 1);
|
||||
migrated
|
||||
.execute(
|
||||
"INSERT INTO skills_mcp_notes
|
||||
(codex_dir, item_kind, item_id, note, updated_at)
|
||||
VALUES
|
||||
('/tmp/legacy-codex', 'skill', 'legacy-skill', 'my note', '2026-01-02T00:00:00Z')",
|
||||
[],
|
||||
)
|
||||
.expect("write note after migration");
|
||||
migrated
|
||||
.execute(
|
||||
"INSERT INTO active_provider_selections
|
||||
(codex_dir, provider_id, updated_at)
|
||||
VALUES
|
||||
('/tmp/legacy-codex', 'legacy-provider', '2026-01-02T00:00:00Z')",
|
||||
[],
|
||||
)
|
||||
.expect("write active provider selection after migration");
|
||||
drop(migrated);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_first_opens_initialize_once() {
|
||||
let path = test_db_path("concurrent-init");
|
||||
let attempts = Arc::new(AtomicUsize::new(0));
|
||||
let barrier = Arc::new(Barrier::new(3));
|
||||
let mut workers = Vec::new();
|
||||
|
||||
for _ in 0..2 {
|
||||
let path = path.clone();
|
||||
// Separate initializers model independent Codex-X processes; the
|
||||
// SQLite transaction and persistent version still permit one run.
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let attempts = Arc::clone(&attempts);
|
||||
let barrier = Arc::clone(&barrier);
|
||||
workers.push(thread::spawn(move || {
|
||||
barrier.wait();
|
||||
initializer.open_at_with(&path, |conn| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
conn.execute_batch("CREATE TABLE initialized_once (id INTEGER PRIMARY KEY);")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
barrier.wait();
|
||||
for worker in workers {
|
||||
drop(
|
||||
worker
|
||||
.join()
|
||||
.expect("join concurrent database opener")
|
||||
.expect("open database concurrently"),
|
||||
);
|
||||
}
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 1);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_initialization_can_retry() {
|
||||
let path = test_db_path("retry-init");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let attempts = AtomicUsize::new(0);
|
||||
|
||||
let error = initializer
|
||||
.open_at_with(&path, |_| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
Err(CodexxError::Database(
|
||||
"injected transient initialization failure".to_string(),
|
||||
))
|
||||
})
|
||||
.expect_err("first initialization must fail");
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("transient initialization failure"));
|
||||
|
||||
let conn = initializer
|
||||
.open_at_with(&path, |conn| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
conn.execute_batch("CREATE TABLE retry_succeeded (id INTEGER PRIMARY KEY);")
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
})
|
||||
.expect("retry initialization");
|
||||
drop(conn);
|
||||
let conn = initializer
|
||||
.open_at_with(&path, |_| {
|
||||
attempts.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
})
|
||||
.expect("open initialized database");
|
||||
assert_eq!(attempts.load(Ordering::SeqCst), 2);
|
||||
drop(conn);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initialized_read_connection_does_not_need_a_write_lock() {
|
||||
let path = test_db_path("read-with-writer");
|
||||
let initializer = DatabaseInitializer::new();
|
||||
let writer = initializer.open_at(&path).expect("initialize database");
|
||||
writer
|
||||
.execute_batch("BEGIN IMMEDIATE")
|
||||
.expect("hold database write reservation");
|
||||
|
||||
let reader = initializer
|
||||
.open_at(&path)
|
||||
.expect("open reader while write reservation is held");
|
||||
let count: i64 = reader
|
||||
.query_row("SELECT COUNT(*) FROM providers", [], |row| row.get(0))
|
||||
.expect("read while another connection holds write reservation");
|
||||
assert_eq!(count, 0);
|
||||
|
||||
drop(reader);
|
||||
writer
|
||||
.execute_batch("ROLLBACK")
|
||||
.expect("release write lock");
|
||||
drop(writer);
|
||||
remove_test_db(&path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,667 @@
|
||||
//! Keep downloads and verification in the signed Tauri updater, but own the
|
||||
//! Windows handoff. The stock updater exits immediately and skips our routing
|
||||
//! shutdown; it also cannot report installation progress after that exit.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tauri::{ipc::Channel, Manager, ResourceId, Webview};
|
||||
use tauri_plugin_updater::Update;
|
||||
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
#[path = "windows_update.rs"]
|
||||
mod windows;
|
||||
|
||||
static UPDATING: AtomicBool = AtomicBool::new(false);
|
||||
// 0: not installing, 1: installer preparation/write, 2: Windows handoff.
|
||||
static INSTALLATION_PHASE: AtomicU8 = AtomicU8::new(0);
|
||||
const DOWNLOAD_TIMEOUT_MS: u64 = 10 * 60 * 1000;
|
||||
|
||||
struct InstallationExitGuard<'a>(&'a AtomicU8);
|
||||
impl<'a> InstallationExitGuard<'a> {
|
||||
fn begin(phase: &'a AtomicU8) -> Self {
|
||||
phase.store(1, Ordering::Release);
|
||||
Self(phase)
|
||||
}
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
fn handed_off(self) {
|
||||
self.0.store(2, Ordering::Release);
|
||||
std::mem::forget(self);
|
||||
}
|
||||
}
|
||||
impl Drop for InstallationExitGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(0, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn defer_exit_during_installation() -> bool {
|
||||
INSTALLATION_PHASE.load(Ordering::Acquire) == 1
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(tag = "event", content = "data")]
|
||||
pub(crate) enum AppUpdateEvent {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Started {
|
||||
content_length: Option<u64>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Progress {
|
||||
chunk_length: usize,
|
||||
},
|
||||
Verifying,
|
||||
#[allow(dead_code)] // Emitted by the Windows handoff; other platforms install in place.
|
||||
Preparing,
|
||||
Installing,
|
||||
#[allow(dead_code)]
|
||||
HandedOff,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum FailureStage {
|
||||
Download,
|
||||
Verify,
|
||||
Prepare,
|
||||
Install,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UpdateFailure {
|
||||
stage: FailureStage,
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
log_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct InstallResult {
|
||||
restart_required: bool,
|
||||
}
|
||||
|
||||
struct UpdateLease<'a>(&'a AtomicBool);
|
||||
impl<'a> UpdateLease<'a> {
|
||||
fn acquire(flag: &'a AtomicBool) -> Result<Self, UpdateFailure> {
|
||||
flag.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
|
||||
.map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Prepare,
|
||||
"已有更新正在进行,请等待当前操作完成。",
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
Ok(Self(flag))
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
fn handoff(self) {
|
||||
// Never permit a second installer while the original app is exiting.
|
||||
std::mem::forget(self);
|
||||
}
|
||||
}
|
||||
impl Drop for UpdateLease<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
fn failure(stage: FailureStage, message: &str, log: Option<&UpdateLog>) -> UpdateFailure {
|
||||
UpdateFailure {
|
||||
stage,
|
||||
message: message.into(),
|
||||
log_path: log.map(|log| log.path.to_string_lossy().into_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check before changing the installed application on every platform. The
|
||||
/// converted supplier cannot resume through a raw Responses endpoint after exit.
|
||||
fn install_with_route_preflight<T>(
|
||||
check: impl FnOnce() -> Result<(), ()>,
|
||||
install: impl FnOnce() -> Result<T, UpdateFailure>,
|
||||
log: &UpdateLog,
|
||||
) -> Result<T, UpdateFailure> {
|
||||
if check().is_err() {
|
||||
log.record("routing_recovery_preflight_failed");
|
||||
return Err(failure(
|
||||
FailureStage::Prepare,
|
||||
"更新前未能确认连接配置可恢复,安装尚未开始。请检查路由配置后重试。",
|
||||
Some(log),
|
||||
));
|
||||
}
|
||||
install()
|
||||
}
|
||||
|
||||
/// This log contains only our fixed stage names and version, never URLs,
|
||||
/// headers, auth/config contents or raw network/parser error messages.
|
||||
struct UpdateLog {
|
||||
path: PathBuf,
|
||||
file: Mutex<File>,
|
||||
}
|
||||
impl UpdateLog {
|
||||
fn create(dir: &Path, version: &str) -> std::io::Result<Self> {
|
||||
let version = semver::Version::parse(version).map_err(|_| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid version")
|
||||
})?;
|
||||
fs::create_dir_all(dir)?;
|
||||
let mut nonce = [0u8; 8];
|
||||
getrandom::getrandom(&mut nonce)
|
||||
.map_err(|_| std::io::Error::other("random source unavailable"))?;
|
||||
let nonce: String = nonce.iter().map(|b| format!("{b:02x}")).collect();
|
||||
let path = dir.join(format!(
|
||||
"update-{}-{}-{nonce}.log",
|
||||
chrono::Utc::now().format("%Y%m%d-%H%M%S"),
|
||||
std::process::id()
|
||||
));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let file = Mutex::new(options.open(&path)?);
|
||||
let log = Self { path, file };
|
||||
log.record(&format!("target_version={version}"));
|
||||
Ok(log)
|
||||
}
|
||||
fn record(&self, stage: &str) {
|
||||
if let Ok(mut file) = self.file.lock() {
|
||||
let _ = writeln!(file, "{} {stage}", chrono::Utc::now().to_rfc3339());
|
||||
let _ = file.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn timeout_ms(value: Option<u64>) -> u64 {
|
||||
value
|
||||
.unwrap_or(DOWNLOAD_TIMEOUT_MS)
|
||||
.clamp(30_000, DOWNLOAD_TIMEOUT_MS)
|
||||
}
|
||||
|
||||
enum DownloadAttemptFailure {
|
||||
Transport,
|
||||
Verification,
|
||||
}
|
||||
|
||||
async fn download_attempt(
|
||||
update: &Update,
|
||||
on_event: &Channel<AppUpdateEvent>,
|
||||
log: &UpdateLog,
|
||||
) -> Result<Vec<u8>, DownloadAttemptFailure> {
|
||||
let finished = AtomicBool::new(false);
|
||||
let mut started = false;
|
||||
update
|
||||
.download(
|
||||
|chunk_length, content_length| {
|
||||
if !started {
|
||||
started = true;
|
||||
let _ = on_event.send(AppUpdateEvent::Started { content_length });
|
||||
}
|
||||
let _ = on_event.send(AppUpdateEvent::Progress { chunk_length });
|
||||
},
|
||||
|| {
|
||||
finished.store(true, Ordering::Release);
|
||||
log.record("verifying_signature");
|
||||
let _ = on_event.send(AppUpdateEvent::Verifying);
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
if finished.load(Ordering::Acquire) {
|
||||
DownloadAttemptFailure::Verification
|
||||
} else {
|
||||
DownloadAttemptFailure::Transport
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn download_with_recovery(
|
||||
update: &Update,
|
||||
mut candidates: Vec<reqwest::Url>,
|
||||
on_event: &Channel<AppUpdateEvent>,
|
||||
log: &UpdateLog,
|
||||
) -> Result<Vec<u8>, UpdateFailure> {
|
||||
match download_attempt(update, on_event, log).await {
|
||||
Ok(bytes) => return Ok(bytes),
|
||||
Err(DownloadAttemptFailure::Verification) => {
|
||||
log.record("signature_verification_failed");
|
||||
return Err(failure(
|
||||
FailureStage::Verify,
|
||||
"更新包校验未通过,未启动安装。请重新检查在线更新。",
|
||||
Some(log),
|
||||
));
|
||||
}
|
||||
Err(DownloadAttemptFailure::Transport) => log.record("primary_download_transport_failed"),
|
||||
}
|
||||
if candidates.is_empty() {
|
||||
match crate::app_update_channel::discover_download_candidates(update).await {
|
||||
Ok(found) => candidates = found,
|
||||
Err(_) => log.record("official_api_asset_lookup_failed"),
|
||||
}
|
||||
}
|
||||
for candidate in candidates {
|
||||
let alternate = match crate::app_update_channel::api_download_update(update, &candidate) {
|
||||
Ok(alternate) => alternate,
|
||||
Err(_) => continue,
|
||||
};
|
||||
log.record("retrying_download_via_official_api");
|
||||
match download_attempt(&alternate, on_event, log).await {
|
||||
Ok(bytes) => {
|
||||
log.record("official_api_download_verified");
|
||||
return Ok(bytes);
|
||||
}
|
||||
Err(DownloadAttemptFailure::Verification) => {
|
||||
log.record("signature_verification_failed");
|
||||
return Err(failure(
|
||||
FailureStage::Verify,
|
||||
"更新包校验未通过,未启动安装。请重新检查在线更新。",
|
||||
Some(log),
|
||||
));
|
||||
}
|
||||
Err(DownloadAttemptFailure::Transport) => {
|
||||
log.record("official_api_download_transport_failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(failure(
|
||||
FailureStage::Download,
|
||||
"在线更新的主通道和官方备用通道均未能完成下载。请检查网络或代理后重试。",
|
||||
Some(log),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn verify_download_recovery_for_test(
|
||||
update: &Update,
|
||||
candidates: Vec<reqwest::Url>,
|
||||
) -> Result<Vec<u8>, UpdateFailure> {
|
||||
let temporary = tempfile::tempdir().expect("temporary updater test log");
|
||||
let log = UpdateLog::create(temporary.path(), &update.version).expect("updater test log");
|
||||
let channel = Channel::new(|_| Ok(()));
|
||||
let bytes = download_with_recovery(update, candidates, &channel, &log).await?;
|
||||
// Exercise the Windows staging handoff with verified fixture bytes only.
|
||||
// Dropping this private temporary file never executes an installer.
|
||||
let staged = windows::StagedInstaller::create(&bytes)
|
||||
.expect("verified synthetic Windows packet stages successfully");
|
||||
drop(staged);
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// No installer is launched until all preparation succeeds. If launch fails,
|
||||
/// restore routing while the original app and single-instance lock still live.
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
fn prepared_install<T>(
|
||||
prepare: impl FnOnce() -> Result<(), ()>,
|
||||
launch: impl FnOnce() -> Result<T, ()>,
|
||||
resume: impl FnOnce() -> Result<(), ()>,
|
||||
log: &UpdateLog,
|
||||
) -> Result<T, UpdateFailure> {
|
||||
if prepare().is_err() {
|
||||
log.record("prepare_failed");
|
||||
let resumed = resume().is_ok();
|
||||
log.record(if resumed {
|
||||
"routing_resumed"
|
||||
} else {
|
||||
"routing_resume_failed"
|
||||
});
|
||||
return Err(failure(
|
||||
FailureStage::Prepare,
|
||||
if resumed {
|
||||
"更新前未能恢复路由配置,安装尚未启动。请检查配置文件后重试。"
|
||||
} else {
|
||||
"更新前的路由清理未完成,安装尚未启动。请检查路由设置后重试。"
|
||||
},
|
||||
Some(log),
|
||||
));
|
||||
}
|
||||
match launch() {
|
||||
Ok(result) => Ok(result),
|
||||
Err(()) => {
|
||||
log.record("installer_launch_failed");
|
||||
let resumed = resume().is_ok();
|
||||
log.record(if resumed {
|
||||
"routing_resumed"
|
||||
} else {
|
||||
"routing_resume_failed"
|
||||
});
|
||||
Err(failure(
|
||||
FailureStage::Install,
|
||||
if resumed {
|
||||
"无法启动更新安装器,原有路由已恢复。请重试或打开下载页安装。"
|
||||
} else {
|
||||
"无法启动更新安装器。请检查路由设置;也可打开下载页安装。"
|
||||
},
|
||||
Some(log),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn install_app_update(
|
||||
webview: Webview,
|
||||
update_rid: ResourceId,
|
||||
on_event: Channel<AppUpdateEvent>,
|
||||
timeout: Option<u64>,
|
||||
headers: Option<Vec<(String, String)>>,
|
||||
) -> Result<InstallResult, UpdateFailure> {
|
||||
let _lease = UpdateLease::acquire(&UPDATING)?;
|
||||
let (mut update, download_candidates) = if let Ok(owned) =
|
||||
webview
|
||||
.resources_table()
|
||||
.get::<crate::app_update_channel::OwnedOnlineUpdate>(update_rid)
|
||||
{
|
||||
(owned.update.clone(), owned.download_candidates.clone())
|
||||
} else {
|
||||
let update = webview
|
||||
.resources_table()
|
||||
.get::<Update>(update_rid)
|
||||
.map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Prepare,
|
||||
"更新信息已失效,请重新检查更新。",
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
((*update).clone(), Vec::new())
|
||||
};
|
||||
// The metadata-check timeout must not become the package-download limit.
|
||||
update.timeout = Some(Duration::from_millis(timeout_ms(timeout)));
|
||||
if let Some(headers) = headers {
|
||||
update.headers.clear();
|
||||
for (name, value) in headers {
|
||||
let name = reqwest::header::HeaderName::from_bytes(name.as_bytes())
|
||||
.map_err(|_| failure(FailureStage::Prepare, "更新请求参数无效。", None))?;
|
||||
let value = reqwest::header::HeaderValue::from_str(&value)
|
||||
.map_err(|_| failure(FailureStage::Prepare, "更新请求参数无效。", None))?;
|
||||
update.headers.append(name, value);
|
||||
}
|
||||
}
|
||||
let dir = crate::paths::app_home()
|
||||
.map_err(|_| failure(FailureStage::Prepare, "无法读取更新日志目录。", None))?
|
||||
.join("update-logs");
|
||||
let log = UpdateLog::create(&dir, &update.version).map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Prepare,
|
||||
"无法创建更新日志,请检查文件夹权限后重试。",
|
||||
None,
|
||||
)
|
||||
})?;
|
||||
log.record("download_started");
|
||||
let bytes = download_with_recovery(&update, download_candidates, &on_event, &log).await?;
|
||||
log.record("signature_verified");
|
||||
let exit_guard = InstallationExitGuard::begin(&INSTALLATION_PHASE);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app = webview.app_handle().clone();
|
||||
let result = install_windows(app, bytes, on_event, log, exit_guard).await;
|
||||
if result.is_ok() {
|
||||
_lease.handoff();
|
||||
}
|
||||
result
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = on_event.send(AppUpdateEvent::Preparing);
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let _exit_guard = exit_guard;
|
||||
install_with_route_preflight(
|
||||
|| crate::failover::begin_update_handoff().map_err(|_| ()),
|
||||
|| {
|
||||
let _ = on_event.send(AppUpdateEvent::Installing);
|
||||
log.record("install_started");
|
||||
update.install(bytes).map_err(|_| {
|
||||
log.record("install_failed");
|
||||
if crate::failover::resume_after_failed_update().is_err() {
|
||||
log.record("routing_resume_failed");
|
||||
}
|
||||
failure(
|
||||
FailureStage::Install,
|
||||
"安装更新未完成,请重试或前往下载页安装。",
|
||||
Some(&log),
|
||||
)
|
||||
})
|
||||
},
|
||||
&log,
|
||||
)?;
|
||||
// The application bytes are already installed. A recovery warning
|
||||
// must not turn this into an install retry or a second installer.
|
||||
if crate::failover::resume_after_failed_update().is_err() {
|
||||
log.record("routing_resume_failed_after_install_restart_required");
|
||||
}
|
||||
log.record("install_finished_restart_required");
|
||||
Ok(InstallResult {
|
||||
restart_required: true,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
let _ = crate::failover::resume_after_failed_update();
|
||||
failure(
|
||||
FailureStage::Install,
|
||||
"安装更新未完成,请重新打开软件后检查更新。",
|
||||
None,
|
||||
)
|
||||
})?
|
||||
}
|
||||
}
|
||||
|
||||
// Compile this path in host tests too: staging/Command/IPC use portable Rust,
|
||||
// so Windows handoff type errors are caught before the Windows build starts.
|
||||
#[cfg(any(test, target_os = "windows"))]
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
async fn install_windows(
|
||||
app: tauri::AppHandle,
|
||||
bytes: Vec<u8>,
|
||||
on_event: Channel<AppUpdateEvent>,
|
||||
log: UpdateLog,
|
||||
exit_guard: InstallationExitGuard<'static>,
|
||||
) -> Result<InstallResult, UpdateFailure> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
// Stage bytes before changing live routes. Only signed .exe payloads
|
||||
// from this release pipeline are supported by the new Windows flow.
|
||||
let installer = windows::StagedInstaller::create(&bytes).map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Install,
|
||||
"无法准备 Windows 更新包,请检查磁盘空间或前往下载页安装。",
|
||||
Some(&log),
|
||||
)
|
||||
})?;
|
||||
let _ = on_event.send(AppUpdateEvent::Preparing);
|
||||
log.record("preparing_route_shutdown");
|
||||
install_with_route_preflight(
|
||||
|| crate::failover::ensure_shutdown_allowed().map_err(|_| ()),
|
||||
|| {
|
||||
prepared_install(
|
||||
|| crate::failover::shutdown_all().map_err(|_| ()),
|
||||
|| {
|
||||
let _ = on_event.send(AppUpdateEvent::Installing);
|
||||
log.record("launching_installer");
|
||||
installer.launch(std::process::id()).map_err(|_| ())
|
||||
},
|
||||
|| crate::failover::resume_after_failed_update().map_err(|_| ()),
|
||||
&log,
|
||||
)
|
||||
},
|
||||
&log,
|
||||
)?;
|
||||
log.record("installer_handed_off");
|
||||
let _ = on_event.send(AppUpdateEvent::HandedOff);
|
||||
// Normal Tauri exit destroys windows/tray/single-instance lock. The
|
||||
// new installer waits for this PID before touching installed files.
|
||||
exit_guard.handed_off();
|
||||
app.exit(0);
|
||||
Ok(InstallResult {
|
||||
restart_required: false,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
failure(
|
||||
FailureStage::Install,
|
||||
"更新准备过程被中断,请重新打开 Codex-X 后检查更新。",
|
||||
None,
|
||||
)
|
||||
})?
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
#[test]
|
||||
fn installation_exit_guard_releases_on_failure_and_allows_windows_handoff() {
|
||||
let phase = AtomicU8::new(0);
|
||||
{
|
||||
let _guard = InstallationExitGuard::begin(&phase);
|
||||
assert_eq!(phase.load(Ordering::Acquire), 1);
|
||||
}
|
||||
assert_eq!(phase.load(Ordering::Acquire), 0);
|
||||
InstallationExitGuard::begin(&phase).handed_off();
|
||||
assert_eq!(phase.load(Ordering::Acquire), 2);
|
||||
assert_ne!(phase.load(Ordering::Acquire), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_recovery_preflight_blocks_install_before_application_bytes_change() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let log = UpdateLog::create(temp.path(), "0.4.0").unwrap();
|
||||
let app = temp.path().join("fixture-app");
|
||||
fs::write(&app, b"original fixture").unwrap();
|
||||
let error = install_with_route_preflight(
|
||||
|| Err(()),
|
||||
|| {
|
||||
fs::write(&app, b"replacement fixture").unwrap();
|
||||
Ok(())
|
||||
},
|
||||
&log,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(error.stage, FailureStage::Prepare);
|
||||
assert!(error.message.contains("可恢复"));
|
||||
assert!(error.message.contains("安装尚未开始"));
|
||||
assert_eq!(fs::read(&app).unwrap(), b"original fixture");
|
||||
let stages = fs::read_to_string(&log.path).unwrap();
|
||||
assert!(stages.contains("routing_recovery_preflight_failed"));
|
||||
assert!(!stages.contains("install_started"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_route_preflight_installs_once_and_preserves_installer_errors() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let log = UpdateLog::create(temp.path(), "0.4.0").unwrap();
|
||||
let calls = RefCell::new(vec![]);
|
||||
let error = install_with_route_preflight(
|
||||
|| {
|
||||
calls.borrow_mut().push("check");
|
||||
Ok(())
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("install");
|
||||
Err::<(), _>(failure(
|
||||
FailureStage::Install,
|
||||
"fixture install error",
|
||||
Some(&log),
|
||||
))
|
||||
},
|
||||
&log,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(*calls.borrow(), vec!["check", "install"]);
|
||||
assert_eq!(error.stage, FailureStage::Install);
|
||||
assert_eq!(error.message, "fixture install error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_update_at_a_time_and_errors_release_the_guard() {
|
||||
let flag = AtomicBool::new(false);
|
||||
let first = UpdateLease::acquire(&flag).unwrap();
|
||||
assert!(UpdateLease::acquire(&flag).is_err());
|
||||
drop(first);
|
||||
assert!(UpdateLease::acquire(&flag).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn package_download_has_its_own_bounded_timeout() {
|
||||
assert_eq!(timeout_ms(None), 600_000);
|
||||
assert_eq!(timeout_ms(Some(15_000)), 30_000);
|
||||
assert_eq!(timeout_ms(Some(u64::MAX)), 600_000);
|
||||
assert_eq!(timeout_ms(Some(120_000)), 120_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handoff_never_launches_on_failed_prepare_and_resumes_on_launch_failure() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let log = UpdateLog::create(temp.path(), "0.4.0").unwrap();
|
||||
let calls = RefCell::new(vec![]);
|
||||
let fail = prepared_install(
|
||||
|| {
|
||||
calls.borrow_mut().push("prepare");
|
||||
Err(())
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("launch");
|
||||
Ok(())
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("resume");
|
||||
Ok(())
|
||||
},
|
||||
&log,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(fail.stage, FailureStage::Prepare);
|
||||
assert_eq!(*calls.borrow(), vec!["prepare", "resume"]);
|
||||
calls.borrow_mut().clear();
|
||||
let fail = prepared_install(
|
||||
|| {
|
||||
calls.borrow_mut().push("prepare");
|
||||
Ok(())
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("launch");
|
||||
Err::<(), _>(())
|
||||
},
|
||||
|| {
|
||||
calls.borrow_mut().push("resume");
|
||||
Err(())
|
||||
},
|
||||
&log,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(fail.stage, FailureStage::Install);
|
||||
assert_eq!(*calls.borrow(), vec!["prepare", "launch", "resume"]);
|
||||
assert!(fail.message.contains("检查路由设置"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_handoff_does_not_resume_routing() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let log = UpdateLog::create(temp.path(), "0.4.0").unwrap();
|
||||
assert_eq!(
|
||||
prepared_install(|| Ok(()), || Ok(42), || panic!("must not resume"), &log).unwrap(),
|
||||
42
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_are_unique_and_reject_untrusted_version_text() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let first = UpdateLog::create(temp.path(), "0.4.0").unwrap();
|
||||
let second = UpdateLog::create(temp.path(), "0.4.0").unwrap();
|
||||
assert_ne!(first.path, second.path);
|
||||
assert!(UpdateLog::create(temp.path(), "0.4.0\nsecret").is_err());
|
||||
first.record("signature_verified");
|
||||
assert!(fs::read_to_string(&first.path)
|
||||
.unwrap()
|
||||
.contains("signature_verified"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
use crate::constants::AGENTS_FILENAME;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, write_json};
|
||||
use crate::paths::app_home;
|
||||
use crate::prompts::agents_path;
|
||||
use crate::{auth_path, config_path};
|
||||
use chrono::Local;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BackupMeta {
|
||||
pub(crate) id: String,
|
||||
pub(crate) action: String,
|
||||
pub(crate) created_at: String,
|
||||
pub(crate) codex_dir: String,
|
||||
pub(crate) config_path: String,
|
||||
pub(crate) auth_path: String,
|
||||
pub(crate) had_config: bool,
|
||||
pub(crate) had_auth: bool,
|
||||
#[serde(default)]
|
||||
pub(crate) agents_path: String,
|
||||
#[serde(default)]
|
||||
pub(crate) had_agents: bool,
|
||||
#[serde(default)]
|
||||
pub(crate) tracks_agents: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BackupEntry {
|
||||
id: String,
|
||||
action: String,
|
||||
created_at: String,
|
||||
path: String,
|
||||
had_config: bool,
|
||||
had_auth: bool,
|
||||
had_agents: bool,
|
||||
}
|
||||
|
||||
fn lexical_absolute_path(path: &Path) -> Result<PathBuf> {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|error| io_err(path, error))?
|
||||
.join(path)
|
||||
};
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in absolute.components() {
|
||||
match component {
|
||||
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
|
||||
Component::RootDir => normalized.push(component.as_os_str()),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
normalized.pop();
|
||||
}
|
||||
Component::Normal(part) => normalized.push(part),
|
||||
}
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
/// Resolves every existing ancestor while retaining a normalized suffix. This
|
||||
/// compares missing CODEX_HOME targets without requiring the target to exist.
|
||||
fn normalized_path_identity(path: &Path) -> Result<PathBuf> {
|
||||
let absolute = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.map_err(|error| io_err(path, error))?
|
||||
.join(path)
|
||||
};
|
||||
for ancestor in absolute.ancestors() {
|
||||
match fs::canonicalize(ancestor) {
|
||||
Ok(canonical) => {
|
||||
let suffix = absolute
|
||||
.strip_prefix(ancestor)
|
||||
.expect("ancestor must be a path prefix");
|
||||
return lexical_absolute_path(&canonical.join(suffix));
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(io_err(ancestor, error)),
|
||||
}
|
||||
}
|
||||
lexical_absolute_path(&absolute)
|
||||
}
|
||||
|
||||
fn same_path_identity(left: &Path, right: &Path) -> Result<bool> {
|
||||
let left = normalized_path_identity(left)?;
|
||||
let right = normalized_path_identity(right)?;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let left = left.to_string_lossy().replace('/', "\\");
|
||||
let right = right.to_string_lossy().replace('/', "\\");
|
||||
Ok(left.eq_ignore_ascii_case(&right))
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Ok(left == right)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_backup_codex_dir(meta: &BackupMeta, codex_dir: &Path) -> Result<()> {
|
||||
let recorded = meta.codex_dir.trim();
|
||||
if recorded.is_empty() {
|
||||
return Err(CodexxError::Config("备份元数据缺少 CODEX_HOME".to_string()));
|
||||
}
|
||||
if same_path_identity(Path::new(recorded), codex_dir)? {
|
||||
return Ok(());
|
||||
}
|
||||
Err(CodexxError::Config(format!(
|
||||
"备份属于其他 CODEX_HOME,拒绝恢复:备份为 {},当前为 {}",
|
||||
Path::new(recorded).display(),
|
||||
codex_dir.display()
|
||||
)))
|
||||
}
|
||||
|
||||
fn backup_root() -> Result<PathBuf> {
|
||||
Ok(app_home()?.join("backups"))
|
||||
}
|
||||
|
||||
pub(crate) fn action_backup_root(codex_dir: &Path) -> Result<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
Ok(codex_dir.join(".codexx-test-backups"))
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let _ = codex_dir;
|
||||
backup_root()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn create_backup(codex_dir: &Path, action: &str) -> Result<Option<String>> {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static BACKUP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let cfg = config_path(codex_dir);
|
||||
let auth = auth_path(codex_dir);
|
||||
let agents = agents_path(codex_dir);
|
||||
let had_config = cfg.exists();
|
||||
let had_auth = auth.exists();
|
||||
let had_agents = agents.exists();
|
||||
|
||||
let id = format!(
|
||||
"{}-{}-{}",
|
||||
Local::now().format("%Y%m%d-%H%M%S-%3f"),
|
||||
BACKUP_COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
action
|
||||
);
|
||||
let dir = action_backup_root(codex_dir)?.join(&id);
|
||||
ensure_directory(&dir)?;
|
||||
|
||||
if had_config {
|
||||
fs::copy(&cfg, dir.join("config.toml")).map_err(|e| io_err(&cfg, e))?;
|
||||
}
|
||||
if had_auth {
|
||||
fs::copy(&auth, dir.join("auth.json")).map_err(|e| io_err(&auth, e))?;
|
||||
}
|
||||
if had_agents {
|
||||
fs::copy(&agents, dir.join(AGENTS_FILENAME)).map_err(|e| io_err(&agents, e))?;
|
||||
}
|
||||
|
||||
let meta = BackupMeta {
|
||||
id: id.clone(),
|
||||
action: action.to_string(),
|
||||
created_at: Local::now().to_rfc3339(),
|
||||
codex_dir: codex_dir.display().to_string(),
|
||||
config_path: cfg.display().to_string(),
|
||||
auth_path: auth.display().to_string(),
|
||||
had_config,
|
||||
had_auth,
|
||||
agents_path: agents.display().to_string(),
|
||||
had_agents,
|
||||
tracks_agents: true,
|
||||
};
|
||||
write_json(
|
||||
&dir.join("meta.json"),
|
||||
&serde_json::to_value(meta).expect("meta serialize"),
|
||||
)?;
|
||||
Ok(Some(id))
|
||||
}
|
||||
|
||||
fn read_backup_entry(dir: &Path) -> Option<BackupEntry> {
|
||||
let meta_path = dir.join("meta.json");
|
||||
let text = fs::read_to_string(&meta_path).ok()?;
|
||||
let meta: BackupMeta = serde_json::from_str(&text).ok()?;
|
||||
Some(BackupEntry {
|
||||
id: meta.id,
|
||||
action: meta.action,
|
||||
created_at: meta.created_at,
|
||||
path: dir.display().to_string(),
|
||||
had_config: meta.had_config,
|
||||
had_auth: meta.had_auth,
|
||||
had_agents: meta.had_agents,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn backups() -> Result<Vec<BackupEntry>> {
|
||||
let root = backup_root()?;
|
||||
if !root.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut entries = Vec::new();
|
||||
for entry in fs::read_dir(&root).map_err(|e| io_err(&root, e))? {
|
||||
let entry = entry.map_err(|e| io_err(&root, e))?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(backup) = read_backup_entry(&path) {
|
||||
entries.push(backup);
|
||||
}
|
||||
}
|
||||
}
|
||||
entries.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
Ok(entries)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::paths::home_dir;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn push_existing_candidate(candidates: &mut Vec<PathBuf>, candidate: Option<PathBuf>) {
|
||||
let Some(path) = candidate else {
|
||||
return;
|
||||
};
|
||||
if !candidates.iter().any(|item| item == &path) {
|
||||
candidates.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ccswitch_db_candidates() -> Result<Vec<PathBuf>> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
if let Ok(value) = std::env::var("CC_SWITCH_HOME") {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(PathBuf::from(trimmed).join("cc-switch.db")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let home = home_dir()?;
|
||||
// cc-switch 当前主要使用这个位置,macOS/Windows/Linux 都适用。
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(home.join(".cc-switch").join("cc-switch.db")),
|
||||
);
|
||||
|
||||
// 兼容 Tauri/AppData 风格位置,防止未来或不同发行版变更数据目录。
|
||||
if let Some(data_dir) = dirs::data_dir() {
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(data_dir.join("com.ccswitch.desktop").join("cc-switch.db")),
|
||||
);
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(data_dir.join("cc-switch").join("cc-switch.db")),
|
||||
);
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(data_dir.join("CC Switch").join("cc-switch.db")),
|
||||
);
|
||||
}
|
||||
if let Some(data_local_dir) = dirs::data_local_dir() {
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(
|
||||
data_local_dir
|
||||
.join("com.ccswitch.desktop")
|
||||
.join("cc-switch.db"),
|
||||
),
|
||||
);
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(data_local_dir.join("cc-switch").join("cc-switch.db")),
|
||||
);
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(data_local_dir.join("CC Switch").join("cc-switch.db")),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(
|
||||
home.join("Library")
|
||||
.join("Application Support")
|
||||
.join("com.ccswitch.desktop")
|
||||
.join("cc-switch.db"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(
|
||||
PathBuf::from(appdata)
|
||||
.join("com.ccswitch.desktop")
|
||||
.join("cc-switch.db"),
|
||||
),
|
||||
);
|
||||
}
|
||||
if let Ok(localappdata) = std::env::var("LOCALAPPDATA") {
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(
|
||||
PathBuf::from(localappdata)
|
||||
.join("com.ccswitch.desktop")
|
||||
.join("cc-switch.db"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME") {
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(
|
||||
PathBuf::from(xdg_data_home)
|
||||
.join("com.ccswitch.desktop")
|
||||
.join("cc-switch.db"),
|
||||
),
|
||||
);
|
||||
}
|
||||
push_existing_candidate(
|
||||
&mut candidates,
|
||||
Some(
|
||||
home.join(".local")
|
||||
.join("share")
|
||||
.join("com.ccswitch.desktop")
|
||||
.join("cc-switch.db"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
pub(crate) fn default_ccswitch_db_path() -> Result<PathBuf> {
|
||||
let candidates = ccswitch_db_candidates()?;
|
||||
candidates
|
||||
.iter()
|
||||
.find(|path| path.exists())
|
||||
.cloned()
|
||||
.or_else(|| candidates.into_iter().next())
|
||||
.ok_or_else(|| CodexxError::Config("无法生成 cc-switch 数据库候选路径".to_string()))
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
//! Windows desktop discovery and activation. This module deliberately uses
|
||||
//! Windows' registered package/signature/process APIs, never executable names
|
||||
//! alone, before stopping a process or attaching to its debugging endpoint.
|
||||
#![cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
|
||||
use base64::Engine;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct WindowsDesktopApp {
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) executable: PathBuf,
|
||||
pub(crate) bundle_id: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) version: String,
|
||||
}
|
||||
|
||||
fn stamps() -> &'static Mutex<HashMap<u32, String>> {
|
||||
static STAMPS: OnceLock<Mutex<HashMap<u32, String>>> = OnceLock::new();
|
||||
STAMPS.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn powershell(script: &str, data: Value, timeout: Duration) -> Result<Value, String> {
|
||||
let system_root = std::env::var_os("SystemRoot").ok_or("无法定位 Windows 系统目录")?;
|
||||
let executable =
|
||||
PathBuf::from(system_root).join("System32/WindowsPowerShell/v1.0/powershell.exe");
|
||||
let encoded = base64::engine::general_purpose::STANDARD
|
||||
.encode(serde_json::to_vec(&data).map_err(|_| "无法编码 Windows 客户端操作参数")?);
|
||||
// All variable input is JSON data. Paths and config values are never
|
||||
// interpolated as PowerShell source or passed through a command shell.
|
||||
let source = format!(
|
||||
"$ErrorActionPreference='Stop'; $ProgressPreference='SilentlyContinue'; \
|
||||
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); \
|
||||
$data=([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('{encoded}')) | ConvertFrom-Json);\n\
|
||||
{COMMON}\n{script}"
|
||||
);
|
||||
let output = crate::platform::desktop_command_output(
|
||||
&executable,
|
||||
&[
|
||||
"-NoLogo",
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-Command",
|
||||
&source,
|
||||
],
|
||||
timeout,
|
||||
)?;
|
||||
if !output.status.success() {
|
||||
let details = String::from_utf8_lossy(&output.stderr)
|
||||
.chars()
|
||||
.take(1800)
|
||||
.collect::<String>();
|
||||
return Err(format!("Windows Codex 操作未完成:{}", details.trim()));
|
||||
}
|
||||
serde_json::from_slice(&output.stdout).map_err(|_| "Windows 未返回有效的客户端操作结果".into())
|
||||
}
|
||||
|
||||
const COMMON: &str = r#"
|
||||
function FullPath($path) { [IO.Path]::GetFullPath([string]$path).TrimEnd('\') }
|
||||
function SamePath($left, $right) {
|
||||
[string]::Equals((FullPath $left), (FullPath $right), [StringComparison]::OrdinalIgnoreCase)
|
||||
}
|
||||
function ProcessStamp($process) {
|
||||
if ($null -eq $process.CreationDate) { throw 'Cannot verify the process creation time.' }
|
||||
$process.CreationDate.ToUniversalTime().Ticks.ToString([Globalization.CultureInfo]::InvariantCulture)
|
||||
}
|
||||
function AppProcesses($exe) {
|
||||
$processes=@(Get-CimInstance Win32_Process -Filter "Name='Codex.exe' OR Name='ChatGPT.exe'")
|
||||
if (@($processes | Where-Object { -not $_.ExecutablePath }).Count -gt 0) {
|
||||
throw 'A Codex/ChatGPT process path is inaccessible. Use matching non-elevated permissions before restarting.'
|
||||
}
|
||||
@($processes | Where-Object { SamePath $_.ExecutablePath $exe })
|
||||
}
|
||||
function MainProcesses($exe) {
|
||||
@(AppProcesses $exe | Where-Object {
|
||||
if ($null -eq $_.CommandLine) { throw 'Cannot verify the desktop process command line.' }
|
||||
$_.CommandLine -notmatch '(?i)(?:^|\s)--type(?:=|\s)' -and
|
||||
$_.CommandLine -notmatch '(?i)(?:^|\s)--crashpad-handler(?:\s|$)'
|
||||
})
|
||||
}
|
||||
function BoundProcess($entry, $exe) {
|
||||
$process=Get-Process -Id $entry.ProcessId -ErrorAction SilentlyContinue
|
||||
if ($null -eq $process) { return $null }
|
||||
try {
|
||||
# Holding this handle pins the process object/PID through the subsequent
|
||||
# identity checks and CloseMainWindow/Kill call.
|
||||
$null=$process.Handle
|
||||
$created=$process.StartTime.ToUniversalTime().Ticks
|
||||
$expected=[long](ProcessStamp $entry)
|
||||
if (-not (SamePath $process.MainModule.FileName $exe) -or
|
||||
[Math]::Abs($created-$expected) -ge 10) { $process.Dispose(); return $null }
|
||||
# CIM timestamps have microsecond precision, while StartTime has 100ns.
|
||||
return $process
|
||||
} catch { $process.Dispose(); return $null }
|
||||
}
|
||||
function IsDesktopExe($exe) {
|
||||
$leaf=[IO.Path]::GetFileName([string]$exe)
|
||||
($leaf -ieq 'Codex.exe' -or $leaf -ieq 'ChatGPT.exe') -and
|
||||
(Test-Path -LiteralPath $exe -PathType Leaf) -and
|
||||
(Test-Path -LiteralPath (Join-Path ([IO.Path]::GetDirectoryName($exe)) 'resources\app.asar') -PathType Leaf)
|
||||
}
|
||||
function TrustedStandalone($exe) {
|
||||
if (-not (IsDesktopExe $exe)) { return $false }
|
||||
$signature=Get-AuthenticodeSignature -LiteralPath $exe
|
||||
if ($signature.Status -ne 'Valid' -or $null -eq $signature.SignerCertificate) { return $false }
|
||||
$publisher=$signature.SignerCertificate.GetNameInfo([Security.Cryptography.X509Certificates.X509NameType]::SimpleName,$false)
|
||||
@('OpenAI OpCo, LLC','OpenAI, L.L.C.') -ccontains $publisher
|
||||
}
|
||||
function TrustedPackage($package) {
|
||||
$null -ne $package -and
|
||||
@('OpenAI.Codex','OpenAI.CodexBeta','OpenAI.ChatGPT-Desktop') -ccontains [string]$package.Name -and
|
||||
[string]$package.PublisherId -ceq '2p2nqsd0c76g0' -and
|
||||
[string]$package.PackageFamilyName -ceq ($package.Name+'_2p2nqsd0c76g0') -and
|
||||
[string]$package.Status -eq 'Ok' -and [string]$package.SignatureKind -eq 'Store' -and
|
||||
-not $package.IsDevelopmentMode
|
||||
}
|
||||
function PackageApp($package) {
|
||||
$root=FullPath $package.InstallLocation
|
||||
$manifest=Get-AppxPackageManifest -Package $package.PackageFullName
|
||||
foreach ($entry in @($manifest.Package.Applications.Application)) {
|
||||
if (-not $entry.Executable -or -not $entry.Id) { continue }
|
||||
$exe=FullPath (Join-Path $root ([string]$entry.Executable))
|
||||
if (-not $exe.StartsWith($root+'\',[StringComparison]::OrdinalIgnoreCase)) { continue }
|
||||
if (-not (IsDesktopExe $exe)) { continue }
|
||||
return [pscustomobject]@{path=$root;executable=$exe;bundleId=($package.PackageFamilyName+'!'+$entry.Id);
|
||||
name=$(if($package.Name -eq 'OpenAI.ChatGPT-Desktop'){'ChatGPT'}else{'Codex'});version=[string]$package.Version}
|
||||
}
|
||||
return $null
|
||||
}
|
||||
function ValidateApp($app) {
|
||||
if (-not (IsDesktopExe $app.executable)) { throw 'The selected path is not the official Electron desktop application.' }
|
||||
if ([string]$app.bundleId -eq 'OpenAI.Codex.Standalone') {
|
||||
if (-not (SamePath ([IO.Path]::GetDirectoryName($app.executable)) $app.path) -or
|
||||
-not (TrustedStandalone $app.executable)) { throw 'The desktop executable has no valid OpenAI publisher signature.' }
|
||||
return
|
||||
}
|
||||
$principal=[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent())
|
||||
if ($principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
throw 'Microsoft Store activation requires Codex-X to run without administrator elevation. No client was stopped.'
|
||||
}
|
||||
$family=([string]$app.bundleId).Split('!')[0]
|
||||
$package=@(Get-AppxPackage | Where-Object { [string]$_.PackageFamilyName -ceq $family }) | Select-Object -First 1
|
||||
if (-not (TrustedPackage $package)) { throw 'The official signed Microsoft Store package is no longer registered.' }
|
||||
$registered=PackageApp $package
|
||||
if ($null -eq $registered -or $registered.bundleId -cne $app.bundleId -or
|
||||
-not (SamePath $registered.path $app.path) -or
|
||||
-not (SamePath $registered.executable $app.executable)) { throw 'The registered Store application identity has changed.' }
|
||||
}
|
||||
function Emit($result) { ConvertTo-Json -InputObject $result -Depth 6 -Compress }
|
||||
"#;
|
||||
|
||||
pub(crate) fn detect() -> Result<WindowsDesktopApp, String> {
|
||||
type Cached = (Instant, Result<WindowsDesktopApp, String>);
|
||||
static CACHE: OnceLock<Mutex<Option<Cached>>> = OnceLock::new();
|
||||
let mut cache = CACHE
|
||||
.get_or_init(|| Mutex::new(None))
|
||||
.lock()
|
||||
.map_err(|_| "Windows 客户端检测状态忙")?;
|
||||
if let Some((recorded, value)) = cache.as_ref() {
|
||||
let ttl = if value.is_ok() { 30 } else { 10 };
|
||||
if recorded.elapsed() < Duration::from_secs(ttl) {
|
||||
return value.clone();
|
||||
}
|
||||
}
|
||||
let value = detect_fresh();
|
||||
*cache = Some((Instant::now(), value.clone()));
|
||||
value
|
||||
}
|
||||
|
||||
pub(crate) fn detect_fresh() -> Result<WindowsDesktopApp, String> {
|
||||
let result = powershell(
|
||||
r#"
|
||||
$candidates=New-Object 'System.Collections.Generic.List[object]'
|
||||
foreach ($package in @(Get-AppxPackage | Sort-Object -Property Version -Descending)) {
|
||||
if (-not (TrustedPackage $package)) { continue }
|
||||
try { $app=PackageApp $package } catch { continue }
|
||||
if ($null -ne $app) { $candidates.Add($app) }
|
||||
}
|
||||
if ($env:LOCALAPPDATA) {
|
||||
$roots=@('OpenAI\Codex\bin','OpenAI\Codex','Programs\OpenAI\Codex','Programs\Codex')
|
||||
$seen=@{}
|
||||
foreach ($relative in $roots) {
|
||||
$root=Join-Path $env:LOCALAPPDATA $relative
|
||||
$directories=@($root,(Join-Path $root 'app'))
|
||||
if (Test-Path -LiteralPath $root -PathType Container) {
|
||||
foreach ($child in @(Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue | Select-Object -First 32)) {
|
||||
$directories+=@($child.FullName,(Join-Path $child.FullName 'app'))
|
||||
}
|
||||
}
|
||||
foreach ($directory in $directories) {
|
||||
foreach ($leaf in @('Codex.exe','ChatGPT.exe')) {
|
||||
$exe=Join-Path $directory $leaf
|
||||
$key=$exe.ToLowerInvariant()
|
||||
if ($seen.ContainsKey($key)) { continue }; $seen[$key]=$true
|
||||
if (-not (TrustedStandalone $exe)) { continue }
|
||||
$version=(Get-Item -LiteralPath $exe).VersionInfo.ProductVersion
|
||||
$candidates.Add([pscustomobject]@{path=(FullPath $directory);executable=(FullPath $exe);
|
||||
bundleId='OpenAI.Codex.Standalone';name='Codex';version=[string]$version})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$running=@()
|
||||
foreach ($app in $candidates) {
|
||||
$main=@(MainProcesses $app.executable)
|
||||
if ($main.Count -gt 1) { throw 'Multiple desktop instances are running. Keep one instance before restarting.' }
|
||||
if ($main.Count -eq 1) { $running+=,$app }
|
||||
}
|
||||
if ($running.Count -gt 1) { throw 'Multiple official desktop installations are running. Keep one before restarting.' }
|
||||
if ($running.Count -eq 1) { Emit $running[0]; exit }
|
||||
if ($candidates.Count -eq 0) { throw 'No verified OpenAI Codex Store package or signed standalone installation was found.' }
|
||||
Emit $candidates[0]
|
||||
"#,
|
||||
Value::Null,
|
||||
Duration::from_secs(25),
|
||||
)?;
|
||||
serde_json::from_value(result).map_err(|_| "Windows 客户端安装信息无效".into())
|
||||
}
|
||||
|
||||
pub(crate) fn validate(app: &WindowsDesktopApp) -> Result<(), String> {
|
||||
powershell(
|
||||
"ValidateApp $data.app; Emit $true",
|
||||
json!({"app":app}),
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_launch(app: &WindowsDesktopApp, home: &Path) -> Result<(), String> {
|
||||
if app.bundle_id != "OpenAI.Codex.Standalone" {
|
||||
let default = dirs::home_dir()
|
||||
.ok_or("无法确认 Windows 默认 Codex 配置目录")?
|
||||
.join(".codex");
|
||||
let requested = crate::paths::normalized_path_scope(home);
|
||||
let expected = crate::paths::normalized_path_scope(&default);
|
||||
if requested != expected {
|
||||
return Err("Windows Store 版无法通过安全激活传入自定义 CODEX_HOME;请使用默认 .codex 目录或官方独立安装版。客户端尚未退出。".into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn pids(app: &WindowsDesktopApp) -> Result<Vec<u32>, String> {
|
||||
let value = powershell(
|
||||
"Emit @(AppProcesses $data.app.executable | ForEach-Object { [uint32]$_.ProcessId })",
|
||||
json!({"app":app}),
|
||||
Duration::from_secs(8),
|
||||
)?;
|
||||
serde_json::from_value(value).map_err(|_| "无法确认 Windows 客户端进程".into())
|
||||
}
|
||||
|
||||
pub(crate) fn quit(app: &WindowsDesktopApp) -> Result<(), String> {
|
||||
powershell(
|
||||
r#"
|
||||
ValidateApp $data.app
|
||||
$main=@(MainProcesses $data.app.executable)
|
||||
if ($main.Count -gt 1) { throw 'Multiple desktop instances are running; none was stopped.' }
|
||||
foreach ($entry in $main) {
|
||||
$process=BoundProcess $entry $data.app.executable
|
||||
if ($null -ne $process) {
|
||||
try { $null=$process.CloseMainWindow() } finally { $process.Dispose() }
|
||||
}
|
||||
}
|
||||
$grace=[DateTime]::UtcNow.AddSeconds(2)
|
||||
while ([DateTime]::UtcNow -lt $grace -and @(AppProcesses $data.app.executable).Count -gt 0) { Start-Sleep -Milliseconds 100 }
|
||||
# The user explicitly requested a restart. If a tray/renderer remains, stop
|
||||
# only processes whose live executable path still matches this verified app.
|
||||
foreach ($entry in @(AppProcesses $data.app.executable)) {
|
||||
$process=BoundProcess $entry $data.app.executable
|
||||
if ($null -ne $process) {
|
||||
try { $process.Kill() } catch { } finally { $process.Dispose() }
|
||||
}
|
||||
}
|
||||
$deadline=[DateTime]::UtcNow.AddSeconds(8); $emptySince=$null
|
||||
do {
|
||||
if (@(AppProcesses $data.app.executable).Count -eq 0) {
|
||||
if ($null -eq $emptySince) { $emptySince=[DateTime]::UtcNow }
|
||||
if (([DateTime]::UtcNow-$emptySince).TotalMilliseconds -ge 500) { Emit $true; exit }
|
||||
} else { $emptySince=$null }
|
||||
Start-Sleep -Milliseconds 100
|
||||
} while ([DateTime]::UtcNow -lt $deadline)
|
||||
throw 'The selected desktop processes did not stop; relaunch was cancelled.'
|
||||
"#,
|
||||
json!({"app":app}),
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn launch(
|
||||
app: &WindowsDesktopApp,
|
||||
home: Option<&Path>,
|
||||
port: Option<u16>,
|
||||
) -> Result<u32, String> {
|
||||
if let Some(home) = home {
|
||||
validate_launch(app, home)?;
|
||||
}
|
||||
if app.bundle_id == "OpenAI.Codex.Standalone" {
|
||||
return launch_standalone(app, home, port);
|
||||
}
|
||||
let result = powershell(
|
||||
r#"
|
||||
ValidateApp $data.app
|
||||
if (@(AppProcesses $data.app.executable).Count -gt 0) { throw 'The desktop application is still stopping; no duplicate instance was launched.' }
|
||||
$arguments=''
|
||||
if ($null -ne $data.port) { $arguments='--remote-debugging-port='+[string]$data.port+' --remote-debugging-address=127.0.0.1' }
|
||||
Add-Type -TypeDefinition @'
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
[ComImport, Guid("2e941141-7f97-4756-ba1d-9decde894a3d"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
interface ICodexActivationManager {
|
||||
[PreserveSig] int ActivateApplication([MarshalAs(UnmanagedType.LPWStr)] string appId,
|
||||
[MarshalAs(UnmanagedType.LPWStr)] string arguments, uint options, out uint processId);
|
||||
}
|
||||
public static class CodexPackageActivation {
|
||||
public static uint Start(string appId, string arguments) {
|
||||
var manager=(ICodexActivationManager)Activator.CreateInstance(Type.GetTypeFromCLSID(new Guid("45ba127d-10a8-46ea-8ab7-56ea9078943c")));
|
||||
try { uint processId; int result=manager.ActivateApplication(appId, arguments, 0, out processId);
|
||||
Marshal.ThrowExceptionForHR(result); return processId; }
|
||||
finally { Marshal.FinalReleaseComObject(manager); }
|
||||
}
|
||||
}
|
||||
'@ | Out-Null
|
||||
$started=[CodexPackageActivation]::Start([string]$data.app.bundleId,$arguments)
|
||||
if ($started -eq 0) { throw 'Windows did not return an activated process id.' }
|
||||
$created=''; $deadline=[DateTime]::UtcNow.AddSeconds(5)
|
||||
do {
|
||||
$process=Get-CimInstance Win32_Process -Filter ('ProcessId='+$started)
|
||||
if ($null -ne $process -and $process.ExecutablePath) {
|
||||
if (-not (SamePath $process.ExecutablePath $data.app.executable)) { throw 'Windows activated a different executable; no debugging connection was made.' }
|
||||
$created=ProcessStamp $process; break
|
||||
}
|
||||
Start-Sleep -Milliseconds 100
|
||||
} while ([DateTime]::UtcNow -lt $deadline)
|
||||
if (-not $created) { throw 'Windows activation returned but the official desktop process did not start.' }
|
||||
Emit @{pid=$started;created=$created}
|
||||
"#,
|
||||
json!({"app":app,"home":home,"port":port}),
|
||||
Duration::from_secs(20),
|
||||
)?;
|
||||
remember_started(result)
|
||||
}
|
||||
|
||||
fn remember_started(result: Value) -> Result<u32, String> {
|
||||
let pid = result["pid"]
|
||||
.as_u64()
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.filter(|pid| *pid != 0)
|
||||
.ok_or("Windows 未确认已启动的客户端进程")?;
|
||||
let created = result["created"]
|
||||
.as_str()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or("Windows 未确认客户端进程身份")?;
|
||||
let mut tracked = stamps().lock().map_err(|_| "Windows 进程状态忙")?;
|
||||
if tracked.len() >= 16 {
|
||||
tracked.clear();
|
||||
}
|
||||
tracked.insert(pid, created.to_owned());
|
||||
Ok(pid)
|
||||
}
|
||||
|
||||
fn launch_standalone(
|
||||
app: &WindowsDesktopApp,
|
||||
home: Option<&Path>,
|
||||
port: Option<u16>,
|
||||
) -> Result<u32, String> {
|
||||
powershell(
|
||||
"ValidateApp $data.app; if (@(AppProcesses $data.app.executable).Count -gt 0) { throw 'The desktop is still stopping; no duplicate was launched.' }; Emit $true",
|
||||
json!({"app":app}),
|
||||
Duration::from_secs(15),
|
||||
)?;
|
||||
let arguments = port
|
||||
.map(|port| {
|
||||
vec![
|
||||
format!("--remote-debugging-port={port}"),
|
||||
"--remote-debugging-address=127.0.0.1".to_owned(),
|
||||
]
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let args: Vec<_> = arguments.iter().map(String::as_str).collect();
|
||||
let mut command = crate::platform::program_command(&app.executable, &args);
|
||||
command
|
||||
.current_dir(&app.path)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
if let Some(home) = home {
|
||||
command.env("CODEX_HOME", home);
|
||||
}
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|_| "无法启动已验证的 Windows Codex 客户端")?;
|
||||
let pid = child.id();
|
||||
let result = powershell(
|
||||
r#"
|
||||
$deadline=[DateTime]::UtcNow.AddSeconds(5)
|
||||
do {
|
||||
$process=Get-CimInstance Win32_Process -Filter ('ProcessId='+[uint32]$data.pid)
|
||||
if ($null -ne $process -and $process.ExecutablePath) {
|
||||
if (-not (SamePath $process.ExecutablePath $data.app.executable)) { throw 'The newly started process has an unexpected executable.' }
|
||||
Emit @{pid=[uint32]$data.pid;created=(ProcessStamp $process)}; exit
|
||||
}
|
||||
Start-Sleep -Milliseconds 100
|
||||
} while ([DateTime]::UtcNow -lt $deadline)
|
||||
throw 'The signed desktop executable did not remain running.'
|
||||
"#,
|
||||
json!({"app":app,"pid":pid}),
|
||||
Duration::from_secs(10),
|
||||
).and_then(remember_started);
|
||||
if result.is_err() {
|
||||
// Child owns the original Windows process handle, so cleanup cannot
|
||||
// terminate an unrelated process after a recycled PID.
|
||||
let _ = child.kill();
|
||||
let _ = child.try_wait();
|
||||
}
|
||||
// Windows has no zombie-process wait requirement. Dropping Child releases
|
||||
// our process/thread handles while the GUI keeps its own lifetime.
|
||||
drop(child);
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn verify_process(app: &WindowsDesktopApp, pid: u32) -> Result<(), String> {
|
||||
let created = stamps()
|
||||
.lock()
|
||||
.map_err(|_| "Windows 进程状态忙")?
|
||||
.get(&pid)
|
||||
.cloned()
|
||||
.ok_or("该客户端进程不属于本次受控启动")?;
|
||||
powershell(
|
||||
r#"
|
||||
$process=Get-CimInstance Win32_Process -Filter ('ProcessId='+[uint32]$data.pid)
|
||||
if ($null -eq $process -or -not $process.ExecutablePath -or
|
||||
-not (SamePath $process.ExecutablePath $data.app.executable) -or
|
||||
(ProcessStamp $process) -cne [string]$data.created) { throw 'The activated desktop process identity has changed.' }
|
||||
Emit $true
|
||||
"#,
|
||||
json!({"app":app,"pid":pid,"created":created}),
|
||||
Duration::from_secs(8),
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn verify_listener(app: &WindowsDesktopApp, pid: u32, port: u16) -> Result<(), String> {
|
||||
let created = stamps()
|
||||
.lock()
|
||||
.map_err(|_| "Windows 进程状态忙")?
|
||||
.get(&pid)
|
||||
.cloned()
|
||||
.ok_or("该客户端进程不属于本次受控启动")?;
|
||||
powershell(
|
||||
r#"
|
||||
$process=Get-CimInstance Win32_Process -Filter ('ProcessId='+[uint32]$data.pid)
|
||||
if ($null -eq $process -or -not $process.ExecutablePath -or
|
||||
-not (SamePath $process.ExecutablePath $data.app.executable) -or
|
||||
(ProcessStamp $process) -cne [string]$data.created) { throw 'The activated desktop process identity has changed.' }
|
||||
$listeners=@(Get-NetTCPConnection -State Listen -LocalPort ([uint16]$data.port) -ErrorAction SilentlyContinue)
|
||||
if ($listeners.Count -eq 0) { throw 'The official desktop has not opened its debugging port.' }
|
||||
foreach ($listener in $listeners) {
|
||||
if ($listener.LocalAddress -cne '127.0.0.1' -or [uint32]$listener.OwningProcess -ne [uint32]$data.pid) {
|
||||
throw 'The debugging port is not exclusively owned by the activated process on 127.0.0.1.'
|
||||
}
|
||||
}
|
||||
$after=Get-CimInstance Win32_Process -Filter ('ProcessId='+[uint32]$data.pid)
|
||||
if ($null -eq $after -or -not $after.ExecutablePath -or
|
||||
-not (SamePath $after.ExecutablePath $data.app.executable) -or
|
||||
(ProcessStamp $after) -cne [string]$data.created) { throw 'The desktop process changed while its port was being verified.' }
|
||||
Emit $true
|
||||
"#,
|
||||
json!({"app":app,"pid":pid,"port":port,"created":created}),
|
||||
Duration::from_secs(8),
|
||||
)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn restart() -> Result<(String, bool), String> {
|
||||
let app = detect_fresh()?;
|
||||
validate(&app)?;
|
||||
let was_running = !pids(&app)?.is_empty();
|
||||
quit(&app)?;
|
||||
launch(&app, None, None)?;
|
||||
Ok((app.name, was_running))
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::io::Read;
|
||||
use std::net::{Ipv4Addr, SocketAddr, TcpStream};
|
||||
use std::time::{Duration, Instant};
|
||||
use tungstenite::{client, Message, WebSocket};
|
||||
|
||||
const MAX_RESPONSE: usize = 512 * 1024;
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub(super) struct Target {
|
||||
pub(super) id: String,
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
url: String,
|
||||
#[serde(rename = "webSocketDebuggerUrl")]
|
||||
socket: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn app_origin(url: &str) -> bool {
|
||||
reqwest::Url::parse(url).is_ok_and(|url| {
|
||||
url.scheme() == "app"
|
||||
&& url.host_str() == Some("-")
|
||||
&& url.port().is_none()
|
||||
&& url.username().is_empty()
|
||||
&& url.password().is_none()
|
||||
&& matches!(url.path(), "/" | "/index.html")
|
||||
})
|
||||
}
|
||||
|
||||
fn socket_url(value: &str, port: u16) -> Result<reqwest::Url, String> {
|
||||
let url = reqwest::Url::parse(value).map_err(|_| "调试目标地址无效")?;
|
||||
if url.scheme() != "ws"
|
||||
|| url.host_str() != Some("127.0.0.1")
|
||||
|| url.port() != Some(port)
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
|| !url.path().starts_with("/devtools/page/")
|
||||
|| url.path().trim_start_matches("/devtools/page/").is_empty()
|
||||
{
|
||||
return Err("调试目标超出指定本机端口,未执行语言脚本。".into());
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub(super) fn targets(port: u16) -> Result<Vec<Target>, String> {
|
||||
crate::remote::ensure_crypto_provider();
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.map_err(|_| "无法初始化本机语言兼容连接")?;
|
||||
let response = client
|
||||
.get(format!("http://127.0.0.1:{port}/json/list"))
|
||||
.send()
|
||||
.map_err(|_| "客户端未开放本机调试通道;此版本可能不支持兼容模式。")?;
|
||||
if !response.status().is_success() {
|
||||
return Err("客户端调试通道尚未就绪".into());
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
response
|
||||
.take((MAX_RESPONSE + 1) as u64)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| "无法读取调试目标")?;
|
||||
if bytes.len() > MAX_RESPONSE {
|
||||
return Err("客户端返回的调试目标超过安全限制".into());
|
||||
}
|
||||
let targets: Vec<Target> =
|
||||
serde_json::from_slice(&bytes).map_err(|_| "客户端未返回有效的调试目标")?;
|
||||
if targets.len() > 128 {
|
||||
return Err("客户端调试目标过多,未继续连接".into());
|
||||
}
|
||||
Ok(targets
|
||||
.into_iter()
|
||||
.filter(|target| {
|
||||
target.kind == "page" && app_origin(&target.url) && target.socket.is_some()
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(super) struct Session {
|
||||
socket: WebSocket<TcpStream>,
|
||||
next_id: u64,
|
||||
script: String,
|
||||
}
|
||||
impl Session {
|
||||
pub(super) fn connect(target: &Target, port: u16) -> Result<Self, String> {
|
||||
if target.kind != "page" || !app_origin(&target.url) {
|
||||
return Err("不是官方客户端的本地界面目标".into());
|
||||
}
|
||||
let url = socket_url(target.socket.as_deref().ok_or("目标没有调试连接")?, port)?;
|
||||
let stream = TcpStream::connect_timeout(
|
||||
&SocketAddr::from((Ipv4Addr::LOCALHOST, port)),
|
||||
Duration::from_secs(2),
|
||||
)
|
||||
.map_err(|_| "本机调试连接未建立")?;
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.map_err(|_| "无法设置调试读取超时")?;
|
||||
stream
|
||||
.set_write_timeout(Some(Duration::from_secs(2)))
|
||||
.map_err(|_| "无法设置调试写入超时")?;
|
||||
let (mut socket, _) = client(url.as_str(), stream)
|
||||
.map_err(|_| "客户端拒绝了安全调试连接;未降低客户端安全设置。")?;
|
||||
socket.set_config(|config| {
|
||||
config.max_message_size = Some(MAX_RESPONSE);
|
||||
config.max_frame_size = Some(MAX_RESPONSE);
|
||||
});
|
||||
Ok(Self {
|
||||
socket,
|
||||
next_id: 0,
|
||||
script: String::new(),
|
||||
})
|
||||
}
|
||||
pub(super) fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
self.next_id += 1;
|
||||
let id = self.next_id;
|
||||
self.socket
|
||||
.send(Message::Text(
|
||||
json!({"id":id,"method":method,"params":params})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.map_err(|_| "语言兼容命令发送失败")?;
|
||||
let deadline = Instant::now() + Duration::from_secs(4);
|
||||
while Instant::now() < deadline {
|
||||
match self.socket.read().map_err(|_| "语言兼容连接中断或超时")? {
|
||||
Message::Text(text) => {
|
||||
if text.len() > MAX_RESPONSE {
|
||||
return Err("调试返回内容超过限制".into());
|
||||
}
|
||||
let event: Value =
|
||||
serde_json::from_str(&text).map_err(|_| "调试返回格式无效")?;
|
||||
if event.get("id").and_then(Value::as_u64) != Some(id) {
|
||||
continue;
|
||||
}
|
||||
if event.get("error").is_some() {
|
||||
return Err("当前客户端不支持所需的界面兼容命令".into());
|
||||
}
|
||||
return Ok(event.get("result").cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
Message::Ping(bytes) => {
|
||||
self.socket
|
||||
.send(Message::Pong(bytes))
|
||||
.map_err(|_| "调试连接中断")?;
|
||||
}
|
||||
Message::Close(_) => return Err("客户端关闭了语言兼容连接".into()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err("等待界面兼容结果超时".into())
|
||||
}
|
||||
pub(super) fn install(&mut self, source: &str) -> Result<(), String> {
|
||||
let origin=self.evaluate("(()=>{try{const u=new URL(location.href);return {allowed:window===window.top&&u.protocol==='app:'&&u.host==='-'&&!u.username&&!u.password&&['/','/index.html'].includes(u.pathname)};}catch{return {allowed:false};}})()")?;
|
||||
if origin.get("allowed").and_then(Value::as_bool) != Some(true) {
|
||||
return Err("目标页面来源在连接后已改变,未注册脚本或重载页面。".into());
|
||||
}
|
||||
self.call("Page.enable", json!({}))?;
|
||||
let result = self.call(
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
json!({"source":source}),
|
||||
)?;
|
||||
self.script = result
|
||||
.get("identifier")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("客户端未确认兼容脚本注册")?
|
||||
.to_owned();
|
||||
self.call("Page.reload", json!({"ignoreCache":false}))?;
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn evaluate(&mut self, source: &str) -> Result<Value, String> {
|
||||
let result=self.call("Runtime.evaluate",json!({"expression":source,"returnByValue":true,"awaitPromise":true,"userGesture":false}))?;
|
||||
if result.get("exceptionDetails").is_some() {
|
||||
return Err("客户端界面不支持此语言兼容模式,未报告应用成功。".into());
|
||||
}
|
||||
Ok(result
|
||||
.pointer("/result/value")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null))
|
||||
}
|
||||
}
|
||||
impl Drop for Session {
|
||||
fn drop(&mut self) {
|
||||
if !self.script.is_empty() {
|
||||
let script = self.script.clone();
|
||||
let _ = self.call(
|
||||
"Page.removeScriptToEvaluateOnNewDocument",
|
||||
json!({"identifier":script}),
|
||||
);
|
||||
}
|
||||
let _ = self.socket.close(None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn rejects_browser_targets_remote_websockets_and_credentials() {
|
||||
assert!(app_origin("app://-/index.html?initialRoute=%2Fsettings"));
|
||||
for origin in [
|
||||
"https://chatgpt.com/",
|
||||
"http://127.0.0.1/index.html",
|
||||
"app://other/index.html",
|
||||
"app://user@-/index.html",
|
||||
"app://-/browser.html",
|
||||
] {
|
||||
assert!(!app_origin(origin));
|
||||
}
|
||||
assert!(socket_url("ws://127.0.0.1:4567/devtools/page/fixture", 4567).is_ok());
|
||||
for url in [
|
||||
"ws://localhost:4567/devtools/page/x",
|
||||
"ws://127.0.0.1:4568/devtools/page/x",
|
||||
"ws://127.0.0.1:4567/devtools/browser/x",
|
||||
"ws://user@127.0.0.1:4567/devtools/page/x",
|
||||
"wss://127.0.0.1:4567/devtools/page/x",
|
||||
] {
|
||||
assert!(socket_url(url, 4567).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn simulated_cdp_registers_main_world_script_and_returns_only_fixture_status() {
|
||||
let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut socket = tungstenite::accept(stream).unwrap();
|
||||
for (index, expected) in [
|
||||
"Runtime.evaluate",
|
||||
"Page.enable",
|
||||
"Page.addScriptToEvaluateOnNewDocument",
|
||||
"Page.reload",
|
||||
"Runtime.evaluate",
|
||||
"Page.removeScriptToEvaluateOnNewDocument",
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let Message::Text(text) = socket.read().unwrap() else {
|
||||
panic!("expected command")
|
||||
};
|
||||
let request: Value = serde_json::from_str(&text).unwrap();
|
||||
assert_eq!(request["method"], expected);
|
||||
let result = match expected {
|
||||
"Page.addScriptToEvaluateOnNewDocument" => {
|
||||
assert!(request["params"].get("worldName").is_none());
|
||||
json!({"identifier":"fixture-script"})
|
||||
}
|
||||
"Runtime.evaluate" if index == 0 => {
|
||||
json!({"result":{"value":{"allowed":true}}})
|
||||
}
|
||||
"Runtime.evaluate" => json!({"result":{"value":{"ready":true}}}),
|
||||
_ => json!({}),
|
||||
};
|
||||
socket
|
||||
.send(Message::Text(
|
||||
json!({"id":request["id"],"result":result})
|
||||
.to_string()
|
||||
.into(),
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
let target = Target {
|
||||
id: "fixture".into(),
|
||||
kind: "page".into(),
|
||||
url: "app://-/index.html".into(),
|
||||
socket: Some(format!("ws://127.0.0.1:{port}/devtools/page/fixture")),
|
||||
};
|
||||
{
|
||||
let mut session = Session::connect(&target, port).unwrap();
|
||||
session.install("/* fixture i18n script */").unwrap();
|
||||
assert_eq!(session.evaluate("fixtureStatus()").unwrap()["ready"], true);
|
||||
}
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
//! The locale compatibility launcher never modifies or re-signs the app bundle.
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(test)]
|
||||
static REAPED: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
|
||||
fn process_reaper() -> Result<&'static std::sync::mpsc::Sender<std::process::Child>, String> {
|
||||
static REAPER: std::sync::OnceLock<
|
||||
Result<std::sync::mpsc::Sender<std::process::Child>, String>,
|
||||
> = std::sync::OnceLock::new();
|
||||
REAPER
|
||||
.get_or_init(|| {
|
||||
let (sender, receiver) = std::sync::mpsc::channel::<std::process::Child>();
|
||||
std::thread::Builder::new()
|
||||
.name("codex-language-process-reaper".into())
|
||||
.spawn(move || {
|
||||
let mut children: Vec<std::process::Child> = Vec::new();
|
||||
loop {
|
||||
match receiver.recv_timeout(std::time::Duration::from_millis(200)) {
|
||||
Ok(child) => children.push(child),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
children.retain_mut(|child| match child.try_wait() {
|
||||
Ok(None) => true,
|
||||
Ok(Some(_)) => {
|
||||
#[cfg(test)]
|
||||
REAPED.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
false
|
||||
}
|
||||
Err(_) => false,
|
||||
});
|
||||
}
|
||||
})
|
||||
.map_err(|_| "无法初始化客户端进程回收线程,尚未退出客户端。".to_owned())?;
|
||||
Ok(sender)
|
||||
})
|
||||
.as_ref()
|
||||
.map_err(Clone::clone)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_launch() -> Result<(), String> {
|
||||
process_reaper().map(|_| ())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DesktopApp {
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) executable: PathBuf,
|
||||
pub(crate) bundle_id: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) version: String,
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_app(app: &DesktopApp) -> crate::platform::desktop_windows::WindowsDesktopApp {
|
||||
crate::platform::desktop_windows::WindowsDesktopApp {
|
||||
path: app.path.clone(),
|
||||
executable: app.executable.clone(),
|
||||
bundle_id: app.bundle_id.clone(),
|
||||
name: app.name.clone(),
|
||||
version: app.version.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn command(program: &str, args: &[&str]) -> Result<String, String> {
|
||||
let output = crate::platform::locale_platform_command(Path::new(program), args)?;
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
||||
}
|
||||
|
||||
pub(crate) fn detect() -> Result<DesktopApp, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let app = crate::platform::locale_desktop_app()?;
|
||||
Ok(DesktopApp {
|
||||
path: app.0,
|
||||
executable: app.1,
|
||||
bundle_id: app.2,
|
||||
name: app.3,
|
||||
version: app.4,
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app = crate::platform::desktop_windows::detect()?;
|
||||
Ok(DesktopApp {
|
||||
path: app.path,
|
||||
executable: app.executable,
|
||||
bundle_id: app.bundle_id,
|
||||
name: app.name,
|
||||
version: app.version,
|
||||
})
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
Err("当前平台尚未实现可验证官方进程的界面语言启动器。".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn detect_for_operation() -> Result<DesktopApp, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app = crate::platform::desktop_windows::detect_fresh()?;
|
||||
Ok(DesktopApp {
|
||||
path: app.path,
|
||||
executable: app.executable,
|
||||
bundle_id: app.bundle_id,
|
||||
name: app.name,
|
||||
version: app.version,
|
||||
})
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
detect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate(app: &DesktopApp) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if !matches!(
|
||||
app.bundle_id.as_str(),
|
||||
"com.openai.codex" | "com.openai.codex.beta"
|
||||
) {
|
||||
return Err("不是受支持的官方 Codex 客户端".into());
|
||||
}
|
||||
let path = app.path.to_str().ok_or("客户端路径无效")?;
|
||||
crate::platform::locale_platform_command(
|
||||
Path::new("/usr/bin/codesign"),
|
||||
&["--verify", "--strict", path],
|
||||
)?;
|
||||
let details = crate::platform::locale_platform_command(
|
||||
Path::new("/usr/bin/codesign"),
|
||||
&["--display", "--verbose=4", path],
|
||||
)?;
|
||||
let info = String::from_utf8_lossy(&details.stderr);
|
||||
if !info.lines().any(|line| line == "TeamIdentifier=2DC432GLL2") {
|
||||
return Err("无法确认客户端为 OpenAI 官方签名,未重启或连接客户端。".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
crate::platform::desktop_windows::validate(&windows_app(app))
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = app;
|
||||
Err("当前平台不支持安全的语言兼容启动".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_launch(app: &DesktopApp, home: &Path) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
crate::platform::desktop_windows::validate_launch(&windows_app(app), home)
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let _ = (app, home);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn quit(app: &DesktopApp) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
crate::platform::locale_quit_desktop(&app.executable, &app.bundle_id)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
crate::platform::desktop_windows::quit(&windows_app(app))
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = app;
|
||||
Err("当前平台不支持安全的语言兼容启动".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_stopped(app: &DesktopApp) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if !crate::platform::locale_desktop_pids(&app.executable)?.is_empty() {
|
||||
return Err("客户端在保存语言前重新启动,已取消写入;请完全退出后重试。".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if !crate::platform::desktop_windows::pids(&windows_app(app))?.is_empty() {
|
||||
return Err("客户端在保存语言前重新启动,已取消写入;请完全退出后重试。".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = app;
|
||||
Err("当前平台不支持安全的语言兼容启动".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn quit_spawned(app: &DesktopApp, pid: u32) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if !crate::platform::locale_desktop_pids(&app.executable)?.contains(&pid) {
|
||||
return Err("刚启动的官方进程身份已改变,未自动退出其它实例;请手动恢复。".into());
|
||||
}
|
||||
quit(app)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
crate::platform::desktop_windows::verify_process(&windows_app(app), pid)?;
|
||||
quit(app)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = (app, pid);
|
||||
Err("当前平台不支持安全的客户端恢复".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn launch(app: &DesktopApp, home: &Path, port: Option<u16>) -> Result<u32, String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return crate::platform::desktop_windows::launch(&windows_app(app), Some(home), port);
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
let mut command = std::process::Command::new(&app.executable);
|
||||
command
|
||||
.env("CODEX_HOME", home)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
if let Some(port) = port {
|
||||
command.args([
|
||||
format!("--remote-debugging-port={port}"),
|
||||
"--remote-debugging-address=127.0.0.1".into(),
|
||||
]);
|
||||
}
|
||||
let reaper = process_reaper()?;
|
||||
let child = command
|
||||
.spawn()
|
||||
.map_err(|_| "无法启动官方客户端,请手动打开客户端后重试。")?;
|
||||
let pid = child.id();
|
||||
reaper
|
||||
.send(child)
|
||||
.map_err(|_| "客户端已启动,但进程监督不可用;请手动恢复并重试。")?;
|
||||
Ok(pid)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn verify_listener(app: &DesktopApp, pid: u32, port: u16) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let ids = crate::platform::locale_desktop_pids(&app.executable)?;
|
||||
if !ids.contains(&pid) {
|
||||
return Err("官方客户端进程已经改变,已停止语言兼容连接。".into());
|
||||
}
|
||||
let output = command(
|
||||
"/usr/sbin/lsof",
|
||||
&["-nP", &format!("-iTCP:{port}"), "-sTCP:LISTEN", "-Fpn"],
|
||||
)?;
|
||||
verify_listener_output(&output, pid, port)
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
crate::platform::desktop_windows::verify_listener(&windows_app(app), pid, port)
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
{
|
||||
let _ = (app, pid, port);
|
||||
Err("当前平台无法验证调试端口归属".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_listener_output(output: &str, pid: u32, port: u16) -> Result<(), String> {
|
||||
let mut current = None;
|
||||
let mut verified = false;
|
||||
for line in output.lines() {
|
||||
if let Some(p) = line.strip_prefix('p') {
|
||||
current = p.parse::<u32>().ok();
|
||||
}
|
||||
if let Some(address) = line.strip_prefix('n') {
|
||||
if current != Some(pid) || address != format!("127.0.0.1:{port}") {
|
||||
return Err("调试端口不属于指定官方进程或未限制在本机,未执行任何注入。".into());
|
||||
}
|
||||
verified = true;
|
||||
}
|
||||
}
|
||||
if !verified {
|
||||
return Err("官方客户端尚未开放可验证的本机调试端口;此构建可能不支持运行时兼容。".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn listener_must_match_pid_and_ipv4_loopback() {
|
||||
assert!(verify_listener_output("p123\nn127.0.0.1:4567\n", 123, 4567).is_ok());
|
||||
for input in [
|
||||
"p456\nn127.0.0.1:4567\n",
|
||||
"p123\nn*:4567\n",
|
||||
"p123\nn192.168.1.2:4567\n",
|
||||
"p123\nn127.0.0.1:8888\n",
|
||||
"",
|
||||
] {
|
||||
assert!(verify_listener_output(input, 123, 4567).is_err());
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn spawned_fixture_children_are_waited_by_the_reaper() {
|
||||
let before = REAPED.load(std::sync::atomic::Ordering::Relaxed);
|
||||
let child = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.arg("--list")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
process_reaper().unwrap().send(child).unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
|
||||
while REAPED.load(std::sync::atomic::Ordering::Relaxed) <= before
|
||||
&& std::time::Instant::now() < deadline
|
||||
{
|
||||
std::thread::sleep(std::time::Duration::from_millis(25));
|
||||
}
|
||||
assert!(REAPED.load(std::sync::atomic::Ordering::Relaxed) > before);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/* Independently implemented locale compatibility. No application files change. */
|
||||
(function (options) {
|
||||
"use strict";
|
||||
let page;
|
||||
try { page = new URL(location.href); } catch { return { installed: false, reason: "unsupported_origin" }; }
|
||||
if (page.protocol !== "app:" || page.hostname !== "-" || page.port || page.username || page.password ||
|
||||
!["/", "/index.html"].includes(page.pathname) || window !== window.top) {
|
||||
return { installed: false, reason: "unsupported_origin" };
|
||||
}
|
||||
if (!options || !["zh-CN", "en-US"].includes(options.locale) ||
|
||||
typeof options.owner !== "string" || !/^[a-f0-9]{64}$/.test(options.owner)) {
|
||||
return { installed: false, reason: "invalid_options" };
|
||||
}
|
||||
const marker = "__CODEXX_LOCALE_RUNTIME_V1__";
|
||||
if (window[marker]?.owner === options.owner) return window[marker].status();
|
||||
if (window[marker]) return { installed: false, reason: "another_owner" };
|
||||
const layerName = "72216192";
|
||||
const changes = [];
|
||||
const clients = new Map();
|
||||
const views = new WeakMap();
|
||||
const registries = new WeakSet();
|
||||
const observed = new WeakMap();
|
||||
let enabled = true;
|
||||
let timer;
|
||||
let messagesSeen = false;
|
||||
function isMessages(name) {
|
||||
try { const url = new URL(name); return url.protocol === "app:" && url.host === "-" && !url.username && !url.password && /^\/assets\/zh-CN-[a-zA-Z0-9_-]+\.js$/.test(url.pathname); }
|
||||
catch { return false; }
|
||||
}
|
||||
const resourceObserver = typeof PerformanceObserver === "function" ? new PerformanceObserver(list => {
|
||||
messagesSeen ||= list.getEntries().some(entry => isMessages(entry.name));
|
||||
}) : null;
|
||||
try { resourceObserver?.observe({ type: "resource", buffered: true }); } catch { /* optional API */ }
|
||||
|
||||
function change(object, name, value) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(object, name);
|
||||
if (descriptor && !descriptor.configurable && !descriptor.writable) return false;
|
||||
try {
|
||||
Object.defineProperty(object, name, descriptor && !descriptor.configurable ? { value } : { configurable: true, writable: true, value });
|
||||
changes.push(() => {
|
||||
if (object[name] !== value) return;
|
||||
if (descriptor) Object.defineProperty(object, name, descriptor);
|
||||
else delete object[name];
|
||||
});
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
function translatedLayer(layer) {
|
||||
if (!layer || typeof layer.get !== "function") return layer;
|
||||
if (views.has(layer)) return views.get(layer);
|
||||
const read = layer.get.bind(layer);
|
||||
const translatedRead = (parameter, ...args) =>
|
||||
enabled && parameter === "enable_i18n" ? true : read(parameter, ...args);
|
||||
const methods = new Map();
|
||||
// A forwarding facade also works for frozen SDK results. A proxy directly
|
||||
// wrapping a frozen own `get` would violate JavaScript proxy invariants.
|
||||
const view = new Proxy(Object.create(Object.getPrototypeOf(layer)), {
|
||||
get(_target, key) {
|
||||
if (key === "get") return translatedRead;
|
||||
const result = Reflect.get(layer, key, layer);
|
||||
if (typeof result !== "function") return result;
|
||||
if (methods.get(key)?.original !== result) methods.set(key, { original: result, bound: result.bind(layer) });
|
||||
return methods.get(key).bound;
|
||||
},
|
||||
has(_target, key) { return key in layer; },
|
||||
ownKeys() { return Reflect.ownKeys(layer); },
|
||||
getOwnPropertyDescriptor(_target, key) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(layer, key);
|
||||
return descriptor ? { ...descriptor, configurable: true } : undefined;
|
||||
},
|
||||
});
|
||||
views.set(layer, view);
|
||||
return view;
|
||||
}
|
||||
function hook(client) {
|
||||
if (!enabled) return;
|
||||
if (!client || typeof client !== "object") return;
|
||||
const previous = clients.get(client);
|
||||
if (previous?.waiting) return;
|
||||
if (previous && previous.replacement === client.getLayer) return;
|
||||
// The SDK registers its client from the base constructor, before the
|
||||
// derived constructor assigns getLayer. Intercept that later assignment so
|
||||
// React's first memoized language evaluation already sees the override.
|
||||
if (typeof client.getLayer !== "function") {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(client, "getLayer");
|
||||
if (descriptor && !descriptor.configurable) return;
|
||||
let raw;
|
||||
let wrapped;
|
||||
const getter = () => wrapped;
|
||||
const setter = next => {
|
||||
raw = next;
|
||||
wrapped = typeof next === "function" ? wrap(client, next) : next;
|
||||
clients.set(client, { replacement: wrapped, waiting: false });
|
||||
};
|
||||
Object.defineProperty(client, "getLayer", { configurable: true, enumerable: descriptor?.enumerable ?? true, get: getter, set: setter });
|
||||
clients.set(client, { waiting: true });
|
||||
changes.push(() => {
|
||||
if (Object.getOwnPropertyDescriptor(client, "getLayer")?.get !== getter) return;
|
||||
if (descriptor && Object.hasOwn(descriptor, "value")) Object.defineProperty(client, "getLayer", { ...descriptor, value: raw });
|
||||
else if (descriptor) Object.defineProperty(client, "getLayer", descriptor);
|
||||
else if (raw === undefined) delete client.getLayer;
|
||||
else Object.defineProperty(client, "getLayer", { configurable: true, enumerable: true, writable: true, value: raw });
|
||||
});
|
||||
return;
|
||||
}
|
||||
const original = client.getLayer;
|
||||
const replacement = wrap(client, original);
|
||||
if (change(client, "getLayer", replacement)) {
|
||||
clients.set(client, { replacement });
|
||||
try { client.$emt?.({ name: "values_updated" }); } catch { /* optional SDK hook */ }
|
||||
}
|
||||
}
|
||||
function wrap(_client, original) {
|
||||
return function (name, ...args) {
|
||||
const result = original.call(this, name, ...args);
|
||||
return enabled && name === layerName ? translatedLayer(result) : result;
|
||||
};
|
||||
}
|
||||
function observe(object, key, receive) {
|
||||
let keys = observed.get(object);
|
||||
if (keys?.has(key)) { receive(object[key]); return; }
|
||||
const descriptor = Object.getOwnPropertyDescriptor(object, key);
|
||||
if (descriptor && !descriptor.configurable) { receive(object[key]); return; }
|
||||
if (!keys) { keys = new Set(); observed.set(object, keys); }
|
||||
keys.add(key);
|
||||
let current = object[key];
|
||||
const getter = () => current;
|
||||
const setter = next => {
|
||||
if (descriptor?.set) descriptor.set.call(object, next);
|
||||
current = descriptor?.get ? descriptor.get.call(object) : next;
|
||||
if (enabled) receive(current);
|
||||
};
|
||||
Object.defineProperty(object, key, { configurable: true, enumerable: descriptor?.enumerable ?? true, get: getter, set: setter });
|
||||
changes.push(() => {
|
||||
if (Object.getOwnPropertyDescriptor(object, key)?.get !== getter) return;
|
||||
if (descriptor && !Object.hasOwn(descriptor, "value")) Object.defineProperty(object, key, descriptor);
|
||||
else if (descriptor) Object.defineProperty(object, key, { ...descriptor, value: current });
|
||||
else if (current === undefined) delete object[key];
|
||||
else Object.defineProperty(object, key, { configurable: true, enumerable: true, writable: true, value: current });
|
||||
});
|
||||
receive(current);
|
||||
}
|
||||
function registryAppeared(registry) {
|
||||
if (!enabled || !registry || typeof registry !== "object" || registries.has(registry)) return;
|
||||
registries.add(registry);
|
||||
observe(registry, "firstInstance", hook);
|
||||
observe(registry, "instances", instances => {
|
||||
if (!instances || typeof instances !== "object") return;
|
||||
// Observe SDK registration without changing the dictionary's identity.
|
||||
for (const [key, client] of Object.entries(instances).slice(0, 16)) {
|
||||
hook(client);
|
||||
observe(instances, key, hook);
|
||||
}
|
||||
});
|
||||
}
|
||||
function scan() {
|
||||
const current = new Set();
|
||||
if (!enabled) return current;
|
||||
const registry = window.__STATSIG__;
|
||||
if (!registry || typeof registry !== "object") return current;
|
||||
if (registry.firstInstance) { hook(registry.firstInstance); current.add(registry.firstInstance); }
|
||||
if (registry.instances && typeof registry.instances === "object") {
|
||||
for (const client of Object.values(registry.instances).slice(0, 16)) { hook(client); current.add(client); }
|
||||
}
|
||||
return current;
|
||||
}
|
||||
const state = {
|
||||
owner: options.owner,
|
||||
status() {
|
||||
const current = scan();
|
||||
let gate = false;
|
||||
for (const client of current) {
|
||||
try { gate ||= client.getLayer(layerName, { disableExposureLog: true }).get("enable_i18n", false) === true; }
|
||||
catch { /* unsupported client instance */ }
|
||||
}
|
||||
const language = document.documentElement?.lang || "";
|
||||
const normalized = String(language).replace(/_/g, "-").toLowerCase();
|
||||
const locale = options.locale === "zh-CN" ? ["zh-cn", "zh-hans", "zh-hans-cn"].includes(normalized) : /^en(?:-|$)/i.test(normalized);
|
||||
const messages = options.locale === "en-US" || messagesSeen || performance.getEntriesByType("resource").some(entry => isMessages(entry.name));
|
||||
return { installed: enabled, ready: enabled && gate && locale && messages,
|
||||
locale: options.locale, clients: current.size, gate, languageMatches: locale, messagesLoaded: messages };
|
||||
},
|
||||
restore() {
|
||||
enabled = false;
|
||||
clearInterval(timer);
|
||||
resourceObserver?.disconnect();
|
||||
for (const undo of changes.reverse()) { try { undo(); } catch { /* preserve later changes */ } }
|
||||
if (window[marker] === state) delete window[marker];
|
||||
return { restored: true };
|
||||
},
|
||||
};
|
||||
Object.defineProperty(window, marker, { configurable: true, value: state });
|
||||
observe(window, "__STATSIG__", registryAppeared);
|
||||
scan();
|
||||
timer = setInterval(scan, 100);
|
||||
return state.status();
|
||||
})(__CODEXX_LANGUAGE_CONFIG__)
|
||||
@@ -0,0 +1,318 @@
|
||||
use crate::backups::create_backup;
|
||||
use crate::config_path;
|
||||
use crate::error::Result;
|
||||
use crate::file_io::parse_toml_document;
|
||||
#[cfg(test)]
|
||||
use crate::live_config::acquire_live_config_lock;
|
||||
use crate::live_config::{atomic_write_if_unchanged, read_file_snapshot, text_from_snapshot};
|
||||
use std::path::Path;
|
||||
use toml_edit::{DocumentMut, Item, Table};
|
||||
|
||||
const INSTRUCTION_KEY: &str = "model_instructions_file";
|
||||
const MODEL_AVAILABILITY_NUX_KEY: &str = "model_availability_nux";
|
||||
|
||||
fn markdown_path(item: Option<&Item>) -> bool {
|
||||
item.and_then(|item| item.as_str())
|
||||
.map(str::trim)
|
||||
.is_some_and(|path| !path.is_empty() && path.to_ascii_lowercase().ends_with(".md"))
|
||||
}
|
||||
|
||||
fn tui_table(doc: &DocumentMut) -> Option<&Table> {
|
||||
doc.get("tui").and_then(|item| item.as_table())
|
||||
}
|
||||
|
||||
fn remove_markdown_path(table: &mut Table, key: &str) -> Option<Item> {
|
||||
if markdown_path(table.get(key)) {
|
||||
table.remove(key)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Repairs prompt paths appended by older Codex-X versions after a `[tui]`
|
||||
/// header. Only the exact legacy keys with Markdown path values are touched.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn migrate_legacy_prompt_config(codex_dir: &Path) -> Result<bool> {
|
||||
let _lock = acquire_live_config_lock(codex_dir)?;
|
||||
migrate_legacy_prompt_config_locked(codex_dir)
|
||||
}
|
||||
|
||||
pub(crate) fn migrated_legacy_prompt_config_text(cfg: &Path, text: &str) -> Result<Option<String>> {
|
||||
if text.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut doc = parse_toml_document(cfg, text)?;
|
||||
let nested_instruction = tui_table(&doc)
|
||||
.and_then(|tui| tui.get(MODEL_AVAILABILITY_NUX_KEY))
|
||||
.and_then(|item| item.as_table())
|
||||
.is_some_and(|nux| markdown_path(nux.get(INSTRUCTION_KEY)));
|
||||
let tui_instruction =
|
||||
tui_table(&doc).is_some_and(|tui| markdown_path(tui.get(INSTRUCTION_KEY)));
|
||||
let nux_as_instruction =
|
||||
tui_table(&doc).is_some_and(|tui| markdown_path(tui.get(MODEL_AVAILABILITY_NUX_KEY)));
|
||||
|
||||
if !nested_instruction && !tui_instruction && !nux_as_instruction {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let root_instruction_exists = doc.as_table().contains_key(INSTRUCTION_KEY);
|
||||
let removed_nested = doc
|
||||
.get_mut("tui")
|
||||
.and_then(|item| item.as_table_mut())
|
||||
.and_then(|tui| tui.get_mut(MODEL_AVAILABILITY_NUX_KEY))
|
||||
.and_then(|item| item.as_table_mut())
|
||||
.and_then(|nux| remove_markdown_path(nux, INSTRUCTION_KEY));
|
||||
let removed_tui = doc
|
||||
.get_mut("tui")
|
||||
.and_then(|item| item.as_table_mut())
|
||||
.and_then(|tui| remove_markdown_path(tui, INSTRUCTION_KEY));
|
||||
let removed_nux_value = doc
|
||||
.get_mut("tui")
|
||||
.and_then(|item| item.as_table_mut())
|
||||
.and_then(|tui| remove_markdown_path(tui, MODEL_AVAILABILITY_NUX_KEY));
|
||||
|
||||
if !root_instruction_exists {
|
||||
let instruction = removed_nested
|
||||
.or(removed_tui)
|
||||
.or(removed_nux_value)
|
||||
.expect("a detected legacy prompt path must still be removable");
|
||||
doc.as_table_mut().insert(INSTRUCTION_KEY, instruction);
|
||||
}
|
||||
|
||||
Ok(Some(doc.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_legacy_prompt_config_locked(codex_dir: &Path) -> Result<bool> {
|
||||
let cfg = config_path(codex_dir);
|
||||
let original = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, original.as_deref())?;
|
||||
let Some(migrated) = migrated_legacy_prompt_config_text(&cfg, &text)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
create_backup(codex_dir, "migrate-legacy-prompt-config")?;
|
||||
|
||||
atomic_write_if_unchanged(&cfg, original.as_deref(), migrated.as_bytes())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backups::action_backup_root;
|
||||
use crate::file_io::write_text;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
fn temp_codex_dir(name: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"codex-x-config-migration-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).expect("create temp Codex directory");
|
||||
dir
|
||||
}
|
||||
|
||||
fn read_doc(codex_dir: &Path) -> DocumentMut {
|
||||
fs::read_to_string(config_path(codex_dir))
|
||||
.expect("read migrated config")
|
||||
.parse()
|
||||
.expect("parse migrated config")
|
||||
}
|
||||
|
||||
fn backup_count(codex_dir: &Path) -> usize {
|
||||
let root = action_backup_root(codex_dir).expect("resolve backup root");
|
||||
if !root.exists() {
|
||||
return 0;
|
||||
}
|
||||
fs::read_dir(root).expect("read backup root").count()
|
||||
}
|
||||
|
||||
fn only_backup_config(codex_dir: &Path) -> String {
|
||||
let root = action_backup_root(codex_dir).expect("resolve backup root");
|
||||
let entries = fs::read_dir(root)
|
||||
.expect("read backup root")
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.expect("read backup entries");
|
||||
assert_eq!(entries.len(), 1);
|
||||
fs::read_to_string(entries[0].path().join("config.toml")).expect("read backed-up config")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_instruction_nested_under_model_availability_nux() {
|
||||
let codex_dir = temp_codex_dir("nested-nux");
|
||||
let original = r#"model = "gpt-5.6"
|
||||
|
||||
[tui.model_availability_nux]
|
||||
# Keep valid model counters and their comments.
|
||||
"gpt-5.5" = 4
|
||||
model_instructions_file = "./legacy-prompt.md" # Keep the prompt note too.
|
||||
"gpt-5.6" = 2
|
||||
|
||||
[tui.notifications]
|
||||
enabled = true
|
||||
"#;
|
||||
write_text(&config_path(&codex_dir), original).expect("write legacy config");
|
||||
|
||||
assert!(migrate_legacy_prompt_config(&codex_dir).expect("migrate config"));
|
||||
|
||||
let migrated_text = fs::read_to_string(config_path(&codex_dir)).expect("read config");
|
||||
let doc = read_doc(&codex_dir);
|
||||
assert_eq!(
|
||||
doc.get(INSTRUCTION_KEY).and_then(|item| item.as_str()),
|
||||
Some("./legacy-prompt.md")
|
||||
);
|
||||
let nux = doc["tui"][MODEL_AVAILABILITY_NUX_KEY]
|
||||
.as_table()
|
||||
.expect("keep model availability table");
|
||||
assert!(!nux.contains_key(INSTRUCTION_KEY));
|
||||
assert_eq!(
|
||||
nux.get("gpt-5.5").and_then(|item| item.as_integer()),
|
||||
Some(4)
|
||||
);
|
||||
assert_eq!(
|
||||
nux.get("gpt-5.6").and_then(|item| item.as_integer()),
|
||||
Some(2)
|
||||
);
|
||||
assert!(migrated_text.contains("# Keep valid model counters and their comments."));
|
||||
assert!(migrated_text.contains("# Keep the prompt note too."));
|
||||
assert_eq!(doc["tui"]["notifications"]["enabled"].as_bool(), Some(true));
|
||||
assert_eq!(backup_count(&codex_dir), 1);
|
||||
assert_eq!(only_backup_config(&codex_dir), original);
|
||||
fs::remove_dir_all(codex_dir).expect("remove temp directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_instruction_directly_under_tui() {
|
||||
let codex_dir = temp_codex_dir("direct-tui");
|
||||
write_text(
|
||||
&config_path(&codex_dir),
|
||||
"[tui]\nnotifications = true\nmodel_instructions_file = \"./direct.md\"\n",
|
||||
)
|
||||
.expect("write legacy config");
|
||||
|
||||
assert!(migrate_legacy_prompt_config(&codex_dir).expect("migrate config"));
|
||||
|
||||
let doc = read_doc(&codex_dir);
|
||||
assert_eq!(
|
||||
doc.get(INSTRUCTION_KEY).and_then(|item| item.as_str()),
|
||||
Some("./direct.md")
|
||||
);
|
||||
assert!(doc["tui"].as_table().is_some_and(|tui| {
|
||||
!tui.contains_key(INSTRUCTION_KEY)
|
||||
&& tui.get("notifications").and_then(|item| item.as_bool()) == Some(true)
|
||||
}));
|
||||
fs::remove_dir_all(codex_dir).expect("remove temp directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrates_markdown_path_stored_as_tui_model_availability_nux() {
|
||||
let codex_dir = temp_codex_dir("nux-as-path");
|
||||
write_text(
|
||||
&config_path(&codex_dir),
|
||||
"[tui]\nmodel_availability_nux = \"./gpt-5.6-sol-unrestricted.md\"\n",
|
||||
)
|
||||
.expect("write legacy config");
|
||||
|
||||
assert!(migrate_legacy_prompt_config(&codex_dir).expect("migrate config"));
|
||||
|
||||
let doc = read_doc(&codex_dir);
|
||||
assert_eq!(
|
||||
doc.get(INSTRUCTION_KEY).and_then(|item| item.as_str()),
|
||||
Some("./gpt-5.6-sol-unrestricted.md")
|
||||
);
|
||||
assert!(doc["tui"]
|
||||
.as_table()
|
||||
.is_some_and(|tui| { !tui.contains_key(MODEL_AVAILABILITY_NUX_KEY) }));
|
||||
fs::remove_dir_all(codex_dir).expect("remove temp directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_instruction_wins_over_legacy_nested_value() {
|
||||
let codex_dir = temp_codex_dir("root-wins");
|
||||
write_text(
|
||||
&config_path(&codex_dir),
|
||||
r#"model_instructions_file = "./current.md"
|
||||
|
||||
[tui.model_availability_nux]
|
||||
"gpt-5.5" = 4
|
||||
model_instructions_file = "./stale.md"
|
||||
"#,
|
||||
)
|
||||
.expect("write conflicting config");
|
||||
|
||||
assert!(migrate_legacy_prompt_config(&codex_dir).expect("migrate config"));
|
||||
|
||||
let doc = read_doc(&codex_dir);
|
||||
assert_eq!(
|
||||
doc.get(INSTRUCTION_KEY).and_then(|item| item.as_str()),
|
||||
Some("./current.md")
|
||||
);
|
||||
assert!(!doc["tui"][MODEL_AVAILABILITY_NUX_KEY]
|
||||
.as_table()
|
||||
.expect("keep nux table")
|
||||
.contains_key(INSTRUCTION_KEY));
|
||||
fs::remove_dir_all(codex_dir).expect("remove temp directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_legal_tui_values_unchanged_without_backup() {
|
||||
let codex_dir = temp_codex_dir("legal-tui");
|
||||
let original = "[tui]\nmodel_availability_nux = 4\nnotifications = true\n";
|
||||
write_text(&config_path(&codex_dir), original).expect("write legal config");
|
||||
|
||||
assert!(!migrate_legacy_prompt_config(&codex_dir).expect("inspect config"));
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&codex_dir)).expect("read unchanged config"),
|
||||
original
|
||||
);
|
||||
assert_eq!(backup_count(&codex_dir), 0);
|
||||
fs::remove_dir_all(codex_dir).expect("remove temp directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_is_idempotent_and_backs_up_only_once() {
|
||||
let codex_dir = temp_codex_dir("idempotent");
|
||||
write_text(
|
||||
&config_path(&codex_dir),
|
||||
"[tui]\nmodel_instructions_file = \"./once.md\"\n",
|
||||
)
|
||||
.expect("write legacy config");
|
||||
|
||||
assert!(migrate_legacy_prompt_config(&codex_dir).expect("first migration"));
|
||||
let after_first = fs::read_to_string(config_path(&codex_dir)).expect("read first result");
|
||||
assert!(!migrate_legacy_prompt_config(&codex_dir).expect("second migration"));
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&codex_dir)).expect("read second result"),
|
||||
after_first
|
||||
);
|
||||
assert_eq!(backup_count(&codex_dir), 1);
|
||||
fs::remove_dir_all(codex_dir).expect("remove temp directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_codex_state_runs_the_migration() {
|
||||
let codex_dir = temp_codex_dir("state-load");
|
||||
write_text(
|
||||
&config_path(&codex_dir),
|
||||
"[tui]\nmodel_availability_nux = \"./loaded.md\"\n",
|
||||
)
|
||||
.expect("write legacy config");
|
||||
|
||||
crate::state::build_state(codex_dir.clone()).expect("load Codex state");
|
||||
|
||||
let doc = read_doc(&codex_dir);
|
||||
assert_eq!(
|
||||
doc.get(INSTRUCTION_KEY).and_then(|item| item.as_str()),
|
||||
Some("./loaded.md")
|
||||
);
|
||||
assert_eq!(backup_count(&codex_dir), 1);
|
||||
fs::remove_dir_all(codex_dir).expect("remove temp directory");
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,32 @@
|
||||
pub(crate) const INSTRUCTION_FILENAME: &str = "gpt5.5-unrestricted.md";
|
||||
pub(crate) const INSTRUCTION_RELATIVE: &str = "./gpt5.5-unrestricted.md";
|
||||
pub(crate) const INSTRUCTION_CONTENT: &str =
|
||||
include_str!("../../../../examples/gpt5.5-unrestricted.md");
|
||||
|
||||
pub(crate) const INSTRUCTION_54_FILENAME: &str = "gpt5.4-unrestricted.md";
|
||||
pub(crate) const INSTRUCTION_54_RELATIVE: &str = "./gpt5.4-unrestricted.md";
|
||||
pub(crate) const INSTRUCTION_54_CONTENT: &str =
|
||||
include_str!("../../../../examples/gpt5.4-unrestricted.md");
|
||||
|
||||
pub(crate) const INSTRUCTION_JELI_FILENAME: &str = "gpt5.5-jeli.md";
|
||||
pub(crate) const INSTRUCTION_JELI_RELATIVE: &str = "./gpt5.5-jeli.md";
|
||||
pub(crate) const INSTRUCTION_JELI_CONTENT: &str =
|
||||
include_str!("../../../../examples/gpt5.5-jeli.md");
|
||||
|
||||
pub(crate) const MAX_SKILL_ZIP_BYTES: u64 = 20 * 1024 * 1024;
|
||||
pub(crate) const INSTRUCTION_56_SOL_FILENAME: &str = "gpt-5.6-sol-unrestricted.md";
|
||||
pub(crate) const INSTRUCTION_56_SOL_CONTENT: &str =
|
||||
include_str!("../../../../examples/gpt-5.6-sol-unrestricted.md");
|
||||
|
||||
pub(crate) const INSTRUCTION_SEAGULL_FILENAME: &str = "海鸥3.0破甲.md";
|
||||
pub(crate) const INSTRUCTION_SEAGULL_CONTENT: &str =
|
||||
include_str!("../../../../examples/海鸥3.0破甲.md");
|
||||
|
||||
pub(crate) const AGENTS_FILENAME: &str = "AGENTS.md";
|
||||
pub(crate) const AGENTS_MANAGED_BEGIN: &str = "<!-- CODEX-X:INSTRUCTIONS:BEGIN -->";
|
||||
pub(crate) const AGENTS_MANAGED_END: &str = "<!-- CODEX-X:INSTRUCTIONS:END -->";
|
||||
pub(crate) const AGENTS_TEMPLATE_PREFIX: &str = "<!-- CODEX-X:TEMPLATE:";
|
||||
pub(crate) const JSDELIVR_EXAMPLES_API: &str =
|
||||
"https://data.jsdelivr.com/v1/packages/gh/yynxxxxx/Codex-X@main?structure=flat";
|
||||
pub(crate) const JSDELIVR_EXAMPLES_BASE: &str =
|
||||
"https://cdn.jsdelivr.net/gh/yynxxxxx/Codex-X@main/examples/";
|
||||
pub(crate) const GITHUB_EXAMPLES_API: &str =
|
||||
"https://api.github.com/repos/yynxxxxx/Codex-X/contents/examples?ref=main";
|
||||
pub(crate) const GITHUB_EXAMPLES_BASE: &str =
|
||||
"https://raw.githubusercontent.com/yynxxxxx/Codex-X/main/examples/";
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use toml_edit::{value, DocumentMut};
|
||||
|
||||
const CONTEXT_WINDOW: &str = "model_context_window";
|
||||
const COMPACT_TOKEN_LIMIT: &str = "model_auto_compact_token_limit";
|
||||
const ONE_MILLION: i64 = 1_000_000;
|
||||
const DEFAULT_COMPACT_LIMIT: i64 = 900_000;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ContextWindowValues {
|
||||
pub(crate) context_window: Option<i64>,
|
||||
pub(crate) compact_token_limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ContextWindowConfig {
|
||||
pub(crate) config_text: String,
|
||||
pub(crate) enabled: bool,
|
||||
#[serde(flatten)]
|
||||
pub(crate) values: ContextWindowValues,
|
||||
}
|
||||
|
||||
fn read_integer(doc: &DocumentMut, key: &str) -> Result<Option<i64>> {
|
||||
doc.get(key)
|
||||
.map(|item| {
|
||||
item.as_integer()
|
||||
.ok_or_else(|| CodexxError::Config(format!("{key} 必须为整数")))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn write_integer(doc: &mut DocumentMut, key: &str, number: i64) {
|
||||
// Keep existing inline comments and spacing when replacing a setting.
|
||||
let decor = doc
|
||||
.get(key)
|
||||
.and_then(|item| item.as_value())
|
||||
.map(|value| value.decor().clone());
|
||||
doc[key] = value(number);
|
||||
if let Some(decor) = decor {
|
||||
*doc[key].as_value_mut().expect("integer value").decor_mut() = decor;
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_integer(doc: &mut DocumentMut, key: &str, number: Option<i64>) {
|
||||
if let Some(number) = number {
|
||||
write_integer(doc, key, number);
|
||||
} else {
|
||||
doc.as_table_mut().remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Edits a supplied draft only; this never reads or writes the live configuration.
|
||||
/// `enabled = None` parses the checkbox state after a manual TOML edit.
|
||||
pub(crate) fn update_codex_context_window_inner(
|
||||
config_text: String,
|
||||
enabled: Option<bool>,
|
||||
previous_values: Option<ContextWindowValues>,
|
||||
) -> Result<ContextWindowConfig> {
|
||||
let mut doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|error| CodexxError::Toml {
|
||||
path: "config.toml".to_string(),
|
||||
message: error.to_string(),
|
||||
})?;
|
||||
let context_window = read_integer(&doc, CONTEXT_WINDOW)?;
|
||||
let compact_token_limit = read_integer(&doc, COMPACT_TOKEN_LIMIT)?;
|
||||
|
||||
match enabled {
|
||||
Some(true) => {
|
||||
write_integer(&mut doc, CONTEXT_WINDOW, ONE_MILLION);
|
||||
if compact_token_limit.is_none() {
|
||||
write_integer(&mut doc, COMPACT_TOKEN_LIMIT, DEFAULT_COMPACT_LIMIT);
|
||||
}
|
||||
}
|
||||
Some(false) => {
|
||||
// Only undo the 1M preset. A manual edit to either value wins.
|
||||
if context_window == Some(ONE_MILLION) {
|
||||
restore_integer(
|
||||
&mut doc,
|
||||
CONTEXT_WINDOW,
|
||||
previous_values
|
||||
.as_ref()
|
||||
.and_then(|values| values.context_window),
|
||||
);
|
||||
}
|
||||
let expected_compact_limit = previous_values
|
||||
.as_ref()
|
||||
.and_then(|values| values.compact_token_limit)
|
||||
.unwrap_or(DEFAULT_COMPACT_LIMIT);
|
||||
if compact_token_limit == Some(expected_compact_limit) {
|
||||
restore_integer(
|
||||
&mut doc,
|
||||
COMPACT_TOKEN_LIMIT,
|
||||
previous_values
|
||||
.as_ref()
|
||||
.and_then(|values| values.compact_token_limit),
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
let context_window = read_integer(&doc, CONTEXT_WINDOW)?;
|
||||
let compact_token_limit = read_integer(&doc, COMPACT_TOKEN_LIMIT)?;
|
||||
Ok(ContextWindowConfig {
|
||||
config_text: if enabled.is_none() {
|
||||
config_text
|
||||
} else {
|
||||
doc.to_string()
|
||||
},
|
||||
enabled: context_window == Some(ONE_MILLION),
|
||||
values: ContextWindowValues {
|
||||
context_window,
|
||||
compact_token_limit,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn enables_at_top_level_and_preserves_unrelated_tables_and_comments() {
|
||||
let original = "# My Codex settings\nmodel = \"gpt-5.5\" # keep model\n\n[mcp_servers.notes]\ncommand = \"notes\"\nmodel_context_window = 120000\n\n[model_providers.custom]\nbase_url = \"https://api.example.test/v1\"\n";
|
||||
let result = update_codex_context_window_inner(original.into(), Some(true), None).unwrap();
|
||||
let doc = result.config_text.parse::<DocumentMut>().unwrap();
|
||||
assert_eq!(doc[CONTEXT_WINDOW].as_integer(), Some(ONE_MILLION));
|
||||
assert_eq!(
|
||||
doc[COMPACT_TOKEN_LIMIT].as_integer(),
|
||||
Some(DEFAULT_COMPACT_LIMIT)
|
||||
);
|
||||
assert_eq!(
|
||||
doc["mcp_servers"]["notes"][CONTEXT_WINDOW].as_integer(),
|
||||
Some(120000)
|
||||
);
|
||||
assert!(result.config_text.contains("# My Codex settings"));
|
||||
assert!(result
|
||||
.config_text
|
||||
.contains("model = \"gpt-5.5\" # keep model"));
|
||||
assert!(result
|
||||
.config_text
|
||||
.contains("base_url = \"https://api.example.test/v1\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_underscores_and_quoted_keys_without_reformatting_the_draft() {
|
||||
let original = "\"model_context_window\" = 1_000_000 # large\nmodel_auto_compact_token_limit = 850_000\n";
|
||||
let result = update_codex_context_window_inner(original.into(), None, None).unwrap();
|
||||
assert!(result.enabled);
|
||||
assert_eq!(result.values.compact_token_limit, Some(850000));
|
||||
assert_eq!(result.config_text, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_context_values_in_tables_or_multiline_strings() {
|
||||
let original = "instructions = '''\nmodel_context_window = 1000000\n'''\n[profiles.large]\nmodel_context_window = 1000000\n";
|
||||
let result = update_codex_context_window_inner(original.into(), None, None).unwrap();
|
||||
assert!(!result.enabled);
|
||||
assert_eq!(result.values.context_window, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_custom_compaction_when_enabling_and_disabling() {
|
||||
let original = "model_auto_compact_token_limit = 850000 # custom\n";
|
||||
let enabled = update_codex_context_window_inner(original.into(), Some(true), None).unwrap();
|
||||
assert_eq!(enabled.values.compact_token_limit, Some(850000));
|
||||
let disabled =
|
||||
update_codex_context_window_inner(enabled.config_text, Some(false), None).unwrap();
|
||||
assert_eq!(disabled.values.context_window, None);
|
||||
assert_eq!(disabled.config_text, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restores_custom_context_and_existing_default_compaction_in_same_edit() {
|
||||
let original = "model_context_window = 256000 # original\nmodel_auto_compact_token_limit = 900000 # intentional\n";
|
||||
let parsed = update_codex_context_window_inner(original.into(), None, None).unwrap();
|
||||
let enabled = update_codex_context_window_inner(original.into(), Some(true), None).unwrap();
|
||||
let disabled = update_codex_context_window_inner(
|
||||
enabled.config_text,
|
||||
Some(false),
|
||||
Some(parsed.values),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(disabled.config_text, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_a_saved_preset_removes_both_default_values() {
|
||||
let original = "model = \"gpt-5.5\"\n";
|
||||
let enabled = update_codex_context_window_inner(original.into(), Some(true), None).unwrap();
|
||||
let disabled =
|
||||
update_codex_context_window_inner(enabled.config_text, Some(false), None).unwrap();
|
||||
assert_eq!(disabled.config_text, original);
|
||||
assert!(!disabled.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_does_not_undo_new_manual_values() {
|
||||
let manual = "model_context_window = 500000\nmodel_auto_compact_token_limit = 420000\n";
|
||||
let result = update_codex_context_window_inner(
|
||||
manual.into(),
|
||||
Some(false),
|
||||
Some(ContextWindowValues {
|
||||
context_window: Some(256000),
|
||||
compact_token_limit: Some(200000),
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.config_text, manual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_toml_and_invalid_top_level_field_types() {
|
||||
for original in [
|
||||
"[unfinished",
|
||||
"model_context_window = '1000000'",
|
||||
"model_auto_compact_token_limit = 'automatic'",
|
||||
] {
|
||||
assert!(update_codex_context_window_inner(original.into(), Some(true), None).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_draft_can_be_enabled_and_returned_to_blank() {
|
||||
let enabled = update_codex_context_window_inner(String::new(), Some(true), None).unwrap();
|
||||
assert!(enabled.enabled);
|
||||
let disabled =
|
||||
update_codex_context_window_inner(enabled.config_text, Some(false), None).unwrap();
|
||||
assert!(disabled.config_text.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Emitter, Manager, WindowEvent,
|
||||
};
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const TRAY_ID: &str = "codex-x-tray";
|
||||
const SHOW_WINDOW_MENU_ID: &str = "show-main-window";
|
||||
const QUIT_APP_MENU_ID: &str = "quit-codex-x";
|
||||
const SHOW_TRAY_MENU_ON_LEFT_CLICK: bool = cfg!(target_os = "macos");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_macos_tray_mode(app: &tauri::AppHandle, dock_visible: bool) -> tauri::Result<()> {
|
||||
let policy = if dock_visible {
|
||||
tauri::ActivationPolicy::Regular
|
||||
} else {
|
||||
tauri::ActivationPolicy::Accessory
|
||||
};
|
||||
|
||||
// Attempt both operations: either one alone can leave a stale Dock entry on some macOS versions.
|
||||
let dock_result = app.set_dock_visibility(dock_visible);
|
||||
let policy_result = app.set_activation_policy(policy);
|
||||
dock_result?;
|
||||
policy_result
|
||||
}
|
||||
|
||||
fn retain_first_error(first_error: &mut Option<tauri::Error>, result: tauri::Result<()>) {
|
||||
if let Err(error) = result {
|
||||
if first_error.is_none() {
|
||||
*first_error = Some(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> {
|
||||
let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut first_error = None;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
retain_first_error(&mut first_error, window.set_skip_taskbar(false));
|
||||
#[cfg(target_os = "macos")]
|
||||
retain_first_error(&mut first_error, app.show());
|
||||
|
||||
retain_first_error(&mut first_error, window.unminimize());
|
||||
retain_first_error(&mut first_error, window.show());
|
||||
retain_first_error(&mut first_error, window.set_focus());
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
retain_first_error(&mut first_error, set_macos_tray_mode(app, true));
|
||||
|
||||
first_error.map_or(Ok(()), Err)
|
||||
}
|
||||
|
||||
pub(crate) fn restore_main_window(app: &tauri::AppHandle) {
|
||||
if let Err(error) = show_main_window(app) {
|
||||
eprintln!("failed to restore the Codex-X window: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn setup_system_tray(app: &tauri::App) -> tauri::Result<()> {
|
||||
let show_window =
|
||||
MenuItem::with_id(app, SHOW_WINDOW_MENU_ID, "显示 Codex-X", true, None::<&str>)?;
|
||||
let quit_app = MenuItem::with_id(app, QUIT_APP_MENU_ID, "退出 Codex-X", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&show_window, &quit_app])?;
|
||||
|
||||
let mut tray = TrayIconBuilder::with_id(TRAY_ID)
|
||||
.menu(&menu)
|
||||
.tooltip("Codex-X")
|
||||
.show_menu_on_left_click(SHOW_TRAY_MENU_ON_LEFT_CLICK)
|
||||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||||
SHOW_WINDOW_MENU_ID => restore_main_window(app),
|
||||
QUIT_APP_MENU_ID => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if !SHOW_TRAY_MENU_ON_LEFT_CLICK
|
||||
&& matches!(
|
||||
event,
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
}
|
||||
)
|
||||
{
|
||||
restore_main_window(tray.app_handle());
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(icon) = app.default_window_icon().cloned() {
|
||||
tray = tray.icon(icon);
|
||||
}
|
||||
|
||||
tray.build(app)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn handle_window_event(window: &tauri::Window, event: &WindowEvent) {
|
||||
if window.label() != MAIN_WINDOW_LABEL {
|
||||
return;
|
||||
}
|
||||
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
if let Err(error) = window.hide() {
|
||||
eprintln!("failed to hide the Codex-X window: {error}");
|
||||
return;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if let Err(error) = window.set_skip_taskbar(true) {
|
||||
eprintln!("failed to remove Codex-X from the taskbar: {error}");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Err(error) = set_macos_tray_mode(window.app_handle(), false) {
|
||||
eprintln!("failed to move Codex-X to the menu bar: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn report_failover_lifecycle_error(app: &tauri::AppHandle, title: &str, reason: &str) {
|
||||
let title = title.to_string();
|
||||
let reason = reason.to_string();
|
||||
eprintln!("{title}: {reason}");
|
||||
let _ = app.emit("provider-failover-error", &reason);
|
||||
tauri::async_runtime::spawn(async move {
|
||||
rfd::AsyncMessageDialog::new()
|
||||
.set_title(title)
|
||||
.set_description(reason)
|
||||
.set_level(rfd::MessageLevel::Error)
|
||||
.set_buttons(rfd::MessageButtons::Ok)
|
||||
.show()
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn handle_run_event(app: &tauri::AppHandle, event: tauri::RunEvent) {
|
||||
if let tauri::RunEvent::ExitRequested { api, .. } = &event {
|
||||
if crate::app_update::defer_exit_during_installation() {
|
||||
// The installed bundle or updater handoff is being changed. A
|
||||
// normal Quit must not interrupt it; Windows marks its authorized
|
||||
// handoff before requesting process exit.
|
||||
api.prevent_exit();
|
||||
return;
|
||||
}
|
||||
if let Err(error) = crate::failover::shutdown_all() {
|
||||
// Keep the listener alive when restoring the direct route failed.
|
||||
// Exiting here would strand Codex on a local address with no server.
|
||||
api.prevent_exit();
|
||||
restore_main_window(app);
|
||||
report_failover_lifecycle_error(
|
||||
app,
|
||||
"暂时无法退出 Codex-X",
|
||||
&format!(
|
||||
"路由仍需保持可用,Codex-X 将继续运行。\n\n{error}\n\n请按提示处理后再退出。"
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Reopen { .. } = event {
|
||||
restore_main_window(app);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let _ = (app, event);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
use serde::Serializer;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub(crate) enum CodexxError {
|
||||
#[error("无法获取用户主目录")]
|
||||
NoHomeDir,
|
||||
#[error("IO error at {path}: {source}")]
|
||||
Io {
|
||||
path: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("TOML parse error at {path}: {message}")]
|
||||
Toml { path: String, message: String },
|
||||
#[error("JSON error at {path}: {source}")]
|
||||
Json {
|
||||
path: String,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
#[error("配置错误: {0}")]
|
||||
Config(String),
|
||||
#[error("SQLite error: {0}")]
|
||||
Database(String),
|
||||
}
|
||||
|
||||
pub(crate) type Result<T> = std::result::Result<T, CodexxError>;
|
||||
|
||||
impl serde::Serialize for CodexxError {
|
||||
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
// Adapted from CC Switch 06082e189d65e6d6dbadc35dacdac1ce6c79d89a,
|
||||
// src-tauri/src/proxy/circuit_breaker.rs. The state transitions and counters
|
||||
// retain the upstream behavior; locks are synchronous and logs are omitted.
|
||||
//
|
||||
// MIT License
|
||||
//
|
||||
// Copyright (c) 2025 Jason Young
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
//! 熔断器模块
|
||||
//!
|
||||
//! 实现熔断器模式,用于防止向不健康的供应商发送请求
|
||||
|
||||
use super::config::RoutingTuning;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
// The upstream state machine is retained; Codex-X forwards on bounded blocking
|
||||
// workers, so its short state locks use std rather than awaiting Tokio locks.
|
||||
struct RwLock<T>(std::sync::RwLock<T>);
|
||||
impl<T> RwLock<T> {
|
||||
fn new(value: T) -> Self {
|
||||
Self(std::sync::RwLock::new(value))
|
||||
}
|
||||
fn read(&self) -> std::sync::RwLockReadGuard<'_, T> {
|
||||
self.0.read().unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
fn write(&self) -> std::sync::RwLockWriteGuard<'_, T> {
|
||||
self.0.write().unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
/// 熔断器状态
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CircuitState {
|
||||
/// 关闭状态 - 正常工作
|
||||
Closed,
|
||||
/// 打开状态 - 熔断激活,拒绝请求
|
||||
Open,
|
||||
/// 半开状态 - 尝试恢复,允许部分请求通过
|
||||
HalfOpen,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CircuitState {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CircuitState::Closed => write!(f, "closed"),
|
||||
CircuitState::Open => write!(f, "open"),
|
||||
CircuitState::HalfOpen => write!(f, "half_open"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 熔断器配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CircuitBreakerConfig {
|
||||
/// 失败阈值 - 连续失败多少次后打开熔断器
|
||||
pub failure_threshold: u32,
|
||||
/// 成功阈值 - 半开状态下成功多少次后关闭熔断器
|
||||
pub success_threshold: u32,
|
||||
/// 超时时间 - 熔断器打开后多久尝试半开(秒)
|
||||
pub timeout_seconds: u64,
|
||||
/// 错误率阈值 - 错误率超过此值时打开熔断器 (0.0-1.0)
|
||||
pub error_rate_threshold: f64,
|
||||
/// 最小请求数 - 计算错误率前的最小请求数
|
||||
pub min_requests: u32,
|
||||
}
|
||||
|
||||
impl From<&RoutingTuning> for CircuitBreakerConfig {
|
||||
fn from(config: &RoutingTuning) -> Self {
|
||||
Self {
|
||||
failure_threshold: config.circuit_failure_threshold,
|
||||
success_threshold: config.circuit_success_threshold,
|
||||
timeout_seconds: config.circuit_timeout_seconds as u64,
|
||||
error_rate_threshold: config.circuit_error_rate_threshold,
|
||||
min_requests: config.circuit_min_requests,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CircuitBreakerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
failure_threshold: 4,
|
||||
success_threshold: 2,
|
||||
timeout_seconds: 60,
|
||||
error_rate_threshold: 0.6,
|
||||
min_requests: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 熔断器实例
|
||||
pub struct CircuitBreaker {
|
||||
/// 当前状态
|
||||
state: Arc<RwLock<CircuitState>>,
|
||||
/// 连续失败计数
|
||||
consecutive_failures: Arc<AtomicU32>,
|
||||
/// 连续成功计数(半开状态)
|
||||
consecutive_successes: Arc<AtomicU32>,
|
||||
/// 总请求计数
|
||||
total_requests: Arc<AtomicU32>,
|
||||
/// 失败请求计数
|
||||
failed_requests: Arc<AtomicU32>,
|
||||
/// 上次打开时间
|
||||
last_opened_at: Arc<RwLock<Option<Instant>>>,
|
||||
/// 配置(支持热更新)
|
||||
config: Arc<RwLock<CircuitBreakerConfig>>,
|
||||
/// 半开状态已放行的请求数(用于限流)
|
||||
half_open_requests: Arc<AtomicU32>,
|
||||
}
|
||||
|
||||
/// 熔断器放行结果
|
||||
///
|
||||
/// `used_half_open_permit` 表示本次放行是否占用了 HalfOpen 探测名额。
|
||||
/// 调用方应在请求结束后把该值传回 `record_success` / `record_failure` 用于正确释放名额。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AllowResult {
|
||||
pub allowed: bool,
|
||||
pub used_half_open_permit: bool,
|
||||
}
|
||||
|
||||
impl CircuitBreaker {
|
||||
/// 创建新的熔断器
|
||||
pub fn new(config: CircuitBreakerConfig) -> Self {
|
||||
Self {
|
||||
state: Arc::new(RwLock::new(CircuitState::Closed)),
|
||||
consecutive_failures: Arc::new(AtomicU32::new(0)),
|
||||
consecutive_successes: Arc::new(AtomicU32::new(0)),
|
||||
total_requests: Arc::new(AtomicU32::new(0)),
|
||||
failed_requests: Arc::new(AtomicU32::new(0)),
|
||||
last_opened_at: Arc::new(RwLock::new(None)),
|
||||
config: Arc::new(RwLock::new(config)),
|
||||
half_open_requests: Arc::new(AtomicU32::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新熔断器配置(热更新,不重置状态)
|
||||
pub fn update_config(&self, new_config: CircuitBreakerConfig) {
|
||||
*self.config.write() = new_config;
|
||||
}
|
||||
|
||||
/// 判断当前 Provider 是否“可被纳入候选链路”
|
||||
///
|
||||
/// 这个方法不会占用 HalfOpen 探测名额,仅用于路由选择阶段的“可用性判断”:
|
||||
/// - Closed / HalfOpen:可用(返回 true)
|
||||
/// - Open:若超时到达则切到 HalfOpen 并返回 true,否则返回 false
|
||||
///
|
||||
/// 注意:真正发起请求前仍需调用 `allow_request()` 来获取 HalfOpen 探测名额,
|
||||
/// 并在请求结束后通过 `record_success()` / `record_failure()` 释放。
|
||||
pub fn is_available(&self) -> bool {
|
||||
let state = *self.state.read();
|
||||
let config = self.config.read();
|
||||
|
||||
match state {
|
||||
CircuitState::Closed | CircuitState::HalfOpen => true,
|
||||
CircuitState::Open => {
|
||||
if let Some(opened_at) = *self.last_opened_at.read() {
|
||||
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_half_open();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否允许请求通过
|
||||
pub fn allow_request(&self) -> AllowResult {
|
||||
let state = *self.state.read();
|
||||
|
||||
match state {
|
||||
CircuitState::Closed => AllowResult {
|
||||
allowed: true,
|
||||
used_half_open_permit: false,
|
||||
},
|
||||
CircuitState::Open => {
|
||||
let config = self.config.read();
|
||||
// 检查是否应该尝试半开
|
||||
if let Some(opened_at) = *self.last_opened_at.read() {
|
||||
if opened_at.elapsed().as_secs() >= config.timeout_seconds {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_half_open();
|
||||
|
||||
// 转换后按当前状态决定是否需要获取 HalfOpen 探测名额
|
||||
let current_state = *self.state.read();
|
||||
return match current_state {
|
||||
CircuitState::Closed => AllowResult {
|
||||
allowed: true,
|
||||
used_half_open_permit: false,
|
||||
},
|
||||
CircuitState::HalfOpen => self.allow_half_open_probe(),
|
||||
CircuitState::Open => AllowResult {
|
||||
allowed: false,
|
||||
used_half_open_permit: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
AllowResult {
|
||||
allowed: false,
|
||||
used_half_open_permit: false,
|
||||
}
|
||||
}
|
||||
CircuitState::HalfOpen => self.allow_half_open_probe(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录成功
|
||||
pub fn record_success(&self, used_half_open_permit: bool) {
|
||||
let state = *self.state.read();
|
||||
let config = self.config.read();
|
||||
|
||||
if used_half_open_permit {
|
||||
self.release_half_open_permit();
|
||||
}
|
||||
|
||||
// 重置失败计数
|
||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||
self.total_requests.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
if state == CircuitState::HalfOpen {
|
||||
let successes = self.consecutive_successes.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
|
||||
if successes >= config.success_threshold {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_closed();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录失败
|
||||
pub fn record_failure(&self, used_half_open_permit: bool) {
|
||||
let state = *self.state.read();
|
||||
let config = self.config.read();
|
||||
|
||||
if used_half_open_permit {
|
||||
self.release_half_open_permit();
|
||||
}
|
||||
|
||||
// 更新计数器
|
||||
let failures = self.consecutive_failures.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
self.total_requests.fetch_add(1, Ordering::SeqCst);
|
||||
self.failed_requests.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
// 重置成功计数
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
|
||||
// 检查是否应该打开熔断器
|
||||
match state {
|
||||
CircuitState::HalfOpen => {
|
||||
// HalfOpen 状态下失败,立即转为 Open
|
||||
drop(config);
|
||||
self.transition_to_open();
|
||||
}
|
||||
CircuitState::Closed => {
|
||||
// 检查连续失败次数
|
||||
if failures >= config.failure_threshold {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_open();
|
||||
} else {
|
||||
// 检查错误率
|
||||
let total = self.total_requests.load(Ordering::SeqCst);
|
||||
let failed = self.failed_requests.load(Ordering::SeqCst);
|
||||
|
||||
if total >= config.min_requests {
|
||||
let error_rate = failed as f64 / total as f64;
|
||||
|
||||
if error_rate >= config.error_rate_threshold {
|
||||
drop(config); // 释放读锁再转换状态
|
||||
self.transition_to_open();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前状态
|
||||
#[allow(dead_code)]
|
||||
pub fn get_state(&self) -> CircuitState {
|
||||
*self.state.read()
|
||||
}
|
||||
|
||||
/// 获取统计信息
|
||||
#[allow(dead_code)]
|
||||
pub fn get_stats(&self) -> CircuitBreakerStats {
|
||||
CircuitBreakerStats {
|
||||
state: *self.state.read(),
|
||||
consecutive_failures: self.consecutive_failures.load(Ordering::SeqCst),
|
||||
consecutive_successes: self.consecutive_successes.load(Ordering::SeqCst),
|
||||
total_requests: self.total_requests.load(Ordering::SeqCst),
|
||||
failed_requests: self.failed_requests.load(Ordering::SeqCst),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cooldown_seconds(&self) -> u64 {
|
||||
if *self.state.read() != CircuitState::Open {
|
||||
return 0;
|
||||
}
|
||||
let config = self.config.read();
|
||||
self.last_opened_at.read().as_ref().map_or(0, |opened| {
|
||||
config
|
||||
.timeout_seconds
|
||||
.saturating_sub(opened.elapsed().as_secs())
|
||||
})
|
||||
}
|
||||
|
||||
/// 重置熔断器(手动恢复)
|
||||
#[allow(dead_code)]
|
||||
pub fn reset(&self) {
|
||||
self.transition_to_closed();
|
||||
}
|
||||
|
||||
fn allow_half_open_probe(&self) -> AllowResult {
|
||||
// 半开状态限流:只允许有限请求通过进行探测
|
||||
let max_half_open_requests = 1u32;
|
||||
let current = self.half_open_requests.fetch_add(1, Ordering::SeqCst);
|
||||
|
||||
if current < max_half_open_requests {
|
||||
AllowResult {
|
||||
allowed: true,
|
||||
used_half_open_permit: true,
|
||||
}
|
||||
} else {
|
||||
// 超过限额,回退计数,拒绝请求
|
||||
self.half_open_requests.fetch_sub(1, Ordering::SeqCst);
|
||||
AllowResult {
|
||||
allowed: false,
|
||||
used_half_open_permit: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅释放 HalfOpen permit,不影响健康统计
|
||||
///
|
||||
/// 用于整流器等场景:请求结果不应计入 Provider 健康度,
|
||||
/// 但仍需释放占用的探测名额,避免 HalfOpen 状态卡死
|
||||
pub fn release_half_open_permit(&self) {
|
||||
let mut current = self.half_open_requests.load(Ordering::SeqCst);
|
||||
loop {
|
||||
if current == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
match self.half_open_requests.compare_exchange(
|
||||
current,
|
||||
current - 1,
|
||||
Ordering::SeqCst,
|
||||
Ordering::SeqCst,
|
||||
) {
|
||||
Ok(_) => return,
|
||||
Err(actual) => current = actual,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换到打开状态
|
||||
fn transition_to_open(&self) {
|
||||
*self.state.write() = CircuitState::Open;
|
||||
*self.last_opened_at.write() = Some(Instant::now());
|
||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 转换到半开状态
|
||||
fn transition_to_half_open(&self) {
|
||||
let mut state = self.state.write();
|
||||
if *state != CircuitState::Open {
|
||||
return;
|
||||
}
|
||||
|
||||
*state = CircuitState::HalfOpen;
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
// 重置半开状态的请求限流计数
|
||||
self.half_open_requests.store(0, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// 转换到关闭状态
|
||||
fn transition_to_closed(&self) {
|
||||
*self.state.write() = CircuitState::Closed;
|
||||
self.consecutive_failures.store(0, Ordering::SeqCst);
|
||||
self.consecutive_successes.store(0, Ordering::SeqCst);
|
||||
// 重置计数器
|
||||
self.total_requests.store(0, Ordering::SeqCst);
|
||||
self.failed_requests.store(0, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// 熔断器统计信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CircuitBreakerStats {
|
||||
pub state: CircuitState,
|
||||
pub consecutive_failures: u32,
|
||||
pub consecutive_successes: u32,
|
||||
pub total_requests: u32,
|
||||
pub failed_requests: u32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_circuit_breaker_closed_to_open() {
|
||||
let config = CircuitBreakerConfig {
|
||||
failure_threshold: 3,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config);
|
||||
|
||||
// 初始状态应该是关闭
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
assert!(breaker.allow_request().allowed);
|
||||
|
||||
// 记录 3 次失败
|
||||
for _ in 0..3 {
|
||||
breaker.record_failure(false);
|
||||
}
|
||||
|
||||
// 应该转换到打开状态
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
assert!(!breaker.allow_request().allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circuit_breaker_half_open_to_closed() {
|
||||
let config = CircuitBreakerConfig {
|
||||
failure_threshold: 2,
|
||||
success_threshold: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config);
|
||||
|
||||
// 打开熔断器
|
||||
breaker.record_failure(false);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
|
||||
// 手动转换到半开状态
|
||||
breaker.transition_to_half_open();
|
||||
assert_eq!(breaker.get_state(), CircuitState::HalfOpen);
|
||||
|
||||
// 记录 2 次成功
|
||||
breaker.record_success(false);
|
||||
breaker.record_success(false);
|
||||
|
||||
// 应该转换到关闭状态
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_half_open_transition_does_not_reset_inflight_permit() {
|
||||
let config = CircuitBreakerConfig {
|
||||
timeout_seconds: 0,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config);
|
||||
|
||||
// 进入 Open,然后由于 timeout_seconds=0,allow_request 会立即切换到 HalfOpen 并占用探测名额
|
||||
breaker.transition_to_open();
|
||||
let first = breaker.allow_request();
|
||||
assert!(first.allowed);
|
||||
assert!(first.used_half_open_permit);
|
||||
assert_eq!(breaker.get_state(), CircuitState::HalfOpen);
|
||||
|
||||
// 模拟并发下的“重复 HalfOpen 转换调用”,不应重置 in-flight 计数
|
||||
breaker.transition_to_half_open();
|
||||
|
||||
// 由于名额仍被占用,第二次请求应被拒绝
|
||||
let second = breaker.allow_request();
|
||||
assert!(!second.allowed);
|
||||
assert!(!second.used_half_open_permit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circuit_breaker_reset() {
|
||||
let config = CircuitBreakerConfig {
|
||||
failure_threshold: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config);
|
||||
|
||||
// 打开熔断器
|
||||
breaker.record_failure(false);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
|
||||
// 重置
|
||||
breaker.reset();
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
assert!(breaker.allow_request().allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_rate_waits_for_minimum_requests_and_uses_inclusive_threshold() {
|
||||
let config = CircuitBreakerConfig {
|
||||
failure_threshold: 20,
|
||||
min_requests: 5,
|
||||
error_rate_threshold: 0.6,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config.clone());
|
||||
breaker.record_failure(false);
|
||||
breaker.record_success(false);
|
||||
breaker.record_failure(false);
|
||||
breaker.record_success(false);
|
||||
assert_eq!(breaker.get_stats().total_requests, 4);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
let higher = CircuitBreaker::new(CircuitBreakerConfig {
|
||||
error_rate_threshold: 0.8,
|
||||
..config.clone()
|
||||
});
|
||||
let minimum = CircuitBreaker::new(CircuitBreakerConfig {
|
||||
min_requests: 10,
|
||||
..config
|
||||
});
|
||||
for breaker in [&higher, &minimum] {
|
||||
breaker.record_failure(false);
|
||||
breaker.record_success(false);
|
||||
breaker.record_failure(false);
|
||||
breaker.record_success(false);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successes_break_consecutive_failures_without_clearing_error_rate_history() {
|
||||
let breaker = CircuitBreaker::new(CircuitBreakerConfig {
|
||||
failure_threshold: 2,
|
||||
min_requests: 100,
|
||||
..Default::default()
|
||||
});
|
||||
breaker.record_failure(false);
|
||||
breaker.record_success(false);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Closed);
|
||||
assert_eq!(breaker.get_stats().failed_requests, 2);
|
||||
assert_eq!(breaker.get_stats().consecutive_failures, 1);
|
||||
breaker.record_failure(false);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
assert_eq!(breaker.get_stats().total_requests, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_wait_and_hot_configuration_preserve_existing_evidence() {
|
||||
let mut config = CircuitBreakerConfig {
|
||||
failure_threshold: 1,
|
||||
timeout_seconds: 2,
|
||||
..Default::default()
|
||||
};
|
||||
let breaker = CircuitBreaker::new(config.clone());
|
||||
breaker.record_failure(false);
|
||||
*breaker.last_opened_at.write() = Some(Instant::now() - std::time::Duration::from_secs(1));
|
||||
assert!(!breaker.allow_request().allowed);
|
||||
assert_eq!(breaker.cooldown_seconds(), 1);
|
||||
config.timeout_seconds = 1;
|
||||
breaker.update_config(config);
|
||||
assert_eq!(breaker.get_stats().failed_requests, 1);
|
||||
let probe = breaker.allow_request();
|
||||
assert!(probe.allowed && probe.used_half_open_permit);
|
||||
assert_eq!(breaker.get_state(), CircuitState::HalfOpen);
|
||||
assert!(!breaker.allow_request().allowed);
|
||||
breaker.release_half_open_permit();
|
||||
let probe = breaker.allow_request();
|
||||
assert!(probe.allowed);
|
||||
breaker.record_failure(probe.used_half_open_permit);
|
||||
assert_eq!(breaker.get_state(), CircuitState::Open);
|
||||
assert!(!breaker.allow_request().allowed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_open_requires_configured_successes_and_reset_clears_all_statistics() {
|
||||
let breaker = CircuitBreaker::new(CircuitBreakerConfig {
|
||||
failure_threshold: 1,
|
||||
success_threshold: 3,
|
||||
timeout_seconds: 0,
|
||||
..Default::default()
|
||||
});
|
||||
breaker.record_failure(false);
|
||||
for completed in 0..3 {
|
||||
let probe = breaker.allow_request();
|
||||
assert!(probe.allowed && probe.used_half_open_permit);
|
||||
assert!(!breaker.allow_request().allowed);
|
||||
breaker.record_success(probe.used_half_open_permit);
|
||||
assert_eq!(
|
||||
breaker.get_state(),
|
||||
if completed < 2 {
|
||||
CircuitState::HalfOpen
|
||||
} else {
|
||||
CircuitState::Closed
|
||||
}
|
||||
);
|
||||
}
|
||||
assert_eq!(breaker.get_stats().total_requests, 0);
|
||||
breaker.record_failure(false);
|
||||
breaker.reset();
|
||||
let stats = breaker.get_stats();
|
||||
assert_eq!(
|
||||
(
|
||||
stats.total_requests,
|
||||
stats.failed_requests,
|
||||
stats.consecutive_failures,
|
||||
stats.consecutive_successes
|
||||
),
|
||||
(0, 0, 0, 0)
|
||||
);
|
||||
assert!(breaker.allow_request().allowed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Codex defaults/ranges follow CC Switch 06082e1 AppProxyConfig and its
|
||||
//! AutoFailoverConfigPanel. Both IPC and the UI validate these settings.
|
||||
|
||||
use crate::error::{CodexxError, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
pub(crate) const DEFAULT_LISTEN_ADDRESS: &str = "127.0.0.1";
|
||||
pub(crate) const DEFAULT_LISTEN_PORT: u16 = 15721;
|
||||
pub(crate) const MAX_QUEUE: usize = 64;
|
||||
pub(crate) const ROUTE_TOKEN_HEADER: &str = "x-codex-x-route-token";
|
||||
pub(crate) const ROUTE_GENERATION_HEADER: &str = "x-codex-x-route-generation";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub(crate) struct RoutingTuning {
|
||||
pub(crate) max_retries: u32,
|
||||
pub(crate) streaming_first_byte_timeout: u64,
|
||||
pub(crate) streaming_idle_timeout: u64,
|
||||
pub(crate) non_streaming_timeout: u64,
|
||||
pub(crate) circuit_failure_threshold: u32,
|
||||
pub(crate) circuit_success_threshold: u32,
|
||||
pub(crate) circuit_timeout_seconds: u64,
|
||||
pub(crate) circuit_error_rate_threshold: f64,
|
||||
pub(crate) circuit_min_requests: u32,
|
||||
}
|
||||
|
||||
impl Default for RoutingTuning {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_retries: 3,
|
||||
streaming_first_byte_timeout: 60,
|
||||
streaming_idle_timeout: 120,
|
||||
non_streaming_timeout: 600,
|
||||
circuit_failure_threshold: 4,
|
||||
circuit_success_threshold: 2,
|
||||
circuit_timeout_seconds: 60,
|
||||
circuit_error_rate_threshold: 0.6,
|
||||
circuit_min_requests: 10,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RoutingTuning {
|
||||
pub(crate) fn validate(&self) -> Result<()> {
|
||||
for (valid, message) in [
|
||||
(self.max_retries <= 10, "最大重试次数须为 0–10"),
|
||||
(
|
||||
(1..=120).contains(&self.streaming_first_byte_timeout),
|
||||
"流式首字节超时须为 1–120 秒",
|
||||
),
|
||||
(
|
||||
self.streaming_idle_timeout <= 600,
|
||||
"流式静默超时须为 0–600 秒,0 表示禁用",
|
||||
),
|
||||
(
|
||||
(60..=1200).contains(&self.non_streaming_timeout),
|
||||
"非流式超时须为 60–1200 秒",
|
||||
),
|
||||
(
|
||||
(1..=20).contains(&self.circuit_failure_threshold),
|
||||
"失败阈值须为 1–20",
|
||||
),
|
||||
(
|
||||
(1..=10).contains(&self.circuit_success_threshold),
|
||||
"恢复成功阈值须为 1–10",
|
||||
),
|
||||
(
|
||||
self.circuit_timeout_seconds <= 300,
|
||||
"恢复等待时间须为 0–300 秒",
|
||||
),
|
||||
(
|
||||
self.circuit_error_rate_threshold.is_finite()
|
||||
&& (0.0..=1.0).contains(&self.circuit_error_rate_threshold),
|
||||
"错误率阈值须为 0–100%",
|
||||
),
|
||||
(
|
||||
(5..=100).contains(&self.circuit_min_requests),
|
||||
"最小请求数须为 5–100",
|
||||
),
|
||||
] {
|
||||
if !valid {
|
||||
return Err(CodexxError::Config(message.into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn listen_ip(address: &str) -> Result<IpAddr> {
|
||||
let address = address.trim();
|
||||
if address.eq_ignore_ascii_case("localhost") {
|
||||
return Ok(IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||
}
|
||||
let address = address
|
||||
.strip_prefix('[')
|
||||
.and_then(|value| value.strip_suffix(']'))
|
||||
.unwrap_or(address);
|
||||
address
|
||||
.parse()
|
||||
.map_err(|_| CodexxError::Config("监听地址须为 IPv4、IPv6 或 localhost".into()))
|
||||
}
|
||||
|
||||
pub(crate) fn client_ip(address: IpAddr) -> IpAddr {
|
||||
match address {
|
||||
IpAddr::V4(ip) if ip.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
IpAddr::V6(ip) if ip.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
address => address,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn local_url(address: IpAddr, port: u16) -> String {
|
||||
match client_ip(address) {
|
||||
IpAddr::V4(ip) => format!("http://{ip}:{port}/v1"),
|
||||
IpAddr::V6(ip) => format!("http://[{ip}]:{port}/v1"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn codex_defaults_and_valid_boundaries_match_reference() {
|
||||
let defaults = RoutingTuning::default();
|
||||
defaults.validate().unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
defaults.max_retries,
|
||||
defaults.streaming_first_byte_timeout,
|
||||
defaults.streaming_idle_timeout,
|
||||
defaults.non_streaming_timeout
|
||||
),
|
||||
(3, 60, 120, 600)
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
defaults.circuit_failure_threshold,
|
||||
defaults.circuit_success_threshold,
|
||||
defaults.circuit_timeout_seconds,
|
||||
defaults.circuit_min_requests
|
||||
),
|
||||
(4, 2, 60, 10)
|
||||
);
|
||||
assert_eq!(defaults.circuit_error_rate_threshold, 0.6);
|
||||
RoutingTuning {
|
||||
max_retries: 0,
|
||||
streaming_first_byte_timeout: 1,
|
||||
streaming_idle_timeout: 0,
|
||||
non_streaming_timeout: 60,
|
||||
circuit_failure_threshold: 1,
|
||||
circuit_success_threshold: 1,
|
||||
circuit_timeout_seconds: 0,
|
||||
circuit_error_rate_threshold: 0.0,
|
||||
circuit_min_requests: 5,
|
||||
}
|
||||
.validate()
|
||||
.unwrap();
|
||||
RoutingTuning {
|
||||
max_retries: 10,
|
||||
streaming_first_byte_timeout: 120,
|
||||
streaming_idle_timeout: 600,
|
||||
non_streaming_timeout: 1200,
|
||||
circuit_failure_threshold: 20,
|
||||
circuit_success_threshold: 10,
|
||||
circuit_timeout_seconds: 300,
|
||||
circuit_error_rate_threshold: 1.0,
|
||||
circuit_min_requests: 100,
|
||||
}
|
||||
.validate()
|
||||
.unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn invalid_tuning_is_rejected_in_backend() {
|
||||
for invalid in [
|
||||
RoutingTuning {
|
||||
max_retries: 11,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
streaming_first_byte_timeout: 0,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
streaming_first_byte_timeout: 121,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
streaming_idle_timeout: 601,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
non_streaming_timeout: 59,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
non_streaming_timeout: 1201,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_failure_threshold: 0,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_failure_threshold: 21,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_success_threshold: 0,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_success_threshold: 11,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_timeout_seconds: 301,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_error_rate_threshold: f64::NAN,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_error_rate_threshold: 1.1,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_min_requests: 4,
|
||||
..Default::default()
|
||||
},
|
||||
RoutingTuning {
|
||||
circuit_min_requests: 101,
|
||||
..Default::default()
|
||||
},
|
||||
] {
|
||||
assert!(invalid.validate().is_err(), "{invalid:?}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn listen_addresses_and_client_urls_support_ipv4_ipv6_and_wildcards() {
|
||||
assert_eq!(listen_ip("localhost").unwrap().to_string(), "127.0.0.1");
|
||||
assert_eq!(
|
||||
local_url(listen_ip("0.0.0.0").unwrap(), 15721),
|
||||
"http://127.0.0.1:15721/v1"
|
||||
);
|
||||
assert_eq!(
|
||||
local_url(listen_ip("::").unwrap(), 15721),
|
||||
"http://[::1]:15721/v1"
|
||||
);
|
||||
assert_eq!(
|
||||
local_url(listen_ip("[::1]").unwrap(), 15721),
|
||||
"http://[::1]:15721/v1"
|
||||
);
|
||||
for value in [
|
||||
"256.0.0.1",
|
||||
"host.example",
|
||||
"127.0.0.1:15721",
|
||||
"::1/path",
|
||||
"",
|
||||
] {
|
||||
assert!(listen_ip(value).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
mod circuit_breaker;
|
||||
pub(crate) mod config;
|
||||
mod controller;
|
||||
pub(crate) mod native_official;
|
||||
pub(crate) mod protocol;
|
||||
mod proxy;
|
||||
|
||||
pub(crate) use controller::resume_after_failed_update;
|
||||
|
||||
pub(crate) use controller::{
|
||||
attach_app_handle, begin_update_handoff, direct_document, ensure_conversion_route,
|
||||
ensure_shutdown_allowed, get_status, initialize, recover_stale_route, refresh_saved_routes,
|
||||
reset_health, save_settings, shutdown_all, with_provider_change, FailoverSettings,
|
||||
FailoverStatus,
|
||||
};
|
||||
@@ -0,0 +1,578 @@
|
||||
//! Native Codex official routing. Codex remains the owner of its login and token
|
||||
//! refresh; this layer only verifies the selected live login and forwards it to
|
||||
//! its fixed official origin. No credential is loaded from an inactive profile.
|
||||
|
||||
use super::proxy::ProxyRoute;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::providers::document_is_official;
|
||||
use crate::providers::official_profiles::{list_official_profiles_inner, selected_profile_id};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
const CHATGPT_BASE: &str = "https://chatgpt.com/backend-api/codex";
|
||||
const OPENAI_API_BASE: &str = "https://api.openai.com/v1";
|
||||
const MAX_CONFIG_BYTES: usize = 2 * 1024 * 1024;
|
||||
const MAX_AUTH_BYTES: usize = 1024 * 1024;
|
||||
const MAX_CATALOG_BYTES: usize = 1024 * 1024;
|
||||
const MAX_TOKEN_BYTES: usize = 32 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct OfficialRouteSpec {
|
||||
pub(crate) codex_dir: PathBuf,
|
||||
pub(crate) profile_id: String,
|
||||
}
|
||||
|
||||
// Intentionally not Debug/Serialize: these values must never appear in status,
|
||||
// frontend events, or error logs.
|
||||
pub(crate) struct OfficialRequestAuth {
|
||||
pub(crate) authorization: String,
|
||||
pub(crate) account_id: Option<String>,
|
||||
pub(crate) base_url: String,
|
||||
}
|
||||
|
||||
struct LiveAuth {
|
||||
token: String,
|
||||
account_id: Option<String>,
|
||||
base_url: &'static str,
|
||||
}
|
||||
|
||||
fn error(message: &str) -> CodexxError {
|
||||
CodexxError::Config(message.to_owned())
|
||||
}
|
||||
|
||||
fn read_bounded(path: &Path, limit: usize, missing_allowed: bool) -> Result<Vec<u8>> {
|
||||
let file = match fs::File::open(path) {
|
||||
Ok(file) => file,
|
||||
Err(err) if missing_allowed && err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(Vec::new())
|
||||
}
|
||||
Err(_) => return Err(error("无法读取当前官方登录配置,请检查文件后重试")),
|
||||
};
|
||||
if !file
|
||||
.metadata()
|
||||
.is_ok_and(|metadata| metadata.is_file() && metadata.len() <= limit as u64)
|
||||
{
|
||||
return Err(error("当前官方登录配置无法读取,请检查文件大小和格式"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
file.take(limit as u64 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| error("读取当前官方登录配置失败,请重试"))?;
|
||||
if bytes.len() > limit {
|
||||
return Err(error("当前官方登录配置过大,请检查文件"));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn logical_document(dir: &Path) -> Result<(Vec<u8>, DocumentMut)> {
|
||||
let bytes = read_bounded(&crate::config_path(dir), MAX_CONFIG_BYTES, true)?;
|
||||
let text = std::str::from_utf8(&bytes)
|
||||
.map_err(|_| error("当前 Codex 配置格式不正确,请先修复配置"))?;
|
||||
let doc = text
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|_| error("当前 Codex 配置格式不正确,请先修复配置"))?;
|
||||
let doc = super::direct_document(dir, &doc)
|
||||
.map_err(|_| error("无法确认当前官方路由,请检查本地路由状态"))?;
|
||||
Ok((bytes, doc))
|
||||
}
|
||||
|
||||
fn checked_secret(value: Option<&Value>) -> Option<String> {
|
||||
let value = value?.as_str()?.trim();
|
||||
(!value.is_empty()
|
||||
&& value.len() <= MAX_TOKEN_BYTES
|
||||
&& value.bytes().all(|byte| byte.is_ascii_graphic())
|
||||
&& value != "PROXY_MANAGED")
|
||||
.then(|| value.to_owned())
|
||||
}
|
||||
|
||||
fn checked_account(value: Option<&str>) -> Result<Option<String>> {
|
||||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if value.len() > 512 || !value.bytes().all(|byte| byte.is_ascii_graphic()) {
|
||||
return Err(error("当前官方账号信息无效,请重新登录 Codex"));
|
||||
}
|
||||
Ok(Some(value.to_owned()))
|
||||
}
|
||||
|
||||
fn parse_live_auth(bytes: &[u8]) -> Result<LiveAuth> {
|
||||
let auth: Value = serde_json::from_slice(bytes)
|
||||
.map_err(|_| error("官方账号尚未登录或认证无效,请在 Codex 中重新登录"))?;
|
||||
if !auth.is_object()
|
||||
|| ["base_url", "baseUrl", "api_base", "endpoint"]
|
||||
.iter()
|
||||
.any(|key| auth.get(key).is_some())
|
||||
{
|
||||
return Err(error("当前官方认证格式无效,请在 Codex 中重新登录"));
|
||||
}
|
||||
let mode = auth.get("auth_mode").and_then(Value::as_str);
|
||||
let api_key = checked_secret(auth.get("OPENAI_API_KEY"));
|
||||
let tokens = auth.get("tokens");
|
||||
let has_tokens = tokens.is_some_and(|value| {
|
||||
!value.is_null() && value.as_object().is_none_or(|values| !values.is_empty())
|
||||
});
|
||||
if let Some(token) = api_key {
|
||||
if mode.is_some_and(|value| !value.eq_ignore_ascii_case("apikey")) || has_tokens {
|
||||
return Err(error("官方登录与 API Key 认证混用,请在 Codex 中重新登录"));
|
||||
}
|
||||
return Ok(LiveAuth {
|
||||
token,
|
||||
account_id: None,
|
||||
base_url: OPENAI_API_BASE,
|
||||
});
|
||||
}
|
||||
if auth.get("OPENAI_API_KEY").is_some_and(|value| {
|
||||
!value.is_null() && value.as_str().is_none_or(|text| !text.trim().is_empty())
|
||||
}) {
|
||||
return Err(error("当前 OpenAI API Key 无效,请重新设置"));
|
||||
}
|
||||
if mode.is_some_and(|value| {
|
||||
!value.eq_ignore_ascii_case("chatgpt") && !value.eq_ignore_ascii_case("chatgptAuthTokens")
|
||||
}) {
|
||||
return Err(error(
|
||||
"此登录方式暂不支持本地路由,请使用 ChatGPT 登录或 OpenAI API Key",
|
||||
));
|
||||
}
|
||||
let token = checked_secret(auth.pointer("/tokens/access_token"))
|
||||
.ok_or_else(|| error("官方账号尚未登录,请在 Codex 中完成 ChatGPT 登录"))?;
|
||||
let account_id = checked_account(auth.pointer("/tokens/account_id").and_then(Value::as_str))?;
|
||||
Ok(LiveAuth {
|
||||
token,
|
||||
account_id,
|
||||
base_url: CHATGPT_BASE,
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_selected(spec: &OfficialRouteSpec) -> Result<(Vec<u8>, DocumentMut)> {
|
||||
let (bytes, doc) = logical_document(&spec.codex_dir)?;
|
||||
if !document_is_official(&doc)
|
||||
|| selected_profile_id(&spec.codex_dir)
|
||||
.map_err(|_| error("无法确认当前官方账号,请刷新后重试"))?
|
||||
!= spec.profile_id
|
||||
{
|
||||
return Err(error("当前官方账号已切换,请重启 Codex 或新建会话后重试"));
|
||||
}
|
||||
Ok((bytes, doc))
|
||||
}
|
||||
|
||||
pub(crate) fn route_for_current(dir: &Path) -> Result<Option<ProxyRoute>> {
|
||||
let (_, doc) = logical_document(dir)?;
|
||||
if !document_is_official(&doc) {
|
||||
return Ok(None);
|
||||
}
|
||||
let profile_id = selected_profile_id(dir)?;
|
||||
let name = list_official_profiles_inner(Some(dir.display().to_string()))?
|
||||
.into_iter()
|
||||
.find(|profile| profile.id == profile_id)
|
||||
.map(|profile| profile.provider_name)
|
||||
.unwrap_or_else(|| "OpenAI Official".to_owned());
|
||||
// A logged-out official route can still be taken over. Codex may complete
|
||||
// login or refresh after startup; every request re-reads and verifies it.
|
||||
let base_url = read_bounded(&crate::auth_path(dir), MAX_AUTH_BYTES, true)
|
||||
.ok()
|
||||
.and_then(|bytes| parse_live_auth(&bytes).ok())
|
||||
.map_or(CHATGPT_BASE, |auth| auth.base_url)
|
||||
.to_owned();
|
||||
let models = crate::string_value(&doc, "model")
|
||||
.into_iter()
|
||||
.collect::<HashSet<_>>();
|
||||
Ok(Some(ProxyRoute {
|
||||
protocol: super::protocol::UpstreamApi::Responses,
|
||||
id: format!("official:{profile_id}"),
|
||||
name,
|
||||
base_url,
|
||||
api_key: None,
|
||||
headers: Vec::new(),
|
||||
models,
|
||||
official: Some(OfficialRouteSpec {
|
||||
codex_dir: dir.to_path_buf(),
|
||||
profile_id,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn verify_request(
|
||||
spec: &OfficialRouteSpec,
|
||||
authorization: &str,
|
||||
account_id: Option<&str>,
|
||||
) -> Result<OfficialRequestAuth> {
|
||||
let (config_before, _) = ensure_selected(spec)?;
|
||||
if authorization.len() > MAX_TOKEN_BYTES + 32 || authorization.chars().any(char::is_control) {
|
||||
return Err(error("官方请求认证无效,请在 Codex 中重新登录"));
|
||||
}
|
||||
let parts: Vec<_> = authorization.split_whitespace().collect();
|
||||
if parts.len() != 2 || !parts[0].eq_ignore_ascii_case("Bearer") {
|
||||
return Err(error("缺少官方请求认证,请在 Codex 中完成登录"));
|
||||
}
|
||||
let auth_before = read_bounded(&crate::auth_path(&spec.codex_dir), MAX_AUTH_BYTES, true)?;
|
||||
let auth = parse_live_auth(&auth_before)?;
|
||||
if parts[1] != auth.token || checked_account(account_id)? != auth.account_id {
|
||||
return Err(error(
|
||||
"此会话没有加载当前官方账号,请重启 Codex 或新建会话后重试",
|
||||
));
|
||||
}
|
||||
// Avoid trusting a token across a simultaneous account switch/logout/refresh.
|
||||
// This reads files only; acquiring the live mutation lock here would deadlock
|
||||
// callers constructing routes while holding their own configuration guard.
|
||||
let (config_after, _) = ensure_selected(spec)?;
|
||||
let auth_after = read_bounded(&crate::auth_path(&spec.codex_dir), MAX_AUTH_BYTES, true)?;
|
||||
if config_before != config_after || auth_before != auth_after {
|
||||
return Err(error("官方登录正在更新,请稍后重试"));
|
||||
}
|
||||
Ok(OfficialRequestAuth {
|
||||
authorization: format!("Bearer {}", auth.token),
|
||||
account_id: auth.account_id,
|
||||
base_url: auth.base_url.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_link(metadata: &fs::Metadata) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
metadata.file_type().is_symlink() || metadata.file_attributes() & 0x400 != 0
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
metadata.file_type().is_symlink()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn native_models(spec: &OfficialRouteSpec) -> Result<Value> {
|
||||
let (_, doc) = ensure_selected(spec)?;
|
||||
let Some(pointer) = doc.get("model_catalog_json").and_then(|item| item.as_str()) else {
|
||||
return Ok(json!({"models":[]}));
|
||||
};
|
||||
let path = Path::new(pointer);
|
||||
if path
|
||||
.components()
|
||||
.any(|part| matches!(part, Component::ParentDir))
|
||||
{
|
||||
return Ok(json!({"models":[]}));
|
||||
}
|
||||
let path = if path.is_absolute() {
|
||||
path.to_owned()
|
||||
} else {
|
||||
spec.codex_dir.join(path)
|
||||
};
|
||||
let owned = spec.codex_dir.join(".codex-x").join("model-catalogs");
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
return Ok(json!({"models":[]}));
|
||||
};
|
||||
if !name
|
||||
.strip_suffix(".json")
|
||||
.is_some_and(|hash| hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit()))
|
||||
{
|
||||
return Ok(json!({"models":[]}));
|
||||
}
|
||||
for directory in [spec.codex_dir.join(".codex-x"), owned.clone()] {
|
||||
if !fs::symlink_metadata(directory)
|
||||
.is_ok_and(|metadata| !is_link(&metadata) && metadata.is_dir())
|
||||
{
|
||||
return Ok(json!({"models":[]}));
|
||||
}
|
||||
}
|
||||
if !fs::symlink_metadata(&path).is_ok_and(|metadata| !is_link(&metadata) && metadata.is_file())
|
||||
|| !path
|
||||
.parent()
|
||||
.and_then(|parent| parent.canonicalize().ok())
|
||||
.zip(owned.canonicalize().ok())
|
||||
.is_some_and(|(parent, owned)| parent == owned)
|
||||
{
|
||||
return Ok(json!({"models":[]}));
|
||||
}
|
||||
let Ok(bytes) = read_bounded(&path, MAX_CATALOG_BYTES, false) else {
|
||||
return Ok(json!({"models":[]}));
|
||||
};
|
||||
let Ok(value) = serde_json::from_slice::<Value>(&bytes) else {
|
||||
return Ok(json!({"models":[]}));
|
||||
};
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"codex-x-provider-model-catalog-v2\0");
|
||||
digest.update(serde_json::to_vec(&value).map_err(|_| error("本地模型目录格式无效"))?);
|
||||
if value
|
||||
.pointer("/_codex_x_model_catalog/source")
|
||||
.and_then(Value::as_str)
|
||||
!= Some("codex-x")
|
||||
|| !value.get("models").is_some_and(Value::is_array)
|
||||
|| format!("{:x}.json", digest.finalize()) != name
|
||||
{
|
||||
return Ok(json!({"models":[]}));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::providers::official_profiles::{
|
||||
save_official_profile_inner, switch_official_profile_inner, OfficialProfileInput,
|
||||
DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
struct Fixture {
|
||||
dir: PathBuf,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
static COUNT: AtomicU64 = AtomicU64::new(0);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"codex-x-native-official-{}-{}",
|
||||
std::process::id(),
|
||||
COUNT.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(crate::config_path(&dir), "model_provider = 'custom'\nmodel='official-model'\n[model_providers.custom]\nname='OpenAI'\nrequires_openai_auth=true\nsupports_websockets=true\nwire_api='responses'\n[mcp_servers.keep]\ncommand='fixture-mcp'\n").unwrap();
|
||||
Self { dir }
|
||||
}
|
||||
fn oauth(&self, access: &str, account: &str) {
|
||||
fs::write(crate::auth_path(&self.dir), serde_json::to_vec(&json!({"auth_mode":"chatgpt","tokens":{"access_token":access,"refresh_token":"unused-refresh-fixture","account_id":account}})).unwrap()).unwrap();
|
||||
}
|
||||
fn spec(&self) -> OfficialRouteSpec {
|
||||
OfficialRouteSpec {
|
||||
codex_dir: self.dir.clone(),
|
||||
profile_id: DEFAULT_OFFICIAL_PROFILE_ID.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_official_checks_exact_live_token_even_for_team_members_sharing_workspace() {
|
||||
let fixture = Fixture::new();
|
||||
fixture.oauth("live-user-a-token", "shared-team-workspace");
|
||||
let auth_before = fs::read(crate::auth_path(&fixture.dir)).unwrap();
|
||||
let config_before = fs::read(crate::config_path(&fixture.dir)).unwrap();
|
||||
let spec = fixture.spec();
|
||||
let allowed = verify_request(
|
||||
&spec,
|
||||
"Bearer live-user-a-token",
|
||||
Some("shared-team-workspace"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(allowed.base_url, CHATGPT_BASE);
|
||||
assert_eq!(allowed.account_id.as_deref(), Some("shared-team-workspace"));
|
||||
for (authorization, account) in [
|
||||
("Bearer another-user-token", Some("shared-team-workspace")),
|
||||
("Bearer live-user-a-token", Some("other-workspace")),
|
||||
("Bearer live-user-a-token", None),
|
||||
("Bearer PROXY_MANAGED", Some("shared-team-workspace")),
|
||||
("Bearer\nlive-user-a-token", Some("shared-team-workspace")),
|
||||
("", Some("shared-team-workspace")),
|
||||
] {
|
||||
let failure = verify_request(&spec, authorization, account)
|
||||
.err()
|
||||
.expect("must reject stale or wrong account credentials")
|
||||
.to_string();
|
||||
assert!(!failure.contains("live-user-a-token"));
|
||||
assert!(!failure.contains("another-user-token"));
|
||||
assert!(!failure.contains("shared-team-workspace"));
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read(crate::auth_path(&fixture.dir)).unwrap(),
|
||||
auth_before
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(crate::config_path(&fixture.dir)).unwrap(),
|
||||
config_before
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_official_login_and_token_refresh_are_owned_by_codex_without_cached_credentials() {
|
||||
let fixture = Fixture::new();
|
||||
let route = route_for_current(&fixture.dir).unwrap().unwrap();
|
||||
assert!(route.api_key.is_none());
|
||||
assert!(route.headers.is_empty());
|
||||
let spec = route.official.unwrap();
|
||||
assert!(verify_request(&spec, "Bearer absent", None).is_err());
|
||||
fixture.oauth("first-token", "account");
|
||||
assert!(verify_request(&spec, "Bearer first-token", Some("account")).is_ok());
|
||||
fixture.oauth("refreshed-token", "account");
|
||||
assert!(verify_request(&spec, "Bearer first-token", Some("account")).is_err());
|
||||
assert!(verify_request(&spec, "Bearer refreshed-token", Some("account")).is_ok());
|
||||
let latest = fs::read(crate::auth_path(&fixture.dir)).unwrap();
|
||||
route_for_current(&fixture.dir).unwrap();
|
||||
assert_eq!(fs::read(crate::auth_path(&fixture.dir)).unwrap(), latest);
|
||||
fs::remove_file(crate::auth_path(&fixture.dir)).unwrap();
|
||||
assert!(verify_request(&spec, "Bearer refreshed-token", Some("account")).is_err());
|
||||
assert!(route_for_current(&fixture.dir).unwrap().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_official_does_not_reuse_inactive_profile_or_third_party_oauth() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let fixture = Fixture::new();
|
||||
fixture.oauth("account-a-token", "workspace");
|
||||
let scope = Some(fixture.dir.display().to_string());
|
||||
let other = save_official_profile_inner(OfficialProfileInput {
|
||||
config_dir: scope.clone(), id: None, provider_name: "Account B".into(),
|
||||
model: Some("official-model".into()), config_text: Some(fs::read_to_string(crate::config_path(&fixture.dir)).unwrap()),
|
||||
auth_json: Some(json!({"auth_mode":"chatgpt","tokens":{"access_token":"account-b-token","account_id":"workspace"}}).to_string()),
|
||||
}).unwrap();
|
||||
let spec = fixture.spec();
|
||||
assert!(verify_request(&spec, "Bearer account-b-token", Some("workspace")).is_err());
|
||||
switch_official_profile_inner(scope, other.profile.id.clone()).unwrap();
|
||||
assert!(verify_request(&spec, "Bearer account-b-token", Some("workspace")).is_err());
|
||||
let current = route_for_current(&fixture.dir)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.official
|
||||
.unwrap();
|
||||
assert_eq!(current.profile_id, other.profile.id);
|
||||
assert!(verify_request(¤t, "Bearer account-b-token", Some("workspace")).is_ok());
|
||||
fs::write(crate::config_path(&fixture.dir), "model_provider='custom'\nmodel='third'\n[model_providers.custom]\nname='Third'\nbase_url='https://third.example.test/v1'\nrequires_openai_auth=false\n").unwrap();
|
||||
assert!(route_for_current(&fixture.dir).unwrap().is_none());
|
||||
assert!(verify_request(¤t, "Bearer account-b-token", Some("workspace")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_api_key_uses_only_openai_api_origin_and_never_accepts_oauth_or_mixed_auth() {
|
||||
let fixture = Fixture::new();
|
||||
fs::write(
|
||||
crate::auth_path(&fixture.dir),
|
||||
json!({"auth_mode":"apikey","OPENAI_API_KEY":"api-key-fixture","tokens":null})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let spec = fixture.spec();
|
||||
let verified = verify_request(&spec, "Bearer api-key-fixture", None).unwrap();
|
||||
assert_eq!(verified.base_url, OPENAI_API_BASE);
|
||||
assert!(verified.account_id.is_none());
|
||||
assert!(verify_request(&spec, "Bearer oauth-fixture", None).is_err());
|
||||
assert!(
|
||||
verify_request(&spec, "Bearer api-key-fixture", Some("old-oauth-account")).is_err()
|
||||
);
|
||||
fs::write(
|
||||
crate::auth_path(&fixture.dir),
|
||||
json!({"OPENAI_API_KEY":"api-key-fixture","tokens":{"access_token":"oauth-fixture"}})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(verify_request(&spec, "Bearer api-key-fixture", None).is_err());
|
||||
assert!(verify_request(&spec, "Bearer oauth-fixture", None).is_err());
|
||||
fs::write(crate::auth_path(&fixture.dir), "{ broken-secret-json").unwrap();
|
||||
let failure = verify_request(&spec, "Bearer api-key-fixture", None)
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(!failure.contains("broken-secret-json"));
|
||||
assert!(!failure.contains("api-key-fixture"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_models_serve_only_verified_owned_catalog_and_never_read_arbitrary_json() {
|
||||
let fixture = Fixture::new();
|
||||
let spec = fixture.spec();
|
||||
assert_eq!(native_models(&spec).unwrap(), json!({"models":[]}));
|
||||
let config_path = crate::config_path(&fixture.dir);
|
||||
let mut doc = fs::read_to_string(&config_path)
|
||||
.unwrap()
|
||||
.parse::<DocumentMut>()
|
||||
.unwrap();
|
||||
crate::providers::model_catalog::prepare_model_catalog(
|
||||
&fixture.dir,
|
||||
"native-test",
|
||||
&[crate::providers::model_catalog::ProviderModelMapping {
|
||||
model: "local-model".into(),
|
||||
display_name: "Local".into(),
|
||||
context_window: None,
|
||||
}],
|
||||
"local-model",
|
||||
&mut doc,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(&config_path, doc.to_string()).unwrap();
|
||||
let owned = PathBuf::from(doc["model_catalog_json"].as_str().unwrap());
|
||||
let before = fs::read(&config_path).unwrap();
|
||||
assert_eq!(
|
||||
native_models(&spec).unwrap()["models"][0]["slug"],
|
||||
"local-model"
|
||||
);
|
||||
assert_eq!(fs::read(&config_path).unwrap(), before);
|
||||
let outside = fixture.dir.join("unrelated-auth.json");
|
||||
fs::write(&outside, r#"{"models":["must-not-leak"]}"#).unwrap();
|
||||
doc["model_catalog_json"] = toml_edit::value(outside.display().to_string());
|
||||
fs::write(&config_path, doc.to_string()).unwrap();
|
||||
assert_eq!(native_models(&spec).unwrap(), json!({"models":[]}));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
fs::remove_file(&owned).unwrap();
|
||||
std::os::unix::fs::symlink(&outside, &owned).unwrap();
|
||||
doc["model_catalog_json"] = toml_edit::value(owned.display().to_string());
|
||||
fs::write(&config_path, doc.to_string()).unwrap();
|
||||
assert_eq!(native_models(&spec).unwrap(), json!({"models":[]}));
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn native_local_models_endpoint_accepts_owned_live_oauth_without_network_refresh_or_fallback() {
|
||||
use super::super::config::{RoutingTuning, ROUTE_TOKEN_HEADER};
|
||||
use super::super::proxy::{ProxyHandle, ProxyOptions};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::time::Duration;
|
||||
struct Server(ProxyHandle);
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
self.0.shutdown();
|
||||
}
|
||||
}
|
||||
let fixture = Fixture::new();
|
||||
fixture.oauth("local-native-access", "local-native-account");
|
||||
let route = route_for_current(&fixture.dir).unwrap().unwrap();
|
||||
let local_token = "native-local-route-token-fixture-000000000000";
|
||||
let server = Server(
|
||||
ProxyHandle::start(
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
0,
|
||||
local_token.into(),
|
||||
vec![route],
|
||||
ProxyOptions {
|
||||
auto_failover_enabled: true,
|
||||
tuning: RoutingTuning::default(),
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.unwrap();
|
||||
for access in ["local-native-access", "local-native-refreshed"] {
|
||||
fixture.oauth(access, "local-native-account");
|
||||
let auth_before = fs::read(crate::auth_path(&fixture.dir)).unwrap();
|
||||
let response = client
|
||||
.get(format!("http://127.0.0.1:{}/v1/models", server.0.port()))
|
||||
.header(ROUTE_TOKEN_HEADER, local_token)
|
||||
.header("authorization", format!("Bearer {access}"))
|
||||
.header("chatgpt-account-id", "local-native-account")
|
||||
.send()
|
||||
.unwrap();
|
||||
assert_eq!(response.status().as_u16(), 200);
|
||||
let body: Value = serde_json::from_str(&response.text().unwrap()).unwrap();
|
||||
assert_eq!(body, json!({"models":[]}));
|
||||
assert_eq!(
|
||||
fs::read(crate::auth_path(&fixture.dir)).unwrap(),
|
||||
auth_before
|
||||
);
|
||||
}
|
||||
let snapshot = server.0.snapshot();
|
||||
assert_eq!(snapshot.success_count, 2);
|
||||
assert_eq!(snapshot.failover_count, 0);
|
||||
assert_eq!(
|
||||
snapshot.last_provider_id.as_deref(),
|
||||
Some("official:openai-official")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
use super::{encode_thinking, ToolContext, UpstreamApi};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
pub(crate) fn error_message(value: &Value) -> Option<String> {
|
||||
let error = value.get("error").filter(|v| !v.is_null()).or_else(|| {
|
||||
(value.get("type").and_then(Value::as_str) == Some("error")).then_some(value)
|
||||
})?;
|
||||
Some(
|
||||
error
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.or_else(|| {
|
||||
error
|
||||
.get("message")
|
||||
.or_else(|| error.get("detail"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| error.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn convert_response(
|
||||
api: UpstreamApi,
|
||||
body: &Value,
|
||||
context: &ToolContext,
|
||||
requested_model: &str,
|
||||
) -> std::result::Result<Value, String> {
|
||||
if let Some(error) = error_message(body) {
|
||||
return Err(error);
|
||||
}
|
||||
match api {
|
||||
UpstreamApi::Responses => Ok(body.clone()),
|
||||
UpstreamApi::ChatCompletions => chat_response(body, context, requested_model),
|
||||
UpstreamApi::AnthropicMessages => anthropic_response(body, context, requested_model),
|
||||
UpstreamApi::Gemini => gemini_response(body, context, requested_model),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn response_id(id: Option<&str>) -> String {
|
||||
if let Some(id) = id.filter(|s| !s.is_empty()) {
|
||||
let digest = Sha256::digest(id.as_bytes());
|
||||
return format!(
|
||||
"resp_codexx_{}",
|
||||
digest
|
||||
.iter()
|
||||
.take(12)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
);
|
||||
}
|
||||
format!(
|
||||
"resp_codexx_{}",
|
||||
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn envelope(
|
||||
id: &str,
|
||||
model: &str,
|
||||
status: &str,
|
||||
output: Vec<Value>,
|
||||
usage: Value,
|
||||
reason: Option<&str>,
|
||||
) -> Value {
|
||||
let mut value = json!({"id":id,"object":"response","created_at":chrono::Utc::now().timestamp(),"model":model,"status":status,"output":output,"usage":usage,"error":null,"incomplete_details":null});
|
||||
if let Some(reason) = reason {
|
||||
value["incomplete_details"] = json!({"reason":reason});
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
pub(super) fn text_item(id: &str, text: &str) -> Value {
|
||||
json!({"id":id,"type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":text,"annotations":[]}]})
|
||||
}
|
||||
|
||||
pub(super) fn reasoning_item(id: &str, text: &str, state: Option<Value>) -> Value {
|
||||
let mut value = json!({"id":id,"type":"reasoning","summary":if text.is_empty(){json!([])}else{json!([{"type":"summary_text","text":text}])}});
|
||||
if let Some(state) = state {
|
||||
value["encrypted_content"] = json!(encode_thinking(&state));
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
pub(super) fn tool_item(
|
||||
context: &ToolContext,
|
||||
id: &str,
|
||||
call_id: &str,
|
||||
upstream_name: &str,
|
||||
arguments: &str,
|
||||
) -> std::result::Result<Value, String> {
|
||||
if upstream_name.is_empty() {
|
||||
return Err("上游工具调用缺少名称".into());
|
||||
}
|
||||
let spec = context.names.get(upstream_name);
|
||||
let mut value = if spec.is_some_and(|s| s.custom) {
|
||||
let arguments: Value =
|
||||
serde_json::from_str(arguments).map_err(|_| "上游 custom 工具参数不是有效的 JSON")?;
|
||||
let input = arguments
|
||||
.get("input")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("上游 custom 工具参数缺少 input 文字")?;
|
||||
json!({"id":id,"type":"custom_tool_call","call_id":call_id,"name":spec.unwrap().name,"input":input,"status":"completed"})
|
||||
} else {
|
||||
let parsed: Value =
|
||||
serde_json::from_str(arguments).map_err(|_| "上游工具参数不是有效的 JSON")?;
|
||||
if !parsed.is_object() {
|
||||
return Err("上游工具参数须是 JSON 对象".into());
|
||||
}
|
||||
json!({"id":id,"type":"function_call","call_id":call_id,"name":spec.map_or(upstream_name,|s|s.name.as_str()),"arguments":arguments,"status":"completed"})
|
||||
};
|
||||
if let Some(namespace) = spec.and_then(|s| s.namespace.as_deref()) {
|
||||
value["namespace"] = json!(namespace);
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(super) fn terminal(
|
||||
api: UpstreamApi,
|
||||
reason: Option<&str>,
|
||||
) -> (&'static str, Option<&'static str>) {
|
||||
match (api, reason) {
|
||||
(_, Some("length" | "max_tokens" | "MAX_TOKENS" | "model_context_window_exceeded")) => {
|
||||
("incomplete", Some("max_output_tokens"))
|
||||
}
|
||||
(
|
||||
_,
|
||||
Some(
|
||||
"content_filter" | "refusal" | "SAFETY" | "BLOCKLIST" | "PROHIBITED_CONTENT"
|
||||
| "RECITATION" | "SPII" | "IMAGE_SAFETY",
|
||||
),
|
||||
) => ("incomplete", Some("content_filter")),
|
||||
(_, Some("MALFORMED_FUNCTION_CALL" | "UNEXPECTED_TOOL_CALL")) => {
|
||||
("incomplete", Some("tool_call_error"))
|
||||
}
|
||||
_ => ("completed", None),
|
||||
}
|
||||
}
|
||||
|
||||
fn tokens(value: Option<&Value>) -> u64 {
|
||||
value.and_then(Value::as_u64).unwrap_or(0)
|
||||
}
|
||||
pub(super) fn usage(api: UpstreamApi, value: Option<&Value>) -> Value {
|
||||
let empty = json!({});
|
||||
let u = value.unwrap_or(&empty);
|
||||
let (input, output, cached, reasoning, write) = match api {
|
||||
UpstreamApi::ChatCompletions => (
|
||||
tokens(u.get("prompt_tokens")),
|
||||
tokens(u.get("completion_tokens")),
|
||||
tokens(u.pointer("/prompt_tokens_details/cached_tokens"))
|
||||
.max(tokens(u.get("prompt_cache_hit_tokens"))),
|
||||
tokens(u.pointer("/completion_tokens_details/reasoning_tokens")),
|
||||
tokens(u.get("cache_creation_input_tokens")),
|
||||
),
|
||||
UpstreamApi::AnthropicMessages => {
|
||||
let cached = tokens(u.get("cache_read_input_tokens"));
|
||||
let write = tokens(u.get("cache_creation_input_tokens"));
|
||||
(
|
||||
tokens(u.get("input_tokens"))
|
||||
.saturating_add(cached)
|
||||
.saturating_add(write),
|
||||
tokens(u.get("output_tokens")),
|
||||
cached,
|
||||
tokens(u.get("thinking_tokens")),
|
||||
write,
|
||||
)
|
||||
}
|
||||
UpstreamApi::Gemini => {
|
||||
let reasoning = tokens(u.get("thoughtsTokenCount"));
|
||||
(
|
||||
tokens(u.get("promptTokenCount")),
|
||||
tokens(u.get("candidatesTokenCount")).saturating_add(reasoning),
|
||||
tokens(u.get("cachedContentTokenCount")),
|
||||
reasoning,
|
||||
0,
|
||||
)
|
||||
}
|
||||
UpstreamApi::Responses => return u.clone(),
|
||||
};
|
||||
json!({"input_tokens":input,"output_tokens":output,"total_tokens":input.saturating_add(output),"input_tokens_details":{"cached_tokens":cached,"cache_write_tokens":write},"output_tokens_details":{"reasoning_tokens":reasoning}})
|
||||
}
|
||||
|
||||
fn chat_response(
|
||||
body: &Value,
|
||||
context: &ToolContext,
|
||||
model: &str,
|
||||
) -> std::result::Result<Value, String> {
|
||||
let choice = body
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|v| v.first())
|
||||
.ok_or("Chat 响应缺少 choices")?;
|
||||
let message = choice.get("message").ok_or("Chat 响应缺少 message")?;
|
||||
let id = response_id(body.get("id").and_then(Value::as_str));
|
||||
let mut output = Vec::new();
|
||||
if let Some(thinking) = chat_reasoning(message) {
|
||||
output.push(reasoning_item(&format!("rs_{id}"), &thinking, None));
|
||||
}
|
||||
let text = match message.get("content") {
|
||||
Some(Value::String(text)) => text.clone(),
|
||||
Some(Value::Array(parts)) => {
|
||||
let mut text = String::new();
|
||||
for part in parts {
|
||||
if matches!(
|
||||
part.get("type").and_then(Value::as_str),
|
||||
Some("text" | "output_text")
|
||||
) {
|
||||
text.push_str(
|
||||
part.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("Chat 文字内容缺少 text")?,
|
||||
);
|
||||
} else {
|
||||
return Err("Chat 响应含不能转换的非文字内容".into());
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
if !text.is_empty() {
|
||||
output.push(text_item(&format!("msg_{id}"), &text));
|
||||
}
|
||||
if let Some(refusal) = message.get("refusal").and_then(Value::as_str) {
|
||||
output.push(json!({"id":format!("msg_refusal_{id}"),"type":"message","role":"assistant","status":"completed","content":[{"type":"refusal","refusal":refusal}]}));
|
||||
}
|
||||
if let Some(calls) = message.get("tool_calls").and_then(Value::as_array) {
|
||||
for (index, call) in calls.iter().enumerate() {
|
||||
let function = call.get("function").ok_or("Chat 工具调用缺少 function")?;
|
||||
let call_id = call
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| format!("call_{id}_{index}"));
|
||||
output.push(tool_item(
|
||||
context,
|
||||
&format!("fc_{id}_{index}"),
|
||||
&call_id,
|
||||
function.get("name").and_then(Value::as_str).unwrap_or(""),
|
||||
function
|
||||
.get("arguments")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("{}"),
|
||||
)?);
|
||||
}
|
||||
}
|
||||
if let Some(call) = message.get("function_call") {
|
||||
output.push(tool_item(
|
||||
context,
|
||||
&format!("fc_{id}_legacy"),
|
||||
&format!("call_{id}_legacy"),
|
||||
call.get("name").and_then(Value::as_str).unwrap_or(""),
|
||||
call.get("arguments")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("{}"),
|
||||
)?);
|
||||
}
|
||||
let reason = choice
|
||||
.get("finish_reason")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("Chat 响应缺少 finish_reason,无法确认完整完成")?;
|
||||
let (status, incomplete) = terminal(UpstreamApi::ChatCompletions, Some(reason));
|
||||
Ok(envelope(
|
||||
&id,
|
||||
body.get("model").and_then(Value::as_str).unwrap_or(model),
|
||||
status,
|
||||
output,
|
||||
usage(UpstreamApi::ChatCompletions, body.get("usage")),
|
||||
incomplete,
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn chat_reasoning(value: &Value) -> Option<String> {
|
||||
value
|
||||
.get("reasoning_content")
|
||||
.or_else(|| value.get("reasoning"))
|
||||
.and_then(|v| match v {
|
||||
Value::String(s) if !s.is_empty() => Some(s.clone()),
|
||||
Value::Array(items) => Some(
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|v| v.get("text").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join(""),
|
||||
),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn anthropic_response(
|
||||
body: &Value,
|
||||
context: &ToolContext,
|
||||
model: &str,
|
||||
) -> std::result::Result<Value, String> {
|
||||
let blocks = body
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or("Anthropic 响应缺少 content")?;
|
||||
let id = response_id(body.get("id").and_then(Value::as_str));
|
||||
let mut output = Vec::new();
|
||||
for (index, block) in blocks.iter().enumerate() {
|
||||
match block.get("type").and_then(Value::as_str) {
|
||||
Some("text") => output.push(text_item(
|
||||
&format!("msg_{id}_{index}"),
|
||||
block.get("text").and_then(Value::as_str).unwrap_or(""),
|
||||
)),
|
||||
Some("thinking" | "redacted_thinking") => output.push(reasoning_item(
|
||||
&format!("rs_{id}_{index}"),
|
||||
block.get("thinking").and_then(Value::as_str).unwrap_or(""),
|
||||
Some(json!({"protocol":"anthropic","block":block})),
|
||||
)),
|
||||
Some("tool_use") => {
|
||||
let call_id = block
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("Anthropic tool_use 缺少 id")?;
|
||||
output.push(tool_item(
|
||||
context,
|
||||
&format!("fc_{id}_{index}"),
|
||||
call_id,
|
||||
block.get("name").and_then(Value::as_str).unwrap_or(""),
|
||||
&block
|
||||
.get("input")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}))
|
||||
.to_string(),
|
||||
)?);
|
||||
}
|
||||
Some(other) => return Err(format!("无法转换 Anthropic 输出类型 {other}")),
|
||||
None => return Err("Anthropic content 缺少类型".into()),
|
||||
}
|
||||
}
|
||||
let reason = body
|
||||
.get("stop_reason")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("Anthropic 响应缺少 stop_reason,无法确认完整完成")?;
|
||||
let (status, incomplete) = terminal(UpstreamApi::AnthropicMessages, Some(reason));
|
||||
Ok(envelope(
|
||||
&id,
|
||||
body.get("model").and_then(Value::as_str).unwrap_or(model),
|
||||
status,
|
||||
output,
|
||||
usage(UpstreamApi::AnthropicMessages, body.get("usage")),
|
||||
incomplete,
|
||||
))
|
||||
}
|
||||
|
||||
fn gemini_response(
|
||||
body: &Value,
|
||||
context: &ToolContext,
|
||||
model: &str,
|
||||
) -> std::result::Result<Value, String> {
|
||||
if let Some(block) = body
|
||||
.pointer("/promptFeedback/blockReason")
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
return Err(format!("Gemini 拒绝了输入:{block}"));
|
||||
}
|
||||
let candidate = body
|
||||
.get("candidates")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|v| v.first())
|
||||
.ok_or("Gemini 响应缺少 candidates")?;
|
||||
let parts = candidate
|
||||
.pointer("/content/parts")
|
||||
.and_then(Value::as_array);
|
||||
if context.single_tool
|
||||
&& parts
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|part| part.get("functionCall").is_some())
|
||||
.count()
|
||||
> 1
|
||||
{
|
||||
return Err("Gemini 返回了多个并行工具调用,而本次请求禁止并行;未向 Codex 返回任何可执行工具,请重试".into());
|
||||
}
|
||||
let id = response_id(body.get("responseId").and_then(Value::as_str));
|
||||
let mut output = Vec::new();
|
||||
let signed: Vec<Value> = parts
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
.filter(|(_, part)| {
|
||||
part.get("thoughtSignature").is_some()
|
||||
&& part.get("thought").and_then(Value::as_bool) != Some(true)
|
||||
})
|
||||
.map(|(index, part)| {
|
||||
let mut part = part.clone();
|
||||
if part.get("text").is_some() {
|
||||
part["codex_item_id"] = json!(format!("msg_{id}_{index}"));
|
||||
}
|
||||
if part.get("functionCall").is_some() && part.pointer("/functionCall/id").is_none() {
|
||||
part["functionCall"]["id"] = json!(format!("call_{id}_{index}"));
|
||||
}
|
||||
part
|
||||
})
|
||||
.collect();
|
||||
if !signed.is_empty() {
|
||||
output.push(reasoning_item(
|
||||
&format!("rs_sig_{id}"),
|
||||
"",
|
||||
Some(json!({"protocol":"gemini","parts":signed})),
|
||||
));
|
||||
}
|
||||
for (index, part) in parts.into_iter().flatten().enumerate() {
|
||||
if let Some(text) = part.get("text").and_then(Value::as_str) {
|
||||
output.push(
|
||||
if part.get("thought").and_then(Value::as_bool) == Some(true) {
|
||||
reasoning_item(
|
||||
&format!("rs_{id}_{index}"),
|
||||
text,
|
||||
Some(json!({"protocol":"gemini","parts":[part]})),
|
||||
)
|
||||
} else {
|
||||
text_item(&format!("msg_{id}_{index}"), text)
|
||||
},
|
||||
);
|
||||
} else if let Some(call) = part.get("functionCall") {
|
||||
let call_id = call
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| format!("call_{id}_{index}"));
|
||||
output.push(tool_item(
|
||||
context,
|
||||
&format!("fc_{id}_{index}"),
|
||||
&call_id,
|
||||
call.get("name").and_then(Value::as_str).unwrap_or(""),
|
||||
&call
|
||||
.get("args")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!({}))
|
||||
.to_string(),
|
||||
)?);
|
||||
} else if part.get("thoughtSignature").is_none() {
|
||||
return Err("Gemini 返回了不能转换为 Codex 输出的多模态内容".into());
|
||||
}
|
||||
}
|
||||
let reason = candidate
|
||||
.get("finishReason")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("Gemini 响应缺少 finishReason,无法确认完整完成")?;
|
||||
let (status, incomplete) = terminal(UpstreamApi::Gemini, Some(reason));
|
||||
Ok(envelope(
|
||||
&id,
|
||||
body.get("modelVersion")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(model),
|
||||
status,
|
||||
output,
|
||||
usage(UpstreamApi::Gemini, body.get("usageMetadata")),
|
||||
incomplete,
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
use super::*;
|
||||
use std::io::{self, Read};
|
||||
|
||||
fn prepared(api: UpstreamApi, body: Value) -> PreparedRequest {
|
||||
prepare(api, &serde_json::to_vec(&body).unwrap(), false).unwrap()
|
||||
}
|
||||
fn request(api: UpstreamApi, body: Value) -> Value {
|
||||
serde_json::from_slice(&prepared(api, body).bytes).unwrap()
|
||||
}
|
||||
fn events(text: &str) -> Vec<Value> {
|
||||
text.lines()
|
||||
.filter_map(|line| line.strip_prefix("data: "))
|
||||
.map(|line| serde_json::from_str(line).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct ByteReader(Vec<u8>, usize);
|
||||
impl Read for ByteReader {
|
||||
fn read(&mut self, output: &mut [u8]) -> io::Result<usize> {
|
||||
if self.1 == self.0.len() {
|
||||
return Ok(0);
|
||||
}
|
||||
output[0] = self.0[self.1];
|
||||
self.1 += 1;
|
||||
Ok(1)
|
||||
}
|
||||
}
|
||||
fn streamed(api: UpstreamApi, input: &str, context: ToolContext) -> String {
|
||||
let mut reader = ResponseStream::new(
|
||||
ByteReader(input.as_bytes().to_vec(), 0),
|
||||
api,
|
||||
context,
|
||||
"fixture".into(),
|
||||
);
|
||||
let mut text = String::new();
|
||||
reader.read_to_string(&mut text).unwrap();
|
||||
text
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_conversions_preserve_roles_media_tools_and_parallel_results() {
|
||||
let body = json!({"model":"fixture","instructions":"system instruction","max_output_tokens":7000,"input":[
|
||||
{"role":"developer","content":"developer instruction"},
|
||||
{"role":"user","content":[{"type":"input_text","text":"inspect this"},{"type":"input_image","image_url":"data:image/png;base64,aGVsbG8="}]},
|
||||
{"type":"function_call","call_id":"call-1","namespace":"functions","name":"read","arguments":"{\"path\":\"x\"}"},
|
||||
{"type":"custom_tool_call","call_id":"call-2","name":"apply_patch","input":"*** Begin Patch\n*** End Patch"},
|
||||
{"type":"function_call_output","call_id":"call-1","output":"file text"},
|
||||
{"type":"custom_tool_call_output","call_id":"call-2","output":[{"type":"input_text","text":"patched"},{"type":"input_image","image_url":"data:image/png;base64,aGVsbG8="}]}
|
||||
],"tools":[{"type":"namespace","name":"functions","tools":[{"type":"function","name":"read","parameters":{"type":"object","properties":{"path":{"type":"string"}}}}]},{"type":"custom","name":"apply_patch","description":"Apply a patch","format":{"type":"grammar","syntax":"lark","definition":"start: /.+/"}}]});
|
||||
let chat = request(UpstreamApi::ChatCompletions, body.clone());
|
||||
assert_eq!(chat["messages"][0]["role"], "system");
|
||||
assert_eq!(chat["messages"][1]["role"], "system");
|
||||
assert_eq!(
|
||||
chat["messages"][2]["content"][1]["image_url"]["url"],
|
||||
"data:image/png;base64,aGVsbG8="
|
||||
);
|
||||
assert_eq!(
|
||||
chat["messages"][3]["tool_calls"].as_array().unwrap().len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(chat["messages"][4]["tool_call_id"], "call-1");
|
||||
assert_eq!(chat["messages"][5]["tool_call_id"], "call-2");
|
||||
assert_eq!(chat["messages"][6]["role"], "user");
|
||||
assert!(chat["tools"][0]["function"]["name"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("functions__read_"));
|
||||
assert!(chat["tools"][1]["function"]["description"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("grammar"));
|
||||
let anthropic = request(UpstreamApi::AnthropicMessages, body.clone());
|
||||
assert!(anthropic["system"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("developer instruction"));
|
||||
assert_eq!(anthropic["max_tokens"], 7000);
|
||||
assert_eq!(
|
||||
anthropic["messages"][0]["content"][1]["source"]["media_type"],
|
||||
"image/png"
|
||||
);
|
||||
assert_eq!(
|
||||
anthropic["messages"][1]["content"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
anthropic["messages"][2]["content"][1]["content"][1]["type"],
|
||||
"image"
|
||||
);
|
||||
let gemini = request(UpstreamApi::Gemini, body);
|
||||
assert_eq!(
|
||||
gemini["systemInstruction"]["parts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
gemini["contents"][0]["parts"][1]["inlineData"]["mimeType"],
|
||||
"image/png"
|
||||
);
|
||||
assert_eq!(
|
||||
gemini["contents"][1]["parts"][0]["functionCall"]["id"],
|
||||
"call-1"
|
||||
);
|
||||
assert_eq!(
|
||||
gemini["contents"][2]["parts"][0]["functionResponse"]["id"],
|
||||
"call-1"
|
||||
);
|
||||
assert_eq!(
|
||||
gemini["contents"][2]["parts"][0]["functionResponse"]["name"],
|
||||
gemini["contents"][1]["parts"][0]["functionCall"]["name"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_responses_server_state_is_explicit_and_native_bytes_are_unchanged() {
|
||||
for api in [
|
||||
UpstreamApi::ChatCompletions,
|
||||
UpstreamApi::AnthropicMessages,
|
||||
UpstreamApi::Gemini,
|
||||
] {
|
||||
for body in [
|
||||
json!({"model":"fixture","input":"x","previous_response_id":"resp-remote"}),
|
||||
json!({"model":"fixture","input":[{"type":"reasoning","encrypted_content":"opaque-upstream-ciphertext"}]}),
|
||||
json!({"model":"fixture","input":[{"type":"item_reference","id":"remote-item"}]}),
|
||||
] {
|
||||
let bytes = serde_json::to_vec(&body).unwrap();
|
||||
assert!(prepare(api, &bytes, false).is_err());
|
||||
assert_eq!(
|
||||
prepare(UpstreamApi::Responses, &bytes, false)
|
||||
.unwrap()
|
||||
.bytes,
|
||||
bytes
|
||||
);
|
||||
}
|
||||
assert!(prepare(api, br#"{"model":"fixture","input":"x"}"#, true)
|
||||
.unwrap_err()
|
||||
.contains("compact"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_and_custom_tools_restore_original_codex_shapes() {
|
||||
let prepared = prepared(
|
||||
UpstreamApi::ChatCompletions,
|
||||
json!({"model":"fixture","input":"x","tools":[{"type":"namespace","name":"functions","tools":[{"type":"function","name":"read"}]},{"type":"custom","name":"apply_patch"}]}),
|
||||
);
|
||||
let name = prepared.context.upstream_name("read", Some("functions"));
|
||||
let body = json!({"id":"chat-1","choices":[{"finish_reason":"tool_calls","message":{"tool_calls":[{"id":"a","function":{"name":name,"arguments":"{}"}},{"id":"b","function":{"name":"apply_patch","arguments":"{\"input\":\"patch text\"}"}}]}}],"usage":{"prompt_tokens":3,"completion_tokens":4}});
|
||||
let converted = convert_response(
|
||||
UpstreamApi::ChatCompletions,
|
||||
&body,
|
||||
&prepared.context,
|
||||
"fixture",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(converted["output"][0]["namespace"], "functions");
|
||||
assert_eq!(converted["output"][0]["name"], "read");
|
||||
assert_eq!(converted["output"][1]["type"], "custom_tool_call");
|
||||
assert_eq!(converted["output"][1]["input"], "patch text");
|
||||
assert_eq!(converted["usage"]["total_tokens"], 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_sse_crosses_utf8_crlf_and_fragmented_function_json() {
|
||||
let context = prepared(
|
||||
UpstreamApi::ChatCompletions,
|
||||
json!({"model":"fixture","input":"x","tools":[{"type":"function","name":"read"}]}),
|
||||
)
|
||||
.context;
|
||||
let input=concat!("data: {\"id\":\"c\",\"choices\":[{\"delta\":{\"content\":\"中文🙂\"}}]}\r\n\r\n", "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-x\",\"function\":{\"name\":\"read\",\"arguments\":\"{\\\"x\\\":\"}}]}}]}\r\n\r\n", "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"1}\"}}]},\"finish_reason\":\"tool_calls\"}]}\r\n\r\n", "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2}}\r\n\r\n", "data: [DONE]\r\n\r\n");
|
||||
let output = streamed(UpstreamApi::ChatCompletions, input, context);
|
||||
let events = events(&output);
|
||||
assert!(events
|
||||
.iter()
|
||||
.any(|v| v["type"] == "response.output_text.delta" && v["delta"] == "中文🙂"));
|
||||
let response = &events.last().unwrap()["response"];
|
||||
assert_eq!(response["status"], "completed");
|
||||
assert_eq!(response["output"][1]["call_id"], "call-x");
|
||||
assert_eq!(response["output"][1]["arguments"], "{\"x\":1}");
|
||||
assert_eq!(response["usage"]["total_tokens"], 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_error_before_output_returns_error_and_after_output_emits_failed() {
|
||||
let error = "event: error\ndata: {\"error\":{\"message\":\"fixture outage\"}}\n\n";
|
||||
let mut early = ResponseStream::new(
|
||||
io::Cursor::new(error),
|
||||
UpstreamApi::ChatCompletions,
|
||||
ToolContext::default(),
|
||||
"fixture".into(),
|
||||
);
|
||||
let mut bytes = Vec::new();
|
||||
assert!(early.read_to_end(&mut bytes).is_err());
|
||||
assert!(bytes.is_empty());
|
||||
let output = streamed(
|
||||
UpstreamApi::ChatCompletions,
|
||||
&format!("data: {{\"choices\":[{{\"delta\":{{\"content\":\"partial\"}}}}]}}\n\n{error}"),
|
||||
ToolContext::default(),
|
||||
);
|
||||
let events = events(&output);
|
||||
assert_eq!(events.last().unwrap()["type"], "response.failed");
|
||||
assert!(events.iter().all(|v| v["type"] != "response.completed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_eof_without_terminal_signal_is_incomplete() {
|
||||
for (api, input) in [
|
||||
(
|
||||
UpstreamApi::ChatCompletions,
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n",
|
||||
),
|
||||
(
|
||||
UpstreamApi::Gemini,
|
||||
"data: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"partial\"}]}}]}\n\n",
|
||||
),
|
||||
] {
|
||||
let output = streamed(api, input, ToolContext::default());
|
||||
let events = events(&output);
|
||||
assert_eq!(events.last().unwrap()["type"], "response.incomplete");
|
||||
assert_eq!(
|
||||
events.last().unwrap()["response"]["incomplete_details"]["reason"],
|
||||
"stream_truncated"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anthropic_thinking_signature_and_gemini_tool_signature_round_trip() {
|
||||
let ctx = prepared(
|
||||
UpstreamApi::Gemini,
|
||||
json!({"model":"fixture","input":"x","tools":[{"type":"function","name":"read"}]}),
|
||||
)
|
||||
.context;
|
||||
let anthropic = json!({"id":"m","type":"message","content":[{"type":"thinking","thinking":"analysis","signature":"signed-thinking"},{"type":"tool_use","id":"call-a","name":"read","input":{}}],"stop_reason":"tool_use","usage":{"input_tokens":2,"output_tokens":3,"cache_read_input_tokens":5}});
|
||||
let response =
|
||||
convert_response(UpstreamApi::AnthropicMessages, &anthropic, &ctx, "fixture").unwrap();
|
||||
assert_eq!(response["usage"]["input_tokens"], 7);
|
||||
let mut history = vec![json!({"role":"user","content":"inspect"})];
|
||||
history.extend(response["output"].as_array().unwrap().clone());
|
||||
history.push(json!({"type":"function_call_output","call_id":"call-a","output":"done"}));
|
||||
let anthropic_req = request(
|
||||
UpstreamApi::AnthropicMessages,
|
||||
json!({"model":"fixture","input":history}),
|
||||
);
|
||||
assert_eq!(
|
||||
anthropic_req["messages"][1]["content"][0]["signature"],
|
||||
"signed-thinking"
|
||||
);
|
||||
let gemini = json!({"responseId":"g","candidates":[{"content":{"parts":[{"functionCall":{"name":"read","args":{}},"thoughtSignature":"signed-function"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":2,"candidatesTokenCount":3,"thoughtsTokenCount":4}});
|
||||
let response = convert_response(UpstreamApi::Gemini, &gemini, &ctx, "fixture").unwrap();
|
||||
let call = response["output"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|v| v["type"] == "function_call")
|
||||
.unwrap();
|
||||
let mut history = vec![json!({"role":"user","content":"inspect"})];
|
||||
history.extend(response["output"].as_array().unwrap().clone());
|
||||
history.push(json!({"type":"function_call_output","call_id":call["call_id"],"output":"done"}));
|
||||
let request = request(
|
||||
UpstreamApi::Gemini,
|
||||
json!({"model":"fixture","input":history}),
|
||||
);
|
||||
assert_eq!(request["contents"][1]["parts"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
request["contents"][1]["parts"][0]["thoughtSignature"],
|
||||
"signed-function"
|
||||
);
|
||||
assert_eq!(response["usage"]["output_tokens"], 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stream_json_fallback_and_length_reason_are_truthful() {
|
||||
let input = r#"{"id":"chat","choices":[{"message":{"content":"truncated"},"finish_reason":"length"}],"usage":{"prompt_tokens":2,"completion_tokens":3}}"#;
|
||||
let output = streamed(UpstreamApi::ChatCompletions, input, ToolContext::default());
|
||||
let events = events(&output);
|
||||
assert_eq!(events.last().unwrap()["type"], "response.incomplete");
|
||||
assert_eq!(
|
||||
events.last().unwrap()["response"]["incomplete_details"]["reason"],
|
||||
"max_output_tokens"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_signed_text_chunks_replay_on_the_matching_assistant_message() {
|
||||
let input="data: {\"responseId\":\"g\",\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"hello \"}]}}]}\n\ndata: {\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"world\",\"thoughtSignature\":\"signature\"}]},\"finishReason\":\"STOP\"}]}\n\n";
|
||||
let output = streamed(UpstreamApi::Gemini, input, ToolContext::default());
|
||||
let ev = events(&output);
|
||||
let response = &ev.last().unwrap()["response"];
|
||||
let mut history = vec![json!({"role":"user","content":"hello world"})];
|
||||
history.extend(response["output"].as_array().unwrap().clone());
|
||||
history.push(json!({"role":"user","content":"continue"}));
|
||||
let converted = request(
|
||||
UpstreamApi::Gemini,
|
||||
json!({"model":"gemini-2.5-pro","input":history}),
|
||||
);
|
||||
assert!(converted["contents"][0]["parts"][0]
|
||||
.get("thoughtSignature")
|
||||
.is_none());
|
||||
assert_eq!(converted["contents"][1]["parts"][0]["text"], "hello world");
|
||||
assert_eq!(
|
||||
converted["contents"][1]["parts"][0]["thoughtSignature"],
|
||||
"signature"
|
||||
);
|
||||
let mut history = vec![json!({"role":"user","content":"OK"})];
|
||||
for (id, sig) in [("g1", "sig1"), ("g2", "sig2")] {
|
||||
let converted=convert_response(UpstreamApi::Gemini,&json!({"responseId":id,"candidates":[{"content":{"parts":[{"text":"OK","thoughtSignature":sig}]},"finishReason":"STOP"}]}),&ToolContext::default(),"fixture").unwrap();
|
||||
history.extend(converted["output"].as_array().unwrap().clone());
|
||||
history.push(json!({"role":"user","content":"OK"}));
|
||||
}
|
||||
let converted = request(
|
||||
UpstreamApi::Gemini,
|
||||
json!({"model":"fixture","input":history}),
|
||||
);
|
||||
assert_eq!(
|
||||
converted["contents"][1]["parts"][0]["thoughtSignature"],
|
||||
"sig1"
|
||||
);
|
||||
assert_eq!(
|
||||
converted["contents"][3]["parts"][0]["thoughtSignature"],
|
||||
"sig2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_single_tool_constraint_buffers_tools_and_rejects_parallel_execution() {
|
||||
let prepared = prepared(
|
||||
UpstreamApi::Gemini,
|
||||
json!({"model":"gemini-2.5-pro","input":"x","parallel_tool_calls":false,"tools":[{"type":"function","name":"read"}]}),
|
||||
);
|
||||
let request: Value = serde_json::from_slice(&prepared.bytes).unwrap();
|
||||
assert!(request["systemInstruction"]["parts"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("at most one"));
|
||||
let response = json!({"candidates":[{"content":{"parts":[{"functionCall":{"name":"read","args":{}}},{"functionCall":{"name":"read","args":{}}}]},"finishReason":"STOP"}]});
|
||||
assert!(
|
||||
convert_response(UpstreamApi::Gemini, &response, &prepared.context, "fixture").is_err()
|
||||
);
|
||||
let input = format!("data: {response}\n\n");
|
||||
let mut stream = ResponseStream::new(
|
||||
ByteReader(input.into_bytes(), 0),
|
||||
UpstreamApi::Gemini,
|
||||
prepared.context.clone(),
|
||||
"fixture".into(),
|
||||
);
|
||||
let mut bytes = Vec::new();
|
||||
assert!(stream.read_to_end(&mut bytes).is_err());
|
||||
assert!(bytes.is_empty());
|
||||
let input=format!("data: {{\"candidates\":[{{\"content\":{{\"parts\":[{{\"text\":\"planning\"}}]}}}}]}}\n\ndata: {response}\n\n");
|
||||
let output = streamed(UpstreamApi::Gemini, &input, prepared.context.clone());
|
||||
let ev = events(&output);
|
||||
assert_eq!(ev.last().unwrap()["type"], "response.failed");
|
||||
assert!(ev
|
||||
.iter()
|
||||
.all(|v| v["type"] != "response.output_item.done" || v["item"]["type"] != "function_call"));
|
||||
let single="data: {\"candidates\":[{\"content\":{\"parts\":[{\"functionCall\":{\"name\":\"read\",\"args\":{}}}]},\"finishReason\":\"STOP\"}]}\n\n";
|
||||
let output = streamed(UpstreamApi::Gemini, single, prepared.context);
|
||||
assert_eq!(
|
||||
events(&output).last().unwrap()["type"],
|
||||
"response.completed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_parallel_results_keep_media_after_all_tool_responses_and_reasoning_on_call() {
|
||||
let body = json!({"model":"fixture","input":[{"role":"user","content":"x"},{"type":"reasoning","summary":[{"text":"analysis"}]},{"role":"assistant","content":"Let me check"},{"type":"function_call","call_id":"a","name":"read","arguments":"{}"},{"type":"function_call","call_id":"b","name":"read","arguments":"{}"},{"type":"function_call_output","call_id":"a","output":[{"type":"input_image","image_url":"data:image/png;base64,aGVsbG8="}]},{"type":"function_call_output","call_id":"b","output":"done"}]});
|
||||
let converted = request(UpstreamApi::ChatCompletions, body);
|
||||
let messages = converted["messages"].as_array().unwrap();
|
||||
assert_eq!(messages[1]["reasoning_content"], "analysis");
|
||||
assert_eq!(messages[1]["content"][0]["text"], "Let me check");
|
||||
assert_eq!(messages[1]["tool_calls"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(messages[2]["tool_call_id"], "a");
|
||||
assert_eq!(messages[3]["tool_call_id"], "b");
|
||||
assert_eq!(messages[4]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_output_and_unsupported_parameters_are_explicit() {
|
||||
let body = json!({"model":"fixture","input":"x","text":{"format":{"type":"json_schema","name":"test","strict":true,"schema":{"type":"object","properties":{"ok":{"type":"boolean"}}}}}});
|
||||
let chat = request(UpstreamApi::ChatCompletions, body.clone());
|
||||
assert_eq!(chat["response_format"]["json_schema"]["strict"], true);
|
||||
let anth = request(UpstreamApi::AnthropicMessages, body.clone());
|
||||
assert_eq!(anth["output_config"]["format"]["schema"]["type"], "object");
|
||||
let gem = request(UpstreamApi::Gemini, body);
|
||||
assert_eq!(
|
||||
gem["generationConfig"]["responseMimeType"],
|
||||
"application/json"
|
||||
);
|
||||
assert_eq!(
|
||||
gem["generationConfig"]["responseJsonSchema"]["type"],
|
||||
"object"
|
||||
);
|
||||
for body in [
|
||||
json!({"model":"fixture","input":"x","unknown_setting":1}),
|
||||
json!({"model":"fixture","input":"x","tools":{}}),
|
||||
json!({"model":"fixture","input":"x","tool_choice":"required"}),
|
||||
json!({"model":"fixture","input":"x","tools":[{"type":"function","name":"read"},{"type":"function","name":"read"}]}),
|
||||
] {
|
||||
assert!(prepare(
|
||||
UpstreamApi::ChatCompletions,
|
||||
&serde_json::to_vec(&body).unwrap(),
|
||||
false
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
assert!(prepare(
|
||||
UpstreamApi::AnthropicMessages,
|
||||
&serde_json::to_vec(
|
||||
&json!({"model":"fixture","input":"x","temperature":0.2,"reasoning":{"effort":"high"}})
|
||||
)
|
||||
.unwrap(),
|
||||
false
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("temperature"));
|
||||
let ultra = request(
|
||||
UpstreamApi::AnthropicMessages,
|
||||
json!({"model":"fixture","input":"x","max_output_tokens":80000,"reasoning":{"effort":"ultra"}}),
|
||||
);
|
||||
assert_eq!(ultra["thinking"]["budget_tokens"], 32768);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_sse_does_not_consume_late_error_or_transport_reset() {
|
||||
struct ResetAfter(Vec<Vec<u8>>, usize);
|
||||
impl Read for ResetAfter {
|
||||
fn read(&mut self, bytes: &mut [u8]) -> io::Result<usize> {
|
||||
if self.1 == self.0.len() {
|
||||
return Err(io::Error::new(io::ErrorKind::ConnectionReset, "late reset"));
|
||||
}
|
||||
let chunk = &self.0[self.1];
|
||||
bytes[..chunk.len()].copy_from_slice(chunk);
|
||||
self.1 += 1;
|
||||
Ok(chunk.len())
|
||||
}
|
||||
}
|
||||
let chunks = vec![
|
||||
b"data: {\"choices\":[{\"delta\":{\"content\":\"hello\"}}]}\n\n".to_vec(),
|
||||
b"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n"
|
||||
.to_vec(),
|
||||
b"event: error\ndata: {\"error\":{\"message\":\"late\"}}\n\n".to_vec(),
|
||||
];
|
||||
let mut stream = ResponseStream::new(
|
||||
ResetAfter(chunks, 0),
|
||||
UpstreamApi::ChatCompletions,
|
||||
ToolContext::default(),
|
||||
"fixture".into(),
|
||||
);
|
||||
let mut output = String::new();
|
||||
stream.read_to_string(&mut output).unwrap();
|
||||
let ev = events(&output);
|
||||
assert_eq!(
|
||||
ev.iter()
|
||||
.filter(|v| v["type"] == "response.completed")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(ev.iter().all(|v| v["type"] != "response.failed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_stream_fallback_has_empty_added_items_before_deltas() {
|
||||
let input = r#"{"id":"chat","choices":[{"message":{"content":"hello","tool_calls":[{"id":"a","function":{"name":"read","arguments":"{\"x\":1}"}}]},"finish_reason":"tool_calls"}]}"#;
|
||||
let output = streamed(UpstreamApi::ChatCompletions, input, ToolContext::default());
|
||||
let ev = events(&output);
|
||||
let added = ev
|
||||
.iter()
|
||||
.filter(|v| v["type"] == "response.output_item.added")
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(added[0]["item"]["status"], "in_progress");
|
||||
assert_eq!(added[0]["item"]["content"], json!([]));
|
||||
assert_eq!(added[1]["item"]["arguments"], "");
|
||||
let part = ev
|
||||
.iter()
|
||||
.find(|v| v["type"] == "response.content_part.added")
|
||||
.unwrap();
|
||||
assert_eq!(part["part"]["text"], "");
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
use super::*;
|
||||
|
||||
struct OwnedRouteFixture {
|
||||
dir: PathBuf,
|
||||
raw: String,
|
||||
local_token: String,
|
||||
}
|
||||
|
||||
impl OwnedRouteFixture {
|
||||
fn new() -> Self {
|
||||
let unique = format!(
|
||||
"{}-{}",
|
||||
std::process::id(),
|
||||
Local::now().timestamp_nanos_opt().unwrap()
|
||||
);
|
||||
let dir = std::env::temp_dir().join(format!("codex-x-failover-integration-{unique}"));
|
||||
fs::create_dir_all(&dir).expect("create isolated Codex directory");
|
||||
let local_token = format!("local-token-{unique}");
|
||||
let original = r#"name = "Primary"
|
||||
base_url = "https://primary.example.test/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
experimental_bearer_token = "real-test-key"
|
||||
supports_websockets = true
|
||||
"#;
|
||||
let raw = format!(
|
||||
r#"model_provider = "custom"
|
||||
model = "test-model"
|
||||
model_reasoning_effort = "high"
|
||||
|
||||
[model_providers.custom]
|
||||
name = "Primary"
|
||||
base_url = "http://127.0.0.1:45555/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
experimental_bearer_token = "{local_token}"
|
||||
supports_websockets = false
|
||||
request_max_retries = 0
|
||||
stream_max_retries = 0
|
||||
|
||||
[mcp_servers.example]
|
||||
command = "local-fixture-command"
|
||||
"#
|
||||
);
|
||||
fs::write(config_path(&dir), &raw).expect("write synthetic local route");
|
||||
let record = json!({
|
||||
"settings": {"enabled":false,"providerIds":[]},
|
||||
"journals": [{"primaryId":"fixture-primary", "providerKey":"custom",
|
||||
"originalTable":original, "port":45555, "token":local_token}],
|
||||
});
|
||||
app_db::open()
|
||||
.expect("open isolated test store")
|
||||
.execute(
|
||||
"INSERT INTO provider_failover (codex_dir, record_json) VALUES (?1, ?2)",
|
||||
params![paths::normalized_path_scope(&dir), record.to_string()],
|
||||
)
|
||||
.expect("seed owned route journal");
|
||||
Self {
|
||||
dir,
|
||||
raw,
|
||||
local_token,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OwnedRouteFixture {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(conn) = app_db::open() {
|
||||
let _ = conn.execute(
|
||||
"DELETE FROM provider_failover WHERE codex_dir = ?1",
|
||||
[paths::normalized_path_scope(&self.dir)],
|
||||
);
|
||||
}
|
||||
let _ = fs::remove_dir_all(&self.dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn managed_route_state_detection_and_drafts_use_original_provider_without_writes() {
|
||||
let fixture = OwnedRouteFixture::new();
|
||||
let mut detected = providers::detected_live_custom_provider(&fixture.dir)
|
||||
.expect("detect logical provider")
|
||||
.expect("third-party provider");
|
||||
assert_eq!(detected.base_url, "https://primary.example.test/v1");
|
||||
assert_eq!(detected.api_key.as_deref(), Some("real-test-key"));
|
||||
assert!(!detected
|
||||
.toml_config
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains(&fixture.local_token));
|
||||
detected.toml_config = None;
|
||||
let draft = build_provider_toml_draft_inner(detected, Some(fixture.dir.display().to_string()))
|
||||
.expect("inherit direct route in editor draft");
|
||||
assert!(!draft.contains("127.0.0.1"));
|
||||
assert!(!draft.contains(&fixture.local_token));
|
||||
assert!(draft.contains("local-fixture-command"));
|
||||
let state =
|
||||
build_state_after_migration(fixture.dir.clone()).expect("read logical application state");
|
||||
let state_json = serde_json::to_value(&state).expect("serialize public state");
|
||||
assert_eq!(
|
||||
state_json["providers"][0]["baseUrl"],
|
||||
"https://primary.example.test/v1"
|
||||
);
|
||||
assert_eq!(state.model.as_deref(), Some("test-model"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&fixture.dir)).unwrap(),
|
||||
fixture.raw
|
||||
);
|
||||
assert!(!auth_path(&fixture.dir).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_managed_toml_submission_restores_owned_route_but_preserves_new_api_key() {
|
||||
let fixture = OwnedRouteFixture::new();
|
||||
for (input_key, expected_key) in [
|
||||
(
|
||||
Some(fixture.local_token.clone()),
|
||||
Some("real-test-key".to_string()),
|
||||
),
|
||||
(
|
||||
Some("new-user-key".to_string()),
|
||||
Some("new-user-key".to_string()),
|
||||
),
|
||||
(
|
||||
Some(format!(" {} ", fixture.local_token)),
|
||||
Some("real-test-key".to_string()),
|
||||
),
|
||||
(None, None),
|
||||
] {
|
||||
let input = direct_provider_toml_input(ProviderTomlInput {
|
||||
config_dir: Some(fixture.dir.display().to_string()),
|
||||
config_text: fixture.raw.replace("\"high\"", "\"low\""),
|
||||
api_key: input_key,
|
||||
})
|
||||
.expect("unwrap stale editor payload");
|
||||
assert_eq!(input.api_key, expected_key);
|
||||
let doc = input
|
||||
.config_text
|
||||
.parse::<DocumentMut>()
|
||||
.expect("parse direct payload");
|
||||
assert_eq!(doc["model_reasoning_effort"].as_str(), Some("low"));
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some("https://primary.example.test/v1")
|
||||
);
|
||||
assert!(!input.config_text.contains(&fixture.local_token));
|
||||
assert!(input.config_text.contains("local-fixture-command"));
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&fixture.dir)).unwrap(),
|
||||
fixture.raw
|
||||
);
|
||||
assert!(!auth_path(&fixture.dir).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_unwrap_handles_multiple_owned_tables_before_detecting_selected_provider() {
|
||||
let mut fixture = OwnedRouteFixture::new();
|
||||
let second_original = "name = \"Inactive\"\nbase_url = \"https://secondary.example.test/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = false\nexperimental_bearer_token = \"secondary-test-key\"\n";
|
||||
fixture.raw.push_str("\n[model_providers.inactive]\nname = \"Inactive\"\nbase_url = \"http://127.0.0.1:45556/v1\"\nwire_api = \"responses\"\nrequires_openai_auth = false\nexperimental_bearer_token = \"secondary-local-token\"\n");
|
||||
fs::write(config_path(&fixture.dir), &fixture.raw).expect("write second local table");
|
||||
let conn = app_db::open().expect("read fixture store");
|
||||
let scope = paths::normalized_path_scope(&fixture.dir);
|
||||
let text: String = conn
|
||||
.query_row(
|
||||
"SELECT record_json FROM provider_failover WHERE codex_dir = ?1",
|
||||
[&scope],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.expect("read recovery journal");
|
||||
let mut record: Value = serde_json::from_str(&text).expect("parse recovery journal");
|
||||
record["journals"].as_array_mut().unwrap().push(json!({
|
||||
"primaryId":"inactive", "providerKey":"inactive", "originalTable":second_original,
|
||||
"port":45556, "token":"secondary-local-token"
|
||||
}));
|
||||
conn.execute(
|
||||
"UPDATE provider_failover SET record_json = ?1 WHERE codex_dir = ?2",
|
||||
params![record.to_string(), scope],
|
||||
)
|
||||
.expect("append recovery identity");
|
||||
drop(conn);
|
||||
let detected = providers::detected_live_custom_provider(&fixture.dir)
|
||||
.expect("detect selected provider")
|
||||
.expect("selected third-party provider");
|
||||
assert_eq!(detected.base_url, "https://primary.example.test/v1");
|
||||
assert_eq!(detected.api_key.as_deref(), Some("real-test-key"));
|
||||
assert!(!detected.toml_config.unwrap().contains("127.0.0.1"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&fixture.dir)).unwrap(),
|
||||
fixture.raw
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_owned_route_keeps_official_status_quota_refresh_and_snapshot_credentials_scoped() {
|
||||
let _guard = app_db::test_db_guard();
|
||||
let mut fixture = OwnedRouteFixture::new();
|
||||
let original = "name = 'OpenAI'\nwire_api = 'responses'\nrequires_openai_auth = true\nsupports_websockets = true\n";
|
||||
fixture.raw = format!("model_provider='custom'\nmodel='official-model'\n[model_providers.custom]\nname='OpenAI'\nwire_api='responses'\nrequires_openai_auth=true\nsupports_websockets=false\nbase_url='http://127.0.0.1:45555/v1'\nhttp_headers={{'x-codex-x-route-token'='{}'}}\n[mcp_servers.keep]\ncommand='keep-native-mcp'\n", fixture.local_token);
|
||||
fs::write(config_path(&fixture.dir), &fixture.raw).unwrap();
|
||||
let record = json!({
|
||||
"version":2,
|
||||
"settings":{"routerEnabled":false,"takeoverEnabled":false,"autoFailoverEnabled":false,"providerIds":[]},
|
||||
"journals":[{"primaryId":"official:openai-official","providerKey":"custom","originalTable":original,
|
||||
"listenAddress":"127.0.0.1","port":45555,"token":fixture.local_token,"official":true,"tableExisted":true,"providersExisted":true}]
|
||||
});
|
||||
app_db::open()
|
||||
.unwrap()
|
||||
.execute(
|
||||
"UPDATE provider_failover SET record_json=?1 WHERE codex_dir=?2",
|
||||
params![
|
||||
record.to_string(),
|
||||
paths::normalized_path_scope(&fixture.dir)
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let auth = json!({"auth_mode":"chatgpt","tokens":{"access_token":"native-current-access","refresh_token":"native-refresh","account_id":"native-account"}});
|
||||
fs::write(auth_path(&fixture.dir), auth.to_string()).unwrap();
|
||||
let config_before = fs::read(config_path(&fixture.dir)).unwrap();
|
||||
let auth_before = fs::read(auth_path(&fixture.dir)).unwrap();
|
||||
let state = build_state_after_migration(fixture.dir.clone()).unwrap();
|
||||
assert!(state.is_official_provider);
|
||||
assert_eq!(
|
||||
state.active_official_profile_id.as_deref(),
|
||||
Some("openai-official")
|
||||
);
|
||||
assert!(providers::detected_live_custom_provider(&fixture.dir)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
let profiles = list_official_profiles_inner(Some(fixture.dir.display().to_string())).unwrap();
|
||||
assert!(profiles[0].is_current);
|
||||
assert!(profiles[0].can_query_quota);
|
||||
let credentials = providers::official_profiles::official_profile_quota_credentials(
|
||||
&fixture.dir,
|
||||
"openai-official",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(credentials.access_token, "native-current-access");
|
||||
let route = failover::native_official::route_for_current(&fixture.dir)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(route.api_key.is_none());
|
||||
let spec = route.official.unwrap();
|
||||
assert!(failover::native_official::verify_request(
|
||||
&spec,
|
||||
"Bearer native-current-access",
|
||||
Some("native-account")
|
||||
)
|
||||
.is_ok());
|
||||
assert_eq!(fs::read(config_path(&fixture.dir)).unwrap(), config_before);
|
||||
assert_eq!(fs::read(auth_path(&fixture.dir)).unwrap(), auth_before);
|
||||
let refreshed = json!({"auth_mode":"chatgpt","tokens":{"access_token":"native-refreshed-access","refresh_token":"native-refreshed-refresh","account_id":"native-account"}});
|
||||
fs::write(auth_path(&fixture.dir), refreshed.to_string()).unwrap();
|
||||
assert_eq!(
|
||||
providers::official_profiles::official_profile_quota_credentials(
|
||||
&fixture.dir,
|
||||
"openai-official"
|
||||
)
|
||||
.unwrap()
|
||||
.access_token,
|
||||
"native-refreshed-access"
|
||||
);
|
||||
assert!(failover::native_official::verify_request(
|
||||
&spec,
|
||||
"Bearer native-current-access",
|
||||
Some("native-account")
|
||||
)
|
||||
.is_err());
|
||||
assert!(failover::native_official::verify_request(
|
||||
&spec,
|
||||
"Bearer native-refreshed-access",
|
||||
Some("native-account")
|
||||
)
|
||||
.is_ok());
|
||||
assert!(providers::capture_live_chatgpt_config(&fixture.dir).unwrap());
|
||||
let snapshot_path = providers::official_snapshot_path_for_test(&fixture.dir).unwrap();
|
||||
let snapshot: Value = serde_json::from_slice(&fs::read(&snapshot_path).unwrap()).unwrap();
|
||||
let saved_config = snapshot["config"].as_str().unwrap();
|
||||
assert!(!saved_config.contains("127.0.0.1"));
|
||||
assert!(!saved_config.contains("x-codex-x-route-token"));
|
||||
assert!(!saved_config.contains(&fixture.local_token));
|
||||
assert!(saved_config.contains("keep-native-mcp"));
|
||||
assert_eq!(
|
||||
snapshot["auth"]["tokens"]["access_token"],
|
||||
"native-refreshed-access"
|
||||
);
|
||||
assert_eq!(fs::read(config_path(&fixture.dir)).unwrap(), config_before);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<Value>(&fs::read(auth_path(&fixture.dir)).unwrap()).unwrap(),
|
||||
refreshed
|
||||
);
|
||||
fs::remove_file(snapshot_path).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use chrono::Local;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use toml_edit::DocumentMut;
|
||||
|
||||
pub(crate) fn io_err(path: &Path, source: std::io::Error) -> CodexxError {
|
||||
CodexxError::Io {
|
||||
path: path.display().to_string(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn json_err(path: &Path, source: serde_json::Error) -> CodexxError {
|
||||
CodexxError::Json {
|
||||
path: path.display().to_string(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn metadata_is_file_link(metadata: &fs::Metadata) -> bool {
|
||||
use std::os::windows::fs::FileTypeExt;
|
||||
metadata.file_type().is_symlink_file()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn metadata_is_file_link(_metadata: &fs::Metadata) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn metadata_is_directory_link(metadata: &fs::Metadata) -> bool {
|
||||
use std::os::windows::fs::FileTypeExt;
|
||||
metadata.file_type().is_symlink_dir()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn metadata_is_directory_link(_metadata: &fs::Metadata) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn metadata_is_directory_entry(metadata: &fs::Metadata) -> bool {
|
||||
metadata.is_dir() && !metadata_is_directory_link(metadata)
|
||||
}
|
||||
|
||||
pub(crate) fn directory_exists(path: &Path) -> bool {
|
||||
let Ok(metadata) = fs::symlink_metadata(path) else {
|
||||
return false;
|
||||
};
|
||||
metadata_is_directory_entry(&metadata)
|
||||
|| (!metadata_is_file_link(&metadata)
|
||||
&& (metadata_is_directory_link(&metadata) || metadata.file_type().is_symlink())
|
||||
&& fs::metadata(path).is_ok_and(|target| target.is_dir()))
|
||||
}
|
||||
|
||||
fn existing_directory_entry(path: &Path, metadata: &fs::Metadata) -> Result<()> {
|
||||
if metadata_is_directory_entry(metadata) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if metadata_is_file_link(metadata) {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"此链接被创建成了文件链接,不能作为文件夹使用:{}。请在创建链接的工具中重新建立目录链接",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
if metadata_is_directory_link(metadata) || metadata.file_type().is_symlink() {
|
||||
return match fs::metadata(path) {
|
||||
Ok(target) if target.is_dir() => Ok(()),
|
||||
_ => Err(CodexxError::Config(format!(
|
||||
"文件夹链接已失效或目标不是文件夹:{}",
|
||||
path.display()
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
Err(CodexxError::Config(format!(
|
||||
"此路径已被同名文件占用,不是文件夹:{}",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_directory(path: &Path) -> Result<()> {
|
||||
match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => existing_directory_entry(path, &metadata),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
match fs::create_dir_all(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(create_error) => match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => existing_directory_entry(path, &metadata),
|
||||
Err(_) => Err(io_err(path, create_error)),
|
||||
},
|
||||
}
|
||||
}
|
||||
Err(error) => Err(io_err(path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_to_string_if_exists(path: &Path) -> Result<String> {
|
||||
if !path.exists() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
fs::read_to_string(path).map_err(|e| io_err(path, e))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn harden_sensitive_file_permissions(path: &Path) -> Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let metadata = match fs::metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(io_err(path, error)),
|
||||
};
|
||||
if !metadata.is_file() || metadata.permissions().mode() & 0o777 == 0o600 {
|
||||
return Ok(());
|
||||
}
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let _ = path;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn parse_toml_document(path: &Path, text: &str) -> Result<DocumentMut> {
|
||||
if text.trim().is_empty() {
|
||||
return Ok(DocumentMut::new());
|
||||
}
|
||||
text.parse::<DocumentMut>().map_err(|e| CodexxError::Toml {
|
||||
path: path.display().to_string(),
|
||||
message: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn atomic_write_mode(path: &Path) -> Option<u32> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let sensitive = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| matches!(name, "config.toml" | "auth.json"));
|
||||
if sensitive {
|
||||
return Some(0o600);
|
||||
}
|
||||
fs::metadata(path)
|
||||
.ok()
|
||||
.map(|metadata| metadata.permissions().mode() & 0o777)
|
||||
}
|
||||
|
||||
fn create_atomic_temp(path: &Path, tmp: &Path, private: bool) -> Result<fs::File> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
|
||||
let mode = private.then_some(0o600).or_else(|| atomic_write_mode(path));
|
||||
if let Some(mode) = mode {
|
||||
options.mode(mode);
|
||||
let file = options.open(tmp).map_err(|error| io_err(tmp, error))?;
|
||||
fs::set_permissions(tmp, fs::Permissions::from_mode(mode))
|
||||
.map_err(|error| io_err(tmp, error))?;
|
||||
return Ok(file);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let _ = private;
|
||||
|
||||
options.open(tmp).map_err(|error| io_err(tmp, error))
|
||||
}
|
||||
|
||||
fn atomic_write_with_privacy_and_check<F>(
|
||||
path: &Path,
|
||||
bytes: &[u8],
|
||||
private: bool,
|
||||
pre_commit: F,
|
||||
) -> Result<()>
|
||||
where
|
||||
F: FnOnce() -> Result<()>,
|
||||
{
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static WRITE_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let tmp = path.with_extension(format!(
|
||||
"tmp.{}.{}.{}",
|
||||
std::process::id(),
|
||||
Local::now().timestamp_nanos_opt().unwrap_or_default(),
|
||||
WRITE_COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
let result = (|| {
|
||||
let mut file = create_atomic_temp(path, &tmp, private)?;
|
||||
file.write_all(bytes).map_err(|e| io_err(&tmp, e))?;
|
||||
file.sync_all().map_err(|e| io_err(&tmp, e))?;
|
||||
drop(file);
|
||||
pre_commit()?;
|
||||
fs::rename(&tmp, path).map_err(|e| io_err(path, e))
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&tmp);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
atomic_write_with_privacy_and_check(path, bytes, false, || Ok(()))
|
||||
}
|
||||
|
||||
pub(crate) fn atomic_write_checked<F>(path: &Path, bytes: &[u8], pre_commit: F) -> Result<()>
|
||||
where
|
||||
F: FnOnce() -> Result<()>,
|
||||
{
|
||||
atomic_write_with_privacy_and_check(path, bytes, false, pre_commit)
|
||||
}
|
||||
|
||||
pub(crate) fn write_text(path: &Path, text: &str) -> Result<()> {
|
||||
atomic_write(path, text.as_bytes())
|
||||
}
|
||||
|
||||
pub(crate) fn write_json(path: &Path, value: &Value) -> Result<()> {
|
||||
let text = serde_json::to_string_pretty(value).map_err(|e| json_err(path, e))?;
|
||||
write_text(path, &(text + "\n"))
|
||||
}
|
||||
|
||||
pub(crate) fn write_private_json(path: &Path, value: &Value) -> Result<()> {
|
||||
let text = serde_json::to_string_pretty(value).map_err(|e| json_err(path, e))?;
|
||||
atomic_write_with_privacy_and_check(path, (text + "\n").as_bytes(), true, || Ok(()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codex-x-file-io-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).expect("create test directory");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_write_replaces_existing_file_without_temp_residue() {
|
||||
let dir = temp_dir("replace");
|
||||
let path = dir.join("state.json");
|
||||
fs::write(&path, b"old").expect("write original file");
|
||||
|
||||
atomic_write(&path, b"new").expect("replace file atomically");
|
||||
|
||||
assert_eq!(fs::read(&path).expect("read replaced file"), b"new");
|
||||
let entries = fs::read_dir(&dir)
|
||||
.expect("read test directory")
|
||||
.map(|entry| entry.expect("read directory entry").file_name())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(entries, vec![path.file_name().unwrap().to_os_string()]);
|
||||
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checked_atomic_write_rejects_a_change_immediately_before_replace() {
|
||||
let dir = temp_dir("checked-race");
|
||||
let path = dir.join("config.toml");
|
||||
fs::write(&path, b"old").expect("write original file");
|
||||
|
||||
let error = atomic_write_checked(&path, b"codex-x", || {
|
||||
fs::write(&path, b"external").expect("simulate external writer");
|
||||
Err(CodexxError::Config("stale snapshot".to_string()))
|
||||
})
|
||||
.expect_err("stale checked write must fail");
|
||||
|
||||
assert!(error.to_string().contains("stale snapshot"));
|
||||
assert_eq!(fs::read(&path).expect("read external value"), b"external");
|
||||
assert_eq!(fs::read_dir(&dir).expect("read directory").count(), 1);
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn atomic_write_restricts_sensitive_codex_files_and_preserves_other_modes() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = temp_dir("permissions");
|
||||
let config = dir.join("config.toml");
|
||||
let auth = dir.join("auth.json");
|
||||
let script = dir.join("tool.sh");
|
||||
for path in [&config, &auth, &script] {
|
||||
fs::write(path, b"old").expect("seed file");
|
||||
}
|
||||
fs::set_permissions(&config, fs::Permissions::from_mode(0o644)).expect("set config mode");
|
||||
fs::set_permissions(&auth, fs::Permissions::from_mode(0o644)).expect("set auth mode");
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).expect("set script mode");
|
||||
|
||||
atomic_write(&config, b"new config").expect("replace config");
|
||||
atomic_write(&auth, b"new auth").expect("replace auth");
|
||||
atomic_write(&script, b"new script").expect("replace script");
|
||||
|
||||
let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode(&config), 0o600);
|
||||
assert_eq!(mode(&auth), 0o600);
|
||||
assert_eq!(mode(&script), 0o755);
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn harden_sensitive_permissions_updates_existing_files_without_creating_missing_files() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = temp_dir("harden-permissions");
|
||||
let config = dir.join("config.toml");
|
||||
let missing = dir.join("auth.json");
|
||||
fs::write(&config, b"model = \"gpt\"\n").expect("seed config");
|
||||
fs::set_permissions(&config, fs::Permissions::from_mode(0o644)).expect("set open mode");
|
||||
|
||||
harden_sensitive_file_permissions(&config).expect("harden config");
|
||||
harden_sensitive_file_permissions(&missing).expect("ignore missing auth");
|
||||
|
||||
assert_eq!(
|
||||
fs::metadata(&config).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
assert!(!missing.exists());
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn private_json_is_private_from_its_first_atomic_write() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = temp_dir("private-json");
|
||||
let path = dir.join("official-snapshot.json");
|
||||
|
||||
write_private_json(&path, &serde_json::json!({"token": "secret"}))
|
||||
.expect("write private JSON");
|
||||
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_directory_accepts_existing_and_missing_directories() {
|
||||
let root = temp_dir("ensure-directory");
|
||||
let existing = root.join("existing");
|
||||
let missing = root.join("missing").join("nested");
|
||||
fs::create_dir(&existing).expect("create existing directory");
|
||||
|
||||
ensure_directory(&existing).expect("accept existing directory");
|
||||
ensure_directory(&missing).expect("create missing directory");
|
||||
|
||||
assert!(missing.is_dir());
|
||||
fs::remove_dir_all(root).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_directory_rejects_a_file_without_removing_it() {
|
||||
let root = temp_dir("ensure-directory-file");
|
||||
let occupied = root.join(".codex");
|
||||
fs::write(&occupied, "keep me").expect("create occupying file");
|
||||
|
||||
let error = ensure_directory(&occupied).expect_err("file is not a directory");
|
||||
|
||||
assert!(!directory_exists(&occupied));
|
||||
assert!(error.to_string().contains("不是文件夹"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(&occupied).expect("read occupying file"),
|
||||
"keep me"
|
||||
);
|
||||
fs::remove_dir_all(root).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn ensure_directory_accepts_a_directory_symlink_and_rejects_a_broken_link() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let root = temp_dir("ensure-directory-link");
|
||||
let target = root.join("target");
|
||||
let link = root.join("linked-codex-home");
|
||||
let broken = root.join("broken-codex-home");
|
||||
fs::create_dir(&target).expect("create target directory");
|
||||
symlink(&target, &link).expect("create directory symlink");
|
||||
symlink(root.join("missing-target"), &broken).expect("create broken symlink");
|
||||
|
||||
ensure_directory(&link).expect("accept directory symlink");
|
||||
atomic_write(&link.join("config.toml"), b"linked").expect("write through symlink");
|
||||
let error = ensure_directory(&broken).expect_err("reject broken symlink");
|
||||
|
||||
assert_eq!(
|
||||
fs::read(target.join("config.toml")).expect("read symlink target"),
|
||||
b"linked"
|
||||
);
|
||||
assert!(error.to_string().contains("链接已失效"));
|
||||
fs::remove_dir_all(root).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn ensure_directory_accepts_a_windows_directory_symlink() {
|
||||
use std::os::windows::fs::symlink_dir;
|
||||
|
||||
let root = temp_dir("ensure-directory-windows-link");
|
||||
let target = root.join("目标 文件夹");
|
||||
let link = root.join("linked-codex-home");
|
||||
fs::create_dir(&target).expect("create target directory");
|
||||
match symlink_dir(&target, &link) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.raw_os_error() == Some(1314) => {
|
||||
fs::remove_dir_all(root).expect("remove test directory");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("create directory symlink: {error}"),
|
||||
}
|
||||
|
||||
ensure_directory(&link).expect("accept Windows directory symlink");
|
||||
assert!(directory_exists(&link));
|
||||
atomic_write(&link.join("config.toml"), b"first").expect("first linked write");
|
||||
atomic_write(&link.join("config.toml"), b"second").expect("replace linked file");
|
||||
|
||||
assert_eq!(
|
||||
fs::read(target.join("config.toml")).expect("read linked target"),
|
||||
b"second"
|
||||
);
|
||||
fs::remove_file(target.join("config.toml")).expect("remove target file");
|
||||
fs::remove_dir(&target).expect("remove symlink target");
|
||||
let error = ensure_directory(&link).expect_err("reject broken directory symlink");
|
||||
assert!(!directory_exists(&link));
|
||||
assert!(error.to_string().contains("链接已失效"));
|
||||
fs::remove_dir(&link).expect("remove directory symlink");
|
||||
fs::remove_dir_all(root).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn ensure_directory_accepts_a_windows_junction() {
|
||||
use std::process::Command;
|
||||
|
||||
let root = temp_dir("ensure-directory-windows-junction");
|
||||
let target = root.join("junction-target");
|
||||
let link = root.join("junction-codex-home");
|
||||
fs::create_dir(&target).expect("create junction target");
|
||||
let status = Command::new("cmd")
|
||||
.args(["/C", "mklink", "/J"])
|
||||
.arg(&link)
|
||||
.arg(&target)
|
||||
.status()
|
||||
.expect("run mklink");
|
||||
assert!(status.success(), "create directory junction");
|
||||
|
||||
ensure_directory(&link).expect("accept Windows junction");
|
||||
assert!(directory_exists(&link));
|
||||
atomic_write(&link.join("config.toml"), b"junction").expect("write through junction");
|
||||
|
||||
assert_eq!(
|
||||
fs::read(target.join("config.toml")).expect("read junction target"),
|
||||
b"junction"
|
||||
);
|
||||
fs::remove_dir(&link).expect("remove directory junction");
|
||||
fs::remove_dir_all(root).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn ensure_directory_rejects_a_file_link_to_a_directory_without_removing_it() {
|
||||
use std::os::windows::fs::symlink_file;
|
||||
|
||||
let root = temp_dir("ensure-directory-windows-file-link");
|
||||
let target = root.join("target");
|
||||
let link = root.join("linked-codex-home");
|
||||
fs::create_dir(&target).expect("create target directory");
|
||||
match symlink_file(&target, &link) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.raw_os_error() == Some(1314) => {
|
||||
fs::remove_dir_all(root).expect("remove test directory");
|
||||
return;
|
||||
}
|
||||
Err(error) => panic!("create file link: {error}"),
|
||||
}
|
||||
|
||||
let error = ensure_directory(&link).expect_err("reject file link as directory");
|
||||
|
||||
assert!(!directory_exists(&link));
|
||||
assert!(error.to_string().contains("文件链接"));
|
||||
assert!(fs::symlink_metadata(&link).is_ok());
|
||||
assert!(target.is_dir());
|
||||
fs::remove_file(&link).expect("remove file link");
|
||||
fs::remove_dir_all(root).expect("remove test directory");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{atomic_write_checked, ensure_directory, io_err};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) struct LiveConfigLock {
|
||||
file: fs::File,
|
||||
}
|
||||
|
||||
impl Drop for LiveConfigLock {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.file.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn acquire_live_config_lock(codex_dir: &Path) -> Result<LiveConfigLock> {
|
||||
let tmp_dir = codex_dir.join("tmp");
|
||||
ensure_directory(&tmp_dir)?;
|
||||
let path = tmp_dir.join("codex-x-live-config.lock");
|
||||
if path.is_dir() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"Codex live 配置锁被同名目录占用: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&path)
|
||||
.map_err(|error| io_err(&path, error))?;
|
||||
file.try_lock().map_err(|_| {
|
||||
CodexxError::Config(format!(
|
||||
"另一个 Codex-X 正在修改 Codex live 配置,请稍后重试: {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
file.set_len(0).map_err(|error| io_err(&path, error))?;
|
||||
writeln!(file, "pid={}", std::process::id()).map_err(|error| io_err(&path, error))?;
|
||||
file.sync_all().map_err(|error| io_err(&path, error))?;
|
||||
Ok(LiveConfigLock { file })
|
||||
}
|
||||
|
||||
pub(crate) fn read_file_snapshot(path: &Path) -> Result<Option<Vec<u8>>> {
|
||||
match fs::read(path) {
|
||||
Ok(bytes) => Ok(Some(bytes)),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(io_err(path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn text_from_snapshot(path: &Path, snapshot: Option<&[u8]>) -> Result<String> {
|
||||
String::from_utf8(snapshot.unwrap_or_default().to_vec())
|
||||
.map_err(|_| CodexxError::Config(format!("{} 不是有效的 UTF-8 文本", path.display())))
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_file_snapshot_unchanged(path: &Path, expected: Option<&[u8]>) -> Result<()> {
|
||||
if read_file_snapshot(path)?.as_deref() == expected {
|
||||
return Ok(());
|
||||
}
|
||||
Err(CodexxError::Config(format!(
|
||||
"{} 已被其他程序修改,本次写入已取消,请刷新后重试",
|
||||
path.display()
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) fn atomic_write_if_unchanged(
|
||||
path: &Path,
|
||||
expected: Option<&[u8]>,
|
||||
replacement: &[u8],
|
||||
) -> Result<()> {
|
||||
atomic_write_checked(path, replacement, || {
|
||||
ensure_file_snapshot_unchanged(path, expected)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn remove_file_if_unchanged(path: &Path, expected: Option<&[u8]>) -> Result<()> {
|
||||
ensure_file_snapshot_unchanged(path, expected)?;
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(io_err(path, error)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn restore_file_snapshot_if_unchanged(
|
||||
path: &Path,
|
||||
expected_current: Option<&[u8]>,
|
||||
snapshot: Option<&[u8]>,
|
||||
) -> Result<()> {
|
||||
match snapshot {
|
||||
Some(bytes) => atomic_write_if_unchanged(path, expected_current, bytes),
|
||||
None => remove_file_if_unchanged(path, expected_current),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct AppliedFileChange {
|
||||
path: std::path::PathBuf,
|
||||
before: Option<Vec<u8>>,
|
||||
after: Option<Vec<u8>>,
|
||||
changed: bool,
|
||||
}
|
||||
|
||||
impl AppliedFileChange {
|
||||
pub(crate) fn rollback(&self) -> Result<()> {
|
||||
if !self.changed {
|
||||
return Ok(());
|
||||
}
|
||||
restore_file_snapshot_if_unchanged(
|
||||
&self.path,
|
||||
self.after.as_deref(),
|
||||
self.before.as_deref(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn apply_file_change(
|
||||
path: &Path,
|
||||
before: Option<Vec<u8>>,
|
||||
after: Option<Vec<u8>>,
|
||||
) -> Result<AppliedFileChange> {
|
||||
let changed = before != after;
|
||||
if changed {
|
||||
match after.as_deref() {
|
||||
Some(bytes) => atomic_write_if_unchanged(path, before.as_deref(), bytes)?,
|
||||
None => remove_file_if_unchanged(path, before.as_deref())?,
|
||||
}
|
||||
}
|
||||
Ok(AppliedFileChange {
|
||||
path: path.to_path_buf(),
|
||||
before,
|
||||
after,
|
||||
changed,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn rollback_file_changes(changes: &[AppliedFileChange]) -> Result<()> {
|
||||
for change in changes.iter().filter(|change| change.changed) {
|
||||
ensure_file_snapshot_unchanged(&change.path, change.after.as_deref())?;
|
||||
}
|
||||
|
||||
let mut failures = Vec::new();
|
||||
for change in changes.iter().rev() {
|
||||
if let Err(error) = change.rollback() {
|
||||
failures.push(error.to_string());
|
||||
}
|
||||
}
|
||||
if failures.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(CodexxError::Config(failures.join(";")))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn fail_with_file_rollback<T>(
|
||||
error: CodexxError,
|
||||
changes: &[AppliedFileChange],
|
||||
) -> Result<T> {
|
||||
match rollback_file_changes(changes) {
|
||||
Ok(()) => Err(error),
|
||||
Err(rollback_error) => Err(CodexxError::Config(format!(
|
||||
"{error};文件回滚失败:{rollback_error}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
fn temp_dir(name: &str) -> std::path::PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"codex-x-live-config-{name}-{}-{}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed),
|
||||
));
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
fs::create_dir_all(&path).expect("create test directory");
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_rejects_a_second_writer_and_releases_on_drop() {
|
||||
let dir = temp_dir("lock");
|
||||
let first = acquire_live_config_lock(&dir).expect("acquire first lock");
|
||||
let error = acquire_live_config_lock(&dir)
|
||||
.err()
|
||||
.expect("second lock must fail");
|
||||
assert!(error.to_string().contains("另一个 Codex-X"));
|
||||
drop(first);
|
||||
acquire_live_config_lock(&dir).expect("lock is released on drop");
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_checked_write_preserves_the_external_value() {
|
||||
let dir = temp_dir("stale");
|
||||
let path = dir.join("config.toml");
|
||||
fs::write(&path, b"old").expect("seed file");
|
||||
let old = read_file_snapshot(&path).expect("capture file");
|
||||
fs::write(&path, b"external").expect("simulate external writer");
|
||||
|
||||
atomic_write_if_unchanged(&path, old.as_deref(), b"codex-x")
|
||||
.expect_err("stale write must fail");
|
||||
|
||||
assert_eq!(fs::read(&path).expect("read file"), b"external");
|
||||
fs::remove_dir_all(dir).expect("remove test directory");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
#[cfg(test)]
|
||||
use chrono::Local;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn home_dir() -> Result<PathBuf> {
|
||||
dirs::home_dir().ok_or(CodexxError::NoHomeDir)
|
||||
}
|
||||
|
||||
pub(crate) fn app_home() -> Result<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
use std::sync::OnceLock;
|
||||
static TEST_APP_HOME: OnceLock<PathBuf> = OnceLock::new();
|
||||
Ok(TEST_APP_HOME
|
||||
.get_or_init(|| {
|
||||
std::env::temp_dir().join(format!(
|
||||
"codex-x-test-home-{}-{}",
|
||||
std::process::id(),
|
||||
Local::now().timestamp_nanos_opt().unwrap_or_default()
|
||||
))
|
||||
})
|
||||
.clone())
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
if let Ok(value) = std::env::var("CODEXX_HOME") {
|
||||
let trimmed = value.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(PathBuf::from(trimmed));
|
||||
}
|
||||
}
|
||||
Ok(home_dir()?.join(".codexx"))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalized_path_scope(path: &Path) -> String {
|
||||
let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let normalized = resolved.to_string_lossy().replace('\\', "/");
|
||||
if cfg!(target_os = "windows") {
|
||||
normalized.to_ascii_lowercase()
|
||||
} else {
|
||||
normalized
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use crate::constants::{
|
||||
AGENTS_FILENAME, AGENTS_MANAGED_BEGIN, AGENTS_MANAGED_END, AGENTS_TEMPLATE_PREFIX,
|
||||
};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::read_to_string_if_exists;
|
||||
#[cfg(test)]
|
||||
use crate::live_config::{apply_file_change, read_file_snapshot, text_from_snapshot};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(crate) fn agents_path(codex_dir: &Path) -> PathBuf {
|
||||
codex_dir.join(AGENTS_FILENAME)
|
||||
}
|
||||
|
||||
pub(crate) fn managed_agents_bounds(content: &str) -> Result<Option<(usize, usize)>> {
|
||||
let begins = content
|
||||
.match_indices(AGENTS_MANAGED_BEGIN)
|
||||
.map(|(index, _)| index)
|
||||
.collect::<Vec<_>>();
|
||||
let ends = content
|
||||
.match_indices(AGENTS_MANAGED_END)
|
||||
.map(|(index, _)| index)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if begins.is_empty() && ends.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if begins.len() != 1 || ends.len() != 1 || begins[0] >= ends[0] {
|
||||
return Err(CodexxError::Config(
|
||||
"AGENTS.md 中的 Codex-X 受管区块标记不完整或重复,请先修复 BEGIN/END 标记".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Some((begins[0], ends[0] + AGENTS_MANAGED_END.len())))
|
||||
}
|
||||
|
||||
pub(crate) fn remove_managed_agents_block_from_content(content: &str) -> Result<(String, bool)> {
|
||||
let Some((start, end)) = managed_agents_bounds(content)? else {
|
||||
return Ok((content.to_string(), false));
|
||||
};
|
||||
let before = content[..start].trim_end();
|
||||
let after = content[end..].trim_start();
|
||||
let merged = match (before.is_empty(), after.is_empty()) {
|
||||
(true, true) => String::new(),
|
||||
(false, true) => format!("{}\n", before),
|
||||
(true, false) => format!("{}\n", after.trim_end()),
|
||||
(false, false) => format!("{}\n\n{}\n", before, after.trim_end()),
|
||||
};
|
||||
Ok((merged, true))
|
||||
}
|
||||
|
||||
pub(crate) fn install_managed_agents_block_in_content(
|
||||
existing: &str,
|
||||
template_key: &str,
|
||||
content: &str,
|
||||
) -> Result<String> {
|
||||
let (base, _) = remove_managed_agents_block_from_content(existing)?;
|
||||
let managed = format!(
|
||||
"{AGENTS_MANAGED_BEGIN}\n{AGENTS_TEMPLATE_PREFIX} {template_key} -->\n{}\n{AGENTS_MANAGED_END}",
|
||||
content.trim()
|
||||
);
|
||||
Ok(if base.trim().is_empty() {
|
||||
format!("{managed}\n")
|
||||
} else {
|
||||
format!("{}\n\n{managed}\n", base.trim_end())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn managed_agents_template_key_from_content(content: &str) -> Option<String> {
|
||||
let (start, end) = managed_agents_bounds(content).ok().flatten()?;
|
||||
content[start..end].lines().find_map(|line| {
|
||||
line.trim()
|
||||
.strip_prefix(AGENTS_TEMPLATE_PREFIX)
|
||||
.and_then(|value| value.strip_suffix("-->"))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn managed_agents_template_key(codex_dir: &Path) -> Result<Option<String>> {
|
||||
let path = agents_path(codex_dir);
|
||||
let content = read_to_string_if_exists(&path)?;
|
||||
Ok(managed_agents_template_key_from_content(&content))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn install_managed_agents_block(
|
||||
codex_dir: &Path,
|
||||
template_key: &str,
|
||||
content: &str,
|
||||
) -> Result<()> {
|
||||
let path = agents_path(codex_dir);
|
||||
let before = read_file_snapshot(&path)?;
|
||||
let existing = text_from_snapshot(&path, before.as_deref())?;
|
||||
let next = install_managed_agents_block_in_content(&existing, template_key, content)?;
|
||||
apply_file_change(&path, before, Some(next.into_bytes()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn uninstall_managed_agents_block(codex_dir: &Path) -> Result<bool> {
|
||||
let path = agents_path(codex_dir);
|
||||
let before = read_file_snapshot(&path)?;
|
||||
if before.is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
let existing = text_from_snapshot(&path, before.as_deref())?;
|
||||
let (next, removed) = remove_managed_agents_block_from_content(&existing)?;
|
||||
if !removed {
|
||||
return Ok(false);
|
||||
}
|
||||
let after = (!next.trim().is_empty()).then(|| next.into_bytes());
|
||||
apply_file_change(&path, before, after)?;
|
||||
Ok(true)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
mod catalog;
|
||||
mod managed_agents;
|
||||
mod store;
|
||||
mod types;
|
||||
|
||||
pub(crate) use catalog::{
|
||||
builtin_prompt_content, builtin_prompt_detail_inner, builtin_prompt_status_inner,
|
||||
bundled_prompt_meta, bundled_prompt_metas, refresh_builtin_prompts_with_active,
|
||||
};
|
||||
pub(crate) use managed_agents::{
|
||||
agents_path, install_managed_agents_block_in_content, managed_agents_bounds,
|
||||
managed_agents_template_key, remove_managed_agents_block_from_content,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use managed_agents::{install_managed_agents_block, uninstall_managed_agents_block};
|
||||
pub(crate) use store::{
|
||||
delete_prompt_inner, get_saved_prompt_inner, list_saved_prompts_inner,
|
||||
normalize_prompt_filename, save_builtin_prompt_override_inner, save_prompt_inner,
|
||||
};
|
||||
pub(crate) use types::{BuiltinPromptDetail, BuiltinPromptStatus, SavedPrompt};
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use catalog::{
|
||||
cached_prompt_fallback_statuses, delete_cached_prompt_ids, github_prompt_catalog_from_entries,
|
||||
jsdelivr_prompt_catalog_from_entries, prompt_content_source_urls, stable_remote_prompt_id,
|
||||
stale_cached_prompt_ids,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use managed_agents::managed_agents_template_key_from_content;
|
||||
#[cfg(test)]
|
||||
pub(crate) use types::{CachedBuiltinPrompt, GithubContentEntry};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::file_io::{parse_toml_document, read_to_string_if_exists};
|
||||
use crate::paths::home_dir;
|
||||
use crate::{config_path, sanitize_id, string_value};
|
||||
use catalog::cached_builtin_prompts;
|
||||
use std::path::{Path, PathBuf};
|
||||
use store::find_saved_prompt_by_current_file;
|
||||
|
||||
fn builtin_prompt_id_for_filename(filename: &str) -> Result<Option<String>> {
|
||||
if let Some(meta) = bundled_prompt_metas()
|
||||
.into_iter()
|
||||
.find(|item| item.filename.eq_ignore_ascii_case(filename))
|
||||
{
|
||||
return Ok(Some(meta.id.to_string()));
|
||||
}
|
||||
Ok(cached_builtin_prompts()?
|
||||
.into_iter()
|
||||
.find(|item| item.filename.eq_ignore_ascii_case(filename))
|
||||
.map(|item| item.id))
|
||||
}
|
||||
|
||||
fn saved_prompt_id_for_filename(filename: &str) -> Result<Option<String>> {
|
||||
Ok(list_saved_prompts_inner()?
|
||||
.into_iter()
|
||||
.find(|item| item.filename.eq_ignore_ascii_case(filename))
|
||||
.map(|item| item.id))
|
||||
}
|
||||
|
||||
pub(crate) fn prompt_template_key_for_instruction(value: &str) -> Result<Option<String>> {
|
||||
let normalized = value.replace('\\', "/");
|
||||
let filename = normalized.rsplit('/').next().unwrap_or(&normalized);
|
||||
if let Some(id) = builtin_prompt_id_for_filename(filename)? {
|
||||
return Ok(Some(format!("builtin:{id}")));
|
||||
}
|
||||
Ok(saved_prompt_id_for_filename(filename)?.map(|id| format!("saved:{id}")))
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_instruction_path(codex_dir: &Path, value: &str) -> PathBuf {
|
||||
let trimmed = value.trim();
|
||||
let expanded = if trimmed == "~" {
|
||||
home_dir().unwrap_or_else(|_| codex_dir.to_path_buf())
|
||||
} else if let Some(rest) = trimmed.strip_prefix("~/") {
|
||||
home_dir()
|
||||
.map(|home| home.join(rest))
|
||||
.unwrap_or_else(|_| PathBuf::from(trimmed))
|
||||
} else {
|
||||
PathBuf::from(trimmed)
|
||||
};
|
||||
if expanded.is_absolute() {
|
||||
expanded
|
||||
} else {
|
||||
codex_dir.join(expanded)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remember_current_instruction_prompt(codex_dir: &Path) -> Result<Option<SavedPrompt>> {
|
||||
let cfg = config_path(codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
if text.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let doc = parse_toml_document(&cfg, &text)?;
|
||||
let Some(current) = string_value(&doc, "model_instructions_file") else {
|
||||
return Ok(None);
|
||||
};
|
||||
if prompt_template_key_for_instruction(¤t)?.is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = resolve_instruction_path(codex_dir, ¤t);
|
||||
if !path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let content = read_to_string_if_exists(&path)?;
|
||||
if content.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|v| v.to_str())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| "external-prompt.md".to_string());
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|v| v.to_str())
|
||||
.unwrap_or("external-prompt");
|
||||
let normalized_filename = normalize_prompt_filename(&file_name, "external-prompt");
|
||||
let existing = find_saved_prompt_by_current_file(&file_name, &content)?;
|
||||
let (id, title, filename) = existing
|
||||
.map(|prompt| (prompt.id, prompt.title, prompt.filename))
|
||||
.unwrap_or_else(|| {
|
||||
(
|
||||
format!("external-{}", sanitize_id(stem)),
|
||||
format!("外部提示词 · {stem}"),
|
||||
normalized_filename,
|
||||
)
|
||||
});
|
||||
save_prompt_inner(SavedPrompt {
|
||||
id,
|
||||
title,
|
||||
filename,
|
||||
content,
|
||||
})
|
||||
.map(Some)
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
use super::types::SavedPrompt;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::{now_rfc3339, open_db};
|
||||
use rusqlite::params;
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub(crate) fn normalize_prompt_filename(input: &str, fallback: &str) -> String {
|
||||
let raw = input.trim().trim_end_matches(".md");
|
||||
let base = if raw.is_empty() { fallback } else { raw };
|
||||
let mut out = String::new();
|
||||
let mut last_dash = false;
|
||||
for ch in base.to_ascii_lowercase().chars() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
|
||||
out.push(ch);
|
||||
last_dash = false;
|
||||
} else if !last_dash {
|
||||
out.push('-');
|
||||
last_dash = true;
|
||||
}
|
||||
}
|
||||
let out = out.trim_matches('-');
|
||||
format!("{}.md", if out.is_empty() { "custom-prompt" } else { out })
|
||||
}
|
||||
|
||||
fn canonical_prompt_content(input: &str) -> String {
|
||||
input
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', "\n")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn list_saved_prompts_inner() -> Result<Vec<SavedPrompt>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, title, filename, content FROM prompts ORDER BY updated_at DESC, created_at DESC")
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(SavedPrompt {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
filename: row.get(2)?,
|
||||
content: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let mut prompts = Vec::new();
|
||||
for row in rows {
|
||||
let prompt = row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let filename_key = prompt.filename.to_ascii_lowercase();
|
||||
let content_key = canonical_prompt_content(&prompt.content);
|
||||
let duplicate_index = prompts.iter().position(|existing: &SavedPrompt| {
|
||||
existing.filename.to_ascii_lowercase() == filename_key
|
||||
|| (canonical_prompt_content(&existing.content) == content_key
|
||||
&& (existing.id.starts_with("external-") || prompt.id.starts_with("external-")))
|
||||
});
|
||||
if let Some(index) = duplicate_index {
|
||||
let existing_is_external = prompts[index].id.starts_with("external-");
|
||||
let prompt_is_external = prompt.id.starts_with("external-");
|
||||
if existing_is_external && !prompt_is_external {
|
||||
prompts[index] = prompt;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
prompts.push(prompt);
|
||||
}
|
||||
Ok(prompts)
|
||||
}
|
||||
|
||||
pub(crate) fn save_prompt_inner(prompt: SavedPrompt) -> Result<SavedPrompt> {
|
||||
let conn = open_db()?;
|
||||
let now = now_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO prompts (id, title, filename, content, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?5)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
filename = excluded.filename,
|
||||
content = excluded.content,
|
||||
updated_at = excluded.updated_at",
|
||||
params![
|
||||
prompt.id,
|
||||
prompt.title,
|
||||
prompt.filename,
|
||||
prompt.content,
|
||||
now
|
||||
],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
list_saved_prompts_inner()?
|
||||
.into_iter()
|
||||
.find(|p| p.id == prompt.id)
|
||||
.ok_or_else(|| CodexxError::Database("prompt saved but not found".to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn get_saved_prompt_inner(id: &str) -> Result<SavedPrompt> {
|
||||
list_saved_prompts_inner()?
|
||||
.into_iter()
|
||||
.find(|p| p.id == id)
|
||||
.ok_or_else(|| CodexxError::Config(format!("提示词不存在: {id}")))
|
||||
}
|
||||
|
||||
pub(crate) fn delete_prompt_inner(id: &str) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
conn.execute("DELETE FROM prompts WHERE id = ?1", params![id])
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn builtin_prompt_override_from_connection(
|
||||
conn: &rusqlite::Connection,
|
||||
template_id: &str,
|
||||
) -> Result<Option<String>> {
|
||||
match conn.query_row(
|
||||
"SELECT content FROM builtin_prompt_overrides WHERE template_id = ?1",
|
||||
[template_id],
|
||||
|row| row.get(0),
|
||||
) {
|
||||
Ok(content) => Ok(Some(content)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(CodexxError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn builtin_prompt_override_inner(template_id: &str) -> Result<Option<String>> {
|
||||
let conn = open_db()?;
|
||||
builtin_prompt_override_from_connection(&conn, template_id.trim())
|
||||
}
|
||||
|
||||
pub(crate) fn builtin_prompt_override_ids_inner() -> Result<HashSet<String>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT template_id FROM builtin_prompt_overrides")
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get(0))
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
rows.map(|row| row.map_err(|e| CodexxError::Database(e.to_string())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn save_builtin_prompt_override_on_connection(
|
||||
conn: &rusqlite::Connection,
|
||||
template_id: &str,
|
||||
content: &str,
|
||||
) -> Result<()> {
|
||||
let id = template_id.trim();
|
||||
if id.is_empty() {
|
||||
return Err(CodexxError::Config("提示词模板标识不能为空".to_string()));
|
||||
}
|
||||
if content.trim().is_empty() {
|
||||
return Err(CodexxError::Config("提示词内容不能为空".to_string()));
|
||||
}
|
||||
let now = now_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO builtin_prompt_overrides (template_id, content, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?3)
|
||||
ON CONFLICT(template_id) DO UPDATE SET
|
||||
content = excluded.content,
|
||||
updated_at = excluded.updated_at",
|
||||
params![id, content, now],
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn save_builtin_prompt_override_inner(template_id: &str, content: &str) -> Result<()> {
|
||||
let conn = open_db()?;
|
||||
save_builtin_prompt_override_on_connection(&conn, template_id, content)
|
||||
}
|
||||
|
||||
fn find_saved_prompt_by_content(content: &str) -> Result<Option<SavedPrompt>> {
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, title, filename, content FROM prompts ORDER BY CASE WHEN id LIKE 'external-%' THEN 1 ELSE 0 END, updated_at DESC, created_at DESC")
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(SavedPrompt {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
filename: row.get(2)?,
|
||||
content: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let target = canonical_prompt_content(content);
|
||||
for row in rows {
|
||||
let prompt = row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
if canonical_prompt_content(&prompt.content) == target {
|
||||
return Ok(Some(prompt));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(super) fn find_saved_prompt_by_current_file(
|
||||
filename: &str,
|
||||
content: &str,
|
||||
) -> Result<Option<SavedPrompt>> {
|
||||
if let Some(prompt) = find_saved_prompt_by_content(content)? {
|
||||
return Ok(Some(prompt));
|
||||
}
|
||||
let normalized_filename = normalize_prompt_filename(filename, "external-prompt");
|
||||
let conn = open_db()?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, title, filename, content FROM prompts
|
||||
WHERE lower(filename) = lower(?1)
|
||||
ORDER BY CASE WHEN id LIKE 'external-%' THEN 1 ELSE 0 END, updated_at DESC, created_at DESC
|
||||
LIMIT 1",
|
||||
)
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
match stmt.query_row([normalized_filename], |row| {
|
||||
Ok(SavedPrompt {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
filename: row.get(2)?,
|
||||
content: row.get(3)?,
|
||||
})
|
||||
}) {
|
||||
Ok(mut prompt) => {
|
||||
if canonical_prompt_content(&prompt.content) != canonical_prompt_content(content) {
|
||||
prompt.content = content.to_string();
|
||||
}
|
||||
Ok(Some(prompt))
|
||||
}
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(CodexxError::Database(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn override_connection() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("open override database");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE builtin_prompt_overrides (
|
||||
template_id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("create override table");
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_prompt_override_can_be_edited_repeatedly() {
|
||||
let conn = override_connection();
|
||||
save_builtin_prompt_override_on_connection(&conn, "template", "first")
|
||||
.expect("save initial override");
|
||||
save_builtin_prompt_override_on_connection(&conn, "template", "second")
|
||||
.expect("update override");
|
||||
|
||||
assert_eq!(
|
||||
builtin_prompt_override_from_connection(&conn, "template")
|
||||
.expect("read override")
|
||||
.as_deref(),
|
||||
Some("second")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_prompt_override_rejects_empty_content() {
|
||||
let conn = override_connection();
|
||||
let error = save_builtin_prompt_override_on_connection(&conn, "template", " \n")
|
||||
.expect_err("reject empty override");
|
||||
assert!(error.to_string().contains("内容不能为空"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_prompt_override_survives_database_reopen() {
|
||||
let database_path = std::env::temp_dir().join(format!(
|
||||
"codexx-prompt-override-{}-{}.sqlite",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system time")
|
||||
.as_nanos()
|
||||
));
|
||||
{
|
||||
let conn = Connection::open(&database_path).expect("open override database");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE builtin_prompt_overrides (
|
||||
template_id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.expect("create override table");
|
||||
save_builtin_prompt_override_on_connection(&conn, "template", "local content")
|
||||
.expect("save override");
|
||||
}
|
||||
|
||||
let reopened = Connection::open(&database_path).expect("reopen override database");
|
||||
assert_eq!(
|
||||
builtin_prompt_override_from_connection(&reopened, "template")
|
||||
.expect("read persisted override")
|
||||
.as_deref(),
|
||||
Some("local content")
|
||||
);
|
||||
drop(reopened);
|
||||
std::fs::remove_file(database_path).expect("remove override database");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SavedPrompt {
|
||||
pub(crate) id: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) filename: String,
|
||||
pub(crate) content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BuiltinPromptStatus {
|
||||
pub(crate) id: String,
|
||||
pub(crate) filename: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) subtitle: String,
|
||||
pub(crate) badge: String,
|
||||
pub(crate) source_url: String,
|
||||
pub(crate) cached: bool,
|
||||
pub(crate) updated: bool,
|
||||
pub(crate) content_source: String,
|
||||
pub(crate) sync_issue: Option<String>,
|
||||
pub(crate) checked_at: Option<String>,
|
||||
pub(crate) message: String,
|
||||
pub(crate) customized: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BuiltinPromptDetail {
|
||||
pub(crate) id: String,
|
||||
pub(crate) filename: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) content: String,
|
||||
pub(crate) customized: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct BundledPromptMeta {
|
||||
pub(super) id: &'static str,
|
||||
pub(super) filename: &'static str,
|
||||
pub(super) title: &'static str,
|
||||
pub(super) subtitle: &'static str,
|
||||
pub(super) badge: &'static str,
|
||||
pub(super) content: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CachedBuiltinPrompt {
|
||||
pub(crate) id: String,
|
||||
pub(crate) filename: String,
|
||||
pub(crate) source_url: String,
|
||||
pub(crate) content: String,
|
||||
pub(crate) checked_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct GithubContentEntry {
|
||||
pub(crate) name: String,
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) kind: String,
|
||||
pub(crate) download_url: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,860 @@
|
||||
//! Read-only balances for saved providers and the current native provider.
|
||||
//! Request destinations are fixed official URLs, never supplied over IPC.
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::providers::{self, SavedProvider};
|
||||
use chrono::{SecondsFormat, Utc};
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{HeaderValue, ACCEPT, AUTHORIZATION};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Condvar, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use toml_edit::{DocumentMut, Item};
|
||||
|
||||
const SUCCESS_TTL: Duration = Duration::from_secs(300);
|
||||
const FAILURE_TTL: Duration = Duration::from_secs(60);
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const MAX_RESPONSE_BYTES: u64 = 64 * 1024;
|
||||
const MAX_CACHE_ENTRIES: usize = 256;
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BalanceAmount {
|
||||
pub(crate) currency: String,
|
||||
/// Exact base-10 text. Never route financial values through f64.
|
||||
pub(crate) amount: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ProviderBalanceResult {
|
||||
pub(crate) source: String,
|
||||
pub(crate) provider_id: String,
|
||||
pub(crate) vendor: Option<&'static str>,
|
||||
pub(crate) supported: bool,
|
||||
pub(crate) balance_kind: Option<&'static str>,
|
||||
pub(crate) balances: Vec<BalanceAmount>,
|
||||
pub(crate) checked_at: Option<String>,
|
||||
pub(crate) error_code: Option<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Vendor {
|
||||
DeepSeek,
|
||||
OpenRouter,
|
||||
SiliconFlow,
|
||||
Moonshot,
|
||||
}
|
||||
impl Vendor {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::DeepSeek => "deepseek",
|
||||
Self::OpenRouter => "openrouter",
|
||||
Self::SiliconFlow => "siliconflow",
|
||||
Self::Moonshot => "moonshot",
|
||||
}
|
||||
}
|
||||
fn endpoint(self) -> &'static str {
|
||||
match self {
|
||||
Self::DeepSeek => "https://api.deepseek.com/user/balance",
|
||||
Self::OpenRouter => "https://openrouter.ai/api/v1/key",
|
||||
Self::SiliconFlow => "https://api.siliconflow.cn/v1/user/info",
|
||||
Self::Moonshot => "https://api.moonshot.cn/v1/users/me/balance",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn vendor_for_provider(provider: &SavedProvider) -> Option<Vendor> {
|
||||
// This flag controls Codex authentication, and legacy third-party routes
|
||||
// can also set it. Eligibility comes from the official API URL below;
|
||||
// official login profiles are never enumerated by this module.
|
||||
let url = reqwest::Url::parse(provider.base_url.trim()).ok()?;
|
||||
if url.scheme() != "https"
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.port().is_some_and(|port| port != 443)
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let path = url.path().trim_end_matches('/');
|
||||
let normal_path = matches!(
|
||||
path,
|
||||
"" | "/v1" | "/v1/chat/completions" | "/v1/responses" | "/v1/messages"
|
||||
);
|
||||
match url.host_str()? {
|
||||
"api.deepseek.com" if normal_path => Some(Vendor::DeepSeek),
|
||||
"api.siliconflow.cn" if normal_path => Some(Vendor::SiliconFlow),
|
||||
"api.moonshot.cn" if normal_path => Some(Vendor::Moonshot),
|
||||
"openrouter.ai"
|
||||
if matches!(
|
||||
path,
|
||||
"" | "/api/v1"
|
||||
| "/api/v1/chat/completions"
|
||||
| "/api/v1/responses"
|
||||
| "/api/v1/messages"
|
||||
) =>
|
||||
{
|
||||
Some(Vendor::OpenRouter)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn environment_value(name: &str) -> std::result::Result<String, &'static str> {
|
||||
let mut bytes = name.bytes();
|
||||
if !bytes
|
||||
.next()
|
||||
.is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
|
||||
|| !bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
|
||||
{
|
||||
return Err("invalid_credential");
|
||||
}
|
||||
std::env::var(name).map_err(|_| "missing_key")
|
||||
}
|
||||
|
||||
fn credential(
|
||||
provider: &SavedProvider,
|
||||
detected: bool,
|
||||
) -> std::result::Result<String, &'static str> {
|
||||
let doc = provider
|
||||
.toml_config
|
||||
.as_deref()
|
||||
.filter(|text| !text.trim().is_empty())
|
||||
.map(|text| {
|
||||
text.parse::<DocumentMut>()
|
||||
.map_err(|_| "invalid_configuration")
|
||||
})
|
||||
.transpose()?;
|
||||
let table = doc.as_ref().and_then(|doc| {
|
||||
let id = doc
|
||||
.get("model_provider")
|
||||
.and_then(Item::as_str)
|
||||
.unwrap_or(&provider.id);
|
||||
doc.get("model_providers")?
|
||||
.as_table_like()?
|
||||
.get(id)?
|
||||
.as_table_like()
|
||||
});
|
||||
// Only the official API's Bearer credential is relevant. Do not forward
|
||||
// arbitrary provider headers (Host, cookies, routing headers, etc.).
|
||||
let mut custom_authorization = None;
|
||||
if let Some(table) = table {
|
||||
for (field, from_env) in [("http_headers", false), ("env_http_headers", true)] {
|
||||
if let Some(item) = table.get(field) {
|
||||
let headers = item.as_table_like().ok_or("invalid_configuration")?;
|
||||
for (name, item) in headers.iter() {
|
||||
if name.eq_ignore_ascii_case("authorization") {
|
||||
if custom_authorization.is_some() {
|
||||
return Err("invalid_credential");
|
||||
}
|
||||
let value = item.as_str().ok_or("invalid_credential")?;
|
||||
custom_authorization = Some(if from_env {
|
||||
environment_value(value)?
|
||||
} else {
|
||||
value.to_string()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The detected-provider helper may carry a legacy auth.json fallback key.
|
||||
// An explicit native env_key must take precedence over that fallback, so an
|
||||
// unrelated credential cannot win over the route's own environment key.
|
||||
let native_key = if detected && custom_authorization.is_none() {
|
||||
if let Some(key) = table
|
||||
.and_then(|table| table.get("experimental_bearer_token"))
|
||||
.and_then(Item::as_str)
|
||||
.filter(|key| !key.trim().is_empty())
|
||||
{
|
||||
Some(key.to_string())
|
||||
} else if let Some(name) = table
|
||||
.and_then(|table| table.get("env_key"))
|
||||
.and_then(Item::as_str)
|
||||
{
|
||||
Some(environment_value(name)?)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let key = if let Some(authorization) = custom_authorization {
|
||||
let (scheme, key) = authorization.split_once(' ').ok_or("invalid_credential")?;
|
||||
if !scheme.eq_ignore_ascii_case("bearer") {
|
||||
return Err("invalid_credential");
|
||||
}
|
||||
key.to_string()
|
||||
} else if let Some(key) = native_key {
|
||||
key
|
||||
} else if let Some(key) = provider
|
||||
.api_key
|
||||
.as_deref()
|
||||
.filter(|key| !key.trim().is_empty())
|
||||
{
|
||||
key.to_string()
|
||||
} else if let Some(key) = table
|
||||
.and_then(|table| table.get("experimental_bearer_token"))
|
||||
.and_then(Item::as_str)
|
||||
.filter(|key| !key.trim().is_empty())
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| {
|
||||
doc.as_ref()
|
||||
.and_then(|doc| providers::experimental_bearer_token_from_doc(doc, None))
|
||||
})
|
||||
{
|
||||
key
|
||||
} else if let Some(name) = table
|
||||
.and_then(|table| table.get("env_key"))
|
||||
.and_then(Item::as_str)
|
||||
{
|
||||
environment_value(name)?
|
||||
} else {
|
||||
return Err("missing_key");
|
||||
};
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
return Err("missing_key");
|
||||
}
|
||||
if key.len() > 16 * 1024 || key.bytes().any(|b| b <= 0x20 || b == 0x7f) {
|
||||
return Err("invalid_credential");
|
||||
}
|
||||
Ok(key.to_string())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BalanceValue {
|
||||
kind: Option<&'static str>,
|
||||
balances: Vec<BalanceAmount>,
|
||||
checked_at: String,
|
||||
error: Option<&'static str>,
|
||||
}
|
||||
impl BalanceValue {
|
||||
fn failure(error: &'static str) -> Self {
|
||||
Self {
|
||||
kind: None,
|
||||
balances: Vec::new(),
|
||||
checked_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
type CacheEntry = Mutex<Option<(Instant, BalanceValue)>>;
|
||||
fn cache() -> &'static Mutex<HashMap<String, Arc<CacheEntry>>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<String, Arc<CacheEntry>>>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
fn cached_balance(vendor: Vendor, key: &str, force: bool, requested_at: Instant) -> BalanceValue {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(vendor.endpoint().as_bytes());
|
||||
digest.update([0]);
|
||||
digest.update(key.as_bytes());
|
||||
let identity = format!("{:x}", digest.finalize());
|
||||
let slot = {
|
||||
let mut entries = cache().lock().unwrap_or_else(|p| p.into_inner());
|
||||
if !entries.contains_key(&identity) && entries.len() >= MAX_CACHE_ENTRIES {
|
||||
entries.retain(|_, entry| {
|
||||
Arc::strong_count(entry) > 1
|
||||
|| entry.try_lock().map_or(true, |value| {
|
||||
value
|
||||
.as_ref()
|
||||
.is_some_and(|(at, _)| at.elapsed() < SUCCESS_TTL)
|
||||
})
|
||||
});
|
||||
}
|
||||
if !entries.contains_key(&identity) && entries.len() >= MAX_CACHE_ENTRIES {
|
||||
return BalanceValue::failure("busy");
|
||||
}
|
||||
Arc::clone(
|
||||
entries
|
||||
.entry(identity)
|
||||
.or_insert_with(|| Arc::new(Mutex::new(None))),
|
||||
)
|
||||
};
|
||||
// A lock per credential, held for the bounded request, deduplicates copies
|
||||
// and overlapping IPC calls without blocking unrelated accounts.
|
||||
let mut entry = slot.lock().unwrap_or_else(|p| p.into_inner());
|
||||
if let Some((at, value)) = entry.as_ref() {
|
||||
let ttl = if value.error.is_some() {
|
||||
FAILURE_TTL
|
||||
} else {
|
||||
SUCCESS_TTL
|
||||
};
|
||||
// A manual refresh bypasses an older TTL entry, but not a request
|
||||
// that finished after this IPC call began. Queued copies of the same
|
||||
// credential therefore share one refresh rather than serially forcing
|
||||
// another HTTP request after acquiring this lock.
|
||||
if *at >= requested_at || (!force && at.elapsed() < ttl) {
|
||||
return value.clone();
|
||||
}
|
||||
}
|
||||
let value = request_balance(vendor, key).unwrap_or_else(BalanceValue::failure);
|
||||
*entry = Some((Instant::now(), value.clone()));
|
||||
value
|
||||
}
|
||||
|
||||
fn json_request(
|
||||
client: &Client,
|
||||
url: &'static str,
|
||||
key: &str,
|
||||
) -> std::result::Result<Value, &'static str> {
|
||||
let mut authorization =
|
||||
HeaderValue::from_str(&format!("Bearer {key}")).map_err(|_| "invalid_credential")?;
|
||||
authorization.set_sensitive(true);
|
||||
let response = client
|
||||
.get(url)
|
||||
.header(ACCEPT, "application/json")
|
||||
.header(AUTHORIZATION, authorization)
|
||||
.send()
|
||||
.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
"timeout"
|
||||
} else {
|
||||
"network_error"
|
||||
}
|
||||
})?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(match status.as_u16() {
|
||||
300..=399 => "redirect_blocked",
|
||||
401 => "authentication_failed",
|
||||
403 => "permission_denied",
|
||||
429 => "rate_limited",
|
||||
_ => "http_error",
|
||||
});
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|size| size > MAX_RESPONSE_BYTES)
|
||||
{
|
||||
return Err("response_too_large");
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
response
|
||||
.take(MAX_RESPONSE_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| "network_error")?;
|
||||
if bytes.len() as u64 > MAX_RESPONSE_BYTES {
|
||||
return Err("response_too_large");
|
||||
}
|
||||
// serde_json's arbitrary_precision feature preserves the original numeric
|
||||
// lexeme, including OpenRouter and Moonshot's JSON number balances.
|
||||
serde_json::from_slice(&bytes).map_err(|_| "invalid_response")
|
||||
}
|
||||
|
||||
fn amount(currency: &str, value: &Value) -> std::result::Result<BalanceAmount, &'static str> {
|
||||
Ok(BalanceAmount {
|
||||
currency: currency.to_string(),
|
||||
amount: Decimal::from_value(value)?.text(),
|
||||
})
|
||||
}
|
||||
|
||||
fn request_balance(vendor: Vendor, key: &str) -> std::result::Result<BalanceValue, &'static str> {
|
||||
crate::remote::ensure_crypto_provider();
|
||||
// Same fresh-client system/ENV proxy behavior and five-second timeout as
|
||||
// remote_client, with credentials additionally confined by no redirects.
|
||||
let client = Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.user_agent("Codex-X")
|
||||
.https_only(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|_| "network_error")?;
|
||||
let payload = json_request(&client, vendor.endpoint(), key)?;
|
||||
let mut kind = "balance";
|
||||
let balances = match vendor {
|
||||
// https://api-docs.deepseek.com/api/get-user-balance
|
||||
Vendor::DeepSeek => {
|
||||
let infos = payload
|
||||
.get("balance_infos")
|
||||
.and_then(Value::as_array)
|
||||
.filter(|items| !items.is_empty() && items.len() <= 4)
|
||||
.ok_or("invalid_response")?;
|
||||
let mut balances = Vec::new();
|
||||
for info in infos {
|
||||
let currency = info
|
||||
.get("currency")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|currency| matches!(*currency, "CNY" | "USD"))
|
||||
.ok_or("invalid_response")?;
|
||||
if balances
|
||||
.iter()
|
||||
.any(|item: &BalanceAmount| item.currency == currency)
|
||||
{
|
||||
return Err("invalid_response");
|
||||
}
|
||||
balances.push(amount(
|
||||
currency,
|
||||
info.get("total_balance").ok_or("invalid_response")?,
|
||||
)?);
|
||||
}
|
||||
balances
|
||||
}
|
||||
// https://openrouter.ai/docs/api/api-reference/api-keys/get-current-key
|
||||
// https://openrouter.ai/docs/api/api-reference/credits/get-credits
|
||||
Vendor::OpenRouter => {
|
||||
let data = payload.get("data").ok_or("invalid_response")?;
|
||||
let remaining = data.get("limit_remaining").ok_or("invalid_response")?;
|
||||
if !remaining.is_null() {
|
||||
kind = "credit_limit";
|
||||
vec![amount("USD", remaining)?]
|
||||
} else {
|
||||
// Management permission may be unavailable to ordinary model
|
||||
// keys. Surface its short error code, never request a new key.
|
||||
let credits = json_request(&client, "https://openrouter.ai/api/v1/credits", key)?;
|
||||
let data = credits.get("data").ok_or("invalid_response")?;
|
||||
let total =
|
||||
Decimal::from_value(data.get("total_credits").ok_or("invalid_response")?)?;
|
||||
let usage =
|
||||
Decimal::from_value(data.get("total_usage").ok_or("invalid_response")?)?;
|
||||
vec![BalanceAmount {
|
||||
currency: "USD".into(),
|
||||
amount: total.subtract(usage)?.text(),
|
||||
}]
|
||||
}
|
||||
}
|
||||
// Official CN OpenAPI: github.com/siliconflow/siliconcloud/openapi.yaml
|
||||
// CN monetary units: docs.siliconflow.cn/docs/userguide/faqs/rate-limit-and-upgradation
|
||||
Vendor::SiliconFlow => {
|
||||
if payload.get("status").and_then(Value::as_bool) != Some(true)
|
||||
|| payload.get("code").and_then(Value::as_u64) != Some(20000)
|
||||
{
|
||||
return Err("api_error");
|
||||
}
|
||||
vec![amount(
|
||||
"CNY",
|
||||
payload
|
||||
.get("data")
|
||||
.and_then(|data| data.get("totalBalance"))
|
||||
.ok_or("invalid_response")?,
|
||||
)?]
|
||||
}
|
||||
// https://platform.moonshot.cn/docs/api/balance (CN keys only)
|
||||
Vendor::Moonshot => {
|
||||
if payload.get("status").and_then(Value::as_bool) != Some(true)
|
||||
|| payload.get("code").and_then(Value::as_u64) != Some(0)
|
||||
{
|
||||
return Err("api_error");
|
||||
}
|
||||
vec![amount(
|
||||
"CNY",
|
||||
payload
|
||||
.get("data")
|
||||
.and_then(|data| data.get("available_balance"))
|
||||
.ok_or("invalid_response")?,
|
||||
)?]
|
||||
}
|
||||
};
|
||||
Ok(BalanceValue {
|
||||
kind: Some(kind),
|
||||
balances,
|
||||
checked_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
|
||||
struct Decimal {
|
||||
units: i128,
|
||||
scale: u32,
|
||||
}
|
||||
impl Decimal {
|
||||
fn from_value(value: &Value) -> std::result::Result<Self, &'static str> {
|
||||
let text = match value {
|
||||
Value::String(text) => text.clone(),
|
||||
Value::Number(number) => number.to_string(),
|
||||
_ => return Err("invalid_response"),
|
||||
};
|
||||
Self::parse(&text)
|
||||
}
|
||||
fn parse(text: &str) -> std::result::Result<Self, &'static str> {
|
||||
if text.is_empty() || text.len() > 128 || text.trim() != text {
|
||||
return Err("invalid_response");
|
||||
}
|
||||
let (negative, unsigned) = text
|
||||
.strip_prefix('-')
|
||||
.map_or((false, text), |text| (true, text));
|
||||
let (mantissa, exponent) = match unsigned.find(['e', 'E']) {
|
||||
Some(index) => (
|
||||
&unsigned[..index],
|
||||
unsigned[index + 1..]
|
||||
.parse::<i32>()
|
||||
.map_err(|_| "invalid_response")?,
|
||||
),
|
||||
None => (unsigned, 0),
|
||||
};
|
||||
if exponent.unsigned_abs() > 36 {
|
||||
return Err("invalid_response");
|
||||
}
|
||||
let (integer, fraction) = mantissa
|
||||
.split_once('.')
|
||||
.map_or((mantissa, ""), |parts| parts);
|
||||
if integer.is_empty()
|
||||
|| !integer.bytes().all(|b| b.is_ascii_digit())
|
||||
|| !fraction.bytes().all(|b| b.is_ascii_digit())
|
||||
|| (mantissa.contains('.') && fraction.is_empty())
|
||||
{
|
||||
return Err("invalid_response");
|
||||
}
|
||||
let digits = format!("{integer}{fraction}");
|
||||
let mut units = digits.parse::<i128>().map_err(|_| "invalid_response")?;
|
||||
if negative {
|
||||
units = -units;
|
||||
}
|
||||
let scale = fraction.len() as i32 - exponent;
|
||||
let scale = if scale < 0 {
|
||||
units = units
|
||||
.checked_mul(
|
||||
10_i128
|
||||
.checked_pow((-scale) as u32)
|
||||
.ok_or("invalid_response")?,
|
||||
)
|
||||
.ok_or("invalid_response")?;
|
||||
0
|
||||
} else if scale > 36 {
|
||||
return Err("invalid_response");
|
||||
} else {
|
||||
scale as u32
|
||||
};
|
||||
Ok(Self { units, scale }.normalized())
|
||||
}
|
||||
fn normalized(mut self) -> Self {
|
||||
while self.scale > 0 && self.units % 10 == 0 {
|
||||
self.units /= 10;
|
||||
self.scale -= 1;
|
||||
}
|
||||
self
|
||||
}
|
||||
fn subtract(self, other: Self) -> std::result::Result<Self, &'static str> {
|
||||
let scale = self.scale.max(other.scale);
|
||||
let align = |number: Self| {
|
||||
number
|
||||
.units
|
||||
.checked_mul(10_i128.checked_pow(scale - number.scale)?)
|
||||
};
|
||||
let units = align(self)
|
||||
.and_then(|left| align(other).and_then(|right| left.checked_sub(right)))
|
||||
.ok_or("invalid_response")?;
|
||||
Ok(Self { units, scale }.normalized())
|
||||
}
|
||||
fn text(&self) -> String {
|
||||
let digits = self.units.unsigned_abs().to_string();
|
||||
let sign = if self.units < 0 { "-" } else { "" };
|
||||
if self.scale == 0 {
|
||||
return format!("{sign}{digits}");
|
||||
}
|
||||
let scale = self.scale as usize;
|
||||
if digits.len() <= scale {
|
||||
format!("{sign}0.{}{digits}", "0".repeat(scale - digits.len()))
|
||||
} else {
|
||||
let at = digits.len() - scale;
|
||||
format!("{sign}{}.{}", &digits[..at], &digits[at..])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_result(
|
||||
source: &str,
|
||||
provider: &SavedProvider,
|
||||
force: bool,
|
||||
requested_at: Instant,
|
||||
) -> ProviderBalanceResult {
|
||||
let mut result = ProviderBalanceResult {
|
||||
source: source.into(),
|
||||
provider_id: if source == "detected" {
|
||||
format!("detected-{}", provider.id)
|
||||
} else {
|
||||
provider.id.clone()
|
||||
},
|
||||
vendor: None,
|
||||
supported: false,
|
||||
balance_kind: None,
|
||||
balances: Vec::new(),
|
||||
checked_at: None,
|
||||
error_code: None,
|
||||
};
|
||||
let Some(vendor) = vendor_for_provider(provider) else {
|
||||
return result;
|
||||
};
|
||||
result.vendor = Some(vendor.name());
|
||||
result.supported = true;
|
||||
let key = match credential(provider, source == "detected") {
|
||||
Ok(key) => key,
|
||||
Err(code) => {
|
||||
result.error_code = Some(code);
|
||||
return result;
|
||||
}
|
||||
};
|
||||
let value = cached_balance(vendor, &key, force, requested_at);
|
||||
result.balance_kind = value.kind;
|
||||
result.balances = value.balances;
|
||||
result.checked_at = Some(value.checked_at);
|
||||
result.error_code = value.error;
|
||||
result
|
||||
}
|
||||
|
||||
fn read_balances_at(
|
||||
home: &Path,
|
||||
continue_query: &(dyn Fn() -> bool + Sync),
|
||||
) -> Result<Vec<ProviderBalanceResult>> {
|
||||
let mut targets: Vec<_> = providers::list_saved_providers_inner()
|
||||
.map_err(|_| CodexxError::Config("读取已保存供应商失败,请稍后重试。".into()))?
|
||||
.into_iter()
|
||||
.map(|provider| ("local", provider))
|
||||
.collect();
|
||||
if let Some(provider) = providers::detected_live_custom_provider(home)
|
||||
.map_err(|_| CodexxError::Config("读取当前供应商配置失败,请检查配置格式后重试。".into()))?
|
||||
{
|
||||
targets.push(("detected", provider));
|
||||
}
|
||||
if targets.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// Bound concurrent network work. Duplicate account credentials still share
|
||||
// the same cache lock even when represented by multiple cards/sources.
|
||||
let workers = targets.len().min(4);
|
||||
let batches = std::thread::scope(|scope| {
|
||||
let handles: Vec<_> = (0..workers)
|
||||
.map(|offset| {
|
||||
let targets = &targets;
|
||||
scope.spawn(move || {
|
||||
(offset..targets.len())
|
||||
.step_by(workers)
|
||||
.take_while(|_| continue_query())
|
||||
.map(|index| {
|
||||
(
|
||||
index,
|
||||
provider_result(
|
||||
targets[index].0,
|
||||
&targets[index].1,
|
||||
false,
|
||||
Instant::now(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles
|
||||
.into_iter()
|
||||
.map(|handle| {
|
||||
handle
|
||||
.join()
|
||||
.map_err(|_| CodexxError::Config("读取供应商余额失败,请稍后重试。".into()))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()
|
||||
})?;
|
||||
let mut results: Vec<_> = batches.into_iter().flatten().collect();
|
||||
results.sort_by_key(|(index, _)| *index);
|
||||
Ok(results.into_iter().map(|(_, result)| result).collect())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MonitorTarget {
|
||||
home: PathBuf,
|
||||
scope: String,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct MonitorState {
|
||||
target: Option<MonitorTarget>,
|
||||
next_refresh: Option<Instant>,
|
||||
}
|
||||
|
||||
struct BalanceMonitor {
|
||||
state: Mutex<MonitorState>,
|
||||
changed: Condvar,
|
||||
}
|
||||
|
||||
fn refresh_delay(result: &Result<Vec<ProviderBalanceResult>>) -> Duration {
|
||||
let Ok(rows) = result else {
|
||||
return FAILURE_TTL;
|
||||
};
|
||||
let now = Utc::now();
|
||||
rows.iter()
|
||||
.filter(|row| row.supported)
|
||||
.map(|row| {
|
||||
let ttl = if row.error_code.is_some() {
|
||||
FAILURE_TTL
|
||||
} else {
|
||||
SUCCESS_TTL
|
||||
};
|
||||
let elapsed = row
|
||||
.checked_at
|
||||
.as_deref()
|
||||
.and_then(|text| chrono::DateTime::parse_from_rfc3339(text).ok())
|
||||
.map(|checked| {
|
||||
now.signed_duration_since(checked.with_timezone(&Utc))
|
||||
.to_std()
|
||||
.unwrap_or(Duration::ZERO)
|
||||
});
|
||||
elapsed.map_or(ttl, |elapsed| {
|
||||
// checkedAt is second-precision; allow one extra second so
|
||||
// the credential cache's monotonic TTL has certainly expired.
|
||||
// Reading warm/manual-refresh results must not reset a full TTL.
|
||||
ttl.saturating_sub(elapsed)
|
||||
.saturating_add(Duration::from_secs(1))
|
||||
})
|
||||
})
|
||||
.min()
|
||||
.unwrap_or(SUCCESS_TTL)
|
||||
}
|
||||
|
||||
impl BalanceMonitor {
|
||||
fn select(&self, home: PathBuf) -> u64 {
|
||||
let scope = crate::paths::normalized_path_scope(&home);
|
||||
let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
|
||||
if let Some(target) = &state.target {
|
||||
if target.scope == scope {
|
||||
return target.generation;
|
||||
}
|
||||
}
|
||||
let generation = state
|
||||
.target
|
||||
.as_ref()
|
||||
.map_or(1, |target| target.generation.wrapping_add(1));
|
||||
state.target = Some(MonitorTarget {
|
||||
home,
|
||||
scope,
|
||||
generation,
|
||||
});
|
||||
// The selecting IPC call performs the first query. Schedule only once
|
||||
// it has completed, rather than racing it or counting from its start.
|
||||
state.next_refresh = None;
|
||||
self.changed.notify_one();
|
||||
generation
|
||||
}
|
||||
|
||||
fn is_current(&self, generation: u64) -> bool {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.target
|
||||
.as_ref()
|
||||
.is_some_and(|target| target.generation == generation)
|
||||
}
|
||||
|
||||
fn completed(&self, generation: u64, delay: Duration, native: bool) {
|
||||
let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
|
||||
if state
|
||||
.target
|
||||
.as_ref()
|
||||
.is_some_and(|target| target.generation == generation)
|
||||
&& (native || state.next_refresh.is_none())
|
||||
{
|
||||
// Reading a warm cache from the renderer must not continually
|
||||
// postpone the independent native deadline.
|
||||
state.next_refresh = Some(Instant::now() + delay);
|
||||
self.changed.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&self) {
|
||||
loop {
|
||||
let target = {
|
||||
let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
|
||||
loop {
|
||||
if let (Some(target), Some(deadline)) =
|
||||
(state.target.clone(), state.next_refresh)
|
||||
{
|
||||
let now = Instant::now();
|
||||
if now >= deadline {
|
||||
state.next_refresh = None;
|
||||
break target;
|
||||
}
|
||||
let (next, _) = self
|
||||
.changed
|
||||
.wait_timeout(state, deadline.saturating_duration_since(now))
|
||||
.unwrap_or_else(|p| p.into_inner());
|
||||
state = next;
|
||||
} else {
|
||||
state = self.changed.wait(state).unwrap_or_else(|p| p.into_inner());
|
||||
}
|
||||
}
|
||||
};
|
||||
// Re-enumerate current saved/native providers every round. A scope
|
||||
// change cancels queued old work between cards; active HTTP calls
|
||||
// retain their existing bounded timeout and official-only URLs.
|
||||
let result = read_balances_at(&target.home, &|| self.is_current(target.generation));
|
||||
self.completed(target.generation, refresh_delay(&result), true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn native_monitor() -> Result<&'static Arc<BalanceMonitor>> {
|
||||
static MONITOR: OnceLock<std::result::Result<Arc<BalanceMonitor>, String>> = OnceLock::new();
|
||||
MONITOR
|
||||
.get_or_init(|| {
|
||||
let monitor = Arc::new(BalanceMonitor {
|
||||
state: Mutex::new(MonitorState::default()),
|
||||
changed: Condvar::new(),
|
||||
});
|
||||
let worker = Arc::clone(&monitor);
|
||||
std::thread::Builder::new()
|
||||
.name("provider-balance-monitor".into())
|
||||
.spawn(move || worker.run())
|
||||
.map_err(|_| "无法启动供应商余额后台刷新,请重启 Codex-X 后重试。".to_string())?;
|
||||
Ok(monitor)
|
||||
})
|
||||
.as_ref()
|
||||
.map_err(|message| CodexxError::Config(message.clone()))
|
||||
}
|
||||
|
||||
/// The IPC wrapper should run this blocking function on spawn_blocking.
|
||||
/// No URL, API key, arbitrary header or official account ID is accepted.
|
||||
pub(crate) fn get_provider_balances_inner(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<Vec<ProviderBalanceResult>> {
|
||||
let home = crate::resolve_codex_dir(config_dir)?;
|
||||
let monitor = native_monitor()?;
|
||||
let generation = monitor.select(home.clone());
|
||||
let result = read_balances_at(&home, &|| monitor.is_current(generation));
|
||||
monitor.completed(generation, refresh_delay(&result), false);
|
||||
result
|
||||
}
|
||||
|
||||
/// Refresh a single persisted/current card. All connection details are read
|
||||
/// again from trusted local provider storage, never accepted from the caller.
|
||||
pub(crate) fn refresh_provider_balance_inner(
|
||||
config_dir: Option<String>,
|
||||
source: String,
|
||||
provider_id: String,
|
||||
) -> Result<ProviderBalanceResult> {
|
||||
// Include provider lookup in the concurrency window: overlapping calls
|
||||
// may spend different amounts of time reading the store/current config.
|
||||
let requested_at = Instant::now();
|
||||
if !matches!(source.as_str(), "local" | "detected") {
|
||||
return Err(CodexxError::Config(
|
||||
"仅支持刷新已保存或当前检测到的第三方供应商余额。".into(),
|
||||
));
|
||||
}
|
||||
let home = crate::resolve_codex_dir(config_dir)?;
|
||||
let provider = if source == "local" {
|
||||
providers::list_saved_providers_inner()
|
||||
.map_err(|_| CodexxError::Config("读取已保存供应商失败,请稍后重试。".into()))?
|
||||
.into_iter()
|
||||
.find(|provider| provider.id == provider_id)
|
||||
} else {
|
||||
providers::detected_live_custom_provider(&home)
|
||||
.map_err(|_| {
|
||||
CodexxError::Config("读取当前供应商配置失败,请检查配置格式后重试。".into())
|
||||
})?
|
||||
.filter(|provider| format!("detected-{}", provider.id) == provider_id)
|
||||
}
|
||||
.ok_or_else(|| {
|
||||
CodexxError::Config("该供应商已不存在或当前配置已改变,请刷新供应商列表。".into())
|
||||
})?;
|
||||
if vendor_for_provider(&provider).is_none() {
|
||||
return Err(CodexxError::Config("该供应商不支持余额刷新。".into()));
|
||||
}
|
||||
// Deliberately leave the independent five-minute monitor deadline alone.
|
||||
Ok(provider_result(&source, &provider, true, requested_at))
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::failover::protocol::UpstreamApi;
|
||||
#[cfg(test)]
|
||||
use crate::remote::ensure_crypto_provider;
|
||||
use crate::remote::{remote_client, remote_request_error, RemoteSource};
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::time::Instant;
|
||||
use toml_edit::{DocumentMut, Item};
|
||||
|
||||
const MODELS_SOURCE_KEY: &str = "获取模型列表";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ProviderConnectionResult {
|
||||
pub(crate) ok: bool,
|
||||
pub(crate) status: Option<u16>,
|
||||
pub(crate) message: String,
|
||||
pub(crate) duration_ms: u128,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ProviderModel {
|
||||
pub(crate) id: String,
|
||||
pub(crate) created: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ProviderModelsResult {
|
||||
pub(crate) models: Vec<ProviderModel>,
|
||||
pub(crate) status: u16,
|
||||
pub(crate) duration_ms: u128,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelsPayload {
|
||||
data: Vec<ModelPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelPayload {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
created: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GeminiModelsPayload {
|
||||
models: Vec<GeminiModelPayload>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GeminiModelPayload {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
supported_generation_methods: Vec<String>,
|
||||
}
|
||||
|
||||
enum ProviderModelsAttempt {
|
||||
Success(ProviderModelsResult),
|
||||
HttpError { status: u16, duration_ms: u128 },
|
||||
}
|
||||
|
||||
fn provider_base_url(base_url: &str) -> Result<reqwest::Url> {
|
||||
let trimmed = base_url.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(CodexxError::Config("base_url 不能为空".to_string()));
|
||||
}
|
||||
|
||||
let url = reqwest::Url::parse(trimmed)
|
||||
.map_err(|_| CodexxError::Config("base_url 格式不正确".to_string()))?;
|
||||
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
|
||||
return Err(CodexxError::Config(
|
||||
"base_url 必须是有效的 http:// 或 https:// 地址".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn provider_models_url(base_url: &str) -> Result<reqwest::Url> {
|
||||
let mut url = provider_base_url(base_url)?;
|
||||
let segments = url
|
||||
.path_segments()
|
||||
.ok_or_else(|| CodexxError::Config("base_url 格式不正确".to_string()))?
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
let already_models = segments.len() >= 2
|
||||
&& segments[segments.len() - 2].eq_ignore_ascii_case("v1")
|
||||
&& segments[segments.len() - 1].eq_ignore_ascii_case("models");
|
||||
let already_v1 = segments
|
||||
.last()
|
||||
.is_some_and(|segment| segment.eq_ignore_ascii_case("v1"));
|
||||
|
||||
{
|
||||
let mut path = url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| CodexxError::Config("base_url 格式不正确".to_string()))?;
|
||||
path.pop_if_empty();
|
||||
if !already_models {
|
||||
if !already_v1 {
|
||||
path.push("v1");
|
||||
}
|
||||
path.push("models");
|
||||
}
|
||||
}
|
||||
url.set_fragment(None);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn provider_models_url_with_protocol(
|
||||
base_url: &str,
|
||||
protocol: UpstreamApi,
|
||||
) -> Result<reqwest::Url> {
|
||||
if protocol != UpstreamApi::Gemini {
|
||||
let mut url = provider_base_url(base_url)?;
|
||||
let path = url.path().trim_end_matches('/');
|
||||
let suffix = match protocol {
|
||||
UpstreamApi::Responses => "/responses",
|
||||
UpstreamApi::ChatCompletions => "/chat/completions",
|
||||
UpstreamApi::AnthropicMessages => "/messages",
|
||||
UpstreamApi::Gemini => unreachable!(),
|
||||
};
|
||||
if let Some(prefix) = path.strip_suffix(suffix) {
|
||||
let prefix = prefix.to_string();
|
||||
url.set_path(&prefix);
|
||||
}
|
||||
return provider_models_url(url.as_str());
|
||||
}
|
||||
let mut url = provider_base_url(base_url)?;
|
||||
let path = url.path().trim_end_matches('/');
|
||||
// A pasted generateContent endpoint still points to the same API prefix.
|
||||
let prefix = path
|
||||
.split_once("/models/")
|
||||
.map_or(path, |(prefix, _)| prefix);
|
||||
let path = if prefix.ends_with("/models") {
|
||||
prefix.to_string()
|
||||
} else if prefix.ends_with("/v1") || prefix.ends_with("/v1beta") {
|
||||
format!("{prefix}/models")
|
||||
} else {
|
||||
format!("{prefix}/v1beta/models")
|
||||
};
|
||||
url.set_path(&path);
|
||||
url.set_fragment(None);
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn provider_models_headers_with_env(
|
||||
config_text: Option<&str>,
|
||||
resolve_env: impl Fn(&str) -> Option<String>,
|
||||
) -> Result<HeaderMap> {
|
||||
let Some(config_text) = config_text.filter(|text| !text.trim().is_empty()) else {
|
||||
return Ok(HeaderMap::new());
|
||||
};
|
||||
let doc = config_text
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|_| CodexxError::Config("供应商 TOML 无效,请先修正配置".into()))?;
|
||||
let id = doc
|
||||
.get("model_provider")
|
||||
.and_then(Item::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|id| !id.is_empty())
|
||||
.ok_or_else(|| CodexxError::Config("供应商 TOML 缺少 model_provider".into()))?;
|
||||
let table = doc
|
||||
.get("model_providers")
|
||||
.and_then(Item::as_table)
|
||||
.and_then(|providers| providers.get(id))
|
||||
.and_then(Item::as_table)
|
||||
.ok_or_else(|| CodexxError::Config("供应商 TOML 缺少当前供应商配置表".into()))?;
|
||||
super::store::validate_provider_header_table(table)?;
|
||||
let mut headers = HeaderMap::new();
|
||||
for header in super::store::read_provider_headers_inner(config_text.to_string())? {
|
||||
let text = match header.source {
|
||||
super::store::ProviderHeaderSource::Static => header.value,
|
||||
super::store::ProviderHeaderSource::Env => {
|
||||
resolve_env(&header.value).ok_or_else(|| {
|
||||
CodexxError::Config(format!(
|
||||
"供应商 Header ({}) 所需的环境变量不可用",
|
||||
header.name
|
||||
))
|
||||
})?
|
||||
}
|
||||
};
|
||||
let name = HeaderName::from_bytes(header.name.as_bytes())
|
||||
.map_err(|_| CodexxError::Config("供应商 Header 名称无效".into()))?;
|
||||
let mut value = HeaderValue::from_str(&text).map_err(|_| {
|
||||
CodexxError::Config(format!(
|
||||
"供应商 Header ({}) 值无效,不能包含换行或控制字符",
|
||||
header.name
|
||||
))
|
||||
})?;
|
||||
value.set_sensitive(true);
|
||||
headers.insert(name, value);
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn provider_models_headers(config_text: Option<&str>) -> Result<HeaderMap> {
|
||||
provider_models_headers_with_env(config_text, |name| std::env::var(name).ok())
|
||||
}
|
||||
|
||||
fn parse_created(value: Option<serde_json::Value>) -> Option<i64> {
|
||||
value.and_then(|value| {
|
||||
value
|
||||
.as_i64()
|
||||
.or_else(|| value.as_str().and_then(|text| text.parse::<i64>().ok()))
|
||||
})
|
||||
}
|
||||
|
||||
fn compare_digit_runs(left: &[u8], right: &[u8]) -> Ordering {
|
||||
let left_significant = left
|
||||
.iter()
|
||||
.position(|byte| *byte != b'0')
|
||||
.map_or(&left[left.len()..], |index| &left[index..]);
|
||||
let right_significant = right
|
||||
.iter()
|
||||
.position(|byte| *byte != b'0')
|
||||
.map_or(&right[right.len()..], |index| &right[index..]);
|
||||
|
||||
left_significant
|
||||
.len()
|
||||
.cmp(&right_significant.len())
|
||||
.then_with(|| left_significant.cmp(right_significant))
|
||||
}
|
||||
|
||||
fn natural_model_id_cmp(left: &str, right: &str) -> Ordering {
|
||||
let left = left.as_bytes();
|
||||
let right = right.as_bytes();
|
||||
let (mut left_index, mut right_index) = (0, 0);
|
||||
|
||||
while left_index < left.len() && right_index < right.len() {
|
||||
if left[left_index].is_ascii_digit() && right[right_index].is_ascii_digit() {
|
||||
let left_end = left[left_index..]
|
||||
.iter()
|
||||
.position(|byte| !byte.is_ascii_digit())
|
||||
.map_or(left.len(), |offset| left_index + offset);
|
||||
let right_end = right[right_index..]
|
||||
.iter()
|
||||
.position(|byte| !byte.is_ascii_digit())
|
||||
.map_or(right.len(), |offset| right_index + offset);
|
||||
let order =
|
||||
compare_digit_runs(&left[left_index..left_end], &right[right_index..right_end]);
|
||||
if order != Ordering::Equal {
|
||||
return order;
|
||||
}
|
||||
left_index = left_end;
|
||||
right_index = right_end;
|
||||
continue;
|
||||
}
|
||||
|
||||
let order = left[left_index]
|
||||
.to_ascii_lowercase()
|
||||
.cmp(&right[right_index].to_ascii_lowercase());
|
||||
if order != Ordering::Equal {
|
||||
return order;
|
||||
}
|
||||
left_index += 1;
|
||||
right_index += 1;
|
||||
}
|
||||
|
||||
left.len().cmp(&right.len()).then_with(|| left.cmp(right))
|
||||
}
|
||||
|
||||
fn parse_models(body: &str) -> Result<Vec<ProviderModel>> {
|
||||
let payload: ModelsPayload = serde_json::from_str(body)
|
||||
.map_err(|_| CodexxError::Config("模型列表返回格式不正确".to_string()))?;
|
||||
Ok(normalize_models(payload.data))
|
||||
}
|
||||
|
||||
fn parse_models_with_protocol(body: &str, protocol: UpstreamApi) -> Result<Vec<ProviderModel>> {
|
||||
if protocol != UpstreamApi::Gemini {
|
||||
return parse_models(body);
|
||||
}
|
||||
let payload: GeminiModelsPayload = serde_json::from_str(body)
|
||||
.map_err(|_| CodexxError::Config("模型列表返回格式不正确".to_string()))?;
|
||||
let models = payload
|
||||
.models
|
||||
.into_iter()
|
||||
.filter(|model| {
|
||||
model
|
||||
.supported_generation_methods
|
||||
.iter()
|
||||
.any(|method| method == "generateContent")
|
||||
})
|
||||
.map(|model| ModelPayload {
|
||||
id: model
|
||||
.name
|
||||
.trim()
|
||||
.strip_prefix("models/")
|
||||
.unwrap_or(model.name.trim())
|
||||
.to_string(),
|
||||
created: None,
|
||||
})
|
||||
.collect();
|
||||
Ok(normalize_models(models))
|
||||
}
|
||||
|
||||
fn normalize_models(payload: Vec<ModelPayload>) -> Vec<ProviderModel> {
|
||||
let mut models = Vec::<ProviderModel>::new();
|
||||
let mut indexes = HashMap::<String, usize>::new();
|
||||
|
||||
for model in payload {
|
||||
let id = model.id.trim();
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let created = parse_created(model.created);
|
||||
if let Some(index) = indexes.get(id).copied() {
|
||||
if created > models[index].created {
|
||||
models[index].created = created;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
indexes.insert(id.to_string(), models.len());
|
||||
models.push(ProviderModel {
|
||||
id: id.to_string(),
|
||||
created,
|
||||
});
|
||||
}
|
||||
|
||||
models.sort_by(|left, right| {
|
||||
right
|
||||
.created
|
||||
.cmp(&left.created)
|
||||
.then_with(|| natural_model_id_cmp(&right.id, &left.id))
|
||||
});
|
||||
|
||||
models
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn request_provider_models_with_client(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
) -> Result<ProviderModelsAttempt> {
|
||||
request_provider_models_with_protocol_client(
|
||||
client,
|
||||
base_url,
|
||||
api_key,
|
||||
UpstreamApi::Responses,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn request_provider_models_with_protocol_client(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
protocol: UpstreamApi,
|
||||
config_text: Option<&str>,
|
||||
) -> Result<ProviderModelsAttempt> {
|
||||
let headers = provider_models_headers(config_text)?;
|
||||
request_provider_models_with_headers(client, base_url, api_key, protocol, headers)
|
||||
}
|
||||
|
||||
fn request_provider_models_with_headers(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
protocol: UpstreamApi,
|
||||
headers: HeaderMap,
|
||||
) -> Result<ProviderModelsAttempt> {
|
||||
let url = provider_models_url_with_protocol(base_url, protocol)?;
|
||||
let source = RemoteSource::new(MODELS_SOURCE_KEY, url.as_str(), Some("application/json"));
|
||||
let mut request = client
|
||||
.get(url.as_str())
|
||||
.header(reqwest::header::ACCEPT, "application/json");
|
||||
if protocol == UpstreamApi::AnthropicMessages {
|
||||
request = request.header("anthropic-version", "2023-06-01");
|
||||
}
|
||||
let custom_auth = ["authorization", "x-api-key", "x-goog-api-key"]
|
||||
.iter()
|
||||
.any(|name| headers.contains_key(*name));
|
||||
if let Some(api_key) = api_key
|
||||
.map(str::trim)
|
||||
.filter(|key| !key.is_empty() && !custom_auth)
|
||||
{
|
||||
let (name, text) = match protocol {
|
||||
UpstreamApi::Responses | UpstreamApi::ChatCompletions => {
|
||||
("authorization", format!("Bearer {api_key}"))
|
||||
}
|
||||
UpstreamApi::AnthropicMessages => ("x-api-key", api_key.to_string()),
|
||||
UpstreamApi::Gemini => ("x-goog-api-key", api_key.to_string()),
|
||||
};
|
||||
let mut value = HeaderValue::from_str(&text)
|
||||
.map_err(|_| CodexxError::Config("供应商 API Key 格式不正确".into()))?;
|
||||
value.set_sensitive(true);
|
||||
request = request.header(name, value);
|
||||
}
|
||||
request = request.headers(headers);
|
||||
|
||||
let started = Instant::now();
|
||||
let response = request
|
||||
.send()
|
||||
.map_err(|error| remote_request_error(&source, &error))?;
|
||||
let duration_ms = started.elapsed().as_millis();
|
||||
let status = response.status().as_u16();
|
||||
if !response.status().is_success() {
|
||||
return Ok(ProviderModelsAttempt::HttpError {
|
||||
status,
|
||||
duration_ms,
|
||||
});
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.map_err(|_| CodexxError::Config("模型列表读取失败".to_string()))?;
|
||||
Ok(ProviderModelsAttempt::Success(ProviderModelsResult {
|
||||
models: parse_models_with_protocol(&body, protocol)?,
|
||||
status,
|
||||
duration_ms,
|
||||
}))
|
||||
}
|
||||
|
||||
fn request_provider_models_with_protocol(
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
upstream_api: Option<&str>,
|
||||
config_text: Option<&str>,
|
||||
) -> Result<ProviderModelsAttempt> {
|
||||
let protocol = UpstreamApi::from_provider(upstream_api, "responses")?;
|
||||
let client = remote_client()?;
|
||||
request_provider_models_with_protocol_client(&client, base_url, api_key, protocol, config_text)
|
||||
}
|
||||
|
||||
pub(crate) fn provider_status_result(status: u16, duration_ms: u128) -> ProviderConnectionResult {
|
||||
ProviderConnectionResult {
|
||||
ok: (200..300).contains(&status),
|
||||
status: Some(status),
|
||||
message: if (200..300).contains(&status) {
|
||||
format!("{duration_ms} ms")
|
||||
} else if status == 401 || status == 403 {
|
||||
format!("HTTP {status} · {duration_ms} ms(认证失败或无权限)")
|
||||
} else {
|
||||
format!("HTTP {status} · {duration_ms} ms")
|
||||
},
|
||||
duration_ms,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn test_provider_connection_inner(
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
) -> Result<ProviderConnectionResult> {
|
||||
test_provider_connection_with_protocol_inner(base_url, api_key, None, None)
|
||||
}
|
||||
|
||||
pub(crate) fn test_provider_connection_with_protocol_inner(
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
upstream_api: Option<String>,
|
||||
config_text: Option<String>,
|
||||
) -> Result<ProviderConnectionResult> {
|
||||
match request_provider_models_with_protocol(
|
||||
&base_url,
|
||||
api_key.as_deref(),
|
||||
upstream_api.as_deref(),
|
||||
config_text.as_deref(),
|
||||
)? {
|
||||
ProviderModelsAttempt::Success(result) => {
|
||||
Ok(provider_status_result(result.status, result.duration_ms))
|
||||
}
|
||||
ProviderModelsAttempt::HttpError {
|
||||
status,
|
||||
duration_ms,
|
||||
} => Ok(provider_status_result(status, duration_ms)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_provider_models_inner(
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
) -> Result<ProviderModelsResult> {
|
||||
fetch_provider_models_with_protocol_inner(base_url, api_key, None, None)
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_provider_models_with_protocol_inner(
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
upstream_api: Option<String>,
|
||||
config_text: Option<String>,
|
||||
) -> Result<ProviderModelsResult> {
|
||||
match request_provider_models_with_protocol(
|
||||
&base_url,
|
||||
api_key.as_deref(),
|
||||
upstream_api.as_deref(),
|
||||
config_text.as_deref(),
|
||||
)? {
|
||||
ProviderModelsAttempt::Success(result) => Ok(result),
|
||||
ProviderModelsAttempt::HttpError { status, .. } => {
|
||||
Err(CodexxError::Config(if matches!(status, 401 | 403) {
|
||||
format!("获取模型列表失败(HTTP {status},请检查 API Key)")
|
||||
} else {
|
||||
format!("获取模型列表失败(HTTP {status})")
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
fn direct_client() -> Client {
|
||||
ensure_crypto_provider();
|
||||
Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.expect("build test client")
|
||||
}
|
||||
|
||||
fn serve_once(status: u16, body: &'static str) -> (String, thread::JoinHandle<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server");
|
||||
let base_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("accept request");
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.expect("set read timeout");
|
||||
let mut bytes = [0_u8; 8192];
|
||||
let read = stream.read(&mut bytes).expect("read request");
|
||||
let status_text = if status == 200 { "OK" } else { "Error" };
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} {status_text}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.expect("write response");
|
||||
String::from_utf8_lossy(&bytes[..read]).into_owned()
|
||||
});
|
||||
(base_url, server)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn models_url_adds_exactly_one_v1_segment() {
|
||||
for (base, expected) in [
|
||||
("https://example.com", "https://example.com/v1/models"),
|
||||
("https://example.com/v1", "https://example.com/v1/models"),
|
||||
("https://example.com/v1/", "https://example.com/v1/models"),
|
||||
(
|
||||
"https://example.com/openai",
|
||||
"https://example.com/openai/v1/models",
|
||||
),
|
||||
] {
|
||||
assert_eq!(provider_models_url(base).unwrap().as_str(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetches_models_with_bearer_auth_and_deduplicates_ids() {
|
||||
let body = r#"{"data":[{"id":" gpt-5.6-sol ","created":20},{"id":"gpt-5.5","created":"10"},{"id":"gpt-5.6-sol","created":30},{"id":" "}]}"#;
|
||||
let (base_url, server) = serve_once(200, body);
|
||||
let attempt = request_provider_models_with_client(
|
||||
&direct_client(),
|
||||
&base_url,
|
||||
Some("sk-private-test"),
|
||||
)
|
||||
.expect("request succeeds");
|
||||
let ProviderModelsAttempt::Success(result) = attempt else {
|
||||
panic!("expected successful models response");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
result.models,
|
||||
vec![
|
||||
ProviderModel {
|
||||
id: "gpt-5.6-sol".to_string(),
|
||||
created: Some(30),
|
||||
},
|
||||
ProviderModel {
|
||||
id: "gpt-5.5".to_string(),
|
||||
created: Some(10),
|
||||
},
|
||||
]
|
||||
);
|
||||
let request = server.join().expect("join mock server");
|
||||
assert!(request.starts_with("GET /v1/models HTTP/1.1"));
|
||||
assert!(request
|
||||
.to_ascii_lowercase()
|
||||
.contains("authorization: bearer sk-private-test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorts_by_created_then_natural_model_version_descending() {
|
||||
let models = parse_models(
|
||||
r#"{"data":[{"id":"gpt-5.5","created":20},{"id":"gpt-5.6-sol","created":20},{"id":"gpt-5.9"},{"id":"gpt-5.10"},{"id":"older-by-name","created":30}]}"#,
|
||||
)
|
||||
.expect("parse models");
|
||||
|
||||
assert_eq!(
|
||||
models
|
||||
.iter()
|
||||
.map(|model| model.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
"older-by-name",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.5",
|
||||
"gpt-5.10",
|
||||
"gpt-5.9",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_errors_are_not_reported_as_connected_or_leaked() {
|
||||
let private_body = r#"{"error":"secret upstream response"}"#;
|
||||
let (base_url, server) = serve_once(403, private_body);
|
||||
let attempt = request_provider_models_with_client(
|
||||
&direct_client(),
|
||||
&base_url,
|
||||
Some("sk-private-test"),
|
||||
)
|
||||
.expect("HTTP status remains an inspectable result");
|
||||
let ProviderModelsAttempt::HttpError {
|
||||
status,
|
||||
duration_ms,
|
||||
} = attempt
|
||||
else {
|
||||
panic!("expected HTTP error");
|
||||
};
|
||||
let result = provider_status_result(status, duration_ms);
|
||||
|
||||
assert!(!result.ok);
|
||||
assert_eq!(result.status, Some(403));
|
||||
assert!(!result.message.contains("secret upstream response"));
|
||||
assert!(!result.message.contains("sk-private-test"));
|
||||
assert!(!result.message.contains(&base_url));
|
||||
server.join().expect("join mock server");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_payload_error_does_not_include_response_body() {
|
||||
let private_body = r#"{"private":"secret response"}"#;
|
||||
let (base_url, server) = serve_once(200, private_body);
|
||||
let error = request_provider_models_with_client(
|
||||
&direct_client(),
|
||||
&base_url,
|
||||
Some("sk-private-test"),
|
||||
)
|
||||
.err()
|
||||
.expect("invalid payload fails");
|
||||
let message = error.to_string();
|
||||
|
||||
assert!(!message.contains("secret response"));
|
||||
assert!(!message.contains("sk-private-test"));
|
||||
assert!(!message.contains(&base_url));
|
||||
server.join().expect("join mock server");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_model_urls_preserve_api_prefix_and_do_not_repeat_versions() {
|
||||
for (protocol, base, expected) in [
|
||||
(UpstreamApi::Responses, "https://example.test/v1/responses", "https://example.test/v1/models"),
|
||||
(UpstreamApi::ChatCompletions, "https://example.test/v1/chat/completions", "https://example.test/v1/models"),
|
||||
(UpstreamApi::AnthropicMessages, "https://example.test", "https://example.test/v1/models"),
|
||||
(UpstreamApi::AnthropicMessages, "https://example.test/anthropic/v1/messages", "https://example.test/anthropic/v1/models"),
|
||||
(UpstreamApi::Gemini, "https://example.test", "https://example.test/v1beta/models"),
|
||||
(UpstreamApi::Gemini, "https://example.test/v1beta", "https://example.test/v1beta/models"),
|
||||
(UpstreamApi::Gemini, "https://example.test/v1beta/", "https://example.test/v1beta/models"),
|
||||
(UpstreamApi::Gemini, "https://example.test/v1", "https://example.test/v1/models"),
|
||||
(UpstreamApi::Gemini, "https://example.test/api/google/v1/models", "https://example.test/api/google/v1/models"),
|
||||
(UpstreamApi::Gemini, "https://example.test/api/google/v1beta/models/gemini-2.5-pro:generateContent?route=fixture#ignored", "https://example.test/api/google/v1beta/models?route=fixture"),
|
||||
(UpstreamApi::Gemini, "https://example.test/api/google", "https://example.test/api/google/v1beta/models"),
|
||||
] {
|
||||
assert_eq!(provider_models_url_with_protocol(base, protocol).unwrap().as_str(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_and_anthropic_models_use_their_protocol_authentication() {
|
||||
for (protocol, auth) in [
|
||||
(
|
||||
UpstreamApi::ChatCompletions,
|
||||
"authorization: bearer fixture-protocol-key",
|
||||
),
|
||||
(
|
||||
UpstreamApi::AnthropicMessages,
|
||||
"x-api-key: fixture-protocol-key",
|
||||
),
|
||||
] {
|
||||
let (url, server) = serve_once(200, r#"{"data":[{"id":"fixture-model"}]}"#);
|
||||
let attempt = request_provider_models_with_protocol_client(
|
||||
&direct_client(),
|
||||
&url,
|
||||
Some("fixture-protocol-key"),
|
||||
protocol,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let ProviderModelsAttempt::Success(result) = attempt else {
|
||||
panic!("expected model list")
|
||||
};
|
||||
assert_eq!(result.models[0].id, "fixture-model");
|
||||
let request = server.join().unwrap().to_ascii_lowercase();
|
||||
assert!(request.starts_with("get /v1/models http/1.1"));
|
||||
assert!(request.contains(auth));
|
||||
if protocol == UpstreamApi::AnthropicMessages {
|
||||
assert!(request.contains("anthropic-version: 2023-06-01"));
|
||||
assert!(!request.contains("authorization:"));
|
||||
} else {
|
||||
assert!(!request.contains("x-api-key:"));
|
||||
}
|
||||
assert!(!request.contains("x-goog-api-key:"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_models_use_google_header_and_include_only_generate_content_models() {
|
||||
let body = r#"{"models":[{"name":"models/gemini-2.5-pro","supportedGenerationMethods":["generateContent","countTokens"]},{"name":"models/text-embedding-004","supportedGenerationMethods":["embedContent"]},{"name":" models/gemini-2.5-flash ","supportedGenerationMethods":["generateContent"]},{"name":"models/gemini-2.5-pro","supportedGenerationMethods":["generateContent"]},{"name":"models/count-only","supportedGenerationMethods":["countTokens"]}]}"#;
|
||||
let (url, server) = serve_once(200, body);
|
||||
let attempt = request_provider_models_with_protocol_client(
|
||||
&direct_client(),
|
||||
&format!("{url}/v1beta"),
|
||||
Some("fixture-google-key"),
|
||||
UpstreamApi::Gemini,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let ProviderModelsAttempt::Success(result) = attempt else {
|
||||
panic!("expected Gemini model list")
|
||||
};
|
||||
assert_eq!(
|
||||
result
|
||||
.models
|
||||
.iter()
|
||||
.map(|model| model.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["gemini-2.5-pro", "gemini-2.5-flash"]
|
||||
);
|
||||
let request = server.join().unwrap().to_ascii_lowercase();
|
||||
assert!(request.starts_with("get /v1beta/models http/1.1"));
|
||||
assert!(request.contains("x-goog-api-key: fixture-google-key"));
|
||||
assert!(!request.contains("authorization:"));
|
||||
assert!(!request.contains("x-api-key:"));
|
||||
assert!(!request
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap()
|
||||
.contains("fixture-google-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_and_environment_headers_are_applied_to_model_discovery() {
|
||||
let config = "model_provider='custom'\n[model_providers.custom]\nhttp_headers={User-Agent='Fixture agent',HTTP-Referer='https://fixture.example.test'}\nenv_http_headers={X-Project='FIXTURE_PROJECT'}\n";
|
||||
let headers = provider_models_headers_with_env(Some(config), |name| {
|
||||
(name == "FIXTURE_PROJECT").then(|| "fixture-project-value".to_string())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!format!("{headers:?}").contains("fixture-project-value"));
|
||||
let (url, server) = serve_once(200, r#"{"data":[]}"#);
|
||||
request_provider_models_with_headers(
|
||||
&direct_client(),
|
||||
&url,
|
||||
Some("fixture-api-key"),
|
||||
UpstreamApi::Responses,
|
||||
headers,
|
||||
)
|
||||
.unwrap();
|
||||
let request = server.join().unwrap().to_ascii_lowercase();
|
||||
assert!(request.contains("user-agent: fixture agent"));
|
||||
assert!(request.contains("http-referer: https://fixture.example.test"));
|
||||
assert!(request.contains("x-project: fixture-project-value"));
|
||||
assert!(request.contains("authorization: bearer fixture-api-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_auth_and_anthropic_version_headers_override_request_defaults() {
|
||||
let config = "model_provider='custom'\n[model_providers.custom]\nhttp_headers={Authorization='Bearer fixture-custom-auth',anthropic-version='2099-01-01'}\n";
|
||||
let (url, server) = serve_once(200, r#"{"data":[]}"#);
|
||||
request_provider_models_with_protocol_client(
|
||||
&direct_client(),
|
||||
&url,
|
||||
Some("fixture-unused-key"),
|
||||
UpstreamApi::AnthropicMessages,
|
||||
Some(config),
|
||||
)
|
||||
.unwrap();
|
||||
let request = server.join().unwrap().to_ascii_lowercase();
|
||||
assert!(request.contains("authorization: bearer fixture-custom-auth"));
|
||||
assert!(request.contains("anthropic-version: 2099-01-01"));
|
||||
assert!(!request.contains("anthropic-version: 2023-06-01"));
|
||||
assert!(!request.contains("x-api-key:"));
|
||||
assert!(!request.contains("fixture-unused-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_validation_rejects_duplicates_and_invalid_resolved_values_without_secret_echo() {
|
||||
let duplicate = "model_provider='custom'\n[model_providers.custom]\nhttp_headers={X-Project='fixture-static-secret'}\nenv_http_headers={x-project='FIXTURE_PROJECT'}\n";
|
||||
let error = provider_models_headers_with_env(Some(duplicate), |_| {
|
||||
panic!("validation must happen before resolving environment values")
|
||||
})
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(!error.contains("fixture-static-secret"));
|
||||
assert!(error.contains("重复"));
|
||||
let env = "model_provider='custom'\n[model_providers.custom]\nenv_http_headers={X-Project='FIXTURE_PROJECT'}\n";
|
||||
for resolved in [
|
||||
None,
|
||||
Some("fixture-env-secret\r\nInjected: value".to_string()),
|
||||
] {
|
||||
let error = provider_models_headers_with_env(Some(env), |_| resolved.clone())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("X-Project"));
|
||||
assert!(!error.contains("fixture-env-secret"));
|
||||
assert!(!error.contains("Injected"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_payload_errors_and_authentication_statuses_do_not_echo_private_data() {
|
||||
let (url, server) = serve_once(200, r#"{"models":"fixture-private-response"}"#);
|
||||
let error = request_provider_models_with_protocol_client(
|
||||
&direct_client(),
|
||||
&url,
|
||||
Some("fixture-google-secret"),
|
||||
UpstreamApi::Gemini,
|
||||
None,
|
||||
)
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(!error.contains("fixture-private-response"));
|
||||
assert!(!error.contains("fixture-google-secret"));
|
||||
server.join().unwrap();
|
||||
let (url, server) = serve_once(401, r#"{"error":"fixture-auth-private-response"}"#);
|
||||
let attempt = request_provider_models_with_protocol_client(
|
||||
&direct_client(),
|
||||
&url,
|
||||
Some("fixture-anthropic-secret"),
|
||||
UpstreamApi::AnthropicMessages,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let ProviderModelsAttempt::HttpError {
|
||||
status,
|
||||
duration_ms,
|
||||
} = attempt
|
||||
else {
|
||||
panic!("expected authentication status")
|
||||
};
|
||||
let result = provider_status_result(status, duration_ms);
|
||||
assert!(!result.ok);
|
||||
assert_eq!(result.status, Some(401));
|
||||
assert!(!result.message.contains("fixture-auth-private-response"));
|
||||
assert!(!result.message.contains("fixture-anthropic-secret"));
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
mod ccswitch;
|
||||
mod connection;
|
||||
mod live;
|
||||
pub(crate) mod model_catalog;
|
||||
mod official_auth;
|
||||
pub(crate) mod official_profiles;
|
||||
pub(crate) mod quota;
|
||||
mod selection;
|
||||
mod store;
|
||||
pub(crate) mod transport;
|
||||
|
||||
use crate::error::Result;
|
||||
use rusqlite::Connection;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use ccswitch::{
|
||||
build_ccswitch_codex_provider, codex_sections_from_config, is_official_ccswitch_row,
|
||||
read_ccswitch_codex_rows, CcSwitchCodexRow,
|
||||
};
|
||||
pub(crate) use ccswitch::{
|
||||
import_ccswitch_codex_providers_inner, read_ccswitch_official_auth_inner, ImportResult,
|
||||
OfficialAuthCandidate,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use connection::provider_status_result;
|
||||
pub(crate) use connection::{
|
||||
fetch_provider_models_inner, fetch_provider_models_with_protocol_inner,
|
||||
test_provider_connection_inner, test_provider_connection_with_protocol_inner,
|
||||
ProviderConnectionResult, ProviderModelsResult,
|
||||
};
|
||||
pub(crate) use live::{
|
||||
activate_saved_provider_inner, build_provider_toml_draft_inner,
|
||||
build_provider_toml_draft_with_origin_inner, delete_saved_provider_inner,
|
||||
get_provider_config_base_inner, save_active_provider_inner,
|
||||
save_active_provider_with_common_config_inner, save_official_config_inner,
|
||||
save_provider_toml_config_inner, switch_provider_inner, OfficialConfigInput, ProviderInput,
|
||||
ProviderTomlInput,
|
||||
};
|
||||
pub(crate) use live::{detected_live_custom_provider, replacement_write_order, LiveWriteOrder};
|
||||
#[cfg(test)]
|
||||
pub(crate) use live::{
|
||||
reset_official_provider_inner, restore_official_provider_inner,
|
||||
save_provider_toml_config_with_pre_persist, switch_official_provider_inner,
|
||||
switch_official_provider_with_pre_persist, switch_provider_with_pre_persist,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use official_auth::{
|
||||
capture_live_chatgpt_config, get_official_config_draft_inner, official_snapshot_path_for_test,
|
||||
};
|
||||
pub(crate) use official_auth::{document_is_official, official_auth_available};
|
||||
pub(crate) use selection::{
|
||||
clear_active_provider_on_connection, clear_provider_selections_on_connection,
|
||||
reconcile_active_provider_on_connection, remember_active_provider_on_connection,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use store::{
|
||||
canonical_provider_base_url, provider_by_id_on_connection, provider_identity,
|
||||
save_manual_provider_on_connection, upsert_provider_on_connection, ProviderUpsertMode,
|
||||
};
|
||||
pub(crate) use store::{
|
||||
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
|
||||
delete_provider_inner, duplicate_provider_inner, experimental_bearer_token_from_doc,
|
||||
get_provider_order_inner, is_placeholder_provider, list_saved_providers_inner,
|
||||
list_saved_providers_on_connection, matching_saved_provider_ids_for_live,
|
||||
matching_saved_provider_ids_for_live_on_connection, normalize_saved_provider,
|
||||
normalize_saved_provider_for_save, provider_template_from_document,
|
||||
read_provider_headers_inner, reserved_codex_provider_id, rollback_provider_store_inner,
|
||||
save_detected_provider_with_rollback_inner, save_provider_inner, save_provider_order_inner,
|
||||
save_provider_with_rollback_inner, strip_provider_bearer_tokens, update_provider_headers_inner,
|
||||
upsert_ccswitch_provider_on_connection, DuplicateProviderResult, ProviderHeader,
|
||||
ProviderStoreRollback, ProviderUpsertKind, SavedProvider,
|
||||
};
|
||||
|
||||
pub(crate) fn open_store() -> Result<Connection> {
|
||||
crate::app_db::open()
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
//! Read-only subscription quota lookup for one saved official login. Credentials
|
||||
//! stay in native code; neither network errors nor response bodies are logged.
|
||||
|
||||
use super::official_profiles::{
|
||||
official_profile_quota_credentials, OfficialProfileQuotaCredentials,
|
||||
};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::live_config::acquire_live_config_lock;
|
||||
use crate::remote::ensure_crypto_provider;
|
||||
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||
use reqwest::blocking::Client;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION, USER_AGENT};
|
||||
use reqwest::StatusCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
const QUOTA_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
|
||||
const RESET_CREDITS_URL: &str = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
|
||||
const MAX_RESPONSE_BYTES: usize = 1024 * 1024;
|
||||
// The file lock is nonblocking. Concurrent quota and credit queries share this
|
||||
// brief gate while reading credentials, so they cannot fail each other's read.
|
||||
// Neither this gate nor the file lock is held during a network request.
|
||||
static QUERY_CREDENTIAL_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum OfficialQuery {
|
||||
Quota,
|
||||
ResetCredits,
|
||||
}
|
||||
|
||||
impl OfficialQuery {
|
||||
fn url(self) -> &'static str {
|
||||
match self {
|
||||
Self::Quota => QUOTA_URL,
|
||||
Self::ResetCredits => RESET_CREDITS_URL,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Quota => "额度",
|
||||
Self::ResetCredits => "重置次数",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialResetCreditsSnapshot {
|
||||
profile_id: String,
|
||||
available_count: u32,
|
||||
checked_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawResetCreditsResponse {
|
||||
// Required and unsigned: unknown or invalid counts must never look like 0.
|
||||
available_count: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialQuotaSnapshot {
|
||||
profile_id: String,
|
||||
email: Option<String>,
|
||||
plan_type: Option<String>,
|
||||
limits: Vec<OfficialQuotaLimit>,
|
||||
checked_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialQuotaLimit {
|
||||
id: String,
|
||||
name: Option<String>,
|
||||
allowed: Option<bool>,
|
||||
limit_reached: Option<bool>,
|
||||
windows: Vec<OfficialQuotaWindow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct OfficialQuotaWindow {
|
||||
id: String,
|
||||
window_seconds: Option<i64>,
|
||||
used_percent: Option<f64>,
|
||||
remaining_percent: Option<f64>,
|
||||
resets_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct RawWindow {
|
||||
limit_window_seconds: Option<i64>,
|
||||
used_percent: Option<f64>,
|
||||
reset_at: Option<i64>,
|
||||
reset_after_seconds: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
struct RawLimit {
|
||||
allowed: Option<bool>,
|
||||
limit_reached: Option<bool>,
|
||||
primary_window: Option<RawWindow>,
|
||||
secondary_window: Option<RawWindow>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawAdditionalLimit {
|
||||
limit_name: Option<String>,
|
||||
name: Option<String>,
|
||||
metered_feature: Option<String>,
|
||||
rate_limit: Option<RawLimit>,
|
||||
#[serde(flatten)]
|
||||
direct_limit: RawLimit,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawQuotaResponse {
|
||||
account_id: Option<String>,
|
||||
email: Option<String>,
|
||||
plan_type: Option<String>,
|
||||
rate_limit: Option<RawLimit>,
|
||||
code_review_rate_limit: Option<RawLimit>,
|
||||
additional_rate_limits: Option<Vec<Option<RawAdditionalLimit>>>,
|
||||
}
|
||||
|
||||
fn config_error(message: &str) -> CodexxError {
|
||||
CodexxError::Config(message.to_owned())
|
||||
}
|
||||
|
||||
fn nonempty(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn display_email(value: Option<&str>) -> Option<String> {
|
||||
nonempty(value)
|
||||
.filter(|email| email.len() <= 320 && email.contains('@'))
|
||||
.filter(|email| {
|
||||
!email
|
||||
.chars()
|
||||
.any(|character| character.is_control() || character.is_whitespace())
|
||||
})
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn quota_headers(credentials: &OfficialProfileQuotaCredentials) -> Result<HeaderMap> {
|
||||
let token = nonempty(Some(&credentials.access_token))
|
||||
.ok_or_else(|| config_error("此官方配置尚未登录,请先在 Codex 中登录"))?;
|
||||
let mut authorization = HeaderValue::from_str(&format!("Bearer {token}"))
|
||||
.map_err(|_| config_error("此官方配置的登录凭据无效,请重新登录"))?;
|
||||
authorization.set_sensitive(true);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(AUTHORIZATION, authorization);
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
|
||||
headers.insert(USER_AGENT, HeaderValue::from_static("codex-cli"));
|
||||
if let Some(account_id) = nonempty(credentials.account_id.as_deref()) {
|
||||
let mut account = HeaderValue::from_str(account_id)
|
||||
.map_err(|_| config_error("此官方配置的账号标识无效,请重新登录"))?;
|
||||
account.set_sensitive(true);
|
||||
headers.insert("ChatGPT-Account-Id", account);
|
||||
}
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn quota_client() -> Result<Client> {
|
||||
ensure_crypto_provider();
|
||||
Client::builder()
|
||||
.https_only(true)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(15))
|
||||
.connect_timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|_| config_error("官方账号查询客户端初始化失败"))
|
||||
}
|
||||
|
||||
fn check_status(status: StatusCode, query: OfficialQuery) -> Result<()> {
|
||||
if status.is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
let label = query.label();
|
||||
let message = match status.as_u16() {
|
||||
401 => format!("官方登录已失效,请重新登录此账号后查询{label}(HTTP 401)"),
|
||||
403 => format!("无法访问官方{label}服务(HTTP 403),请稍后重试或检查网络"),
|
||||
429 => format!("官方{label}查询过于频繁,请稍后重试(HTTP 429)"),
|
||||
300..=399 => format!("官方{label}接口发生重定向,已停止请求,请稍后重试"),
|
||||
code @ 500..=599 => format!("官方{label}服务暂不可用(HTTP {code}),请稍后重试"),
|
||||
code => format!("官方{label}查询失败(HTTP {code}),请稍后重试"),
|
||||
};
|
||||
Err(CodexxError::Config(message))
|
||||
}
|
||||
|
||||
fn read_bounded_body(reader: impl Read, content_length: Option<u64>) -> Result<Vec<u8>> {
|
||||
if content_length.is_some_and(|length| length > MAX_RESPONSE_BYTES as u64) {
|
||||
return Err(config_error("官方查询响应过大,已停止读取"));
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
reader
|
||||
.take(MAX_RESPONSE_BYTES as u64 + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| config_error("读取官方查询响应失败,请稍后重试"))?;
|
||||
if bytes.len() > MAX_RESPONSE_BYTES {
|
||||
return Err(config_error("官方查询响应过大,已停止读取"));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn window(id: &str, raw: RawWindow, checked_at: DateTime<Utc>) -> OfficialQuotaWindow {
|
||||
let used_percent = raw
|
||||
.used_percent
|
||||
.filter(|value| value.is_finite())
|
||||
.map(|value| value.clamp(0.0, 100.0));
|
||||
let resets_at = raw
|
||||
.reset_at
|
||||
.and_then(|seconds| DateTime::from_timestamp(seconds, 0))
|
||||
.or_else(|| {
|
||||
raw.reset_after_seconds
|
||||
.filter(|seconds| *seconds >= 0)
|
||||
.and_then(ChronoDuration::try_seconds)
|
||||
.and_then(|duration| checked_at.checked_add_signed(duration))
|
||||
})
|
||||
.map(|time| time.to_rfc3339());
|
||||
OfficialQuotaWindow {
|
||||
id: id.to_owned(),
|
||||
window_seconds: raw.limit_window_seconds.filter(|seconds| *seconds > 0),
|
||||
used_percent,
|
||||
remaining_percent: used_percent.map(|value| (100.0 - value).clamp(0.0, 100.0)),
|
||||
resets_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn limit(
|
||||
id: String,
|
||||
name: Option<String>,
|
||||
raw: RawLimit,
|
||||
checked_at: DateTime<Utc>,
|
||||
) -> OfficialQuotaLimit {
|
||||
let windows = [
|
||||
("primary", raw.primary_window),
|
||||
("secondary", raw.secondary_window),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(id, raw)| raw.map(|raw| window(id, raw, checked_at)))
|
||||
.collect();
|
||||
OfficialQuotaLimit {
|
||||
id,
|
||||
name,
|
||||
allowed: raw.allowed,
|
||||
limit_reached: raw.limit_reached,
|
||||
windows,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_quota_response(
|
||||
bytes: &[u8],
|
||||
profile_id: &str,
|
||||
credentials: &OfficialProfileQuotaCredentials,
|
||||
checked_at: DateTime<Utc>,
|
||||
) -> Result<OfficialQuotaSnapshot> {
|
||||
let raw: RawQuotaResponse = serde_json::from_slice(bytes)
|
||||
.map_err(|_| config_error("官方额度响应格式无法识别,请稍后重试"))?;
|
||||
if nonempty(raw.account_id.as_deref())
|
||||
.zip(nonempty(credentials.account_id.as_deref()))
|
||||
.is_some_and(|(actual, expected)| actual != expected)
|
||||
{
|
||||
return Err(config_error(
|
||||
"额度响应与所选官方账号不一致,请重新登录此配置后重试",
|
||||
));
|
||||
}
|
||||
let mut limits = Vec::new();
|
||||
if let Some(raw) = raw.rate_limit {
|
||||
limits.push(limit("main".to_owned(), None, raw, checked_at));
|
||||
}
|
||||
if let Some(raw) = raw.code_review_rate_limit {
|
||||
limits.push(limit("code_review".to_owned(), None, raw, checked_at));
|
||||
}
|
||||
for (index, additional) in raw
|
||||
.additional_rate_limits
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let Some(additional) = additional else {
|
||||
continue;
|
||||
};
|
||||
let name = nonempty(additional.limit_name.as_deref())
|
||||
.or_else(|| nonempty(additional.name.as_deref()))
|
||||
.or_else(|| nonempty(additional.metered_feature.as_deref()))
|
||||
.map(str::to_owned);
|
||||
limits.push(limit(
|
||||
format!("additional-{index}"),
|
||||
name,
|
||||
additional.rate_limit.unwrap_or(additional.direct_limit),
|
||||
checked_at,
|
||||
));
|
||||
}
|
||||
Ok(OfficialQuotaSnapshot {
|
||||
profile_id: profile_id.to_owned(),
|
||||
email: display_email(raw.email.as_deref())
|
||||
.or_else(|| display_email(credentials.email.as_deref())),
|
||||
plan_type: nonempty(raw.plan_type.as_deref()).map(str::to_owned),
|
||||
limits,
|
||||
checked_at: checked_at.to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
fn credential_fingerprint(credentials: &OfficialProfileQuotaCredentials) -> [u8; 32] {
|
||||
let mut digest = Sha256::new();
|
||||
// Length-delimit components so two different identities cannot have the
|
||||
// same concatenated input. Only this digest is compared after the request.
|
||||
for value in [
|
||||
Some(credentials.access_token.as_str()),
|
||||
credentials.account_id.as_deref(),
|
||||
credentials.email.as_deref(),
|
||||
] {
|
||||
let bytes = value.unwrap_or_default().as_bytes();
|
||||
digest.update((bytes.len() as u64).to_le_bytes());
|
||||
digest.update(bytes);
|
||||
}
|
||||
digest.finalize().into()
|
||||
}
|
||||
|
||||
fn parse_reset_credits_response(
|
||||
bytes: &[u8],
|
||||
profile_id: &str,
|
||||
checked_at: DateTime<Utc>,
|
||||
) -> Result<OfficialResetCreditsSnapshot> {
|
||||
let raw: RawResetCreditsResponse = serde_json::from_slice(bytes)
|
||||
.map_err(|_| config_error("官方重置次数响应格式无法识别,请稍后重试"))?;
|
||||
Ok(OfficialResetCreditsSnapshot {
|
||||
profile_id: profile_id.to_owned(),
|
||||
available_count: raw.available_count,
|
||||
checked_at: checked_at.to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
fn ensure_same_credentials(
|
||||
expected: [u8; 32],
|
||||
current: &OfficialProfileQuotaCredentials,
|
||||
) -> Result<()> {
|
||||
if expected != credential_fingerprint(current) {
|
||||
return Err(config_error("此官方账号的登录信息已变化,请重新刷新"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fetch_official_response(
|
||||
query: OfficialQuery,
|
||||
credentials: &OfficialProfileQuotaCredentials,
|
||||
) -> Result<Vec<u8>> {
|
||||
let headers = quota_headers(credentials)?;
|
||||
let label = query.label();
|
||||
let response = quota_client()?
|
||||
.get(query.url())
|
||||
.headers(headers)
|
||||
.send()
|
||||
.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
config_error(&format!("官方{label}查询超时,请检查网络后重试"))
|
||||
} else if error.is_connect() {
|
||||
config_error(&format!("无法连接官方{label}接口,请检查网络或代理设置"))
|
||||
} else {
|
||||
config_error(&format!("官方{label}网络请求失败,请稍后重试"))
|
||||
}
|
||||
})?;
|
||||
check_status(response.status(), query)?;
|
||||
let length = response.content_length();
|
||||
read_bounded_body(response, length)
|
||||
}
|
||||
|
||||
fn read_query_credentials(
|
||||
codex_dir: &Path,
|
||||
profile_id: &str,
|
||||
) -> Result<OfficialProfileQuotaCredentials> {
|
||||
let _query_guard = QUERY_CREDENTIAL_LOCK
|
||||
.lock()
|
||||
.map_err(|_| config_error("官方账号查询暂不可用,请重试"))?;
|
||||
let _file_guard = acquire_live_config_lock(codex_dir)
|
||||
.map_err(|_| config_error("官方配置正在修改,请稍后刷新"))?;
|
||||
official_profile_quota_credentials(codex_dir, profile_id)
|
||||
}
|
||||
|
||||
fn read_official_snapshot<T>(
|
||||
codex_dir: &Path,
|
||||
profile_id: &str,
|
||||
fetch: impl FnOnce(&OfficialProfileQuotaCredentials) -> Result<Vec<u8>>,
|
||||
parse: impl FnOnce(&[u8], &str, &OfficialProfileQuotaCredentials, DateTime<Utc>) -> Result<T>,
|
||||
) -> Result<T> {
|
||||
let credentials = read_query_credentials(codex_dir, profile_id)?;
|
||||
let fingerprint = credential_fingerprint(&credentials);
|
||||
let bytes = fetch(&credentials)?;
|
||||
let snapshot = parse(&bytes, profile_id, &credentials, Utc::now())?;
|
||||
let current = read_query_credentials(codex_dir, profile_id)
|
||||
.map_err(|_| config_error("此官方配置已变化或被删除,请重新选择账号后查询"))?;
|
||||
ensure_same_credentials(fingerprint, ¤t)?;
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub(crate) fn get_official_profile_quota_inner(
|
||||
config_dir: Option<String>,
|
||||
profile_id: String,
|
||||
) -> Result<OfficialQuotaSnapshot> {
|
||||
let codex_dir = crate::resolve_codex_dir(config_dir)?;
|
||||
read_official_snapshot(
|
||||
&codex_dir,
|
||||
profile_id.trim(),
|
||||
|credentials| fetch_official_response(OfficialQuery::Quota, credentials),
|
||||
parse_quota_response,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn get_official_profile_reset_credits_inner(
|
||||
config_dir: Option<String>,
|
||||
profile_id: String,
|
||||
) -> Result<OfficialResetCreditsSnapshot> {
|
||||
let codex_dir = crate::resolve_codex_dir(config_dir)?;
|
||||
read_official_snapshot(
|
||||
&codex_dir,
|
||||
profile_id.trim(),
|
||||
|credentials| fetch_official_response(OfficialQuery::ResetCredits, credentials),
|
||||
|bytes, profile_id, _, checked_at| {
|
||||
parse_reset_credits_response(bytes, profile_id, checked_at)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn credentials(account: Option<&str>) -> OfficialProfileQuotaCredentials {
|
||||
OfficialProfileQuotaCredentials {
|
||||
access_token: "synthetic-access-token".to_owned(),
|
||||
account_id: account.map(str::to_owned),
|
||||
email: Some("saved@example.test".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_at() -> DateTime<Utc> {
|
||||
DateTime::parse_from_rfc3339("2026-09-09T03:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc)
|
||||
}
|
||||
|
||||
fn parse(value: serde_json::Value, account: Option<&str>) -> OfficialQuotaSnapshot {
|
||||
parse_quota_response(
|
||||
&serde_json::to_vec(&value).unwrap(),
|
||||
"official-profile-a",
|
||||
&credentials(account),
|
||||
checked_at(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pro_primary_window_can_be_weekly_and_account_id_can_be_absent() {
|
||||
let snapshot = parse(
|
||||
json!({
|
||||
"plan_type":"pro", "email":"active@example.test", "account_id":"server-account",
|
||||
"rate_limit":{"allowed":true,"limit_reached":false,"primary_window":{"limit_window_seconds":604800,"used_percent":37.5,"reset_after_seconds":600}},
|
||||
"additional_rate_limits":null,"code_review_rate_limit":null
|
||||
}),
|
||||
None,
|
||||
);
|
||||
assert_eq!(snapshot.profile_id, "official-profile-a");
|
||||
assert_eq!(snapshot.email.as_deref(), Some("active@example.test"));
|
||||
assert_eq!(snapshot.plan_type.as_deref(), Some("pro"));
|
||||
assert_eq!(snapshot.limits.len(), 1);
|
||||
let limit = &snapshot.limits[0];
|
||||
assert_eq!(limit.id, "main");
|
||||
assert_eq!(limit.allowed, Some(true));
|
||||
assert_eq!(limit.windows[0].id, "primary");
|
||||
assert_eq!(limit.windows[0].window_seconds, Some(604800));
|
||||
assert_eq!(limit.windows[0].remaining_percent, Some(62.5));
|
||||
assert_eq!(
|
||||
limit.windows[0].resets_at.as_deref(),
|
||||
Some("2026-09-09T03:10:00+00:00")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn team_two_windows_preserve_real_durations_and_absolute_reset_priority() {
|
||||
let reset = checked_at().timestamp() + 3600;
|
||||
let snapshot = parse(
|
||||
json!({
|
||||
"account_id":"team-a","plan_type":"team",
|
||||
"rate_limit":{"allowed":false,"limit_reached":true,
|
||||
"primary_window":{"limit_window_seconds":18000,"used_percent":100,"reset_at":reset,"reset_after_seconds":20},
|
||||
"secondary_window":{"limit_window_seconds":604800,"used_percent":23.25}}
|
||||
}),
|
||||
Some("team-a"),
|
||||
);
|
||||
let limit = &snapshot.limits[0];
|
||||
assert_eq!(limit.windows.len(), 2);
|
||||
assert_eq!(limit.limit_reached, Some(true));
|
||||
assert_eq!(limit.windows[0].window_seconds, Some(18000));
|
||||
assert_eq!(limit.windows[0].remaining_percent, Some(0.0));
|
||||
assert_eq!(
|
||||
limit.windows[0].resets_at.as_deref(),
|
||||
Some("2026-09-09T04:00:00+00:00")
|
||||
);
|
||||
assert_eq!(limit.windows[1].window_seconds, Some(604800));
|
||||
assert_eq!(limit.windows[1].remaining_percent, Some(76.75));
|
||||
assert!(limit.windows[1].resets_at.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_review_and_additional_features_remain_separate_limits() {
|
||||
let snapshot = parse(
|
||||
json!({
|
||||
"rate_limit":null,
|
||||
"code_review_rate_limit":{"allowed":true,"primary_window":{"used_percent":10,"limit_window_seconds":604800}},
|
||||
"additional_rate_limits":[null,
|
||||
{"limit_name":"Codex Spark","metered_feature":"codex_bengal","rate_limit":{"allowed":true,"primary_window":{"used_percent":40,"limit_window_seconds":18000}}},
|
||||
{"metered_feature":"future_feature","allowed":false,"limit_reached":true,"primary_window":{"used_percent":100,"limit_window_seconds":86400}}]
|
||||
}),
|
||||
None,
|
||||
);
|
||||
assert_eq!(snapshot.limits.len(), 3);
|
||||
assert_eq!(snapshot.limits[0].id, "code_review");
|
||||
assert_eq!(snapshot.limits[1].id, "additional-1");
|
||||
assert_eq!(snapshot.limits[1].name.as_deref(), Some("Codex Spark"));
|
||||
assert_eq!(snapshot.limits[2].name.as_deref(), Some("future_feature"));
|
||||
assert_eq!(snapshot.limits[2].allowed, Some(false));
|
||||
assert_eq!(snapshot.limits[2].windows[0].window_seconds, Some(86400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_usage_is_unknown_instead_of_zero_or_full_remaining() {
|
||||
let snapshot = parse(
|
||||
json!({"rate_limit":{"primary_window":{"limit_window_seconds":18000}}}),
|
||||
None,
|
||||
);
|
||||
let window = &snapshot.limits[0].windows[0];
|
||||
assert!(window.used_percent.is_none());
|
||||
assert!(window.remaining_percent.is_none());
|
||||
assert!(snapshot.limits[0].allowed.is_none());
|
||||
assert!(snapshot.limits[0].limit_reached.is_none());
|
||||
let empty = parse(json!({}), None);
|
||||
assert!(empty.limits.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_percentages_are_clamped_and_nonfinite_values_stay_unknown() {
|
||||
for (used, expected) in [
|
||||
(-20.0, Some(0.0)),
|
||||
(130.0, Some(100.0)),
|
||||
(f64::NAN, None),
|
||||
(f64::INFINITY, None),
|
||||
] {
|
||||
let value = window(
|
||||
"primary",
|
||||
RawWindow {
|
||||
used_percent: Some(used),
|
||||
..Default::default()
|
||||
},
|
||||
checked_at(),
|
||||
);
|
||||
assert_eq!(value.used_percent, expected);
|
||||
assert_eq!(value.remaining_percent, expected.map(|used| 100.0 - used));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_reset_and_window_numbers_do_not_panic_or_invent_dates() {
|
||||
let value = window(
|
||||
"primary",
|
||||
RawWindow {
|
||||
limit_window_seconds: Some(-1),
|
||||
reset_at: Some(i64::MAX),
|
||||
reset_after_seconds: Some(i64::MAX),
|
||||
..Default::default()
|
||||
},
|
||||
checked_at(),
|
||||
);
|
||||
assert!(value.window_seconds.is_none());
|
||||
assert!(value.resets_at.is_none());
|
||||
let negative = window(
|
||||
"primary",
|
||||
RawWindow {
|
||||
reset_after_seconds: Some(-10),
|
||||
..Default::default()
|
||||
},
|
||||
checked_at(),
|
||||
);
|
||||
assert!(negative.resets_at.is_none());
|
||||
let now = window(
|
||||
"primary",
|
||||
RawWindow {
|
||||
reset_after_seconds: Some(0),
|
||||
..Default::default()
|
||||
},
|
||||
checked_at(),
|
||||
);
|
||||
assert_eq!(now.resets_at.as_deref(), Some("2026-09-09T03:00:00+00:00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_nonempty_response_account_is_rejected_without_identifiers_in_error() {
|
||||
let error = parse_quota_response(
|
||||
br#"{"account_id":"other-private-account"}"#,
|
||||
"official-profile-a",
|
||||
&credentials(Some("selected-private-account")),
|
||||
checked_at(),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("不一致"));
|
||||
assert!(!error.contains("private-account"));
|
||||
assert!(!error.contains("synthetic-access-token"));
|
||||
assert!(
|
||||
parse(json!({"account_id":null}), Some("selected-private-account"))
|
||||
.limits
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn email_uses_response_first_then_safe_saved_display_metadata() {
|
||||
assert_eq!(
|
||||
parse(json!({"email":"response@example.test"}), None)
|
||||
.email
|
||||
.as_deref(),
|
||||
Some("response@example.test")
|
||||
);
|
||||
assert_eq!(
|
||||
parse(json!({"email":"bad\n@example.test"}), None)
|
||||
.email
|
||||
.as_deref(),
|
||||
Some("saved@example.test")
|
||||
);
|
||||
assert_eq!(
|
||||
parse(json!({}), None).email.as_deref(),
|
||||
Some("saved@example.test")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_has_only_fixed_https_endpoint_and_sensitive_auth_headers() {
|
||||
let request = quota_client()
|
||||
.unwrap()
|
||||
.get(QUOTA_URL)
|
||||
.headers(quota_headers(&credentials(Some("account-a"))).unwrap())
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(request.method(), reqwest::Method::GET);
|
||||
assert_eq!(request.url().as_str(), QUOTA_URL);
|
||||
assert_eq!(request.url().host_str(), Some("chatgpt.com"));
|
||||
assert_eq!(request.url().scheme(), "https");
|
||||
assert!(request.headers()[AUTHORIZATION].is_sensitive());
|
||||
assert_eq!(
|
||||
request.headers()[AUTHORIZATION],
|
||||
"Bearer synthetic-access-token"
|
||||
);
|
||||
assert!(request.headers()["chatgpt-account-id"].is_sensitive());
|
||||
assert_eq!(request.headers()["chatgpt-account-id"], "account-a");
|
||||
assert_eq!(request.headers()[ACCEPT], "application/json");
|
||||
assert_eq!(request.headers()[USER_AGENT], "codex-cli");
|
||||
assert!(!format!("{request:?}").contains("synthetic-access-token"));
|
||||
assert!(!quota_headers(&credentials(None))
|
||||
.unwrap()
|
||||
.contains_key("chatgpt-account-id"));
|
||||
let credits_request = quota_client()
|
||||
.unwrap()
|
||||
.get(OfficialQuery::ResetCredits.url())
|
||||
.headers(quota_headers(&credentials(Some("account-a"))).unwrap())
|
||||
.build()
|
||||
.unwrap();
|
||||
assert_eq!(credits_request.method(), reqwest::Method::GET);
|
||||
assert_eq!(credits_request.url().as_str(), RESET_CREDITS_URL);
|
||||
assert_eq!(credits_request.url().scheme(), "https");
|
||||
assert_eq!(credits_request.url().host_str(), Some("chatgpt.com"));
|
||||
assert!(credits_request.headers()[AUTHORIZATION].is_sensitive());
|
||||
assert!(credits_request.headers()["chatgpt-account-id"].is_sensitive());
|
||||
assert!(!format!("{credits_request:?}").contains("synthetic-access-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_or_injected_credentials_fail_without_revealing_content() {
|
||||
let mut empty = credentials(None);
|
||||
empty.access_token = " ".to_owned();
|
||||
assert!(quota_headers(&empty)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("尚未登录"));
|
||||
let mut invalid = credentials(None);
|
||||
invalid.access_token = "private-secret\r\nInjected: true".to_owned();
|
||||
let error = quota_headers(&invalid).unwrap_err().to_string();
|
||||
assert!(!error.contains("private-secret"));
|
||||
assert!(!error.contains("Injected"));
|
||||
invalid = credentials(Some("private-account\r\nInjected: true"));
|
||||
let error = quota_headers(&invalid).unwrap_err().to_string();
|
||||
assert!(!error.contains("private-account"));
|
||||
assert!(error.contains("账号标识无效"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_statuses_have_safe_actionable_messages() {
|
||||
for query in [OfficialQuery::Quota, OfficialQuery::ResetCredits] {
|
||||
assert!(check_status(StatusCode::OK, query).is_ok());
|
||||
for (status, expected) in [
|
||||
(401, "重新登录"),
|
||||
(403, "检查网络"),
|
||||
(429, "过于频繁"),
|
||||
(302, "重定向"),
|
||||
(503, "HTTP 503"),
|
||||
] {
|
||||
let error = check_status(StatusCode::from_u16(status).unwrap(), query)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(expected));
|
||||
assert!(error.contains(query.label()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_limit_applies_even_without_or_with_false_content_length() {
|
||||
assert_eq!(read_bounded_body(&b"{}"[..], Some(2)).unwrap(), b"{}");
|
||||
let large = vec![b'x'; MAX_RESPONSE_BYTES + 1];
|
||||
for content_length in [None, Some(1), Some(large.len() as u64)] {
|
||||
assert!(read_bounded_body(&large[..], content_length)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("响应过大"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_response_never_enters_error_text() {
|
||||
let error = parse_quota_response(
|
||||
b"server accidentally returned private-secret-token",
|
||||
"official-profile-a",
|
||||
&credentials(None),
|
||||
checked_at(),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("格式无法识别"));
|
||||
assert!(!error.contains("private-secret-token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_change_rejects_stale_result_for_token_account_or_email() {
|
||||
let original = credentials(Some("account-a"));
|
||||
let fingerprint = credential_fingerprint(&original);
|
||||
assert!(ensure_same_credentials(fingerprint, &credentials(Some("account-a"))).is_ok());
|
||||
for field in ["token", "account", "email"] {
|
||||
let mut changed = credentials(Some("account-a"));
|
||||
match field {
|
||||
"token" => changed.access_token = "private-replacement-token".into(),
|
||||
"account" => changed.account_id = Some("account-b".into()),
|
||||
_ => changed.email = Some("changed@example.test".into()),
|
||||
}
|
||||
let error = ensure_same_credentials(fingerprint, &changed)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("登录信息已变化"));
|
||||
assert!(!error.contains("private-replacement-token"));
|
||||
assert!(!error.contains("account-b"));
|
||||
assert!(!error.contains("changed@example.test"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_snapshot_contains_only_display_fields_and_no_credentials() {
|
||||
let snapshot = parse(
|
||||
json!({"plan_type":"team","rate_limit":{"primary_window":{"used_percent":40,"limit_window_seconds":18000}}}),
|
||||
Some("account-a"),
|
||||
);
|
||||
let value = serde_json::to_value(&snapshot).unwrap();
|
||||
assert_eq!(value["profileId"], "official-profile-a");
|
||||
assert_eq!(value["planType"], "team");
|
||||
assert_eq!(value["limits"][0]["windows"][0]["remainingPercent"], 60.0);
|
||||
let serialized = value.to_string();
|
||||
assert!(!serialized.contains("access_token"));
|
||||
assert!(!serialized.contains("synthetic-access-token"));
|
||||
assert!(!serialized.contains("account-a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_credits_uses_available_count_and_returns_no_credit_details() {
|
||||
let raw = json!({
|
||||
"available_count": 3,
|
||||
"total_earned_count": 0,
|
||||
"credits": [{"id":"private-credit-id", "status":"available", "title":"private-credit-title"}],
|
||||
"immediate_reset_purchase_eligible": true
|
||||
});
|
||||
let snapshot = parse_reset_credits_response(
|
||||
&serde_json::to_vec(&raw).unwrap(),
|
||||
"official-profile-a",
|
||||
checked_at(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(snapshot).unwrap(),
|
||||
json!({
|
||||
"profileId":"official-profile-a",
|
||||
"availableCount":3,
|
||||
"checkedAt":"2026-09-09T03:00:00+00:00"
|
||||
})
|
||||
);
|
||||
let zero = parse_reset_credits_response(
|
||||
br#"{"available_count":0,"credits":[{"status":"available"}]}"#,
|
||||
"official-profile-a",
|
||||
checked_at(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(zero.available_count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_credits_unknown_or_invalid_counts_are_not_reported_as_zero() {
|
||||
for raw in [
|
||||
"{}",
|
||||
"null",
|
||||
r#"{"available_count":null}"#,
|
||||
r#"{"available_count":-1}"#,
|
||||
r#"{"available_count":1.5}"#,
|
||||
r#"{"available_count":"3"}"#,
|
||||
r#"{"available_count":true}"#,
|
||||
r#"{"available_count":4294967296}"#,
|
||||
"private-malformed-response",
|
||||
] {
|
||||
let error =
|
||||
parse_reset_credits_response(raw.as_bytes(), "official-profile-a", checked_at())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("重置次数响应格式无法识别"));
|
||||
assert!(!error.contains("private-malformed-response"));
|
||||
}
|
||||
}
|
||||
|
||||
fn query_fixture(label: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"codex-x-official-query-{label}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
crate::file_io::write_text(
|
||||
&crate::config_path(&dir),
|
||||
"model_provider = \"openai\"\nmodel = \"synthetic-model\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
crate::file_io::write_json(
|
||||
&crate::auth_path(&dir),
|
||||
&json!({
|
||||
"auth_mode":"chatgpt", "tokens":{
|
||||
"access_token":"synthetic-query-token", "account_id":"synthetic-account"
|
||||
}
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_and_reset_queries_overlap_without_holding_the_live_config_lock() {
|
||||
use super::super::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID;
|
||||
use std::sync::mpsc;
|
||||
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let dir = query_fixture("parallel");
|
||||
// Initialize the test database before starting the two independent reads.
|
||||
read_query_credentials(&dir, DEFAULT_OFFICIAL_PROFILE_ID).unwrap();
|
||||
let (started_tx, started_rx) = mpsc::channel();
|
||||
let (quota_release_tx, quota_release_rx) = mpsc::channel();
|
||||
let (credits_release_tx, credits_release_rx) = mpsc::channel();
|
||||
std::thread::scope(|scope| {
|
||||
let quota_started = started_tx.clone();
|
||||
let quota_dir = &dir;
|
||||
let quota = scope.spawn(move || {
|
||||
read_official_snapshot(
|
||||
quota_dir,
|
||||
DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
|_| {
|
||||
quota_started.send("quota").unwrap();
|
||||
quota_release_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.unwrap();
|
||||
Ok(b"{}".to_vec())
|
||||
},
|
||||
parse_quota_response,
|
||||
)
|
||||
});
|
||||
let credits_dir = &dir;
|
||||
let credits = scope.spawn(move || {
|
||||
read_official_snapshot(
|
||||
credits_dir,
|
||||
DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
|_| {
|
||||
started_tx.send("credits").unwrap();
|
||||
credits_release_rx
|
||||
.recv_timeout(Duration::from_secs(5))
|
||||
.unwrap();
|
||||
Ok(br#"{"available_count":3}"#.to_vec())
|
||||
},
|
||||
|bytes, id, _, time| parse_reset_credits_response(bytes, id, time),
|
||||
)
|
||||
});
|
||||
// Both requests must start before either can finish. A lock held over
|
||||
// the simulated network wait would make this fail instead of hang.
|
||||
let first = started_rx.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
let second = started_rx.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
assert_ne!(first, second);
|
||||
drop(acquire_live_config_lock(&dir).unwrap());
|
||||
quota_release_tx.send(()).unwrap();
|
||||
credits_release_tx.send(()).unwrap();
|
||||
assert_eq!(
|
||||
quota.join().unwrap().unwrap().profile_id,
|
||||
DEFAULT_OFFICIAL_PROFILE_ID
|
||||
);
|
||||
assert_eq!(credits.join().unwrap().unwrap().available_count, 3);
|
||||
});
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_query_rejects_login_replaced_during_request() {
|
||||
use super::super::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID;
|
||||
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
let dir = query_fixture("changed-login");
|
||||
let error = read_official_snapshot(
|
||||
&dir, DEFAULT_OFFICIAL_PROFILE_ID,
|
||||
|_| {
|
||||
let _guard = acquire_live_config_lock(&dir).unwrap();
|
||||
crate::file_io::write_json(&crate::auth_path(&dir), &json!({
|
||||
"auth_mode":"chatgpt", "tokens":{
|
||||
"access_token":"synthetic-replacement-token", "account_id":"synthetic-account"
|
||||
}
|
||||
})).unwrap();
|
||||
Ok(br#"{"available_count":3}"#.to_vec())
|
||||
},
|
||||
|bytes, id, _, time| parse_reset_credits_response(bytes, id, time),
|
||||
).unwrap_err().to_string();
|
||||
assert!(error.contains("登录信息已变化"));
|
||||
assert!(!error.contains("synthetic-replacement-token"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::now_rfc3339;
|
||||
use crate::paths::normalized_path_scope;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
pub(super) fn selected_provider_id_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &Path,
|
||||
) -> Result<Option<String>> {
|
||||
conn.query_row(
|
||||
"SELECT provider_id
|
||||
FROM active_provider_selections
|
||||
WHERE codex_dir = ?1",
|
||||
[normalized_path_scope(codex_dir)],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn remember_active_provider_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &Path,
|
||||
provider_id: &str,
|
||||
) -> Result<()> {
|
||||
let provider_id = provider_id.trim();
|
||||
if provider_id.is_empty() {
|
||||
return Err(CodexxError::Config("当前供应商 ID 不能为空".to_string()));
|
||||
}
|
||||
let exists = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM providers WHERE id = ?1)",
|
||||
[provider_id],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if !exists {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"无法记录当前供应商,未找到 ID {provider_id}"
|
||||
)));
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO active_provider_selections (codex_dir, provider_id, updated_at)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(codex_dir) DO UPDATE SET
|
||||
provider_id = excluded.provider_id,
|
||||
updated_at = excluded.updated_at",
|
||||
params![normalized_path_scope(codex_dir), provider_id, now_rfc3339()],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_active_provider_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &Path,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM active_provider_selections WHERE codex_dir = ?1",
|
||||
[normalized_path_scope(codex_dir)],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn clear_provider_selections_on_connection(
|
||||
conn: &Connection,
|
||||
provider_id: &str,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"DELETE FROM active_provider_selections WHERE provider_id = ?1",
|
||||
[provider_id],
|
||||
)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_active_provider_on_connection(
|
||||
conn: &Connection,
|
||||
codex_dir: &Path,
|
||||
candidate_ids: &[String],
|
||||
) -> Result<Option<String>> {
|
||||
let candidates = candidate_ids
|
||||
.iter()
|
||||
.map(|id| id.trim())
|
||||
.filter(|id| !id.is_empty())
|
||||
.collect::<HashSet<_>>();
|
||||
let selected = selected_provider_id_on_connection(conn, codex_dir)?;
|
||||
if let Some(selected) = selected.as_deref() {
|
||||
if candidates.contains(selected) {
|
||||
return Ok(Some(selected.to_string()));
|
||||
}
|
||||
clear_active_provider_on_connection(conn, codex_dir)?;
|
||||
}
|
||||
|
||||
if candidates.len() != 1 {
|
||||
return Ok(None);
|
||||
}
|
||||
let provider_id = candidates
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("one candidate must be present");
|
||||
remember_active_provider_on_connection(conn, codex_dir, provider_id)?;
|
||||
Ok(Some(provider_id.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn test_connection() -> Connection {
|
||||
let conn = Connection::open_in_memory().expect("open selection test database");
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE providers (id TEXT PRIMARY KEY);
|
||||
CREATE TABLE active_provider_selections (
|
||||
codex_dir TEXT PRIMARY KEY,
|
||||
provider_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO providers (id) VALUES ('original'), ('copy'), ('other');",
|
||||
)
|
||||
.expect("create selection test schema");
|
||||
conn
|
||||
}
|
||||
|
||||
fn scope(name: &str) -> PathBuf {
|
||||
std::env::temp_dir().join(format!("codex-x-selection-{name}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_duplicate_does_not_replace_the_remembered_current_provider() {
|
||||
let conn = test_connection();
|
||||
let codex_dir = scope("duplicate");
|
||||
remember_active_provider_on_connection(&conn, &codex_dir, "original")
|
||||
.expect("remember original provider");
|
||||
|
||||
let active = reconcile_active_provider_on_connection(
|
||||
&conn,
|
||||
&codex_dir,
|
||||
&["original".to_string(), "copy".to_string()],
|
||||
)
|
||||
.expect("reconcile duplicate profiles");
|
||||
|
||||
assert_eq!(active.as_deref(), Some("original"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selections_are_independent_for_each_codex_directory() {
|
||||
let conn = test_connection();
|
||||
let first = scope("first");
|
||||
let second = scope("second");
|
||||
remember_active_provider_on_connection(&conn, &first, "original")
|
||||
.expect("remember first selection");
|
||||
remember_active_provider_on_connection(&conn, &second, "copy")
|
||||
.expect("remember second selection");
|
||||
let candidates = ["original".to_string(), "copy".to_string()];
|
||||
|
||||
assert_eq!(
|
||||
reconcile_active_provider_on_connection(&conn, &first, &candidates)
|
||||
.expect("reconcile first selection")
|
||||
.as_deref(),
|
||||
Some("original")
|
||||
);
|
||||
assert_eq!(
|
||||
reconcile_active_provider_on_connection(&conn, &second, &candidates)
|
||||
.expect("reconcile second selection")
|
||||
.as_deref(),
|
||||
Some("copy")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_selection_falls_back_only_when_the_live_match_is_unique() {
|
||||
let conn = test_connection();
|
||||
let codex_dir = scope("stale");
|
||||
remember_active_provider_on_connection(&conn, &codex_dir, "original")
|
||||
.expect("remember stale selection");
|
||||
|
||||
assert_eq!(
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &["other".to_string()])
|
||||
.expect("reconcile unique replacement")
|
||||
.as_deref(),
|
||||
Some("other")
|
||||
);
|
||||
assert_eq!(
|
||||
selected_provider_id_on_connection(&conn, &codex_dir)
|
||||
.expect("read repaired selection")
|
||||
.as_deref(),
|
||||
Some("other")
|
||||
);
|
||||
|
||||
assert!(
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &[])
|
||||
.expect("clear unmatched selection")
|
||||
.is_none()
|
||||
);
|
||||
assert!(selected_provider_id_on_connection(&conn, &codex_dir)
|
||||
.expect("read cleared selection")
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
use toml_edit::{value, Item, TableLike};
|
||||
|
||||
/// Match the official API exactly. A proxy that forwards DeepSeek may support
|
||||
/// WebSockets independently, so model names and partial host matches are unsafe.
|
||||
pub(crate) fn is_deepseek_http_endpoint(base_url: &str) -> bool {
|
||||
reqwest::Url::parse(base_url.trim()).is_ok_and(|url| {
|
||||
matches!(url.scheme(), "http" | "https") && url.host_str() == Some("api.deepseek.com")
|
||||
})
|
||||
}
|
||||
|
||||
/// A provider's WebSocket capability belongs to its endpoint, not the common
|
||||
/// Codex config inherited when creating a provider. Call before replacing URL.
|
||||
pub(super) fn configure_third_party_transport(
|
||||
table: &mut dyn TableLike,
|
||||
base_url: &str,
|
||||
own_template: bool,
|
||||
) {
|
||||
let same_endpoint = table
|
||||
.get("base_url")
|
||||
.and_then(Item::as_str)
|
||||
.is_some_and(|old| {
|
||||
super::store::canonical_provider_base_url(old)
|
||||
== super::store::canonical_provider_base_url(base_url)
|
||||
});
|
||||
let preserve_explicit = own_template
|
||||
&& same_endpoint
|
||||
&& !is_deepseek_http_endpoint(base_url)
|
||||
&& table
|
||||
.get("supports_websockets")
|
||||
.and_then(Item::as_bool)
|
||||
.is_some();
|
||||
if !preserve_explicit {
|
||||
// Explicit false also overrides Codex's built-in provider defaults if a
|
||||
// user-chosen provider ID happens to shadow a built-in provider.
|
||||
let decor = table
|
||||
.get("supports_websockets")
|
||||
.and_then(Item::as_value)
|
||||
.map(|value| value.decor().clone());
|
||||
let mut replacement = value(false);
|
||||
if let Some(decor) = decor {
|
||||
*replacement
|
||||
.as_value_mut()
|
||||
.expect("boolean value")
|
||||
.decor_mut() = decor;
|
||||
}
|
||||
table.insert("supports_websockets", replacement);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use toml_edit::Table;
|
||||
|
||||
#[test]
|
||||
fn deepseek_host_matching_does_not_disable_proxies() {
|
||||
assert!(is_deepseek_http_endpoint("https://api.deepseek.com/v1/"));
|
||||
assert!(is_deepseek_http_endpoint("HTTPS://API.DEEPSEEK.COM"));
|
||||
for endpoint in [
|
||||
"https://deepseek.proxy.example/v1",
|
||||
"https://api.deepseek.com.proxy.example/v1",
|
||||
"https://api.deepseek.com@proxy.example/v1",
|
||||
"https://proxy.example/api.deepseek.com",
|
||||
"invalid api.deepseek.com",
|
||||
] {
|
||||
assert!(!is_deepseek_http_endpoint(endpoint), "{endpoint}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn endpoint_ownership_controls_websocket_inheritance() {
|
||||
for (source, destination, own, expected) in [
|
||||
(
|
||||
"https://proxy.example/v1",
|
||||
"https://proxy.example/v1/",
|
||||
true,
|
||||
true,
|
||||
),
|
||||
(
|
||||
"https://proxy.example/v1",
|
||||
"https://proxy.example/v1",
|
||||
false,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"https://old.example/v1",
|
||||
"https://new.example/v1",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"https://api.deepseek.com",
|
||||
"https://api.deepseek.com",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let mut table = Table::new();
|
||||
table["base_url"] = value(source);
|
||||
table["supports_websockets"] = value(true);
|
||||
table["request_max_retries"] = value(9);
|
||||
configure_third_party_transport(&mut table, destination, own);
|
||||
assert_eq!(table["supports_websockets"].as_bool(), Some(expected));
|
||||
assert_eq!(table["request_max_retries"].as_integer(), Some(9));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
const REMOTE_USER_AGENT: &str = "Codex-X";
|
||||
const REMOTE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct RemoteSource<'a> {
|
||||
pub(crate) key: &'static str,
|
||||
pub(crate) url: &'a str,
|
||||
pub(crate) accept: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl<'a> RemoteSource<'a> {
|
||||
pub(crate) const fn new(key: &'static str, url: &'a str, accept: Option<&'static str>) -> Self {
|
||||
Self { key, url, accept }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_crypto_provider() {
|
||||
static CRYPTO_PROVIDER: OnceLock<()> = OnceLock::new();
|
||||
CRYPTO_PROVIDER.get_or_init(|| {
|
||||
// Another TLS client may already have initialized the process-wide provider.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn remote_client() -> Result<reqwest::blocking::Client> {
|
||||
ensure_crypto_provider();
|
||||
reqwest::blocking::Client::builder()
|
||||
.timeout(REMOTE_TIMEOUT)
|
||||
.user_agent(REMOTE_USER_AGENT)
|
||||
.build()
|
||||
.map_err(|_| CodexxError::Config("网络客户端初始化失败".to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn remote_request_error(
|
||||
source: &RemoteSource<'_>,
|
||||
error: &reqwest::Error,
|
||||
) -> CodexxError {
|
||||
let reason = if error.is_timeout() {
|
||||
"请求超时"
|
||||
} else if error.is_connect() {
|
||||
"网络连接失败"
|
||||
} else {
|
||||
"网络请求失败"
|
||||
};
|
||||
CodexxError::Config(format!("{} {reason}", source.key))
|
||||
}
|
||||
|
||||
fn fetch_remote_text(source: &RemoteSource<'_>) -> Result<String> {
|
||||
// A fresh client picks up proxy changes made while the app is running. Reqwest's
|
||||
// system proxy resolver also handles proxy environment variables and NO_PROXY.
|
||||
let client = remote_client()?;
|
||||
let mut request = client.get(source.url);
|
||||
if let Some(accept) = source.accept {
|
||||
request = request.header(reqwest::header::ACCEPT, accept);
|
||||
}
|
||||
let response = request
|
||||
.send()
|
||||
.map_err(|error| remote_request_error(source, &error))?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"{} 请求失败(HTTP {})",
|
||||
source.key,
|
||||
status.as_u16()
|
||||
)));
|
||||
}
|
||||
response
|
||||
.text()
|
||||
.map_err(|_| CodexxError::Config(format!("{} 响应读取失败", source.key)))
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_first_valid<T, Parse>(sources: &[RemoteSource<'_>], parse: Parse) -> Result<T>
|
||||
where
|
||||
Parse: FnMut(&RemoteSource<'_>, &str) -> Result<T>,
|
||||
{
|
||||
fetch_first_valid_with(sources, fetch_remote_text, parse)
|
||||
}
|
||||
|
||||
pub(crate) fn fetch_first_valid_with<T, Fetch, Parse>(
|
||||
sources: &[RemoteSource<'_>],
|
||||
mut fetch: Fetch,
|
||||
mut parse: Parse,
|
||||
) -> Result<T>
|
||||
where
|
||||
Fetch: FnMut(&RemoteSource<'_>) -> Result<String>,
|
||||
Parse: FnMut(&RemoteSource<'_>, &str) -> Result<T>,
|
||||
{
|
||||
let mut errors = Vec::new();
|
||||
for source in sources {
|
||||
match fetch(source) {
|
||||
Ok(body) if body.trim().is_empty() => {
|
||||
errors.push(format!("{} 返回空内容", source.key));
|
||||
}
|
||||
Ok(body) => match parse(source, &body) {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(error) => errors.push(format!("{}: {error}", source.key)),
|
||||
},
|
||||
Err(error) => errors.push(format!("{}: {error}", source.key)),
|
||||
}
|
||||
}
|
||||
|
||||
Err(CodexxError::Config(format!(
|
||||
"远程内容获取失败{}",
|
||||
if errors.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(":{}", errors.join(";"))
|
||||
}
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::process::Command;
|
||||
use std::thread;
|
||||
use std::time::Instant;
|
||||
|
||||
const SOURCES: [RemoteSource<'static>; 2] = [
|
||||
RemoteSource::new("cdn", "https://cdn.example.test", None),
|
||||
RemoteSource::new("origin", "https://origin.example.test", None),
|
||||
];
|
||||
const PROXY_TEST_URL: &str = "CODEXX_REMOTE_PROXY_TEST_URL";
|
||||
const PROXY_TEST_BODY: &str = "CODEXX_REMOTE_PROXY_TEST_BODY";
|
||||
|
||||
fn clear_proxy_environment(command: &mut Command) {
|
||||
for name in [
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
"HTTPS_PROXY",
|
||||
"https_proxy",
|
||||
"HTTP_PROXY",
|
||||
"http_proxy",
|
||||
"NO_PROXY",
|
||||
"no_proxy",
|
||||
"REQUEST_METHOD",
|
||||
] {
|
||||
command.env_remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
fn serve_once(listener: TcpListener, body: &'static str) -> String {
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.expect("set listener nonblocking");
|
||||
let started = Instant::now();
|
||||
let (mut stream, _) = loop {
|
||||
match listener.accept() {
|
||||
Ok(connection) => break connection,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(8),
|
||||
"proxy test connection timed out"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
Err(error) => panic!("accept proxy test connection: {error}"),
|
||||
}
|
||||
};
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.expect("set read timeout");
|
||||
let mut request = [0_u8; 8192];
|
||||
let read = stream.read(&mut request).expect("read proxy request");
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
stream
|
||||
.write_all(response.as_bytes())
|
||||
.expect("write proxy response");
|
||||
String::from_utf8_lossy(&request[..read]).into_owned()
|
||||
}
|
||||
|
||||
fn run_proxy_test_child(url: &str, body: &str, configure: impl FnOnce(&mut Command)) {
|
||||
let mut command = Command::new(std::env::current_exe().expect("current test executable"));
|
||||
command.args([
|
||||
"--exact",
|
||||
"remote::tests::proxy_environment_child",
|
||||
"--nocapture",
|
||||
]);
|
||||
clear_proxy_environment(&mut command);
|
||||
command.env(PROXY_TEST_URL, url).env(PROXY_TEST_BODY, body);
|
||||
configure(&mut command);
|
||||
let output = command.output().expect("run proxy test child");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"proxy test child failed:\n{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_environment_child() {
|
||||
let Ok(url) = std::env::var(PROXY_TEST_URL) else {
|
||||
return;
|
||||
};
|
||||
let expected = std::env::var(PROXY_TEST_BODY).expect("proxy test body");
|
||||
let source = RemoteSource::new("proxy test", &url, None);
|
||||
assert_eq!(
|
||||
fetch_remote_text(&source).expect("fetch test response"),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_source_stops_fallback_chain() {
|
||||
let mut calls = Vec::new();
|
||||
let value = fetch_first_valid_with(
|
||||
&SOURCES,
|
||||
|source| {
|
||||
calls.push(source.key);
|
||||
Ok("valid".to_string())
|
||||
},
|
||||
|_, body| Ok(body.to_string()),
|
||||
)
|
||||
.expect("first source succeeds");
|
||||
|
||||
assert_eq!(value, "valid");
|
||||
assert_eq!(calls, ["cdn"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_unparseable_responses_try_later_sources() {
|
||||
let mut calls = Vec::new();
|
||||
let value = fetch_first_valid_with(
|
||||
&SOURCES,
|
||||
|source| {
|
||||
calls.push(source.key);
|
||||
if source.key == "cdn" {
|
||||
Ok(" ".to_string())
|
||||
} else {
|
||||
Ok("42".to_string())
|
||||
}
|
||||
},
|
||||
|_, body| {
|
||||
body.parse::<u32>()
|
||||
.map_err(|error| CodexxError::Config(error.to_string()))
|
||||
},
|
||||
)
|
||||
.expect("origin succeeds after empty CDN response");
|
||||
|
||||
assert_eq!(value, 42);
|
||||
assert_eq!(calls, ["cdn", "origin"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_client_uses_lowercase_http_proxy() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test proxy");
|
||||
let proxy_url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || serve_once(listener, "through proxy"));
|
||||
|
||||
run_proxy_test_child(
|
||||
"http://remote.invalid/template.md",
|
||||
"through proxy",
|
||||
|command| {
|
||||
command.env("http_proxy", proxy_url);
|
||||
},
|
||||
);
|
||||
|
||||
let request = server.join().expect("join proxy server");
|
||||
assert!(request.starts_with("GET http://remote.invalid/template.md "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_client_respects_lowercase_no_proxy() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test origin");
|
||||
let origin_url = format!("http://{}/direct.md", listener.local_addr().unwrap());
|
||||
let server = thread::spawn(move || serve_once(listener, "direct response"));
|
||||
|
||||
run_proxy_test_child(&origin_url, "direct response", |command| {
|
||||
command
|
||||
.env("HTTP_PROXY", "http://127.0.0.1:9")
|
||||
.env("no_proxy", "127.0.0.1");
|
||||
});
|
||||
|
||||
let request = server.join().expect("join origin server");
|
||||
assert!(request.starts_with("GET /direct.md "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_errors_do_not_expose_request_or_proxy_details() {
|
||||
remote_client().expect("initialize TLS provider");
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.no_proxy()
|
||||
.timeout(Duration::from_millis(200))
|
||||
.build()
|
||||
.expect("build direct client");
|
||||
let error = client
|
||||
.get("http://127.0.0.1:0/private")
|
||||
.send()
|
||||
.expect_err("reserved port should fail");
|
||||
let message = remote_request_error(&SOURCES[0], &error).to_string();
|
||||
|
||||
assert!(message.contains("cdn"));
|
||||
assert!(!message.contains("127.0.0.1"));
|
||||
assert!(!message.contains("private"));
|
||||
assert!(!message.contains('@'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
use crate::error::Result;
|
||||
use crate::platform;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Stdio};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct OfficialSessionDeleteOutcome {
|
||||
pub(super) deleted_ids: HashSet<String>,
|
||||
pub(super) completed_roots: HashSet<String>,
|
||||
pub(super) failed_roots: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum AppServerDeleteAttempt {
|
||||
Success(OfficialSessionDeleteOutcome),
|
||||
Unsupported(String),
|
||||
}
|
||||
|
||||
fn send_app_server_message(
|
||||
stdin: &mut impl Write,
|
||||
value: &Value,
|
||||
) -> std::result::Result<(), String> {
|
||||
let mut line = serde_json::to_vec(value).map_err(|error| error.to_string())?;
|
||||
line.push(b'\n');
|
||||
stdin.write_all(&line).map_err(|error| error.to_string())?;
|
||||
stdin.flush().map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn recv_app_server_message(
|
||||
receiver: &mpsc::Receiver<std::io::Result<String>>,
|
||||
deadline: Instant,
|
||||
) -> std::result::Result<Value, String> {
|
||||
loop {
|
||||
let remaining = deadline
|
||||
.checked_duration_since(Instant::now())
|
||||
.ok_or_else(|| "Codex App Server 响应超时".to_string())?;
|
||||
let line = receiver
|
||||
.recv_timeout(remaining)
|
||||
.map_err(|error| format!("Codex App Server 响应失败: {error}"))?
|
||||
.map_err(|error| format!("读取 Codex App Server 输出失败: {error}"))?;
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
return serde_json::from_str(&line)
|
||||
.map_err(|error| format!("解析 Codex App Server 输出失败: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
fn app_server_error(value: &Value) -> Option<(i64, String)> {
|
||||
let error = value.get("error")?;
|
||||
Some((
|
||||
error
|
||||
.get("code")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or_default(),
|
||||
error
|
||||
.get("message")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("Codex App Server 返回未知错误")
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
fn stop_app_server_child(child: &mut Child) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
fn run_app_server_delete_attempt(
|
||||
mut child: Child,
|
||||
codex_dir: &Path,
|
||||
roots: &[String],
|
||||
) -> AppServerDeleteAttempt {
|
||||
let Some(mut stdin) = child.stdin.take() else {
|
||||
stop_app_server_child(&mut child);
|
||||
return AppServerDeleteAttempt::Unsupported("Codex App Server stdin 不可用".to_string());
|
||||
};
|
||||
let Some(stdout) = child.stdout.take() else {
|
||||
stop_app_server_child(&mut child);
|
||||
return AppServerDeleteAttempt::Unsupported("Codex App Server stdout 不可用".to_string());
|
||||
};
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let reader = std::thread::spawn(move || {
|
||||
for line in BufReader::new(stdout).lines() {
|
||||
if sender.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let result = (|| -> AppServerDeleteAttempt {
|
||||
let initialize_id = 1i64;
|
||||
let initialize = json!({
|
||||
"id": initialize_id,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"clientInfo": {
|
||||
"name": "codex_x",
|
||||
"title": "Codex-X",
|
||||
"version": env!("CARGO_PKG_VERSION")
|
||||
},
|
||||
"capabilities": null
|
||||
}
|
||||
});
|
||||
if let Err(error) = send_app_server_message(&mut stdin, &initialize) {
|
||||
return AppServerDeleteAttempt::Unsupported(error);
|
||||
}
|
||||
let initialize_deadline = Instant::now() + Duration::from_secs(8);
|
||||
loop {
|
||||
let message = match recv_app_server_message(&receiver, initialize_deadline) {
|
||||
Ok(message) => message,
|
||||
Err(error) => return AppServerDeleteAttempt::Unsupported(error),
|
||||
};
|
||||
if message.get("id").and_then(Value::as_i64) != Some(initialize_id) {
|
||||
continue;
|
||||
}
|
||||
if let Some((_, message)) = app_server_error(&message) {
|
||||
return AppServerDeleteAttempt::Unsupported(message);
|
||||
}
|
||||
let Some(server_home) = message
|
||||
.get("result")
|
||||
.and_then(|value| value.get("codexHome"))
|
||||
.and_then(Value::as_str)
|
||||
else {
|
||||
return AppServerDeleteAttempt::Unsupported(
|
||||
"Codex App Server 未返回 CODEX_HOME".to_string(),
|
||||
);
|
||||
};
|
||||
let requested_home = codex_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| codex_dir.to_path_buf());
|
||||
let returned_home = PathBuf::from(server_home)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| PathBuf::from(server_home));
|
||||
if requested_home != returned_home {
|
||||
return AppServerDeleteAttempt::Unsupported(format!(
|
||||
"Codex App Server 使用了不同的 CODEX_HOME: {}",
|
||||
returned_home.display()
|
||||
));
|
||||
}
|
||||
break;
|
||||
}
|
||||
if let Err(error) = send_app_server_message(&mut stdin, &json!({"method": "initialized"})) {
|
||||
return AppServerDeleteAttempt::Unsupported(error);
|
||||
}
|
||||
|
||||
let mut outcome = OfficialSessionDeleteOutcome::default();
|
||||
'delete_roots: for (index, root) in roots.iter().enumerate() {
|
||||
let request_id = 1000 + index as i64;
|
||||
if let Err(error) = send_app_server_message(
|
||||
&mut stdin,
|
||||
&json!({
|
||||
"id": request_id,
|
||||
"method": "thread/delete",
|
||||
"params": { "threadId": root }
|
||||
}),
|
||||
) {
|
||||
for pending in &roots[index..] {
|
||||
outcome.failed_roots.push((pending.clone(), error.clone()));
|
||||
}
|
||||
break 'delete_roots;
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
let message = match recv_app_server_message(&receiver, deadline) {
|
||||
Ok(message) => message,
|
||||
Err(error) => {
|
||||
for pending in &roots[index..] {
|
||||
outcome.failed_roots.push((pending.clone(), error.clone()));
|
||||
}
|
||||
break 'delete_roots;
|
||||
}
|
||||
};
|
||||
if message.get("method").and_then(Value::as_str) == Some("thread/deleted") {
|
||||
if let Some(id) = message
|
||||
.get("params")
|
||||
.and_then(|value| value.get("threadId"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
outcome.deleted_ids.insert(id.to_string());
|
||||
}
|
||||
}
|
||||
if message.get("id").and_then(Value::as_i64) == Some(request_id) {
|
||||
if let Some((code, error)) = app_server_error(&message) {
|
||||
let lower = error.to_ascii_lowercase();
|
||||
if code == -32601 {
|
||||
return AppServerDeleteAttempt::Unsupported(error);
|
||||
}
|
||||
if code == -32600
|
||||
&& (lower.contains("no rollout")
|
||||
|| lower.contains("not found")
|
||||
|| lower.contains("does not exist"))
|
||||
{
|
||||
outcome.completed_roots.insert(root.clone());
|
||||
break;
|
||||
}
|
||||
outcome.failed_roots.push((root.clone(), error));
|
||||
break;
|
||||
}
|
||||
outcome.completed_roots.insert(root.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
AppServerDeleteAttempt::Success(outcome)
|
||||
})();
|
||||
|
||||
drop(stdin);
|
||||
stop_app_server_child(&mut child);
|
||||
let _ = reader.join();
|
||||
result
|
||||
}
|
||||
|
||||
pub(super) fn delete_sessions_via_codex_app_server(
|
||||
codex_dir: &Path,
|
||||
roots: &[String],
|
||||
) -> Result<Option<OfficialSessionDeleteOutcome>> {
|
||||
let mut unsupported_messages = Vec::new();
|
||||
for program in platform::codex_executable_candidates() {
|
||||
let is_bare_command = program.components().count() == 1;
|
||||
if !is_bare_command && !program.is_file() {
|
||||
continue;
|
||||
}
|
||||
let mut command = platform::program_command(&program, &["app-server", "--stdio"]);
|
||||
command
|
||||
.env("CODEX_HOME", codex_dir)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null());
|
||||
let child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
unsupported_messages.push(format!("{}: {error}", program.display()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match run_app_server_delete_attempt(child, codex_dir, roots) {
|
||||
AppServerDeleteAttempt::Success(outcome) => return Ok(Some(outcome)),
|
||||
AppServerDeleteAttempt::Unsupported(message) => {
|
||||
unsupported_messages.push(format!("{}: {message}", program.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = unsupported_messages;
|
||||
Ok(None)
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, write_json};
|
||||
use chrono::Local;
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct BackupSnapshot {
|
||||
live_path: PathBuf,
|
||||
backup_path: PathBuf,
|
||||
existed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct ProviderSyncBackup {
|
||||
pub(super) dir: PathBuf,
|
||||
}
|
||||
|
||||
pub(crate) fn provider_sync_backup_root(codex_dir: &Path) -> PathBuf {
|
||||
codex_dir.join("backups_state").join("provider-sync")
|
||||
}
|
||||
|
||||
fn backup_relative_path(codex_dir: &Path, source: &Path) -> PathBuf {
|
||||
match source.strip_prefix(codex_dir) {
|
||||
Ok(relative) if !relative.as_os_str().is_empty() => relative.to_path_buf(),
|
||||
_ => {
|
||||
use sha2::{Digest, Sha256};
|
||||
let digest = Sha256::digest(source.to_string_lossy().as_bytes());
|
||||
let key = digest[..8]
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<String>();
|
||||
PathBuf::from("external").join(key).join(
|
||||
source
|
||||
.file_name()
|
||||
.unwrap_or_else(|| std::ffi::OsStr::new("file")),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn backup_target_path(codex_dir: &Path, backup_dir: &Path, source: &Path) -> Result<PathBuf> {
|
||||
let target = backup_dir.join(backup_relative_path(codex_dir, source));
|
||||
if target == source {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"拒绝将备份写回源文件: {}",
|
||||
source.display()
|
||||
)));
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn copy_file_to_backup(
|
||||
codex_dir: &Path,
|
||||
backup_dir: &Path,
|
||||
source: &Path,
|
||||
) -> Result<BackupSnapshot> {
|
||||
let target = backup_target_path(codex_dir, backup_dir, source)?;
|
||||
let existed = source.exists();
|
||||
if existed {
|
||||
if let Some(parent) = target.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
fs::copy(source, &target).map_err(|e| io_err(&target, e))?;
|
||||
}
|
||||
Ok(BackupSnapshot {
|
||||
live_path: source.to_path_buf(),
|
||||
backup_path: target,
|
||||
existed,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn backup_sqlite_to_backup(
|
||||
codex_dir: &Path,
|
||||
backup_dir: &Path,
|
||||
source: &Path,
|
||||
) -> Result<()> {
|
||||
use rusqlite::backup::{Backup, StepResult};
|
||||
|
||||
if !source.exists() {
|
||||
return Err(CodexxError::Database(format!(
|
||||
"SQLite 快照源不存在: {}",
|
||||
source.display()
|
||||
)));
|
||||
}
|
||||
let target = backup_target_path(codex_dir, backup_dir, source)?;
|
||||
if let Some(parent) = target.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let from = Connection::open_with_flags(
|
||||
source,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| {
|
||||
CodexxError::Database(format!("打开 SQLite 备份源失败 {}: {e}", source.display()))
|
||||
})?;
|
||||
from.busy_timeout(Duration::from_secs(5))
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let mut to = Connection::open(&target).map_err(|e| {
|
||||
CodexxError::Database(format!("创建 SQLite 备份失败 {}: {e}", target.display()))
|
||||
})?;
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
{
|
||||
let backup = Backup::new(&from, &mut to)
|
||||
.map_err(|e| CodexxError::Database(format!("初始化 SQLite 快照失败: {e}")))?;
|
||||
loop {
|
||||
if Instant::now() >= deadline {
|
||||
return Err(CodexxError::Database(format!(
|
||||
"SQLite 快照超时: {}",
|
||||
source.display()
|
||||
)));
|
||||
}
|
||||
match backup
|
||||
.step(128)
|
||||
.map_err(|e| CodexxError::Database(format!("写入 SQLite 快照失败: {e}")))?
|
||||
{
|
||||
StepResult::Done => break,
|
||||
StepResult::More => {}
|
||||
StepResult::Busy | StepResult::Locked => {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let quick_check: String = to
|
||||
.query_row("PRAGMA quick_check", [], |row| row.get(0))
|
||||
.map_err(|e| CodexxError::Database(format!("校验 SQLite 备份失败: {e}")))?;
|
||||
if quick_check != "ok" {
|
||||
return Err(CodexxError::Database(format!(
|
||||
"SQLite 备份校验失败 {}: {quick_check}",
|
||||
target.display()
|
||||
)));
|
||||
}
|
||||
if !target.is_file() {
|
||||
return Err(CodexxError::Database(format!(
|
||||
"SQLite 快照未生成: {}",
|
||||
target.display()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn prune_provider_sync_backups(codex_dir: &Path) -> Result<()> {
|
||||
let root = provider_sync_backup_root(codex_dir);
|
||||
if !root.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut dirs = Vec::new();
|
||||
for entry in fs::read_dir(&root).map_err(|e| io_err(&root, e))? {
|
||||
let entry = entry.map_err(|e| io_err(&root, e))?;
|
||||
let path = entry.path();
|
||||
let metadata_path = path.join("metadata.json");
|
||||
if !path.is_dir() || !metadata_path.exists() {
|
||||
continue;
|
||||
}
|
||||
let is_v2_provider_sync_backup = fs::read_to_string(&metadata_path)
|
||||
.ok()
|
||||
.and_then(|text| serde_json::from_str::<Value>(&text).ok())
|
||||
.is_some_and(|metadata| {
|
||||
metadata.get("managedBy").and_then(Value::as_str)
|
||||
== Some("Codex-X provider sync v2")
|
||||
});
|
||||
if is_v2_provider_sync_backup {
|
||||
dirs.push(path);
|
||||
}
|
||||
}
|
||||
dirs.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
|
||||
for path in dirs.into_iter().skip(5) {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn create_provider_sync_backup(
|
||||
codex_dir: &Path,
|
||||
target_provider: &str,
|
||||
changed_rollouts: &[PathBuf],
|
||||
sqlite_paths: &[PathBuf],
|
||||
) -> Result<ProviderSyncBackup> {
|
||||
let root = provider_sync_backup_root(codex_dir);
|
||||
ensure_directory(&root)?;
|
||||
let mut backup_dir = root.join(Local::now().format("%Y%m%d%H%M%S").to_string());
|
||||
let mut suffix = 0;
|
||||
while backup_dir.exists() {
|
||||
suffix += 1;
|
||||
backup_dir = root.join(format!("{}-{suffix}", Local::now().format("%Y%m%d%H%M%S")));
|
||||
}
|
||||
ensure_directory(&backup_dir)?;
|
||||
|
||||
let mut snapshots = Vec::new();
|
||||
for path in sqlite_paths {
|
||||
backup_sqlite_to_backup(codex_dir, &backup_dir, path)?;
|
||||
snapshots.push(BackupSnapshot {
|
||||
live_path: path.clone(),
|
||||
backup_path: backup_target_path(codex_dir, &backup_dir, path)?,
|
||||
existed: path.exists(),
|
||||
});
|
||||
}
|
||||
for path in changed_rollouts {
|
||||
snapshots.push(copy_file_to_backup(codex_dir, &backup_dir, path)?);
|
||||
}
|
||||
write_json(
|
||||
&backup_dir.join("metadata.json"),
|
||||
&json!({
|
||||
"version": 1,
|
||||
"namespace": "provider-sync",
|
||||
"managedBy": "Codex-X provider sync v2",
|
||||
"codexHome": codex_dir.display().to_string(),
|
||||
"targetProvider": target_provider,
|
||||
"createdAt": Local::now().to_rfc3339(),
|
||||
"changedRolloutFiles": changed_rollouts.iter().map(|p| p.display().to_string()).collect::<Vec<_>>(),
|
||||
"snapshots": snapshots.iter().map(|snapshot| json!({
|
||||
"livePath": snapshot.live_path.display().to_string(),
|
||||
"backupPath": snapshot.backup_path.display().to_string(),
|
||||
"existed": snapshot.existed,
|
||||
})).collect::<Vec<_>>(),
|
||||
}),
|
||||
)?;
|
||||
Ok(ProviderSyncBackup { dir: backup_dir })
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
// Every source, SQLite database, backup and lock in these tests is temporary.
|
||||
fn compress_rollout(logical: &Path) -> std::path::PathBuf {
|
||||
let compressed = logical.with_extension("jsonl.zst");
|
||||
let mut source = fs::File::open(logical).unwrap();
|
||||
let output = fs::File::create(&compressed).unwrap();
|
||||
let mut encoder = zstd::stream::write::Encoder::new(output, 1).unwrap();
|
||||
std::io::copy(&mut source, &mut encoder).unwrap();
|
||||
encoder.finish().unwrap();
|
||||
drop(source);
|
||||
fs::remove_file(logical).unwrap();
|
||||
compressed
|
||||
}
|
||||
|
||||
fn decoded(path: &Path) -> Vec<u8> {
|
||||
let mut source = super::super::super::rollout_stream::decoded_rollout_reader(
|
||||
fs::File::open(path).unwrap(),
|
||||
path,
|
||||
)
|
||||
.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
source.read_to_end(&mut bytes).unwrap();
|
||||
bytes
|
||||
}
|
||||
|
||||
fn indexed_compressed_fixture(
|
||||
label: &str,
|
||||
) -> (
|
||||
std::path::PathBuf,
|
||||
String,
|
||||
std::path::PathBuf,
|
||||
std::path::PathBuf,
|
||||
) {
|
||||
let dir = temp_codex_dir(label);
|
||||
write_config(&dir, SHARED_SESSION_PROVIDER);
|
||||
let id = "01a0e854-0000-7000-8000-000000000001".to_string();
|
||||
let logical = write_rollout(&dir, &id, "openai");
|
||||
create_thread_database_with_rollout(&dir.join("state_5.sqlite"), &id, "openai", &logical);
|
||||
let compressed = compress_rollout(&logical);
|
||||
(dir, id, logical, compressed)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_logical_reference_syncs_and_keeps_sqlite_alias_and_content() {
|
||||
let (dir, id, logical, compressed) = indexed_compressed_fixture("zst-logical-alias");
|
||||
let mut text = decoded(&compressed);
|
||||
text.extend_from_slice(b"\r\n{\"type\":\"event_msg\",\"payload\":{\"message\":\"untouched\\ntext\",\"unicode\":\"\xe4\xb8\xad\xe6\x96\x87\"}}");
|
||||
fs::write(
|
||||
&compressed,
|
||||
zstd::stream::encode_all(text.as_slice(), 1).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let original = fs::read(&compressed).unwrap();
|
||||
let database = dir.join("state_5.sqlite");
|
||||
Connection::open(&database)
|
||||
.unwrap()
|
||||
.execute("UPDATE threads SET title='renamed title'", [])
|
||||
.unwrap();
|
||||
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(status.scan_complete, "{:?}", status.scan_failures);
|
||||
assert!(status.needs_sync);
|
||||
assert_eq!(status.rollout_files, 1);
|
||||
assert_eq!(status.sessions[0].title, "renamed title");
|
||||
assert_eq!(
|
||||
fs::read(&compressed).unwrap(),
|
||||
original,
|
||||
"status is read-only"
|
||||
);
|
||||
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(result.status.scan_complete);
|
||||
assert!(!result.status.needs_sync);
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert_eq!(thread_provider(&database, &id), SHARED_SESSION_PROVIDER);
|
||||
let expected = String::from_utf8(text).unwrap().replace(
|
||||
"\"model_provider\":\"openai\"",
|
||||
"\"model_provider\":\"custom\"",
|
||||
);
|
||||
assert_eq!(decoded(&compressed), expected.as_bytes());
|
||||
let stored: String = Connection::open(&database)
|
||||
.unwrap()
|
||||
.query_row(
|
||||
"SELECT rollout_path FROM threads WHERE id=?1",
|
||||
[&id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stored, logical.display().to_string());
|
||||
assert!(
|
||||
!logical.exists(),
|
||||
"sync must not expand or rename logical storage"
|
||||
);
|
||||
let next = fs::read(&compressed).unwrap();
|
||||
let again = sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert_eq!(again.updated_rollouts, 0);
|
||||
assert_eq!(again.updated_threads, 0);
|
||||
assert!(again.backup_dir.is_empty());
|
||||
assert_eq!(
|
||||
fs::read(&compressed).unwrap(),
|
||||
next,
|
||||
"matched compressed bytes stay identical"
|
||||
);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_rollout_wins_over_stale_compressed_sibling() {
|
||||
let (dir, id, logical, compressed) = indexed_compressed_fixture("zst-plain-priority");
|
||||
let stale = fs::read(&compressed).unwrap();
|
||||
write_rollout_at(&logical, &id, "openai");
|
||||
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(
|
||||
result.status.scan_complete,
|
||||
"{:?}",
|
||||
result.status.scan_failures
|
||||
);
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert_eq!(result.status.rollout_files, 1);
|
||||
assert_eq!(fs::read(&compressed).unwrap(), stale);
|
||||
assert!(fs::read_to_string(&logical)
|
||||
.unwrap()
|
||||
.contains("\"model_provider\":\"custom\""));
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_archive_and_internal_threads_are_not_promoted_or_modified() {
|
||||
let (dir, id, logical, compressed) = indexed_compressed_fixture("zst-hidden-threads");
|
||||
let database = dir.join("state_5.sqlite");
|
||||
let archived_id = "01a0e854-0000-7000-8000-000000000002";
|
||||
let archived_logical = dir.join(format!(
|
||||
"archived_sessions/rollout-test-{archived_id}.jsonl"
|
||||
));
|
||||
write_rollout_at(&archived_logical, archived_id, "openai");
|
||||
let archived = compress_rollout(&archived_logical);
|
||||
let internal_id = "01a0e854-0000-7000-8000-000000000003";
|
||||
let internal_logical = write_rollout(&dir, internal_id, "openai");
|
||||
fs::write(&internal_logical, format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{internal_id}\",\"source\":{{\"internal\":\"guardian\"}},\"model_provider\":\"openai\"}}}}\n")).unwrap();
|
||||
let internal = compress_rollout(&internal_logical);
|
||||
let archive_bytes = fs::read(&archived).unwrap();
|
||||
let internal_bytes = fs::read(&internal).unwrap();
|
||||
let conn = Connection::open(&database).unwrap();
|
||||
for (thread, path, archived) in [
|
||||
(archived_id, &archived_logical, 1),
|
||||
(internal_id, &internal_logical, 0),
|
||||
] {
|
||||
conn.execute("INSERT INTO threads(id,model_provider,rollout_path,archived) VALUES(?1,'openai',?2,?3)", (thread, path.display().to_string(), archived)).unwrap();
|
||||
}
|
||||
drop(conn);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[]);
|
||||
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(
|
||||
result.status.scan_complete,
|
||||
"{:?}",
|
||||
result.status.scan_failures
|
||||
);
|
||||
assert!(!result.status.needs_sync);
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert_eq!(thread_provider(&database, &id), SHARED_SESSION_PROVIDER);
|
||||
assert_eq!(thread_provider(&database, internal_id), "openai");
|
||||
assert_eq!(thread_provider(&database, archived_id), "openai");
|
||||
assert_eq!(fs::read(&archived).unwrap(), archive_bytes);
|
||||
assert_eq!(fs::read(&internal).unwrap(), internal_bytes);
|
||||
assert!(catalog_provider_and_visibility(&catalog, internal_id).is_none());
|
||||
assert!(catalog_provider_and_visibility(&catalog, archived_id).is_none());
|
||||
assert_eq!(
|
||||
result
|
||||
.status
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|row| row.id == internal_id)
|
||||
.unwrap()
|
||||
.is_subagent,
|
||||
true
|
||||
);
|
||||
assert!(!logical.exists());
|
||||
assert!(compressed.exists());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_revert_uses_thread_id_and_current_alias_for_internal_classification() {
|
||||
let dir = temp_codex_dir("zst-revert-identity");
|
||||
write_config(&dir, SHARED_SESSION_PROVIDER);
|
||||
let thread = "01a0e854-0000-7000-8000-000000000004";
|
||||
let rollout = "01a0e854-0000-7000-8000-000000000005";
|
||||
let old = write_rollout(&dir, thread, "openai");
|
||||
fs::write(&old, format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{thread}\",\"source\":\"subagent\",\"model_provider\":\"openai\"}}}}\n")).unwrap();
|
||||
let old = compress_rollout(&old);
|
||||
let old_bytes = fs::read(&old).unwrap();
|
||||
let current = dir.join(format!(
|
||||
"sessions/2026/10/01/rollout-2026-10-01-{thread}_{rollout}.jsonl"
|
||||
));
|
||||
write_rollout_at(¤t, thread, "openai");
|
||||
let compressed = compress_rollout(¤t);
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, thread, "openai", ¤t);
|
||||
let catalog = dir.join("sqlite/codex-dev.db");
|
||||
create_catalog_database(&catalog, &[]);
|
||||
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(
|
||||
result.status.scan_complete,
|
||||
"{:?}",
|
||||
result.status.scan_failures
|
||||
);
|
||||
assert_eq!(result.updated_rollouts, 1);
|
||||
assert_eq!(thread_provider(&database, thread), SHARED_SESSION_PROVIDER);
|
||||
assert!(catalog_provider_and_visibility(&catalog, rollout).is_none());
|
||||
assert_eq!(fs::read(&old).unwrap(), old_bytes);
|
||||
assert!(String::from_utf8(decoded(&compressed))
|
||||
.unwrap()
|
||||
.contains("\"model_provider\":\"custom\""));
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_or_truncated_compressed_reference_never_reports_synced() {
|
||||
for invalid in [b"not zstandard".to_vec(), {
|
||||
let mut compressed = zstd::stream::encode_all(&b"{\"type\":\"session_meta\",\"payload\":{\"id\":\"t\",\"model_provider\":\"custom\"}}\n"[..], 1).unwrap();
|
||||
compressed.truncate(compressed.len() - 4);
|
||||
compressed
|
||||
}] {
|
||||
let (dir, id, _logical, compressed) = indexed_compressed_fixture("zst-corrupt");
|
||||
fs::write(&compressed, &invalid).unwrap();
|
||||
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(!status.scan_complete);
|
||||
assert!(!status.scan_failures.is_empty());
|
||||
sync_sessions_provider_inner(Some(dir.display().to_string()), None)
|
||||
.expect_err("invalid compressed data blocks every mutation");
|
||||
assert_eq!(thread_provider(&dir.join("state_5.sqlite"), &id), "openai");
|
||||
assert_eq!(fs::read(&compressed).unwrap(), invalid);
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_transaction_rollback_restores_exact_original_compressed_bytes() {
|
||||
let (dir, id, _logical, compressed) = indexed_compressed_fixture("zst-rollback");
|
||||
let original = fs::read(&compressed).unwrap();
|
||||
let error = sync_sessions_provider_with_hook(Some(dir.display().to_string()), None, |point| {
|
||||
if point == MutationPoint::AfterSqliteCommit(0) {
|
||||
assert_ne!(fs::read(&compressed).unwrap(), original);
|
||||
return Err(CodexxError::Config("fixture rollback".into()));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("fixture rollback"));
|
||||
assert_eq!(fs::read(&compressed).unwrap(), original);
|
||||
assert_eq!(thread_provider(&dir.join("state_5.sqlite"), &id), "openai");
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_source_replacement_or_expansion_during_sync_aborts_without_overwrite() {
|
||||
for expansion in [false, true] {
|
||||
for point_to_change in [
|
||||
MutationPoint::BeforeRolloutMutation,
|
||||
MutationPoint::BeforeEachRolloutMutation(0),
|
||||
] {
|
||||
let (dir, id, logical, compressed) =
|
||||
indexed_compressed_fixture("zst-concurrent-representation");
|
||||
let original = fs::read(&compressed).unwrap();
|
||||
let external = format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"model_provider\":\"external\"}}}}\n").into_bytes();
|
||||
let external_compressed = zstd::stream::encode_all(external.as_slice(), 1).unwrap();
|
||||
sync_sessions_provider_with_hook(Some(dir.display().to_string()), None, |point| {
|
||||
if point == point_to_change {
|
||||
if expansion {
|
||||
fs::write(&logical, &external).unwrap();
|
||||
} else {
|
||||
fs::write(&compressed, &external_compressed).unwrap();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("external replacement/expansion must abort synchronization");
|
||||
assert_eq!(thread_provider(&dir.join("state_5.sqlite"), &id), "openai");
|
||||
if expansion {
|
||||
assert_eq!(fs::read(&logical).unwrap(), external);
|
||||
assert_eq!(fs::read(&compressed).unwrap(), original);
|
||||
} else {
|
||||
assert_eq!(fs::read(&compressed).unwrap(), external_compressed);
|
||||
}
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_reference_with_wrong_metadata_id_blocks_sync_without_uuid_reassociation() {
|
||||
let (dir, id, logical, compressed) = indexed_compressed_fixture("zst-wrong-identity");
|
||||
let other = "01a0e854-0000-7000-8000-000000000009";
|
||||
let wrong = format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{other}\",\"model_provider\":\"openai\"}}}}\n");
|
||||
let bytes = zstd::stream::encode_all(wrong.as_bytes(), 1).unwrap();
|
||||
fs::write(&compressed, &bytes).unwrap();
|
||||
// A different file with the right ID must not replace an explicit current alias.
|
||||
let fallback = dir.join(format!("sessions/history/rollout-old-{id}.jsonl"));
|
||||
write_rollout_at(&fallback, &id, "openai");
|
||||
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(!status.scan_complete);
|
||||
assert!(status
|
||||
.scan_failures
|
||||
.iter()
|
||||
.any(|failure| failure.contains("线程 ID 不一致")));
|
||||
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap_err();
|
||||
assert_eq!(fs::read(&compressed).unwrap(), bytes);
|
||||
assert_eq!(thread_provider(&dir.join("state_5.sqlite"), &id), "openai");
|
||||
assert!(!logical.exists());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_reference_moved_to_compressed_archive_is_reported_as_archive_conflict() {
|
||||
let (dir, id, logical, compressed) = indexed_compressed_fixture("zst-archive-conflict");
|
||||
let archived = dir
|
||||
.join("archived_sessions")
|
||||
.join(compressed.file_name().unwrap());
|
||||
fs::create_dir_all(archived.parent().unwrap()).unwrap();
|
||||
fs::rename(&compressed, &archived).unwrap();
|
||||
let bytes = fs::read(&archived).unwrap();
|
||||
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert!(!status.scan_complete);
|
||||
assert!(status
|
||||
.scan_failures
|
||||
.iter()
|
||||
.any(|failure| failure.contains("归档状态不一致")
|
||||
&& failure.contains(&id)
|
||||
&& failure.contains(&logical.display().to_string())
|
||||
&& failure.contains(&archived.display().to_string())));
|
||||
assert!(!status
|
||||
.scan_failures
|
||||
.iter()
|
||||
.any(|failure| failure.contains("均缺失")));
|
||||
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap_err();
|
||||
assert_eq!(fs::read(&archived).unwrap(), bytes);
|
||||
assert_eq!(thread_provider(&dir.join("state_5.sqlite"), &id), "openai");
|
||||
assert!(!logical.exists());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_expansion_after_commit_preserves_external_plain_log_and_retains_backup() {
|
||||
let (dir, id, logical, compressed) = indexed_compressed_fixture("zst-expansion-after-commit");
|
||||
let external = format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"model_provider\":\"external\"}}}}\n");
|
||||
let error = sync_sessions_provider_with_hook(Some(dir.display().to_string()), None, |point| {
|
||||
if point == MutationPoint::AfterSqliteCommit(0) {
|
||||
fs::write(&logical, external.as_bytes()).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("postcommit expansion must not be mistaken for a successful synchronization");
|
||||
assert!(error.to_string().contains("变化") || error.to_string().contains("回滚"));
|
||||
assert_eq!(fs::read(&logical).unwrap(), external.as_bytes());
|
||||
assert_eq!(thread_provider(&dir.join("state_5.sqlite"), &id), "openai");
|
||||
assert!(compressed.exists());
|
||||
let backups = crate::sessions::provider_sync_backup_root(&dir);
|
||||
assert!(fs::read_dir(backups).unwrap().next().is_some());
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_can_really_switch_during_sync_while_session_target_stays_fixed() {
|
||||
let _app_db_guard = crate::app_db::test_db_guard();
|
||||
let dir = temp_codex_dir("provider-switch-during-session-sync");
|
||||
write_config(&dir, "openai");
|
||||
let id = "01a0e854-0000-7000-8000-000000000011";
|
||||
let logical = write_rollout(&dir, id, "historical-provider");
|
||||
let compressed = compress_rollout(&logical);
|
||||
let database = dir.join("state_5.sqlite");
|
||||
create_thread_database_with_rollout(&database, id, "historical-provider", &logical);
|
||||
let mut switched = false;
|
||||
let result = sync_sessions_provider_with_hook(Some(dir.display().to_string()), None, |point| {
|
||||
if point == MutationPoint::BeforeSqliteLock {
|
||||
crate::providers::switch_provider_inner(crate::providers::ProviderInput {
|
||||
config_dir: Some(dir.display().to_string()),
|
||||
provider_id: Some("session-sync-switch-fixture".into()),
|
||||
provider_name: "Session sync switch fixture".into(),
|
||||
base_url: "https://session-sync-fixture.invalid/v1".into(),
|
||||
model: "fixture-model".into(),
|
||||
api_key: Some("fixture-key-never-sent".into()),
|
||||
wire_api: Some("responses".into()),
|
||||
requires_openai_auth: None,
|
||||
})?;
|
||||
assert_eq!(current_model_provider(&dir, None)?, "custom");
|
||||
switched = true;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
switched,
|
||||
"the real provider switch ran while the maintenance lock was held"
|
||||
);
|
||||
assert_eq!(result.status.target_provider, "openai");
|
||||
assert!(!result.status.needs_sync);
|
||||
assert_eq!(thread_provider(&database, id), "openai");
|
||||
assert!(String::from_utf8(decoded(&compressed))
|
||||
.unwrap()
|
||||
.contains("\"model_provider\":\"openai\""));
|
||||
assert_eq!(current_model_provider(&dir, None).unwrap(), "custom");
|
||||
let current = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
|
||||
assert_eq!(current.target_provider, "custom");
|
||||
assert!(
|
||||
current.needs_sync,
|
||||
"a new explicit status read sees the new target, without stale scan caching"
|
||||
);
|
||||
let live_config = fs::read_to_string(dir.join("config.toml")).unwrap();
|
||||
assert!(live_config.contains("https://session-sync-fixture.invalid/v1"));
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||