mirror of
https://github.com/yynxxxxx/Codex-X.git
synced 2026-10-18 05:59:23 +08:00
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 |
@@ -177,7 +177,7 @@ jobs:
|
||||
- name: Windows x64
|
||||
platform: windows-latest
|
||||
macos: false
|
||||
args: --bundles msi
|
||||
args: --bundles nsis
|
||||
asset_pattern: Codex-X-[version]-windows-x64[ext]
|
||||
|
||||
steps:
|
||||
@@ -186,6 +186,16 @@ jobs:
|
||||
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'
|
||||
run: |
|
||||
@@ -221,9 +231,33 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check frontend types and regressions
|
||||
run: |
|
||||
pnpm --dir apps/desktop typecheck
|
||||
node --test apps/desktop/tests/*.test.mjs
|
||||
|
||||
- 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'
|
||||
run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked -- --test-threads=1
|
||||
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
|
||||
@@ -316,7 +350,7 @@ jobs:
|
||||
releaseAssetNamePattern: ${{ matrix.asset_pattern }}
|
||||
uploadUpdaterJson: true
|
||||
uploadUpdaterSignatures: true
|
||||
updaterJsonPreferNsis: false
|
||||
updaterJsonPreferNsis: true
|
||||
retryAttempts: 3
|
||||
|
||||
- name: Verify updater signatures were generated
|
||||
@@ -328,6 +362,14 @@ jobs:
|
||||
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: |
|
||||
@@ -355,11 +397,12 @@ jobs:
|
||||
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.
|
||||
@@ -434,6 +477,7 @@ jobs:
|
||||
--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
|
||||
|
||||
|
||||
@@ -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
|
||||
+55
-1
@@ -2,7 +2,61 @@
|
||||
|
||||
记录 Codex-X 的新功能、体验改进和问题修复。
|
||||
|
||||
## [Unreleased]
|
||||
## [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
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center"><b>跨平台使用</b></td>
|
||||
<td align="left">提供 macOS Apple Silicon / Intel、Windows MSI / 便携版和 Linux 安装包;安装版可在应用内直接下载、校验并安装更新,便携版继续使用手动下载。</td>
|
||||
<td align="left">提供 macOS Apple Silicon / Intel、Windows EXE / 便携版和 Linux 安装包;安装版可在应用内直接下载、校验并安装更新,便携版继续使用手动下载。</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -238,7 +238,8 @@ Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可
|
||||
- 保存多个可命名的官方 Codex 登录配置与第三方供应商,随时查看当前正在使用哪一个
|
||||
- 点击“复制”直接新增独立副本,无需确认或进入编辑页;名称可稍后修改
|
||||
- 切换前可检测连接,并可获取模型进行测试
|
||||
- 在同一页面编辑 Base URL、API Key、Model、Wire API 和完整 TOML
|
||||
- 在同一页面编辑 Base URL、API Key、Model、上游接口协议、Headers 和完整 TOML
|
||||
- 本地路由支持 Responses、Chat Completions、Claude Messages、Gemini 原生接口,使用方式与边界见[供应商协议说明](docs/PROVIDER_PROTOCOLS.md)
|
||||
- 从 cc-switch 导入时自动区分新增、更新、合并与跳过;相同 URL + Key 不再重复显示
|
||||
- 切回 OpenAI Official 时保留当前官方登录态,第三方配置也不会凭空消失
|
||||
|
||||
@@ -335,7 +336,7 @@ Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可
|
||||
|
||||
- macOS Apple Silicon `.dmg`
|
||||
- macOS Intel `.dmg`
|
||||
- Windows `.msi`
|
||||
- Windows `.exe`(安装到当前用户,后续更新无需管理员权限)
|
||||
- Windows Portable `.zip`
|
||||
- Linux `.deb` / `.rpm` / `.AppImage`
|
||||
- GitHub Releases 自动构建发布
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "codex-x",
|
||||
"version": "0.3.17",
|
||||
"version": "0.3.27",
|
||||
"private": true,
|
||||
"description": "Codex Switch & Instruct desktop manager",
|
||||
"type": "module",
|
||||
|
||||
Generated
+152
-1
@@ -438,6 +438,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -490,13 +492,17 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "codex-x"
|
||||
version = "0.3.17"
|
||||
version = "0.3.27"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"flate2",
|
||||
"getrandom 0.2.17",
|
||||
"httparse",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"rfd",
|
||||
"rusqlite",
|
||||
"rustls",
|
||||
"semver",
|
||||
@@ -508,10 +514,15 @@ dependencies = [
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml_edit 0.22.27",
|
||||
"tungstenite",
|
||||
"ureq",
|
||||
"windows-sys 0.61.2",
|
||||
"zip 2.4.2",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -709,6 +720,12 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.12"
|
||||
@@ -1953,6 +1970,16 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.103"
|
||||
@@ -2679,6 +2706,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pollster"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -2694,6 +2727,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "precomputed-hash"
|
||||
version = "0.1.1"
|
||||
@@ -2791,6 +2833,35 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -2917,6 +2988,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.17.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"percent-encoding",
|
||||
"pollster",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3338,6 +3433,17 @@ dependencies = [
|
||||
"stable_deref_trait",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -4344,6 +4450,23 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -5606,6 +5729,34 @@ dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882"
|
||||
dependencies = [
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-sys"
|
||||
version = "2.1.0+zstd.1.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.13.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "codex-x"
|
||||
version = "0.3.17"
|
||||
version = "0.3.27"
|
||||
description = "Codex Switch & Instruct desktop manager"
|
||||
authors = ["yynxxxxx"]
|
||||
license = "MIT"
|
||||
@@ -19,7 +19,7 @@ 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"] }
|
||||
@@ -38,4 +38,18 @@ 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"] }
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
"core:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"updater:allow-check",
|
||||
"updater:allow-download",
|
||||
"updater:allow-install",
|
||||
"updater:allow-download-and-install",
|
||||
"process:allow-restart"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
const APP_DB_SCHEMA_VERSION: i64 = 5;
|
||||
const APP_DB_SCHEMA_VERSION: i64 = 9;
|
||||
|
||||
struct DatabaseInitializer {
|
||||
migration_lock: Mutex<()>,
|
||||
@@ -149,6 +149,7 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
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,
|
||||
@@ -208,6 +209,14 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
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,
|
||||
@@ -220,6 +229,11 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
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()))?;
|
||||
@@ -247,6 +261,12 @@ fn initialize_schema(conn: &Connection) -> Result<()> {
|
||||
"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)
|
||||
@@ -392,7 +412,17 @@ mod tests {
|
||||
PRAGMA user_version = 4;").unwrap();
|
||||
drop(legacy);
|
||||
let migrated = DatabaseInitializer::new().open_at(&path).unwrap();
|
||||
assert_eq!(schema_version(&migrated).unwrap(), 5);
|
||||
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());
|
||||
@@ -416,6 +446,180 @@ mod tests {
|
||||
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");
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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__)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -30,5 +30,3 @@ 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/";
|
||||
|
||||
pub(crate) const MAX_SKILL_ZIP_BYTES: u64 = 20 * 1024 * 1024;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
Manager, WindowEvent,
|
||||
Emitter, Manager, WindowEvent,
|
||||
};
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
@@ -123,7 +123,46 @@ pub(crate) fn handle_window_event(window: &tauri::Window, event: &WindowEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"], "");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
@@ -14,18 +14,24 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
mod app_db;
|
||||
mod app_update;
|
||||
mod app_update_channel;
|
||||
mod backups;
|
||||
mod ccswitch;
|
||||
mod codex_language;
|
||||
mod config_health;
|
||||
mod config_migration;
|
||||
mod constants;
|
||||
mod context_config;
|
||||
mod desktop_lifecycle;
|
||||
mod error;
|
||||
mod failover;
|
||||
mod file_io;
|
||||
mod live_config;
|
||||
mod paths;
|
||||
mod platform;
|
||||
mod prompts;
|
||||
mod provider_balance;
|
||||
mod providers;
|
||||
mod remote;
|
||||
mod sessions;
|
||||
@@ -33,6 +39,7 @@ mod skills_mcp;
|
||||
mod sqlite_utils;
|
||||
mod state;
|
||||
mod toml_utils;
|
||||
mod transfers;
|
||||
mod updates;
|
||||
mod usage;
|
||||
|
||||
@@ -92,8 +99,8 @@ use providers::{
|
||||
upsert_provider_on_connection, CcSwitchCodexRow, ProviderUpsertKind, ProviderUpsertMode,
|
||||
};
|
||||
use providers::{
|
||||
build_provider_toml_draft_inner, clear_active_provider_on_connection,
|
||||
delete_saved_provider_inner, fetch_provider_models_inner,
|
||||
build_provider_toml_draft_inner, build_provider_toml_draft_with_origin_inner,
|
||||
clear_active_provider_on_connection, delete_saved_provider_inner, fetch_provider_models_inner,
|
||||
import_ccswitch_codex_providers_inner, list_saved_providers_inner, open_store,
|
||||
read_ccswitch_official_auth_inner, remember_active_provider_on_connection,
|
||||
save_active_provider_inner, save_official_config_inner, save_provider_inner,
|
||||
@@ -234,6 +241,12 @@ pub(crate) fn sanitize_id(input: &str) -> String {
|
||||
}
|
||||
|
||||
fn default_codex_dir() -> Result<PathBuf> {
|
||||
// Explicit app-only override for isolated native validation and portable setups.
|
||||
if let Ok(value) = std::env::var("CODEXX_CONFIG_DIR") {
|
||||
if let Some(path) = codex_dir_from_text(&value)? {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
if let Ok(value) = std::env::var("CODEX_HOME") {
|
||||
if let Some(path) = codex_dir_from_text(&value)? {
|
||||
return Ok(path);
|
||||
@@ -532,6 +545,99 @@ async fn get_startup_diagnostics(config_dir: Option<String>) -> Result<StartupDi
|
||||
.map_err(|e| CodexxError::Config(format!("启动检测失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_codex_config(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<config_health::ConfigHealthReport> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
config_health::check_codex_config_inner(config_dir)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("检查配置失败,请稍后重试。".to_string()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn repair_codex_config(
|
||||
config_dir: Option<String>,
|
||||
expected_fingerprint: String,
|
||||
) -> Result<config_health::ConfigHealthRepairResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
config_health::repair_codex_config_inner(config_dir, expected_fingerprint)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("修复配置失败,请稍后重试。".to_string()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_codex_config_file(config_dir: Option<String>) -> Result<()> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
config_health::open_codex_config_file_inner(config_dir)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("打开配置文件失败,请稍后重试。".to_string()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn refresh_provider_balance(
|
||||
config_dir: Option<String>,
|
||||
source: String,
|
||||
provider_id: String,
|
||||
) -> Result<provider_balance::ProviderBalanceResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
provider_balance::refresh_provider_balance_inner(config_dir, source, provider_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("刷新供应商余额失败,请稍后重试。".into()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_provider_balances(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<Vec<provider_balance::ProviderBalanceResult>> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
provider_balance::get_provider_balances_inner(config_dir)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("读取供应商余额失败,请稍后重试。".into()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_codex_language_status(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<codex_language::LanguageStatus> {
|
||||
tauri::async_runtime::spawn_blocking(move || codex_language::get_status(config_dir))
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("读取 Codex 界面语言状态失败,请重试。".into()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_codex_language_preferences(
|
||||
config_dir: Option<String>,
|
||||
locale: codex_language::Locale,
|
||||
enabled: bool,
|
||||
) -> Result<codex_language::LanguageStatus> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
codex_language::save_preferences(config_dir, locale, enabled)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("保存 Codex 界面语言偏好失败,请重试。".into()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn apply_codex_language_preferences(
|
||||
config_dir: Option<String>,
|
||||
restore: bool,
|
||||
) -> Result<codex_language::LanguageStatus> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
sessions::with_session_maintenance(&codex_dir, || {
|
||||
codex_language::apply(config_dir, restore)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("应用 Codex 界面语言失败,请查看状态后重试。".into()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_session_sync_status(
|
||||
config_dir: Option<String>,
|
||||
@@ -574,9 +680,15 @@ async fn read_ccswitch_official_auth(
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_ccswitch_codex_providers(db_path: Option<String>) -> Result<ImportResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || import_ccswitch_codex_providers_inner(db_path))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("导入 cc-switch Provider 失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = import_ccswitch_codex_providers_inner(db_path)?;
|
||||
failover::refresh_saved_routes().map_err(|error| {
|
||||
CodexxError::Config(format!("供应商已导入,但自动切换状态更新失败:{error}"))
|
||||
})?;
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("导入 cc-switch Provider 失败: {e}")))?
|
||||
}
|
||||
|
||||
fn get_about_info_inner(config_dir: Option<String>) -> Result<AboutInfo> {
|
||||
@@ -611,11 +723,18 @@ async fn get_about_info(config_dir: Option<String>) -> Result<AboutInfo> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn restart_codex_desktop() -> std::result::Result<CodexDesktopRestartResult, String> {
|
||||
let (app_name, was_running) =
|
||||
tauri::async_runtime::spawn_blocking(platform::restart_codex_desktop)
|
||||
.await
|
||||
.map_err(|error| format!("重启 Codex 桌面客户端失败: {error}"))??;
|
||||
async fn restart_codex_desktop(
|
||||
config_dir: Option<String>,
|
||||
) -> std::result::Result<CodexDesktopRestartResult, String> {
|
||||
let (app_name, was_running) = tauri::async_runtime::spawn_blocking(move || {
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone()).map_err(|e| e.to_string())?;
|
||||
sessions::with_session_maintenance(&codex_dir, || {
|
||||
codex_language::restart_client(config_dir)
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("重启 Codex 桌面客户端失败: {error}"))??;
|
||||
Ok(CodexDesktopRestartResult {
|
||||
app_name,
|
||||
was_running,
|
||||
@@ -896,6 +1015,25 @@ async fn list_saved_providers() -> Result<Vec<SavedProvider>> {
|
||||
.map_err(|e| CodexxError::Config(format!("读取供应商列表失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_provider_order(config_dir: Option<String>) -> Result<Vec<String>> {
|
||||
tauri::async_runtime::spawn_blocking(move || providers::get_provider_order_inner(config_dir))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("读取供应商排序失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_provider_order(
|
||||
config_dir: Option<String>,
|
||||
order: Vec<String>,
|
||||
) -> Result<Vec<String>> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
providers::save_provider_order_inner(config_dir, order)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("保存供应商排序失败: {error}")))?
|
||||
}
|
||||
|
||||
enum ActiveProviderSelectionUpdate {
|
||||
Set(String),
|
||||
ClearIfOfficial,
|
||||
@@ -944,20 +1082,51 @@ fn finish_provider_selection(
|
||||
result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_provider_config_base(config_dir: Option<String>) -> Result<String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
providers::get_provider_config_base_inner(config_dir)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("读取供应商通用配置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn build_provider_toml_draft(
|
||||
provider: SavedProvider,
|
||||
config_dir: Option<String>,
|
||||
new_provider: Option<bool>,
|
||||
) -> Result<String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
build_provider_toml_draft_inner(provider, config_dir)
|
||||
if new_provider.unwrap_or(false) {
|
||||
build_provider_toml_draft_with_origin_inner(provider, config_dir, true)
|
||||
} else {
|
||||
build_provider_toml_draft_inner(provider, config_dir)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("生成供应商 TOML 失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_provider_headers(config_text: String) -> Result<Vec<providers::ProviderHeader>> {
|
||||
providers::read_provider_headers_inner(config_text)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_provider_headers(
|
||||
config_text: String,
|
||||
headers: Vec<providers::ProviderHeader>,
|
||||
) -> Result<String> {
|
||||
providers::update_provider_headers_inner(config_text, headers)
|
||||
}
|
||||
|
||||
fn save_provider_command_inner(provider: SavedProvider) -> Result<SavedProvider> {
|
||||
save_provider_inner(provider)
|
||||
let saved = save_provider_inner(provider)?;
|
||||
failover::refresh_saved_routes().map_err(|error| {
|
||||
CodexxError::Config(format!("供应商已保存,但自动切换状态更新失败:{error}"))
|
||||
})?;
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1003,17 +1172,51 @@ async fn get_usage_statistics(
|
||||
.map_err(|error| CodexxError::Config(format!("读取用量统计失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_provider_failover(config_dir: Option<String>) -> Result<failover::FailoverStatus> {
|
||||
tauri::async_runtime::spawn_blocking(move || failover::get_status(config_dir))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("读取自动切换设置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_provider_failover(
|
||||
config_dir: Option<String>,
|
||||
settings: failover::FailoverSettings,
|
||||
) -> Result<failover::FailoverStatus> {
|
||||
tauri::async_runtime::spawn_blocking(move || failover::save_settings(config_dir, settings))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("保存自动切换设置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn reset_provider_failover_health(
|
||||
config_dir: Option<String>,
|
||||
provider_id: Option<String>,
|
||||
) -> Result<failover::FailoverStatus> {
|
||||
tauri::async_runtime::spawn_blocking(move || failover::reset_health(config_dir, provider_id))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("重置供应商健康状态失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn activate_saved_provider(
|
||||
config_dir: Option<String>,
|
||||
provider_id: String,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = providers::activate_saved_provider_inner(config_dir, provider_id.clone())?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
))
|
||||
let provider = providers::list_saved_providers_inner()?
|
||||
.into_iter()
|
||||
.find(|provider| provider.id == provider_id)
|
||||
.ok_or_else(|| CodexxError::Config("供应商不存在".to_string()))?;
|
||||
failover::ensure_conversion_route(config_dir.clone(), &provider)?;
|
||||
failover::with_provider_change(config_dir.clone(), || {
|
||||
let result = providers::activate_saved_provider_inner(config_dir, provider_id.clone())?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("启用供应商失败: {error}")))?
|
||||
@@ -1023,14 +1226,28 @@ async fn activate_saved_provider(
|
||||
async fn save_active_provider(
|
||||
provider: SavedProvider,
|
||||
config_dir: Option<String>,
|
||||
apply_common_config: Option<bool>,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let provider_id = provider.id.clone();
|
||||
let result = save_active_provider_inner(provider, config_dir)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
))
|
||||
let provider =
|
||||
providers::normalize_saved_provider_for_save(&providers::open_store()?, provider)?;
|
||||
failover::ensure_conversion_route(config_dir.clone(), &provider)?;
|
||||
let result = failover::with_provider_change(config_dir.clone(), || {
|
||||
let provider_id = provider.id.clone();
|
||||
let result = if apply_common_config.unwrap_or(false) {
|
||||
providers::save_active_provider_with_common_config_inner(provider, config_dir)?
|
||||
} else {
|
||||
save_active_provider_inner(provider, config_dir)?
|
||||
};
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
))
|
||||
})?;
|
||||
failover::refresh_saved_routes().map_err(|error| {
|
||||
CodexxError::Config(format!("供应商已保存,但自动切换状态更新失败:{error}"))
|
||||
})?;
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存活动供应商失败: {e}")))?
|
||||
@@ -1038,9 +1255,32 @@ async fn save_active_provider(
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_saved_provider(id: String, config_dir: Option<String>) -> Result<()> {
|
||||
tauri::async_runtime::spawn_blocking(move || delete_saved_provider_inner(id.trim(), config_dir))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("删除供应商失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
delete_saved_provider_inner(id.trim(), config_dir)?;
|
||||
failover::refresh_saved_routes().map_err(|error| {
|
||||
CodexxError::Config(format!("供应商已删除,但自动切换状态更新失败:{error}"))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("删除供应商失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn repair_provider_model_catalog(
|
||||
config_dir: Option<String>,
|
||||
) -> Result<providers::model_catalog::ProviderModelCatalogRepairResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
let changed = providers::model_catalog::upgrade_owned_model_catalog(&codex_dir)?;
|
||||
Ok(providers::model_catalog::ProviderModelCatalogRepairResult {
|
||||
changed,
|
||||
message: changed.then(|| {
|
||||
"已修复供应商模型菜单的图片输入和思考强度,请重启 Codex 后使用".to_string()
|
||||
}),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("修复供应商模型菜单失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1058,14 +1298,16 @@ fn get_codex_state_inner(config_dir: Option<String>) -> Result<CodexState> {
|
||||
#[tauri::command]
|
||||
async fn switch_official_provider(config_dir: Option<String>) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = switch_official_profile_inner(
|
||||
config_dir,
|
||||
providers::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID.to_string(),
|
||||
)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
failover::with_provider_change(config_dir.clone(), || {
|
||||
let result = switch_official_profile_inner(
|
||||
config_dir,
|
||||
providers::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID.to_string(),
|
||||
)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("切换官方配置失败: {e}")))?
|
||||
@@ -1114,9 +1356,26 @@ async fn get_official_profile(
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_official_profile(input: OfficialProfileInput) -> Result<OfficialProfileActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || save_official_profile_inner(input))
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("保存官方配置失败: {error}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
// Saving a separate account is a library edit, not a manual route change.
|
||||
let applies_live = match input.id.as_ref() {
|
||||
Some(id) => {
|
||||
get_official_profile_inner(input.config_dir.clone(), id.clone())?
|
||||
.profile
|
||||
.is_current
|
||||
}
|
||||
None => false,
|
||||
};
|
||||
if applies_live {
|
||||
failover::with_provider_change(input.config_dir.clone(), || {
|
||||
save_official_profile_inner(input)
|
||||
})
|
||||
} else {
|
||||
save_official_profile_inner(input)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("保存官方配置失败: {error}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1138,7 +1397,9 @@ async fn switch_official_profile(
|
||||
profile_id: String,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
switch_official_profile_inner(config_dir, profile_id)
|
||||
failover::with_provider_change(config_dir.clone(), || {
|
||||
switch_official_profile_inner(config_dir, profile_id)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|error| CodexxError::Config(format!("切换官方配置失败: {error}")))?
|
||||
@@ -1156,11 +1417,13 @@ async fn delete_official_profile(config_dir: Option<String>, profile_id: String)
|
||||
#[tauri::command]
|
||||
async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = providers::official_profiles::reset_default_official_profile_inner(input)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
failover::with_provider_change(input.config_dir.clone(), || {
|
||||
let result = providers::official_profiles::reset_default_official_profile_inner(input)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("新建官方配置失败: {e}")))?
|
||||
@@ -1169,16 +1432,18 @@ async fn reset_official_provider(input: OfficialConfigInput) -> Result<ActionRes
|
||||
#[tauri::command]
|
||||
async fn save_official_config(input: OfficialConfigInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = save_official_config_inner(
|
||||
input.config_dir,
|
||||
input.model,
|
||||
input.auth_json,
|
||||
input.config_text,
|
||||
)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
failover::with_provider_change(input.config_dir.clone(), || {
|
||||
let result = save_official_config_inner(
|
||||
input.config_dir,
|
||||
input.model,
|
||||
input.auth_json,
|
||||
input.config_text,
|
||||
)?;
|
||||
Ok(finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::ClearIfOfficial,
|
||||
))
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("保存官方配置失败: {e}")))?
|
||||
@@ -1357,18 +1622,41 @@ async fn disable_external_instruction(config_dir: Option<String>) -> Result<Acti
|
||||
.map_err(|e| CodexxError::Config(format!("禁用外部提示词失败: {e}")))?
|
||||
}
|
||||
|
||||
fn direct_provider_toml_input(mut input: ProviderTomlInput) -> Result<ProviderTomlInput> {
|
||||
let codex_dir = resolve_codex_dir(input.config_dir.clone())?;
|
||||
let doc = parse_toml_document(&config_path(&codex_dir), &input.config_text)?;
|
||||
let direct = failover::direct_document(&codex_dir, &doc)?;
|
||||
if direct.to_string() != doc.to_string() {
|
||||
let old_id = string_value(&doc, "model_provider");
|
||||
let old_token = providers::experimental_bearer_token_from_doc(&doc, old_id.as_deref());
|
||||
// Only replace the known local credential echoed back by the editor.
|
||||
// A newly entered key belongs to the user's edit and must be retained.
|
||||
if old_token.is_some() && input.api_key.as_deref().map(str::trim) == old_token.as_deref() {
|
||||
let direct_id = string_value(&direct, "model_provider");
|
||||
input.api_key =
|
||||
providers::experimental_bearer_token_from_doc(&direct, direct_id.as_deref());
|
||||
}
|
||||
input.config_text = direct.to_string();
|
||||
}
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_provider_toml_config(
|
||||
input: ProviderTomlInput,
|
||||
provider_id: Option<String>,
|
||||
) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let result = save_provider_toml_config_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => {
|
||||
finish_provider_selection(result, ActiveProviderSelectionUpdate::Set(provider_id))
|
||||
}
|
||||
None => result,
|
||||
failover::with_provider_change(input.config_dir.clone(), || {
|
||||
let input = direct_provider_toml_input(input)?;
|
||||
let result = save_provider_toml_config_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
),
|
||||
None => result,
|
||||
})
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -1379,32 +1667,61 @@ async fn save_provider_toml_config(
|
||||
async fn test_provider_connection(
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
upstream_api: Option<String>,
|
||||
config_text: Option<String>,
|
||||
) -> Result<ProviderConnectionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || test_provider_connection_inner(base_url, api_key))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("测试连接失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if upstream_api.is_none() && config_text.is_none() {
|
||||
test_provider_connection_inner(base_url, api_key)
|
||||
} else {
|
||||
providers::test_provider_connection_with_protocol_inner(
|
||||
base_url,
|
||||
api_key,
|
||||
upstream_api,
|
||||
config_text,
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("测试连接失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_provider_models(
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
upstream_api: Option<String>,
|
||||
config_text: Option<String>,
|
||||
) -> Result<ProviderModelsResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || fetch_provider_models_inner(base_url, api_key))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("获取模型列表失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
if upstream_api.is_none() && config_text.is_none() {
|
||||
fetch_provider_models_inner(base_url, api_key)
|
||||
} else {
|
||||
providers::fetch_provider_models_with_protocol_inner(
|
||||
base_url,
|
||||
api_key,
|
||||
upstream_api,
|
||||
config_text,
|
||||
)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("获取模型列表失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn switch_provider(input: ProviderInput) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let provider_id = input.provider_id.clone();
|
||||
let result = switch_provider_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => {
|
||||
finish_provider_selection(result, ActiveProviderSelectionUpdate::Set(provider_id))
|
||||
}
|
||||
None => result,
|
||||
failover::with_provider_change(input.config_dir.clone(), || {
|
||||
let provider_id = input.provider_id.clone();
|
||||
let result = switch_provider_inner(input)?;
|
||||
Ok(match provider_id {
|
||||
Some(provider_id) => finish_provider_selection(
|
||||
result,
|
||||
ActiveProviderSelectionUpdate::Set(provider_id),
|
||||
),
|
||||
None => result,
|
||||
})
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -1495,11 +1812,14 @@ fn restore_backup_inner(config_dir: Option<String>, backup_id: String) -> Result
|
||||
let backup_config = match backup_config {
|
||||
Some(bytes) => {
|
||||
let text = text_from_snapshot(&backup_cfg, Some(&bytes))?;
|
||||
Some(
|
||||
migrated_legacy_prompt_config_text(&cfg, &text)?
|
||||
.unwrap_or(text)
|
||||
.into_bytes(),
|
||||
)
|
||||
let text = migrated_legacy_prompt_config_text(&cfg, &text)?.unwrap_or(text);
|
||||
let doc = parse_toml_document(&cfg, &text)?;
|
||||
let direct = failover::direct_document(&codex_dir, &doc)?;
|
||||
Some(if direct.to_string() == doc.to_string() {
|
||||
text.into_bytes()
|
||||
} else {
|
||||
direct.to_string().into_bytes()
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
@@ -1539,9 +1859,16 @@ fn restore_backup_inner(config_dir: Option<String>, backup_id: String) -> Result
|
||||
|
||||
#[tauri::command]
|
||||
async fn restore_backup(config_dir: Option<String>, backup_id: String) -> Result<ActionResult> {
|
||||
tauri::async_runtime::spawn_blocking(move || restore_backup_inner(config_dir, backup_id))
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("恢复备份失败: {e}")))?
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
failover::with_provider_change(config_dir.clone(), || {
|
||||
let mut result = restore_backup_inner(config_dir.clone(), backup_id)?;
|
||||
failover::recover_stale_route(config_dir)?;
|
||||
result.state = build_state_after_migration(PathBuf::from(&result.state.codex_dir))?;
|
||||
Ok(result)
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| CodexxError::Config(format!("恢复备份失败: {e}")))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1590,10 +1917,20 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.setup(|app| {
|
||||
desktop_lifecycle::setup_system_tray(app)?;
|
||||
failover::attach_app_handle(app.handle().clone());
|
||||
if let Err(error) = failover::initialize() {
|
||||
desktop_lifecycle::report_failover_lifecycle_error(
|
||||
app.handle(),
|
||||
"自动切换暂时无法启动",
|
||||
&error.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(desktop_lifecycle::handle_window_event)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
app_update_channel::check_online_app_update,
|
||||
app_update::install_app_update,
|
||||
get_about_info,
|
||||
restart_codex_desktop,
|
||||
check_app_update,
|
||||
@@ -1604,8 +1941,19 @@ pub fn run() {
|
||||
toggle_codex_mcp,
|
||||
save_skills_mcp_note,
|
||||
install_skill_zip,
|
||||
transfers::import_skills_mcp_archive,
|
||||
transfers::export_skills_mcp_archive,
|
||||
transfers::export_codex_sessions,
|
||||
check_skill_updates,
|
||||
get_startup_diagnostics,
|
||||
check_codex_config,
|
||||
repair_codex_config,
|
||||
open_codex_config_file,
|
||||
refresh_provider_balance,
|
||||
get_provider_balances,
|
||||
get_codex_language_status,
|
||||
save_codex_language_preferences,
|
||||
apply_codex_language_preferences,
|
||||
get_session_sync_status,
|
||||
sync_sessions_provider,
|
||||
delete_codex_sessions,
|
||||
@@ -1621,15 +1969,24 @@ pub fn run() {
|
||||
delete_saved_prompt,
|
||||
enable_saved_prompt,
|
||||
list_saved_providers,
|
||||
get_provider_order,
|
||||
save_provider_order,
|
||||
get_provider_config_base,
|
||||
build_provider_toml_draft,
|
||||
read_provider_headers,
|
||||
update_provider_headers,
|
||||
save_provider,
|
||||
duplicate_provider,
|
||||
update_codex_context_window,
|
||||
get_usage_statistics,
|
||||
get_provider_failover,
|
||||
save_provider_failover,
|
||||
reset_provider_failover_health,
|
||||
save_active_provider,
|
||||
activate_saved_provider,
|
||||
delete_saved_provider,
|
||||
get_codex_state,
|
||||
repair_provider_model_catalog,
|
||||
switch_official_provider,
|
||||
list_official_profiles,
|
||||
get_official_profile,
|
||||
@@ -1661,3 +2018,6 @@ pub fn run() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod failover_integration_tests;
|
||||
|
||||
@@ -7,6 +7,9 @@ use std::sync::{mpsc, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[path = "codex_desktop_windows.rs"]
|
||||
pub(crate) mod desktop_windows;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::env;
|
||||
|
||||
@@ -36,7 +39,7 @@ const CODEX_DESKTOP_COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const CODEX_DESKTOP_QUIT_TIMEOUT: Duration = Duration::from_secs(8);
|
||||
#[cfg(target_os = "macos")]
|
||||
const CODEX_DESKTOP_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(test)]
|
||||
const WINDOWS_RESTART_RESULT_PREFIX: &str = "CODEX_X_DESKTOP_RESTART";
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
@@ -816,96 +819,209 @@ fn macos_main_process_ids(
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn desktop_command_output(
|
||||
program: &Path,
|
||||
args: &[&str],
|
||||
timeout: Duration,
|
||||
) -> Result<Output, String> {
|
||||
run_program_with_timeout(program, args, Some(Instant::now() + timeout), timeout)
|
||||
.ok_or_else(|| "客户端系统操作超时,未继续重启或连接客户端。".into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn wait_macos_stopped(executable: &Path) -> Result<(), String> {
|
||||
let deadline = Instant::now() + CODEX_DESKTOP_QUIT_TIMEOUT;
|
||||
let mut empty_since = None;
|
||||
while Instant::now() < deadline {
|
||||
let pids = match macos_main_process_ids(executable, Some(deadline)) {
|
||||
Ok(pids) => pids,
|
||||
Err(_) if Instant::now() >= deadline => break,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if pids.is_empty() {
|
||||
let since = empty_since.get_or_insert_with(Instant::now);
|
||||
if since.elapsed() >= Duration::from_millis(500) {
|
||||
return Ok(());
|
||||
}
|
||||
} else {
|
||||
empty_since = None;
|
||||
}
|
||||
thread::sleep(CODEX_DESKTOP_POLL_INTERVAL);
|
||||
}
|
||||
Err("Codex/ChatGPT 客户端未正常退出,已取消重新启动;请先结束当前任务后重试。".into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn launch_macos_codex_app(app: &MacosCodexApp) -> Result<(), String> {
|
||||
let deadline = Instant::now()
|
||||
.checked_add(CODEX_DESKTOP_COMMAND_TIMEOUT)
|
||||
.ok_or_else(|| "创建 Codex/ChatGPT 启动超时时间失败".to_string())?;
|
||||
let bundle_path = app
|
||||
.bundle_path
|
||||
.to_str()
|
||||
.ok_or_else(|| "Codex/ChatGPT 客户端路径不是有效文本".to_string())?;
|
||||
let output = run_program_with_timeout(
|
||||
Path::new("/usr/bin/open"),
|
||||
&["-a", bundle_path],
|
||||
Some(deadline),
|
||||
CODEX_DESKTOP_COMMAND_TIMEOUT,
|
||||
.ok_or("Codex 客户端路径不是有效文本")?;
|
||||
let mut last_error = "未确认 Codex 客户端已启动".to_owned();
|
||||
for attempt in 0..2 {
|
||||
let output = desktop_command_output(
|
||||
Path::new("/usr/bin/open"),
|
||||
&["-n", "-a", bundle_path],
|
||||
CODEX_DESKTOP_COMMAND_TIMEOUT,
|
||||
);
|
||||
let accepted = output.as_ref().is_ok_and(|output| output.status.success());
|
||||
if let Ok(output) = &output {
|
||||
if !output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_owned();
|
||||
if !message.is_empty() {
|
||||
last_error = message;
|
||||
}
|
||||
}
|
||||
} else if let Err(error) = output {
|
||||
last_error = error;
|
||||
}
|
||||
// LaunchServices can acknowledge a launch before a process appears,
|
||||
// or report a stale quit/launch error after the new process starts.
|
||||
let deadline = Instant::now()
|
||||
+ if accepted {
|
||||
CODEX_DESKTOP_QUIT_TIMEOUT
|
||||
} else {
|
||||
Duration::from_millis(750)
|
||||
};
|
||||
let mut observed = None;
|
||||
while Instant::now() < deadline {
|
||||
let pids = match macos_main_process_ids(&app.executable_path, Some(deadline)) {
|
||||
Ok(pids) => pids,
|
||||
Err(_) if Instant::now() >= deadline => break,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if !pids.is_empty() {
|
||||
let (previous, since) =
|
||||
observed.get_or_insert_with(|| (pids.clone(), Instant::now()));
|
||||
if *previous == pids && since.elapsed() >= Duration::from_millis(200) {
|
||||
return Ok(());
|
||||
}
|
||||
if *previous != pids {
|
||||
*previous = pids;
|
||||
*since = Instant::now();
|
||||
}
|
||||
} else {
|
||||
observed = None;
|
||||
}
|
||||
thread::sleep(CODEX_DESKTOP_POLL_INTERVAL);
|
||||
}
|
||||
if accepted || attempt == 1 {
|
||||
break;
|
||||
}
|
||||
// Retry only an unacknowledged launch with no surviving main process.
|
||||
// Never open a second instance after LaunchServices confirmed one.
|
||||
}
|
||||
Err(format!("启动 Codex/ChatGPT 未完成:{last_error}"))
|
||||
}
|
||||
|
||||
// Narrow helpers shared with the opt-in locale launcher. They do not quit or
|
||||
// launch anything until the explicit locale apply command calls them.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn locale_platform_command(program: &Path, args: &[&str]) -> Result<Output, String> {
|
||||
let deadline = Instant::now() + CODEX_DESKTOP_COMMAND_TIMEOUT;
|
||||
let output =
|
||||
run_program_with_timeout(program, args, Some(deadline), CODEX_DESKTOP_COMMAND_TIMEOUT)
|
||||
.ok_or("客户端操作超时,未继续应用语言兼容。")?;
|
||||
if !output.status.success() {
|
||||
return Err("客户端操作未完成,请检查官方客户端状态后重试。".into());
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn locale_desktop_app() -> Result<(PathBuf, PathBuf, String, String, String), String> {
|
||||
let deadline = Some(Instant::now() + CODEX_DESKTOP_COMMAND_TIMEOUT);
|
||||
let apps: Vec<_> = macos_codex_apps(deadline)?
|
||||
.into_iter()
|
||||
.filter(|app| {
|
||||
matches!(
|
||||
app.bundle_id.as_str(),
|
||||
"com.openai.codex" | "com.openai.codex.beta"
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut running = Vec::new();
|
||||
for app in &apps {
|
||||
if !macos_main_process_ids(&app.executable_path, deadline)?.is_empty() {
|
||||
running.push(app);
|
||||
}
|
||||
}
|
||||
if running.len() > 1 {
|
||||
return Err("检测到多个官方客户端实例,请先保留一个实例再应用界面语言。".into());
|
||||
}
|
||||
let app = running
|
||||
.first()
|
||||
.copied()
|
||||
.or_else(|| apps.first())
|
||||
.ok_or("未找到官方 Codex 客户端")?;
|
||||
let version = macos_info_plist_version(&app.bundle_path).unwrap_or_else(|| "unknown".into());
|
||||
Ok((
|
||||
app.bundle_path.clone(),
|
||||
app.executable_path.clone(),
|
||||
app.bundle_id.clone(),
|
||||
app.app_name.clone(),
|
||||
version,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn locale_desktop_pids(executable: &Path) -> Result<Vec<u32>, String> {
|
||||
macos_main_process_ids(
|
||||
executable,
|
||||
Some(Instant::now() + CODEX_DESKTOP_COMMAND_TIMEOUT),
|
||||
)
|
||||
.ok_or_else(|| "启动 Codex/ChatGPT 客户端失败或超时".to_string())?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) fn locale_quit_desktop(executable: &Path, bundle_id: &str) -> Result<(), String> {
|
||||
if !MACOS_CODEX_BUNDLE_IDS.contains(&bundle_id) {
|
||||
return Err("不支持的客户端身份".into());
|
||||
}
|
||||
if locale_desktop_pids(executable)?.is_empty() {
|
||||
return wait_macos_stopped(executable);
|
||||
}
|
||||
let requested = locale_platform_command(
|
||||
Path::new("/usr/bin/osascript"),
|
||||
&[
|
||||
"-e",
|
||||
&format!("tell application id \"{bundle_id}\" to quit"),
|
||||
],
|
||||
);
|
||||
// Apple Events may lose the connection while the application exits. An
|
||||
// error from osascript is not proof that the exact selected process lives.
|
||||
match wait_macos_stopped(executable) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) => Err(requested
|
||||
.err()
|
||||
.map(|request| format!("{request};{error}"))
|
||||
.unwrap_or(error)),
|
||||
}
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if message.is_empty() {
|
||||
"启动 Codex/ChatGPT 客户端失败".to_string()
|
||||
} else {
|
||||
format!("启动 Codex/ChatGPT 客户端失败: {message}")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn restart_codex_desktop() -> Result<(String, bool), String> {
|
||||
let detection_deadline = Instant::now()
|
||||
.checked_add(CODEX_DESKTOP_COMMAND_TIMEOUT)
|
||||
.ok_or_else(|| "创建 Codex/ChatGPT 检测超时时间失败".to_string())?;
|
||||
let apps = macos_codex_apps(Some(detection_deadline))?;
|
||||
let detection_deadline = Some(Instant::now() + CODEX_DESKTOP_COMMAND_TIMEOUT);
|
||||
let apps = macos_codex_apps(detection_deadline)?;
|
||||
let mut first_installed = None;
|
||||
let mut running_app = None;
|
||||
let mut running = Vec::new();
|
||||
for app in apps {
|
||||
let is_running =
|
||||
!macos_main_process_ids(&app.executable_path, Some(detection_deadline))?.is_empty();
|
||||
if is_running {
|
||||
running_app = Some(app);
|
||||
break;
|
||||
}
|
||||
if first_installed.is_none() {
|
||||
if !macos_main_process_ids(&app.executable_path, detection_deadline)?.is_empty() {
|
||||
running.push(app);
|
||||
} else if first_installed.is_none() {
|
||||
first_installed = Some(app);
|
||||
}
|
||||
}
|
||||
let was_running = running_app.is_some();
|
||||
let app = running_app
|
||||
if running.len() > 1 {
|
||||
return Err("检测到多个官方 Codex 客户端,请先保留一个再重启。".into());
|
||||
}
|
||||
let was_running = !running.is_empty();
|
||||
let app = running
|
||||
.pop()
|
||||
.or(first_installed)
|
||||
.ok_or_else(|| "未找到可重启的 Codex/ChatGPT 桌面客户端".to_string())?;
|
||||
if !was_running {
|
||||
launch_macos_codex_app(&app)?;
|
||||
return Ok((app.app_name, false));
|
||||
}
|
||||
|
||||
let quit_script = format!("tell application id \"{}\" to quit", app.bundle_id);
|
||||
let command_deadline = Instant::now()
|
||||
.checked_add(CODEX_DESKTOP_COMMAND_TIMEOUT)
|
||||
.ok_or_else(|| "创建 Codex/ChatGPT 退出命令超时时间失败".to_string())?;
|
||||
let output = run_program_with_timeout(
|
||||
Path::new("/usr/bin/osascript"),
|
||||
&["-e", quit_script.as_str()],
|
||||
Some(command_deadline),
|
||||
CODEX_DESKTOP_COMMAND_TIMEOUT,
|
||||
)
|
||||
.ok_or_else(|| "请求 Codex/ChatGPT 客户端正常退出失败或超时".to_string())?;
|
||||
if !output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(if message.is_empty() {
|
||||
"请求 Codex/ChatGPT 客户端正常退出失败".to_string()
|
||||
} else {
|
||||
format!("请求 Codex/ChatGPT 客户端正常退出失败: {message}")
|
||||
});
|
||||
}
|
||||
|
||||
let quit_deadline = Instant::now()
|
||||
.checked_add(CODEX_DESKTOP_QUIT_TIMEOUT)
|
||||
.ok_or_else(|| "创建 Codex/ChatGPT 退出等待时间失败".to_string())?;
|
||||
loop {
|
||||
if macos_main_process_ids(&app.executable_path, Some(quit_deadline))?.is_empty() {
|
||||
break;
|
||||
}
|
||||
if Instant::now() >= quit_deadline {
|
||||
return Err("Codex/ChatGPT 客户端未在限定时间内正常退出,已取消重新启动".to_string());
|
||||
}
|
||||
thread::sleep(CODEX_DESKTOP_POLL_INTERVAL);
|
||||
}
|
||||
|
||||
.ok_or("未找到可重启的 Codex 客户端")?;
|
||||
locale_quit_desktop(&app.executable_path, &app.bundle_id)?;
|
||||
launch_macos_codex_app(&app)?;
|
||||
Ok((app.app_name, true))
|
||||
Ok((app.app_name, was_running))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -913,7 +1029,7 @@ fn macos_app_version(_deadline: Option<Instant>) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(test)]
|
||||
fn parse_windows_restart_result(output: &str) -> Option<(String, bool)> {
|
||||
output.lines().rev().find_map(|line| {
|
||||
let payload = line
|
||||
@@ -934,7 +1050,7 @@ fn parse_windows_restart_result(output: &str) -> Option<(String, bool)> {
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
#[cfg(test)]
|
||||
fn windows_restart_script() -> String {
|
||||
let identities = WINDOWS_CODEX_PACKAGE_IDENTITIES
|
||||
.iter()
|
||||
@@ -1026,35 +1142,7 @@ $runningFlag = if ($wasRunning) { '1' } else { '0' }
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn restart_codex_desktop() -> Result<(String, bool), String> {
|
||||
let script = windows_restart_script();
|
||||
let timeout = CODEX_DESKTOP_QUIT_TIMEOUT + CODEX_DESKTOP_COMMAND_TIMEOUT;
|
||||
let deadline = Instant::now()
|
||||
.checked_add(timeout)
|
||||
.ok_or_else(|| "创建 Codex/ChatGPT 重启超时时间失败".to_string())?;
|
||||
let output = run_program_with_timeout(
|
||||
Path::new("powershell.exe"),
|
||||
&[
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
script.as_str(),
|
||||
],
|
||||
Some(deadline),
|
||||
timeout,
|
||||
)
|
||||
.ok_or_else(|| "重启 Codex/ChatGPT 客户端失败或超时".to_string())?;
|
||||
if !output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return Err(if message.is_empty() {
|
||||
"重启 Codex/ChatGPT 客户端失败".to_string()
|
||||
} else {
|
||||
format!("重启 Codex/ChatGPT 客户端失败: {message}")
|
||||
});
|
||||
}
|
||||
parse_windows_restart_result(&String::from_utf8_lossy(&output.stdout))
|
||||
.ok_or_else(|| "无法确认 Codex/ChatGPT 客户端的重启结果".to_string())
|
||||
desktop_windows::restart()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use super::{
|
||||
};
|
||||
use crate::ccswitch::{ccswitch_db_candidates, default_ccswitch_db_path};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::failover::protocol::UpstreamApi;
|
||||
use crate::sqlite_utils::table_column_set;
|
||||
use crate::string_value;
|
||||
use crate::toml_utils::ensure_table;
|
||||
@@ -43,6 +44,7 @@ pub(crate) struct CcSwitchCodexRow {
|
||||
pub(crate) name: String,
|
||||
pub(crate) settings_config: String,
|
||||
pub(crate) category: Option<String>,
|
||||
pub(crate) meta: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) fn is_official_ccswitch_row(row: &CcSwitchCodexRow) -> bool {
|
||||
@@ -60,8 +62,13 @@ pub(crate) fn read_ccswitch_codex_rows(conn: &Connection) -> Result<Vec<CcSwitch
|
||||
} else {
|
||||
"NULL"
|
||||
};
|
||||
let meta_column = if provider_columns.contains("meta") {
|
||||
"meta"
|
||||
} else {
|
||||
"NULL"
|
||||
};
|
||||
let provider_query = format!(
|
||||
"SELECT id, name, settings_config, {category_column} FROM providers
|
||||
"SELECT id, name, settings_config, {category_column}, {meta_column} FROM providers
|
||||
WHERE app_type = 'codex' ORDER BY sort_index ASC, created_at ASC"
|
||||
);
|
||||
let mut stmt = conn
|
||||
@@ -74,6 +81,7 @@ pub(crate) fn read_ccswitch_codex_rows(conn: &Connection) -> Result<Vec<CcSwitch
|
||||
name: row.get::<_, String>(1)?,
|
||||
settings_config: row.get::<_, String>(2)?,
|
||||
category: row.get::<_, Option<String>>(3)?,
|
||||
meta: row.get::<_, Option<String>>(4)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
@@ -116,6 +124,94 @@ fn ccswitch_auth_api_key(settings: &Value) -> Option<String> {
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn explicit_ccswitch_api_format(
|
||||
row: &CcSwitchCodexRow,
|
||||
object: &Value,
|
||||
prefix: &str,
|
||||
) -> Result<Option<(String, String)>> {
|
||||
for key in ["apiFormat", "api_format"] {
|
||||
let Some(value) = object.get(key).filter(|value| !value.is_null()) else {
|
||||
continue;
|
||||
};
|
||||
let field = format!("{prefix}.{key}");
|
||||
let text = value.as_str().ok_or_else(|| {
|
||||
CodexxError::Config(format!(
|
||||
"cc-switch 供应商 {} 的 {field} 必须是协议名称文本",
|
||||
row.id
|
||||
))
|
||||
})?;
|
||||
if !text.trim().is_empty() {
|
||||
return Ok(Some((text.trim().to_string(), field)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn ccswitch_upstream_api(
|
||||
row: &CcSwitchCodexRow,
|
||||
settings: &Value,
|
||||
wire_api: &str,
|
||||
provider_id: Option<&str>,
|
||||
) -> Result<Option<String>> {
|
||||
let mut selected = None;
|
||||
if let Some(text) = row.meta.as_deref().filter(|text| !text.trim().is_empty()) {
|
||||
let meta: Value = serde_json::from_str(text).map_err(|_| {
|
||||
CodexxError::Config(format!("cc-switch 供应商 {} 的 meta 不是有效 JSON", row.id))
|
||||
})?;
|
||||
if !meta.is_null() && !meta.is_object() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"cc-switch 供应商 {} 的 meta 必须是 JSON 对象",
|
||||
row.id
|
||||
)));
|
||||
}
|
||||
selected = explicit_ccswitch_api_format(row, &meta, "meta")?;
|
||||
}
|
||||
if selected.is_none() {
|
||||
if let Some(meta) = settings.get("meta").filter(|meta| !meta.is_null()) {
|
||||
if !meta.is_object() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"cc-switch 供应商 {} 的 settings.meta 必须是 JSON 对象",
|
||||
row.id
|
||||
)));
|
||||
}
|
||||
selected = explicit_ccswitch_api_format(row, meta, "settings.meta")?;
|
||||
}
|
||||
}
|
||||
if selected.is_none() {
|
||||
selected = explicit_ccswitch_api_format(row, settings, "settings")?;
|
||||
}
|
||||
let explicit = selected.is_some();
|
||||
let (format, field) = selected.unwrap_or_else(|| {
|
||||
(
|
||||
wire_api.trim().to_string(),
|
||||
format!(
|
||||
"config.model_providers.{}.wire_api",
|
||||
provider_id.unwrap_or("<当前供应商>")
|
||||
),
|
||||
)
|
||||
});
|
||||
let protocol = UpstreamApi::from_provider(Some(&format), "responses").map_err(|_| {
|
||||
CodexxError::Config(format!(
|
||||
"cc-switch 供应商 {} 的 {field} 上游协议不受支持;请选择 Responses、Chat Completions、Anthropic Messages 或 Gemini",
|
||||
row.id
|
||||
))
|
||||
})?;
|
||||
// An old native template says nothing about a locally selected bridge.
|
||||
// Leave None so a refresh can retain Codex-X's independent metadata.
|
||||
if !explicit && protocol == UpstreamApi::Responses {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(
|
||||
match protocol {
|
||||
UpstreamApi::Responses => "responses",
|
||||
UpstreamApi::ChatCompletions => "chat_completions",
|
||||
UpstreamApi::AnthropicMessages => "anthropic_messages",
|
||||
UpstreamApi::Gemini => "gemini",
|
||||
}
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn codex_section_from_table(
|
||||
id: &str,
|
||||
table: &Table,
|
||||
@@ -285,6 +381,12 @@ fn ccswitch_provider_template(
|
||||
}
|
||||
providers.insert(provider_id, Item::Table(table));
|
||||
}
|
||||
let table = providers
|
||||
.get_mut(provider_id)
|
||||
.and_then(Item::as_table_mut)?;
|
||||
if table.get("wire_api").and_then(Item::as_str) != Some("responses") {
|
||||
table["wire_api"] = value("responses");
|
||||
}
|
||||
strip_provider_bearer_tokens(&mut doc);
|
||||
Some(doc.to_string().trim_end().to_string())
|
||||
}
|
||||
@@ -292,9 +394,18 @@ fn ccswitch_provider_template(
|
||||
pub(crate) fn build_ccswitch_codex_provider(
|
||||
row: &CcSwitchCodexRow,
|
||||
global_sections: &HashMap<String, CcSwitchCodexSection>,
|
||||
) -> Option<SavedProvider> {
|
||||
let settings: Value = serde_json::from_str(&row.settings_config).ok()?;
|
||||
let section = select_ccswitch_section_for_row(row, &settings, global_sections)?;
|
||||
) -> Result<Option<SavedProvider>> {
|
||||
let Ok(settings) = serde_json::from_str::<Value>(&row.settings_config) else {
|
||||
ccswitch_upstream_api(row, &Value::Null, "responses", None)?;
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(section) = select_ccswitch_section_for_row(row, &settings, global_sections) else {
|
||||
// A configured but unsupported protocol should not be hidden behind
|
||||
// the generic incomplete-provider warning.
|
||||
ccswitch_upstream_api(row, &settings, "responses", None)?;
|
||||
return Ok(None);
|
||||
};
|
||||
let upstream_api = ccswitch_upstream_api(row, &settings, §ion.wire_api, Some(§ion.id))?;
|
||||
let api_key = ccswitch_auth_api_key(&settings).or(section.experimental_bearer_token.clone());
|
||||
let provider_name = section
|
||||
.name
|
||||
@@ -304,19 +415,25 @@ pub(crate) fn build_ccswitch_codex_provider(
|
||||
(!name.is_empty()).then(|| name.to_string())
|
||||
})
|
||||
.unwrap_or_else(|| row.id.clone());
|
||||
let model = section.model.clone()?;
|
||||
let toml_config = ccswitch_provider_template(&settings, §ion, &provider_name, &model)?;
|
||||
Some(SavedProvider {
|
||||
let Some(model) = section.model.clone() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(toml_config) = ccswitch_provider_template(&settings, §ion, &provider_name, &model)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(SavedProvider {
|
||||
id: custom_provider_id(&row.id),
|
||||
provider_name,
|
||||
base_url: section.base_url,
|
||||
model,
|
||||
api_key,
|
||||
toml_config: Some(toml_config),
|
||||
wire_api: section.wire_api,
|
||||
wire_api: "responses".to_string(),
|
||||
requires_openai_auth: section.requires_openai_auth,
|
||||
upstream_api,
|
||||
model_mappings: Vec::new(),
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Result<ImportResult> {
|
||||
@@ -388,7 +505,7 @@ pub(crate) fn import_ccswitch_codex_providers_inner(path: Option<String>) -> Res
|
||||
));
|
||||
continue;
|
||||
}
|
||||
match build_ccswitch_codex_provider(&row, &global_sections) {
|
||||
match build_ccswitch_codex_provider(&row, &global_sections)? {
|
||||
Some(provider) => {
|
||||
let provider = normalize_saved_provider(provider)?;
|
||||
let result =
|
||||
@@ -521,6 +638,249 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn protocol_row(format: Option<&str>) -> CcSwitchCodexRow {
|
||||
CcSwitchCodexRow {
|
||||
id: "protocol-fixture".into(),
|
||||
name: "Protocol fixture".into(),
|
||||
settings_config: json!({
|
||||
"auth": {"OPENAI_API_KEY": "fixture-api-key"},
|
||||
"config": "# keep imported comment\nmodel_provider='custom'\nmodel='fixture-model'\n[model_providers.custom]\nname='Protocol fixture'\nbase_url='https://neutral-gateway.example.test/custom-api-prefix'\nwire_api='responses'\nrequires_openai_auth=false\nhttp_headers={X-Title='Fixture title'}\n[mcp_servers.keep]\ncommand='fixture-server'\n",
|
||||
}).to_string(),
|
||||
category: None,
|
||||
meta: format.map(|format| json!({"apiFormat":format}).to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_ccswitch_database(with_meta: bool) -> (tempfile::TempDir, Connection) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let conn = Connection::open(dir.path().join("fixture-ccswitch.db")).unwrap();
|
||||
let optional = if with_meta {
|
||||
"category TEXT, meta TEXT,"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
conn.execute_batch(&format!(
|
||||
"CREATE TABLE providers (id TEXT, name TEXT, app_type TEXT, settings_config TEXT, {optional} sort_index INTEGER, created_at INTEGER);"
|
||||
)).unwrap();
|
||||
(dir, conn)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_database_reads_metadata_and_builds_all_four_protocols_without_endpoint_guessing() {
|
||||
let (_dir, conn) = fixture_ccswitch_database(true);
|
||||
for (index, (format, canonical)) in [
|
||||
("openai_responses", "responses"),
|
||||
("openai_chat", "chat_completions"),
|
||||
("anthropic", "anthropic_messages"),
|
||||
("gemini", "gemini"),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut row = protocol_row(Some(format));
|
||||
row.id = format!("protocol-{index}");
|
||||
conn.execute(
|
||||
"INSERT INTO providers (id,name,app_type,settings_config,meta,sort_index,created_at) VALUES (?1,?2,'codex',?3,?4,?5,1)",
|
||||
rusqlite::params![row.id,row.name,row.settings_config,row.meta,index],
|
||||
).unwrap();
|
||||
let read = read_ccswitch_codex_rows(&conn).unwrap().pop().unwrap();
|
||||
let provider = build_ccswitch_codex_provider(&read, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(provider.upstream_api.as_deref(), Some(canonical));
|
||||
assert_eq!(provider.wire_api, "responses");
|
||||
assert_eq!(
|
||||
provider.base_url,
|
||||
"https://neutral-gateway.example.test/custom-api-prefix"
|
||||
);
|
||||
let text = provider.toml_config.unwrap();
|
||||
assert!(text.contains("# keep imported comment"));
|
||||
let doc = text.parse::<DocumentMut>().unwrap();
|
||||
let table = doc["model_providers"]["custom"].as_table().unwrap();
|
||||
assert_eq!(table["wire_api"].as_str(), Some("responses"));
|
||||
assert_eq!(
|
||||
table["http_headers"]["X-Title"].as_str(),
|
||||
Some("Fixture title")
|
||||
);
|
||||
assert!(table.get("upstream_api").is_none());
|
||||
assert!(table.get("apiFormat").is_none());
|
||||
assert_eq!(
|
||||
doc["mcp_servers"]["keep"]["command"].as_str(),
|
||||
Some("fixture-server")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_database_without_metadata_columns_preserves_absence_and_uses_settings_formats() {
|
||||
let (_dir, conn) = fixture_ccswitch_database(false);
|
||||
let mut row = protocol_row(None);
|
||||
let mut settings: Value = serde_json::from_str(&row.settings_config).unwrap();
|
||||
settings["api_format"] = json!("anthropic_messages");
|
||||
row.settings_config = settings.to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO providers (id,name,app_type,settings_config,sort_index,created_at) VALUES (?1,?2,'codex',?3,0,1)",
|
||||
rusqlite::params![row.id,row.name,row.settings_config],
|
||||
).unwrap();
|
||||
let read = read_ccswitch_codex_rows(&conn).unwrap().pop().unwrap();
|
||||
assert!(read.category.is_none());
|
||||
assert!(read.meta.is_none());
|
||||
let provider = build_ccswitch_codex_provider(&read, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(provider.upstream_api.as_deref(), Some("anthropic_messages"));
|
||||
let native = build_ccswitch_codex_provider(&protocol_row(None), &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(native.upstream_api.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protocol_metadata_priority_is_database_meta_then_nested_settings_then_settings_format() {
|
||||
let mut row = protocol_row(Some("openai_chat"));
|
||||
let mut settings: Value = serde_json::from_str(&row.settings_config).unwrap();
|
||||
settings["meta"] = json!({"apiFormat":"anthropic"});
|
||||
settings["apiFormat"] = json!("gemini");
|
||||
settings["api_format"] = json!("responses");
|
||||
row.settings_config = settings.to_string();
|
||||
let db = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(db.upstream_api.as_deref(), Some("chat_completions"));
|
||||
row.meta = None;
|
||||
let nested = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(nested.upstream_api.as_deref(), Some("anthropic_messages"));
|
||||
settings.as_object_mut().unwrap().remove("meta");
|
||||
row.settings_config = settings.to_string();
|
||||
let camel = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(camel.upstream_api.as_deref(), Some("gemini"));
|
||||
settings.as_object_mut().unwrap().remove("apiFormat");
|
||||
row.settings_config = settings.to_string();
|
||||
let snake = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(snake.upstream_api.as_deref(), Some("responses"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_metadata_overrides_legacy_wire_format_and_legacy_chat_migrates_native_toml() {
|
||||
let mut row = protocol_row(None);
|
||||
row.settings_config = row
|
||||
.settings_config
|
||||
.replace("wire_api='responses'", "wire_api='chat'");
|
||||
let legacy = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(legacy.upstream_api.as_deref(), Some("chat_completions"));
|
||||
assert_eq!(legacy.wire_api, "responses");
|
||||
assert_eq!(
|
||||
legacy.toml_config.unwrap().parse::<DocumentMut>().unwrap()["model_providers"]
|
||||
["custom"]["wire_api"]
|
||||
.as_str(),
|
||||
Some("responses")
|
||||
);
|
||||
row.meta = Some(json!({"apiFormat":"openai_responses"}).to_string());
|
||||
let explicit = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(explicit.upstream_api.as_deref(), Some("responses"));
|
||||
row.settings_config = row
|
||||
.settings_config
|
||||
.replace("wire_api='chat'", "wire_api='unknown-wire'");
|
||||
let overridden = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(overridden.upstream_api.as_deref(), Some("responses"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_protocol_metadata_reports_its_provider_and_field_without_echoing_credentials() {
|
||||
for (meta, settings_format, key) in [
|
||||
(Some("future_protocol"), None, "meta.apiFormat"),
|
||||
(None, Some("future_protocol"), "settings.api_format"),
|
||||
] {
|
||||
let mut row = protocol_row(meta);
|
||||
if let Some(format) = settings_format {
|
||||
let mut settings: Value = serde_json::from_str(&row.settings_config).unwrap();
|
||||
settings["api_format"] = json!(format);
|
||||
row.settings_config = settings.to_string();
|
||||
}
|
||||
let error = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(&row.id));
|
||||
assert!(error.contains(key));
|
||||
assert!(error.contains("不受支持"));
|
||||
assert!(!error.contains("fixture-api-key"));
|
||||
}
|
||||
let mut row = protocol_row(None);
|
||||
row.settings_config = row
|
||||
.settings_config
|
||||
.replace("wire_api='responses'", "wire_api='future_wire'");
|
||||
let error = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("config.model_providers.custom.wire_api"));
|
||||
row.meta = Some(json!({"apiFormat":"future_protocol"}).to_string());
|
||||
row.settings_config = json!({"apiFormat":"responses","config":""}).to_string();
|
||||
assert!(build_ccswitch_codex_provider(&row, &HashMap::new()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_metadata_and_non_text_protocol_fields_are_rejected_without_raw_json() {
|
||||
for meta in ["{fixture-private-meta", "[]", "{\"apiFormat\":42}"] {
|
||||
let mut row = protocol_row(None);
|
||||
row.meta = Some(meta.to_string());
|
||||
let error = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("meta"));
|
||||
assert!(!error.contains("fixture-private-meta"));
|
||||
assert!(!error.contains("fixture-api-key"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refreshing_old_native_input_keeps_the_local_protocol_but_an_explicit_choice_replaces_it() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.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,requires_openai_auth INTEGER NOT NULL,model_mappings_json TEXT NOT NULL,upstream_api TEXT,source TEXT NOT NULL,source_id TEXT,created_at TEXT NOT NULL,updated_at TEXT NOT NULL);").unwrap();
|
||||
let original = normalize_saved_provider(
|
||||
build_ccswitch_codex_provider(&protocol_row(Some("openai_chat")), &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
upsert_ccswitch_provider_on_connection(&conn, original, "protocol-fixture").unwrap();
|
||||
let old_input = build_ccswitch_codex_provider(&protocol_row(None), &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(old_input.upstream_api.is_none());
|
||||
let retained = upsert_ccswitch_provider_on_connection(
|
||||
&conn,
|
||||
normalize_saved_provider(old_input).unwrap(),
|
||||
"protocol-fixture",
|
||||
)
|
||||
.unwrap()
|
||||
.provider;
|
||||
assert_eq!(retained.upstream_api.as_deref(), Some("chat_completions"));
|
||||
let explicit =
|
||||
build_ccswitch_codex_provider(&protocol_row(Some("openai_responses")), &HashMap::new())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let replaced = upsert_ccswitch_provider_on_connection(
|
||||
&conn,
|
||||
normalize_saved_provider(explicit).unwrap(),
|
||||
"protocol-fixture",
|
||||
)
|
||||
.unwrap()
|
||||
.provider;
|
||||
assert_eq!(replaced.upstream_api.as_deref(), Some("responses"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_import_round_trips_complete_config_without_bearer_tokens() {
|
||||
let settings_config = json!({
|
||||
@@ -540,6 +900,8 @@ wire_api = "responses"
|
||||
requires_openai_auth = false
|
||||
experimental_bearer_token = "sk-from-config"
|
||||
request_max_retries = 7
|
||||
http_headers = { "HTTP-Referer" = "https://header.example.test", "User-Agent" = "Fixture agent" }
|
||||
env_http_headers = { "X-Project" = "PROJECT_ID" }
|
||||
|
||||
[model_providers.other]
|
||||
name = "Other provider"
|
||||
@@ -576,10 +938,12 @@ command = "docs-server"
|
||||
name: " Sky2_free ".to_string(),
|
||||
settings_config,
|
||||
category: None,
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let provider =
|
||||
build_ccswitch_codex_provider(&row, &HashMap::new()).expect("build cc-switch provider");
|
||||
let provider = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.expect("valid cc-switch provider")
|
||||
.expect("build cc-switch provider");
|
||||
assert_eq!(provider.id, "magicai-123");
|
||||
assert_eq!(provider.provider_name, "Sky2api");
|
||||
assert_eq!(provider.base_url, "https://proxy.example.com/v1");
|
||||
@@ -607,6 +971,18 @@ command = "docs-server"
|
||||
doc["model_providers"]["custom"]["request_max_retries"].as_integer(),
|
||||
Some(7)
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["http_headers"]["HTTP-Referer"].as_str(),
|
||||
Some("https://header.example.test")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["http_headers"]["User-Agent"].as_str(),
|
||||
Some("Fixture agent")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["custom"]["env_http_headers"]["X-Project"].as_str(),
|
||||
Some("PROJECT_ID")
|
||||
);
|
||||
assert_eq!(
|
||||
doc["model_providers"]["other"]["base_url"].as_str(),
|
||||
Some("https://other.example.com/v1")
|
||||
@@ -665,9 +1041,12 @@ requires_openai_auth = false
|
||||
})
|
||||
.to_string(),
|
||||
category: None,
|
||||
meta: None,
|
||||
};
|
||||
|
||||
assert!(build_ccswitch_codex_provider(&row, &HashMap::new()).is_none());
|
||||
assert!(build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -697,8 +1076,11 @@ requires_openai_auth = false
|
||||
})
|
||||
.to_string(),
|
||||
category: None,
|
||||
meta: None,
|
||||
};
|
||||
|
||||
assert!(build_ccswitch_codex_provider(&row, &global_sections).is_none());
|
||||
assert!(build_ccswitch_codex_provider(&row, &global_sections)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
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 = "获取模型列表";
|
||||
|
||||
@@ -46,18 +49,31 @@ struct ModelPayload {
|
||||
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_models_url(base_url: &str) -> Result<reqwest::Url> {
|
||||
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 mut url = reqwest::Url::parse(trimmed)
|
||||
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(
|
||||
@@ -65,6 +81,11 @@ fn provider_models_url(base_url: &str) -> Result<reqwest::Url> {
|
||||
));
|
||||
}
|
||||
|
||||
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()))?
|
||||
@@ -93,6 +114,97 @@ fn provider_models_url(base_url: &str) -> Result<reqwest::Url> {
|
||||
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
|
||||
@@ -158,10 +270,42 @@ fn natural_model_id_cmp(left: &str, right: &str) -> Ordering {
|
||||
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.data {
|
||||
for model in payload {
|
||||
let id = model.id.trim();
|
||||
if id.is_empty() {
|
||||
continue;
|
||||
@@ -187,22 +331,70 @@ fn parse_models(body: &str) -> Result<Vec<ProviderModel>> {
|
||||
.then_with(|| natural_model_id_cmp(&right.id, &left.id))
|
||||
});
|
||||
|
||||
Ok(models)
|
||||
models
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn request_provider_models_with_client(
|
||||
client: &Client,
|
||||
base_url: &str,
|
||||
api_key: Option<&str>,
|
||||
) -> Result<ProviderModelsAttempt> {
|
||||
let url = provider_models_url(base_url)?;
|
||||
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 let Some(api_key) = api_key.map(str::trim).filter(|key| !key.is_empty()) {
|
||||
request = request.bearer_auth(api_key);
|
||||
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
|
||||
@@ -221,15 +413,21 @@ fn request_provider_models_with_client(
|
||||
.text()
|
||||
.map_err(|_| CodexxError::Config("模型列表读取失败".to_string()))?;
|
||||
Ok(ProviderModelsAttempt::Success(ProviderModelsResult {
|
||||
models: parse_models(&body)?,
|
||||
models: parse_models_with_protocol(&body, protocol)?,
|
||||
status,
|
||||
duration_ms,
|
||||
}))
|
||||
}
|
||||
|
||||
fn request_provider_models(base_url: &str, api_key: Option<&str>) -> Result<ProviderModelsAttempt> {
|
||||
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_client(&client, base_url, api_key)
|
||||
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 {
|
||||
@@ -251,7 +449,21 @@ pub(crate) fn test_provider_connection_inner(
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
) -> Result<ProviderConnectionResult> {
|
||||
match request_provider_models(&base_url, api_key.as_deref())? {
|
||||
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))
|
||||
}
|
||||
@@ -266,7 +478,21 @@ pub(crate) fn fetch_provider_models_inner(
|
||||
base_url: String,
|
||||
api_key: Option<String>,
|
||||
) -> Result<ProviderModelsResult> {
|
||||
match request_provider_models(&base_url, api_key.as_deref())? {
|
||||
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) {
|
||||
@@ -434,4 +660,204 @@ mod tests {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ pub(crate) mod official_profiles;
|
||||
pub(crate) mod quota;
|
||||
mod selection;
|
||||
mod store;
|
||||
pub(crate) mod transport;
|
||||
|
||||
use crate::error::Result;
|
||||
use rusqlite::Connection;
|
||||
@@ -23,15 +24,19 @@ pub(crate) use ccswitch::{
|
||||
#[cfg(test)]
|
||||
pub(crate) use connection::provider_status_result;
|
||||
pub(crate) use connection::{
|
||||
fetch_provider_models_inner, test_provider_connection_inner, ProviderConnectionResult,
|
||||
ProviderModelsResult,
|
||||
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::detected_live_custom_provider;
|
||||
pub(crate) use live::{
|
||||
activate_saved_provider_inner, build_provider_toml_draft_inner, delete_saved_provider_inner,
|
||||
save_active_provider_inner, save_official_config_inner, save_provider_toml_config_inner,
|
||||
switch_provider_inner, OfficialConfigInput, ProviderInput, ProviderTomlInput,
|
||||
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,
|
||||
@@ -55,14 +60,15 @@ pub(crate) use store::{
|
||||
pub(crate) use store::{
|
||||
consolidate_legacy_provider_duplicates_on_connection, custom_provider_id,
|
||||
delete_provider_inner, duplicate_provider_inner, experimental_bearer_token_from_doc,
|
||||
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,
|
||||
reserved_codex_provider_id, rollback_provider_store_inner,
|
||||
save_detected_provider_with_rollback_inner, save_provider_inner,
|
||||
save_provider_with_rollback_inner, strip_provider_bearer_tokens,
|
||||
upsert_ccswitch_provider_on_connection, DuplicateProviderResult, ProviderStoreRollback,
|
||||
ProviderUpsertKind, SavedProvider,
|
||||
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> {
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, write_private_json};
|
||||
use crate::live_config::{
|
||||
acquire_live_config_lock, atomic_write_if_unchanged, ensure_file_snapshot_unchanged,
|
||||
read_file_snapshot, text_from_snapshot,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -22,6 +26,9 @@ const CATALOG_DIRECTORY: &str = "model-catalogs";
|
||||
const CATALOG_FIELD: &str = "model_catalog_json";
|
||||
const OWNERSHIP_FIELD: &str = "_codex_x_model_catalog";
|
||||
const MAX_CATALOG_BYTES: u64 = 1024 * 1024;
|
||||
const REASONING_EFFORTS: &[&str] = &[
|
||||
"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra",
|
||||
];
|
||||
const BASE_INSTRUCTIONS: &str = "You are a coding assistant working with the user in a shared workspace. Use the available tools to inspect and edit project files, follow the user's requirements, and verify your changes.";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -34,6 +41,13 @@ pub(crate) struct ProviderModelMapping {
|
||||
pub(crate) context_window: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct ProviderModelCatalogRepairResult {
|
||||
pub(crate) changed: bool,
|
||||
pub(crate) message: Option<String>,
|
||||
}
|
||||
|
||||
fn validate_name(name: &str, label: &str) -> Result<String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
@@ -108,14 +122,16 @@ fn model_entry(mapping: &ProviderModelMapping, priority: usize, default_context:
|
||||
// Schema: openai/codex rust-v0.153.4, protocol/src/openai_models.rs,
|
||||
// ModelInfo + ModelsResponse. Keep legacy required fields for older Codex.
|
||||
// Unlike cloning a GPT cache entry, this does not import proprietary model
|
||||
// instructions, hosted tools, service tiers, vision, or reasoning promises.
|
||||
// instructions, hosted tools or service tiers. Allow image attachments instead
|
||||
// of making the client reject every third-party model before contacting its
|
||||
// provider. The provider determines the supported modalities and efforts.
|
||||
json!({
|
||||
"slug": mapping.model,
|
||||
"display_name": mapping.display_name,
|
||||
"description": mapping.display_name,
|
||||
"base_instructions": BASE_INSTRUCTIONS,
|
||||
"default_reasoning_level": null,
|
||||
"supported_reasoning_levels": [],
|
||||
"default_reasoning_level": "high",
|
||||
"supported_reasoning_levels": REASONING_EFFORTS.iter().map(|effort| json!({"effort": effort, "description": if *effort == "none" { "Disable thinking".to_owned() } else { format!("{effort} reasoning effort") }})).collect::<Vec<_>>(),
|
||||
"shell_type": "shell_command",
|
||||
"visibility": "list",
|
||||
"supported_in_api": true,
|
||||
@@ -124,7 +140,8 @@ fn model_entry(mapping: &ProviderModelMapping, priority: usize, default_context:
|
||||
"service_tiers": [],
|
||||
"availability_nux": null,
|
||||
"upgrade": null,
|
||||
"supports_reasoning_summaries": false,
|
||||
// Older Codex gates the entire reasoning object (including effort) here.
|
||||
"supports_reasoning_summaries": true,
|
||||
"supports_reasoning_summary_parameter": false,
|
||||
"default_reasoning_summary": "none",
|
||||
"support_verbosity": false,
|
||||
@@ -137,9 +154,10 @@ fn model_entry(mapping: &ProviderModelMapping, priority: usize, default_context:
|
||||
"max_context_window": context_window,
|
||||
"effective_context_window_percent": 95,
|
||||
"experimental_supported_tools": [],
|
||||
"input_modalities": ["text"],
|
||||
"input_modalities": ["text", "image"],
|
||||
"supports_search_tool": false,
|
||||
"use_responses_lite": false
|
||||
"use_responses_lite": false,
|
||||
"prefer_websockets": false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -338,6 +356,15 @@ pub(crate) fn prepare_model_catalog(
|
||||
"source": "codex-x", "format": 1,
|
||||
"provider_hash": format!("{:x}", Sha256::digest(provider_id.as_bytes()))
|
||||
});
|
||||
let path = persist_owned_catalog(codex_dir, &catalog)?;
|
||||
// Change the caller's document only after a complete catalog is available.
|
||||
// The caller owns the live config transaction; no auth/config files are
|
||||
// read or written here, and models_cache.json is intentionally untouched.
|
||||
doc[CATALOG_FIELD] = value(path.to_string_lossy().to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn persist_owned_catalog(codex_dir: &Path, catalog: &Value) -> Result<PathBuf> {
|
||||
let filename = catalog_filename(&catalog)?;
|
||||
let directory = prepare_owned_directory(codex_dir)?;
|
||||
let path = directory.join(filename);
|
||||
@@ -349,22 +376,101 @@ pub(crate) fn prepare_model_catalog(
|
||||
));
|
||||
}
|
||||
let existing = fs::read(&path).map_err(|error| io_err(&path, error))?;
|
||||
if serde_json::from_slice::<Value>(&existing).ok().as_ref() != Some(&catalog) {
|
||||
if serde_json::from_slice::<Value>(&existing).ok().as_ref() != Some(catalog) {
|
||||
return Err(CodexxError::Config(
|
||||
"已生成的模型目录文件被修改,未覆盖原文件".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
write_private_json(&path, &catalog)?
|
||||
write_private_json(&path, catalog)?
|
||||
}
|
||||
Err(error) => return Err(io_err(&path, error)),
|
||||
}
|
||||
// Change the caller's document only after a complete catalog is available.
|
||||
// The caller owns the live config transaction; no auth/config files are
|
||||
// read or written here, and models_cache.json is intentionally untouched.
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// Upgrade a verified Codex-X catalog already selected by an older app. Keep
|
||||
/// the old immutable file available for backups and failed provider switches.
|
||||
/// User catalogs, edited generated files and official configurations are skipped.
|
||||
pub(crate) fn upgrade_owned_model_catalog(codex_dir: &Path) -> Result<bool> {
|
||||
let cfg = crate::config_path(codex_dir);
|
||||
let original = read_file_snapshot(&cfg)?;
|
||||
let text = text_from_snapshot(&cfg, original.as_deref())?;
|
||||
if text.trim().is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut doc = crate::file_io::parse_toml_document(&cfg, &text)?;
|
||||
if super::document_is_official(&doc) {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(pointer) = doc.get(CATALOG_FIELD).and_then(|item| item.as_str()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
if !is_owned_pointer(codex_dir, pointer) {
|
||||
return Ok(false);
|
||||
}
|
||||
let pointer = Path::new(pointer);
|
||||
let source = if pointer.is_absolute() {
|
||||
pointer.to_path_buf()
|
||||
} else {
|
||||
codex_dir.join(pointer)
|
||||
};
|
||||
let Some(filename) = source.file_name().and_then(|name| name.to_str()) else {
|
||||
return Ok(false);
|
||||
};
|
||||
// Ownership is stronger than the path-only recognition used for removing
|
||||
// stale pointers: only a digest-verified generated catalog may be upgraded.
|
||||
if !has_verified_ownership(&source, filename) {
|
||||
return Ok(false);
|
||||
}
|
||||
let bytes = fs::read(&source).map_err(|error| io_err(&source, error))?;
|
||||
let mut catalog: Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|_| CodexxError::Config("供应商模型目录不是有效的 JSON".into()))?;
|
||||
// Recheck the read snapshot rather than trusting the first verification,
|
||||
// since a user or another process can edit the source between reads.
|
||||
if catalog_filename(&catalog)? != filename {
|
||||
return Ok(false);
|
||||
}
|
||||
let before = catalog.clone();
|
||||
let capabilities = model_entry(
|
||||
&ProviderModelMapping {
|
||||
model: String::new(),
|
||||
display_name: String::new(),
|
||||
context_window: None,
|
||||
},
|
||||
0,
|
||||
DEFAULT_CONTEXT_WINDOW,
|
||||
);
|
||||
for model in catalog["models"]
|
||||
.as_array_mut()
|
||||
.expect("verified catalogs contain a models array")
|
||||
{
|
||||
if !model.is_object() {
|
||||
return Ok(false);
|
||||
}
|
||||
for key in [
|
||||
"input_modalities",
|
||||
"default_reasoning_level",
|
||||
"supported_reasoning_levels",
|
||||
"supports_reasoning_summaries",
|
||||
"supports_reasoning_summary_parameter",
|
||||
"default_reasoning_summary",
|
||||
] {
|
||||
model[key] = capabilities[key].clone();
|
||||
}
|
||||
}
|
||||
if catalog == before {
|
||||
return Ok(false);
|
||||
}
|
||||
let _lock = acquire_live_config_lock(codex_dir)?;
|
||||
ensure_file_snapshot_unchanged(&cfg, original.as_deref())?;
|
||||
ensure_file_snapshot_unchanged(&source, Some(&bytes))?;
|
||||
let path = persist_owned_catalog(codex_dir, &catalog)?;
|
||||
doc[CATALOG_FIELD] = value(path.to_string_lossy().to_string());
|
||||
Ok(())
|
||||
crate::backups::create_backup(codex_dir, "upgrade-provider-model-capabilities")?;
|
||||
atomic_write_if_unchanged(&cfg, original.as_deref(), doc.to_string().as_bytes())?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -404,6 +510,60 @@ mod tests {
|
||||
serde_json::from_slice(&fs::read(path(doc)).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_mapped_model_has_the_complete_effort_menu_without_extra_settings() {
|
||||
for id in [
|
||||
"deepseek-flash",
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-pro",
|
||||
"vendor/custom-model",
|
||||
"gpt-5.5",
|
||||
"gpt-6-astra",
|
||||
] {
|
||||
let entry = model_entry(&mapping(id, id, None), 0, 128000);
|
||||
let levels: Vec<_> = entry["supported_reasoning_levels"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|level| level["effort"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(levels, REASONING_EFFORTS);
|
||||
assert_eq!(entry["default_reasoning_level"], "high");
|
||||
assert_eq!(entry["supports_reasoning_summaries"], true);
|
||||
assert_eq!(entry["supports_reasoning_summary_parameter"], false);
|
||||
assert_eq!(entry["default_reasoning_summary"], "none");
|
||||
assert_eq!(entry["prefer_websockets"], false);
|
||||
assert!(!entry.to_string().contains("Maps to"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_saved_rows_need_no_additional_reasoning_configuration() {
|
||||
let fixture = Fixture::new();
|
||||
for payload in [
|
||||
json!({"model":"custom-model"}),
|
||||
json!({"model":"custom-model","reasoningEfforts":[]}),
|
||||
] {
|
||||
let row: ProviderModelMapping = serde_json::from_value(payload).unwrap();
|
||||
let mut doc = DocumentMut::new();
|
||||
prepare_model_catalog(&fixture.0, "provider", &[row], "custom-model", &mut doc)
|
||||
.unwrap();
|
||||
let generated = catalog(&doc);
|
||||
assert_eq!(
|
||||
generated["models"][0]["supported_reasoning_levels"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
8
|
||||
);
|
||||
assert_eq!(
|
||||
generated["models"][0]["supported_reasoning_levels"][0]["effort"],
|
||||
"none"
|
||||
);
|
||||
assert_eq!(generated["models"][0]["default_reasoning_level"], "high");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalization_preserves_real_models_and_adds_missing_default() {
|
||||
let result = normalize_mappings(
|
||||
@@ -478,7 +638,7 @@ mod tests {
|
||||
assert_eq!(model["display_name"], "DeepSeek 常用");
|
||||
assert_eq!(model["context_window"], 131072);
|
||||
assert_eq!(model["apply_patch_tool_type"], Value::Null);
|
||||
assert_eq!(model["input_modalities"], json!(["text"]));
|
||||
assert_eq!(model["input_modalities"], json!(["text", "image"]));
|
||||
assert!(!catalog.to_string().contains("gpt-5.5"));
|
||||
assert!(!catalog.to_string().contains("PRIVATE"));
|
||||
assert!(!catalog.to_string().contains("namespace-secret"));
|
||||
@@ -546,6 +706,148 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn legacy_catalog(fixture: &Fixture, doc: &mut DocumentMut) -> (PathBuf, Vec<u8>) {
|
||||
prepare_model_catalog(
|
||||
&fixture.0,
|
||||
"provider",
|
||||
&[
|
||||
mapping("deepseek-flash", "DeepSeek Flash", Some(131072)),
|
||||
mapping("gpt-6-astra", "GPT-6 Astra", Some(1_000_000)),
|
||||
],
|
||||
"deepseek-flash",
|
||||
doc,
|
||||
)
|
||||
.unwrap();
|
||||
let mut legacy = catalog(doc);
|
||||
for model in legacy["models"].as_array_mut().unwrap() {
|
||||
model["input_modalities"] = json!(["text"]);
|
||||
model["supported_reasoning_levels"] = json!([
|
||||
{"effort": "low", "description": "Low effort"},
|
||||
{"effort": "max", "description": "Maximum effort"}
|
||||
]);
|
||||
model["supports_reasoning_summaries"] = json!(false);
|
||||
}
|
||||
let filename = catalog_filename(&legacy).unwrap();
|
||||
let path = owned_catalog_directory(&fixture.0).join(filename);
|
||||
let bytes = serde_json::to_vec(&legacy).unwrap();
|
||||
fs::write(&path, &bytes).unwrap();
|
||||
doc[CATALOG_FIELD] = value(path.to_string_lossy().to_string());
|
||||
(path, bytes)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrades_active_legacy_capabilities_without_touching_credentials_or_old_catalog() {
|
||||
let fixture = Fixture::new();
|
||||
let mut doc: DocumentMut = r#"# Keep this comment.
|
||||
model_provider = "custom"
|
||||
model = "deepseek-flash"
|
||||
model_reasoning_effort = "ultra"
|
||||
approval_policy = "never"
|
||||
[model_providers.custom]
|
||||
name = "DeepSeek"
|
||||
base_url = "https://fixture.invalid/v1"
|
||||
wire_api = "responses"
|
||||
"#
|
||||
.parse()
|
||||
.unwrap();
|
||||
let (old_path, old_bytes) = legacy_catalog(&fixture, &mut doc);
|
||||
fs::write(fixture.0.join("config.toml"), doc.to_string()).unwrap();
|
||||
fs::write(fixture.0.join("auth.json"), "untouched-fixture-credential").unwrap();
|
||||
fs::write(fixture.0.join("models_cache.json"), "untouched-model-cache").unwrap();
|
||||
|
||||
assert!(upgrade_owned_model_catalog(&fixture.0).unwrap());
|
||||
let updated: DocumentMut = fs::read_to_string(fixture.0.join("config.toml"))
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert_ne!(path(&updated), old_path);
|
||||
assert_eq!(fs::read(old_path).unwrap(), old_bytes);
|
||||
assert_eq!(updated["model"].as_str(), Some("deepseek-flash"));
|
||||
assert_eq!(updated["model_reasoning_effort"].as_str(), Some("ultra"));
|
||||
assert_eq!(updated["approval_policy"].as_str(), Some("never"));
|
||||
assert!(updated.to_string().contains("# Keep this comment."));
|
||||
assert_eq!(
|
||||
updated["model_providers"]["custom"]["base_url"].as_str(),
|
||||
Some("https://fixture.invalid/v1")
|
||||
);
|
||||
let upgraded = catalog(&updated);
|
||||
assert_eq!(upgraded["models"][0]["slug"], "deepseek-flash");
|
||||
assert_eq!(upgraded["models"][1]["slug"], "gpt-6-astra");
|
||||
assert_eq!(upgraded["models"][0]["context_window"], 131072);
|
||||
assert_eq!(upgraded["models"][1]["context_window"], 1_000_000);
|
||||
for model in upgraded["models"].as_array().unwrap() {
|
||||
assert_eq!(model["input_modalities"], json!(["text", "image"]));
|
||||
assert_eq!(model["supports_reasoning_summaries"], true);
|
||||
assert_eq!(
|
||||
model["supported_reasoning_levels"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.last()
|
||||
.unwrap()["effort"],
|
||||
"ultra"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("auth.json")).unwrap(),
|
||||
"untouched-fixture-credential"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("models_cache.json")).unwrap(),
|
||||
"untouched-model-cache"
|
||||
);
|
||||
let config_after = fs::read(fixture.0.join("config.toml")).unwrap();
|
||||
assert!(!upgrade_owned_model_catalog(&fixture.0).unwrap());
|
||||
assert_eq!(
|
||||
fs::read(fixture.0.join("config.toml")).unwrap(),
|
||||
config_after
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_upgrade_skips_official_and_unverified_catalogs() {
|
||||
let fixture = Fixture::new();
|
||||
let mut doc: DocumentMut = "model_provider = 'custom'\nmodel = 'deepseek-flash'"
|
||||
.parse()
|
||||
.unwrap();
|
||||
let (old_path, old_bytes) = legacy_catalog(&fixture, &mut doc);
|
||||
for provider in ["openai", "custom"] {
|
||||
doc["model_provider"] = value(provider);
|
||||
let config = doc.to_string();
|
||||
fs::write(fixture.0.join("config.toml"), &config).unwrap();
|
||||
if provider == "custom" {
|
||||
let mut modified: Value = serde_json::from_slice(&old_bytes).unwrap();
|
||||
modified["models"][0]["display_name"] = json!("User's own menu");
|
||||
fs::write(&old_path, serde_json::to_vec(&modified).unwrap()).unwrap();
|
||||
}
|
||||
assert!(!upgrade_owned_model_catalog(&fixture.0).unwrap());
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("config.toml")).unwrap(),
|
||||
config
|
||||
);
|
||||
}
|
||||
doc[CATALOG_FIELD] = value("my-models.json");
|
||||
let config = doc.to_string();
|
||||
fs::write(fixture.0.join("config.toml"), &config).unwrap();
|
||||
fs::write(fixture.0.join("my-models.json"), &old_bytes).unwrap();
|
||||
assert!(!upgrade_owned_model_catalog(&fixture.0).unwrap());
|
||||
assert_eq!(
|
||||
fs::read_to_string(fixture.0.join("config.toml")).unwrap(),
|
||||
config
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(fixture.0.join("my-models.json")).unwrap(),
|
||||
old_bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_config_upgrade_does_not_create_a_codex_home() {
|
||||
let fixture = Fixture::new();
|
||||
let missing = fixture.0.join("missing-codex-home");
|
||||
assert!(!upgrade_owned_model_catalog(&missing).unwrap());
|
||||
assert!(!missing.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_removes_only_our_pointer_without_deleting_catalog() {
|
||||
let fixture = Fixture::new();
|
||||
|
||||
@@ -201,7 +201,13 @@ fn live_official_config_text(codex_dir: &Path) -> Result<Option<String>> {
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
|
||||
let doc = parse_toml_document(&path, &text)?;
|
||||
Ok(document_is_official(&doc).then_some(text))
|
||||
let direct = crate::failover::direct_document(codex_dir, &doc)?;
|
||||
let text = if direct.to_string() == doc.to_string() {
|
||||
text
|
||||
} else {
|
||||
direct.to_string()
|
||||
};
|
||||
Ok(document_is_official(&direct).then_some(text))
|
||||
}
|
||||
|
||||
fn remove_bearer_tokens(doc: &mut toml_edit::DocumentMut) {
|
||||
@@ -229,7 +235,8 @@ pub(crate) fn build_official_config_text(
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let mut doc = parse_toml_document(&path, &text)?;
|
||||
let doc = parse_toml_document(&path, &text)?;
|
||||
let mut doc = crate::failover::direct_document(codex_dir, &doc)?;
|
||||
|
||||
doc["model_provider"] = value("custom");
|
||||
doc.as_table_mut().remove("base_url");
|
||||
@@ -302,6 +309,7 @@ pub(crate) fn live_config_is_official(codex_dir: &Path) -> Result<bool> {
|
||||
}
|
||||
let text = fs::read_to_string(&path).map_err(|error| io_err(&path, error))?;
|
||||
let doc = parse_toml_document(&path, &text)?;
|
||||
let doc = crate::failover::direct_document(codex_dir, &doc)?;
|
||||
Ok(document_is_official(&doc))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::live::{
|
||||
apply_official_config_with_snapshot_locked, persist_detected_live_custom_provider,
|
||||
read_live_file_snapshot, AppliedLiveFiles, LiveAuthAction,
|
||||
apply_official_config_with_snapshot_locked, official_activation_config_text,
|
||||
persist_detected_live_custom_provider, read_live_file_snapshot, AppliedLiveFiles,
|
||||
LiveAuthAction,
|
||||
};
|
||||
use super::official_auth::{
|
||||
build_official_config_text, capture_live_official_config_before_provider_switch,
|
||||
@@ -436,6 +437,7 @@ pub(crate) fn list_official_profiles_inner(
|
||||
let selected = selected_profile_id(&codex_dir)?;
|
||||
let config_path = crate::config_path(&codex_dir);
|
||||
let config = parse_toml_document(&config_path, &read_to_string_if_exists(&config_path)?)?;
|
||||
let config = crate::failover::direct_document(&codex_dir, &config)?;
|
||||
let is_official = document_is_official(&config);
|
||||
let live_model = crate::string_value(&config, "model");
|
||||
let live_auth = is_official
|
||||
@@ -867,6 +869,7 @@ fn switch_official_profile_with_before_apply(
|
||||
&target.config_text,
|
||||
target.profile.model.as_deref(),
|
||||
)?;
|
||||
let config = official_activation_config_text(&codex_dir, &config)?;
|
||||
let auth = parse_auth(&target.auth_json)?;
|
||||
with_mutation(&codex_dir, |mutation| {
|
||||
let previous_id = selected_profile_id(&codex_dir)?;
|
||||
@@ -1533,10 +1536,14 @@ mod tests {
|
||||
Some(b.profile.id.as_str())
|
||||
);
|
||||
assert_eq!(live_auth(&dir), auth("b"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&dir)).unwrap().trim_end(),
|
||||
config("b").trim_end()
|
||||
);
|
||||
let configured = fs::read_to_string(config_path(&dir))
|
||||
.unwrap()
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.unwrap();
|
||||
assert_eq!(configured["model"].as_str(), Some("model-b"));
|
||||
assert!(document_is_official(&configured));
|
||||
assert_eq!(configured["approval_policy"].as_str(), Some("never"));
|
||||
assert_eq!(configured["features"]["web_search"].as_bool(), Some(true));
|
||||
|
||||
let refreshed = auth("b-refreshed");
|
||||
write_json(&auth_path(&dir), &refreshed).unwrap();
|
||||
@@ -1569,10 +1576,14 @@ mod tests {
|
||||
write_json(&auth_path(&dir), &auth("a-refreshed")).unwrap();
|
||||
switch(&dir, &b.profile.id);
|
||||
assert_eq!(live_auth(&dir), refreshed);
|
||||
assert_eq!(
|
||||
fs::read_to_string(config_path(&dir)).unwrap().trim_end(),
|
||||
b_config.trim_end()
|
||||
);
|
||||
let configured = fs::read_to_string(config_path(&dir))
|
||||
.unwrap()
|
||||
.parse::<toml_edit::DocumentMut>()
|
||||
.unwrap();
|
||||
assert_eq!(configured["model"].as_str(), Some("model-b"));
|
||||
assert!(document_is_official(&configured));
|
||||
assert_eq!(configured["desktop"]["notify"].as_bool(), Some(true));
|
||||
assert_eq!(configured["features"]["web_search"].as_bool(), Some(true));
|
||||
switch(&dir, DEFAULT_OFFICIAL_PROFILE_ID);
|
||||
assert_eq!(live_auth(&dir), auth("a-refreshed"));
|
||||
fs::remove_dir_all(dir).unwrap();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
use super::storage::{
|
||||
source_text_is_subagent, sqlite_subagent_thread_ids, thread_source_is_internal,
|
||||
};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::sqlite_utils::{sqlite_has_table, table_column_set};
|
||||
use rusqlite::{params_from_iter, types::Value as SqlValue, Connection, OpenFlags};
|
||||
@@ -81,6 +84,59 @@ fn timestamp_expr(columns: &HashSet<String>, ms_column: &str, seconds_column: &s
|
||||
}
|
||||
}
|
||||
|
||||
// Catalog visibility is independent of the thread index. A stale or incomplete
|
||||
// index must never turn a source-marked internal catalog row into a user thread.
|
||||
pub(super) fn catalog_internal_thread_ids(
|
||||
conn: &Connection,
|
||||
columns: &HashSet<String>,
|
||||
) -> Result<HashSet<String>> {
|
||||
if !columns.contains("thread_id") {
|
||||
return Ok(HashSet::new());
|
||||
}
|
||||
let source = text_expr(columns, "source_kind", "NULL");
|
||||
let thread_source = text_expr(columns, "thread_source", "NULL");
|
||||
let mut statement = conn
|
||||
.prepare(&format!(
|
||||
"SELECT thread_id, {source}, {thread_source} FROM local_thread_catalog"
|
||||
))
|
||||
.map_err(database_error)?;
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?,
|
||||
row.get::<_, Option<String>>(2)?,
|
||||
))
|
||||
})
|
||||
.map_err(database_error)?;
|
||||
let mut ids = HashSet::new();
|
||||
for row in rows {
|
||||
let (id, source, thread_source) = row.map_err(database_error)?;
|
||||
if source.as_deref().is_some_and(source_text_is_subagent)
|
||||
|| thread_source
|
||||
.as_deref()
|
||||
.is_some_and(thread_source_is_internal)
|
||||
{
|
||||
ids.insert(id);
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
pub(super) fn scan_catalog_internal_thread_ids(paths: &[PathBuf]) -> Result<HashSet<String>> {
|
||||
let mut ids = HashSet::new();
|
||||
for path in paths {
|
||||
let conn = Connection::open_with_flags(
|
||||
path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(database_error)?;
|
||||
let columns = catalog_columns(&conn)?;
|
||||
ids.extend(catalog_internal_thread_ids(&conn, &columns)?);
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn collect_catalog_repair_threads(
|
||||
paths: &[PathBuf],
|
||||
target_provider: &str,
|
||||
@@ -100,6 +156,7 @@ fn collect_catalog_repair_threads(
|
||||
if !columns.contains("id") {
|
||||
continue;
|
||||
}
|
||||
let internal_ids = sqlite_subagent_thread_ids(&conn, &columns)?;
|
||||
let display_title = coalesce_text_expr(
|
||||
&columns,
|
||||
&["name", "title", "preview", "first_user_message"],
|
||||
@@ -150,7 +207,7 @@ fn collect_catalog_repair_threads(
|
||||
.map_err(database_error)?;
|
||||
for row in rows {
|
||||
let thread = row.map_err(database_error)?;
|
||||
if !syncable_thread_ids.contains(&thread.id) {
|
||||
if !syncable_thread_ids.contains(&thread.id) || internal_ids.contains(&thread.id) {
|
||||
continue;
|
||||
}
|
||||
let replace = threads
|
||||
@@ -238,8 +295,13 @@ pub(super) fn scan_catalog_sync(
|
||||
target_provider: &str,
|
||||
syncable_thread_ids: &HashSet<String>,
|
||||
) -> Result<CatalogSyncScan> {
|
||||
let internal_ids = scan_catalog_internal_thread_ids(catalog_paths)?;
|
||||
let syncable_thread_ids: HashSet<String> = syncable_thread_ids
|
||||
.difference(&internal_ids)
|
||||
.cloned()
|
||||
.collect();
|
||||
let sources =
|
||||
collect_catalog_repair_threads(thread_paths, target_provider, syncable_thread_ids)?;
|
||||
collect_catalog_repair_threads(thread_paths, target_provider, &syncable_thread_ids)?;
|
||||
let mut scan = CatalogSyncScan {
|
||||
sources,
|
||||
..CatalogSyncScan::default()
|
||||
@@ -680,18 +742,35 @@ pub(super) fn apply_catalog_updates(
|
||||
sources: &HashMap<String, CatalogRepairThread>,
|
||||
provider_thread_ids: &HashSet<String>,
|
||||
) -> Result<CatalogUpdateCounts> {
|
||||
let internal_ids = catalog_internal_thread_ids(conn, columns)?;
|
||||
let provider_thread_ids: HashSet<String> = provider_thread_ids
|
||||
.difference(&internal_ids)
|
||||
.cloned()
|
||||
.collect();
|
||||
let sources = sources
|
||||
.iter()
|
||||
.filter(|(id, thread)| {
|
||||
provider_thread_ids.contains(*id)
|
||||
&& !source_text_is_subagent(&thread.source_kind)
|
||||
&& !thread
|
||||
.thread_source
|
||||
.as_deref()
|
||||
.is_some_and(thread_source_is_internal)
|
||||
})
|
||||
.map(|(id, thread)| (id.clone(), thread.clone()))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut counts = CatalogUpdateCounts::default();
|
||||
let repair_host = if !sources.is_empty() && catalog_supports_repair(columns) {
|
||||
Some(local_catalog_host_id(conn)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
snapshot_catalog_provider_changes(conn, columns, target_provider, provider_thread_ids)?;
|
||||
snapshot_catalog_provider_changes(conn, columns, target_provider, &provider_thread_ids)?;
|
||||
if let Some(host_id) = repair_host.as_deref() {
|
||||
snapshot_hidden_catalog_sources(conn, columns, host_id, sources)?;
|
||||
snapshot_hidden_catalog_sources(conn, columns, host_id, &sources)?;
|
||||
}
|
||||
if columns.contains("model_provider") && columns.contains("thread_id") {
|
||||
for thread_id in provider_thread_ids {
|
||||
for thread_id in &provider_thread_ids {
|
||||
conn.execute(
|
||||
"UPDATE local_thread_catalog SET model_provider = ?1 \
|
||||
WHERE thread_id = ?2 AND COALESCE(model_provider, '') <> ?1",
|
||||
@@ -701,9 +780,9 @@ pub(super) fn apply_catalog_updates(
|
||||
}
|
||||
}
|
||||
if let Some(host_id) = repair_host.as_deref() {
|
||||
reactivate_catalog_sources(conn, columns, host_id, sources)?;
|
||||
reactivate_catalog_sources(conn, columns, host_id, &sources)?;
|
||||
let (inserted, observation_sequence, max_source_updated_at) =
|
||||
insert_missing_catalog_sources(conn, columns, host_id, sources)?;
|
||||
insert_missing_catalog_sources(conn, columns, host_id, &sources)?;
|
||||
counts.inserted_rows = inserted;
|
||||
if inserted > 0 {
|
||||
update_catalog_sync_state(conn, host_id, observation_sequence, max_source_updated_at)?;
|
||||
@@ -1114,4 +1193,30 @@ mod tests {
|
||||
("openai".to_string(), 1, 7)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn internal_catalog_rows_and_sources_ignore_even_explicit_sync_candidates() {
|
||||
let conn = Connection::open_in_memory().expect("open internal catalog fixture");
|
||||
create_catalog_schema(&conn);
|
||||
insert_catalog_row(&conn, "hidden-review", "openai", 1);
|
||||
conn.execute("UPDATE local_thread_catalog SET thread_source = 'guardian_review' WHERE thread_id = 'hidden-review'", [])
|
||||
.expect("mark internal catalog source");
|
||||
let mut internal_source = repair_source("missing-review", "custom");
|
||||
internal_source.source_kind = r#"{"internal":"guardian"}"#.to_string();
|
||||
internal_source.thread_source = None;
|
||||
let sources = HashMap::from([(internal_source.id.clone(), internal_source)]);
|
||||
let candidates = HashSet::from(["hidden-review".to_string(), "missing-review".to_string()]);
|
||||
let counts = apply_and_commit(&conn, "custom", &sources, &candidates);
|
||||
assert_eq!(counts.provider_rows, 0);
|
||||
assert_eq!(counts.inserted_rows, 0);
|
||||
assert_eq!(
|
||||
catalog_state(&conn, "hidden-review"),
|
||||
("openai".to_string(), 1, 7)
|
||||
);
|
||||
let rows: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM local_thread_catalog", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.expect("count preserved catalog");
|
||||
assert_eq!(rows, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,463 @@
|
||||
use super::types::SessionPreview;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
#[cfg(unix)]
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct DirectoryIdentity {
|
||||
key: String,
|
||||
birth_verified: bool,
|
||||
}
|
||||
|
||||
fn filesystem_identity(
|
||||
platform: &[u8],
|
||||
parts: &[&[u8]],
|
||||
birth: Option<SystemTime>,
|
||||
) -> DirectoryIdentity {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(b"codex-x/session-directory-identity/v1\0");
|
||||
digest.update(platform);
|
||||
digest.update(b"\0");
|
||||
for part in parts {
|
||||
digest.update(part);
|
||||
}
|
||||
if let Some(birth) = birth {
|
||||
digest.update(b"\0birth\0");
|
||||
let (sign, age) = match birth.duration_since(UNIX_EPOCH) {
|
||||
Ok(age) => (b'+', age),
|
||||
Err(before_epoch) => (b'-', before_epoch.duration()),
|
||||
};
|
||||
digest.update([sign]);
|
||||
digest.update(age.as_secs().to_le_bytes());
|
||||
digest.update(age.subsec_nanos().to_le_bytes());
|
||||
} else {
|
||||
digest.update(b"\0no-birth\0");
|
||||
}
|
||||
DirectoryIdentity {
|
||||
key: format!("filesystem:{:x}", digest.finalize()),
|
||||
birth_verified: birth.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(unix, test))]
|
||||
fn unix_directory_identity(
|
||||
device: u64,
|
||||
inode: u64,
|
||||
birth: Option<SystemTime>,
|
||||
) -> Option<DirectoryIdentity> {
|
||||
// Some virtual or unsupported filesystems expose no usable inode number.
|
||||
if inode == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(filesystem_identity(
|
||||
b"unix",
|
||||
&[&device.to_le_bytes(), &inode.to_le_bytes()],
|
||||
birth,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn windows_directory_identity(
|
||||
volume: u64,
|
||||
identifier: &[u8; 16],
|
||||
birth: Option<SystemTime>,
|
||||
) -> Option<DirectoryIdentity> {
|
||||
// The complete 128-bit ID is needed for ReFS; the old 64-bit file index
|
||||
// cannot safely distinguish all directories on that filesystem.
|
||||
if identifier.iter().all(|byte| *byte == 0) {
|
||||
return None;
|
||||
}
|
||||
Some(filesystem_identity(
|
||||
b"windows",
|
||||
&[&volume.to_le_bytes(), identifier],
|
||||
birth,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn native_directory_identity(path: &Path) -> io::Result<DirectoryIdentity> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
let handle = File::open(path)?;
|
||||
let metadata = handle.metadata()?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"not a directory",
|
||||
));
|
||||
}
|
||||
unix_directory_identity(metadata.dev(), metadata.ino(), metadata.created().ok())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::Unsupported, "directory has no stable inode"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn native_directory_identity(path: &Path) -> io::Result<DirectoryIdentity> {
|
||||
use std::fs::OpenOptions;
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
FileIdInfo, GetFileInformationByHandleEx, FILE_FLAG_BACKUP_SEMANTICS, FILE_ID_INFO,
|
||||
FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
|
||||
};
|
||||
|
||||
// READ_ATTRIBUTES plus BACKUP_SEMANTICS opens directories without requiring
|
||||
// permission to list them. No content is read and no filesystem state is changed.
|
||||
let handle = OpenOptions::new()
|
||||
.read(true)
|
||||
.access_mode(FILE_READ_ATTRIBUTES)
|
||||
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
|
||||
.open(path)?;
|
||||
let metadata = handle.metadata()?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"not a directory",
|
||||
));
|
||||
}
|
||||
let mut info = FILE_ID_INFO::default();
|
||||
// The owned read-only handle stays alive while Windows fills this exact
|
||||
// FILE_ID_INFO buffer. Unsupported filesystems return an error and fall back.
|
||||
let succeeded = unsafe {
|
||||
GetFileInformationByHandleEx(
|
||||
handle.as_raw_handle(),
|
||||
FileIdInfo,
|
||||
(&mut info as *mut FILE_ID_INFO).cast(),
|
||||
std::mem::size_of::<FILE_ID_INFO>() as u32,
|
||||
)
|
||||
};
|
||||
if succeeded == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
windows_directory_identity(
|
||||
info.VolumeSerialNumber,
|
||||
&info.FileId.Identifier,
|
||||
metadata.created().ok(),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"directory has no stable file ID",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn native_directory_identity(_path: &Path) -> io::Result<DirectoryIdentity> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"directory identity is unavailable",
|
||||
))
|
||||
}
|
||||
|
||||
fn unresolved_directory_identity(path: &str) -> DirectoryIdentity {
|
||||
DirectoryIdentity {
|
||||
key: format!("path:{path}"),
|
||||
birth_verified: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn directory_lookup(path: &str) -> DirectoryIdentity {
|
||||
let filesystem_path = Path::new(path);
|
||||
// Historical relative cwd values cannot be resolved against this app's
|
||||
// process cwd. Check the path kind before opening, including FIFO/device
|
||||
// values that must never be treated as a workspace directory.
|
||||
if !filesystem_path.is_absolute()
|
||||
|| !std::fs::metadata(filesystem_path).is_ok_and(|metadata| metadata.is_dir())
|
||||
{
|
||||
return unresolved_directory_identity(path);
|
||||
}
|
||||
native_directory_identity(filesystem_path)
|
||||
.unwrap_or_else(|_| unresolved_directory_identity(path))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn directory_identity(path: &str) -> String {
|
||||
directory_lookup(path).key
|
||||
}
|
||||
|
||||
fn pin_scope_key(path: &str, identity: &DirectoryIdentity) -> String {
|
||||
if identity.birth_verified {
|
||||
identity.key.clone()
|
||||
} else {
|
||||
format!("path:{path}")
|
||||
}
|
||||
}
|
||||
|
||||
/// The explicitly selected Codex home may use its native identity. Historical
|
||||
/// workspace paths remain lexical: opening or probing them merely to group
|
||||
/// sessions can request access to protected macOS folders such as Documents.
|
||||
/// Preserve their complete spelling on every platform; without inspecting a
|
||||
/// directory, volume defaults cannot exclude per-directory case sensitivity.
|
||||
pub(super) fn session_directory_identities(
|
||||
codex_dir: &Path,
|
||||
sessions: &[SessionPreview],
|
||||
) -> (HashMap<String, String>, String) {
|
||||
let mut identities = HashMap::new();
|
||||
let home = codex_dir.display().to_string();
|
||||
let home_identity = directory_lookup(&home);
|
||||
let pin_scope = pin_scope_key(&home, &home_identity);
|
||||
identities.insert(home.clone(), home_identity.key);
|
||||
for path in sessions.iter().filter_map(|session| session.cwd.as_deref()) {
|
||||
identities
|
||||
.entry(path.to_string())
|
||||
.or_insert_with(|| unresolved_directory_identity(path).key);
|
||||
}
|
||||
(identities, pin_scope)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct TestDirectory {
|
||||
path: PathBuf,
|
||||
_directory: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl TestDirectory {
|
||||
fn new() -> Self {
|
||||
let mut builder = tempfile::Builder::new();
|
||||
builder.prefix("codex-x-directory-identity-");
|
||||
let directory = match std::env::var_os("CODEXX_TEST_CASE_DIRECTORY_ROOT") {
|
||||
Some(root) => builder.tempdir_in(root),
|
||||
None => builder.tempdir(),
|
||||
}
|
||||
.expect("create temporary directory identity fixture");
|
||||
Self {
|
||||
path: directory.path().to_path_buf(),
|
||||
_directory: directory,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn preview(id: &str, cwd: Option<String>) -> SessionPreview {
|
||||
SessionPreview {
|
||||
id: id.to_string(),
|
||||
title: id.to_string(),
|
||||
cwd,
|
||||
model_provider: None,
|
||||
model: None,
|
||||
rollout_path: None,
|
||||
updated_at_ms: None,
|
||||
archived: false,
|
||||
has_user_event: false,
|
||||
is_subagent: false,
|
||||
needs_sync: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_directory_and_lexical_alias_have_the_same_stable_identity() {
|
||||
let fixture = TestDirectory::new();
|
||||
let folder = fixture.path.join("Workspace");
|
||||
fs::create_dir(&folder).unwrap();
|
||||
let direct = directory_identity(&folder.display().to_string());
|
||||
let alias = directory_identity(&folder.join(".").display().to_string());
|
||||
assert!(direct.starts_with("filesystem:"));
|
||||
assert_eq!(direct, alias);
|
||||
assert_eq!(direct, directory_identity(&folder.display().to_string()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symbolic_directory_alias_resolves_to_the_same_identity() {
|
||||
let fixture = TestDirectory::new();
|
||||
let folder = fixture.path.join("Workspace");
|
||||
let alias = fixture.path.join("WorkspaceAlias");
|
||||
fs::create_dir(&folder).unwrap();
|
||||
std::os::unix::fs::symlink(&folder, &alias).unwrap();
|
||||
assert_eq!(
|
||||
directory_identity(&folder.display().to_string()),
|
||||
directory_identity(&alias.display().to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_only_names_follow_the_actual_fixture_filesystem() {
|
||||
let fixture = TestDirectory::new();
|
||||
let upper = fixture.path.join("CaseDirectory");
|
||||
let lower = fixture.path.join("casedirectory");
|
||||
fs::create_dir(&upper).unwrap();
|
||||
let separate_directories = match fs::create_dir(&lower) {
|
||||
Ok(()) => true,
|
||||
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => false,
|
||||
Err(error) => panic!("create case-only directory fixture: {error}"),
|
||||
};
|
||||
let upper_key = directory_identity(&upper.display().to_string());
|
||||
let lower_key = directory_identity(&lower.display().to_string());
|
||||
assert!(upper_key.starts_with("filesystem:"));
|
||||
assert!(lower_key.starts_with("filesystem:"));
|
||||
if separate_directories {
|
||||
assert_ne!(
|
||||
upper_key, lower_key,
|
||||
"case-sensitive directories must remain distinct"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
upper_key, lower_key,
|
||||
"case aliases of the same directory must match"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_never_borrow_identity_from_the_application_working_directory() {
|
||||
for raw in [".", "./", "src", "foo", "C:Project", " relative\\Name "] {
|
||||
assert_eq!(directory_identity(raw), format!("path:{raw}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_nondirectory_paths_keep_the_complete_original_spelling() {
|
||||
let fixture = TestDirectory::new();
|
||||
for suffix in ["Missing", "missing", "Missing\\Name ", " missing "] {
|
||||
let raw = fixture.path.join(suffix).display().to_string();
|
||||
assert_eq!(directory_identity(&raw), format!("path:{raw}"));
|
||||
}
|
||||
let file = fixture.path.join("a-file");
|
||||
fs::write(&file, b"read-only identity lookup must not alter contents").unwrap();
|
||||
let raw = file.display().to_string();
|
||||
assert_eq!(directory_identity(&raw), format!("path:{raw}"));
|
||||
assert_eq!(
|
||||
fs::read(&file).unwrap(),
|
||||
b"read-only identity lookup must not alter contents"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unreadable_directory_falls_back_without_normalizing_its_name() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let fixture = TestDirectory::new();
|
||||
let directory = fixture.path.join("UnreadableDirectory");
|
||||
fs::create_dir(&directory).unwrap();
|
||||
fs::set_permissions(&directory, fs::Permissions::from_mode(0)).unwrap();
|
||||
let raw = directory.display().to_string();
|
||||
let readable_by_process = File::open(&directory).is_ok();
|
||||
let identity = directory_identity(&raw);
|
||||
fs::set_permissions(&directory, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
if !readable_by_process {
|
||||
assert_eq!(identity, format!("path:{raw}"));
|
||||
} else {
|
||||
// A privileged test runner can bypass the permission restriction.
|
||||
assert!(identity.starts_with("filesystem:"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_native_identifiers_are_rejected_and_full_windows_file_ids_are_used() {
|
||||
assert!(unix_directory_identity(1, 0, None).is_none());
|
||||
assert!(windows_directory_identity(1, &[0; 16], None).is_none());
|
||||
let mut first = [0; 16];
|
||||
first[0] = 7;
|
||||
let mut second = first;
|
||||
second[15] = 1;
|
||||
assert_ne!(
|
||||
windows_directory_identity(1, &first, None),
|
||||
windows_directory_identity(1, &second, None),
|
||||
"ReFS IDs that differ only beyond the old 64-bit index must not merge"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creation_generation_includes_epoch_sign_full_seconds_and_nanoseconds() {
|
||||
use std::time::Duration;
|
||||
// Windows SystemTime uses 100 ns intervals; use exactly representable
|
||||
// fractions while still testing that subsecond birth precision matters.
|
||||
let created = UNIX_EPOCH + Duration::new(1234, 100);
|
||||
let original = unix_directory_identity(1, 9, Some(created)).unwrap();
|
||||
assert!(original.birth_verified);
|
||||
assert_eq!(
|
||||
original,
|
||||
unix_directory_identity(1, 9, Some(created)).unwrap()
|
||||
);
|
||||
for new_generation in [
|
||||
UNIX_EPOCH + Duration::new(1234, 200),
|
||||
UNIX_EPOCH + Duration::new(1234 + (1u64 << 32), 100),
|
||||
UNIX_EPOCH - Duration::new(1234, 100),
|
||||
] {
|
||||
assert_ne!(
|
||||
original,
|
||||
unix_directory_identity(1, 9, Some(new_generation)).unwrap(),
|
||||
"reused inode numbers from another directory generation must get different keys"
|
||||
);
|
||||
}
|
||||
let file_id = [7; 16];
|
||||
assert_ne!(
|
||||
windows_directory_identity(1, &file_id, Some(created)),
|
||||
windows_directory_identity(1, &file_id, Some(created + Duration::from_nanos(100)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persistent_pin_scope_requires_verified_birth_and_preserves_raw_fallback_paths() {
|
||||
let transient = unix_directory_identity(1, 9, None).unwrap();
|
||||
assert!(transient.key.starts_with("filesystem:"));
|
||||
assert!(!transient.birth_verified);
|
||||
for path in ["/Home/Codex", "/Home/codex", "/Home/Codex\\ "] {
|
||||
assert_eq!(pin_scope_key(path, &transient), format!("path:{path}"));
|
||||
}
|
||||
let verified = unix_directory_identity(1, 9, Some(UNIX_EPOCH)).unwrap();
|
||||
assert_eq!(pin_scope_key("/Home/Codex", &verified), verified.key);
|
||||
assert_eq!(pin_scope_key("/Home/codex", &verified), verified.key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_directory_contents_does_not_change_birth_identity_or_pin_scope() {
|
||||
let fixture = TestDirectory::new();
|
||||
let directory = fixture.path.join("Workspace");
|
||||
fs::create_dir(&directory).unwrap();
|
||||
let raw = directory.display().to_string();
|
||||
let before = directory_lookup(&raw);
|
||||
fs::write(directory.join("first.txt"), b"first").unwrap();
|
||||
fs::write(directory.join("first.txt"), b"updated").unwrap();
|
||||
fs::write(directory.join("second.txt"), b"second").unwrap();
|
||||
fs::remove_file(directory.join("first.txt")).unwrap();
|
||||
let after = directory_lookup(&raw);
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"mtime/ctime changes must not replace the directory birth identity"
|
||||
);
|
||||
assert_eq!(pin_scope_key(&raw, &before), pin_scope_key(&raw, &after));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_identities_include_only_its_home_and_loaded_workspaces_and_isolate_homes() {
|
||||
let fixture = TestDirectory::new();
|
||||
let first_home = fixture.path.join("CodexHomeA");
|
||||
let second_home = fixture.path.join("CodexHomeB");
|
||||
let workspace = fixture.path.join("Project");
|
||||
let unused = fixture.path.join("Unused");
|
||||
for directory in [&first_home, &second_home, &workspace, &unused] {
|
||||
fs::create_dir(directory).unwrap();
|
||||
}
|
||||
let config = first_home.join("config.toml");
|
||||
fs::write(&config, b"model_provider = \"openai\"\n").unwrap();
|
||||
let cwd = workspace.display().to_string();
|
||||
let sessions = vec![
|
||||
preview("first", Some(cwd.clone())),
|
||||
preview("second", Some(cwd.clone())),
|
||||
preview("unknown", None),
|
||||
];
|
||||
let (first, first_scope) = session_directory_identities(&first_home, &sessions);
|
||||
let (second, second_scope) = session_directory_identities(&second_home, &sessions);
|
||||
assert_eq!(first.len(), 2);
|
||||
assert!(first.contains_key(&first_home.display().to_string()));
|
||||
assert!(first.contains_key(&cwd));
|
||||
assert!(!first.contains_key(&unused.display().to_string()));
|
||||
assert_ne!(
|
||||
first[&first_home.display().to_string()],
|
||||
second[&second_home.display().to_string()]
|
||||
);
|
||||
assert_ne!(first_scope, second_scope);
|
||||
assert_eq!(first[&cwd], second[&cwd]);
|
||||
assert_eq!(fs::read(&config).unwrap(), b"model_provider = \"openai\"\n");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,10 @@ mod app_server;
|
||||
mod backup;
|
||||
mod catalog;
|
||||
mod delete;
|
||||
mod directory_identity;
|
||||
mod export;
|
||||
mod global_state;
|
||||
mod rollout_stream;
|
||||
mod storage;
|
||||
mod sync;
|
||||
mod transaction;
|
||||
@@ -15,14 +18,69 @@ pub(crate) use backup::{
|
||||
#[cfg(test)]
|
||||
pub(crate) use delete::{active_session_ids_present, hard_delete_sessions_locally};
|
||||
pub(crate) use delete::{delete_codex_sessions_inner, SessionDeleteInput, SessionDeleteResult};
|
||||
pub(crate) use export::{export_codex_sessions_inner, SessionExportResult};
|
||||
#[cfg(test)]
|
||||
pub(crate) use storage::{
|
||||
apply_session_changes, list_session_previews, restore_session_changes, scan_rollouts,
|
||||
scan_sqlite, sqlite_session_db_paths,
|
||||
};
|
||||
pub(crate) use storage::{
|
||||
session_project_title, session_titles_by_id, source_value_is_subagent, sqlite_candidate_paths,
|
||||
usage_thread_identities, UsageThreadIdentity,
|
||||
session_project_title, session_titles_by_id, source_kind_is_internal, sqlite_candidate_paths,
|
||||
sqlite_candidate_paths_with_timeout, thread_source_is_internal, usage_thread_identities,
|
||||
UsageThreadIdentity,
|
||||
};
|
||||
pub(crate) use sync::{session_sync_status_inner, sync_sessions_provider_inner};
|
||||
pub(crate) use types::{SessionSyncResult, SessionSyncStatus};
|
||||
|
||||
/// Serialize operations that can restart the official client with session
|
||||
/// synchronization and deletion, including callers from other windows or IPC.
|
||||
/// Provider activation remains independent of this maintenance lock.
|
||||
pub(crate) fn with_session_maintenance<T>(
|
||||
codex_dir: &std::path::Path,
|
||||
operation: impl FnOnce() -> crate::error::Result<T>,
|
||||
) -> crate::error::Result<T> {
|
||||
let _guard = sync::acquire_session_maintenance_lock(codex_dir)?;
|
||||
operation()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod maintenance_tests {
|
||||
use super::*;
|
||||
use std::cell::Cell;
|
||||
|
||||
#[test]
|
||||
fn maintenance_conflict_never_executes_callback_and_release_allows_retry() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let held = sync::acquire_session_maintenance_lock(directory.path()).unwrap();
|
||||
let called = Cell::new(false);
|
||||
let error = with_session_maintenance(directory.path(), || {
|
||||
called.set(true);
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("another session operation must block the callback");
|
||||
assert!(!called.get());
|
||||
assert!(error.to_string().contains("会话维护正在进行"));
|
||||
drop(held);
|
||||
let result = with_session_maintenance(directory.path(), || {
|
||||
called.set(true);
|
||||
Ok(17)
|
||||
})
|
||||
.unwrap();
|
||||
assert!(called.get());
|
||||
assert_eq!(result, 17);
|
||||
let _available = sync::acquire_session_maintenance_lock(directory.path()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maintenance_guard_releases_when_callback_returns_an_error() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let error = with_session_maintenance(directory.path(), || {
|
||||
Err::<(), _>(crate::error::CodexxError::Config(
|
||||
"fixture operation failed".into(),
|
||||
))
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("fixture operation failed"));
|
||||
let _available = sync::acquire_session_maintenance_lock(directory.path()).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,204 @@
|
||||
// Manual release-profile benchmark. It never accepts a user-supplied Codex
|
||||
// directory; all databases, config, histories and backups belong to TempDir.
|
||||
use super::super::super::rollout_stream::decoded_rollout_reader;
|
||||
use std::io::BufReader;
|
||||
use std::time::Instant;
|
||||
|
||||
fn random_ascii(buffer: &mut [u8], seed: &mut u64) {
|
||||
const ASCII: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-";
|
||||
for byte in buffer {
|
||||
*seed ^= *seed << 13;
|
||||
*seed ^= *seed >> 7;
|
||||
*seed ^= *seed << 17;
|
||||
*byte = ASCII[(*seed >> 8) as usize & 63];
|
||||
}
|
||||
}
|
||||
|
||||
fn event_payload(writer: &mut impl Write, size: usize, seed: &mut u64) -> u64 {
|
||||
let prefix = b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"agent_message\",\"message\":\"";
|
||||
let suffix = b"\"}}\n";
|
||||
writer.write_all(prefix).unwrap();
|
||||
let mut buffer = [0; 64 * 1024];
|
||||
let mut remaining = size;
|
||||
while remaining != 0 {
|
||||
let count = remaining.min(buffer.len());
|
||||
random_ascii(&mut buffer[..count], seed);
|
||||
writer.write_all(&buffer[..count]).unwrap();
|
||||
remaining -= count;
|
||||
}
|
||||
writer.write_all(suffix).unwrap();
|
||||
(prefix.len() + size + suffix.len()) as u64
|
||||
}
|
||||
|
||||
fn content_hash(path: &Path) -> [u8; 32] {
|
||||
let file = fs::File::open(path).unwrap();
|
||||
let mut reader = BufReader::new(decoded_rollout_reader(file, path).unwrap());
|
||||
let mut first = Vec::new();
|
||||
reader.read_until(b'\n', &mut first).unwrap();
|
||||
assert!(first.len() < 4096);
|
||||
let first: serde_json::Value = serde_json::from_slice(&first).unwrap();
|
||||
assert_eq!(first["type"], "session_meta");
|
||||
let mut hash = Sha256::new();
|
||||
let mut buffer = [0; 64 * 1024];
|
||||
loop {
|
||||
let count = reader.read(&mut buffer).unwrap();
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
hash.update(&buffer[..count]);
|
||||
}
|
||||
hash.finalize().into()
|
||||
}
|
||||
|
||||
fn timed<T>(action: impl FnOnce() -> T) -> (T, f64) {
|
||||
let started = Instant::now();
|
||||
let value = action();
|
||||
(value, started.elapsed().as_secs_f64() * 1000.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "manual release-profile measurement using temporary session fixtures only"]
|
||||
fn measure_session_sync_on_temporary_plain_and_compressed_logs() {
|
||||
const FILES: usize = 24;
|
||||
const LARGE_RECORD_BYTES: usize = 34 * 1024 * 1024;
|
||||
const SMALL_RECORDS_PER_FILE: usize = 1000;
|
||||
const SMALL_RECORD_BYTES: usize = 512;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let dir = directory.path();
|
||||
write_config(dir, SHARED_SESSION_PROVIDER);
|
||||
let database = dir.join("state_5.sqlite");
|
||||
let conn = Connection::open(&database).unwrap();
|
||||
conn.execute_batch("CREATE TABLE threads(id TEXT PRIMARY KEY, model_provider TEXT NOT NULL, title TEXT, rollout_path TEXT, archived INTEGER NOT NULL DEFAULT 0)").unwrap();
|
||||
let mut raw_bytes = 0u64;
|
||||
let mut raw_compressed_bytes = 0u64;
|
||||
let mut plain_bytes = 0u64;
|
||||
let mut compressed_bytes = 0u64;
|
||||
let mut record_count = 0usize;
|
||||
let mut fixtures = Vec::new();
|
||||
let mut seed = 0x1357_2468_90ab_cdef;
|
||||
for index in 0..FILES {
|
||||
let id = format!("01a0e854-0000-7000-8000-{index:012}");
|
||||
let logical = dir.join(format!("sessions/2026/10/16/rollout-benchmark-{id}.jsonl"));
|
||||
fs::create_dir_all(logical.parent().unwrap()).unwrap();
|
||||
let compressed = index % 2 == 1;
|
||||
let path = if compressed {
|
||||
logical.with_extension("jsonl.zst")
|
||||
} else {
|
||||
logical.clone()
|
||||
};
|
||||
let mut file = fs::File::create(&path).unwrap();
|
||||
let mut encoder = if compressed {
|
||||
Some(zstd::stream::write::Encoder::new(file.try_clone().unwrap(), 1).unwrap())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut output = std::io::BufWriter::new(if let Some(encoder) = encoder.as_mut() {
|
||||
encoder as &mut dyn Write
|
||||
} else {
|
||||
&mut file as &mut dyn Write
|
||||
});
|
||||
let meta = format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"source\":\"cli\",\"model_provider\":\"openai\"}}}}\n");
|
||||
output.write_all(meta.as_bytes()).unwrap();
|
||||
let mut bytes = meta.len() as u64;
|
||||
record_count += 1;
|
||||
if index < 2 {
|
||||
bytes += event_payload(&mut output, LARGE_RECORD_BYTES, &mut seed);
|
||||
record_count += 1;
|
||||
} else {
|
||||
for _ in 0..SMALL_RECORDS_PER_FILE {
|
||||
bytes += event_payload(&mut output, SMALL_RECORD_BYTES, &mut seed);
|
||||
record_count += 1;
|
||||
}
|
||||
}
|
||||
output.flush().unwrap();
|
||||
drop(output);
|
||||
if let Some(encoder) = encoder {
|
||||
encoder.finish().unwrap();
|
||||
}
|
||||
drop(file);
|
||||
let stored_bytes = fs::metadata(&path).unwrap().len();
|
||||
raw_bytes += bytes;
|
||||
if compressed {
|
||||
compressed_bytes += stored_bytes;
|
||||
raw_compressed_bytes += bytes;
|
||||
} else {
|
||||
plain_bytes += stored_bytes;
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO threads(id,model_provider,title,rollout_path) VALUES(?1,'openai',?1,?2)",
|
||||
(&id, logical.display().to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
let hash = content_hash(&path);
|
||||
fixtures.push((id, logical, path, hash));
|
||||
}
|
||||
drop(conn);
|
||||
let config_dir = Some(dir.display().to_string());
|
||||
let (before, status_before_ms) =
|
||||
timed(|| session_sync_status_inner(config_dir.clone(), None).unwrap());
|
||||
assert!(before.scan_complete, "{:?}", before.scan_failures);
|
||||
assert!(before.needs_sync);
|
||||
assert_eq!(before.rollout_files, FILES);
|
||||
assert_eq!(before.mismatched_sessions, FILES);
|
||||
let (result, sync_ms) =
|
||||
timed(|| sync_sessions_provider_inner(config_dir.clone(), None).unwrap());
|
||||
assert!(
|
||||
result.status.scan_complete,
|
||||
"{:?}",
|
||||
result.status.scan_failures
|
||||
);
|
||||
assert!(!result.status.needs_sync);
|
||||
assert_eq!(result.updated_rollouts, FILES);
|
||||
assert_eq!(result.updated_threads, FILES);
|
||||
let (after, status_after_ms) =
|
||||
timed(|| session_sync_status_inner(config_dir.clone(), None).unwrap());
|
||||
assert!(after.scan_complete);
|
||||
assert!(!after.needs_sync);
|
||||
let raw_hashes = fixtures
|
||||
.iter()
|
||||
.map(|(_, _, path, _)| rollout_digest(path))
|
||||
.collect::<Vec<_>>();
|
||||
let (repeat, idempotent_sync_ms) =
|
||||
timed(|| sync_sessions_provider_inner(config_dir, None).unwrap());
|
||||
assert_eq!(repeat.updated_rollouts, 0);
|
||||
assert_eq!(repeat.updated_threads, 0);
|
||||
assert!(repeat.backup_dir.is_empty());
|
||||
assert!(repeat.status.scan_complete);
|
||||
assert!(!repeat.status.needs_sync);
|
||||
let conn = Connection::open(&database).unwrap();
|
||||
for ((id, logical, path, expected_content_hash), raw_hash) in fixtures.iter().zip(raw_hashes) {
|
||||
assert_eq!(content_hash(path), *expected_content_hash);
|
||||
assert_eq!(
|
||||
rollout_digest(path),
|
||||
raw_hash,
|
||||
"idempotent sync must not recompress matching storage"
|
||||
);
|
||||
let (provider, reference): (String, String) = conn
|
||||
.query_row(
|
||||
"SELECT model_provider,rollout_path FROM threads WHERE id=?1",
|
||||
[id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(provider, SHARED_SESSION_PROVIDER);
|
||||
assert_eq!(reference, logical.display().to_string());
|
||||
}
|
||||
println!(
|
||||
"SESSION_SYNC_BENCHMARK {}",
|
||||
serde_json::json!({
|
||||
"profile": if cfg!(debug_assertions) { "debug" } else { "release" },
|
||||
"fixture_only": true, "files": FILES, "plain_files": FILES / 2,
|
||||
"compressed_files": FILES / 2, "jsonl_records": record_count,
|
||||
"largest_record_payload_bytes": LARGE_RECORD_BYTES,
|
||||
"decoded_jsonl_bytes": raw_bytes, "plain_stored_bytes": plain_bytes,
|
||||
"compressed_decoded_bytes": raw_compressed_bytes, "compressed_stored_bytes": compressed_bytes,
|
||||
"total_stored_bytes": plain_bytes + compressed_bytes,
|
||||
"status_before_ms": status_before_ms, "sync_ms": sync_ms,
|
||||
"status_after_ms": status_after_ms, "idempotent_sync_ms": idempotent_sync_ms,
|
||||
"updated_rollouts": result.updated_rollouts, "updated_threads": result.updated_threads,
|
||||
"all_content_hashes_preserved": true, "logical_references_unchanged": true,
|
||||
"cache_context": "warm local filesystem after fixture generation; no cache flush",
|
||||
"comparison": "current implementation only; previous implementation did not support compressed rollouts"
|
||||
})
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,11 @@ use super::catalog::{
|
||||
apply_catalog_updates, catalog_columns, create_catalog_rollback_tables,
|
||||
restore_catalog_updates, CatalogRepairThread,
|
||||
};
|
||||
use super::storage::{apply_session_changes, restore_session_changes};
|
||||
use super::rollout_stream::ensure_current_rollout_representation;
|
||||
use super::storage::{
|
||||
apply_session_changes_with_hook, hash_rollout_file, restore_session_changes,
|
||||
rollout_path_has_syncable_identity, rollout_text_is_internal, sqlite_subagent_thread_ids,
|
||||
};
|
||||
use super::types::{RolloutScan, SessionFileChange};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::io_err;
|
||||
@@ -34,6 +38,7 @@ impl SqliteUpdateCounts {
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct MutationJournal {
|
||||
applied_rollouts: Vec<SessionFileChange>,
|
||||
expected_rollout_hashes: HashMap<PathBuf, [u8; 32]>,
|
||||
sqlite_restore_attempts: Vec<SqliteRestoreAttempt>,
|
||||
}
|
||||
|
||||
@@ -182,6 +187,11 @@ fn apply_sqlite_updates(
|
||||
let mut sorted_thread_ids = syncable_thread_ids.iter().collect::<Vec<_>>();
|
||||
sorted_thread_ids.sort();
|
||||
for update in pending.iter_mut() {
|
||||
let internal_ids = if update.thread_columns.contains("id") {
|
||||
sqlite_subagent_thread_ids(&update.conn, &update.thread_columns)?
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
if update.thread_columns.contains("id") && update.thread_columns.contains("model_provider")
|
||||
{
|
||||
let archived_filter = if update.thread_columns.contains("archived") {
|
||||
@@ -203,6 +213,9 @@ fn apply_sqlite_updates(
|
||||
WHERE id = ?2 AND COALESCE(model_provider, '') <> ?1{archived_filter}"
|
||||
);
|
||||
for thread_id in &sorted_thread_ids {
|
||||
if internal_ids.contains(*thread_id) {
|
||||
continue;
|
||||
}
|
||||
update
|
||||
.conn
|
||||
.execute(&snapshot_sql, (target_provider, thread_id))
|
||||
@@ -234,7 +247,7 @@ fn apply_sqlite_updates(
|
||||
WHERE id = ?2 AND COALESCE(cwd, '') <> ?1{archived_filter}"
|
||||
);
|
||||
for (thread_id, cwd) in &rollouts.cwd_by_thread_id {
|
||||
if !syncable_thread_ids.contains(thread_id) {
|
||||
if !syncable_thread_ids.contains(thread_id) || internal_ids.contains(thread_id) {
|
||||
continue;
|
||||
}
|
||||
update
|
||||
@@ -247,12 +260,16 @@ fn apply_sqlite_updates(
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
}
|
||||
let local_syncable_ids = syncable_thread_ids
|
||||
.difference(&internal_ids)
|
||||
.cloned()
|
||||
.collect();
|
||||
let catalog_counts = apply_catalog_updates(
|
||||
&update.conn,
|
||||
&update.catalog_columns,
|
||||
target_provider,
|
||||
catalog_sources,
|
||||
syncable_thread_ids,
|
||||
&local_syncable_ids,
|
||||
)?;
|
||||
update.counts.provider_rows += catalog_counts.provider_rows;
|
||||
update.counts.catalog_insert_rows += catalog_counts.inserted_rows;
|
||||
@@ -270,6 +287,7 @@ where
|
||||
{
|
||||
let mut updated = SqliteUpdateCounts::default();
|
||||
for index in 0..pending.len() {
|
||||
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
|
||||
let before_commit = sqlite_data_version(&pending[index].observer)?;
|
||||
journal.sqlite_restore_attempts.push(SqliteRestoreAttempt {
|
||||
path: pending[index].path.clone(),
|
||||
@@ -296,6 +314,9 @@ where
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
// The final commit hook can publish an external rollout update as well.
|
||||
// Returning success requires the complete candidate set to remain verified.
|
||||
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
@@ -402,15 +423,87 @@ pub(super) fn mutation_error(original: CodexxError, recovery_errors: Vec<String>
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum MutationPoint {
|
||||
BeforeSqliteLock,
|
||||
BeforeRolloutMutation,
|
||||
BeforeEachRolloutMutation(usize),
|
||||
AfterRolloutMutation,
|
||||
AfterSqliteCommit(usize),
|
||||
}
|
||||
|
||||
pub(super) struct MutationResult {
|
||||
pub(super) applied_rollouts: usize,
|
||||
pub(super) skipped_rollouts: Vec<PathBuf>,
|
||||
pub(super) sqlite_updates: SqliteUpdateCounts,
|
||||
}
|
||||
|
||||
// A file may be reclassified after scanning. In particular, the general file
|
||||
// writer skips concurrently changed rollouts; that must not leave their old IDs
|
||||
// eligible for a catalog insert or a provider update in this transaction.
|
||||
fn validate_rollout_classification(rollouts: &RolloutScan) -> Result<()> {
|
||||
let mut paths = rollouts.provider_candidate_paths.clone();
|
||||
for change in &rollouts.changes {
|
||||
let original_is_internal = if let Some(snapshot) = &change.streamed {
|
||||
!rollout_path_has_syncable_identity(&snapshot.original_path)?
|
||||
} else {
|
||||
rollout_text_is_internal(&change.original_text)
|
||||
};
|
||||
if original_is_internal {
|
||||
return Err(CodexxError::Config(
|
||||
"内部会话不能同步为普通会话。".to_string(),
|
||||
));
|
||||
}
|
||||
paths.insert(change.path.clone());
|
||||
}
|
||||
for path in paths {
|
||||
if !rollout_path_has_syncable_identity(&path)? {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话类型已变化,已停止同步;请重新检查会话:{}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn expected_rollout_hashes(rollouts: &RolloutScan) -> Result<HashMap<PathBuf, [u8; 32]>> {
|
||||
let mut expected = HashMap::new();
|
||||
for path in &rollouts.provider_candidate_paths {
|
||||
let hash = rollouts.verified_rollout_hashes.get(path).ok_or_else(|| {
|
||||
CodexxError::Config(format!(
|
||||
"缺少会话文件验证快照,已停止同步:{}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
expected.insert(path.clone(), *hash);
|
||||
}
|
||||
for change in &rollouts.changes {
|
||||
let original_hash = change.original_hash();
|
||||
if expected
|
||||
.get(&change.path)
|
||||
.is_some_and(|hash| *hash != original_hash)
|
||||
{
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话文件改写快照与扫描结果不一致,已停止同步:{}",
|
||||
change.path.display()
|
||||
)));
|
||||
}
|
||||
// A planned change already carries the scan's verified original snapshot.
|
||||
expected.insert(change.path.clone(), original_hash);
|
||||
}
|
||||
Ok(expected)
|
||||
}
|
||||
|
||||
fn validate_rollout_snapshots(expected: &HashMap<PathBuf, [u8; 32]>) -> Result<()> {
|
||||
for (path, expected_hash) in expected {
|
||||
ensure_current_rollout_representation(path)?;
|
||||
if hash_rollout_file(path).map_err(|error| io_err(path, error))? != *expected_hash {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话文件已发生变化,已停止同步;请重新检查会话:{}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn execute_provider_sync_mutation<F>(
|
||||
rollouts: &RolloutScan,
|
||||
pending_sqlite: &mut [PendingSqliteUpdate],
|
||||
@@ -424,8 +517,35 @@ where
|
||||
F: FnMut(MutationPoint) -> Result<()>,
|
||||
{
|
||||
let result = (|| -> Result<MutationResult> {
|
||||
let (applied_rollouts, skipped_rollouts) = apply_session_changes(&rollouts.changes)?;
|
||||
if !rollouts.blocked_failures.is_empty() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"会话扫描未完成,已停止同步:{}",
|
||||
rollouts.blocked_failures.join(";")
|
||||
)));
|
||||
}
|
||||
journal.expected_rollout_hashes = expected_rollout_hashes(rollouts)?;
|
||||
hook(MutationPoint::BeforeRolloutMutation)?;
|
||||
validate_rollout_classification(rollouts)?;
|
||||
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
|
||||
let (applied_rollouts, skipped_rollouts) =
|
||||
apply_session_changes_with_hook(&rollouts.changes, |index| {
|
||||
hook(MutationPoint::BeforeEachRolloutMutation(index))
|
||||
})?;
|
||||
journal.applied_rollouts = applied_rollouts;
|
||||
for change in &journal.applied_rollouts {
|
||||
journal
|
||||
.expected_rollout_hashes
|
||||
.insert(change.path.clone(), change.next_hash());
|
||||
}
|
||||
if !skipped_rollouts.is_empty() {
|
||||
return Err(CodexxError::Config(format!(
|
||||
"有 {} 个会话文件被占用或已发生变化,已停止同步;请退出 Codex 后重新检查会话。",
|
||||
skipped_rollouts.len()
|
||||
)));
|
||||
}
|
||||
hook(MutationPoint::AfterRolloutMutation)?;
|
||||
validate_rollout_classification(rollouts)?;
|
||||
validate_rollout_snapshots(&journal.expected_rollout_hashes)?;
|
||||
apply_sqlite_updates(
|
||||
pending_sqlite,
|
||||
rollouts,
|
||||
@@ -436,7 +556,6 @@ where
|
||||
let sqlite_updates = commit_sqlite_updates(pending_sqlite, journal, hook)?;
|
||||
Ok(MutationResult {
|
||||
applied_rollouts: journal.applied_rollouts.len(),
|
||||
skipped_rollouts,
|
||||
sqlite_updates,
|
||||
})
|
||||
})();
|
||||
|
||||
@@ -2,13 +2,16 @@ use super::*;
|
||||
use crate::sessions::backup::provider_sync_backup_root;
|
||||
use crate::sessions::sync::sync_sessions_provider_with_hook;
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
const LARGE_ROLLOUT_TEST_BYTES: u64 = 33 * 1024 * 1024;
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
@@ -73,6 +76,633 @@ fn sqlite_quick_check(path: &Path) -> String {
|
||||
.expect("run quick check")
|
||||
}
|
||||
|
||||
struct RolloutFailureFixture {
|
||||
codex_dir: PathBuf,
|
||||
ids: [&'static str; 2],
|
||||
rollouts: [PathBuf; 2],
|
||||
originals: [Vec<u8>; 2],
|
||||
database: PathBuf,
|
||||
catalog: PathBuf,
|
||||
}
|
||||
|
||||
impl RolloutFailureFixture {
|
||||
fn new(name: &str) -> Self {
|
||||
let codex_dir = temp_dir(name);
|
||||
let ids = [
|
||||
"019f6000-0000-7000-8000-000000000991",
|
||||
"019f6000-0000-7000-8000-000000000992",
|
||||
];
|
||||
let rollouts = ids.map(|id| codex_dir.join(format!("sessions/rollout-test-{id}.jsonl")));
|
||||
let originals = std::array::from_fn(|index| write_rollout(&rollouts[index], ids[index]));
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, ids[0], &rollouts[0]);
|
||||
Connection::open(&database)
|
||||
.unwrap()
|
||||
.execute(
|
||||
"INSERT INTO threads (id, model_provider, rollout_path) VALUES (?1, 'openai', ?2)",
|
||||
(ids[1], rollouts[1].display().to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
let catalog = codex_dir.join("sqlite/codex-dev.db");
|
||||
fs::create_dir_all(catalog.parent().unwrap()).unwrap();
|
||||
let conn = Connection::open(&catalog).unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE local_thread_catalog (
|
||||
host_id TEXT NOT NULL, thread_id TEXT NOT NULL, model_provider TEXT NOT NULL,
|
||||
PRIMARY KEY (host_id, thread_id));
|
||||
CREATE TABLE local_thread_catalog_metadata (
|
||||
id INTEGER PRIMARY KEY, catalog_revision INTEGER NOT NULL);
|
||||
INSERT INTO local_thread_catalog_metadata VALUES (1, 7);",
|
||||
)
|
||||
.unwrap();
|
||||
for id in ids {
|
||||
conn.execute(
|
||||
"INSERT INTO local_thread_catalog VALUES ('local', ?1, 'openai')",
|
||||
[id],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
Self {
|
||||
codex_dir,
|
||||
ids,
|
||||
rollouts,
|
||||
originals,
|
||||
database,
|
||||
catalog,
|
||||
}
|
||||
}
|
||||
|
||||
fn scan(&self) -> RolloutScan {
|
||||
let changes = (0..2)
|
||||
.map(|index| {
|
||||
let original_text = String::from_utf8(self.originals[index].clone()).unwrap();
|
||||
SessionFileChange {
|
||||
path: self.rollouts[index].clone(),
|
||||
next_text: original_text.replace("\"openai\"", "\"custom\""),
|
||||
original_text,
|
||||
original_mtime: None,
|
||||
streamed: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
RolloutScan {
|
||||
changes,
|
||||
provider_candidate_paths: self.rollouts.iter().cloned().collect(),
|
||||
verified_rollout_hashes: (0..2)
|
||||
.map(|index| {
|
||||
(
|
||||
self.rollouts[index].clone(),
|
||||
Sha256::digest(&self.originals[index]).into(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
..RolloutScan::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn set_rollouts_to_target_provider(&mut self) {
|
||||
for index in 0..2 {
|
||||
self.originals[index] = String::from_utf8(self.originals[index].clone())
|
||||
.unwrap()
|
||||
.replace("\"openai\"", "\"custom\"")
|
||||
.into_bytes();
|
||||
fs::write(&self.rollouts[index], &self.originals[index]).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn catalog_provider(&self, id: &str) -> String {
|
||||
Connection::open(&self.catalog)
|
||||
.unwrap()
|
||||
.query_row(
|
||||
"SELECT model_provider FROM local_thread_catalog WHERE thread_id = ?1",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn assert_databases_unchanged(&self) {
|
||||
for id in self.ids {
|
||||
assert_eq!(thread_provider(&self.database, id), "openai");
|
||||
assert_eq!(self.catalog_provider(id), "openai");
|
||||
}
|
||||
let (rows, revision): (i64, i64) = Connection::open(&self.catalog)
|
||||
.unwrap()
|
||||
.query_row(
|
||||
"SELECT (SELECT COUNT(*) FROM local_thread_catalog),
|
||||
(SELECT catalog_revision FROM local_thread_catalog_metadata WHERE id = 1)",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((rows, revision), (2, 7));
|
||||
}
|
||||
|
||||
fn sync_with_hook<F>(&self, hook: F) -> Result<super::super::types::SessionSyncResult>
|
||||
where
|
||||
F: FnMut(MutationPoint) -> Result<()>,
|
||||
{
|
||||
sync_sessions_provider_with_hook(Some(self.codex_dir.display().to_string()), None, hook)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RolloutFailureFixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.codex_dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocked_rollout_scan_aborts_before_hooks_or_database_updates() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-blocked-scan");
|
||||
let mut rollouts = fixture.scan();
|
||||
rollouts
|
||||
.blocked_failures
|
||||
.push("会话文件读取失败,未完成扫描".to_string());
|
||||
let mut pending =
|
||||
prepare_sqlite_updates(&[fixture.database.clone(), fixture.catalog.clone()]).unwrap();
|
||||
let mut journal = MutationJournal::default();
|
||||
let mut hook_called = false;
|
||||
let error = execute_provider_sync_mutation(
|
||||
&rollouts,
|
||||
&mut pending,
|
||||
"custom",
|
||||
&HashMap::new(),
|
||||
&fixture.ids.map(str::to_string).into_iter().collect(),
|
||||
&mut journal,
|
||||
&mut |_| {
|
||||
hook_called = true;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.err()
|
||||
.expect("blocked scan must fail");
|
||||
assert!(error.to_string().contains("读取失败"));
|
||||
assert!(!hook_called);
|
||||
assert!(journal.applied_rollouts.is_empty());
|
||||
assert!(rollback_mutation(&journal, &mut pending).is_empty());
|
||||
fixture.assert_databases_unchanged();
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[index]).unwrap(),
|
||||
fixture.originals[index]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_rollout_change_is_rejected_before_file_or_database_updates() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-skip-rollback");
|
||||
let mut external = fixture.originals[1].clone();
|
||||
external.extend_from_slice(
|
||||
b"{\"type\":\"event_msg\",\"payload\":{\"message\":\"keep-new-data\"}}\n",
|
||||
);
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeRolloutMutation {
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("any skipped rollout must abort the full sync");
|
||||
assert!(error.to_string().contains("已停止同步"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_rollout_after_first_write_rolls_back_and_never_updates_indexes() {
|
||||
let fixture = RolloutFailureFixture::new("streamed-skip-after-write");
|
||||
let mut external = fixture.originals[1].clone();
|
||||
external.extend_from_slice(
|
||||
b"{\"type\":\"event_msg\",\"payload\":{\"message\":\"keep-external-data\"}}\n",
|
||||
);
|
||||
let mut first_write_observed = false;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeEachRolloutMutation(1) {
|
||||
first_write_observed = fs::read_to_string(&fixture.rollouts[0])
|
||||
.unwrap()
|
||||
.contains("custom");
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("a skipped second rollout must fail and roll back the first write");
|
||||
assert!(first_write_observed);
|
||||
assert!(error.to_string().contains("已停止同步"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_read_failure_after_scan_aborts_without_database_updates() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-read-failure");
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeRolloutMutation {
|
||||
fs::remove_file(&fixture.rollouts[1]).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("unreadable full rollout must fail the sync");
|
||||
assert!(error.to_string().contains(fixture.ids[1]), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert!(
|
||||
!fixture.rollouts[1].exists(),
|
||||
"external deletion must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rollout_atomic_write_failure_restores_earlier_files_without_database_updates() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut fixture = RolloutFailureFixture::new("streaming-write-failure");
|
||||
let blocked_parent = fixture.codex_dir.join("sessions/zz-write-blocked");
|
||||
fs::create_dir_all(&blocked_parent).unwrap();
|
||||
let second_path = blocked_parent.join(fixture.rollouts[1].file_name().unwrap());
|
||||
fs::rename(&fixture.rollouts[1], &second_path).unwrap();
|
||||
fixture.rollouts[1] = second_path;
|
||||
Connection::open(&fixture.database)
|
||||
.unwrap()
|
||||
.execute(
|
||||
"UPDATE threads SET rollout_path = ?1 WHERE id = ?2",
|
||||
(fixture.rollouts[1].display().to_string(), fixture.ids[1]),
|
||||
)
|
||||
.unwrap();
|
||||
let mut reached_file_mutation = false;
|
||||
let result = fixture.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeEachRolloutMutation(1) {
|
||||
reached_file_mutation = true;
|
||||
fs::set_permissions(&blocked_parent, fs::Permissions::from_mode(0o500)).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
// Always restore the temporary fixture permissions before assertions/drop.
|
||||
fs::set_permissions(&blocked_parent, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let error = result.expect_err("temporary rollout write permission failure must fail sync");
|
||||
assert!(
|
||||
reached_file_mutation,
|
||||
"failure must occur after scan and backup"
|
||||
);
|
||||
assert!(error.to_string().contains("IO error"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[index]).unwrap(),
|
||||
fixture.originals[index]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_provider_rewrite_after_mutation_aborts_without_database_or_catalog_changes() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-provider-race");
|
||||
let external = fixture.originals[1].clone();
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterRolloutMutation {
|
||||
assert!(fs::read_to_string(&fixture.rollouts[0])
|
||||
.unwrap()
|
||||
.contains("\"custom\""));
|
||||
assert!(fs::read_to_string(&fixture.rollouts[1])
|
||||
.unwrap()
|
||||
.contains("\"custom\""));
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("ordinary provider change after writes must fail sync");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_snapshot_is_rechecked_before_each_database_commit() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-between-commits");
|
||||
let mut first_committed = false;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterSqliteCommit(0) {
|
||||
first_committed = true;
|
||||
assert!(
|
||||
thread_provider(&fixture.database, fixture.ids[0]) == "custom"
|
||||
|| fixture.catalog_provider(fixture.ids[0]) == "custom"
|
||||
);
|
||||
fs::write(&fixture.rollouts[1], &fixture.originals[1]).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("new rollout data before the second commit must abort and restore the first");
|
||||
assert!(first_committed);
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[index]).unwrap(),
|
||||
fixture.originals[index]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_growth_after_scan_is_not_overwritten_or_reported_as_success() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-growth-before-mutation");
|
||||
let enlarged_len = LARGE_ROLLOUT_TEST_BYTES;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::BeforeRolloutMutation {
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&fixture.rollouts[1])
|
||||
.unwrap()
|
||||
.set_len(enlarged_len)
|
||||
.unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("concurrent growth must invalidate the verified snapshot");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
|
||||
enlarged_len
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_growth_after_file_writes_preserves_external_data_and_rolls_back_other_files() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-growth-after-mutation");
|
||||
let marker = b"keep-external-growth";
|
||||
let marker_offset = LARGE_ROLLOUT_TEST_BYTES;
|
||||
let enlarged_len = marker_offset + marker.len() as u64;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterRolloutMutation {
|
||||
let mut external = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&fixture.rollouts[1])
|
||||
.unwrap();
|
||||
external.set_len(enlarged_len).unwrap();
|
||||
external.seek(SeekFrom::Start(marker_offset)).unwrap();
|
||||
external.write_all(marker).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("growth after writes must prevent all database commits");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
|
||||
enlarged_len
|
||||
);
|
||||
let mut preserved = fs::File::open(&fixture.rollouts[1]).unwrap();
|
||||
preserved.seek(SeekFrom::Start(marker_offset)).unwrap();
|
||||
let mut restored_marker = vec![0; marker.len()];
|
||||
preserved.read_exact(&mut restored_marker).unwrap();
|
||||
assert_eq!(restored_marker, marker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollout_read_failure_after_writes_restores_other_files_and_preserves_external_content() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-read-failure-after-writes");
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterRolloutMutation {
|
||||
fs::remove_file(&fixture.rollouts[1]).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("full snapshot read failure after writes must abort the sync");
|
||||
assert!(error.to_string().contains(fixture.ids[1]), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert!(
|
||||
!fixture.rollouts[1].exists(),
|
||||
"external deletion must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidate_without_verified_hash_is_rejected_before_mutation() {
|
||||
let fixture = RolloutFailureFixture::new("bounded-unverified-candidate");
|
||||
let mut rollouts = fixture.scan();
|
||||
rollouts.changes.clear();
|
||||
rollouts.verified_rollout_hashes.clear();
|
||||
let mut pending =
|
||||
prepare_sqlite_updates(&[fixture.database.clone(), fixture.catalog.clone()]).unwrap();
|
||||
let mut journal = MutationJournal::default();
|
||||
let mut hook_called = false;
|
||||
let error = execute_provider_sync_mutation(
|
||||
&rollouts,
|
||||
&mut pending,
|
||||
"custom",
|
||||
&HashMap::new(),
|
||||
&fixture.ids.map(str::to_string).into_iter().collect(),
|
||||
&mut journal,
|
||||
&mut |_| {
|
||||
hook_called = true;
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.err()
|
||||
.expect("unverified candidate must not take a fresh mutable file as its baseline");
|
||||
assert!(
|
||||
error.to_string().contains("缺少会话文件验证快照"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(!hook_called);
|
||||
fixture.assert_databases_unchanged();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidates_normally_repair_databases_without_rewriting_rollouts() {
|
||||
let mut fixture = RolloutFailureFixture::new("bounded-matched-normal-repair");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let result = fixture
|
||||
.sync_with_hook(|_| Ok(()))
|
||||
.expect("matched verified logs permit database repair");
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert!(result.updated_threads > 0);
|
||||
for index in 0..2 {
|
||||
assert_eq!(
|
||||
thread_provider(&fixture.database, fixture.ids[index]),
|
||||
"custom"
|
||||
);
|
||||
assert_eq!(fixture.catalog_provider(fixture.ids[index]), "custom");
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[index]).unwrap(),
|
||||
fixture.originals[index]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_unchanged_candidate_repairs_databases_without_rewriting_the_file() {
|
||||
let mut fixture = RolloutFailureFixture::new("streamed-large-matched-repair");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let event = b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"unchanged large history\"}}\n";
|
||||
let chunk = event.repeat(512);
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&fixture.rollouts[1])
|
||||
.unwrap();
|
||||
let mut original_len = file.metadata().unwrap().len();
|
||||
while original_len <= LARGE_ROLLOUT_TEST_BYTES {
|
||||
file.write_all(&chunk).unwrap();
|
||||
original_len += chunk.len() as u64;
|
||||
}
|
||||
file.sync_all().unwrap();
|
||||
drop(file);
|
||||
let original_hash = hash_rollout_file(&fixture.rollouts[1]).unwrap();
|
||||
|
||||
let result = fixture
|
||||
.sync_with_hook(|_| Ok(()))
|
||||
.expect("large matched files permit database repair");
|
||||
assert_eq!(result.updated_rollouts, 0);
|
||||
assert!(result.updated_threads > 0);
|
||||
for id in fixture.ids {
|
||||
assert_eq!(thread_provider(&fixture.database, id), "custom");
|
||||
assert_eq!(fixture.catalog_provider(id), "custom");
|
||||
}
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
|
||||
original_len
|
||||
);
|
||||
assert_eq!(
|
||||
hash_rollout_file(&fixture.rollouts[1]).unwrap(),
|
||||
original_hash
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidate_provider_changes_before_or_after_mutation_block_database_repair() {
|
||||
for stage in [
|
||||
MutationPoint::BeforeRolloutMutation,
|
||||
MutationPoint::AfterRolloutMutation,
|
||||
] {
|
||||
let mut fixture = RolloutFailureFixture::new("bounded-matched-provider-race");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let external = String::from_utf8(fixture.originals[1].clone())
|
||||
.unwrap()
|
||||
.replace("\"custom\"", "\"openai\"")
|
||||
.into_bytes();
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == stage {
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("changed already-matched candidate must not permit database repair");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidate_growth_before_or_after_mutation_blocks_database_repair() {
|
||||
for stage in [
|
||||
MutationPoint::BeforeRolloutMutation,
|
||||
MutationPoint::AfterRolloutMutation,
|
||||
] {
|
||||
let mut fixture = RolloutFailureFixture::new("bounded-matched-growth-race");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let enlarged_len = LARGE_ROLLOUT_TEST_BYTES;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == stage {
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&fixture.rollouts[1])
|
||||
.unwrap()
|
||||
.set_len(enlarged_len)
|
||||
.unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("changed already-matched candidate must prevent database repair");
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&fixture.rollouts[1]).unwrap().len(),
|
||||
enlarged_len
|
||||
);
|
||||
let mut external = fs::File::open(&fixture.rollouts[1]).unwrap();
|
||||
let mut prefix = vec![0; fixture.originals[1].len()];
|
||||
external.read_exact(&mut prefix).unwrap();
|
||||
assert_eq!(prefix, fixture.originals[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_candidate_change_after_final_commit_rolls_back_databases_and_preserves_external_log() {
|
||||
let mut fixture = RolloutFailureFixture::new("bounded-matched-final-commit-race");
|
||||
fixture.set_rollouts_to_target_provider();
|
||||
let external = String::from_utf8(fixture.originals[1].clone())
|
||||
.unwrap()
|
||||
.replace("\"custom\"", "\"openai\"")
|
||||
.into_bytes();
|
||||
let mut last_commit_observed = false;
|
||||
let error = fixture
|
||||
.sync_with_hook(|point| {
|
||||
if point == MutationPoint::AfterSqliteCommit(1) {
|
||||
last_commit_observed = true;
|
||||
assert_eq!(thread_provider(&fixture.database, fixture.ids[0]), "custom");
|
||||
assert_eq!(fixture.catalog_provider(fixture.ids[0]), "custom");
|
||||
fs::write(&fixture.rollouts[1], &external).unwrap();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("the final commit hook must not bypass the complete snapshot validation");
|
||||
assert!(last_commit_observed);
|
||||
assert!(error.to_string().contains("会话文件已发生变化"), "{error}");
|
||||
fixture.assert_databases_unchanged();
|
||||
assert_eq!(
|
||||
fs::read(&fixture.rollouts[0]).unwrap(),
|
||||
fixture.originals[0]
|
||||
);
|
||||
assert_eq!(fs::read(&fixture.rollouts[1]).unwrap(), external);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_update_failure_rolls_back_every_open_database_and_jsonl() {
|
||||
let codex_dir = temp_dir("multi-sqlite-update-failure");
|
||||
@@ -452,3 +1082,89 @@ fn injected_failure_restores_sqlite_and_jsonl_without_touching_global_state() {
|
||||
drop(wal_guard);
|
||||
fs::remove_dir_all(codex_dir).expect("remove test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_reclassification_before_mutation_aborts_without_registering_or_rewriting() {
|
||||
for existing_provider in ["openai", "custom"] {
|
||||
let codex_dir = temp_dir("internal-before-mutation");
|
||||
let id = "019f6000-0000-7000-8000-000000000980";
|
||||
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
|
||||
let existing = String::from_utf8(write_rollout(&rollout, id))
|
||||
.expect("UTF-8 fixture")
|
||||
.replace("openai", existing_provider);
|
||||
fs::write(&rollout, existing).expect("set current rollout provider");
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, id, &rollout);
|
||||
let internal = format!(
|
||||
"{}\n",
|
||||
serde_json::json!({"type":"session_meta", "payload":{
|
||||
"id":id, "model_provider":existing_provider, "source":{"internal":"guardian"}
|
||||
}})
|
||||
);
|
||||
let error = sync_sessions_provider_with_hook(
|
||||
Some(codex_dir.display().to_string()),
|
||||
None,
|
||||
|point| {
|
||||
if point == MutationPoint::BeforeRolloutMutation {
|
||||
fs::write(&rollout, &internal).expect("reclassify rollout during sync");
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.expect_err("reclassification must abort sync");
|
||||
assert!(error.to_string().contains("会话类型已变化"), "{error}");
|
||||
assert_eq!(thread_provider(&database, id), "openai");
|
||||
assert_eq!(
|
||||
fs::read_to_string(&rollout).expect("read internal rollout"),
|
||||
internal
|
||||
);
|
||||
fs::remove_dir_all(codex_dir).expect("remove race fixture");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_reclassification_after_file_writes_rolls_back_other_files_and_all_databases() {
|
||||
let codex_dir = temp_dir("internal-after-rollout-mutation");
|
||||
let parent = "019f6000-0000-7000-8000-000000000981";
|
||||
let child = "019f6000-0000-7000-8000-000000000982";
|
||||
let parent_rollout = codex_dir.join(format!("sessions/rollout-test-{parent}.jsonl"));
|
||||
let child_rollout = codex_dir.join(format!("sessions/rollout-test-{child}.jsonl"));
|
||||
let original_parent = write_rollout(&parent_rollout, parent);
|
||||
write_rollout(&child_rollout, child);
|
||||
let database = codex_dir.join("state_10.sqlite");
|
||||
create_thread_database(&database, parent, &parent_rollout);
|
||||
Connection::open(&database)
|
||||
.expect("open fixture index")
|
||||
.execute(
|
||||
"INSERT INTO threads (id, model_provider, rollout_path) VALUES (?1, 'openai', ?2)",
|
||||
(child, child_rollout.display().to_string()),
|
||||
)
|
||||
.expect("insert second thread");
|
||||
let internal = format!(
|
||||
"{}\n",
|
||||
serde_json::json!({"type":"session_meta", "payload":{
|
||||
"id":child, "model_provider":"openai", "source":{"internal":"guardian"}
|
||||
}})
|
||||
);
|
||||
let error =
|
||||
sync_sessions_provider_with_hook(Some(codex_dir.display().to_string()), None, |point| {
|
||||
if point == MutationPoint::AfterRolloutMutation {
|
||||
fs::write(&child_rollout, &internal)
|
||||
.expect("publish internal metadata during sync");
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect_err("reclassification must prevent database commits");
|
||||
assert!(error.to_string().contains("会话类型已变化"), "{error}");
|
||||
assert_eq!(thread_provider(&database, parent), "openai");
|
||||
assert_eq!(thread_provider(&database, child), "openai");
|
||||
assert_eq!(
|
||||
fs::read(&parent_rollout).expect("read restored parent"),
|
||||
original_parent
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&child_rollout).expect("preserve newer internal source"),
|
||||
internal
|
||||
);
|
||||
fs::remove_dir_all(codex_dir).expect("remove rollback fixture");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -23,6 +25,10 @@ pub(crate) struct SessionPreview {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SessionSyncStatus {
|
||||
pub(crate) codex_dir: String,
|
||||
#[serde(default)]
|
||||
pub(crate) directory_identities: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub(crate) pin_scope_key: Option<String>,
|
||||
pub(crate) target_provider: String,
|
||||
pub(crate) rollout_files: usize,
|
||||
pub(crate) session_meta_count: usize,
|
||||
@@ -59,11 +65,21 @@ pub(crate) struct RolloutScan {
|
||||
pub(crate) mismatched_rollouts: usize,
|
||||
pub(crate) mismatched_session_meta: usize,
|
||||
pub(crate) changes: Vec<SessionFileChange>,
|
||||
pub(crate) provider_candidate_paths: HashSet<PathBuf>,
|
||||
/// SHA-256 of every completely validated candidate's original JSONL. Keep
|
||||
/// matched rollouts covered without retaining another copy of their text.
|
||||
pub(crate) verified_rollout_hashes: HashMap<PathBuf, [u8; 32]>,
|
||||
pub(crate) cwd_by_thread_id: HashMap<String, String>,
|
||||
pub(crate) thread_ids: HashSet<String>,
|
||||
/// Non-user threads identified from their own rollout metadata. Retained
|
||||
/// even when the rollout is excluded from provider synchronization.
|
||||
pub(crate) internal_thread_ids: HashSet<String>,
|
||||
pub(crate) mismatched_thread_ids: HashSet<String>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
pub(crate) scan_failures: Vec<String>,
|
||||
/// Incomplete user-rollout scans that must block Provider/index mutations,
|
||||
/// including unindexed files that would otherwise become only warnings.
|
||||
pub(crate) blocked_failures: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -72,6 +88,24 @@ pub(crate) struct SessionFileChange {
|
||||
pub(crate) original_text: String,
|
||||
pub(crate) next_text: String,
|
||||
pub(crate) original_mtime: Option<SystemTime>,
|
||||
// Production snapshots live on disk; inline text is retained for small fixtures.
|
||||
pub(super) streamed: Option<Arc<super::rollout_stream::StreamedSnapshot>>,
|
||||
}
|
||||
|
||||
impl SessionFileChange {
|
||||
pub(super) fn original_hash(&self) -> [u8; 32] {
|
||||
self.streamed.as_ref().map_or_else(
|
||||
|| Sha256::digest(self.original_text.as_bytes()).into(),
|
||||
|snapshot| snapshot.original_hash,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn next_hash(&self) -> [u8; 32] {
|
||||
self.streamed.as_ref().map_or_else(
|
||||
|| Sha256::digest(self.next_text.as_bytes()).into(),
|
||||
|snapshot| snapshot.next_hash,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
|
||||
@@ -0,0 +1,993 @@
|
||||
use super::build_skills_mcp_state_inner;
|
||||
use super::mcp::{
|
||||
commit_mcp_transaction_with_config, document_bytes, is_valid_mcp_config, json_to_toml_item,
|
||||
mcp_configs_equal, save_managed_mcp_on_connection,
|
||||
};
|
||||
use super::skills::{
|
||||
codex_skills_dir, copy_dir_recursive, disabled_skills_dir, read_skill_metadata,
|
||||
sanitize_dir_name,
|
||||
};
|
||||
use super::types::{SkillsMcpActionResult, SkillsMcpExportResult};
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, parse_toml_document};
|
||||
use crate::live_config::{acquire_live_config_lock, read_file_snapshot, text_from_snapshot};
|
||||
use crate::paths::{app_home, normalized_path_scope};
|
||||
use crate::toml_utils::ensure_table;
|
||||
use crate::{config_path, now_rfc3339, open_db, resolve_codex_dir};
|
||||
use rusqlite::{params, TransactionBehavior};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Seek};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
const MANIFEST: &str = "codex-x-archive.json";
|
||||
const FORMAT: &str = "codex-x-skills-mcp";
|
||||
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct Manifest {
|
||||
format: String,
|
||||
version: u32,
|
||||
#[serde(default)]
|
||||
skills: Vec<SkillEntry>,
|
||||
#[serde(default)]
|
||||
mcp_servers: Vec<McpEntry>,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct SkillEntry {
|
||||
directory: String,
|
||||
path: String,
|
||||
enabled: bool,
|
||||
note: Option<String>,
|
||||
}
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct McpEntry {
|
||||
id: String,
|
||||
name: String,
|
||||
config: Value,
|
||||
enabled: bool,
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> CodexxError {
|
||||
CodexxError::Config(message.into())
|
||||
}
|
||||
fn zip_error(error: impl std::fmt::Display) -> CodexxError {
|
||||
invalid(format!("无法读取或写入 ZIP:{error}"))
|
||||
}
|
||||
fn unique_name(prefix: &str) -> String {
|
||||
format!(
|
||||
".{prefix}-{}-{}-{}",
|
||||
std::process::id(),
|
||||
chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default(),
|
||||
NEXT_TEMP.fetch_add(1, Ordering::Relaxed)
|
||||
)
|
||||
}
|
||||
struct TempDir(PathBuf);
|
||||
impl TempDir {
|
||||
fn new(parent: &Path) -> Result<Self> {
|
||||
ensure_directory(parent)?;
|
||||
let path = parent.join(unique_name("codex-x-archive"));
|
||||
fs::create_dir(&path).map_err(|error| io_err(&path, error))?;
|
||||
Ok(Self(path))
|
||||
}
|
||||
}
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Reject Windows traversal/drive names on every OS too, so exported bundles can
|
||||
// safely move between machines. Links and special files are never extracted.
|
||||
fn safe_relative(name: &str) -> Result<PathBuf> {
|
||||
if name.is_empty() || name.contains('\\') || name.contains(':') || name.contains('\0') {
|
||||
return Err(invalid("ZIP 中含有不安全的文件路径"));
|
||||
}
|
||||
let path = Path::new(name);
|
||||
if path
|
||||
.components()
|
||||
.any(|part| !matches!(part, Component::Normal(_)))
|
||||
{
|
||||
return Err(invalid("ZIP 中含有不安全的文件路径"));
|
||||
}
|
||||
for part in name.trim_end_matches('/').split('/') {
|
||||
let stem = part.split('.').next().unwrap_or("").to_ascii_uppercase();
|
||||
if part.is_empty()
|
||||
|| part.ends_with([' ', '.'])
|
||||
|| matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| (stem.len() == 4
|
||||
&& (stem.starts_with("COM") || stem.starts_with("LPT"))
|
||||
&& stem.as_bytes()[3].is_ascii_digit())
|
||||
{
|
||||
return Err(invalid("ZIP 中含有无法跨平台安全使用的文件路径"));
|
||||
}
|
||||
}
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
fn safe_directory(name: &str) -> Result<()> {
|
||||
let path = safe_relative(name)?;
|
||||
if path.components().count() != 1 || name.starts_with('.') {
|
||||
return Err(invalid("归档中的 Skill 目录名无效"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract<R: Read + Seek>(reader: R, destination: &Path) -> Result<()> {
|
||||
let mut archive = zip::ZipArchive::new(reader).map_err(zip_error)?;
|
||||
let mut seen = HashSet::new();
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive.by_index(index).map_err(zip_error)?;
|
||||
let name = entry.name().trim_end_matches('/');
|
||||
let relative = safe_relative(name)?;
|
||||
let mode = entry.unix_mode().unwrap_or(0) & 0o170000;
|
||||
if mode != 0 && mode != 0o100000 && mode != 0o040000 {
|
||||
return Err(invalid("ZIP 不支持符号链接或特殊文件"));
|
||||
}
|
||||
if !seen.insert(name.to_ascii_lowercase()) {
|
||||
return Err(invalid("ZIP 中含有重复文件路径"));
|
||||
}
|
||||
let output = destination.join(relative);
|
||||
if entry.is_dir() {
|
||||
ensure_directory(&output)?;
|
||||
} else {
|
||||
ensure_directory(output.parent().ok_or_else(|| invalid("ZIP 路径无效"))?)?;
|
||||
let mut file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&output)
|
||||
.map_err(|error| io_err(&output, error))?;
|
||||
std::io::copy(&mut entry, &mut file).map_err(|error| io_err(&output, error))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Some(mode) = entry.unix_mode() {
|
||||
fs::set_permissions(&output, fs::Permissions::from_mode(mode & 0o777))
|
||||
.map_err(|error| io_err(&output, error))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_entries(root: &Path, current: &Path, output: &mut Vec<PathBuf>) -> Result<()> {
|
||||
let metadata = fs::symlink_metadata(current).map_err(|error| io_err(current, error))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err(invalid("Skill 包含符号链接,请先替换为实际文件再导出"));
|
||||
}
|
||||
if metadata.is_file() {
|
||||
output.push(
|
||||
current
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| invalid("Skill 文件路径无效"))?
|
||||
.to_path_buf(),
|
||||
);
|
||||
} else if metadata.is_dir() {
|
||||
if current != root {
|
||||
output.push(
|
||||
current
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| invalid("Skill 目录路径无效"))?
|
||||
.to_path_buf(),
|
||||
);
|
||||
}
|
||||
for entry in fs::read_dir(current).map_err(|error| io_err(current, error))? {
|
||||
collect_entries(
|
||||
root,
|
||||
&entry.map_err(|error| io_err(current, error))?.path(),
|
||||
output,
|
||||
)?;
|
||||
}
|
||||
} else {
|
||||
return Err(invalid("Skill 包含不支持的特殊文件"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn directory_digest(path: &Path) -> Result<Vec<u8>> {
|
||||
let mut files = Vec::new();
|
||||
collect_entries(path, path, &mut files)?;
|
||||
files.sort();
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
for relative in files {
|
||||
let name = relative.to_string_lossy().replace('\\', "/");
|
||||
digest.update((name.len() as u64).to_le_bytes());
|
||||
digest.update(name.as_bytes());
|
||||
let file_path = path.join(relative);
|
||||
if file_path.is_dir() {
|
||||
digest.update(b"directory");
|
||||
continue;
|
||||
}
|
||||
digest.update(b"file");
|
||||
let mut file = File::open(&file_path).map_err(|error| io_err(&file_path, error))?;
|
||||
digest.update(
|
||||
file.metadata()
|
||||
.map_err(|error| io_err(&file_path, error))?
|
||||
.len()
|
||||
.to_le_bytes(),
|
||||
);
|
||||
loop {
|
||||
let count = file
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| io_err(&file_path, error))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buffer[..count]);
|
||||
}
|
||||
}
|
||||
Ok(digest.finalize().to_vec())
|
||||
}
|
||||
fn write_archive(path: &Path, manifest: &Manifest, sources: &[PathBuf]) -> Result<()> {
|
||||
let file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
}
|
||||
let mut writer = zip::ZipWriter::new(file);
|
||||
let options = SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Deflated)
|
||||
.large_file(true);
|
||||
writer.start_file(MANIFEST, options).map_err(zip_error)?;
|
||||
serde_json::to_writer_pretty(&mut writer, manifest).map_err(|_| invalid("无法写入归档清单"))?;
|
||||
for (entry, source) in manifest.skills.iter().zip(sources) {
|
||||
let mut files = Vec::new();
|
||||
collect_entries(source, source, &mut files)?;
|
||||
files.sort();
|
||||
for relative in files {
|
||||
let relative_name = relative.to_string_lossy().replace('\\', "/");
|
||||
safe_relative(&relative_name)?;
|
||||
let source_file = source.join(&relative);
|
||||
let mut options = options;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
options = options.unix_permissions(
|
||||
fs::metadata(&source_file)
|
||||
.map_err(|error| io_err(&source_file, error))?
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
);
|
||||
}
|
||||
if source_file.is_dir() {
|
||||
writer
|
||||
.add_directory(format!("{}/{}/", entry.path, relative_name), options)
|
||||
.map_err(zip_error)?;
|
||||
continue;
|
||||
}
|
||||
writer
|
||||
.start_file(format!("{}/{}", entry.path, relative_name), options)
|
||||
.map_err(zip_error)?;
|
||||
let mut input =
|
||||
File::open(&source_file).map_err(|error| io_err(&source_file, error))?;
|
||||
std::io::copy(&mut input, &mut writer).map_err(|error| io_err(path, error))?;
|
||||
}
|
||||
}
|
||||
writer
|
||||
.finish()
|
||||
.map_err(zip_error)?
|
||||
.sync_all()
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn destination_snapshot(path: &Path) -> Result<Option<Vec<u8>>> {
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(io_err(path, error)),
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err(invalid("请选择普通 ZIP 文件路径,不能覆盖符号链接或目录"));
|
||||
}
|
||||
let mut file = File::open(path).map_err(|error| io_err(path, error))?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let count = file
|
||||
.read(&mut buffer)
|
||||
.map_err(|error| io_err(path, error))?;
|
||||
if count == 0 {
|
||||
break;
|
||||
}
|
||||
digest.update(&buffer[..count]);
|
||||
}
|
||||
Ok(Some(digest.finalize().to_vec()))
|
||||
}
|
||||
|
||||
fn write_archive_atomic<BeforeReplace: FnOnce() -> Result<()>>(
|
||||
output: &Path,
|
||||
manifest: &Manifest,
|
||||
sources: &[PathBuf],
|
||||
before_replace: BeforeReplace,
|
||||
) -> Result<()> {
|
||||
let initial = destination_snapshot(output)?;
|
||||
let parent = output
|
||||
.parent()
|
||||
.ok_or_else(|| invalid("请选择有效的保存目录"))?;
|
||||
let temporary = parent.join(unique_name("codex-x-export"));
|
||||
let result = (|| {
|
||||
write_archive(&temporary, manifest, sources)?;
|
||||
before_replace()?;
|
||||
if destination_snapshot(output)? != initial {
|
||||
return Err(invalid(
|
||||
"保存位置的文件已被其他程序修改,请重新选择位置再导出",
|
||||
));
|
||||
}
|
||||
fs::rename(&temporary, output).map_err(|error| io_err(output, error))
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn validate_export_destination(output: &Path, codex_dir: &Path) -> Result<PathBuf> {
|
||||
if !output
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("zip"))
|
||||
{
|
||||
return Err(invalid("请将导出文件保存为 .zip 格式"));
|
||||
}
|
||||
let parent = output
|
||||
.parent()
|
||||
.filter(|parent| parent.is_dir())
|
||||
.ok_or_else(|| invalid("请选择有效的保存目录"))?;
|
||||
let canonical_parent = fs::canonicalize(parent).map_err(|error| io_err(parent, error))?;
|
||||
for protected in [codex_dir.to_path_buf(), app_home()?] {
|
||||
let canonical = fs::canonicalize(&protected).unwrap_or(protected);
|
||||
if canonical_parent.starts_with(canonical) {
|
||||
return Err(invalid(
|
||||
"请将 ZIP 保存到下载、桌面等目录,不要放入 Codex 配置或应用数据目录",
|
||||
));
|
||||
}
|
||||
}
|
||||
destination_snapshot(output)?;
|
||||
Ok(canonical_parent)
|
||||
}
|
||||
|
||||
pub(crate) fn export_skills_mcp_archive_inner(
|
||||
config_dir: Option<String>,
|
||||
kind: String,
|
||||
destination: String,
|
||||
) -> Result<SkillsMcpExportResult> {
|
||||
if !matches!(kind.as_str(), "skills" | "mcp") {
|
||||
return Err(invalid("请选择导出 Skills 或 MCP"));
|
||||
}
|
||||
let state = build_skills_mcp_state_inner(config_dir)?;
|
||||
let mut manifest = Manifest {
|
||||
format: FORMAT.to_owned(),
|
||||
version: 1,
|
||||
skills: vec![],
|
||||
mcp_servers: vec![],
|
||||
};
|
||||
let mut sources = Vec::new();
|
||||
if kind == "skills" {
|
||||
for skill in state.skills {
|
||||
safe_directory(&skill.directory)?;
|
||||
manifest.skills.push(SkillEntry {
|
||||
path: format!("skills/{}", skill.directory),
|
||||
directory: skill.directory,
|
||||
enabled: skill.enabled,
|
||||
note: skill.note,
|
||||
});
|
||||
sources.push(PathBuf::from(skill.path));
|
||||
}
|
||||
} else {
|
||||
for mcp in state.mcp_servers {
|
||||
manifest.mcp_servers.push(McpEntry {
|
||||
id: mcp.id,
|
||||
name: mcp.name,
|
||||
config: mcp.config_json,
|
||||
enabled: mcp.enabled,
|
||||
note: mcp.note,
|
||||
});
|
||||
}
|
||||
}
|
||||
if manifest.skills.is_empty() && manifest.mcp_servers.is_empty() {
|
||||
return Err(invalid("当前没有可导出的内容"));
|
||||
}
|
||||
let output = PathBuf::from(destination);
|
||||
let canonical_parent = validate_export_destination(&output, Path::new(&state.codex_dir))?;
|
||||
for source in &sources {
|
||||
let canonical_source = fs::canonicalize(source).map_err(|error| io_err(source, error))?;
|
||||
if canonical_parent.starts_with(canonical_source) {
|
||||
return Err(invalid("请将 ZIP 保存在 Skill 目录之外"));
|
||||
}
|
||||
}
|
||||
write_archive_atomic(&output, &manifest, &sources, || Ok(()))?;
|
||||
Ok(SkillsMcpExportResult {
|
||||
path: output.display().to_string(),
|
||||
exported_skills: manifest.skills.len(),
|
||||
exported_mcp: manifest.mcp_servers.len(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn install_skill_archive_path_inner(
|
||||
config_dir: Option<String>,
|
||||
path: String,
|
||||
) -> Result<SkillsMcpActionResult> {
|
||||
let path = Path::new(&path);
|
||||
let file = File::open(path).map_err(|error| io_err(path, error))?;
|
||||
install_archive_reader(
|
||||
config_dir,
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("skills.zip")
|
||||
.to_owned(),
|
||||
file,
|
||||
)
|
||||
}
|
||||
|
||||
fn find_skill_roots(path: &Path, output: &mut Vec<PathBuf>) -> Result<()> {
|
||||
if path.join("SKILL.md").is_file() {
|
||||
output.push(path.to_path_buf());
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(path).map_err(|error| io_err(path, error))? {
|
||||
let entry = entry.map_err(|error| io_err(path, error))?;
|
||||
if entry
|
||||
.file_type()
|
||||
.map_err(|error| io_err(path, error))?
|
||||
.is_dir()
|
||||
{
|
||||
find_skill_roots(&entry.path(), output)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn manifest_from_extracted(path: &Path, file_name: &str) -> Result<Manifest> {
|
||||
let manifest_path = path.join(MANIFEST);
|
||||
if manifest_path.exists() {
|
||||
let file = File::open(&manifest_path).map_err(|error| io_err(&manifest_path, error))?;
|
||||
// This limits only metadata, not ZIP contents or Skill file sizes.
|
||||
let manifest: Manifest = serde_json::from_reader(file.take(8 * 1024 * 1024))
|
||||
.map_err(|_| invalid("Codex-X 归档清单无效或过大"))?;
|
||||
if manifest.format != FORMAT || manifest.version != 1 {
|
||||
return Err(invalid("暂不支持此归档版本,请更新 Codex-X"));
|
||||
}
|
||||
return Ok(manifest);
|
||||
}
|
||||
let mut roots = Vec::new();
|
||||
find_skill_roots(path, &mut roots)?;
|
||||
let mut manifest = Manifest {
|
||||
format: FORMAT.to_owned(),
|
||||
version: 1,
|
||||
skills: vec![],
|
||||
mcp_servers: vec![],
|
||||
};
|
||||
for root in roots {
|
||||
let fallback = if root == path {
|
||||
file_name.trim_end_matches(".zip")
|
||||
} else {
|
||||
root.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(file_name.trim_end_matches(".zip"))
|
||||
};
|
||||
let (name, _) = read_skill_metadata(&root, fallback);
|
||||
let relative = root
|
||||
.strip_prefix(path)
|
||||
.map_err(|_| invalid("Skill 路径无效"))?
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
manifest.skills.push(SkillEntry {
|
||||
directory: sanitize_dir_name(&name, "skill"),
|
||||
path: relative,
|
||||
enabled: true,
|
||||
note: None,
|
||||
});
|
||||
}
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
pub(super) fn install_archive_reader<R: Read + Seek>(
|
||||
config_dir: Option<String>,
|
||||
file_name: String,
|
||||
reader: R,
|
||||
) -> Result<SkillsMcpActionResult> {
|
||||
let temporary = TempDir::new(&app_home()?.join("tmp"))?;
|
||||
extract(reader, &temporary.0)?;
|
||||
let manifest = manifest_from_extracted(&temporary.0, &file_name)?;
|
||||
let (imported_skills, imported_mcp, skipped) =
|
||||
import_manifest(config_dir.clone(), &temporary.0, manifest)?;
|
||||
Ok(SkillsMcpActionResult {
|
||||
imported_skills,
|
||||
imported_mcp,
|
||||
message: format!(
|
||||
"已导入 {imported_skills} 个 Skills、{imported_mcp} 个 MCP{}",
|
||||
if skipped > 0 {
|
||||
format!(",跳过 {skipped} 个已存在的项目")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
),
|
||||
state: build_skills_mcp_state_inner(config_dir)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn import_manifest(
|
||||
config_dir: Option<String>,
|
||||
root: &Path,
|
||||
manifest: Manifest,
|
||||
) -> Result<(usize, usize, usize)> {
|
||||
if manifest.skills.is_empty() && manifest.mcp_servers.is_empty() {
|
||||
return Err(invalid("ZIP 中没有可导入的 Skills 或 MCP"));
|
||||
}
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
ensure_directory(&codex_dir)?;
|
||||
let _lock = acquire_live_config_lock(&codex_dir)?;
|
||||
let current = build_skills_mcp_state_inner(config_dir)?;
|
||||
let cfg = config_path(&codex_dir);
|
||||
let before = read_file_snapshot(&cfg)?;
|
||||
let mut document = parse_toml_document(&cfg, &text_from_snapshot(&cfg, before.as_deref())?)?;
|
||||
let mut skill_ids = HashSet::new();
|
||||
let mut mcp_ids = HashSet::new();
|
||||
let existing_skills: HashMap<_, _> = current
|
||||
.skills
|
||||
.iter()
|
||||
.map(|skill| (skill.directory.as_str(), skill))
|
||||
.collect();
|
||||
let existing_mcp: HashMap<_, _> = current
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.map(|mcp| (mcp.id.as_str(), mcp))
|
||||
.collect();
|
||||
let mut skill_plan = Vec::new();
|
||||
let mut mcp_plan = Vec::new();
|
||||
let mut skipped = 0;
|
||||
for skill in manifest.skills {
|
||||
safe_directory(&skill.directory)?;
|
||||
if !skill_ids.insert(sanitize_dir_name(&skill.directory, "skill")) {
|
||||
return Err(invalid("归档中存在同名 Skill"));
|
||||
}
|
||||
let relative = if skill.path.is_empty() {
|
||||
PathBuf::new()
|
||||
} else {
|
||||
safe_relative(&skill.path)?
|
||||
};
|
||||
let source = root.join(relative);
|
||||
if !source.join("SKILL.md").is_file() {
|
||||
return Err(invalid(format!("Skill {} 缺少 SKILL.md", skill.directory)));
|
||||
}
|
||||
if let Some(existing) = existing_skills.get(skill.directory.as_str()) {
|
||||
if directory_digest(&source)? == directory_digest(Path::new(&existing.path))? {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(invalid(format!(
|
||||
"Skill「{}」已存在且内容不同。请先重命名或移除已有项目,导入不会覆盖它。",
|
||||
skill.directory
|
||||
)));
|
||||
}
|
||||
let parent = if skill.enabled {
|
||||
codex_skills_dir(&codex_dir)
|
||||
} else {
|
||||
disabled_skills_dir()?
|
||||
};
|
||||
let destination = parent.join(&skill.directory);
|
||||
if destination.exists() {
|
||||
return Err(invalid(format!(
|
||||
"Skill 目录「{}」已存在,未覆盖",
|
||||
skill.directory
|
||||
)));
|
||||
}
|
||||
skill_plan.push((skill, source, destination));
|
||||
}
|
||||
for mcp in manifest.mcp_servers {
|
||||
if mcp.id.trim().is_empty()
|
||||
|| !mcp_ids.insert(mcp.id.clone())
|
||||
|| !is_valid_mcp_config(&mcp.config)
|
||||
{
|
||||
return Err(invalid("归档中存在无效或重复的 MCP 配置"));
|
||||
}
|
||||
if let Some(existing) = existing_mcp.get(mcp.id.as_str()) {
|
||||
if mcp_configs_equal(&existing.config_json, &mcp.config) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
return Err(invalid(format!(
|
||||
"MCP「{}」已存在且配置不同。请先重命名或移除已有项目,导入不会覆盖它。",
|
||||
mcp.name
|
||||
)));
|
||||
}
|
||||
// A malformed live entry is still user data; do not overwrite it.
|
||||
if document
|
||||
.get("mcp_servers")
|
||||
.and_then(|item| item.as_table())
|
||||
.is_some_and(|table| table.contains_key(&mcp.id))
|
||||
{
|
||||
return Err(invalid(format!("MCP「{}」已存在,未覆盖", mcp.name)));
|
||||
}
|
||||
mcp_plan.push(mcp);
|
||||
}
|
||||
let mut connection = open_db()?;
|
||||
let transaction = connection
|
||||
.transaction_with_behavior(TransactionBehavior::Immediate)
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
let scope = normalized_path_scope(&codex_dir);
|
||||
let save_note = |kind: &str, id: &str, note: &Option<String>| -> Result<()> {
|
||||
if let Some(note) = note.as_deref().filter(|note| !note.trim().is_empty()) {
|
||||
if note.chars().count() > super::SKILLS_MCP_NOTE_MAX_CHARS {
|
||||
return Err(invalid("归档中的备注过长"));
|
||||
}
|
||||
transaction.execute("INSERT INTO skills_mcp_notes (codex_dir,item_kind,item_id,note,updated_at) VALUES (?1,?2,?3,?4,?5) ON CONFLICT(codex_dir,item_kind,item_id) DO NOTHING", params![scope, kind, id, note, now_rfc3339()]).map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
for mcp in &mcp_plan {
|
||||
save_managed_mcp_on_connection(&transaction, &mcp.id, &mcp.name, &mcp.config, mcp.enabled)?;
|
||||
if mcp.enabled {
|
||||
ensure_table(document.as_table_mut(), "mcp_servers")?
|
||||
.insert(&mcp.id, json_to_toml_item(&mcp.config));
|
||||
}
|
||||
save_note("mcp", &mcp.id, &mcp.note)?;
|
||||
}
|
||||
for (skill, _, _) in &skill_plan {
|
||||
save_note(
|
||||
"skill",
|
||||
&sanitize_dir_name(&skill.directory, "skill"),
|
||||
&skill.note,
|
||||
)?;
|
||||
}
|
||||
let mut created = Vec::new();
|
||||
let apply = (|| -> Result<()> {
|
||||
for (_, source, destination) in &skill_plan {
|
||||
let staging = TempDir::new(
|
||||
destination
|
||||
.parent()
|
||||
.ok_or_else(|| invalid("Skill 目标目录无效"))?,
|
||||
)?;
|
||||
copy_dir_recursive(source, &staging.0)?;
|
||||
if destination.exists() {
|
||||
return Err(invalid("导入期间出现同名 Skill,已停止且未覆盖"));
|
||||
}
|
||||
fs::rename(&staging.0, destination).map_err(|error| io_err(destination, error))?;
|
||||
created.push(destination.clone());
|
||||
}
|
||||
let after = document_bytes(before.as_deref(), &document);
|
||||
commit_mcp_transaction_with_config(
|
||||
transaction,
|
||||
&codex_dir,
|
||||
before,
|
||||
after,
|
||||
"import-skills-mcp-zip",
|
||||
|_| Ok(()),
|
||||
|_| Ok(()),
|
||||
)
|
||||
})();
|
||||
if apply.is_err() {
|
||||
for path in created {
|
||||
let _ = fs::remove_dir_all(path);
|
||||
}
|
||||
}
|
||||
apply?;
|
||||
Ok((skill_plan.len(), mcp_plan.len(), skipped))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
fn manifest() -> Manifest {
|
||||
Manifest {
|
||||
format: FORMAT.into(),
|
||||
version: 1,
|
||||
skills: vec![],
|
||||
mcp_servers: vec![],
|
||||
}
|
||||
}
|
||||
fn test_dir() -> TempDir {
|
||||
TempDir::new(&std::env::temp_dir().join("codex-x-archive-tests")).unwrap()
|
||||
}
|
||||
fn codex(dir: &Path) -> Option<String> {
|
||||
fs::create_dir_all(dir).unwrap();
|
||||
Some(dir.display().to_string())
|
||||
}
|
||||
fn skill(root: &Path, directory: &str, enabled: bool) -> SkillEntry {
|
||||
let path = root.join("skills").join(directory);
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
fs::write(
|
||||
path.join("SKILL.md"),
|
||||
format!("---\nname: {directory}\ndescription: Example\n---\n# Readme"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(path.join(".extra"), b"hidden file").unwrap();
|
||||
fs::create_dir_all(path.join("empty-resources")).unwrap();
|
||||
SkillEntry {
|
||||
directory: directory.into(),
|
||||
path: format!("skills/{directory}"),
|
||||
enabled,
|
||||
note: Some("My skill note".into()),
|
||||
}
|
||||
}
|
||||
fn zip_entries(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, data) in entries {
|
||||
writer
|
||||
.start_file(
|
||||
*name,
|
||||
SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored),
|
||||
)
|
||||
.unwrap();
|
||||
writer.write_all(data).unwrap();
|
||||
}
|
||||
writer.finish().unwrap().into_inner()
|
||||
}
|
||||
fn clean_mcp(id: &str) {
|
||||
let db = open_db().unwrap();
|
||||
db.execute("DELETE FROM managed_mcp_servers WHERE id=?1", [id])
|
||||
.unwrap();
|
||||
db.execute("DELETE FROM skills_mcp_notes WHERE item_id=?1", [id])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zip_bundle_round_trip_preserves_files_notes_disabled_skills_and_mcp_settings() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
let source = temporary.0.join("source");
|
||||
let destination = temporary.0.join("codex");
|
||||
let directory = format!("archive-roundtrip-{}", std::process::id());
|
||||
let id = format!("archive-roundtrip-mcp-{}", std::process::id());
|
||||
clean_mcp(&id);
|
||||
let disabled = disabled_skills_dir().unwrap().join(&directory);
|
||||
let _ = fs::remove_dir_all(&disabled);
|
||||
let mut bundle = manifest();
|
||||
bundle.skills.push(skill(&source, &directory, false));
|
||||
bundle.mcp_servers.push(McpEntry { id: id.clone(), name: "Portable MCP".into(), config: json!({"command":"example-server", "args":["--safe"], "env":{"TOKEN":"synthetic-test-token"}}), enabled: true, note: Some("MCP note".into()) });
|
||||
let output = temporary.0.join("bundle.zip");
|
||||
write_archive(&output, &bundle, &[source.join("skills").join(&directory)]).unwrap();
|
||||
fs::create_dir_all(&destination).unwrap();
|
||||
fs::write(
|
||||
destination.join("config.toml"),
|
||||
"# Keep this\nmodel = \"gpt-test\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let imported =
|
||||
install_skill_archive_path_inner(codex(&destination), output.display().to_string())
|
||||
.unwrap();
|
||||
assert_eq!((imported.imported_skills, imported.imported_mcp), (1, 1));
|
||||
let skill = imported
|
||||
.state
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.id == directory)
|
||||
.unwrap();
|
||||
assert!(!skill.enabled);
|
||||
assert_eq!(skill.note.as_deref(), Some("My skill note"));
|
||||
assert_eq!(fs::read(disabled.join(".extra")).unwrap(), b"hidden file");
|
||||
assert!(disabled.join("empty-resources").is_dir());
|
||||
let mcp = imported
|
||||
.state
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|mcp| mcp.id == id)
|
||||
.unwrap();
|
||||
assert_eq!(mcp.note.as_deref(), Some("MCP note"));
|
||||
assert!(mcp.enabled);
|
||||
assert!(fs::read_to_string(destination.join("config.toml"))
|
||||
.unwrap()
|
||||
.starts_with("# Keep this"));
|
||||
let second =
|
||||
install_skill_archive_path_inner(codex(&destination), output.display().to_string())
|
||||
.unwrap();
|
||||
assert_eq!((second.imported_skills, second.imported_mcp), (0, 0));
|
||||
fs::remove_dir_all(disabled).unwrap();
|
||||
clean_mcp(&id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regular_skill_zip_larger_than_twenty_megabytes_installs_without_size_limit() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
let payload = vec![b'x'; 21 * 1024 * 1024];
|
||||
let bytes = zip_entries(&[
|
||||
(
|
||||
"package/SKILL.md",
|
||||
b"---\nname: large-archive-fixture\n---\n",
|
||||
),
|
||||
("package/data.bin", &payload),
|
||||
]);
|
||||
assert!(bytes.len() > 20 * 1024 * 1024);
|
||||
let result = install_archive_reader(
|
||||
codex(&temporary.0.join("codex")),
|
||||
"large.zip".into(),
|
||||
Cursor::new(bytes),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.imported_skills, 1);
|
||||
assert_eq!(
|
||||
fs::metadata(
|
||||
temporary
|
||||
.0
|
||||
.join("codex/skills/large-archive-fixture/data.bin")
|
||||
)
|
||||
.unwrap()
|
||||
.len(),
|
||||
payload.len() as u64
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicting_bundle_is_rejected_before_any_skill_or_mcp_is_written() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
let source = temporary.0.join("source");
|
||||
let destination = temporary.0.join("codex");
|
||||
let mut bundle = manifest();
|
||||
bundle
|
||||
.skills
|
||||
.push(skill(&source, "archive-new-first", true));
|
||||
bundle
|
||||
.skills
|
||||
.push(skill(&source, "archive-existing-second", true));
|
||||
let existing = destination.join("skills/archive-existing-second");
|
||||
fs::create_dir_all(&existing).unwrap();
|
||||
fs::write(existing.join("SKILL.md"), "original").unwrap();
|
||||
assert!(import_manifest(codex(&destination), &source, bundle).is_err());
|
||||
assert!(!destination.join("skills/archive-new-first").exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(existing.join("SKILL.md")).unwrap(),
|
||||
"original"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_duplicate_and_link_zip_entries_are_rejected() {
|
||||
let temporary = test_dir();
|
||||
for name in [
|
||||
"../escape",
|
||||
"/absolute",
|
||||
"C:/drive",
|
||||
"folder\\escape",
|
||||
"CON.txt",
|
||||
] {
|
||||
let target = TempDir::new(&temporary.0).unwrap();
|
||||
assert!(
|
||||
extract(Cursor::new(zip_entries(&[(name, b"unsafe")])), &target.0).is_err(),
|
||||
"accepted {name}"
|
||||
);
|
||||
}
|
||||
let target = TempDir::new(&temporary.0).unwrap();
|
||||
assert!(extract(
|
||||
Cursor::new(zip_entries(&[("a", b"1"), ("A", b"2")])),
|
||||
&target.0
|
||||
)
|
||||
.is_err());
|
||||
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
writer
|
||||
.add_symlink("link", "../outside", SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
assert!(extract(
|
||||
Cursor::new(writer.finish().unwrap().into_inner()),
|
||||
&TempDir::new(&temporary.0).unwrap().0
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_unknown_version_archives_are_rejected() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
assert!(install_archive_reader(
|
||||
codex(&temporary.0.join("codex")),
|
||||
"empty.zip".into(),
|
||||
Cursor::new(zip_entries(&[("readme.txt", b"No skills")]))
|
||||
)
|
||||
.is_err());
|
||||
let mut unknown = manifest();
|
||||
unknown.version = 99;
|
||||
let bytes = serde_json::to_vec(&unknown).unwrap();
|
||||
assert!(install_archive_reader(
|
||||
codex(&temporary.0.join("codex")),
|
||||
"future.zip".into(),
|
||||
Cursor::new(zip_entries(&[(MANIFEST, &bytes)]))
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_skill_archive_can_be_imported_with_original_state() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
let source = temporary.0.join("source");
|
||||
let destination = temporary.0.join("destination");
|
||||
let directory = format!("archive-export-{}", std::process::id());
|
||||
skill(&source, &directory, true);
|
||||
let archive = temporary.0.join("export.zip");
|
||||
let result = export_skills_mcp_archive_inner(
|
||||
codex(&source),
|
||||
"skills".into(),
|
||||
archive.display().to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(result.exported_skills >= 1);
|
||||
assert_eq!(result.exported_mcp, 0);
|
||||
let imported =
|
||||
install_skill_archive_path_inner(codex(&destination), archive.display().to_string())
|
||||
.unwrap();
|
||||
assert!(imported
|
||||
.state
|
||||
.skills
|
||||
.iter()
|
||||
.any(|skill| skill.directory == directory && skill.enabled));
|
||||
assert_eq!(
|
||||
directory_digest(&source.join("skills").join(&directory)).unwrap(),
|
||||
directory_digest(&destination.join("skills").join(&directory)).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_mcp_id_with_different_configuration_does_not_overwrite_live_config() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let temporary = test_dir();
|
||||
let destination = temporary.0.join("codex");
|
||||
codex(&destination);
|
||||
let original = "[mcp_servers.archive_conflict]\ncommand = \"existing\"\n";
|
||||
fs::write(destination.join("config.toml"), original).unwrap();
|
||||
let mut bundle = manifest();
|
||||
bundle.mcp_servers.push(McpEntry {
|
||||
id: "archive_conflict".into(),
|
||||
name: "Conflict".into(),
|
||||
config: json!({"command":"different"}),
|
||||
enabled: true,
|
||||
note: None,
|
||||
});
|
||||
assert!(import_manifest(codex(&destination), &temporary.0, bundle).is_err());
|
||||
assert_eq!(
|
||||
fs::read_to_string(destination.join("config.toml")).unwrap(),
|
||||
original
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn export_refuses_changed_destinations_and_cleans_temporary_output() {
|
||||
let temporary = test_dir();
|
||||
let output = temporary.0.join("export.zip");
|
||||
fs::write(&output, b"before").unwrap();
|
||||
let result = write_archive_atomic(&output, &manifest(), &[], || {
|
||||
fs::write(&output, b"external-update").unwrap();
|
||||
Ok(())
|
||||
});
|
||||
assert!(result.is_err());
|
||||
assert_eq!(fs::read(&output).unwrap(), b"external-update");
|
||||
assert_eq!(fs::read_dir(&temporary.0).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_rejects_codex_data_and_non_zip_destinations() {
|
||||
let temporary = test_dir();
|
||||
let codex = temporary.0.join("codex");
|
||||
fs::create_dir_all(&codex).unwrap();
|
||||
assert!(validate_export_destination(&codex.join("backup.zip"), &codex).is_err());
|
||||
assert!(validate_export_destination(&temporary.0.join("auth.json"), &codex).is_err());
|
||||
assert!(validate_export_destination(&temporary.0.join("backup.zip"), &codex).is_ok());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let file = temporary.0.join("original.zip");
|
||||
fs::write(&file, "original").unwrap();
|
||||
let link = temporary.0.join("symlink.zip");
|
||||
std::os::unix::fs::symlink(&file, &link).unwrap();
|
||||
assert!(validate_export_destination(&link, &codex).is_err());
|
||||
assert_eq!(fs::read_to_string(file).unwrap(), "original");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,14 @@ fn toml_value_to_json(value: &toml_edit::Value) -> Value {
|
||||
if let Some(b) = value.as_bool() {
|
||||
return json!(b);
|
||||
}
|
||||
if let Some(table) = value.as_inline_table() {
|
||||
return Value::Object(
|
||||
table
|
||||
.iter()
|
||||
.map(|(key, value)| (key.to_owned(), toml_value_to_json(value)))
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
if let Some(arr) = value.as_array() {
|
||||
return Value::Array(arr.iter().map(toml_value_to_json).collect());
|
||||
}
|
||||
@@ -53,7 +61,7 @@ fn toml_item_to_json(item: &Item) -> Value {
|
||||
Value::Null
|
||||
}
|
||||
|
||||
fn json_to_toml_item(value_json: &Value) -> Item {
|
||||
pub(super) fn json_to_toml_item(value_json: &Value) -> Item {
|
||||
match value_json {
|
||||
Value::String(s) => value(s.clone()),
|
||||
Value::Bool(b) => value(*b),
|
||||
@@ -143,7 +151,7 @@ pub(super) fn mcp_summary(config: &Value) -> (String, Option<String>, Option<Str
|
||||
(transport, command, url, summary)
|
||||
}
|
||||
|
||||
fn save_managed_mcp_on_connection(
|
||||
pub(super) fn save_managed_mcp_on_connection(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
name: &str,
|
||||
@@ -198,14 +206,17 @@ fn document_mcp_ids(doc: &toml_edit::DocumentMut) -> HashSet<String> {
|
||||
.map(|table| {
|
||||
table
|
||||
.iter()
|
||||
.filter(|(_, item)| item.is_table())
|
||||
.filter(|(_, item)| item.as_table_like().is_some())
|
||||
.map(|(id, _)| id.to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn document_bytes(snapshot: Option<&[u8]>, doc: &toml_edit::DocumentMut) -> Option<Vec<u8>> {
|
||||
pub(super) fn document_bytes(
|
||||
snapshot: Option<&[u8]>,
|
||||
doc: &toml_edit::DocumentMut,
|
||||
) -> Option<Vec<u8>> {
|
||||
let bytes = doc.to_string().into_bytes();
|
||||
if snapshot.is_none() && bytes.is_empty() {
|
||||
None
|
||||
@@ -214,7 +225,7 @@ fn document_bytes(snapshot: Option<&[u8]>, doc: &toml_edit::DocumentMut) -> Opti
|
||||
}
|
||||
}
|
||||
|
||||
fn commit_mcp_transaction_with_config<BeforeApply, BeforeCommit>(
|
||||
pub(super) fn commit_mcp_transaction_with_config<BeforeApply, BeforeCommit>(
|
||||
transaction: Transaction<'_>,
|
||||
codex_dir: &Path,
|
||||
before: Option<Vec<u8>>,
|
||||
@@ -285,16 +296,22 @@ pub(super) fn list_mcp_from_config(codex_dir: &Path) -> Result<Vec<ManagedMcpSer
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for (id, item) in mcp_tbl.iter() {
|
||||
if !item.is_table() {
|
||||
if item.as_table_like().is_none() {
|
||||
continue;
|
||||
}
|
||||
let config = toml_item_to_json(item);
|
||||
if !is_valid_mcp_config(&config) {
|
||||
continue;
|
||||
}
|
||||
let (transport, command, url, summary) = mcp_summary(&config);
|
||||
out.push(ManagedMcpServer {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
transport,
|
||||
enabled: true,
|
||||
enabled: config
|
||||
.get("enabled")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(true),
|
||||
source: "config.toml".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
@@ -319,51 +336,7 @@ pub(super) fn import_ccswitch_mcp_servers_for_codex(
|
||||
codex_dir: &Path,
|
||||
imported_ids: &mut HashSet<String>,
|
||||
) -> Result<usize> {
|
||||
let db = default_ccswitch_db_path()?;
|
||||
if !db.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let conn = Connection::open_with_flags(
|
||||
&db,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| {
|
||||
CodexxError::Database(format!(
|
||||
"打开 cc-switch MCP 数据库失败 {}: {e}",
|
||||
db.display()
|
||||
))
|
||||
})?;
|
||||
let mut stmt = match conn
|
||||
.prepare("SELECT id, name, server_config, enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
|
||||
.or_else(|_| {
|
||||
conn.prepare("SELECT id, name, server_config, 0 AS enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
|
||||
}) {
|
||||
Ok(stmt) => stmt,
|
||||
Err(rusqlite::Error::SqliteFailure(_, Some(message)))
|
||||
if message.to_lowercase().contains("no such table") =>
|
||||
{
|
||||
return Ok(0);
|
||||
}
|
||||
Err(e) => return Err(CodexxError::Database(e.to_string())),
|
||||
};
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, bool>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let mut candidates = Vec::new();
|
||||
for row in rows {
|
||||
let (id, name, config_text, enabled_codex) =
|
||||
row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let config: Value =
|
||||
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default()));
|
||||
candidates.push((id, name, config, enabled_codex));
|
||||
}
|
||||
let candidates = new_ccswitch_mcp_candidates(codex_dir, imported_ids)?;
|
||||
|
||||
import_ccswitch_mcp_candidates_with_hooks(
|
||||
codex_dir,
|
||||
@@ -388,7 +361,11 @@ where
|
||||
let mut staged_ids = HashSet::new();
|
||||
let candidates = candidates
|
||||
.into_iter()
|
||||
.filter(|(id, _, _, _)| !imported_ids.contains(id) && staged_ids.insert(id.clone()))
|
||||
.filter(|(id, _, config, _)| {
|
||||
is_valid_mcp_config(config)
|
||||
&& !imported_ids.contains(id)
|
||||
&& staged_ids.insert(id.clone())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if candidates.is_empty() {
|
||||
return Ok(0);
|
||||
@@ -429,9 +406,83 @@ where
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
pub(super) fn preview_ccswitch_mcp_servers_for_codex(
|
||||
codex_dir: &Path,
|
||||
) -> Result<Vec<ManagedMcpServer>> {
|
||||
// Both preview and import use the same validated records. Malformed JSON is never
|
||||
// converted to an empty stdio server.
|
||||
pub(super) fn is_valid_mcp_config(config: &Value) -> bool {
|
||||
let Some(object) = config.as_object() else {
|
||||
return false;
|
||||
};
|
||||
let nonempty = |key| {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
};
|
||||
let command = nonempty("command");
|
||||
let url = nonempty("url");
|
||||
if command == url {
|
||||
return false;
|
||||
}
|
||||
if command
|
||||
&& object.get("args").is_some_and(|args| {
|
||||
!args
|
||||
.as_array()
|
||||
.is_some_and(|values| values.iter().all(Value::is_string))
|
||||
})
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if object.get("env").is_some_and(|env| {
|
||||
!env.as_object()
|
||||
.is_some_and(|values| values.values().all(Value::is_string))
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(super) fn mcp_configs_equal(left: &Value, right: &Value) -> bool {
|
||||
let normalize = |value: &Value| {
|
||||
let mut config = value.clone();
|
||||
if let Some(object) = config.as_object_mut() {
|
||||
// Transport is inferred from command/url by Codex; enabled is stored
|
||||
// separately from the connection settings in managed records.
|
||||
object.remove("type");
|
||||
object.remove("enabled");
|
||||
for key in ["args", "env", "http_headers", "env_http_headers"] {
|
||||
if object.get(key).is_some_and(|value| {
|
||||
value.as_array().is_some_and(Vec::is_empty)
|
||||
|| value.as_object().is_some_and(serde_json::Map::is_empty)
|
||||
}) {
|
||||
object.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
config
|
||||
};
|
||||
normalize(left) == normalize(right)
|
||||
}
|
||||
|
||||
fn normalized_ccswitch_config(config: Value, id: &str) -> Option<Value> {
|
||||
if is_valid_mcp_config(&config) {
|
||||
return Some(config);
|
||||
}
|
||||
for key in ["mcpServers", "mcp_servers"] {
|
||||
if let Some(servers) = config.get(key).and_then(Value::as_object) {
|
||||
let server = servers.get(id).or_else(|| {
|
||||
(servers.len() == 1)
|
||||
.then(|| servers.values().next())
|
||||
.flatten()
|
||||
})?;
|
||||
if is_valid_mcp_config(server) {
|
||||
return Some(server.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_ccswitch_mcp_candidates() -> Result<Vec<CcSwitchMcpCandidate>> {
|
||||
let db = default_ccswitch_db_path()?;
|
||||
if !db.exists() {
|
||||
return Ok(vec![]);
|
||||
@@ -440,29 +491,20 @@ pub(super) fn preview_ccswitch_mcp_servers_for_codex(
|
||||
&db,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)
|
||||
.map_err(|e| {
|
||||
CodexxError::Database(format!(
|
||||
"打开 cc-switch MCP 数据库失败 {}: {e}",
|
||||
db.display()
|
||||
))
|
||||
})?;
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
read_ccswitch_mcp_candidates_from_connection(&conn)
|
||||
}
|
||||
|
||||
fn read_ccswitch_mcp_candidates_from_connection(
|
||||
conn: &Connection,
|
||||
) -> Result<Vec<CcSwitchMcpCandidate>> {
|
||||
let mut stmt = match conn
|
||||
.prepare("SELECT id, name, server_config, enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
|
||||
.or_else(|_| {
|
||||
conn.prepare("SELECT id, name, server_config, 0 AS enabled_codex FROM mcp_servers ORDER BY name ASC, id ASC")
|
||||
}) {
|
||||
.or_else(|_| conn.prepare("SELECT id, name, server_config, 0 FROM mcp_servers ORDER BY name ASC, id ASC")) {
|
||||
Ok(stmt) => stmt,
|
||||
Err(rusqlite::Error::SqliteFailure(_, Some(message)))
|
||||
if message.to_lowercase().contains("no such table") =>
|
||||
{
|
||||
return Ok(vec![]);
|
||||
}
|
||||
Err(e) => return Err(CodexxError::Database(e.to_string())),
|
||||
Err(rusqlite::Error::SqliteFailure(_, Some(message))) if message.to_lowercase().contains("no such table") => return Ok(vec![]),
|
||||
Err(error) => return Err(CodexxError::Database(error.to_string())),
|
||||
};
|
||||
let live_enabled = list_mcp_from_config(codex_dir)?
|
||||
.into_iter()
|
||||
.map(|server| server.id)
|
||||
.collect::<HashSet<_>>();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
@@ -472,30 +514,90 @@ pub(super) fn preview_ccswitch_mcp_servers_for_codex(
|
||||
row.get::<_, bool>(3)?,
|
||||
))
|
||||
})
|
||||
.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, name, config_text, enabled_codex) =
|
||||
row.map_err(|e| CodexxError::Database(e.to_string()))?;
|
||||
let config: Value =
|
||||
serde_json::from_str(&config_text).unwrap_or(Value::Object(Default::default()));
|
||||
let (transport, command, url, summary) = mcp_summary(&config);
|
||||
out.push(ManagedMcpServer {
|
||||
id: id.clone(),
|
||||
name,
|
||||
transport,
|
||||
enabled: enabled_codex || live_enabled.contains(&id),
|
||||
source: "cc-switch".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
});
|
||||
let (id, name, text, enabled) =
|
||||
row.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
if id.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(config) = serde_json::from_str(&text)
|
||||
.ok()
|
||||
.and_then(|config| normalized_ccswitch_config(config, &id))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let name = if name.trim().is_empty() {
|
||||
id.clone()
|
||||
} else {
|
||||
name
|
||||
};
|
||||
out.push((id, name, config, enabled));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn new_ccswitch_mcp_candidates(
|
||||
codex_dir: &Path,
|
||||
already_seen: &HashSet<String>,
|
||||
) -> Result<Vec<CcSwitchMcpCandidate>> {
|
||||
let current = list_mcp_from_config(codex_dir)?;
|
||||
let managed = db_managed_mcp()?;
|
||||
let mut ids = already_seen.clone();
|
||||
let mut configs = Vec::new();
|
||||
for server in current {
|
||||
ids.insert(server.id);
|
||||
configs.push(server.config_json);
|
||||
}
|
||||
for (id, _, config, _) in managed {
|
||||
ids.insert(id);
|
||||
if is_valid_mcp_config(&config) {
|
||||
configs.push(config);
|
||||
}
|
||||
}
|
||||
let mut output = Vec::new();
|
||||
for candidate in read_ccswitch_mcp_candidates()? {
|
||||
if !ids.insert(candidate.0.clone())
|
||||
|| configs
|
||||
.iter()
|
||||
.any(|config| mcp_configs_equal(config, &candidate.2))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
configs.push(candidate.2.clone());
|
||||
output.push(candidate);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
pub(super) fn preview_ccswitch_mcp_servers_for_codex(
|
||||
codex_dir: &Path,
|
||||
) -> Result<Vec<ManagedMcpServer>> {
|
||||
let live_enabled = list_mcp_from_config(codex_dir)?
|
||||
.into_iter()
|
||||
.map(|server| server.id)
|
||||
.collect::<HashSet<_>>();
|
||||
Ok(new_ccswitch_mcp_candidates(codex_dir, &HashSet::new())?
|
||||
.into_iter()
|
||||
.map(|(id, name, config, enabled)| {
|
||||
let (transport, command, url, summary) = mcp_summary(&config);
|
||||
ManagedMcpServer {
|
||||
enabled: enabled || live_enabled.contains(&id),
|
||||
id,
|
||||
name,
|
||||
transport,
|
||||
source: "cc-switch".to_string(),
|
||||
summary,
|
||||
note: None,
|
||||
command,
|
||||
url,
|
||||
config_json: config,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) fn toggle_codex_mcp_inner(
|
||||
config_dir: Option<String>,
|
||||
id: String,
|
||||
@@ -529,8 +631,19 @@ where
|
||||
.map_err(|error| CodexxError::Database(error.to_string()))?;
|
||||
let stored = managed_mcp_on_connection(&transaction, &id)?;
|
||||
if enabled {
|
||||
let (name, config, _) =
|
||||
stored.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
|
||||
let (name, mut config, _) = stored
|
||||
.or_else(|| {
|
||||
doc.get("mcp_servers")
|
||||
.and_then(Item::as_table)
|
||||
.and_then(|table| table.get(&id))
|
||||
.map(|item| (id.clone(), toml_item_to_json(item), false))
|
||||
})
|
||||
.ok_or_else(|| CodexxError::Config(format!("未找到 MCP: {id}")))?;
|
||||
if let Some(object) = config.as_object_mut() {
|
||||
if object.contains_key("enabled") {
|
||||
object.insert("enabled".to_owned(), Value::Bool(true));
|
||||
}
|
||||
}
|
||||
ensure_table(doc.as_table_mut(), "mcp_servers")?.insert(&id, json_to_toml_item(&config));
|
||||
save_managed_mcp_on_connection(&transaction, &id, &name, &config, true)?;
|
||||
} else {
|
||||
@@ -596,6 +709,75 @@ mod tests {
|
||||
.expect("remove test MCP");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabling_existing_unmanaged_disabled_mcp_updates_enabled_flag() {
|
||||
let _guard = crate::app_db::test_db_guard();
|
||||
let (directory, id) = test_case("disabled-unmanaged");
|
||||
let config = format!("[mcp_servers.\"{id}\"]\ncommand = \"fixture\"\nenabled = false\n");
|
||||
std::fs::write(directory.join("config.toml"), config).unwrap();
|
||||
let initial = list_mcp_from_config(&directory).unwrap();
|
||||
assert!(!initial[0].enabled);
|
||||
let result =
|
||||
toggle_codex_mcp_inner(Some(directory.display().to_string()), id.clone(), true)
|
||||
.unwrap();
|
||||
assert!(
|
||||
result
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.find(|server| server.id == id)
|
||||
.unwrap()
|
||||
.enabled
|
||||
);
|
||||
remove_test_mcp(&id);
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ccswitch_candidates_skip_empty_invalid_and_transport_only_records() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE mcp_servers(id TEXT,name TEXT,server_config TEXT,enabled_codex BOOLEAN)",
|
||||
)
|
||||
.unwrap();
|
||||
for (id, data) in [
|
||||
("broken", "invalid"),
|
||||
("empty", "{}"),
|
||||
("transport", r#"{"type":"stdio"}"#),
|
||||
("valid", r#"{"command":"npx","args":["server"]}"#),
|
||||
(
|
||||
"wrapped",
|
||||
r#"{"mcpServers":{"wrapped":{"url":"https://example.test/mcp"}}}"#,
|
||||
),
|
||||
] {
|
||||
conn.execute(
|
||||
"INSERT INTO mcp_servers VALUES(?1,?1,?2,0)",
|
||||
params![id, data],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let candidates = read_ccswitch_mcp_candidates_from_connection(&conn).unwrap();
|
||||
assert_eq!(
|
||||
candidates
|
||||
.iter()
|
||||
.map(|entry| entry.0.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["valid", "wrapped"]
|
||||
);
|
||||
assert_eq!(candidates[1].2["url"], "https://example.test/mcp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_environment_round_trip_retains_mcp_credentials() {
|
||||
let document: toml_edit::DocumentMut =
|
||||
"[mcp_servers.example]\ncommand = \"example\"\nenv = { TOKEN = \"synthetic\" }\n"
|
||||
.parse()
|
||||
.unwrap();
|
||||
let value = toml_item_to_json(&document["mcp_servers"]["example"]);
|
||||
assert!(is_valid_mcp_config(&value));
|
||||
assert_eq!(value["env"]["TOKEN"], "synthetic");
|
||||
assert_eq!(toml_item_to_json(&json_to_toml_item(&value)), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_updates_only_the_target_mcp_and_keeps_database_in_sync() {
|
||||
let _db_guard = crate::app_db::test_db_guard();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
mod archive;
|
||||
mod mcp;
|
||||
pub(crate) use archive::{export_skills_mcp_archive_inner, install_skill_archive_path_inner};
|
||||
mod skills;
|
||||
mod types;
|
||||
|
||||
@@ -8,7 +10,8 @@ pub(crate) use skills::{
|
||||
sort_managed_skills, toggle_codex_skill_inner,
|
||||
};
|
||||
pub(crate) use types::{
|
||||
ManagedMcpServer, SkillsMcpActionResult, SkillsMcpImportPreview, SkillsMcpState,
|
||||
ManagedMcpServer, SkillsMcpActionResult, SkillsMcpExportResult, SkillsMcpImportPreview,
|
||||
SkillsMcpState,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -17,7 +20,7 @@ pub(crate) use skills::read_skill_metadata;
|
||||
pub(crate) use types::ManagedSkill;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::file_io::{ensure_directory, io_err};
|
||||
use crate::file_io::ensure_directory;
|
||||
use crate::paths::{home_dir, normalized_path_scope};
|
||||
use crate::resolve_codex_dir;
|
||||
use crate::{now_rfc3339, open_db};
|
||||
@@ -26,10 +29,9 @@ use mcp::{
|
||||
preview_ccswitch_mcp_servers_for_codex, save_managed_mcp,
|
||||
};
|
||||
use rusqlite::params;
|
||||
use skills::{
|
||||
codex_skills_dir, copy_dir_recursive, disabled_skills_dir, sanitize_dir_name, scan_skill_dir,
|
||||
};
|
||||
use skills::{codex_skills_dir, copy_dir_recursive, disabled_skills_dir, scan_skill_dir};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
#[cfg(test)]
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -128,7 +130,7 @@ pub(crate) fn build_skills_mcp_state_inner(config_dir: Option<String>) -> Result
|
||||
let mut mcp_servers = list_mcp_from_config(&codex_dir)?;
|
||||
let enabled_ids: HashSet<String> = mcp_servers.iter().map(|s| s.id.clone()).collect();
|
||||
for (id, name, config, enabled) in db_managed_mcp()? {
|
||||
if enabled_ids.contains(&id) {
|
||||
if enabled_ids.contains(&id) || !mcp::is_valid_mcp_config(&config) {
|
||||
continue;
|
||||
}
|
||||
let (transport, command, url, summary) = mcp_summary(&config);
|
||||
@@ -221,31 +223,16 @@ pub(crate) fn import_existing_skills_mcp_inner(
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
ensure_directory(&skills_dir)?;
|
||||
let preview = preview_existing_skills_mcp_inner(config_dir.clone())?;
|
||||
let mut imported_skills = 0usize;
|
||||
let candidates = vec![
|
||||
home_dir()?.join(".agents").join("skills"),
|
||||
home_dir()?.join(".cc-switch").join("skills"),
|
||||
];
|
||||
for base in candidates {
|
||||
if !base.exists() {
|
||||
continue;
|
||||
}
|
||||
for entry in fs::read_dir(&base).map_err(|e| io_err(&base, e))? {
|
||||
let entry = entry.map_err(|e| io_err(&base, e))?;
|
||||
let src = entry.path();
|
||||
if !src.is_dir() || !src.join("SKILL.md").is_file() {
|
||||
continue;
|
||||
}
|
||||
let directory = sanitize_dir_name(&entry.file_name().to_string_lossy(), "skill");
|
||||
let dst = skills_dir.join(&directory);
|
||||
if !dst.exists() {
|
||||
copy_dir_recursive(&src, &dst)?;
|
||||
imported_skills += 1;
|
||||
}
|
||||
for skill in preview.skills {
|
||||
let destination = skills_dir.join(&skill.directory);
|
||||
if !destination.exists() {
|
||||
copy_dir_recursive(Path::new(&skill.path), &destination)?;
|
||||
imported_skills += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut imported_mcp = 0usize;
|
||||
let mut imported_mcp_ids = db_managed_mcp()?
|
||||
.into_iter()
|
||||
.map(|(id, _, _, _)| id)
|
||||
@@ -254,10 +241,11 @@ pub(crate) fn import_existing_skills_mcp_inner(
|
||||
if !imported_mcp_ids.insert(server.id.clone()) {
|
||||
continue;
|
||||
}
|
||||
// Already visible in the list: record it for deduplication without
|
||||
// presenting it as a newly imported server.
|
||||
save_managed_mcp(&server.id, &server.name, &server.config_json, true)?;
|
||||
imported_mcp += 1;
|
||||
}
|
||||
imported_mcp += import_ccswitch_mcp_servers_for_codex(&codex_dir, &mut imported_mcp_ids)?;
|
||||
let imported_mcp = import_ccswitch_mcp_servers_for_codex(&codex_dir, &mut imported_mcp_ids)?;
|
||||
let state = build_skills_mcp_state_inner(config_dir)?;
|
||||
Ok(SkillsMcpActionResult {
|
||||
imported_skills,
|
||||
@@ -272,6 +260,7 @@ pub(crate) fn preview_existing_skills_mcp_inner(
|
||||
) -> Result<SkillsMcpImportPreview> {
|
||||
let codex_dir = resolve_codex_dir(config_dir)?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
let disabled_dir = disabled_skills_dir()?;
|
||||
let mut warnings = Vec::new();
|
||||
let mut skills = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
@@ -293,14 +282,20 @@ pub(crate) fn preview_existing_skills_mcp_inner(
|
||||
warnings.push(e.to_string());
|
||||
}
|
||||
for skill in &mut skills[before..] {
|
||||
if skills_dir.join(&skill.directory).exists() {
|
||||
if skills_dir.join(&skill.directory).exists()
|
||||
|| disabled_dir.join(&skill.directory).exists()
|
||||
{
|
||||
skill.update_status = "已存在,将跳过".to_string();
|
||||
} else {
|
||||
skill.update_status = "可导入".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
skills.retain(|skill| skill.update_status != "已存在,将跳过");
|
||||
let mut candidate_ids = HashSet::new();
|
||||
skills.retain(|skill| {
|
||||
skill.update_status != "已存在,将跳过"
|
||||
&& candidate_ids.insert(skill.directory.to_ascii_lowercase())
|
||||
});
|
||||
|
||||
let mut config_mcp_servers = list_mcp_from_config(&codex_dir)?;
|
||||
for server in &mut config_mcp_servers {
|
||||
@@ -311,7 +306,7 @@ pub(crate) fn preview_existing_skills_mcp_inner(
|
||||
.map(|(id, _, _, _)| id)
|
||||
.collect::<HashSet<_>>();
|
||||
let mut mcp_servers = Vec::new();
|
||||
extend_unmanaged_mcp_candidates(&mut mcp_servers, &mut seen_mcp, config_mcp_servers);
|
||||
seen_mcp.extend(config_mcp_servers.into_iter().map(|server| server.id));
|
||||
extend_unmanaged_mcp_candidates(
|
||||
&mut mcp_servers,
|
||||
&mut seen_mcp,
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
use super::build_skills_mcp_state_inner;
|
||||
use super::types::{CcSwitchSkillMeta, ManagedSkill, SkillsMcpActionResult, SkillsMcpState};
|
||||
use crate::ccswitch::default_ccswitch_db_path;
|
||||
use crate::constants::MAX_SKILL_ZIP_BYTES;
|
||||
use crate::error::{CodexxError, Result};
|
||||
use crate::file_io::{ensure_directory, io_err, read_to_string_if_exists};
|
||||
use crate::paths::app_home;
|
||||
use crate::{now_rfc3339, open_db, resolve_codex_dir};
|
||||
use chrono::Local;
|
||||
use rusqlite::{params, Connection, OpenFlags};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
@@ -101,8 +99,15 @@ fn compute_dir_hash(dir: &Path) -> Result<String> {
|
||||
.replace('\\', "/");
|
||||
hasher.update(rel.as_bytes());
|
||||
hasher.update(b"\0");
|
||||
let bytes = fs::read(&path).map_err(|e| io_err(&path, e))?;
|
||||
hasher.update(&bytes);
|
||||
let mut file = fs::File::open(&path).map_err(|e| io_err(&path, e))?;
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
loop {
|
||||
let size = file.read(&mut buffer).map_err(|e| io_err(&path, e))?;
|
||||
if size == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buffer[..size]);
|
||||
}
|
||||
hasher.update(b"\0");
|
||||
}
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
@@ -338,83 +343,7 @@ pub(crate) fn install_skill_zip_inner(
|
||||
file_name: String,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<SkillsMcpActionResult> {
|
||||
let codex_dir = resolve_codex_dir(config_dir.clone())?;
|
||||
let skills_dir = codex_skills_dir(&codex_dir);
|
||||
ensure_directory(&skills_dir)?;
|
||||
let mut archive = zip::ZipArchive::new(Cursor::new(bytes))
|
||||
.map_err(|e| CodexxError::Config(format!("读取 ZIP 失败: {e}")))?;
|
||||
let tmp = app_home()?
|
||||
.join("tmp")
|
||||
.join(format!("skill-zip-{}", Local::now().timestamp_millis()));
|
||||
ensure_directory(&tmp)?;
|
||||
let install_result = (|| -> Result<usize> {
|
||||
let mut total_size = 0u64;
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| CodexxError::Config(format!("读取 ZIP 条目失败: {e}")))?;
|
||||
let Some(path) = file.enclosed_name().map(|p| p.to_path_buf()) else {
|
||||
continue;
|
||||
};
|
||||
total_size += file.size();
|
||||
if total_size > MAX_SKILL_ZIP_BYTES {
|
||||
return Err(CodexxError::Config("ZIP 解压后超过 20MB".to_string()));
|
||||
}
|
||||
let out = tmp.join(path);
|
||||
if file.name().ends_with('/') {
|
||||
ensure_directory(&out)?;
|
||||
} else {
|
||||
if let Some(parent) = out.parent() {
|
||||
ensure_directory(parent)?;
|
||||
}
|
||||
let mut outfile = fs::File::create(&out).map_err(|e| io_err(&out, e))?;
|
||||
std::io::copy(&mut file, &mut outfile).map_err(|e| io_err(&out, e))?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut skill_dirs = Vec::new();
|
||||
fn find_skill_dirs(current: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
||||
if current.join("SKILL.md").is_file() {
|
||||
out.push(current.to_path_buf());
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(current).map_err(|e| io_err(current, e))? {
|
||||
let entry = entry.map_err(|e| io_err(current, e))?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
find_skill_dirs(&path, out)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
find_skill_dirs(&tmp, &mut skill_dirs)?;
|
||||
if skill_dirs.is_empty() {
|
||||
return Err(CodexxError::Config("ZIP 中没有找到 SKILL.md".to_string()));
|
||||
}
|
||||
let mut imported_skills = 0usize;
|
||||
for src in skill_dirs {
|
||||
let fallback = file_name.trim_end_matches(".zip");
|
||||
let dir_name = src.file_name().and_then(|v| v.to_str()).unwrap_or(fallback);
|
||||
let (skill_name, _) = read_skill_metadata(&src, dir_name);
|
||||
let dst_name = sanitize_dir_name(&skill_name, "skill");
|
||||
let dst = skills_dir.join(dst_name);
|
||||
if dst.exists() {
|
||||
fs::remove_dir_all(&dst).map_err(|e| io_err(&dst, e))?;
|
||||
}
|
||||
copy_dir_recursive(&src, &dst)?;
|
||||
imported_skills += 1;
|
||||
}
|
||||
Ok(imported_skills)
|
||||
})();
|
||||
let _ = fs::remove_dir_all(&tmp);
|
||||
let imported_skills = install_result?;
|
||||
let state = build_skills_mcp_state_inner(config_dir)?;
|
||||
Ok(SkillsMcpActionResult {
|
||||
imported_skills,
|
||||
imported_mcp: 0,
|
||||
message: format!("已从 ZIP 安装 {imported_skills} 个 Skill"),
|
||||
state,
|
||||
})
|
||||
super::archive::install_archive_reader(config_dir, file_name, Cursor::new(bytes))
|
||||
}
|
||||
|
||||
fn ccswitch_skill_meta_by_directory() -> Result<HashMap<String, CcSwitchSkillMeta>> {
|
||||
|
||||
@@ -66,3 +66,11 @@ pub(super) struct CcSwitchSkillMeta {
|
||||
pub(super) repo_branch: String,
|
||||
pub(super) content_hash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct SkillsMcpExportResult {
|
||||
pub(crate) path: String,
|
||||
pub(crate) exported_skills: usize,
|
||||
pub(crate) exported_mcp: usize,
|
||||
}
|
||||
|
||||
@@ -201,6 +201,7 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
|
||||
let auth = auth_path(&codex_dir);
|
||||
let text = read_to_string_if_exists(&cfg)?;
|
||||
let doc = parse_toml_document(&cfg, &text)?;
|
||||
let doc = crate::failover::direct_document(&codex_dir, &doc)?;
|
||||
let model = string_value(&doc, "model");
|
||||
let model_provider = string_value(&doc, "model_provider");
|
||||
let is_official_provider = document_is_official(&doc);
|
||||
@@ -235,7 +236,8 @@ pub(crate) fn build_state_after_migration(codex_dir: PathBuf) -> Result<CodexSta
|
||||
)?;
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
|
||||
} else {
|
||||
let candidates = matching_saved_provider_ids_from_config(&text, &saved_providers);
|
||||
let candidates =
|
||||
matching_saved_provider_ids_from_config(&doc.to_string(), &saved_providers);
|
||||
reconcile_active_provider_on_connection(&conn, &codex_dir, &candidates)?
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"testOnly": true,
|
||||
"fixturePurpose": "Synthetic fixture for cfg(test) Tauri SDK download and Minisign verification. The 256-byte PE header contains no executable code, sections or entry point. Never install or run it. This public key must never replace the production updater public key. Its private key was generated only in memory and was not saved.",
|
||||
"expectedVersion": "0.3.24",
|
||||
"publicKeyBase64": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDMxOUI5MzI2MzA2QkQ5NUMKUldSYzJXc3dKcE9iTWZrYjhWL1VCZXZ0TWFBNE9IWE9zTENncU5LSFFKaW9wblF4c3VtQXVSSVMK",
|
||||
"signatureBase64": "dW50cnVzdGVkIGNvbW1lbnQ6IHN5bnRoZXRpYyB0ZXN0IHNpZ25hdHVyZTsgbmV2ZXIgdXNlIGZvciBwcm9kdWN0aW9uIHVwZGF0ZXMKUlVSYzJXc3dKcE9iTWRZZ0xROVZ0T3c1SXVuWXYvU3FUbkRrNGowMjNId2FhcnhTeFpIa25DUm80bGdINzd4UEYvcE1mRWxKUHJZeVczQzBiNHlwWjJYZ3Mxcm0yNVJaYmdBPQp0cnVzdGVkIGNvbW1lbnQ6IHRpbWVzdGFtcDoxNzkxNTA0MDAwCWZpbGU6b25saW5lLXVwZGF0ZS1maXh0dXJlLmV4ZQoxYWxWSVlaWHdYcHlib01YcjdRS1V3RlR4d3FvVFhqMERJWlBGVGR1Z1hyYnZKTFByR1RwaFJjTnRWRGM4d2tLUEsxV01Lcmk5dy9sSzMyVEg0dWRBZz09Cg==",
|
||||
"payloadBase64": "TVoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAFBFAABkhgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQ29kZXgtWCBzeW50aGV0aWMgdXBkYXRlciBmaXh0dXJlOiBubyBzZWN0aW9ucywgbm8gZW50cnkgcG9pbnQsIG5vIGV4ZWN1dGFibGUgY29kZS4gTmV2ZXIgaW5zdGFsbC4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==",
|
||||
"sha256": "7764dacacade2426d04ec977eb28b25ed31f8e272c096c1a9d1e9873d0e49be4"
|
||||
}
|
||||
@@ -134,6 +134,7 @@ fn provider_test_connection() -> Connection {
|
||||
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,
|
||||
@@ -160,6 +161,7 @@ fn provider_fixture(
|
||||
api_key: api_key.map(ToString::to_string),
|
||||
toml_config: toml_config.map(ToString::to_string),
|
||||
wire_api: "responses".to_string(),
|
||||
upstream_api: None,
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
}
|
||||
@@ -551,6 +553,7 @@ fn ccswitch_row_reader_supports_legacy_schema_without_category() {
|
||||
name: "OpenAI Official".to_string(),
|
||||
settings_config: "{}".to_string(),
|
||||
category: None,
|
||||
meta: None,
|
||||
};
|
||||
assert!(is_official_ccswitch_row(&official));
|
||||
}
|
||||
@@ -961,6 +964,7 @@ wire_api = "responses"
|
||||
api_key: Some("sk-same".to_string()),
|
||||
toml_config: Some(toml.to_string()),
|
||||
wire_api: "responses".to_string(),
|
||||
upstream_api: None,
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
};
|
||||
@@ -1809,6 +1813,7 @@ enabled = true
|
||||
api_key: Some("sk-provider-a".to_string()),
|
||||
toml_config: Some(provider_a_config.to_string()),
|
||||
wire_api: "responses".to_string(),
|
||||
upstream_api: None,
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
},
|
||||
@@ -1820,6 +1825,7 @@ enabled = true
|
||||
api_key: Some("sk-provider-b".to_string()),
|
||||
toml_config: Some(provider_b_config.to_string()),
|
||||
wire_api: "responses".to_string(),
|
||||
upstream_api: None,
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
},
|
||||
@@ -2730,6 +2736,7 @@ requires_openai_auth = true
|
||||
api_key: None,
|
||||
toml_config: Some(provider_config.to_string()),
|
||||
wire_api: "responses".to_string(),
|
||||
upstream_api: None,
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
})
|
||||
@@ -3198,6 +3205,7 @@ requires_openai_auth = true
|
||||
api_key: Some("sk-duplicate-current".to_string()),
|
||||
toml_config: Some(custom_config.trim_end().to_string()),
|
||||
wire_api: "responses".to_string(),
|
||||
upstream_api: None,
|
||||
requires_openai_auth: true,
|
||||
model_mappings: Vec::new(),
|
||||
};
|
||||
@@ -3656,8 +3664,11 @@ experimental_bearer_token = "sk-from-config"
|
||||
name: "Proxy".to_string(),
|
||||
settings_config,
|
||||
category: None,
|
||||
meta: None,
|
||||
};
|
||||
let provider = build_ccswitch_codex_provider(&row, &HashMap::new()).expect("provider");
|
||||
let provider = build_ccswitch_codex_provider(&row, &HashMap::new())
|
||||
.expect("valid provider")
|
||||
.expect("provider");
|
||||
assert_eq!(provider.id, "openai-custom");
|
||||
assert_eq!(provider.api_key.as_deref(), Some("sk-from-config"));
|
||||
assert_eq!(provider.base_url, "https://proxy.example.com/v1");
|
||||
@@ -3682,6 +3693,7 @@ requires_openai_auth = true
|
||||
})
|
||||
.to_string(),
|
||||
category: None,
|
||||
meta: None,
|
||||
};
|
||||
let magic_row = CcSwitchCodexRow {
|
||||
id: "magicai-1782956845071".to_string(),
|
||||
@@ -3706,6 +3718,7 @@ requires_openai_auth = true
|
||||
})
|
||||
.to_string(),
|
||||
category: None,
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let mut sections = HashMap::new();
|
||||
@@ -3718,8 +3731,12 @@ requires_openai_auth = true
|
||||
}
|
||||
}
|
||||
|
||||
let sky = build_ccswitch_codex_provider(&sky_row, §ions).expect("sky");
|
||||
let magic = build_ccswitch_codex_provider(&magic_row, §ions).expect("magic");
|
||||
let sky = build_ccswitch_codex_provider(&sky_row, §ions)
|
||||
.expect("valid sky provider")
|
||||
.expect("sky");
|
||||
let magic = build_ccswitch_codex_provider(&magic_row, §ions)
|
||||
.expect("valid magic provider")
|
||||
.expect("magic");
|
||||
|
||||
assert_eq!(sky.provider_name, "Sky2api");
|
||||
assert_eq!(sky.base_url, "https://ikuncode.site/v1");
|
||||
@@ -4571,7 +4588,8 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
let root_b = "019f6000-0000-7000-8000-000000000002";
|
||||
let child = "019f6000-0000-7000-8000-000000000003";
|
||||
let orphan_subagent = "019f6000-0000-7000-8000-000000000004";
|
||||
let forked_user = "019f6000-0000-7000-8000-000000000005";
|
||||
let mislabeled_child = "019f6000-0000-7000-8000-000000000005";
|
||||
let forked_user = "019f6000-0000-7000-8000-000000000006";
|
||||
let rollout = codex_dir.join("sessions/rollout.jsonl");
|
||||
seed_thread_database(
|
||||
&database,
|
||||
@@ -4579,6 +4597,7 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
(root_a, &rollout),
|
||||
(root_b, &rollout),
|
||||
(child, &rollout),
|
||||
(mislabeled_child, &rollout),
|
||||
(forked_user, &rollout),
|
||||
],
|
||||
Some((root_a, child)),
|
||||
@@ -4588,6 +4607,7 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
"ALTER TABLE threads ADD COLUMN title TEXT;
|
||||
ALTER TABLE threads ADD COLUMN source TEXT;
|
||||
ALTER TABLE threads ADD COLUMN thread_source TEXT;
|
||||
ALTER TABLE threads ADD COLUMN forked_from_id TEXT;
|
||||
UPDATE threads SET title = 'same title';",
|
||||
)
|
||||
.expect("extend thread schema");
|
||||
@@ -4598,14 +4618,19 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
.expect("mark child subagent");
|
||||
conn.execute(
|
||||
"UPDATE threads SET thread_source = 'user' WHERE id = ?1",
|
||||
[forked_user],
|
||||
[mislabeled_child],
|
||||
)
|
||||
.expect("mark forked user thread");
|
||||
.expect("seed generic user label on an actual spawned child");
|
||||
conn.execute(
|
||||
"INSERT INTO thread_spawn_edges (parent_thread_id, child_thread_id) VALUES (?1, ?2)",
|
||||
(root_a, mislabeled_child),
|
||||
)
|
||||
.expect("insert actual child spawn edge");
|
||||
conn.execute(
|
||||
"UPDATE threads SET thread_source = 'user', source = 'cli', forked_from_id = ?1 WHERE id = ?2",
|
||||
(root_a, forked_user),
|
||||
)
|
||||
.expect("insert user fork edge");
|
||||
.expect("mark a real user fork without a spawn edge");
|
||||
conn.execute(
|
||||
"INSERT INTO threads (id, model_provider, rollout_path, title, source)
|
||||
VALUES (?1, 'openai', ?2, 'same title', ?3)",
|
||||
@@ -4620,14 +4645,25 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
|
||||
let rollouts = scan_rollouts(&codex_dir, "openai").expect("scan rollouts");
|
||||
let scan = scan_sqlite(&codex_dir, &rollouts, "openai").expect("scan sqlite");
|
||||
assert_eq!(scan.sqlite_threads, 5);
|
||||
assert_eq!(scan.sqlite_threads, 6);
|
||||
assert_eq!(scan.top_level_threads, 3);
|
||||
assert_eq!(scan.subagent_threads, 2);
|
||||
assert_eq!(scan.subagent_threads, 3);
|
||||
assert!(scan.syncable_thread_ids.contains(forked_user));
|
||||
assert!(!scan.syncable_thread_ids.contains(mislabeled_child));
|
||||
|
||||
let (previews, warnings) =
|
||||
list_session_previews(&codex_dir, &rollouts, "openai", 50).expect("list previews");
|
||||
assert!(warnings.is_empty());
|
||||
assert_eq!(previews.iter().filter(|item| item.is_subagent).count(), 2);
|
||||
assert_eq!(previews.iter().filter(|item| item.is_subagent).count(), 3);
|
||||
for preview in &previews {
|
||||
assert_eq!(
|
||||
preview.is_subagent,
|
||||
[child, orphan_subagent, mislabeled_child].contains(&preview.id.as_str())
|
||||
);
|
||||
if preview.is_subagent {
|
||||
assert!(!preview.needs_sync);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
previews
|
||||
.into_iter()
|
||||
@@ -4638,6 +4674,7 @@ fn session_previews_return_subagents_with_explicit_marker() {
|
||||
root_b.to_string(),
|
||||
child.to_string(),
|
||||
orphan_subagent.to_string(),
|
||||
mislabeled_child.to_string(),
|
||||
forked_user.to_string(),
|
||||
])
|
||||
);
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Native file selection keeps large archives out of the renderer/IPC payload.
|
||||
use crate::error::{CodexxError, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn export_name(suggested: &str, fallback: &str, extension: &str) -> String {
|
||||
let mut bytes = 0;
|
||||
let stem: String = suggested
|
||||
.trim()
|
||||
.chars()
|
||||
.filter(|c| !c.is_control() && !"/\\:*?\"<>|".contains(*c))
|
||||
.take_while(|c| {
|
||||
bytes += c.len_utf8();
|
||||
bytes <= 180
|
||||
})
|
||||
.collect();
|
||||
let stem = stem.trim_matches(|c: char| c == '.' || c.is_whitespace());
|
||||
let stem = if stem.is_empty() { fallback } else { stem };
|
||||
let prefix = stem
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_uppercase();
|
||||
let reserved = matches!(prefix.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| (prefix.len() == 4
|
||||
&& (prefix.starts_with("COM") || prefix.starts_with("LPT"))
|
||||
&& prefix.as_bytes()[3].is_ascii_digit());
|
||||
format!("{}{stem}.{extension}", if reserved { "_" } else { "" })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn suggested_names_fit_filesystems_without_losing_utf8_or_using_windows_devices() {
|
||||
for title in ["中".repeat(100), "🌏".repeat(100)] {
|
||||
let name = export_name(&title, "session", "md");
|
||||
assert!(name.len() <= 184);
|
||||
assert!(name.ends_with(".md"));
|
||||
assert!(!name.contains('\u{fffd}'));
|
||||
}
|
||||
for title in ["CON", "nul", "PRN.notes", "LPT1", "COM9"] {
|
||||
assert!(export_name(title, "session", "md").starts_with('_'));
|
||||
}
|
||||
assert_eq!(export_name(" . /\\ ", "session", "zip"), "session.zip");
|
||||
}
|
||||
}
|
||||
|
||||
fn require_extension(path: &Path, extension: &str) -> Result<()> {
|
||||
if !path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|value| value.eq_ignore_ascii_case(extension))
|
||||
{
|
||||
return Err(CodexxError::Config(format!(
|
||||
"请使用 .{extension} 文件名保存。"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn save_path(
|
||||
window: &tauri::WebviewWindow,
|
||||
name: &str,
|
||||
extension: &str,
|
||||
title: &str,
|
||||
) -> Result<Option<PathBuf>> {
|
||||
let Some(file) = rfd::AsyncFileDialog::new()
|
||||
.set_parent(window)
|
||||
.set_title(title)
|
||||
.set_file_name(name)
|
||||
.add_filter(extension, &[extension])
|
||||
.save_file()
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
require_extension(file.path(), extension)?;
|
||||
Ok(Some(file.path().to_path_buf()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn export_codex_sessions(
|
||||
window: tauri::WebviewWindow,
|
||||
config_dir: Option<String>,
|
||||
session_ids: Vec<String>,
|
||||
suggested_name: String,
|
||||
lang: Option<String>,
|
||||
) -> Result<Option<crate::sessions::SessionExportResult>> {
|
||||
if session_ids.is_empty() {
|
||||
return Err(CodexxError::Config("请先选择要导出的会话。".into()));
|
||||
}
|
||||
let extension = if session_ids.len() == 1 { "md" } else { "zip" };
|
||||
let name = export_name(&suggested_name, "Codex-sessions", extension);
|
||||
let title = if lang.as_deref() == Some("en") {
|
||||
"Export conversations"
|
||||
} else {
|
||||
"导出会话"
|
||||
};
|
||||
let Some(destination) = save_path(&window, &name, extension, title).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
crate::sessions::export_codex_sessions_inner(
|
||||
config_dir,
|
||||
session_ids,
|
||||
destination.to_string_lossy().into_owned(),
|
||||
)
|
||||
.map(Some)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("导出会话失败,请重试。".into()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn export_skills_mcp_archive(
|
||||
window: tauri::WebviewWindow,
|
||||
config_dir: Option<String>,
|
||||
kind: String,
|
||||
lang: Option<String>,
|
||||
) -> Result<Option<crate::skills_mcp::SkillsMcpExportResult>> {
|
||||
let label = match kind.as_str() {
|
||||
"mcp" => "MCP",
|
||||
"skills" => "Skills",
|
||||
_ => return Err(CodexxError::Config("不支持的导出类型。".into())),
|
||||
};
|
||||
let name = format!(
|
||||
"Codex-X-{label}-{}.zip",
|
||||
chrono::Local::now().format("%Y%m%d")
|
||||
);
|
||||
let title = if lang.as_deref() == Some("en") {
|
||||
format!("Export {label}")
|
||||
} else {
|
||||
format!("导出 {label}")
|
||||
};
|
||||
let Some(destination) = save_path(&window, &name, "zip", &title).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
crate::skills_mcp::export_skills_mcp_archive_inner(
|
||||
config_dir,
|
||||
kind,
|
||||
destination.to_string_lossy().into_owned(),
|
||||
)
|
||||
.map(Some)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("导出 ZIP 失败,请重试。".into()))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn import_skills_mcp_archive(
|
||||
window: tauri::WebviewWindow,
|
||||
config_dir: Option<String>,
|
||||
lang: Option<String>,
|
||||
) -> Result<Option<crate::skills_mcp::SkillsMcpActionResult>> {
|
||||
let title = if lang.as_deref() == Some("en") {
|
||||
"Import Skills / MCP ZIP"
|
||||
} else {
|
||||
"导入 Skills / MCP ZIP"
|
||||
};
|
||||
let Some(file) = rfd::AsyncFileDialog::new()
|
||||
.set_parent(&window)
|
||||
.set_title(title)
|
||||
.add_filter("ZIP", &["zip"])
|
||||
.pick_file()
|
||||
.await
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = file.path().to_string_lossy().into_owned();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
crate::skills_mcp::install_skill_archive_path_inner(config_dir, path).map(Some)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| CodexxError::Config("导入 ZIP 失败,请重试。".into()))?
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
use crate::error::{CodexxError, Result};
|
||||
use chrono::{DateTime, Duration, Local, NaiveDate, TimeZone, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::de::{DeserializeSeed, IgnoredAny, MapAccess, SeqAccess, Visitor};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::fs::{self, File, Metadata};
|
||||
@@ -13,7 +14,10 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::SystemTime;
|
||||
|
||||
const MAX_LINE_BYTES: usize = 2 * 1024 * 1024;
|
||||
// Keep ordinary rollout records on the fast slice parser. Larger records use
|
||||
// the streaming parser, which skips image/tool/message bodies without retaining
|
||||
// their contents. This is a buffer threshold, never a record-size limit.
|
||||
const MAX_BUFFERED_LINE_BYTES: usize = 64 * 1024;
|
||||
const MAX_SCAN_BYTES: u64 = 1024 * 1024 * 1024;
|
||||
const MAX_FILES: usize = 25_000;
|
||||
const MAX_DEPTH: usize = 6;
|
||||
@@ -185,6 +189,97 @@ struct RateLimits {
|
||||
limit_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Keep only the source classification and parent ID used for grouping agents.
|
||||
/// In particular, newer source metadata may embed large, unrelated payloads.
|
||||
#[derive(Debug, Default)]
|
||||
struct UsageSource {
|
||||
is_subagent: bool,
|
||||
parent: Option<String>,
|
||||
}
|
||||
|
||||
struct UsageSourceSeed(usize);
|
||||
|
||||
impl<'de> DeserializeSeed<'de> for UsageSourceSeed {
|
||||
type Value = UsageSource;
|
||||
|
||||
fn deserialize<D: Deserializer<'de>>(
|
||||
self,
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Self::Value, D::Error> {
|
||||
deserializer.deserialize_any(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Visitor<'de> for UsageSourceSeed {
|
||||
type Value = UsageSource;
|
||||
|
||||
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
formatter.write_str("session source metadata")
|
||||
}
|
||||
|
||||
fn visit_str<E: serde::de::Error>(self, value: &str) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource {
|
||||
is_subagent: self.0 == 0 && crate::sessions::source_kind_is_internal(value),
|
||||
parent: (self.0 == 3).then(|| value.to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
fn visit_map<M: MapAccess<'de>>(
|
||||
self,
|
||||
mut map: M,
|
||||
) -> std::result::Result<Self::Value, M::Error> {
|
||||
const PATH: [&str; 3] = ["subagent", "thread_spawn", "parent_thread_id"];
|
||||
let mut source = UsageSource::default();
|
||||
while let Some(key) = map.next_key::<String>()? {
|
||||
if self.0 == 0 && key == "internal" {
|
||||
map.next_value::<IgnoredAny>()?;
|
||||
source.is_subagent = true;
|
||||
} else if PATH.get(self.0).copied() == Some(key.as_str()) {
|
||||
let child = map.next_value_seed(UsageSourceSeed(self.0 + 1))?;
|
||||
source.is_subagent |= self.0 == 0;
|
||||
source.parent = child.parent;
|
||||
} else {
|
||||
map.next_value::<IgnoredAny>()?;
|
||||
}
|
||||
}
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
fn visit_seq<A: SeqAccess<'de>>(
|
||||
self,
|
||||
mut sequence: A,
|
||||
) -> std::result::Result<Self::Value, A::Error> {
|
||||
while sequence.next_element::<IgnoredAny>()?.is_some() {}
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
|
||||
fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
|
||||
fn visit_bool<E: serde::de::Error>(self, _: bool) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
|
||||
fn visit_i64<E: serde::de::Error>(self, _: i64) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
|
||||
fn visit_u64<E: serde::de::Error>(self, _: u64) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
|
||||
fn visit_f64<E: serde::de::Error>(self, _: f64) -> std::result::Result<Self::Value, E> {
|
||||
Ok(UsageSource::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for UsageSource {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||
UsageSourceSeed(0).deserialize(deserializer)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct LogPayload {
|
||||
#[serde(rename = "type", default)]
|
||||
@@ -193,7 +288,8 @@ struct LogPayload {
|
||||
#[serde(alias = "threadId")]
|
||||
thread_id: Option<String>,
|
||||
forked_from_id: Option<String>,
|
||||
source: Option<serde_json::Value>,
|
||||
source: Option<UsageSource>,
|
||||
thread_source: Option<String>,
|
||||
cwd: Option<String>,
|
||||
model: Option<String>,
|
||||
info: Option<TokenInfo>,
|
||||
@@ -226,7 +322,6 @@ struct TokenEvent {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
struct ParseIssues {
|
||||
malformed_lines: usize,
|
||||
oversized_lines: usize,
|
||||
invalid_usage: usize,
|
||||
incomplete_breakdown: usize,
|
||||
invalid_timestamps: usize,
|
||||
@@ -235,7 +330,6 @@ struct ParseIssues {
|
||||
impl ParseIssues {
|
||||
fn add(&mut self, other: &Self) {
|
||||
self.malformed_lines += other.malformed_lines;
|
||||
self.oversized_lines += other.oversized_lines;
|
||||
self.invalid_usage += other.invalid_usage;
|
||||
self.incomplete_breakdown += other.incomplete_breakdown;
|
||||
self.invalid_timestamps += other.invalid_timestamps;
|
||||
@@ -296,13 +390,7 @@ fn timestamp(value: Option<&str>) -> Option<DateTime<Utc>> {
|
||||
}
|
||||
|
||||
impl ParsedLog {
|
||||
fn parse_line(&mut self, bytes: &[u8]) -> std::result::Result<(), serde_json::Error> {
|
||||
if bytes.iter().all(u8::is_ascii_whitespace) {
|
||||
return Ok(());
|
||||
}
|
||||
// Serde skips unknown fields. In particular, it does not materialize a
|
||||
// response_item's message bodies or a turn_context's instructions.
|
||||
let record: LogRecord = serde_json::from_slice(bytes)?;
|
||||
fn parse_record(&mut self, record: LogRecord) {
|
||||
let time = timestamp(record.timestamp.as_deref());
|
||||
if let Some(time) = time {
|
||||
self.max_timestamp = Some(
|
||||
@@ -323,15 +411,17 @@ impl ParsedLog {
|
||||
let spawned = payload
|
||||
.source
|
||||
.as_ref()
|
||||
.and_then(|value| value.pointer("/subagent/thread_spawn/parent_thread_id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_owned);
|
||||
.and_then(|value| value.parent.clone());
|
||||
let forked = nonempty(payload.forked_from_id);
|
||||
let spawned = nonempty(spawned);
|
||||
self.is_subagent = payload
|
||||
.source
|
||||
.as_ref()
|
||||
.is_some_and(crate::sessions::source_value_is_subagent);
|
||||
.is_some_and(|source| source.is_subagent)
|
||||
|| payload
|
||||
.thread_source
|
||||
.as_deref()
|
||||
.is_some_and(crate::sessions::thread_source_is_internal);
|
||||
self.replays_parent = forked.is_some();
|
||||
self.spawn_parent = spawned.clone();
|
||||
self.invalid_spawn_parent = self.id.is_some() && self.id == self.spawn_parent;
|
||||
@@ -360,7 +450,7 @@ impl ParsedLog {
|
||||
}
|
||||
"event_msg" if payload.kind == "token_count" => {
|
||||
let Some(info) = payload.info else {
|
||||
return Ok(());
|
||||
return;
|
||||
};
|
||||
if let Some(model) = nonempty(info.model.or(info.model_name).or(payload.model)) {
|
||||
self.model = model;
|
||||
@@ -375,7 +465,7 @@ impl ParsedLog {
|
||||
.and_then(RawCounters::counters);
|
||||
if total.is_none() && last.is_none() {
|
||||
self.issues.invalid_usage += 1;
|
||||
return Ok(());
|
||||
return;
|
||||
}
|
||||
let effective = if last.is_some() {
|
||||
info.last_token_usage.as_ref()
|
||||
@@ -399,7 +489,6 @@ impl ParsedLog {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,41 +573,123 @@ fn anchors(file: &mut File, offset: u64) -> std::io::Result<([u8; 32], [u8; 32])
|
||||
Ok((prefix, boundary))
|
||||
}
|
||||
|
||||
struct BoundedLine {
|
||||
bytes: Vec<u8>,
|
||||
struct ParsedLine {
|
||||
record: std::result::Result<Option<LogRecord>, serde_json::Error>,
|
||||
consumed: u64,
|
||||
terminated: bool,
|
||||
oversized: bool,
|
||||
}
|
||||
|
||||
fn read_bounded_line(reader: &mut impl BufRead) -> std::io::Result<BoundedLine> {
|
||||
let mut result = BoundedLine {
|
||||
bytes: Vec::new(),
|
||||
consumed: 0,
|
||||
terminated: false,
|
||||
oversized: false,
|
||||
};
|
||||
/// Presents exactly one physical JSONL line to Serde. Caching the next newline
|
||||
/// boundary avoids rescanning the entire buffer for each byte Serde requests.
|
||||
/// Syntax errors can be drained to the same boundary, preserving the next event.
|
||||
struct JsonLineReader<'a, R> {
|
||||
reader: &'a mut R,
|
||||
available: usize,
|
||||
ends_line: bool,
|
||||
consumed: u64,
|
||||
terminated: bool,
|
||||
non_whitespace: bool,
|
||||
}
|
||||
|
||||
impl<'a, R: BufRead> JsonLineReader<'a, R> {
|
||||
fn new(reader: &'a mut R) -> Self {
|
||||
Self {
|
||||
reader,
|
||||
available: 0,
|
||||
ends_line: false,
|
||||
consumed: 0,
|
||||
terminated: false,
|
||||
non_whitespace: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn drain(&mut self) -> std::io::Result<()> {
|
||||
std::io::copy(self, &mut std::io::sink())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: BufRead> Read for JsonLineReader<'_, R> {
|
||||
fn read(&mut self, output: &mut [u8]) -> std::io::Result<usize> {
|
||||
if self.terminated || output.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let buffer = self.reader.fill_buf()?;
|
||||
if self.available == 0 {
|
||||
let newline = buffer.iter().position(|byte| *byte == b'\n');
|
||||
self.available = newline.map_or(buffer.len(), |index| index + 1);
|
||||
self.ends_line = newline.is_some();
|
||||
}
|
||||
let count = output.len().min(self.available);
|
||||
output[..count].copy_from_slice(&buffer[..count]);
|
||||
self.non_whitespace = self.non_whitespace
|
||||
|| output[..count]
|
||||
.iter()
|
||||
.any(|byte| !byte.is_ascii_whitespace());
|
||||
self.reader.consume(count);
|
||||
self.consumed += count as u64;
|
||||
self.available -= count;
|
||||
self.terminated = self.available == 0 && self.ends_line;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_record_slice(bytes: &[u8]) -> std::result::Result<Option<LogRecord>, serde_json::Error> {
|
||||
if bytes.iter().all(u8::is_ascii_whitespace) {
|
||||
Ok(None)
|
||||
} else {
|
||||
serde_json::from_slice(bytes).map(Some)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_log_record(reader: &mut impl BufRead) -> std::io::Result<ParsedLine> {
|
||||
let mut bytes = Vec::new();
|
||||
loop {
|
||||
let buffer = reader.fill_buf()?;
|
||||
if buffer.is_empty() {
|
||||
break;
|
||||
return Ok(ParsedLine {
|
||||
record: parse_record_slice(&bytes),
|
||||
consumed: bytes.len() as u64,
|
||||
terminated: false,
|
||||
});
|
||||
}
|
||||
let newline = buffer.iter().position(|byte| *byte == b'\n');
|
||||
let count = newline.map_or(buffer.len(), |index| index + 1);
|
||||
result.consumed += count as u64;
|
||||
if !result.oversized && result.bytes.len() + count <= MAX_LINE_BYTES {
|
||||
result.bytes.extend_from_slice(&buffer[..count]);
|
||||
} else {
|
||||
result.oversized = true;
|
||||
result.bytes.clear();
|
||||
if bytes.len() + count > MAX_BUFFERED_LINE_BYTES {
|
||||
// Derived struct deserializers use IgnoredAny for unknown fields:
|
||||
// Serde validates/skips large strings and arrays directly from the
|
||||
// reader, rather than constructing a String, Value, or whole line.
|
||||
let mut tail = JsonLineReader::new(reader);
|
||||
let prefix_non_whitespace = bytes.iter().any(|byte| !byte.is_ascii_whitespace());
|
||||
let record = serde_json::from_reader(bytes.as_slice().chain(&mut tail)).map(Some);
|
||||
if let Err(error) = &record {
|
||||
if let Some(kind) = error.io_error_kind() {
|
||||
return Err(std::io::Error::new(kind, "会话文件在读取期间发生错误"));
|
||||
}
|
||||
}
|
||||
// A bad record must not consume the next JSONL line, nor prevent it
|
||||
// from contributing usage. Also count all bytes against scan budget.
|
||||
tail.drain()?;
|
||||
return Ok(ParsedLine {
|
||||
record: if prefix_non_whitespace || tail.non_whitespace {
|
||||
record
|
||||
} else {
|
||||
Ok(None)
|
||||
},
|
||||
consumed: bytes.len() as u64 + tail.consumed,
|
||||
terminated: tail.terminated,
|
||||
});
|
||||
}
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
reader.consume(count);
|
||||
if newline.is_some() {
|
||||
result.terminated = true;
|
||||
break;
|
||||
return Ok(ParsedLine {
|
||||
record: parse_record_slice(&bytes),
|
||||
consumed: bytes.len() as u64,
|
||||
terminated: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn parse_file(
|
||||
@@ -574,23 +745,19 @@ fn parse_file(
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
let line = read_bounded_line(&mut reader).map_err(|error| io_error(path, error))?;
|
||||
let line = read_log_record(&mut reader).map_err(|error| io_error(path, error))?;
|
||||
if line.consumed == 0 {
|
||||
break;
|
||||
}
|
||||
*budget = budget.saturating_sub(line.consumed);
|
||||
if line.oversized {
|
||||
if !line.terminated {
|
||||
match line.record {
|
||||
Ok(Some(record)) => parsed.parse_record(record),
|
||||
Ok(None) => {}
|
||||
Err(error) if !line.terminated && error.is_eof() => {
|
||||
parsed.pending_line = true;
|
||||
break;
|
||||
}
|
||||
parsed.issues.oversized_lines += 1;
|
||||
} else if let Err(error) = parsed.parse_line(&line.bytes) {
|
||||
if !line.terminated && error.is_eof() {
|
||||
parsed.pending_line = true;
|
||||
break;
|
||||
}
|
||||
parsed.issues.malformed_lines += 1;
|
||||
Err(_) => parsed.issues.malformed_lines += 1,
|
||||
}
|
||||
offset += line.consumed;
|
||||
}
|
||||
@@ -698,6 +865,7 @@ struct SessionLog {
|
||||
fn combine_files(
|
||||
cache: &DirectoryCache,
|
||||
coverage: &mut UsageCoverage,
|
||||
indexed_identities: &HashMap<String, crate::sessions::UsageThreadIdentity>,
|
||||
) -> BTreeMap<String, SessionLog> {
|
||||
let mut sessions: BTreeMap<String, SessionLog> = BTreeMap::new();
|
||||
let mut issues = ParseIssues::default();
|
||||
@@ -748,7 +916,38 @@ fn combine_files(
|
||||
}
|
||||
session.events.extend(parsed.events.iter().cloned());
|
||||
}
|
||||
for session in sessions.values_mut() {
|
||||
let mut canonical_files: Option<HashMap<PathBuf, &CachedFile>> = None;
|
||||
for (id, session) in &mut sessions {
|
||||
// Keep token records from every copy, but use the current database's
|
||||
// actual rollout for identity. A stale archived/internal copy with the
|
||||
// same ID must not hide a user conversation or supply a different parent.
|
||||
let authoritative = indexed_identities
|
||||
.get(id)
|
||||
.and_then(|identity| identity.rollout_path.as_ref())
|
||||
.and_then(|path| {
|
||||
cache.files.get(path).or_else(|| {
|
||||
let canonical = path.canonicalize().ok()?;
|
||||
canonical_files
|
||||
.get_or_insert_with(|| {
|
||||
cache
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|(path, entry)| {
|
||||
path.canonicalize().ok().map(|path| (path, entry))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.get(&canonical)
|
||||
.copied()
|
||||
})
|
||||
})
|
||||
.map(|entry| &entry.parsed)
|
||||
.filter(|parsed| parsed.meta_seen && parsed.id.as_ref() == Some(id));
|
||||
if let Some(parsed) = authoritative {
|
||||
session.is_subagent = parsed.is_subagent;
|
||||
session.spawn_parent.clone_from(&parsed.spawn_parent);
|
||||
session.invalid_spawn_parent = parsed.invalid_spawn_parent;
|
||||
}
|
||||
// The same rollout can coexist in sessions and archived_sessions, or
|
||||
// have overlapping resume segments. Merge its token records once.
|
||||
let mut seen = HashSet::new();
|
||||
@@ -763,7 +962,6 @@ fn combine_files(
|
||||
}
|
||||
for (count, message) in [
|
||||
(issues.malformed_lines, "行日志无法解析"),
|
||||
(issues.oversized_lines, "行日志超过 2 MiB,已跳过"),
|
||||
(issues.invalid_usage, "条用量记录缺少有效的输入或输出计数"),
|
||||
(issues.invalid_timestamps, "条用量记录缺少有效时间"),
|
||||
(without_identity, "个会话文件缺少会话标识,已跳过"),
|
||||
@@ -946,7 +1144,7 @@ impl Bucket {
|
||||
|
||||
fn conversation_identities(
|
||||
sessions: &BTreeMap<String, SessionLog>,
|
||||
codex_dir: &Path,
|
||||
indexed_identities: HashMap<String, crate::sessions::UsageThreadIdentity>,
|
||||
) -> HashMap<String, crate::sessions::UsageThreadIdentity> {
|
||||
let mut identities = sessions
|
||||
.iter()
|
||||
@@ -958,18 +1156,24 @@ fn conversation_identities(
|
||||
classification_known: session.is_subagent,
|
||||
parent_id: session.spawn_parent.clone(),
|
||||
parent_conflict: session.invalid_spawn_parent,
|
||||
rollout_path: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
for (id, mut indexed) in crate::sessions::usage_thread_identities(codex_dir) {
|
||||
for (id, mut indexed) in indexed_identities {
|
||||
// Older indexes may have only id/title columns. Absence of a source
|
||||
// marker must not erase a positive subagent classification in the log.
|
||||
if !indexed.classification_known && identities.contains_key(&id) {
|
||||
continue;
|
||||
}
|
||||
// The session manager's current SQLite classification is authoritative.
|
||||
// A source-only subagent marker may still need its rollout's parent ID.
|
||||
// A generic user/default database marker must not promote a thread
|
||||
// whose own rollout explicitly identifies it as an internal task.
|
||||
if identities.get(&id).is_some_and(|log| log.is_subagent) {
|
||||
indexed.is_subagent = true;
|
||||
indexed.classification_known = true;
|
||||
}
|
||||
// A source-only internal marker may still need its rollout's parent ID.
|
||||
if indexed.is_subagent && indexed.parent_id.is_none() && !indexed.parent_conflict {
|
||||
if let Some(log) = identities.get(&id) {
|
||||
indexed.parent_id.clone_from(&log.parent_id);
|
||||
@@ -1049,8 +1253,9 @@ where
|
||||
.and_then(|date| midnight(&timezone, date))
|
||||
.map(|time| time.with_timezone(&Utc));
|
||||
let range_end = now.with_timezone(&Utc);
|
||||
let sessions = combine_files(cache, &mut coverage);
|
||||
let identities = conversation_identities(&sessions, codex_dir);
|
||||
let indexed_identities = crate::sessions::usage_thread_identities(codex_dir);
|
||||
let sessions = combine_files(cache, &mut coverage, &indexed_identities);
|
||||
let identities = conversation_identities(&sessions, indexed_identities);
|
||||
let mut total = Bucket::default();
|
||||
let mut days: BTreeMap<NaiveDate, Bucket> = BTreeMap::new();
|
||||
let mut models: BTreeMap<String, Bucket> = BTreeMap::new();
|
||||
@@ -1886,7 +2091,7 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let changed = fixture.stats("all", None);
|
||||
assert_eq!(changed.sessions[0].id, "child");
|
||||
assert_eq!(changed.sessions[0].id, "main");
|
||||
assert_eq!(changed.totals.total_tokens, 55);
|
||||
}
|
||||
|
||||
@@ -1931,7 +2136,95 @@ mod tests {
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fixture.stats("all", None).totals.session_count, 2);
|
||||
assert_eq!(fixture.stats("all", None).totals.session_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_and_memory_usage_stays_counted_without_creating_user_conversation_rows() {
|
||||
let mut fixture = Fixture::new();
|
||||
for (index, source) in [
|
||||
json!({"internal":"guardian"}),
|
||||
json!({"internal":"memory_consolidation"}),
|
||||
json!("internal_guardian"),
|
||||
json!("subagent_review"),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let id = format!("internal-{index}");
|
||||
let mut metadata = meta(&id, "2026-09-08T01:00:00Z", None);
|
||||
metadata["payload"]["source"] = source;
|
||||
fixture.write(
|
||||
&format!("sessions/{id}.jsonl"),
|
||||
&[
|
||||
metadata,
|
||||
context("codex-auto-review"),
|
||||
total("2026-09-08T01:00:01Z", 50, 5),
|
||||
],
|
||||
);
|
||||
}
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 220);
|
||||
assert_eq!(stats.totals.session_count, 0);
|
||||
assert!(stats.sessions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_rollout_controls_classification_while_duplicate_usage_is_preserved() {
|
||||
let mut fixture = Fixture::new();
|
||||
let current = [
|
||||
meta("main", "2026-09-08T01:00:00Z", None),
|
||||
context("model"),
|
||||
total("2026-09-08T01:00:01Z", 100, 10),
|
||||
];
|
||||
fixture.write("sessions/current.jsonl", ¤t);
|
||||
let mut stale = current.to_vec();
|
||||
stale[0]["payload"]["source"] = json!({"internal":"guardian"});
|
||||
stale.push(total("2026-09-08T01:00:02Z", 150, 15));
|
||||
fixture.write("archived_sessions/stale-copy.jsonl", &stale);
|
||||
let db = rusqlite::Connection::open(fixture.dir.join("state_5.sqlite")).unwrap();
|
||||
db.execute_batch("CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT, thread_source TEXT, source TEXT, rollout_path TEXT);
|
||||
INSERT INTO threads VALUES ('main', 'User conversation', 'user', 'cli', 'sessions/current.jsonl');").unwrap();
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 165);
|
||||
assert_eq!(stats.totals.session_count, 1);
|
||||
assert_eq!(stats.sessions.len(), 1);
|
||||
assert_eq!(stats.sessions[0].id, "main");
|
||||
assert_eq!(stats.sessions[0].totals.total_tokens, 165);
|
||||
|
||||
// An explicit internal source in the authoritative file still wins
|
||||
// over generic user/cli labels, even with a normal duplicate present.
|
||||
db.execute("UPDATE threads SET rollout_path = 'archived_sessions/stale-copy.jsonl' WHERE id = 'main'", []).unwrap();
|
||||
let internal = fixture.stats("all", None);
|
||||
assert_eq!(internal.totals.total_tokens, 165);
|
||||
assert_eq!(internal.totals.session_count, 0);
|
||||
assert!(internal.sessions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_metadata_thread_source_hides_internal_tasks_without_database_markers() {
|
||||
let mut fixture = Fixture::new();
|
||||
for (index, source) in ["guardian_review", "memory_consolidation"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let id = format!("internal-{index}");
|
||||
let mut metadata = meta(&id, "2026-09-08T01:00:00Z", None);
|
||||
metadata["payload"]["source"] = json!("cli");
|
||||
metadata["payload"]["thread_source"] = json!(source);
|
||||
fixture.write(
|
||||
&format!("sessions/{id}.jsonl"),
|
||||
&[
|
||||
metadata,
|
||||
context("model"),
|
||||
total("2026-09-08T01:00:01Z", 50, 5),
|
||||
],
|
||||
);
|
||||
}
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 110);
|
||||
assert_eq!(stats.totals.session_count, 0);
|
||||
assert!(stats.sessions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2235,7 +2528,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_reader_skips_large_lines_and_reports_bad_or_old_usage() {
|
||||
fn streaming_reader_recovers_after_large_invalid_lines_and_reports_bad_or_old_usage() {
|
||||
let mut fixture = Fixture::new();
|
||||
let path = fixture.write(
|
||||
"sessions/one.jsonl",
|
||||
@@ -2256,7 +2549,7 @@ mod tests {
|
||||
);
|
||||
let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap();
|
||||
file.write_all(b"{not json}\n").unwrap();
|
||||
file.write_all(&vec![b'x'; MAX_LINE_BYTES + 10]).unwrap();
|
||||
file.write_all(&vec![b'x'; 2 * 1024 * 1024 + 10]).unwrap();
|
||||
file.write_all(b"\n").unwrap();
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 110);
|
||||
@@ -2264,7 +2557,7 @@ mod tests {
|
||||
.coverage
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("2 MiB")));
|
||||
.any(|warning| warning.contains("2 行日志无法解析")));
|
||||
assert!(stats
|
||||
.coverage
|
||||
.warnings
|
||||
@@ -2282,6 +2575,158 @@ mod tests {
|
||||
.any(|warning| warning.contains("有效的输入或输出")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_tool_image_and_token_records_keep_all_usage_without_coverage_warnings() {
|
||||
let mut fixture = Fixture::new();
|
||||
let huge = "x".repeat(2 * 1024 * 1024 + 100);
|
||||
// Field ordering is deliberate: counters/metadata can follow the large
|
||||
// value, so looking only at a record's prefix cannot recover usage.
|
||||
let text = format!(
|
||||
"{{\"payload\":{{\"instructions\":\"{huge}\",\"id\":\"one\"}},\"type\":\"session_meta\",\"timestamp\":\"2026-09-08T01:00:00Z\"}}\n\
|
||||
{{\"payload\":{{\"input_image\":{{\"image_url\":\"data:image/png;base64,{huge}\"}},\"output\":\"{huge}\"}},\"type\":\"response_item\"}}\n\
|
||||
{{\"payload\":{{\"instructions\":\"{huge}\",\"model\":\"gpt-large-fixture\"}},\"type\":\"turn_context\"}}\n\
|
||||
{{\"payload\":{{\"info\":{{\"ignored\":\"{huge}\",\"total_token_usage\":{{\"input_tokens\":100,\"cached_input_tokens\":20,\"output_tokens\":10,\"reasoning_output_tokens\":5}}}},\"type\":\"token_count\"}},\"type\":\"event_msg\",\"timestamp\":\"2026-09-08T01:00:01Z\"}}\n"
|
||||
);
|
||||
let path = fixture.dir.join("sessions/one.jsonl");
|
||||
fs::write(&path, text).unwrap();
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 110);
|
||||
assert_eq!(stats.totals.cached_input_tokens, 20);
|
||||
assert_eq!(stats.totals.reasoning_tokens, 5);
|
||||
assert_eq!(stats.sessions[0].model, "gpt-large-fixture");
|
||||
assert!(stats.coverage.warnings.is_empty(), "{:?}", stats.coverage);
|
||||
// Appended events still resume at the exact boundary after large lines.
|
||||
fixture.append(&path, &[total("2026-09-08T01:00:02Z", 150, 15)]);
|
||||
assert_eq!(fixture.stats("all", None).totals.total_tokens, 165);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_source_metadata_retains_agent_parent_without_materializing_extensions() {
|
||||
let mut fixture = Fixture::new();
|
||||
fixture.write(
|
||||
"sessions/root.jsonl",
|
||||
&[
|
||||
meta("root", "2026-09-08T01:00:00Z", None),
|
||||
context("gpt-5.4"),
|
||||
total("2026-09-08T01:00:01Z", 100, 10),
|
||||
],
|
||||
);
|
||||
let mut child = spawn_meta("child", "root", "2026-09-08T01:00:02Z");
|
||||
child["payload"]["source"]["subagent"]["thread_spawn"]["instructions"] =
|
||||
json!("private extension ".repeat(140_000));
|
||||
fixture.write(
|
||||
"sessions/child.jsonl",
|
||||
&[
|
||||
child,
|
||||
context("gpt-5.4"),
|
||||
total("2026-09-08T01:00:03Z", 50, 5),
|
||||
],
|
||||
);
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 165);
|
||||
assert_eq!(stats.sessions.len(), 1);
|
||||
assert_eq!(stats.sessions[0].id, "root");
|
||||
assert!(stats.coverage.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_broken_record_does_not_count_partial_tokens_or_consume_next_line() {
|
||||
let mut fixture = Fixture::new();
|
||||
let path = fixture.write(
|
||||
"sessions/one.jsonl",
|
||||
&[
|
||||
meta("one", "2026-09-08T01:00:00Z", None),
|
||||
context("gpt-5.4"),
|
||||
],
|
||||
);
|
||||
let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap();
|
||||
let mut bad = total("2026-09-08T01:00:01Z", 1000, 100);
|
||||
bad["large_ignored"] = json!("x".repeat(2 * 1024 * 1024 + 100));
|
||||
writeln!(file, "{bad} trailing garbage").unwrap();
|
||||
fixture.append(&path, &[total("2026-09-08T01:00:02Z", 100, 10)]);
|
||||
let stats = fixture.stats("all", None);
|
||||
assert_eq!(stats.totals.total_tokens, 110);
|
||||
assert_eq!(stats.coverage.warnings.len(), 1);
|
||||
assert!(stats.coverage.warnings[0].contains("1 行日志无法解析"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partially_written_large_record_retries_then_counts_once_when_completed() {
|
||||
let mut fixture = Fixture::new();
|
||||
let path = fixture.write(
|
||||
"sessions/one.jsonl",
|
||||
&[
|
||||
meta("one", "2026-09-08T01:00:00Z", None),
|
||||
context("gpt-5.4"),
|
||||
total("2026-09-08T01:00:01Z", 100, 10),
|
||||
],
|
||||
);
|
||||
let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap();
|
||||
write!(
|
||||
file,
|
||||
"{{\"ignored\":\"{}",
|
||||
"x".repeat(2 * 1024 * 1024 + 100)
|
||||
)
|
||||
.unwrap();
|
||||
let pending = fixture.stats("all", None);
|
||||
assert_eq!(pending.totals.total_tokens, 110);
|
||||
assert_eq!(pending.coverage.warnings.len(), 1);
|
||||
assert!(pending.coverage.warnings[0].contains("仍在写入"));
|
||||
let event = total("2026-09-08T01:00:02Z", 200, 20).to_string();
|
||||
// A complete final record is valid even before its newline is written.
|
||||
write!(file, "\",{}", &event[1..]).unwrap();
|
||||
let complete = fixture.stats("all", None);
|
||||
assert_eq!(complete.totals.total_tokens, 220);
|
||||
assert!(complete.coverage.warnings.is_empty());
|
||||
writeln!(file).unwrap();
|
||||
fixture.append(&path, &[total("2026-09-08T01:00:03Z", 300, 30)]);
|
||||
assert_eq!(fixture.stats("all", None).totals.total_tokens, 330);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_line_boundaries_accept_escaped_content_and_large_blank_lines() {
|
||||
let huge = "\\\"中文\n".repeat(300_000);
|
||||
let mut event = total("2026-09-08T01:00:01Z", 100, 10);
|
||||
event["unknown"] = json!(huge);
|
||||
let text = format!("{}\n{event}\n", " ".repeat(2 * 1024 * 1024 + 10));
|
||||
let mut reader = BufReader::with_capacity(127, text.as_bytes());
|
||||
let blank = read_log_record(&mut reader).unwrap();
|
||||
assert!(blank.record.unwrap().is_none());
|
||||
assert!(blank.terminated);
|
||||
let record = read_log_record(&mut reader).unwrap();
|
||||
assert_eq!(record.record.unwrap().unwrap().payload.kind, "token_count");
|
||||
assert!(record.terminated);
|
||||
assert_eq!(blank.consumed + record.consumed, text.len() as u64);
|
||||
assert_eq!(read_log_record(&mut reader).unwrap().consumed, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_budget_resumes_after_large_record_without_skipping_its_tokens() {
|
||||
let fixture = Fixture::new();
|
||||
let mut large = total("2026-09-08T01:00:01Z", 100, 10);
|
||||
large["unknown"] = json!("x".repeat(2 * 1024 * 1024 + 10));
|
||||
let path = fixture.write(
|
||||
"sessions/one.jsonl",
|
||||
&[
|
||||
meta("one", "2026-09-08T01:00:00Z", None),
|
||||
large,
|
||||
total("2026-09-08T01:00:02Z", 150, 15),
|
||||
],
|
||||
);
|
||||
// Scan budgets stop between records, even if one large record crosses
|
||||
// the remaining budget. Its completed token event must be retained.
|
||||
let mut budget = MAX_BUFFERED_LINE_BYTES as u64;
|
||||
let partial = parse_file(&path, None, false, &mut budget).unwrap();
|
||||
assert!(!partial.complete);
|
||||
assert_eq!(partial.parsed.events.len(), 1);
|
||||
assert_eq!(budget, 0);
|
||||
let mut budget = MAX_SCAN_BYTES;
|
||||
let complete = parse_file(&path, Some(partial), false, &mut budget).unwrap();
|
||||
assert!(complete.complete);
|
||||
assert_eq!(complete.parsed.events.len(), 2);
|
||||
assert!(complete.parsed.issues.malformed_lines == 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_timestamp_is_not_fabricated_from_file_modified_time() {
|
||||
let mut fixture = Fixture::new();
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Windows staging/launching is deliberately separate from MSI service work.
|
||||
//! Only bytes already verified by tauri-plugin-updater reach this module.
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(super) struct StagedInstaller {
|
||||
dir: tempfile::TempDir,
|
||||
executable: PathBuf,
|
||||
}
|
||||
|
||||
fn is_windows_executable(bytes: &[u8]) -> bool {
|
||||
if bytes.len() < 64 || bytes.get(..2) != Some(b"MZ") {
|
||||
return false;
|
||||
}
|
||||
let offset = u32::from_le_bytes(bytes[60..64].try_into().unwrap()) as usize;
|
||||
offset >= 64
|
||||
&& offset
|
||||
.checked_add(4)
|
||||
.is_some_and(|end| bytes.get(offset..end) == Some(b"PE\0\0"))
|
||||
}
|
||||
|
||||
fn installer_arguments(pid: u32) -> [String; 4] {
|
||||
[
|
||||
"/P".into(),
|
||||
"/R".into(),
|
||||
"/UPDATE".into(),
|
||||
format!("/CODEXXPID={pid}"),
|
||||
]
|
||||
}
|
||||
|
||||
impl StagedInstaller {
|
||||
pub(super) fn create(verified_bytes: &[u8]) -> io::Result<Self> {
|
||||
if !is_windows_executable(verified_bytes) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"expected a verified Windows EXE installer",
|
||||
));
|
||||
}
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("Codex-X-update-")
|
||||
.tempdir()?;
|
||||
let executable = dir.path().join("Codex-X-setup.exe");
|
||||
let mut file = OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&executable)?;
|
||||
file.write_all(verified_bytes)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
Ok(Self { dir, executable })
|
||||
}
|
||||
|
||||
pub(super) fn launch(self, parent_pid: u32) -> io::Result<()> {
|
||||
if parent_pid == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"invalid parent PID",
|
||||
));
|
||||
}
|
||||
// No shell or interpolated command line: Unicode/space-containing user
|
||||
// paths are passed through Rust's native Windows argument quoting.
|
||||
let child = std::process::Command::new(&self.executable)
|
||||
.args(installer_arguments(parent_pid))
|
||||
.current_dir(self.dir.path())
|
||||
.spawn()?;
|
||||
// It owns its visible progress/errors from here, and waits for parent_pid
|
||||
// to exit before migrating or copying files. Never kill system installers.
|
||||
drop(child);
|
||||
let _ = self.dir.keep();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn staging_rejects_msi_html_truncated_and_invalid_pe_payloads() {
|
||||
for bytes in [
|
||||
b"\xd0\xcf\x11\xe0".as_slice(),
|
||||
b"<html>login</html>",
|
||||
b"MZ",
|
||||
&[0; 128],
|
||||
] {
|
||||
assert!(StagedInstaller::create(bytes).is_err());
|
||||
}
|
||||
let mut bytes = vec![0; 128];
|
||||
bytes[..2].copy_from_slice(b"MZ");
|
||||
bytes[60..64].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
assert!(StagedInstaller::create(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installer_staging_is_unique_and_cancel_cleans_only_its_directory() {
|
||||
let mut bytes = vec![0; 128];
|
||||
bytes[..2].copy_from_slice(b"MZ");
|
||||
bytes[60..64].copy_from_slice(&64u32.to_le_bytes());
|
||||
bytes[64..68].copy_from_slice(b"PE\0\0");
|
||||
let a = StagedInstaller::create(&bytes).unwrap();
|
||||
let b = StagedInstaller::create(&bytes).unwrap();
|
||||
assert_ne!(a.dir.path(), b.dir.path());
|
||||
assert_eq!(std::fs::read(&a.executable).unwrap(), bytes);
|
||||
let path = a.dir.path().to_owned();
|
||||
drop(a);
|
||||
assert!(!path.exists());
|
||||
assert!(b.executable.is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launcher_arguments_do_not_forward_user_cli_arguments_or_installer_paths() {
|
||||
assert_eq!(
|
||||
installer_arguments(123),
|
||||
["/P", "/R", "/UPDATE", "/CODEXXPID=123"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Codex-X",
|
||||
"version": "0.3.17",
|
||||
"version": "0.3.27",
|
||||
"identifier": "com.yynxxxxx.codexx",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -35,6 +35,9 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"createUpdaterArtifacts": true,
|
||||
"resources": {
|
||||
"../../../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md"
|
||||
},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
@@ -42,7 +45,18 @@
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"targets": "all"
|
||||
"targets": "all",
|
||||
"windows": {
|
||||
"nsis": {
|
||||
"installMode": "currentUser",
|
||||
"template": "nsis/installer.nsi",
|
||||
"languages": [
|
||||
"SimpChinese",
|
||||
"English"
|
||||
],
|
||||
"displayLanguageSelector": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
|
||||
+28
-260
@@ -1,270 +1,38 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import {
|
||||
check as checkForTauriUpdate,
|
||||
type CheckOptions,
|
||||
type DownloadEvent,
|
||||
type DownloadOptions,
|
||||
type Update,
|
||||
} from "@tauri-apps/plugin-updater";
|
||||
import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
import { Update } from "@tauri-apps/plugin-updater";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { AppUpdaterController, type AppUpdateEvent } from "./appUpdaterController";
|
||||
|
||||
export type AppUpdaterPhase =
|
||||
| "idle"
|
||||
| "checking"
|
||||
| "available"
|
||||
| "downloading"
|
||||
| "installing"
|
||||
| "ready"
|
||||
| "error";
|
||||
export { INITIAL_APP_UPDATER_STATE, isAppUpdateBusy } from "./appUpdaterController";
|
||||
export type { AppUpdaterPhase, AppUpdaterState, AppUpdaterFailure, AppUpdaterCheckResult, AppUpdaterCheckOptions } from "./appUpdaterController";
|
||||
|
||||
export type AppUpdaterFailure = "check" | "download" | "install" | "restart" | null;
|
||||
|
||||
export type AppUpdaterState = Readonly<{
|
||||
phase: AppUpdaterPhase;
|
||||
currentVersion: string | null;
|
||||
latestVersion: string | null;
|
||||
notes: string | null;
|
||||
publishedAt: string | null;
|
||||
downloadedBytes: number;
|
||||
totalBytes: number | null;
|
||||
failure: AppUpdaterFailure;
|
||||
}>;
|
||||
|
||||
export type AppUpdaterCheckResult = "available" | "up-to-date" | "error";
|
||||
|
||||
export type AppUpdaterCheckOptions = CheckOptions & {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export const INITIAL_APP_UPDATER_STATE: AppUpdaterState = {
|
||||
phase: "idle",
|
||||
currentVersion: null,
|
||||
latestVersion: null,
|
||||
notes: null,
|
||||
publishedAt: null,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
failure: null,
|
||||
};
|
||||
|
||||
type Listener = () => void;
|
||||
type RetryAction = "check" | "update" | "restart";
|
||||
|
||||
function normalizeByteCount(value: number | undefined): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
class AppUpdaterController {
|
||||
private state: AppUpdaterState = INITIAL_APP_UPDATER_STATE;
|
||||
private readonly listeners = new Set<Listener>();
|
||||
private update: Update | null = null;
|
||||
private checkPromise: Promise<AppUpdaterCheckResult> | null = null;
|
||||
private updatePromise: Promise<AppUpdaterPhase> | null = null;
|
||||
private restartPromise: Promise<AppUpdaterPhase> | null = null;
|
||||
private retryAction: RetryAction = "check";
|
||||
|
||||
readonly getSnapshot = (): AppUpdaterState => this.state;
|
||||
|
||||
readonly subscribe = (listener: Listener): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
readonly check = (options: AppUpdaterCheckOptions = {}): Promise<AppUpdaterCheckResult> => {
|
||||
if (this.checkPromise) return this.checkPromise;
|
||||
|
||||
if (this.isUpdateBusy()) {
|
||||
return Promise.resolve(this.update ? "available" : "error");
|
||||
}
|
||||
|
||||
if (this.update && !options.force) return Promise.resolve("available");
|
||||
|
||||
const { force: _force, ...pluginOptions } = options;
|
||||
this.checkPromise = this.performCheck(pluginOptions).finally(() => {
|
||||
this.checkPromise = null;
|
||||
export const appUpdater = new AppUpdaterController({
|
||||
check: async (options) => {
|
||||
const metadata = await invoke<ConstructorParameters<typeof Update>[0] | null>("check_online_app_update", {
|
||||
timeout: options.timeout,
|
||||
headers: options.headers ? Array.from(new Headers(options.headers).entries()) : undefined,
|
||||
proxy: options.proxy,
|
||||
target: options.target,
|
||||
allowDowngrades: options.allowDowngrades,
|
||||
});
|
||||
return this.checkPromise;
|
||||
};
|
||||
|
||||
readonly downloadAndInstall = (options?: DownloadOptions): Promise<AppUpdaterPhase> => {
|
||||
if (this.updatePromise) return this.updatePromise;
|
||||
if (this.state.phase === "ready") return Promise.resolve("ready");
|
||||
|
||||
if (!this.update) {
|
||||
this.retryAction = "check";
|
||||
this.setState({ phase: "error", failure: "check" });
|
||||
return Promise.resolve("error");
|
||||
}
|
||||
|
||||
this.updatePromise = this.performDownloadAndInstall(this.update, options).finally(() => {
|
||||
this.updatePromise = null;
|
||||
return metadata ? new Update(metadata) : null;
|
||||
},
|
||||
install: async (update, onEvent, options) => {
|
||||
const channel = new Channel<AppUpdateEvent>(onEvent);
|
||||
return invoke<{ restartRequired: boolean }>("install_app_update", {
|
||||
updateRid: update.rid,
|
||||
onEvent: channel,
|
||||
timeout: options?.timeout,
|
||||
headers: options?.headers ? Array.from(new Headers(options.headers).entries()) : undefined,
|
||||
});
|
||||
return this.updatePromise;
|
||||
};
|
||||
|
||||
readonly retry = (): Promise<AppUpdaterCheckResult | AppUpdaterPhase> => {
|
||||
if (this.retryAction === "restart") return this.restart();
|
||||
if (this.retryAction === "update" && this.update) return this.downloadAndInstall();
|
||||
return this.check({ force: true });
|
||||
};
|
||||
|
||||
readonly restart = (): Promise<AppUpdaterPhase> => {
|
||||
if (this.restartPromise) return this.restartPromise;
|
||||
if (this.state.phase !== "ready" && this.state.failure !== "restart") {
|
||||
return Promise.resolve(this.state.phase);
|
||||
}
|
||||
|
||||
this.restartPromise = this.performRestart().finally(() => {
|
||||
this.restartPromise = null;
|
||||
});
|
||||
return this.restartPromise;
|
||||
};
|
||||
|
||||
private isUpdateBusy(): boolean {
|
||||
return this.state.phase === "downloading" || this.state.phase === "installing";
|
||||
}
|
||||
|
||||
private async performCheck(options: CheckOptions): Promise<AppUpdaterCheckResult> {
|
||||
this.retryAction = "check";
|
||||
this.setState({
|
||||
phase: "checking",
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
failure: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const nextUpdate = await checkForTauriUpdate(options);
|
||||
if (!nextUpdate) {
|
||||
await this.replaceUpdate(null);
|
||||
this.setState({
|
||||
phase: "idle",
|
||||
latestVersion: null,
|
||||
notes: null,
|
||||
publishedAt: null,
|
||||
failure: null,
|
||||
});
|
||||
return "up-to-date";
|
||||
}
|
||||
|
||||
await this.replaceUpdate(nextUpdate);
|
||||
this.retryAction = "update";
|
||||
this.setState({
|
||||
phase: "available",
|
||||
currentVersion: nextUpdate.currentVersion,
|
||||
latestVersion: nextUpdate.version,
|
||||
notes: nextUpdate.body?.trim() || null,
|
||||
publishedAt: nextUpdate.date || null,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
failure: null,
|
||||
});
|
||||
return "available";
|
||||
} catch {
|
||||
await this.replaceUpdate(null);
|
||||
this.setState({ phase: "error", failure: "check" });
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
private async performDownloadAndInstall(update: Update, options?: DownloadOptions): Promise<AppUpdaterPhase> {
|
||||
this.retryAction = "update";
|
||||
let downloadFinished = false;
|
||||
this.setState({
|
||||
phase: "downloading",
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
failure: null,
|
||||
});
|
||||
|
||||
try {
|
||||
await update.downloadAndInstall((event) => {
|
||||
if (event.event === "Finished") downloadFinished = true;
|
||||
this.handleDownloadEvent(event);
|
||||
}, options);
|
||||
this.setState({
|
||||
phase: "ready",
|
||||
downloadedBytes: this.state.totalBytes ?? this.state.downloadedBytes,
|
||||
failure: null,
|
||||
});
|
||||
return "ready";
|
||||
} catch {
|
||||
const failedDuringInstall = downloadFinished || this.state.phase === "installing";
|
||||
this.setState({
|
||||
phase: "error",
|
||||
failure: failedDuringInstall ? "install" : "download",
|
||||
});
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
private handleDownloadEvent(event: DownloadEvent): void {
|
||||
if (event.event === "Started") {
|
||||
this.setState({
|
||||
phase: "downloading",
|
||||
downloadedBytes: 0,
|
||||
totalBytes: normalizeByteCount(event.data.contentLength),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.event === "Progress") {
|
||||
const nextBytes = this.state.downloadedBytes + Math.max(0, event.data.chunkLength);
|
||||
this.setState({
|
||||
downloadedBytes: this.state.totalBytes === null
|
||||
? nextBytes
|
||||
: Math.min(nextBytes, this.state.totalBytes),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
phase: "installing",
|
||||
downloadedBytes: this.state.totalBytes ?? this.state.downloadedBytes,
|
||||
});
|
||||
}
|
||||
|
||||
private async performRestart(): Promise<AppUpdaterPhase> {
|
||||
this.retryAction = "restart";
|
||||
this.setState({ failure: null });
|
||||
try {
|
||||
await relaunch();
|
||||
return "ready";
|
||||
} catch {
|
||||
this.setState({ phase: "error", failure: "restart" });
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
private async replaceUpdate(nextUpdate: Update | null): Promise<void> {
|
||||
const previousUpdate = this.update;
|
||||
this.update = nextUpdate;
|
||||
if (!previousUpdate || previousUpdate === nextUpdate) return;
|
||||
|
||||
try {
|
||||
await previousUpdate.close();
|
||||
} catch (error) {
|
||||
console.warn("Unable to release the previous updater resource", error);
|
||||
}
|
||||
}
|
||||
|
||||
private setState(patch: Partial<AppUpdaterState>): void {
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
|
||||
export const appUpdater = new AppUpdaterController();
|
||||
},
|
||||
restart: relaunch,
|
||||
setTimer: (callback, delay) => window.setTimeout(callback, delay),
|
||||
clearTimer: (timer) => window.clearTimeout(timer as number),
|
||||
});
|
||||
|
||||
export function useAppUpdater() {
|
||||
const state = useSyncExternalStore(appUpdater.subscribe, appUpdater.getSnapshot, appUpdater.getSnapshot);
|
||||
return {
|
||||
state,
|
||||
check: appUpdater.check,
|
||||
downloadAndInstall: appUpdater.downloadAndInstall,
|
||||
retry: appUpdater.retry,
|
||||
restart: appUpdater.restart,
|
||||
} as const;
|
||||
return { state, check: appUpdater.check, downloadAndInstall: appUpdater.downloadAndInstall, retry: appUpdater.retry, restart: appUpdater.restart } as const;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { CheckOptions, DownloadOptions } from "@tauri-apps/plugin-updater";
|
||||
|
||||
export type AppUpdaterPhase = "idle" | "checking" | "available" | "downloading" | "verifying" | "preparing" | "installing" | "handed-off" | "ready" | "error";
|
||||
export type AppUpdaterCheckFailureKind = "timeout" | "network" | "platform" | "invalid-release" | "public-key" | "unknown";
|
||||
export type AppUpdaterFailure = "check" | "download" | "verify" | "prepare" | "install" | "restart" | null;
|
||||
export type AppUpdaterState = Readonly<{
|
||||
phase: AppUpdaterPhase;
|
||||
currentVersion: string | null;
|
||||
latestVersion: string | null;
|
||||
notes: string | null;
|
||||
publishedAt: string | null;
|
||||
downloadedBytes: number;
|
||||
totalBytes: number | null;
|
||||
failure: AppUpdaterFailure;
|
||||
errorMessage: string | null;
|
||||
checkFailure?: AppUpdaterCheckFailureKind | null;
|
||||
logPath: string | null;
|
||||
takingLonger: boolean;
|
||||
}>;
|
||||
export type AppUpdaterCheckResult = "available" | "up-to-date" | "error";
|
||||
export type AppUpdaterCheckOptions = CheckOptions & { force?: boolean };
|
||||
export type AppUpdateEvent =
|
||||
| { event: "Started"; data: { contentLength?: number } }
|
||||
| { event: "Progress"; data: { chunkLength: number } }
|
||||
| { event: "Verifying" | "Preparing" | "Installing" | "HandedOff" };
|
||||
export type CheckedAppUpdate = {
|
||||
rid: number;
|
||||
currentVersion: string;
|
||||
version: string;
|
||||
body?: string;
|
||||
date?: string;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
export type AppUpdaterDependencies = {
|
||||
check: (options: CheckOptions) => Promise<CheckedAppUpdate | null>;
|
||||
install: (update: CheckedAppUpdate, onEvent: (event: AppUpdateEvent) => void, options?: DownloadOptions) => Promise<{ restartRequired: boolean }>;
|
||||
restart: () => Promise<void>;
|
||||
setTimer: (callback: () => void, delay: number) => unknown;
|
||||
clearTimer: (timer: unknown) => void;
|
||||
};
|
||||
export const UPDATER_SLOW_NOTICE_MS = 30_000;
|
||||
export const INITIAL_APP_UPDATER_STATE: AppUpdaterState = {
|
||||
phase: "idle", currentVersion: null, latestVersion: null, notes: null, publishedAt: null,
|
||||
downloadedBytes: 0, totalBytes: null, failure: null, errorMessage: null, checkFailure: null, logPath: null, takingLonger: false,
|
||||
};
|
||||
export function isAppUpdateBusy(phase: AppUpdaterPhase): boolean {
|
||||
return ["downloading", "verifying", "preparing", "installing", "handed-off"].includes(phase);
|
||||
}
|
||||
function normalizeByteCount(value: number | undefined): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
// Plugin errors can contain private endpoints and credentials. Inspect bounded
|
||||
// text only to select one of these fixed explanations; never return that text.
|
||||
export function classifyAppUpdaterCheckError(error: unknown): AppUpdaterCheckFailureKind {
|
||||
let text = "";
|
||||
if (typeof error === "string") text = error;
|
||||
else if (error instanceof Error) text = `${error.name} ${error.message}`;
|
||||
else if (typeof error === "object" && error !== null) {
|
||||
const record = error as Record<string, unknown>;
|
||||
text = ["code", "kind", "type", "name", "message"].map((key) => record[key])
|
||||
.filter((value): value is string => typeof value === "string").join(" ");
|
||||
}
|
||||
text = text.slice(0, 4096).replace(/https?:\/\/[^\s<>"']+/gi, "").toLowerCase();
|
||||
if (/timeout|timed?\s*out|time out|etimedout|deadline exceeded|超时/.test(text)) return "timeout";
|
||||
if (/(?:platform|target)[\s\S]{0,200}(?:not found|missing|unsupported)|unsupported[\s\S]{0,80}(?:architecture|platform|operating system|\bos\b)|unsupportedarch|unsupportedos|targets?notfound|不支持.*(?:平台|系统|架构)/.test(text)) return "platform";
|
||||
if (/release json|(?:invalid|malformed)[^\n]{0,80}(?:release|json|manifest|version)|serialization|semver|invalid updater resource|missing field|expected (?:value|ident)[^\n]{0,80}line|eof while parsing|trailing characters|key must be a string|(?:relative url without a base)|更新信息.*(?:无效|格式)/.test(text)) return "invalid-release";
|
||||
if (/public.?key|pubkey|minisign|signature|invalid base64|base64[^\n]{0,80}(?:invalid|decode)|公钥|签名/.test(text)) return "public-key";
|
||||
if (/network|failed to fetch|error sending request|connection (?:refused|reset|closed)|connect error|dns|tcp|tls|certificate|resolve (?:host|dns)|http (?:error|status)|status code|could not connect|enotfound|econnreset|econnrefused|网络|连接失败/.test(text)) return "network";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function appUpdaterCheckFailureMessage(kind: AppUpdaterCheckFailureKind, lang: "zh" | "en" = "zh"): string {
|
||||
const messages = lang === "zh" ? {
|
||||
timeout: "在线更新检查超时。请检查网络或代理后重试在线检查。",
|
||||
network: "无法连接在线更新服务。请检查网络或代理后重试在线检查。",
|
||||
platform: "更新服务没有提供适用于当前系统或架构的在线安装包。请重试在线检查;也可使用下载页的对应平台安装包。",
|
||||
"invalid-release": "在线更新信息格式无效或未能读取。请重试在线检查;若持续失败,可使用下载页的安装包。",
|
||||
"public-key": "在线更新签名或公钥配置无效。请重试在线检查;若持续失败,请从下载页获取官方安装包。",
|
||||
unknown: "在线更新检查未完成。请重试在线检查;下载页可作为备用。",
|
||||
} : {
|
||||
timeout: "The online update check timed out. Check your network or proxy, then retry the online check.",
|
||||
network: "The online update service could not be reached. Check your network or proxy, then retry the online check.",
|
||||
platform: "The update service did not provide an online installer for this system or architecture. Retry the online check, or use your platform’s installer from the download page.",
|
||||
"invalid-release": "Online update information is invalid or could not be read. Retry the online check; if it keeps failing, use the installer from the download page.",
|
||||
"public-key": "The online update signature or public key configuration is invalid. Retry the online check; if it keeps failing, get the official installer from the download page.",
|
||||
unknown: "The online update check did not finish. Retry the online check; the download page is available as a fallback.",
|
||||
};
|
||||
return messages[kind] ?? messages.unknown;
|
||||
}
|
||||
|
||||
function installationError(error: unknown, fallback: AppUpdaterFailure) {
|
||||
// Only the app's structured installation command supplies user-facing details.
|
||||
// Do not render arbitrary plugin/network errors, which can include private URLs.
|
||||
if (typeof error === "object" && error !== null && "stage" in error && "message" in error) {
|
||||
const data = error as Record<string, unknown>;
|
||||
const stage = ["download", "verify", "prepare", "install"].includes(String(data.stage))
|
||||
? data.stage as AppUpdaterFailure : fallback;
|
||||
return {
|
||||
failure: stage,
|
||||
errorMessage: typeof data.message === "string" ? data.message.slice(0, 1000) : null,
|
||||
logPath: typeof data.logPath === "string" ? data.logPath : null,
|
||||
};
|
||||
}
|
||||
return { failure: fallback, errorMessage: null, logPath: null };
|
||||
}
|
||||
|
||||
export class AppUpdaterController {
|
||||
private state: AppUpdaterState = INITIAL_APP_UPDATER_STATE;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private readonly dependencies: AppUpdaterDependencies;
|
||||
private update: CheckedAppUpdate | null = null;
|
||||
private checkPromise: Promise<AppUpdaterCheckResult> | null = null;
|
||||
private updatePromise: Promise<AppUpdaterPhase> | null = null;
|
||||
private restartPromise: Promise<AppUpdaterPhase> | null = null;
|
||||
private retryAction: "check" | "update" | "restart" = "check";
|
||||
private slowTimer: unknown = null;
|
||||
private attempt = 0;
|
||||
|
||||
constructor(dependencies: AppUpdaterDependencies) { this.dependencies = dependencies; }
|
||||
readonly getSnapshot = (): AppUpdaterState => this.state;
|
||||
readonly subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
return () => { this.listeners.delete(listener); };
|
||||
};
|
||||
readonly check = (options: AppUpdaterCheckOptions = {}): Promise<AppUpdaterCheckResult> => {
|
||||
if (this.checkPromise) return this.checkPromise;
|
||||
if (isAppUpdateBusy(this.state.phase) || this.state.phase === "ready") {
|
||||
return Promise.resolve(this.update ? "available" : "error");
|
||||
}
|
||||
if (this.update && !options.force) return Promise.resolve("available");
|
||||
const { force: _force, ...pluginOptions } = options;
|
||||
this.checkPromise = this.performCheck(pluginOptions).finally(() => { this.checkPromise = null; });
|
||||
return this.checkPromise;
|
||||
};
|
||||
readonly downloadAndInstall = (options?: DownloadOptions): Promise<AppUpdaterPhase> => {
|
||||
if (this.updatePromise) return this.updatePromise;
|
||||
if (this.checkPromise) return Promise.resolve(this.state.phase);
|
||||
if (this.state.phase === "ready" || this.state.phase === "handed-off") return Promise.resolve(this.state.phase);
|
||||
if (!this.update) {
|
||||
this.retryAction = "check";
|
||||
this.setState({ phase: "error", failure: "check", checkFailure: "invalid-release", errorMessage: appUpdaterCheckFailureMessage("invalid-release"), latestVersion: null, notes: null, publishedAt: null });
|
||||
return Promise.resolve("error");
|
||||
}
|
||||
this.updatePromise = this.performDownloadAndInstall(this.update, options).finally(() => { this.updatePromise = null; });
|
||||
return this.updatePromise;
|
||||
};
|
||||
readonly retry = (): Promise<AppUpdaterCheckResult | AppUpdaterPhase> => {
|
||||
if (this.retryAction === "restart") return this.restart();
|
||||
if (this.retryAction === "update" && this.update) return this.downloadAndInstall();
|
||||
return this.check({ force: true });
|
||||
};
|
||||
readonly restart = (): Promise<AppUpdaterPhase> => {
|
||||
if (this.restartPromise) return this.restartPromise;
|
||||
if (this.state.phase !== "ready" && this.state.failure !== "restart") return Promise.resolve(this.state.phase);
|
||||
this.restartPromise = this.performRestart().finally(() => { this.restartPromise = null; });
|
||||
return this.restartPromise;
|
||||
};
|
||||
|
||||
private async performCheck(options: CheckOptions): Promise<AppUpdaterCheckResult> {
|
||||
this.retryAction = "check";
|
||||
this.setState({ phase: "checking", downloadedBytes: 0, totalBytes: null, failure: null, errorMessage: null, checkFailure: null, latestVersion: null, notes: null, publishedAt: null, logPath: null, takingLonger: false });
|
||||
try {
|
||||
const nextUpdate = await this.dependencies.check(options);
|
||||
await this.replaceUpdate(nextUpdate);
|
||||
if (nextUpdate && (!Number.isInteger(nextUpdate.rid) || nextUpdate.rid < 0 || nextUpdate.rid > 0xffff_ffff)) {
|
||||
throw new Error("invalid updater resource");
|
||||
}
|
||||
if (!nextUpdate) {
|
||||
this.setState({ phase: "idle", latestVersion: null, notes: null, publishedAt: null });
|
||||
return "up-to-date";
|
||||
}
|
||||
this.retryAction = "update";
|
||||
this.setState({ phase: "available", currentVersion: nextUpdate.currentVersion, latestVersion: nextUpdate.version,
|
||||
notes: nextUpdate.body?.trim() || null, publishedAt: nextUpdate.date || null });
|
||||
return "available";
|
||||
} catch (error) {
|
||||
await this.replaceUpdate(null);
|
||||
const checkFailure = classifyAppUpdaterCheckError(error);
|
||||
this.setState({ phase: "error", failure: "check", checkFailure, errorMessage: appUpdaterCheckFailureMessage(checkFailure), latestVersion: null, notes: null, publishedAt: null });
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
private async performDownloadAndInstall(update: CheckedAppUpdate, options?: DownloadOptions): Promise<AppUpdaterPhase> {
|
||||
this.retryAction = "update";
|
||||
const attempt = ++this.attempt;
|
||||
let acceptingEvents = true;
|
||||
let handedOff = false;
|
||||
this.setState({ phase: "downloading", downloadedBytes: 0, totalBytes: null, failure: null, errorMessage: null, checkFailure: null, logPath: null, takingLonger: false });
|
||||
this.armSlowNotice();
|
||||
try {
|
||||
const result = await this.dependencies.install(update, (event) => {
|
||||
if (!acceptingEvents || attempt !== this.attempt || handedOff) return;
|
||||
if (event.event === "HandedOff") handedOff = true;
|
||||
this.handleInstallEvent(event);
|
||||
}, options);
|
||||
// Windows can leave this WebView alive briefly after handing off. The
|
||||
// installer has started, but its success is not yet known; never relaunch it.
|
||||
const phase = handedOff || !result.restartRequired ? "handed-off" : "ready";
|
||||
this.setState({ phase, failure: null, takingLonger: false });
|
||||
if (phase === "handed-off") this.armSlowNotice();
|
||||
return phase;
|
||||
} catch (error) {
|
||||
if (handedOff) return "handed-off";
|
||||
const fallback = this.state.phase === "verifying" ? "verify" : this.state.phase === "preparing" ? "prepare"
|
||||
: this.state.phase === "installing" ? "install" : "download";
|
||||
this.setState({ phase: "error", ...installationError(error, fallback), takingLonger: false });
|
||||
return "error";
|
||||
} finally {
|
||||
acceptingEvents = false;
|
||||
if (this.state.phase !== "handed-off") this.clearSlowNotice();
|
||||
}
|
||||
}
|
||||
private handleInstallEvent(event: AppUpdateEvent): void {
|
||||
if (event.event === "Started") {
|
||||
this.setState({ phase: "downloading", downloadedBytes: 0, totalBytes: normalizeByteCount(event.data.contentLength) });
|
||||
} else if (event.event === "Progress") {
|
||||
const bytes = normalizeByteCount(event.data.chunkLength);
|
||||
if (bytes === null) return;
|
||||
const next = this.state.downloadedBytes + bytes;
|
||||
this.setState({ downloadedBytes: this.state.totalBytes === null ? next : Math.min(next, this.state.totalBytes) });
|
||||
} else {
|
||||
const phase = { Verifying: "verifying", Preparing: "preparing", Installing: "installing", HandedOff: "handed-off" }[event.event] as AppUpdaterPhase;
|
||||
this.setState({ phase, downloadedBytes: this.state.totalBytes ?? this.state.downloadedBytes });
|
||||
}
|
||||
this.armSlowNotice();
|
||||
}
|
||||
private armSlowNotice(): void {
|
||||
this.clearSlowNotice();
|
||||
this.setState({ takingLonger: false });
|
||||
this.slowTimer = this.dependencies.setTimer(() => {
|
||||
this.slowTimer = null;
|
||||
if (isAppUpdateBusy(this.state.phase)) this.setState({ takingLonger: true });
|
||||
}, UPDATER_SLOW_NOTICE_MS);
|
||||
}
|
||||
private clearSlowNotice(): void {
|
||||
if (this.slowTimer !== null) this.dependencies.clearTimer(this.slowTimer);
|
||||
this.slowTimer = null;
|
||||
}
|
||||
private async performRestart(): Promise<AppUpdaterPhase> {
|
||||
this.retryAction = "restart";
|
||||
this.setState({ failure: null, errorMessage: null, checkFailure: null, logPath: null });
|
||||
try { await this.dependencies.restart(); this.setState({ phase: "ready" }); return "ready"; }
|
||||
catch { this.setState({ phase: "error", failure: "restart" }); return "error"; }
|
||||
}
|
||||
private async replaceUpdate(next: CheckedAppUpdate | null): Promise<void> {
|
||||
const previous = this.update;
|
||||
this.update = next;
|
||||
if (previous && previous !== next) {
|
||||
try { await previous.close(); }
|
||||
catch { console.warn("Unable to release the previous updater resource"); }
|
||||
}
|
||||
}
|
||||
private setState(patch: Partial<AppUpdaterState>): void {
|
||||
this.state = { ...this.state, ...patch };
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { CodexLanguageStatus } from "./codexLanguageTypes";
|
||||
|
||||
// Keep the last detected setting across settings-page mounts. Never invent an
|
||||
// initial "off" state while the native configuration is still being read.
|
||||
const statuses = new Map<string, CodexLanguageStatus>();
|
||||
export function cachedCodexLanguageStatus(configDir: string) { return statuses.get(configDir) ?? null; }
|
||||
export function rememberCodexLanguageStatus(configDir: string, status: CodexLanguageStatus) {
|
||||
statuses.set(configDir, status);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type CodexLocale = "auto" | "zh-CN" | "en-US";
|
||||
export type CodexLanguageStatus = {
|
||||
locale: string;
|
||||
enabled: boolean;
|
||||
pendingEnabled: boolean | null;
|
||||
supported: boolean;
|
||||
appName: string | null;
|
||||
appVersion: string | null;
|
||||
phase: "inactive" | "pending" | "starting" | "active" | "failed" | "unsupported";
|
||||
message: string;
|
||||
readyPages: number;
|
||||
restoreAvailable: boolean;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
@@ -7,9 +8,11 @@ import {
|
||||
RotateCcw,
|
||||
Settings,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { INITIAL_APP_UPDATER_STATE, type AppUpdaterState } from "../appUpdater";
|
||||
import { INITIAL_APP_UPDATER_STATE, isAppUpdateBusy, type AppUpdaterState } from "../appUpdater";
|
||||
import { appUpdaterCheckFailureMessage } from "../appUpdaterController";
|
||||
import type { Lang, StartupDiagnostics } from "../types";
|
||||
import { Button, ModalShell } from "./ui";
|
||||
|
||||
@@ -18,6 +21,8 @@ export type AppToastProps = {
|
||||
message: string;
|
||||
error: string;
|
||||
loading?: boolean;
|
||||
persistent?: boolean;
|
||||
action?: { label: string; onClick: () => void; disabled?: boolean };
|
||||
onDismissMessage: () => void;
|
||||
onDismissError: () => void;
|
||||
};
|
||||
@@ -27,6 +32,8 @@ export function AppToast({
|
||||
message,
|
||||
error,
|
||||
loading = false,
|
||||
action,
|
||||
persistent = false,
|
||||
onDismissMessage,
|
||||
onDismissError,
|
||||
}: AppToastProps) {
|
||||
@@ -38,11 +45,12 @@ export function AppToast({
|
||||
const [firstLine, ...remainingLines] = activeText.split("\n");
|
||||
const detail = remainingLines.join("\n").trim();
|
||||
const dismiss = isError ? onDismissError : onDismissMessage;
|
||||
const visibleAction = !loading ? action : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${status}:${activeText}`}
|
||||
className={`cx-app-toast cx-app-toast--${status}`}
|
||||
className={`cx-app-toast cx-app-toast--${status}${visibleAction ? " cx-app-toast--actionable" : ""}${persistent ? " cx-app-toast--persistent" : ""}`}
|
||||
role={isError ? "alert" : "status"}
|
||||
aria-live={isError ? "assertive" : "polite"}
|
||||
onAnimationEnd={(event) => {
|
||||
@@ -57,6 +65,10 @@ export function AppToast({
|
||||
<strong>{firstLine || (isError ? (lang === "zh" ? "操作失败" : "Action failed") : "Codex-X")}</strong>
|
||||
{detail && <span>{detail}</span>}
|
||||
</div>
|
||||
{visibleAction && <>
|
||||
<button type="button" className="cx-app-toast-action" onClick={visibleAction.onClick} disabled={visibleAction.disabled}>{visibleAction.label}</button>
|
||||
<button type="button" className="cx-app-toast-dismiss" onClick={dismiss} aria-label={lang === "zh" ? "关闭提示" : "Dismiss notification"}><X size={14} aria-hidden="true" /></button>
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -101,7 +113,11 @@ export function UpdateDialog({
|
||||
latestVersion: latestVersion ?? null,
|
||||
};
|
||||
const phase = updaterState.phase;
|
||||
const isBusy = phase === "downloading" || phase === "installing";
|
||||
const isBusy = isAppUpdateBusy(phase);
|
||||
const checkFailed = phase === "error" && updaterState.failure === "check";
|
||||
const failureDetails = checkFailed
|
||||
? appUpdaterCheckFailureMessage(updaterState.checkFailure ?? "unknown", lang)
|
||||
: updaterState.errorMessage;
|
||||
const totalBytes = updaterState.totalBytes;
|
||||
const hasKnownProgress = totalBytes !== null && totalBytes > 0;
|
||||
const progress = totalBytes !== null && totalBytes > 0
|
||||
@@ -118,12 +134,18 @@ export function UpdateDialog({
|
||||
: "检测到新版本,可前往下载页获取对应平台的安装包。",
|
||||
downloadingTitle: "正在下载更新",
|
||||
downloadingDescription: "请保持 Codex-X 打开,下载完成后会自动安装。",
|
||||
installingTitle: "正在安装更新",
|
||||
installingDescription: "即将完成,请暂时不要关闭软件。",
|
||||
verifyingTitle: "正在验证安装包",
|
||||
verifyingDescription: "下载已完成,正在确认安装包完整可靠。",
|
||||
preparingTitle: "正在准备更新",
|
||||
preparingDescription: "正在保存状态并恢复连接配置,完成后会自动退出并开始安装。",
|
||||
installingTitle: "正在启动安装程序",
|
||||
installingDescription: "请稍候。如系统询问是否允许安装,请确认授权。",
|
||||
handedOffTitle: "安装程序已启动",
|
||||
handedOffDescription: "Codex-X 即将退出,请在安装窗口继续。首次升级旧版时,系统可能需要一次管理员授权。",
|
||||
readyTitle: "更新已准备好",
|
||||
readyDescription: "重新启动 Codex-X 即可使用新版本。",
|
||||
errorTitle: "更新没有完成",
|
||||
errorDescription: updaterState.failure === "restart"
|
||||
errorTitle: checkFailed ? "在线更新检查失败" : "更新没有完成",
|
||||
errorDescription: checkFailed ? "在线安装尚未开始。请重试在线检查;下载页可作为备用。" : updaterState.failure === "restart"
|
||||
? "软件未能重新启动,请再试一次。"
|
||||
: "请重试;如果仍然失败,也可以前往下载页更新。",
|
||||
idleTitle: "当前已是最新版本",
|
||||
@@ -134,10 +156,20 @@ export function UpdateDialog({
|
||||
close: "关闭",
|
||||
updateNow: "立即更新",
|
||||
downloading: "正在下载",
|
||||
installing: "正在安装",
|
||||
verifying: "验证安装包",
|
||||
preparing: "准备退出",
|
||||
installing: "启动安装程序",
|
||||
handedOff: "等待安装程序",
|
||||
slowTitle: "这一步比平时慢一些",
|
||||
slowDescription: phase === "downloading"
|
||||
? "暂时没有收到新的下载数据,正在等待网络恢复。请勿重复点击更新。"
|
||||
: "仍在等待当前步骤完成。请查看是否有系统授权或安装窗口等待操作,不要重复启动安装。",
|
||||
detailLabel: "失败原因",
|
||||
logLabel: "安装日志",
|
||||
restart: "重新启动",
|
||||
retry: "重试",
|
||||
retry: checkFailed ? "重试在线检查" : "重试",
|
||||
downloadPage: "打开下载页",
|
||||
backupDownloadPage: "打开下载页(备用)",
|
||||
releaseNotes: "本次更新",
|
||||
}
|
||||
: {
|
||||
@@ -149,12 +181,18 @@ export function UpdateDialog({
|
||||
: "A new version is available from the download page for your platform.",
|
||||
downloadingTitle: "Downloading update",
|
||||
downloadingDescription: "Keep Codex-X open. Installation starts automatically after download.",
|
||||
installingTitle: "Installing update",
|
||||
installingDescription: "Almost done. Please keep the app open.",
|
||||
verifyingTitle: "Verifying installer",
|
||||
verifyingDescription: "Download complete. Checking the installer before making changes.",
|
||||
preparingTitle: "Preparing update",
|
||||
preparingDescription: "Saving state and restoring connection settings before exiting to install.",
|
||||
installingTitle: "Starting installer",
|
||||
installingDescription: "Please wait. Approve the installation if your system asks for permission.",
|
||||
handedOffTitle: "Installer started",
|
||||
handedOffDescription: "Codex-X will exit. Continue in the installer window. Upgrading an older installation may ask for administrator permission once.",
|
||||
readyTitle: "Update is ready",
|
||||
readyDescription: "Restart Codex-X to use the new version.",
|
||||
errorTitle: "Update did not finish",
|
||||
errorDescription: updaterState.failure === "restart"
|
||||
errorTitle: checkFailed ? "Online update check failed" : "Update did not finish",
|
||||
errorDescription: checkFailed ? "Online installation has not started. Retry the online check; the download page is available as a fallback." : updaterState.failure === "restart"
|
||||
? "Codex-X could not restart. Please try again."
|
||||
: "Try again, or use the download page if the problem continues.",
|
||||
idleTitle: "Codex-X is up to date",
|
||||
@@ -165,39 +203,40 @@ export function UpdateDialog({
|
||||
close: "Close",
|
||||
updateNow: "Update now",
|
||||
downloading: "Downloading",
|
||||
installing: "Installing",
|
||||
verifying: "Verifying installer",
|
||||
preparing: "Preparing to exit",
|
||||
installing: "Starting installer",
|
||||
handedOff: "Waiting for installer",
|
||||
slowTitle: "Taking a little longer",
|
||||
slowDescription: phase === "downloading"
|
||||
? "Waiting for more download data. Please do not start another update."
|
||||
: "Still waiting for this step to finish. Check for a system permission or installer window requiring your attention. Do not start another installation.",
|
||||
detailLabel: "What went wrong",
|
||||
logLabel: "Installation log",
|
||||
restart: "Restart",
|
||||
retry: "Try again",
|
||||
retry: checkFailed ? "Retry online check" : "Try again",
|
||||
downloadPage: "Open download page",
|
||||
backupDownloadPage: "Open download page (fallback)",
|
||||
releaseNotes: "What's new",
|
||||
};
|
||||
|
||||
const title = phase === "checking"
|
||||
? copy.checkingTitle
|
||||
: phase === "available"
|
||||
? copy.availableTitle
|
||||
: phase === "downloading"
|
||||
? copy.downloadingTitle
|
||||
: phase === "installing"
|
||||
? copy.installingTitle
|
||||
: phase === "ready"
|
||||
? copy.readyTitle
|
||||
: phase === "error"
|
||||
? copy.errorTitle
|
||||
: copy.idleTitle;
|
||||
const description = phase === "checking"
|
||||
? copy.checkingDescription
|
||||
: phase === "available"
|
||||
? copy.availableDescription
|
||||
: phase === "downloading"
|
||||
? copy.downloadingDescription
|
||||
: phase === "installing"
|
||||
? copy.installingDescription
|
||||
: phase === "ready"
|
||||
? copy.readyDescription
|
||||
: phase === "error"
|
||||
? copy.errorDescription
|
||||
: copy.idleDescription;
|
||||
const phaseCopy = {
|
||||
checking: [copy.checkingTitle, copy.checkingDescription],
|
||||
available: [copy.availableTitle, copy.availableDescription],
|
||||
downloading: [copy.downloadingTitle, copy.downloadingDescription],
|
||||
verifying: [copy.verifyingTitle, copy.verifyingDescription],
|
||||
preparing: [copy.preparingTitle, copy.preparingDescription],
|
||||
installing: [copy.installingTitle, copy.installingDescription],
|
||||
"handed-off": [copy.handedOffTitle, copy.handedOffDescription],
|
||||
ready: [copy.readyTitle, copy.readyDescription],
|
||||
error: [copy.errorTitle, copy.errorDescription],
|
||||
idle: [copy.idleTitle, copy.idleDescription],
|
||||
};
|
||||
const [title, description] = phaseCopy[phase];
|
||||
const progressLabel = phase === "downloading" ? copy.downloading
|
||||
: phase === "verifying" ? copy.verifying : phase === "preparing" ? copy.preparing
|
||||
: phase === "handed-off" ? copy.handedOff : phase === "ready" ? copy.restart : copy.installing;
|
||||
const indeterminate = isBusy && (phase !== "downloading" || progress === null);
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isBusy) onClose();
|
||||
@@ -228,7 +267,7 @@ export function UpdateDialog({
|
||||
? (
|
||||
<>
|
||||
<Button variant="secondary" icon={<Download size={16} />} onClick={onDownload}>
|
||||
{copy.downloadPage}
|
||||
{checkFailed ? copy.backupDownloadPage : copy.downloadPage}
|
||||
</Button>
|
||||
<Button icon={<RotateCcw size={16} />} onClick={() => void onRetry?.()}>
|
||||
{copy.retry}
|
||||
@@ -238,7 +277,7 @@ export function UpdateDialog({
|
||||
: isBusy
|
||||
? (
|
||||
<Button disabled icon={<Loader2 className="spin" size={16} />}>
|
||||
{phase === "downloading" ? copy.downloading : copy.installing}
|
||||
{progressLabel}
|
||||
</Button>
|
||||
)
|
||||
: <Button variant="secondary" onClick={handleClose}>{copy.close}</Button>;
|
||||
@@ -271,10 +310,10 @@ export function UpdateDialog({
|
||||
<div><dt>{copy.latest}</dt><dd>{updaterState.latestVersion || latestVersion || "-"}</dd></div>
|
||||
</dl>
|
||||
|
||||
{(phase === "downloading" || phase === "installing" || phase === "ready") && (
|
||||
{(isBusy || phase === "ready") && (
|
||||
<div className="cx-update-progress" aria-live="polite">
|
||||
<div className="cx-update-progress-copy">
|
||||
<span>{phase === "downloading" ? copy.downloading : phase === "installing" ? copy.installing : copy.restart}</span>
|
||||
<span>{progressLabel}</span>
|
||||
<strong>
|
||||
{phase === "downloading"
|
||||
? hasKnownProgress
|
||||
@@ -288,18 +327,31 @@ export function UpdateDialog({
|
||||
</strong>
|
||||
</div>
|
||||
<div
|
||||
className={`cx-update-progress-track${progress === null && phase === "downloading" ? " cx-update-progress-track--indeterminate" : ""}`}
|
||||
className={`cx-update-progress-track${indeterminate ? " cx-update-progress-track--indeterminate" : ""}`}
|
||||
role="progressbar"
|
||||
aria-label={phase === "downloading" ? copy.downloading : copy.installing}
|
||||
aria-label={progressLabel}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={phase === "ready" ? 100 : progress ?? undefined}
|
||||
aria-valuenow={phase === "ready" ? 100 : indeterminate ? undefined : progress ?? undefined}
|
||||
>
|
||||
<span style={{ width: phase === "ready" || phase === "installing" ? "100%" : progress === null ? "38%" : `${progress}%` }} />
|
||||
<span style={{ width: phase === "ready" ? "100%" : indeterminate ? "38%" : `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updaterState.takingLonger && isBusy && (
|
||||
<section className="cx-update-notes" role="status">
|
||||
<strong>{copy.slowTitle}</strong>
|
||||
<p>{copy.slowDescription}</p>
|
||||
</section>
|
||||
)}
|
||||
{phase === "error" && failureDetails && (
|
||||
<section className="cx-update-notes" role="alert">
|
||||
<strong>{copy.detailLabel}</strong>
|
||||
<p>{failureDetails}</p>
|
||||
{updaterState.logPath && <p className="cx-update-log-path">{copy.logLabel}: {updaterState.logPath}</p>}
|
||||
</section>
|
||||
)}
|
||||
{updaterState.notes && phase !== "checking" && (
|
||||
<section className="cx-update-notes">
|
||||
<strong>{copy.releaseNotes}</strong>
|
||||
@@ -313,8 +365,11 @@ export function UpdateDialog({
|
||||
export type StartupWizardDialogProps = {
|
||||
open: boolean;
|
||||
closing: boolean;
|
||||
mode?: "startup" | "manual";
|
||||
lang: Lang;
|
||||
diagnostics: StartupDiagnostics | null;
|
||||
diagnosticsError?: string;
|
||||
configHealthPanel?: ReactNode;
|
||||
configDir: string;
|
||||
loading: boolean;
|
||||
onConfigDirChange: (value: string) => void;
|
||||
@@ -327,8 +382,11 @@ export type StartupWizardDialogProps = {
|
||||
export function StartupWizardDialog({
|
||||
open,
|
||||
closing,
|
||||
mode = "startup",
|
||||
lang,
|
||||
diagnostics,
|
||||
diagnosticsError,
|
||||
configHealthPanel,
|
||||
configDir,
|
||||
loading,
|
||||
onConfigDirChange,
|
||||
@@ -338,48 +396,76 @@ export function StartupWizardDialog({
|
||||
onEnter,
|
||||
}: StartupWizardDialogProps) {
|
||||
const isChinese = lang === "zh";
|
||||
if (!diagnostics) return null;
|
||||
const isManual = mode === "manual";
|
||||
const recheckButton = (
|
||||
<Button
|
||||
variant={isManual ? "primary" : "secondary"}
|
||||
icon={<RefreshCw size={16} className={loading ? "spin" : undefined} />}
|
||||
onClick={onRecheck}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (isChinese ? "正在检查" : "Checking") : (isChinese ? "重新检查" : "Recheck")}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalShell
|
||||
open={open}
|
||||
onClose={onSkip}
|
||||
size="lg"
|
||||
title={isChinese ? "首次启动向导" : "First-run wizard"}
|
||||
description={diagnostics.summary}
|
||||
showCloseButton={false}
|
||||
closeOnBackdrop={false}
|
||||
closeOnEscape={false}
|
||||
title={isChinese ? "环境与配置检查" : "Environment & configuration check"}
|
||||
description={isManual
|
||||
? (isChinese ? "查看环境与配置状态,发现问题后可选择修复。" : "Review your environment and configuration, and choose whether to repair any issues.")
|
||||
: (isChinese ? "首次使用前,检查 Codex 环境与配置是否就绪。" : "Before you get started, check whether your Codex environment and configuration are ready.")}
|
||||
showCloseButton={isManual}
|
||||
closeLabel={isChinese ? "关闭" : "Close"}
|
||||
closeOnBackdrop={isManual}
|
||||
closeOnEscape={isManual}
|
||||
className={closing ? "cx-startup-dialog cx-startup-dialog--closing" : "cx-startup-dialog"}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="ghost" onClick={onSkip}>{isChinese ? "跳过" : "Skip"}</Button>
|
||||
<Button variant="secondary" icon={<Settings size={16} />} onClick={onOpenSettings}>{isChinese ? "去设置" : "Settings"}</Button>
|
||||
<Button icon={<CheckCircle2 size={16} />} onClick={onEnter}>{isChinese ? "进入 Codex-X" : "Enter Codex-X"}</Button>
|
||||
</>
|
||||
isManual ? (
|
||||
<>
|
||||
<Button variant="secondary" onClick={onSkip}>{isChinese ? "关闭" : "Close"}</Button>
|
||||
{recheckButton}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" onClick={onSkip}>{isChinese ? "跳过" : "Skip"}</Button>
|
||||
<Button variant="secondary" icon={<Settings size={16} />} onClick={onOpenSettings}>{isChinese ? "去设置" : "Settings"}</Button>
|
||||
<Button icon={<CheckCircle2 size={16} />} onClick={onEnter}>{isChinese ? "进入 Codex-X" : "Enter Codex-X"}</Button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
>
|
||||
<div className="cx-startup-path-control">
|
||||
<div className={`cx-startup-path-control${isManual ? " cx-startup-path-control--manual" : ""}`}>
|
||||
<label htmlFor="cx-startup-codex-home">CODEX_HOME</label>
|
||||
<input
|
||||
id="cx-startup-codex-home"
|
||||
value={configDir || diagnostics.codexDir}
|
||||
value={configDir}
|
||||
onChange={(event) => onConfigDirChange(event.target.value)}
|
||||
placeholder="~/.codex"
|
||||
disabled={loading}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={<RefreshCw size={16} className={loading ? "spin" : undefined} />}
|
||||
onClick={onRecheck}
|
||||
disabled={loading}
|
||||
>
|
||||
{isChinese ? "重新检测" : "Recheck"}
|
||||
</Button>
|
||||
{!isManual && recheckButton}
|
||||
</div>
|
||||
|
||||
<div className="cx-startup-checks">
|
||||
{diagnosticsError && <div className="cx-startup-diagnostics-notice cx-startup-diagnostics-notice--error" role="alert">
|
||||
<AlertCircle size={17} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{isChinese ? "环境检查未完成" : "Environment check did not finish"}</strong>
|
||||
<p>{diagnosticsError}</p>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{!diagnostics && !diagnosticsError && <div className="cx-startup-diagnostics-notice" role="status" aria-live="polite">
|
||||
{loading ? <Loader2 className="spin" size={17} aria-hidden="true" /> : <RefreshCw size={17} aria-hidden="true" />}
|
||||
<p>{loading
|
||||
? (isChinese ? "正在检查 Codex 环境…" : "Checking your Codex environment…")
|
||||
: (isChinese ? "点击重新检查,查看当前环境状态。" : "Choose Recheck to see the current environment status.")}</p>
|
||||
</div>}
|
||||
|
||||
{diagnostics && <div className="cx-startup-checks">
|
||||
{diagnostics.items.map((item) => {
|
||||
const isOk = item.status === "ok";
|
||||
const isManual = item.status === "manual";
|
||||
@@ -403,7 +489,9 @@ export function StartupWizardDialog({
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{configHealthPanel && <div className="cx-startup-config-health">{configHealthPanel}</div>}
|
||||
</ModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { AppUpdaterPhase } from "../appUpdater";
|
||||
import { isAppUpdateBusy, type AppUpdaterPhase } from "../appUpdater";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
export type AppLanguage = "zh" | "en";
|
||||
@@ -102,11 +102,10 @@ export function AppShell({
|
||||
? (lang === "zh" ? "切换为浅色模式" : "Switch to light mode")
|
||||
: (lang === "zh" ? "切换为深色模式" : "Switch to dark mode");
|
||||
const ThemeIcon = theme === "dark" ? Moon : Sun;
|
||||
const updateActionState = updatePhase === "downloading"
|
||||
|| updatePhase === "installing"
|
||||
const updateActionState = isAppUpdateBusy(updatePhase)
|
||||
|| updatePhase === "ready"
|
||||
|| updatePhase === "available"
|
||||
? updatePhase
|
||||
? (isAppUpdateBusy(updatePhase) && updatePhase !== "downloading" ? "installing" : updatePhase)
|
||||
: hasUpdate
|
||||
? "available"
|
||||
: null;
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Languages, Loader2 } from "lucide-react";
|
||||
import type { CodexLanguageStatus } from "../codexLanguageTypes";
|
||||
import { Toggle } from "./ui";
|
||||
import { cachedCodexLanguageStatus, rememberCodexLanguageStatus } from "../codexLanguageState";
|
||||
import "../styles/codex-language.css";
|
||||
|
||||
export function CodexLanguageControl({ lang, configDir, active = true, disabled = false, onBusyChange, onPreferencesSaved }: {
|
||||
lang: "zh" | "en";
|
||||
configDir: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
onBusyChange?: (busy: boolean) => void;
|
||||
onPreferencesSaved?: (status: CodexLanguageStatus) => void;
|
||||
}) {
|
||||
const text = (zh: string, en: string) => lang === "zh" ? zh : en;
|
||||
const [detected, setDetected] = useState(() => ({ configDir, value: cachedCodexLanguageStatus(configDir) }));
|
||||
const status = detected.configDir === configDir ? detected.value : cachedCodexLanguageStatus(configDir);
|
||||
const setStatus = (value: CodexLanguageStatus) => {
|
||||
rememberCodexLanguageStatus(configDir, value);
|
||||
setDetected({ configDir, value });
|
||||
};
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [readError, setReadError] = useState("");
|
||||
const [actionError, setActionError] = useState("");
|
||||
const parentBusy = useRef(disabled);
|
||||
parentBusy.current = disabled;
|
||||
const generation = useRef(0);
|
||||
const operationInFlight = useRef(false);
|
||||
const statusRequest = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
generation.current += 1;
|
||||
setDetected({ configDir, value: cachedCodexLanguageStatus(configDir) });
|
||||
setReadError(""); setActionError(""); setBusy(false);
|
||||
}, [configDir]);
|
||||
useEffect(() => () => { generation.current += 1; }, []);
|
||||
|
||||
useEffect(() => {
|
||||
const current = generation.current;
|
||||
let disposed = false;
|
||||
let loading = false;
|
||||
const load = async () => {
|
||||
if (loading || disposed || !active || operationInFlight.current || parentBusy.current) return;
|
||||
loading = true;
|
||||
const request = ++statusRequest.current;
|
||||
try {
|
||||
const next = await invoke<CodexLanguageStatus>("get_codex_language_status", { configDir });
|
||||
if (!disposed && generation.current === current && request === statusRequest.current && !operationInFlight.current && !parentBusy.current) {
|
||||
setStatus(next); setReadError("");
|
||||
}
|
||||
} catch (cause) {
|
||||
if (!disposed && generation.current === current && request === statusRequest.current && !operationInFlight.current && !parentBusy.current) setReadError(String(cause));
|
||||
} finally { loading = false; }
|
||||
};
|
||||
if (active) void load();
|
||||
const interval = active ? window.setInterval(() => { void load(); }, 1800) : undefined;
|
||||
return () => { disposed = true; if (interval !== undefined) window.clearInterval(interval); };
|
||||
}, [configDir, active]);
|
||||
|
||||
const save = async (enabled: boolean) => {
|
||||
if (busy || disabled || operationInFlight.current) return;
|
||||
operationInFlight.current = true;
|
||||
statusRequest.current += 1;
|
||||
onBusyChange?.(true);
|
||||
const current = generation.current;
|
||||
setBusy(true); setActionError("");
|
||||
try {
|
||||
const next = await invoke<CodexLanguageStatus>("save_codex_language_preferences", { configDir, locale: enabled ? "zh-CN" : "auto", enabled });
|
||||
if (current === generation.current) { setStatus(next); setReadError(""); onPreferencesSaved?.(next); }
|
||||
} catch (cause) {
|
||||
if (current === generation.current) setActionError(String(cause));
|
||||
} finally {
|
||||
operationInFlight.current = false;
|
||||
onBusyChange?.(false);
|
||||
if (current === generation.current) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pending = status?.pendingEnabled != null;
|
||||
const enabled = status?.pendingEnabled ?? status?.enabled ?? false;
|
||||
const starting = status?.phase === "starting";
|
||||
const blocked = busy || disabled || starting;
|
||||
const error = actionError || readError || (status?.phase === "failed" ? status.message : "");
|
||||
const description = error || (status && !status.supported ? status.message : text(
|
||||
"为 Codex 应用简体中文,重启生效。自动检测当前配置。",
|
||||
"Use Simplified Chinese in Codex after restart. Detects current settings.",
|
||||
));
|
||||
const stateLabel = busy ? text("处理中", "Saving")
|
||||
: readError ? text("检测失败", "Unavailable")
|
||||
: actionError ? text("操作失败", "Failed")
|
||||
: !status ? text("检测中", "Checking")
|
||||
: status.phase === "failed" ? text("应用失败", "Failed")
|
||||
: starting ? text("应用中", "Applying")
|
||||
: pending ? text("待重启", "Restart needed")
|
||||
: enabled ? text("已开启", "On") : text("已关闭", "Off");
|
||||
|
||||
return <section className="cx-page-setting-row cx-codex-language" aria-labelledby="codex-language-title" aria-busy={busy || starting}>
|
||||
<div className="cx-page-setting-icon" aria-hidden="true"><Languages size={18} strokeWidth={1.9} /></div>
|
||||
<div className="cx-page-setting-copy">
|
||||
<strong id="codex-language-title">{text("Codex 简体中文", "Codex Simplified Chinese")}</strong>
|
||||
<p id="codex-language-description" title={description} role={error ? "alert" : undefined} className={error ? "cx-codex-language-error" : undefined}>{description}</p>
|
||||
</div>
|
||||
<div className="cx-page-setting-action cx-codex-language-action">
|
||||
<div className="cx-codex-language-switch">
|
||||
<span className={enabled && !pending && !error ? "cx-codex-language-enabled" : undefined} aria-live="polite">{stateLabel}</span>
|
||||
{status ? <Toggle checked={enabled} disabled={blocked || !status.supported || Boolean(readError)} aria-labelledby="codex-language-title" aria-describedby="codex-language-description" onCheckedChange={next => void save(next)} />
|
||||
: <span className="cx-codex-language-loading" aria-hidden="true"><Loader2 size={15} className="cx-page-spin" /></span>}
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useId } from "react";
|
||||
import { AlertTriangle, CheckCircle2, FileSearch, FolderOpen, Loader2, RefreshCw, Wrench } from "lucide-react";
|
||||
import { configHealthIssueLocation, configHealthRepairUnavailableReason } from "../configHealthPresentation";
|
||||
import type { ConfigHealthIssue, ConfigHealthReport } from "../configHealthTypes";
|
||||
import { Button } from "./ui";
|
||||
import "../styles/config-health.css";
|
||||
|
||||
export type ConfigHealthPanelProps = {
|
||||
lang: "zh" | "en";
|
||||
report: ConfigHealthReport | null;
|
||||
checking: boolean;
|
||||
repairing: boolean;
|
||||
error: string;
|
||||
onCheck: () => void;
|
||||
onRepair: () => void;
|
||||
onOpenConfig?: () => void;
|
||||
};
|
||||
|
||||
export function ConfigHealthIssueDetails({ issue, report, lang }: { issue: ConfigHealthIssue; report: ConfigHealthReport; lang: "zh" | "en" }) {
|
||||
const location = configHealthIssueLocation(issue, report, lang);
|
||||
return <>
|
||||
<p className="cx-config-health-location"><code>{location.path}</code><span>{location.position}</span></p>
|
||||
<p className="cx-config-health-key">{lang === "zh" ? "参数:" : "Setting: "}<code>{location.key}</code></p>
|
||||
{issue.suggestion && <p className="cx-config-health-suggestion">{lang === "zh" ? "修复建议:" : "Suggested fix: "}{issue.suggestion}</p>}
|
||||
</>;
|
||||
}
|
||||
|
||||
export function ConfigHealthStatus({ lang, report, checking, repairing, error }: Pick<ConfigHealthPanelProps, "lang" | "report" | "checking" | "repairing" | "error">) {
|
||||
const zh = lang === "zh";
|
||||
const busy = checking || repairing;
|
||||
const hasIssues = report?.status === "issues";
|
||||
const healthy = report?.status === "healthy" && !error;
|
||||
const tone = error || hasIssues || report?.status === "unavailable" ? "warning" : healthy ? "success" : "neutral";
|
||||
const status = repairing ? (zh ? "正在修复" : "Repairing")
|
||||
: checking ? (zh ? "正在检查" : "Checking")
|
||||
: error ? (zh ? "操作未完成" : "Action incomplete")
|
||||
: healthy ? (zh ? "配置正常" : "Looks good")
|
||||
: hasIssues ? (zh ? "发现问题" : "Needs attention")
|
||||
: report?.status === "missing" ? (zh ? "尚无配置" : "No configuration")
|
||||
: report?.status === "unavailable" ? (zh ? "暂时无法检查" : "Unavailable")
|
||||
: (zh ? "未检查" : "Not checked");
|
||||
const StatusIcon = busy ? Loader2 : tone === "warning" ? AlertTriangle : healthy ? CheckCircle2 : FileSearch;
|
||||
return <span className={`cx-config-health-status cx-config-health-status--${tone}`} role="status" aria-live="polite">
|
||||
<StatusIcon size={14} aria-hidden="true" className={busy ? "cx-config-health-spinner" : undefined} />
|
||||
{status}
|
||||
</span>;
|
||||
}
|
||||
|
||||
export function ConfigHealthPanel({ lang, report, checking, repairing, error, onCheck, onRepair, onOpenConfig }: ConfigHealthPanelProps) {
|
||||
const titleId = useId();
|
||||
const zh = lang === "zh";
|
||||
const busy = checking || repairing;
|
||||
const hasIssues = report?.status === "issues";
|
||||
const healthy = report?.status === "healthy" && !error;
|
||||
const manualIssues = hasIssues && report.issues.some((issue) => !issue.repairable);
|
||||
|
||||
return <section className="cx-config-health" aria-labelledby={titleId}>
|
||||
<div className="cx-config-health-heading">
|
||||
<span className="cx-config-health-icon" aria-hidden="true"><FileSearch size={19} /></span>
|
||||
<div className="cx-config-health-heading-copy">
|
||||
<h3 id={titleId}>{zh ? "配置检查与修复" : "Configuration check & repair"}</h3>
|
||||
<p>{zh ? "检查影响 Codex 启动和供应商使用的配置问题,由你决定是否修复。" : "Check for configuration issues that affect Codex and your providers. You choose whether to repair them."}</p>
|
||||
</div>
|
||||
<ConfigHealthStatus lang={lang} report={report} checking={checking} repairing={repairing} error={error} />
|
||||
</div>
|
||||
|
||||
{error && <p className="cx-config-health-error" role="alert">{error}</p>}
|
||||
|
||||
{hasIssues && <div className="cx-config-health-details">
|
||||
<ul className="cx-config-health-issues">
|
||||
{report.issues.map((issue, index) => <li key={`${issue.code}-${index}`}>
|
||||
<AlertTriangle size={15} aria-hidden="true" />
|
||||
<div><strong>{issue.title}</strong><p>{issue.description}</p><ConfigHealthIssueDetails issue={issue} report={report} lang={lang} /></div>
|
||||
</li>)}
|
||||
</ul>
|
||||
{report.canRepair && report.repairSummary.length > 0 && <div className="cx-config-health-plan">
|
||||
<strong>{zh ? "点击修复后,将进行以下调整:" : "Repair will make these changes:"}</strong>
|
||||
<ul>{report.repairSummary.map((summary, index) => <li key={index}>{summary}</li>)}</ul>
|
||||
</div>}
|
||||
{manualIssues && <p className="cx-config-health-note">{zh ? "请按每项问题的文件位置与修复建议处理需要手动确认的参数,然后重新检查。" : "Use each issue’s file location and suggested fix to review settings that need manual confirmation, then check again."}</p>}
|
||||
</div>}
|
||||
|
||||
{report?.status === "missing" && <p className="cx-config-health-note">{zh ? "还没有找到配置文件。完成 Codex 登录或启用供应商后,可以再次检查。" : "No configuration file was found. Check again after signing in to Codex or enabling a provider."}</p>}
|
||||
{report?.status === "unavailable" && !error && <p className="cx-config-health-note">{report.issues[0]?.description || (zh ? "暂时无法读取配置,请稍后重试或打开配置查看。" : "The configuration could not be read. Try again later or open it to review.")}</p>}
|
||||
|
||||
<div className="cx-config-health-footer">
|
||||
<p className="cx-config-health-note">{healthy
|
||||
? (zh ? "暂未发现需要修复的配置问题。" : "No configuration issues were found.")
|
||||
: (zh ? "检查不会修改配置。点击修复后,会先自动备份。" : "Checking does not change your settings. Choosing repair creates a backup first.")}</p>
|
||||
<div className="cx-config-health-actions">
|
||||
{onOpenConfig && (hasIssues || report?.status === "unavailable") && <Button variant="ghost" size="sm" icon={<FolderOpen size={15} />} disabled={busy} onClick={onOpenConfig}>{zh ? "查看配置" : "View configuration"}</Button>}
|
||||
<Button variant="secondary" size="sm" icon={checking ? <Loader2 size={15} className="cx-config-health-spinner" /> : <RefreshCw size={15} />} disabled={busy} onClick={onCheck}>{checking ? (zh ? "检查中" : "Checking") : (zh ? "检查配置" : "Check configuration")}</Button>
|
||||
{hasIssues && !report.canRepair && <Button variant="secondary" size="sm" icon={<Wrench size={15} />} disabled title={configHealthRepairUnavailableReason(report, lang)}>{zh ? "需手动修复" : "Manual repair needed"}</Button>}
|
||||
{report?.canRepair && hasIssues && <Button size="sm" icon={repairing ? <Loader2 size={15} className="cx-config-health-spinner" /> : <Wrench size={15} />} disabled={busy} onClick={onRepair}>{repairing ? (zh ? "修复中" : "Repairing") : (zh ? "修复配置" : "Repair configuration")}</Button>}
|
||||
</div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { AlertTriangle, ArrowRight, Loader2, Wrench, X } from "lucide-react";
|
||||
import { primaryConfigHealthIssue } from "../configHealthPresentation";
|
||||
import { ConfigHealthIssueDetails } from "./ConfigHealthPanel";
|
||||
import type { ConfigHealthReport } from "../configHealthTypes";
|
||||
import { Button } from "./ui";
|
||||
import "../styles/config-health.css";
|
||||
|
||||
export type ConfigHealthToastProps = {
|
||||
lang: "zh" | "en";
|
||||
report: ConfigHealthReport;
|
||||
repairing: boolean;
|
||||
onRepair: () => void;
|
||||
onDismiss: () => void;
|
||||
onOpenSettings: () => void;
|
||||
};
|
||||
|
||||
/** The caller owns notification lifetime; CSS never dismisses this toast. */
|
||||
export function ConfigHealthToast({ lang, report, repairing, onRepair, onDismiss, onOpenSettings }: ConfigHealthToastProps) {
|
||||
const zh = lang === "zh";
|
||||
const issue = primaryConfigHealthIssue(report);
|
||||
return <aside className="cx-config-health-toast" aria-label={zh ? "配置检查提醒" : "Configuration check notification"}>
|
||||
<span className="cx-config-health-toast-icon" aria-hidden="true"><AlertTriangle size={20} /></span>
|
||||
<div className="cx-config-health-toast-content">
|
||||
<div role="status" aria-live="polite" aria-atomic="true">
|
||||
<strong>{repairing ? (zh ? "正在修复 Codex 配置" : "Repairing Codex configuration") : (zh ? "检测到 Codex 配置问题" : "Codex configuration needs attention")}</strong>
|
||||
<p>{(report.canRepair ? issue?.title : issue?.description) || (zh ? "部分配置可能影响 Codex 正常使用。" : "Some settings may prevent Codex from working correctly.")}</p>
|
||||
{issue && <ConfigHealthIssueDetails issue={issue} report={report} lang={lang} />}
|
||||
{report.canRepair && report.repairSummary[0] && <p className="cx-config-health-toast-plan">{report.repairSummary[0]}</p>}
|
||||
</div>
|
||||
<div className="cx-config-health-toast-actions">
|
||||
{report.canRepair && <Button size="sm" icon={repairing ? <Loader2 size={15} className="cx-config-health-spinner" /> : <Wrench size={15} />} disabled={repairing} onClick={onRepair}>{repairing ? (zh ? "修复中" : "Repairing") : (zh ? "修复配置" : "Repair configuration")}</Button>}
|
||||
<Button variant={report.canRepair ? "ghost" : "primary"} size="sm" icon={<ArrowRight size={15} />} iconPosition="end" disabled={repairing} onClick={onOpenSettings}>{zh ? "查看设置" : "View settings"}</Button>
|
||||
</div>
|
||||
<p className="cx-config-health-toast-hint">{zh ? "也可稍后前往「设置 → 通用设置 → 环境与配置检查」处理。" : "You can also use Settings → General → Environment & configuration check later."}</p>
|
||||
</div>
|
||||
<button type="button" className="cx-config-health-toast-close" disabled={repairing} onClick={onDismiss} aria-label={zh ? "关闭配置检查提醒" : "Dismiss configuration notification"} title={zh ? "关闭提醒" : "Dismiss"}><X size={17} aria-hidden="true" /></button>
|
||||
</aside>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import type { ProviderBalance } from "../providerBalance";
|
||||
import "../styles/provider-balance.css";
|
||||
|
||||
function errorLabel(code: string | null, zh: boolean) {
|
||||
if (code === "permission_denied") return zh ? "查询权限不足" : "Access denied";
|
||||
if (code === "invalid_credential" || code === "authentication_failed") return zh ? "请检查 API Key" : "Check API key";
|
||||
if (code === "missing_key") return zh ? "未设置 API Key" : "API key missing";
|
||||
if (code === "rate_limited") return zh ? "请求较多,请稍后刷新" : "Rate limited. Try again shortly.";
|
||||
if (code === "timeout" || code === "network_error") return zh ? "网络异常,请稍后刷新" : "Check your connection and retry.";
|
||||
return zh ? "暂时无法查询" : "Temporarily unavailable";
|
||||
}
|
||||
|
||||
function amountText(amount: string) {
|
||||
const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(amount);
|
||||
if (!match) return amount;
|
||||
const integer = match[2].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
const fraction = (match[3] || "").replace(/0+$/, "").padEnd(2, "0");
|
||||
// Group the exact decimal string; financial values never pass through Number.
|
||||
return `${match[1] === "-" ? "−" : ""}${integer}.${fraction}`;
|
||||
}
|
||||
|
||||
export function ProviderBalanceBadge({ balance, loading, refreshing = false, failed, lang, onRefresh }: {
|
||||
balance?: ProviderBalance;
|
||||
loading: boolean;
|
||||
refreshing?: boolean;
|
||||
failed: boolean;
|
||||
lang: "zh" | "en";
|
||||
onRefresh?: () => void;
|
||||
}) {
|
||||
const zh = lang === "zh";
|
||||
const hasBalance = Boolean(balance?.balances.length);
|
||||
const unavailable = failed || Boolean(balance?.errorCode);
|
||||
const busy = loading || refreshing;
|
||||
const label = balance?.balanceKind === "credit_limit" ? (zh ? "Key 剩余额度" : "Key credit left") : (zh ? "可用余额" : "Available balance");
|
||||
const updated = balance?.checkedAt ? new Date(balance.checkedAt) : null;
|
||||
const updateTime = updated && Number.isFinite(updated.getTime())
|
||||
? updated.toLocaleTimeString(zh ? "zh-CN" : "en-GB", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }) : null;
|
||||
const detail = errorLabel(balance?.errorCode ?? null, zh);
|
||||
const hint = [balance?.balanceKind === "credit_limit" ? (zh ? "此 API Key 的剩余额度。" : "Remaining credit for this API key.") : "",
|
||||
unavailable ? detail : "", updateTime ? `${hasBalance ? (zh ? "余额更新于" : "Balance updated") : (zh ? "最近查询于" : "Last checked")} ${updated!.toLocaleString(zh ? "zh-CN" : "en-GB")}` : "",
|
||||
zh ? "后台每 5 分钟自动刷新,也可点击余额旁的按钮立即查询。" : "Refreshes every 5 minutes in the background. Use the refresh button to query now."].filter(Boolean).join("\n");
|
||||
return <section className={`cx-provider-balance${unavailable ? " cx-provider-balance--stale" : ""}`} aria-label={label} aria-busy={busy} title={hint}>
|
||||
<div className="cx-provider-balance-heading">
|
||||
<span>{label}</span>
|
||||
|
||||
</div>
|
||||
<div className="cx-provider-balance-reading">
|
||||
{hasBalance ? <div className={`cx-provider-balance-amounts${balance!.balances.length > 1 ? " cx-provider-balance-amounts--multiple" : ""}`}>
|
||||
{balance!.balances.map(item => <div key={item.currency} className={`cx-provider-balance-value${item.amount.startsWith("-") || /^0(?:\.0+)?$/.test(item.amount) ? " cx-provider-balance-value--empty" : ""}`} title={`${item.amount} ${item.currency}`}>
|
||||
<strong>{amountText(item.amount)}</strong><span>{item.currency}</span>
|
||||
</div>)}
|
||||
</div> : busy ? <div className="cx-provider-balance-placeholder" aria-label={zh ? "正在查询余额" : "Loading balance"}><span /></div>
|
||||
: <div className="cx-provider-balance-unavailable"><AlertCircle size={14} aria-hidden="true" /><span>{detail}</span></div>}
|
||||
<button type="button" className="cx-provider-balance-refresh" disabled={busy || !onRefresh} onClick={onRefresh}
|
||||
title={zh ? "立即刷新余额" : "Refresh balance now"} aria-label={zh ? "刷新余额" : "Refresh balance"}>
|
||||
<RefreshCw size={13} strokeWidth={1.8} className={busy ? "cx-provider-balance-spin" : undefined} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className={`cx-provider-balance-footer${unavailable && !busy ? " cx-provider-balance-footer--error" : ""}`} aria-live="polite">
|
||||
{busy ? (zh ? "正在刷新…" : "Refreshing…") : <>{unavailable ? (zh ? "刷新失败" : "Refresh failed") : !hasBalance ? (zh ? "尚未更新" : "Not updated") : (zh ? "已更新" : "Updated")}{updateTime && <><span aria-hidden="true"> · </span>{unavailable && hasBalance ? (zh ? "上次 " : "Last ") : ""}<time dateTime={balance!.checkedAt!}>{updateTime}</time></>}</>}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ProviderHeadersEditor } from "./ProviderHeadersEditor";
|
||||
import { normalizeProviderHeaders, validateProviderHeaders } from "../providerHeaders";
|
||||
import type { ProviderHeader } from "../providerHeaders";
|
||||
import { createProviderHeadersControl } from "../providerHeadersControl";
|
||||
import type { ProviderHeadersControlState } from "../providerHeadersControl";
|
||||
|
||||
export function ProviderHeadersControl({ lang, configText, disabled = false, onConfigChange, onBusyChange, onValidityChange }: {
|
||||
lang: "zh" | "en";
|
||||
configText: string;
|
||||
disabled?: boolean;
|
||||
onConfigChange: (configText: string) => void;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
onValidityChange: (valid: boolean) => void;
|
||||
}) {
|
||||
const runtime = useRef({ configText, onConfigChange, onBusyChange, onValidityChange });
|
||||
runtime.current = { configText, onConfigChange, onBusyChange, onValidityChange };
|
||||
const control = useRef<ReturnType<typeof createProviderHeadersControl> | null>(null);
|
||||
const [rows, setRows] = useState<ProviderHeader[]>([]);
|
||||
const [state, setState] = useState<ProviderHeadersControlState>({ busy: true, reading: true, valid: false, error: null });
|
||||
|
||||
useEffect(() => {
|
||||
const instance = createProviderHeadersControl({
|
||||
read: (text) => invoke<ProviderHeader[]>("read_provider_headers", { configText: text }),
|
||||
update: (text, headers) => invoke<string>("update_provider_headers", { configText: text, headers }),
|
||||
getConfigText: () => runtime.current.configText,
|
||||
isValid: (next) => validateProviderHeaders(next).valid,
|
||||
normalize: normalizeProviderHeaders,
|
||||
onRowsChange: setRows,
|
||||
onConfigChange: (text) => runtime.current.onConfigChange(text),
|
||||
onStateChange: (next) => {
|
||||
setState(next);
|
||||
runtime.current.onBusyChange(next.busy);
|
||||
runtime.current.onValidityChange(next.valid);
|
||||
},
|
||||
});
|
||||
control.current = instance;
|
||||
void instance.load(runtime.current.configText);
|
||||
return () => {
|
||||
instance.dispose();
|
||||
if (control.current === instance) control.current = null;
|
||||
runtime.current.onBusyChange(false);
|
||||
runtime.current.onValidityChange(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void control.current?.load(configText); }, [configText]);
|
||||
|
||||
const errorText = state.error === "read"
|
||||
? lang === "zh" ? "无法读取 Headers,请检查供应商 TOML 后重试。" : "Could not read headers. Check the provider TOML and retry."
|
||||
: lang === "zh" ? "无法更新 Headers,请重试或检查供应商 TOML。" : "Could not update headers. Retry or check the provider TOML.";
|
||||
|
||||
return <div className="cx-provider-headers-control">
|
||||
<ProviderHeadersEditor lang={lang} rows={rows} disabled={disabled || state.reading} onChange={(next) => { void control.current?.changeRows(next); }} />
|
||||
{state.busy && <p className="cx-provider-headers-hint" role="status">{state.reading
|
||||
? lang === "zh" ? "正在读取 Headers…" : "Reading headers…"
|
||||
: lang === "zh" ? "正在更新配置…" : "Updating configuration…"}</p>}
|
||||
{state.error && <div className="cx-provider-headers-feedback"><p className="cx-provider-headers-error" role="status">{errorText}</p><button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" disabled={disabled || state.busy} onClick={() => { void control.current?.retry(); }}>{lang === "zh" ? "重试" : "Retry"}</button></div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useId } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
createProviderHeader,
|
||||
removeProviderHeader,
|
||||
updateProviderHeader,
|
||||
validateProviderHeaders,
|
||||
} from "../providerHeaders";
|
||||
import type { ProviderHeader, ProviderHeaderSource } from "../providerHeaders";
|
||||
import { applyProviderHeaderPreset, PROVIDER_HEADER_PRESETS, providerHeaderValuePlaceholder } from "../providerHeaderPresets";
|
||||
import "../styles/provider-headers.css";
|
||||
|
||||
export function ProviderHeadersEditor({ lang, rows, disabled = false, onChange }: {
|
||||
lang: "zh" | "en";
|
||||
rows: readonly ProviderHeader[];
|
||||
disabled?: boolean;
|
||||
onChange: (rows: ProviderHeader[]) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const copy = lang === "zh" ? {
|
||||
title: "自定义请求头 Headers", optional: "可选", hint: "按供应商要求设置 User-Agent(客户端标识)或其他附加请求头;不填写时使用默认设置。",
|
||||
presets: "User-Agent 预设", presetHint: "预设只设置 User-Agent,其他请求头保留;已有 User-Agent 会更新为所选固定值。预设是否适用以供应商要求为准。",
|
||||
envHint: "环境变量模式只保存变量名;直连由 Codex 读取,路由由 Codex-X 读取,请在启动对应应用前设置。",
|
||||
add: "添加 Header", empty: "尚未配置自定义 Headers", name: "名称", value: "值", source: "值来源", literal: "固定值", env: "环境变量", remove: "删除 Header",
|
||||
placeholder: "例如 User-Agent", envPlaceholder: "例如 PROVIDER_HEADER_VALUE", fix: "请先修正标红的 Headers,再保存供应商。",
|
||||
row: (index: number) => `第 ${index + 1} 行`,
|
||||
} : {
|
||||
title: "Custom headers", optional: "Optional", hint: "Set User-Agent (client identification) or other HTTP headers required by your provider. Leave them empty to keep the defaults.",
|
||||
presets: "User-Agent presets", presetHint: "Presets set only User-Agent and keep other headers. An existing User-Agent becomes the selected literal value. Check your provider requirements before choosing one.",
|
||||
envHint: "Environment mode saves only the variable name. Set it before launching Codex for direct requests, or Codex-X for routed requests.",
|
||||
add: "Add header", empty: "No custom headers configured", name: "Name", value: "Value", source: "Value source", literal: "Literal value", env: "Environment variable", remove: "Remove header",
|
||||
placeholder: "e.g. User-Agent", envPlaceholder: "e.g. PROVIDER_HEADER_VALUE", fix: "Correct the highlighted headers before saving this provider.",
|
||||
row: (index: number) => `Row ${index + 1}`,
|
||||
};
|
||||
const validation = validateProviderHeaders(rows, lang);
|
||||
const update = (index: number, patch: Partial<ProviderHeader>) => {
|
||||
if (!disabled) onChange(updateProviderHeader(rows, index, patch));
|
||||
};
|
||||
|
||||
return <section className="cx-provider-headers" aria-labelledby={`${id}-title`}>
|
||||
<div className="cx-provider-headers-heading">
|
||||
<div><strong id={`${id}-title`}>{copy.title}<span>{copy.optional}</span></strong><p>{copy.hint}</p></div>
|
||||
<div className="cx-provider-headers-actions">
|
||||
<select className="cx-provider-headers-presets" aria-label={copy.presets} aria-describedby={`${id}-preset-hint`} value="" disabled={disabled} onChange={(event) => {
|
||||
const selected = event.currentTarget.value;
|
||||
if (selected) onChange(applyProviderHeaderPreset(rows, selected));
|
||||
}}>
|
||||
<option value="">{copy.presets}</option>
|
||||
{PROVIDER_HEADER_PRESETS.map((preset) => <option key={preset.id} value={preset.id}>{preset.label[lang]} · {preset.value}</option>)}
|
||||
</select>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" disabled={disabled} onClick={() => onChange([...rows, createProviderHeader()])}><Plus size={14} aria-hidden="true" />{copy.add}</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="cx-provider-headers-hint cx-provider-headers-preset-hint" id={`${id}-preset-hint`}>{copy.presetHint}</p>
|
||||
{rows.length === 0 ? <p className="cx-provider-headers-empty">{copy.empty}</p> : <div className="cx-provider-headers-rows">
|
||||
{rows.map((row, index) => {
|
||||
const error = validation.errors[index];
|
||||
return <div key={index} className="cx-provider-header-row" role="group" aria-label={copy.row(index)}>
|
||||
<label className="cx-provider-header-field"><span>{copy.name}</span><input value={row.name} placeholder={copy.placeholder} aria-label={`${copy.name} · ${copy.row(index)}`} aria-invalid={Boolean(error.name)} aria-describedby={error.name ? `${id}-${index}-name-error` : undefined} onChange={(event) => update(index, { name: event.currentTarget.value })} disabled={disabled} autoComplete="off" autoCapitalize="none" spellCheck={false} />{error.name && <small id={`${id}-${index}-name-error`}>{error.name}</small>}</label>
|
||||
<label className="cx-provider-header-field"><span>{copy.source}</span><select value={row.source} aria-label={`${copy.source} · ${copy.row(index)}`} aria-invalid={Boolean(error.source)} aria-describedby={error.source ? `${id}-${index}-source-error` : undefined} onChange={(event) => update(index, { source: event.currentTarget.value as ProviderHeaderSource })} disabled={disabled}><option value="static">{copy.literal}</option><option value="env">{copy.env}</option></select>{error.source && <small id={`${id}-${index}-source-error`}>{error.source}</small>}</label>
|
||||
<label className="cx-provider-header-field"><span>{row.source === "env" ? copy.env : copy.value}</span><input value={row.value} placeholder={row.source === "env" ? copy.envPlaceholder : providerHeaderValuePlaceholder(row.name, lang)} aria-label={`${row.source === "env" ? copy.env : copy.value} · ${copy.row(index)}`} aria-invalid={Boolean(error.value)} aria-describedby={error.value ? `${id}-${index}-value-error` : row.source === "env" ? `${id}-env-hint` : undefined} onChange={(event) => update(index, { value: event.currentTarget.value })} disabled={disabled} autoComplete="off" autoCapitalize="none" spellCheck={false} />{error.value && <small id={`${id}-${index}-value-error`}>{error.value}</small>}</label>
|
||||
<button type="button" className="cx-providers-icon-button cx-providers-icon-button--danger cx-provider-header-remove" title={`${copy.remove} · ${copy.row(index)}`} aria-label={`${copy.remove} · ${copy.row(index)}`} disabled={disabled} onClick={() => onChange(removeProviderHeader(rows, index))}><Trash2 size={14} aria-hidden="true" /></button>
|
||||
</div>;
|
||||
})}
|
||||
</div>}
|
||||
<p className="cx-provider-headers-hint" id={`${id}-env-hint`}>{copy.envHint}</p>
|
||||
{rows.some((row) => row.name.trim().toLowerCase() === "authorization") && <p className="cx-provider-headers-hint">{lang === "zh" ? "直连使用 Authorization Header 认证时,请留空 API Key;填写 API Key 会替换原有 Authorization。" : "For direct Authorization header authentication, leave API Key empty. An explicit API Key replaces the existing Authorization header."}</p>}
|
||||
{!validation.valid && <p className="cx-provider-headers-error" role="status">{copy.fix}</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ function getCopy(lang: Language) {
|
||||
return lang === "zh" ? {
|
||||
title: "模型映射", optional: "可选", subtitle: "让 Codex 的模型菜单显示这个供应商实际提供的模型。",
|
||||
hint: "适用于支持 Responses 接口的模型。保存并启用后,重启 Codex 更新模型菜单。已有会话会保留原来的模型选择。",
|
||||
capabilities: "模型菜单允许文本与图片输入;实际能力由供应商接口决定。Ultra 还需在 Codex 设置中开启「模型选择器滑块中的 Ultra 选项」。",
|
||||
displayName: "菜单显示名称", displayPlaceholder: "留空使用模型 ID", model: "实际模型 ID", modelPlaceholder: "填写供应商提供的模型 ID",
|
||||
context: "上下文窗口", contextPlaceholder: "可选,如 128000", contextHint: "单位为 Token;留空使用默认值,最大 10,000,000。",
|
||||
addCurrent: "添加当前模型", importModels: "导入已获取模型", addRow: "添加空行", remove: "删除映射",
|
||||
@@ -25,6 +26,7 @@ function getCopy(lang: Language) {
|
||||
} : {
|
||||
title: "Model mappings", optional: "Optional", subtitle: "Show this provider’s actual models in the Codex model menu.",
|
||||
hint: "For models that support the Responses API. Save and enable this provider, then restart Codex to update its model menu. Existing conversations keep their selected model.",
|
||||
capabilities: "The menu allows text and image input; actual support depends on the provider API. To show Ultra, also enable the Ultra option in the model picker slider in Codex settings.",
|
||||
displayName: "Menu display name", displayPlaceholder: "Defaults to the model ID", model: "Actual model ID", modelPlaceholder: "Enter the provider’s model ID",
|
||||
context: "Context window", contextPlaceholder: "Optional, e.g. 128000", contextHint: "In tokens. Leave blank for the default; maximum 10,000,000.",
|
||||
addCurrent: "Add current model", importModels: "Import fetched models", addRow: "Add row", remove: "Remove mapping",
|
||||
@@ -136,6 +138,7 @@ export function ProviderModelMappings({ lang, rows, currentModel, availableModel
|
||||
<summary><ListFilter size={17} aria-hidden="true" /><div><strong>{copy.title}<span>{copy.optional}</span></strong><p>{copy.subtitle}</p></div><span className={`cx-provider-mappings-count${!validation.valid ? " cx-provider-mappings-count--invalid" : ""}`} title={copy.limit}>{validation.size} / {MAX_MAPPINGS}</span><ChevronDown size={16} className="cx-provider-mappings-chevron" aria-hidden="true" /></summary>
|
||||
<div className="cx-provider-mappings-body">
|
||||
<p className="cx-provider-mappings-hint">{copy.hint}</p>
|
||||
<p className="cx-provider-mappings-hint">{copy.capabilities}</p>
|
||||
<div className="cx-provider-mappings-toolbar">
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={() => add(current)} disabled={disabled || !canAddCurrent} title={currentIncluded ? copy.currentExists : !current ? copy.noCurrent : hasInvalidModelId(current) ? copy.invalidDefault : !canAddCurrent ? copy.limit : undefined}><Plus size={14} aria-hidden="true" />{copy.addCurrent}</button>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={importModels} disabled={disabled || !canImport} title={!availableModels.length ? copy.fetchFirst : !candidates.length ? copy.allImported : !canImport ? copy.limit : undefined}><Download size={14} aria-hidden="true" />{copy.importModels}</button>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { ConfigHealthReport } from "./configHealthTypes";
|
||||
|
||||
type Options = {
|
||||
read: () => Promise<ConfigHealthReport>;
|
||||
onReport: (report: ConfigHealthReport) => void;
|
||||
onProblem: (report: ConfigHealthReport) => void;
|
||||
onRecovered?: (report: ConfigHealthReport) => void;
|
||||
canRead: () => boolean;
|
||||
canNotify: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
revision: () => number;
|
||||
intervalMs?: number;
|
||||
settleMs?: number;
|
||||
now?: () => number;
|
||||
schedule?: (callback: () => void, delay: number) => () => void;
|
||||
};
|
||||
|
||||
// Confirm a broken file twice before notifying: an editor may briefly truncate
|
||||
// or replace it. Reads are single-flight and never change shared loading state.
|
||||
export function createConfigHealthMonitor(options: Options) {
|
||||
const schedule = options.schedule ?? ((callback, delay) => {
|
||||
const timer = setTimeout(callback, delay);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
const now = options.now ?? Date.now;
|
||||
const interval = options.intervalMs ?? 20_000;
|
||||
const settle = options.settleMs ?? 1_000;
|
||||
let stopped = false;
|
||||
let inFlight = false;
|
||||
let wakePending = false;
|
||||
let cancel: (() => void) | undefined;
|
||||
let candidate: { fingerprint: string; revision: number; at: number } | null = null;
|
||||
let recovery: { fingerprint: string; revision: number; at: number } | null = null;
|
||||
|
||||
function next(delay = interval) {
|
||||
cancel?.();
|
||||
cancel = undefined;
|
||||
if (!stopped && options.isVisible()) cancel = schedule(wake, delay);
|
||||
}
|
||||
async function read() {
|
||||
if (stopped || !options.isVisible()) return;
|
||||
if (!options.canRead()) { next(); return; }
|
||||
inFlight = true;
|
||||
const revision = options.revision();
|
||||
let delay = interval;
|
||||
try {
|
||||
const report = await options.read();
|
||||
if (stopped || wakePending || revision !== options.revision() || !options.canRead() || !options.isVisible()) return;
|
||||
options.onReport(report);
|
||||
if (report.status === "healthy") {
|
||||
if (recovery?.fingerprint === report.fingerprint && recovery.revision === revision && now() - recovery.at >= settle) {
|
||||
options.onRecovered?.(report);
|
||||
} else if (!recovery || recovery.fingerprint !== report.fingerprint || recovery.revision !== revision) {
|
||||
recovery = { fingerprint: report.fingerprint, revision, at: now() };
|
||||
}
|
||||
} else recovery = null;
|
||||
if (report.status !== "issues" || !report.issues.length) {
|
||||
candidate = null;
|
||||
return;
|
||||
}
|
||||
if (!candidate || candidate.fingerprint !== report.fingerprint || candidate.revision !== revision) {
|
||||
candidate = { fingerprint: report.fingerprint, revision, at: now() };
|
||||
delay = settle;
|
||||
} else if (now() - candidate.at < settle) {
|
||||
delay = settle - (now() - candidate.at);
|
||||
} else if (options.canNotify()) {
|
||||
options.onProblem(report);
|
||||
}
|
||||
} catch {
|
||||
// A transient read/IPC failure is not evidence of a configuration error.
|
||||
candidate = null;
|
||||
recovery = null;
|
||||
} finally {
|
||||
inFlight = false;
|
||||
if (wakePending) { wakePending = false; wake(); } else next(delay);
|
||||
}
|
||||
}
|
||||
function wake() {
|
||||
cancel?.(); cancel = undefined;
|
||||
if (stopped) return;
|
||||
if (inFlight) { wakePending = true; return; }
|
||||
void read();
|
||||
}
|
||||
return { wake, stop() { stopped = true; cancel?.(); cancel = undefined; candidate = null; } };
|
||||
}
|
||||
|
||||
// This is only notification deduplication, not a security or repair token.
|
||||
function labelHash(value: string) {
|
||||
let first = 2166136261;
|
||||
let second = 5381;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
first = Math.imul(first ^ value.charCodeAt(index), 16777619);
|
||||
second = Math.imul(second, 33) ^ value.charCodeAt(index);
|
||||
}
|
||||
return `${(first >>> 0).toString(16)}-${(second >>> 0).toString(16)}`;
|
||||
}
|
||||
export function configHealthIssueKey(report: ConfigHealthReport) {
|
||||
return labelHash(JSON.stringify(report.issues.map(({ code, title, description, key, suggestion, repairable }) => [code, title, description, key, suggestion, repairable]).sort()));
|
||||
}
|
||||
type StorageLike = Pick<Storage, "getItem" | "setItem">;
|
||||
export function createConfigHealthNoticeRegistry(storage?: StorageLike) {
|
||||
const storageKey = "codexx.configHealth.notified.v1";
|
||||
let entries: { scope: string; issue: string }[] = [];
|
||||
try {
|
||||
const saved: unknown = JSON.parse(storage?.getItem(storageKey) || "[]");
|
||||
if (Array.isArray(saved)) entries = saved.filter((value) => typeof value?.scope === "string" && typeof value?.issue === "string").slice(-32);
|
||||
} catch { /* Use in-memory suppression if browser storage is unavailable. */ }
|
||||
const scope = (report: ConfigHealthReport) => labelHash(report.codexDir.replace(/\\/g, "/"));
|
||||
const issue = configHealthIssueKey;
|
||||
const persist = () => { try { storage?.setItem(storageKey, JSON.stringify(entries)); } catch { /* Optional persistence. */ } };
|
||||
return {
|
||||
has(report: ConfigHealthReport) { return entries.some((entry) => entry.scope === scope(report) && entry.issue === issue(report)); },
|
||||
mark(report: ConfigHealthReport) {
|
||||
entries = [...entries.filter((entry) => entry.scope !== scope(report) || entry.issue !== issue(report)), { scope: scope(report), issue: issue(report) }].slice(-32);
|
||||
persist();
|
||||
},
|
||||
clear(report: ConfigHealthReport) {
|
||||
const remaining = entries.filter((entry) => entry.scope !== scope(report));
|
||||
if (remaining.length !== entries.length) { entries = remaining; persist(); }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ConfigHealthIssue, ConfigHealthReport } from "./configHealthTypes";
|
||||
|
||||
// Fall back for older backend/fixture reports while retaining exact locations
|
||||
// from the diagnostic command. Missing definitions do not have a source line.
|
||||
export function configHealthIssueLocation(issue: ConfigHealthIssue, report: ConfigHealthReport, lang: "zh" | "en") {
|
||||
const zh = lang === "zh";
|
||||
const path = issue.path || `${report.codexDir.replace(/[\\/]$/, "")}/config.toml`;
|
||||
const position = issue.line != null && issue.line > 0
|
||||
? (zh ? `第 ${issue.line} 行${issue.column != null ? `,第 ${issue.column} 列` : ""}`
|
||||
: `line ${issue.line}${issue.column != null ? `, column ${issue.column}` : ""}`)
|
||||
: !issue.key
|
||||
? (zh ? "旧版报告未提供行列,请重新检查" : "location not supplied; check again")
|
||||
: issue.key.startsWith('"<')
|
||||
? (zh ? "文件层面" : "file level")
|
||||
: (zh ? "未定义,尚无对应行" : "not defined; no source line");
|
||||
return { path, position, key: issue.key || (zh ? "配置文件" : "configuration file") };
|
||||
}
|
||||
|
||||
export function primaryConfigHealthIssue(report: ConfigHealthReport) {
|
||||
return (report.canRepair ? report.issues.find((issue) => issue.repairable) : undefined) ?? report.issues[0];
|
||||
}
|
||||
|
||||
export function configHealthRepairUnavailableReason(report: ConfigHealthReport, lang: "zh" | "en") {
|
||||
return (report.issues.find((issue) => issue.code === "config-linked-file")
|
||||
?? report.issues.find((issue) => !issue.repairable))?.suggestion
|
||||
|| (lang === "zh" ? "现有配置无法可靠确定,请按问题中的建议修改后重新检查。" : "The correct settings cannot be determined safely. Follow the issue's suggestion and check again.");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export type ConfigHealthIssue = {
|
||||
code: string;
|
||||
title: string;
|
||||
description: string;
|
||||
repairable: boolean;
|
||||
path: string;
|
||||
line: number | null;
|
||||
column: number | null;
|
||||
key: string;
|
||||
suggestion: string;
|
||||
};
|
||||
|
||||
export type ConfigHealthReport = {
|
||||
codexDir: string;
|
||||
fingerprint: string;
|
||||
status: "healthy" | "issues" | "missing" | "unavailable";
|
||||
issues: ConfigHealthIssue[];
|
||||
canRepair: boolean;
|
||||
repairSummary: string[];
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export type ConfigHealthRepairResult = {
|
||||
report: ConfigHealthReport;
|
||||
backupId: string | null;
|
||||
changed: boolean;
|
||||
};
|
||||
+627
-147
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
export const MAX_AUTH_IMPORT_BYTES = 1024 * 1024;
|
||||
type ImportErrorCode = "too_large" | "invalid_text" | "invalid_json" | "unsupported_shape";
|
||||
|
||||
export class OfficialAuthImportError extends Error {
|
||||
readonly code: ImportErrorCode;
|
||||
constructor(code: ImportErrorCode) {
|
||||
super(code);
|
||||
this.name = "OfficialAuthImportError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseOfficialAuthImport(bytes: Uint8Array): string {
|
||||
if (bytes.byteLength > MAX_AUTH_IMPORT_BYTES) throw new OfficialAuthImportError("too_large");
|
||||
let text: string;
|
||||
try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/^\uFEFF/, "").trim(); }
|
||||
catch { throw new OfficialAuthImportError("invalid_text"); }
|
||||
let value: unknown;
|
||||
try { value = JSON.parse(text); }
|
||||
catch { throw new OfficialAuthImportError("invalid_json"); }
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new OfficialAuthImportError("unsupported_shape");
|
||||
const auth = value as Record<string, unknown>;
|
||||
const tokens = auth.tokens && typeof auth.tokens === "object" && !Array.isArray(auth.tokens)
|
||||
? auth.tokens as Record<string, unknown> : null;
|
||||
const nonempty = (value: unknown) => typeof value === "string" && Boolean(value.trim());
|
||||
if (["base_url", "baseUrl", "api_base", "endpoint"].some(key => Object.prototype.hasOwnProperty.call(auth, key))
|
||||
|| !(nonempty(auth.OPENAI_API_KEY) || (tokens && [tokens.access_token, tokens.id_token, tokens.refresh_token].some(nonempty)))) {
|
||||
throw new OfficialAuthImportError("unsupported_shape");
|
||||
}
|
||||
// Only load the editor draft. The existing save command validates credentials.
|
||||
return text;
|
||||
}
|
||||
|
||||
export async function readOfficialAuthImport(file: Pick<File, "size" | "arrayBuffer">): Promise<string> {
|
||||
if (file.size > MAX_AUTH_IMPORT_BYTES) throw new OfficialAuthImportError("too_large");
|
||||
return parseOfficialAuthImport(new Uint8Array(await file.arrayBuffer()));
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Activity, AlertCircle, ArrowDown, ArrowUp, CheckCircle2, Clock3, Loader2, Plus, Power, RefreshCw, RotateCcw, Server, ShieldCheck, Shuffle, Trash2 } from "lucide-react";
|
||||
import { Button, IconButton } from "../components/ui";
|
||||
import { parseRoutingDraft, routingDraft, routingFields, sameRoutingDraft, type RoutingDraft, type RoutingSettings } from "../routingSettings";
|
||||
import "../styles/provider-failover.css";
|
||||
|
||||
type Language = "zh" | "en";
|
||||
type Provider = { id: string; providerName: string; model: string; models: string[]; baseUrl?: string | null; eligible: boolean; reason: string | null; official: boolean };
|
||||
type Health = { id: string; state: "closed" | "open" | "half_open"; cooldownSeconds: number; lastStatus: number | null; consecutiveFailures: number; consecutiveSuccesses: number; totalRequests: number; failedRequests: number };
|
||||
export type FailoverStatus = {
|
||||
settings: RoutingSettings; running: boolean; takeoverActive: boolean; autoFailoverActive: boolean; address: string | null;
|
||||
primary: Provider | null; providers: Provider[];
|
||||
runtime: { requestCount: number; failoverCount: number; inFlight: number; successCount: number; failureCount: number; uptimeSeconds: number; lastRequestAt: string | null; lastProviderId: string | null; lastError: string | null; providers: Health[] };
|
||||
message: string | null;
|
||||
};
|
||||
type PageState = { dir: string; status: FailoverStatus; draft: RoutingDraft; baseline: RoutingDraft };
|
||||
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
||||
|
||||
function SwitchRow({ id, icon, title, hint, checked, disabled, onChange }: {
|
||||
id: string; icon: React.ReactNode; title: string; hint: string; checked: boolean; disabled?: boolean; onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return <div className="cx-failover-toggle-row"><span className="cx-failover-symbol">{icon}</span><div className="cx-failover-toggle-copy"><label id={`${id}-label`} htmlFor={id}>{title}</label><p id={`${id}-hint`}>{hint}</p></div><button id={id} className="cx-failover-switch" type="button" role="switch" aria-checked={checked} aria-labelledby={`${id}-label`} aria-describedby={`${id}-hint`} disabled={disabled} onClick={() => onChange(!checked)}><span /></button></div>;
|
||||
}
|
||||
|
||||
export function ProviderFailoverPage({ lang, configDir, active = true, onChange }: {
|
||||
lang: Language; configDir: string; active?: boolean; onChange?: () => void | Promise<void>;
|
||||
}) {
|
||||
const zh = lang === "zh";
|
||||
const tr = (cn: string, en: string) => zh ? cn : en;
|
||||
const id = useId();
|
||||
const [page, setPage] = useState<PageState | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [resetting, setResetting] = useState<string | null>(null);
|
||||
const [warning, setWarning] = useState("");
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const generation = useRef(0);
|
||||
const busy = useRef(false);
|
||||
const mounted = useRef(true);
|
||||
const current = useRef({ active, configDir });
|
||||
const pageRef = useRef(page);
|
||||
current.current = { active, configDir };
|
||||
pageRef.current = page;
|
||||
|
||||
const acceptStatus = (status: FailoverStatus, preserveDraft: boolean) => {
|
||||
setPage((previous) => {
|
||||
const saved = routingDraft(status.settings);
|
||||
const keep = preserveDraft && previous?.dir === configDir && !sameRoutingDraft(previous.draft, previous.baseline)
|
||||
&& !sameRoutingDraft(previous.draft, saved);
|
||||
return { dir: configDir, status, draft: keep ? previous.draft : saved, baseline: keep ? previous.baseline : routingDraft(status.settings) };
|
||||
});
|
||||
};
|
||||
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
||||
useEffect(() => { setNotice(""); setSaveError(""); setWarning(""); }, [configDir]);
|
||||
useEffect(() => {
|
||||
const version = ++generation.current;
|
||||
if (!active) return;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const valid = () => mounted.current && generation.current === version && current.current.active && current.current.configDir === configDir;
|
||||
const load = async () => {
|
||||
if (!valid()) return;
|
||||
if (busy.current || document.hidden) { timer = setTimeout(() => void load(), 5000); return; }
|
||||
setLoading(pageRef.current?.dir !== configDir);
|
||||
try {
|
||||
const status = await invoke<FailoverStatus>("get_provider_failover", { configDir: configDir || null });
|
||||
if (!valid()) return;
|
||||
acceptStatus(status, true);
|
||||
setWarning("");
|
||||
} catch (error) {
|
||||
if (valid()) setWarning(errorText(error));
|
||||
} finally {
|
||||
if (valid()) { setLoading(false); timer = setTimeout(() => void load(), 5000); }
|
||||
}
|
||||
};
|
||||
void load();
|
||||
return () => { ++generation.current; if (timer) clearTimeout(timer); };
|
||||
}, [active, configDir, refresh]);
|
||||
|
||||
const view = page?.dir === configDir ? page : null;
|
||||
const status = view?.status;
|
||||
const draft = view?.draft;
|
||||
const dirty = Boolean(view && !sameRoutingDraft(view.draft, view.baseline));
|
||||
const changedElsewhere = Boolean(view && dirty && !sameRoutingDraft(routingDraft(view.status.settings), view.baseline));
|
||||
const parsed = draft ? parseRoutingDraft(draft) : null;
|
||||
const selected = draft?.providerIds ?? [];
|
||||
const providers = status?.providers.filter((provider) => !provider.official) ?? [];
|
||||
const candidates = providers.filter((provider) => !selected.includes(provider.id));
|
||||
const invalidQueue = selected.some((item) => !providers.find((provider) => provider.id === item)?.eligible);
|
||||
const enablingAuto = Boolean(draft?.autoFailoverEnabled && !status?.settings.autoFailoverEnabled);
|
||||
const validation = changedElsewhere ? tr("设置已在别处更新,请先撤销修改,再重新保存。", "Settings changed elsewhere. Discard your changes before saving again.")
|
||||
: parsed && !parsed.settings ? tr("请检查标红的输入项。", "Check the highlighted fields.")
|
||||
: draft?.takeoverEnabled && !draft.routerEnabled ? tr("请先开启路由总开关。", "Enable the routing service first.")
|
||||
: draft?.takeoverEnabled && !status?.primary?.eligible ? status?.primary?.reason || tr("请先选择一个可用的供应商或官方账号。", "Select an available provider or official account first.")
|
||||
: enablingAuto && (!draft?.routerEnabled || !draft.takeoverEnabled) ? tr("请先开启本地路由和 Codex 路由。", "Enable the local router and Codex routing first.")
|
||||
: enablingAuto && !selected.length && (!status?.primary?.eligible || status.primary.official) ? tr("请添加至少一个 API 供应商作为 P1。", "Add an API provider as P1 first.")
|
||||
: invalidQueue ? tr("队列中有不可用的供应商,请移除或修复后保存。", "Remove or fix unavailable providers in the queue before saving.") : "";
|
||||
const controlsBusy = saving || resetting !== null;
|
||||
const edit = (patch: Partial<RoutingDraft>) => {
|
||||
if (busy.current) return;
|
||||
setPage((previous) => previous?.dir === configDir ? { ...previous, draft: { ...previous.draft, ...patch } } : previous);
|
||||
setSaveError(""); setNotice("");
|
||||
};
|
||||
const reset = () => {
|
||||
if (!view || busy.current) return;
|
||||
acceptStatus(view.status, false); setNotice(""); setSaveError("");
|
||||
};
|
||||
const reorder = (index: number, offset: number) => {
|
||||
if (index + offset < 0 || index + offset >= selected.length) return;
|
||||
const providerIds = [...selected];
|
||||
[providerIds[index], providerIds[index + offset]] = [providerIds[index + offset], providerIds[index]];
|
||||
edit({ providerIds });
|
||||
};
|
||||
const save = async () => {
|
||||
if (!view || !draft || !dirty || validation || busy.current || !active || !parsed?.settings) return;
|
||||
const version = ++generation.current;
|
||||
busy.current = true; setSaving(true); setSaveError(""); setNotice("");
|
||||
try {
|
||||
const result = await invoke<FailoverStatus>("save_provider_failover", { configDir: configDir || null, settings: parsed.settings });
|
||||
if (mounted.current && current.current.configDir === configDir && generation.current === version) {
|
||||
acceptStatus(result, false); setWarning("");
|
||||
setNotice(tr("路由设置已保存。", "Routing settings saved."));
|
||||
}
|
||||
if (current.current.configDir === configDir) {
|
||||
try { await onChange?.(); } catch { /* A later status read remains authoritative. */ }
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted.current && current.current.configDir === configDir && generation.current === version) setSaveError(errorText(error));
|
||||
} finally {
|
||||
busy.current = false;
|
||||
if (mounted.current) { setSaving(false); setRefresh((value) => value + 1); }
|
||||
}
|
||||
};
|
||||
const resetHealth = async (providerId: string) => {
|
||||
if (busy.current || !active) return;
|
||||
const version = ++generation.current;
|
||||
busy.current = true; setResetting(providerId); setSaveError(""); setNotice("");
|
||||
try {
|
||||
const result = await invoke<FailoverStatus>("reset_provider_failover_health", { configDir: configDir || null, providerId });
|
||||
if (mounted.current && current.current.configDir === configDir && generation.current === version) {
|
||||
acceptStatus(result, true);
|
||||
setNotice(tr("已清除该供应商的故障状态,可重新尝试。", "Provider health reset. It can be tried again."));
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted.current && current.current.configDir === configDir && generation.current === version) setSaveError(errorText(error));
|
||||
} finally {
|
||||
busy.current = false;
|
||||
if (mounted.current) { setResetting(null); setRefresh((value) => value + 1); }
|
||||
}
|
||||
};
|
||||
const healthFor = (providerId: string) => status?.runtime.providers.find((provider) => provider.id === providerId);
|
||||
const healthBadge = (providerId: string) => {
|
||||
const health = healthFor(providerId);
|
||||
if (!status?.running || !health) return <span className="cx-failover-health cx-failover-health--idle">{tr("未使用", "Not used")}</span>;
|
||||
const label = health.state === "open" ? `${tr("暂时跳过", "Unavailable")} · ${Math.ceil(health.cooldownSeconds)}s`
|
||||
: health.state === "half_open" ? tr("试探恢复", "Recovering") : tr("正常", "Healthy");
|
||||
return <span className={`cx-failover-health cx-failover-health--${health.state}`} title={tr(`连续失败 ${health.consecutiveFailures} 次 · ${health.failedRequests}/${health.totalRequests} 次失败`, `${health.consecutiveFailures} consecutive failures · ${health.failedRequests}/${health.totalRequests} failed`)}><i />{label}</span>;
|
||||
};
|
||||
const primary = status?.primary;
|
||||
const recent = status?.runtime.lastProviderId === primary?.id ? primary : providers.find((provider) => provider.id === status?.runtime.lastProviderId);
|
||||
const uptime = status ? `${Math.floor(status.runtime.uptimeSeconds / 3600)}h ${Math.floor(status.runtime.uptimeSeconds % 3600 / 60)}m` : "—";
|
||||
const fieldError = (key: keyof NonNullable<typeof parsed>["errors"]) => parsed?.errors[key];
|
||||
|
||||
return <section className="cx-failover" aria-busy={loading || controlsBusy}>
|
||||
<header className="cx-failover-heading"><div><h3>{tr("路由与故障转移", "Routing & failover")}</h3><p>{tr("管理 Codex 的连接方式,让请求在供应商故障时继续完成。", "Manage Codex routing and keep requests moving when a provider is unavailable.")}</p></div><IconButton size="sm" label={tr("刷新运行状态", "Refresh routing status")} icon={<RefreshCw size={17} className={loading ? "cx-page-spin" : ""} />} disabled={controlsBusy || loading} onClick={() => setRefresh((value) => value + 1)} /></header>
|
||||
{warning && <div className="cx-failover-message cx-failover-message--warning" role="status"><AlertCircle size={18} /><div>{status && <strong>{tr("状态更新失败,仍显示上次结果。", "Unable to update status. Showing the last result.")}</strong>}<span>{warning}</span></div>{!status && <Button size="sm" variant="secondary" onClick={() => setRefresh((value) => value + 1)}>{tr("重试", "Retry")}</Button>}</div>}
|
||||
{!status || !draft ? (loading ? <div className="cx-failover-loading"><Loader2 size={20} className="cx-page-spin" />{tr("正在读取路由设置…", "Loading routing settings…")}</div> : null) : <>
|
||||
<section className="cx-failover-card">
|
||||
<div className="cx-failover-card-heading"><h4><Server size={18} />{tr("本地路由", "Local routing")}</h4><span className={`cx-failover-state${status.running ? " cx-failover-state--running" : ""}`}><i />{status.running ? tr("运行中", "Running") : tr("已停止", "Stopped")}</span></div>
|
||||
<SwitchRow id={`${id}-router`} icon={<Power size={22} />} title={tr("路由总开关", "Routing service")} hint={tr("启动本机路由服务;更改将在保存后生效。", "Run the local routing service. Changes take effect when saved.")} checked={draft.routerEnabled} disabled={controlsBusy} onChange={(checked) => edit({ routerEnabled: checked, ...(!checked ? { takeoverEnabled: false } : {}) })} />
|
||||
<div className="cx-failover-address-grid">
|
||||
<label htmlFor={`${id}-address`}>{tr("监听地址", "Listen address")}<input id={`${id}-address`} value={draft.listenAddress} disabled={controlsBusy || status.running} aria-invalid={Boolean(fieldError("listenAddress"))} onChange={(event) => edit({ listenAddress: event.target.value })} spellCheck={false} /><small className={fieldError("listenAddress") ? "cx-failover-field-error" : ""}>{fieldError("listenAddress") ? tr("请输入有效 IPv4、IPv6 或 localhost。", "Enter a valid IPv4, IPv6 or localhost.") : "IPv4 / IPv6 / localhost"}</small></label>
|
||||
<label htmlFor={`${id}-port`}>{tr("监听端口", "Listen port")}<input id={`${id}-port`} type="number" min={1024} max={65535} value={draft.listenPort} disabled={controlsBusy || status.running} aria-invalid={Boolean(fieldError("listenPort"))} onChange={(event) => edit({ listenPort: event.target.value })} /><small className={fieldError("listenPort") ? "cx-failover-field-error" : ""}>{fieldError("listenPort") ? tr("有效范围:1024–65535", "Valid range: 1024–65535") : status.running ? tr("停止并保存后可修改地址和端口。", "Stop and save before changing the address or port.") : "1024–65535"}</small></label>
|
||||
</div>
|
||||
<SwitchRow id={`${id}-takeover`} icon={<ShieldCheck size={22} />} title={tr("为 Codex 启用路由", "Route Codex requests")} hint={tr("让 Codex 请求经过本地路由;关闭后恢复直接连接。", "Send Codex requests through this router. Turn off to restore direct access.")} checked={draft.takeoverEnabled} disabled={controlsBusy || !draft.takeoverEnabled && (!draft.routerEnabled || !primary?.eligible)} onChange={(checked) => edit({ takeoverEnabled: checked })} />
|
||||
{(!primary?.eligible || !draft.routerEnabled) && <p className="cx-failover-inline-hint">{!draft.routerEnabled ? tr("先开启路由总开关,再选择是否接管 Codex。", "Enable the routing service before routing Codex requests.") : primary?.reason || tr("请先在供应商页面选择一个供应商或已登录的官方账号。", "Select a provider or a signed-in official account first.")}</p>}
|
||||
<div className="cx-failover-primary"><div className="cx-failover-provider-copy"><span className="cx-failover-label">{tr("当前供应商", "Current provider")}</span><strong>{primary?.providerName || tr("尚未选择", "None selected")}</strong><small>{primary?.baseUrl || primary?.model || "—"}</small></div>{primary?.official && <span className="cx-failover-state">{tr("官方登录", "Official account")}</span>}<span className={`cx-failover-state${status.takeoverActive ? " cx-failover-state--running" : ""}`}>{status.takeoverActive ? tr("已接管", "Routed") : tr("直接连接", "Direct access")}</span></div>
|
||||
{status.running && status.address && <div className="cx-failover-service-address"><span>{tr("当前路由地址", "Active route")}</span><code>{status.address}</code></div>}
|
||||
</section>
|
||||
|
||||
<section className="cx-failover-card">
|
||||
<div className="cx-failover-card-heading"><h4><Shuffle size={18} />{tr("自动故障转移", "Automatic failover")}</h4><span className={`cx-failover-state${status.autoFailoverActive ? " cx-failover-state--running" : ""}`}>{status.autoFailoverActive ? tr("已启用", "Active") : tr("未运行", "Inactive")}</span></div>
|
||||
<SwitchRow id={`${id}-auto`} icon={<Shuffle size={22} />} title={tr("自动切换供应商", "Switch providers automatically")} hint={tr("启用并保存后会切到 P1;每次请求按 P1 → P2 → P3 的顺序尝试。", "Enabling and saving switches to P1. Each request tries P1 → P2 → P3 in order.")} checked={draft.autoFailoverEnabled} disabled={controlsBusy || !draft.autoFailoverEnabled && (!draft.routerEnabled || !draft.takeoverEnabled)} onChange={(checked) => edit({ autoFailoverEnabled: checked, ...(checked && !selected.length && primary?.eligible && !primary.official ? { providerIds: [primary.id] } : {}) })} />
|
||||
<div className="cx-failover-queue-heading"><div><h4>{tr("优先级队列", "Priority queue")}</h4><p>{tr("可以提前准备队列。请求保留 Codex 中选择的模型,请确保供应商支持它。", "Prepare your queue at any time. Requests keep the model selected in Codex; providers must support it.")}</p></div><span>{selected.length}/64</span></div>
|
||||
{!selected.length ? <div className="cx-failover-empty"><strong>{tr("还没有队列供应商", "Your queue is empty")}</strong><span>{tr("从下方添加供应商,第一位就是 P1;一个供应商也可以启用。", "Add providers below. The first is P1; a one-provider queue is also supported.")}</span></div> : <ol className="cx-failover-queue">{selected.map((providerId, index) => {
|
||||
const provider = providers.find((item) => item.id === providerId);
|
||||
const health = healthFor(providerId);
|
||||
return <li key={providerId} className={`${index === 0 ? "cx-failover-provider--first" : ""}${!provider?.eligible ? " cx-failover-provider--invalid" : ""}`}><span className="cx-failover-order">P{index + 1}</span><div className="cx-failover-provider-copy"><strong>{provider?.providerName || tr("供应商已删除", "Provider deleted")}</strong><small title={provider?.baseUrl || undefined}>{!provider?.eligible ? provider?.reason || tr("请移除此项或检查供应商配置。", "Remove this item or fix its configuration.") : provider.baseUrl || provider.model}</small></div>{healthBadge(providerId)}<div className="cx-failover-row-actions">{health && health.state !== "closed" && <IconButton size="sm" label={`${tr("重置健康状态", "Reset health")} ${provider?.providerName || ""}`} icon={<RotateCcw size={15} className={resetting === providerId ? "cx-page-spin" : ""} />} disabled={controlsBusy} onClick={() => void resetHealth(providerId)} />}<IconButton size="sm" label={`${tr("上移", "Move up")} ${provider?.providerName || ""}`} icon={<ArrowUp size={15} />} disabled={controlsBusy || index === 0} onClick={() => reorder(index, -1)} /><IconButton size="sm" label={`${tr("下移", "Move down")} ${provider?.providerName || ""}`} icon={<ArrowDown size={15} />} disabled={controlsBusy || index === selected.length - 1} onClick={() => reorder(index, 1)} /><IconButton size="sm" label={`${tr("移除", "Remove")} ${provider?.providerName || ""}`} variant="ghost" icon={<Trash2 size={15} />} disabled={controlsBusy} onClick={() => edit({ providerIds: selected.filter((item) => item !== providerId) })} /></div></li>;
|
||||
})}</ol>}
|
||||
<div className="cx-failover-candidates"><h4>{tr("添加供应商", "Add providers")}</h4>{candidates.length ? <div className="cx-failover-candidate-list">{candidates.map((provider) => <button type="button" className="cx-failover-candidate" key={provider.id} disabled={controlsBusy || !provider.eligible || selected.length >= 64} title={provider.reason || provider.baseUrl || undefined} onClick={() => edit({ providerIds: [...selected, provider.id] })}><div><strong>{provider.providerName}</strong><small>{provider.eligible ? provider.baseUrl || provider.model : provider.reason}</small></div><Plus size={17} /><span className="cx-failover-sr-only">{tr("添加", "Add")}</span></button>)}</div> : <p>{tr("所有可选供应商已在队列中;也可以到供应商页面添加更多。", "All available providers are already queued. Add more on the Providers page.")}</p>}</div>
|
||||
{primary?.official && <p className="cx-failover-inline-hint">{tr("官方账号仅使用当前登录,不参与自动切换。队列只包含 API 供应商。", "Official accounts use the current login only. Only API providers participate in failover.")}</p>}
|
||||
</section>
|
||||
|
||||
<section className="cx-failover-card cx-failover-tuning"><div className="cx-failover-card-heading"><h4><Clock3 size={18} />{tr("重试、超时与恢复", "Retries, timeouts & recovery")}</h4><span className="cx-failover-section-note">{tr("自动故障转移时生效", "Applied during automatic failover")}</span></div>{(["retry", "timeout", "recovery"] as const).map((group) => <div className={`cx-failover-field-group cx-failover-field-group--${group}`} key={group}><h5>{group === "retry" ? tr("重试策略", "Retry policy") : group === "timeout" ? tr("超时时间", "Timeouts") : tr("故障恢复", "Recovery policy")}</h5><div className="cx-failover-fields">{routingFields.filter((field) => field.group === group).map((field) => <label key={field.key} htmlFor={`${id}-${field.key}`}>{zh ? field.zh : field.en}<div className="cx-failover-field-input"><input id={`${id}-${field.key}`} type="number" min={field.min} max={field.max} step={field.key === "circuitErrorRateThreshold" ? "any" : 1} value={draft[field.key]} disabled={controlsBusy} aria-invalid={Boolean(fieldError(field.key))} aria-describedby={`${id}-${field.key}-hint`} onChange={(event) => edit({ [field.key]: event.target.value })} /><span>{field.min}–{field.max}</span></div><small id={`${id}-${field.key}-hint`} className={fieldError(field.key) ? "cx-failover-field-error" : ""}>{fieldError(field.key) ? tr(`请输入 ${field.min}–${field.max} 范围内的${field.key === "circuitErrorRateThreshold" ? "数值" : "整数"}。`, `Enter ${field.min}–${field.max}${field.key === "circuitErrorRateThreshold" ? "." : " (whole numbers)."}`) : zh ? field.hintZh : field.hintEn}</small></label>)}</div></div>)}</section>
|
||||
|
||||
<section className="cx-failover-card cx-failover-runtime"><div className="cx-failover-card-heading"><h4><Activity size={18} />{tr("运行状态", "Runtime status")}</h4><span className="cx-failover-section-note">{tr("运行时间", "Uptime")} {uptime}</span></div><dl><div><dt>{tr("处理请求", "Requests")}</dt><dd>{status.runtime.requestCount.toLocaleString()}</dd></div><div><dt>{tr("成功 / 失败", "Succeeded / failed")}</dt><dd>{status.runtime.successCount.toLocaleString()} <span>/ {status.runtime.failureCount.toLocaleString()}</span></dd></div><div><dt>{tr("自动切换", "Failovers")}</dt><dd>{status.runtime.failoverCount.toLocaleString()}</dd></div><div><dt>{tr("正在处理", "In progress")}</dt><dd>{status.runtime.inFlight.toLocaleString()}</dd></div></dl><div className="cx-failover-runtime-current"><span>{tr("最近使用", "Last used")}</span><strong>{recent?.providerName || tr("等待请求", "Waiting for requests")}</strong>{status.runtime.lastRequestAt && <time>{new Date(status.runtime.lastRequestAt).toLocaleTimeString(lang === "zh" ? "zh-CN" : "en-US", { hour: "2-digit", minute: "2-digit" })}</time>}</div>{status.message && <p className="cx-failover-runtime-note">{status.message}</p>}{status.runtime.lastError && <p className="cx-failover-runtime-note"><AlertCircle size={15} />{status.runtime.lastError}</p>}</section>
|
||||
<p className="cx-failover-bottom-note">{tr("回复已经开始后不会重复发送。关闭 Codex-X 窗口仍会在后台运行;完全退出时恢复直接连接。", "A reply that has already started is never replayed. Closing this window keeps routing active; quitting Codex-X restores direct access.")}</p>
|
||||
{saveError && <div className="cx-failover-message cx-failover-message--warning" role="alert"><AlertCircle size={18} /><span>{saveError}</span></div>}
|
||||
{notice && <div className="cx-failover-message cx-failover-message--success" role="status"><CheckCircle2 size={18} /><span>{notice}</span></div>}
|
||||
<footer className="cx-failover-save"><div aria-live="polite" className={validation ? "cx-failover-field-error" : ""}>{validation || (dirty ? tr("有未保存的修改", "Unsaved changes") : tr("更改将在保存后生效", "Changes take effect when saved"))}</div><div><Button size="sm" variant="secondary" disabled={!dirty || controlsBusy} onClick={reset} icon={<RotateCcw size={15} />}>{tr("撤销修改", "Discard changes")}</Button><Button size="sm" disabled={!dirty || controlsBusy || Boolean(validation)} icon={saving ? <Loader2 size={15} className="cx-page-spin" /> : undefined} onClick={() => void save()}>{saving ? tr("保存中…", "Saving…") : tr("保存设置", "Save settings")}</Button></div></footer>
|
||||
</>}
|
||||
</section>;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
EyeOff,
|
||||
FilePlus2,
|
||||
Gauge,
|
||||
GripVertical,
|
||||
Loader2,
|
||||
PencilLine,
|
||||
Plus,
|
||||
@@ -22,12 +23,18 @@ import type { LucideIcon } from "lucide-react";
|
||||
import type { Ref } from "react";
|
||||
import { PageTransition } from "../components/PageTransition";
|
||||
import { OfficialQuotaDialog } from "../components/OfficialQuotaDialog";
|
||||
import { ProviderBalanceBadge } from "../components/ProviderBalanceBadge";
|
||||
import { supportsProviderBalance, type ProviderBalanceState } from "../providerBalance";
|
||||
import { OfficialAccountBadge } from "../components/OfficialAccountBadge";
|
||||
import { ProviderModelMappings, validateProviderModelMappings } from "../components/ProviderModelMappings";
|
||||
import { ProviderHeadersControl } from "../components/ProviderHeadersControl";
|
||||
import { providerUpstreamApi } from "../providerProtocol";
|
||||
import { OfficialAuthImportError, readOfficialAuthImport } from "../officialAuthImport";
|
||||
import { ProviderPresetPicker } from "../components/ProviderPresetPicker";
|
||||
import { PROVIDER_PRESETS, getProviderPreset, getProviderPresetVariant } from "../providerPresets";
|
||||
import { Button, Checkbox, ModalShell } from "../components/ui";
|
||||
import type { ProviderMode, ProviderModelMapping } from "../types";
|
||||
import { moveProviderRow, providerRowKey } from "../providerRowOrder";
|
||||
import "../styles/providers-page.css";
|
||||
|
||||
export type ProviderRowSource = "official" | "local" | "detected";
|
||||
@@ -38,8 +45,10 @@ export type ProviderRow = {
|
||||
providerName: string;
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
modelDisplayName?: string;
|
||||
apiKey?: string;
|
||||
wireApi: string;
|
||||
upstreamApi?: string | null;
|
||||
requiresOpenaiAuth: boolean;
|
||||
isCurrent: boolean;
|
||||
isDefaultOfficial?: boolean;
|
||||
@@ -62,6 +71,7 @@ export type ProviderFormValue = {
|
||||
providerName: string;
|
||||
model: string;
|
||||
wireApi: string;
|
||||
upstreamApi?: string | null;
|
||||
requiresOpenaiAuth: boolean;
|
||||
};
|
||||
|
||||
@@ -146,9 +156,13 @@ export type ProvidersPageProps = {
|
||||
copy: ProviderCopy;
|
||||
mode: ProviderMode;
|
||||
providerRows: readonly ProviderRow[];
|
||||
providerBalances?: ProviderBalanceState;
|
||||
loading: boolean;
|
||||
testingId: string;
|
||||
actionBusy?: string;
|
||||
activationBusy?: boolean;
|
||||
orderBusy?: boolean;
|
||||
onReorderProviders: (order: string[]) => Promise<boolean>;
|
||||
editingProviderId: string | null;
|
||||
creatingProvider: boolean;
|
||||
selectedPresetId: string;
|
||||
@@ -191,10 +205,11 @@ export type ProvidersPageProps = {
|
||||
onProviderNameChange: (value: string) => void;
|
||||
onProviderModelChange: (value: string) => void;
|
||||
onFetchModels: () => void;
|
||||
onWireApiChange: (value: string) => void;
|
||||
onUpstreamApiChange: (value: string) => void;
|
||||
onRequiresAuthChange: (value: boolean) => void;
|
||||
onToggleApiKeyVisibility: () => void;
|
||||
onProviderTomlDraftChange: (value: string) => void;
|
||||
onProviderTomlDraftChange: (value: string, origin?: "manual" | "context") => void;
|
||||
onProviderHeadersConfigChange: (value: string) => void;
|
||||
onResetProviderToml: () => void;
|
||||
onSaveProvider: () => void;
|
||||
};
|
||||
@@ -391,9 +406,12 @@ function ListPage({
|
||||
configDir,
|
||||
copy,
|
||||
providerRows,
|
||||
providerBalances,
|
||||
loading,
|
||||
testingId,
|
||||
actionBusy,
|
||||
activationBusy,
|
||||
orderBusy,
|
||||
onImportCcSwitch,
|
||||
onAddProvider,
|
||||
onEnableProvider,
|
||||
@@ -401,11 +419,77 @@ function ListPage({
|
||||
onEditProvider,
|
||||
onDuplicateProvider,
|
||||
onDeleteProvider,
|
||||
}: Pick<ProvidersPageProps, "lang" | "configDir" | "copy" | "providerRows" | "loading" | "testingId" | "actionBusy" | "onImportCcSwitch" | "onAddProvider" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDuplicateProvider" | "onDeleteProvider">) {
|
||||
onReorderProviders,
|
||||
}: Pick<ProvidersPageProps, "lang" | "configDir" | "copy" | "providerRows" | "providerBalances" | "loading" | "testingId" | "actionBusy" | "activationBusy" | "orderBusy" | "onReorderProviders" | "onImportCcSwitch" | "onAddProvider" | "onEnableProvider" | "onTestProvider" | "onEditProvider" | "onDuplicateProvider" | "onDeleteProvider">) {
|
||||
const [providerToDelete, setProviderToDelete] = useState<ProviderRow | null>(null);
|
||||
const [quotaSelection, setQuotaSelection] = useState<{ id: string; configDir: string; email: string | null } | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const providerActionsBusy = loading || Boolean(actionBusy);
|
||||
const providerActivationBusy = activationBusy ?? providerActionsBusy;
|
||||
const sortingDisabled = providerActionsBusy || Boolean(orderBusy) || providerRows.length < 2;
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const pointerDragRef = useRef<{ key: string; pointerId: number; startY: number; moved: boolean } | null>(null);
|
||||
const pointerPositionRef = useRef({ x: 0, y: 0 });
|
||||
const scrollFrameRef = useRef<number | null>(null);
|
||||
const dropTargetRef = useRef<{ key: string; position: "before" | "after" } | null>(null);
|
||||
const [draggedKey, setDraggedKey] = useState("");
|
||||
const [dropTarget, setDropTarget] = useState<{ key: string; position: "before" | "after" } | null>(null);
|
||||
const finishDrag = () => {
|
||||
if (scrollFrameRef.current !== null) cancelAnimationFrame(scrollFrameRef.current);
|
||||
scrollFrameRef.current = null;
|
||||
pointerDragRef.current = null;
|
||||
dropTargetRef.current = null;
|
||||
setDraggedKey("");
|
||||
setDropTarget(null);
|
||||
};
|
||||
useEffect(() => { finishDrag(); }, [configDir]);
|
||||
useEffect(() => () => { if (scrollFrameRef.current !== null) cancelAnimationFrame(scrollFrameRef.current); }, []);
|
||||
const reorder = async (source: string, target: string, position: "before" | "after") => {
|
||||
const current = providerRows.map(providerRowKey);
|
||||
const next = moveProviderRow(current, source, target, position);
|
||||
if (next.some((key, index) => key !== current[index])) {
|
||||
await onReorderProviders(next);
|
||||
if (document.activeElement === document.body || document.activeElement?.classList.contains("cx-providers-drag-handle")) {
|
||||
const card = Array.from(listRef.current?.querySelectorAll<HTMLElement>("[data-provider-key]") || []).find((item) => item.dataset.providerKey === source);
|
||||
card?.querySelector<HTMLButtonElement>(".cx-providers-drag-handle")?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
const updateDropTarget = (clientX: number, clientY: number) => {
|
||||
const list = listRef.current;
|
||||
if (!list) return;
|
||||
const bounds = list.getBoundingClientRect();
|
||||
if (clientX < bounds.left || clientX > bounds.right) {
|
||||
dropTargetRef.current = null;
|
||||
setDropTarget(null);
|
||||
return;
|
||||
}
|
||||
const cards = Array.from(list.querySelectorAll<HTMLElement>("[data-provider-key]"));
|
||||
const card = cards.find((item) => clientY < item.getBoundingClientRect().bottom) || cards[cards.length - 1];
|
||||
if (!card) return;
|
||||
const rect = card.getBoundingClientRect();
|
||||
const target = { key: card.dataset.providerKey!, position: clientY < rect.top + rect.height / 2 ? "before" as const : "after" as const };
|
||||
dropTargetRef.current = target;
|
||||
setDropTarget(target);
|
||||
};
|
||||
const startAutoScroll = () => {
|
||||
if (scrollFrameRef.current !== null) return;
|
||||
const tick = () => {
|
||||
const list = listRef.current;
|
||||
if (!pointerDragRef.current?.moved || !list) { scrollFrameRef.current = null; return; }
|
||||
const { x, y } = pointerPositionRef.current;
|
||||
const bounds = list.getBoundingClientRect();
|
||||
if (x >= bounds.left && x <= bounds.right) {
|
||||
const speed = y < bounds.top + 48 ? -Math.min(16, (bounds.top + 48 - y) / 3)
|
||||
: y > bounds.bottom - 48 ? Math.min(16, (y - bounds.bottom + 48) / 3) : 0;
|
||||
const previous = list.scrollTop;
|
||||
list.scrollTop += speed;
|
||||
if (list.scrollTop !== previous) updateDropTarget(x, y);
|
||||
}
|
||||
scrollFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
scrollFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
const quotaProfile = quotaSelection?.configDir === configDir
|
||||
? providerRows.find((row) => row.source === "official" && row.id === quotaSelection.id && row.canQueryQuota && (row.email ?? null) === quotaSelection.email)
|
||||
: undefined;
|
||||
@@ -453,15 +537,60 @@ function ListPage({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="cx-providers-list" role="list">
|
||||
<p className="cx-providers-sort-hint">{lang === "zh" ? "拖动卡片左侧手柄调整顺序,自动保存。" : "Drag the handle on the left to reorder. Changes save automatically."}</p>
|
||||
<div className="cx-providers-list" role="list" ref={listRef}>
|
||||
{providerRows.length === 0 ? (
|
||||
<div className="cx-providers-empty" role="status">{copy.noProviders}</div>
|
||||
) : providerRows.map((row) => {
|
||||
const balance = providerBalances?.balances.find(item => item.source === row.source && item.providerId === row.id);
|
||||
const showBalance = row.source !== "official" && (balance ? balance.supported : supportsProviderBalance(row.baseUrl));
|
||||
const testingKey = row.testingKey || `${row.source}-${row.id}`;
|
||||
const isTesting = testingId === testingKey;
|
||||
const isProtectedOfficial = row.source === "official" && row.isDefaultOfficial === true;
|
||||
const key = providerRowKey(row);
|
||||
const modelName = row.modelDisplayName || row.model || (lang === "zh" ? "跟随 Codex" : "Follow Codex");
|
||||
return (
|
||||
<article className={`cx-providers-row${row.isCurrent ? " cx-providers-row--current" : ""}${row.source === "official" ? " cx-providers-row--official" : ""}`} key={`${row.source}-${row.id}-${row.baseUrl}`} role="listitem">
|
||||
<article className={`cx-providers-row${row.isCurrent ? " cx-providers-row--current" : ""}${row.source === "official" ? " cx-providers-row--official" : showBalance ? " cx-providers-row--balance" : ""}${draggedKey === key ? " cx-providers-row--dragging" : ""}${dropTarget?.key === key && draggedKey !== key ? ` cx-providers-row--drop-${dropTarget.position}` : ""}`} key={key} role="listitem" data-provider-key={key}>
|
||||
<button type="button" className="cx-providers-drag-handle" disabled={sortingDisabled}
|
||||
aria-label={lang === "zh" ? `排序 ${row.providerName}:拖动或按上下箭头移动` : `Reorder ${row.providerName}: drag or use Up and Down arrows`}
|
||||
title={lang === "zh" ? "拖动排序,也可按上下箭头移动" : "Drag to reorder, or use Up and Down arrows"}
|
||||
onPointerDown={(event) => {
|
||||
if (sortingDisabled || event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
pointerDragRef.current = { key, pointerId: event.pointerId, startY: event.clientY, moved: false };
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const drag = pointerDragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId || sortingDisabled) return;
|
||||
if (!drag.moved && Math.abs(event.clientY - drag.startY) < 5) return;
|
||||
drag.moved = true;
|
||||
pointerPositionRef.current = { x: event.clientX, y: event.clientY };
|
||||
setDraggedKey(drag.key);
|
||||
updateDropTarget(event.clientX, event.clientY);
|
||||
startAutoScroll();
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
const drag = pointerDragRef.current;
|
||||
const target = dropTargetRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
finishDrag();
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
const bounds = listRef.current?.getBoundingClientRect();
|
||||
const inside = bounds && event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top && event.clientY <= bounds.bottom;
|
||||
if (drag.moved && target && inside && !sortingDisabled) void reorder(drag.key, target.key, target.position);
|
||||
}}
|
||||
onPointerCancel={finishDrag}
|
||||
onLostPointerCapture={finishDrag}
|
||||
onKeyDown={(event) => {
|
||||
if (sortingDisabled || !["ArrowUp", "ArrowDown"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const index = providerRows.findIndex((entry) => providerRowKey(entry) === key);
|
||||
const target = providerRows[index + (event.key === "ArrowUp" ? -1 : 1)];
|
||||
if (target) void reorder(key, providerRowKey(target), event.key === "ArrowUp" ? "before" : "after");
|
||||
}}
|
||||
><GripVertical size={16} aria-hidden="true" /></button>
|
||||
<ProviderAvatar row={row} />
|
||||
<div className="cx-providers-row-content">
|
||||
<div className="cx-providers-row-main">
|
||||
@@ -474,17 +603,19 @@ function ListPage({
|
||||
)}
|
||||
</div>
|
||||
<code title={row.baseUrl || copy.noBaseUrlLabel}>{row.baseUrl || copy.noBaseUrlLabel}</code>
|
||||
<div className="cx-providers-row-model" title={row.model || modelName}><span>{lang === "zh" ? "模型" : "Model"}</span><b>{modelName}</b>{row.modelDisplayName && row.modelDisplayName !== row.model && <code>{row.model}</code>}</div>
|
||||
{row.source !== "official" && row.meta && <div className="cx-providers-row-meta">{row.meta}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{row.source === "official" && <OfficialAccountBadge lang={lang} email={row.email} planType={row.planType} hasAuth={Boolean(row.hasAuth)} canQueryQuota={Boolean(row.canQueryQuota)} className="cx-providers-login-badge" />}
|
||||
{showBalance && <ProviderBalanceBadge balance={balance} loading={providerBalances?.loading ?? true} refreshing={providerBalances?.refreshingKeys.includes(`${row.source}:${row.id}`)} failed={providerBalances?.failed ?? false} lang={lang} onRefresh={row.source !== "official" && providerBalances ? () => { void providerBalances.refreshBalance(row.source as "local" | "detected", row.id); } : undefined} />}
|
||||
<div className="cx-providers-row-actions">
|
||||
{row.isCurrent && <span className="cx-providers-current-badge"><span aria-hidden="true" />{copy.currentLabel}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className="cx-providers-button cx-providers-button--small cx-providers-button--secondary"
|
||||
onClick={() => onEnableProvider(row)}
|
||||
disabled={providerActionsBusy || row.isCurrent}
|
||||
disabled={providerActivationBusy || row.isCurrent}
|
||||
>
|
||||
{copy.enableLabel}
|
||||
</button>
|
||||
@@ -616,9 +747,15 @@ function OfficialForm({
|
||||
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "selectedPresetId" | "selectedPresetVariantId" | "onPresetSelect" | "onPresetVariantSelect" | "officialForm" | "officialProfileIsDefault" | "canLoadCurrentOfficial" | "officialAuthRef" | "officialTomlRef" | "officialInfo" | "loading" | "actionBusy" | "onCancelMode" | "onOfficialNameChange" | "onOfficialModelChange" | "onOfficialAuthChange" | "onOfficialConfigChange" | "onSaveOfficial" | "onLoadCcSwitchOfficial" | "onLoadCurrentOfficial" | "onResetOfficial">) {
|
||||
const [resetConfirmOpen, setResetConfirmOpen] = useState(false);
|
||||
const [contextWindowBusy, setContextWindowBusy] = useState(false);
|
||||
const [authImportBusy, setAuthImportBusy] = useState(false);
|
||||
const [authImportError, setAuthImportError] = useState("");
|
||||
const [authImportName, setAuthImportName] = useState("");
|
||||
const authFileRef = useRef<HTMLInputElement>(null);
|
||||
const authImportRequest = useRef(0);
|
||||
useEffect(() => () => { authImportRequest.current += 1; }, []);
|
||||
const loadingCcSwitch = actionBusy === "loadCcSwitchOfficial";
|
||||
const loadingCurrent = actionBusy === "loadCurrentOfficial";
|
||||
const formBusy = loading || actionBusy === "loadOfficialDraft" || loadingCcSwitch || loadingCurrent || contextWindowBusy;
|
||||
const formBusy = loading || actionBusy === "loadOfficialDraft" || loadingCcSwitch || loadingCurrent || contextWindowBusy || authImportBusy;
|
||||
const showDefaultActions = officialProfileIsDefault && !creatingProvider;
|
||||
const profileHint = lang === "zh"
|
||||
? "认证内容可留空;保存并启用后,在 Codex 中完成登录。复制官方供应商会保留原认证,并可修改名称。"
|
||||
@@ -630,6 +767,23 @@ function OfficialForm({
|
||||
onResetOfficial();
|
||||
};
|
||||
|
||||
const importAuthFile = async (file: File) => {
|
||||
if (formBusy) return;
|
||||
const request = ++authImportRequest.current;
|
||||
setAuthImportBusy(true); setAuthImportError(""); setAuthImportName("");
|
||||
try {
|
||||
const text = await readOfficialAuthImport(file);
|
||||
if (request !== authImportRequest.current) return;
|
||||
onOfficialAuthChange(text);
|
||||
setAuthImportName(file.name);
|
||||
} catch (error) {
|
||||
if (request !== authImportRequest.current) return;
|
||||
setAuthImportError(error instanceof OfficialAuthImportError && error.code === "too_large"
|
||||
? (lang === "zh" ? "认证文件超过 1 MiB,请选择单个 auth.json。" : "Choose a single auth.json file smaller than 1 MiB.")
|
||||
: (lang === "zh" ? "请选择有效的单个 Codex auth.json;批量账号或其他工具的封装格式暂不支持。" : "Choose a valid single Codex auth.json. Account arrays and other export formats are not supported yet."));
|
||||
} finally { if (request === authImportRequest.current) setAuthImportBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModeHeader
|
||||
@@ -666,6 +820,17 @@ function OfficialForm({
|
||||
/>
|
||||
</div>
|
||||
<Field label={copy.officialAuthLabel} className="cx-providers-editor-field">
|
||||
<div className="cx-providers-context-heading">
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary" disabled={formBusy} onClick={() => authFileRef.current?.click()}>
|
||||
<Download size={14} aria-hidden="true" />{lang === "zh" ? "导入 auth.json 文件" : "Import auth.json file"}
|
||||
</button>
|
||||
<input ref={authFileRef} type="file" accept=".json,application/json" hidden onChange={event => {
|
||||
const file = event.currentTarget.files?.[0]; event.currentTarget.value = "";
|
||||
if (file) void importAuthFile(file);
|
||||
}} />
|
||||
</div>
|
||||
{authImportError && <span className="cx-providers-context-error" role="alert">{authImportError}</span>}
|
||||
{authImportName && <span className="cx-providers-context-hint" role="status">{lang === "zh" ? `已读入 ${authImportName},保存官方账号后生效。` : `Loaded ${authImportName}. Save the official account to apply it.`}</span>}
|
||||
<textarea
|
||||
ref={officialAuthRef}
|
||||
className="cx-providers-code-editor cx-providers-auth-editor"
|
||||
@@ -744,17 +909,21 @@ function ProviderForm({
|
||||
onProviderNameChange,
|
||||
onProviderModelChange,
|
||||
onFetchModels,
|
||||
onWireApiChange,
|
||||
onUpstreamApiChange,
|
||||
onRequiresAuthChange,
|
||||
onToggleApiKeyVisibility,
|
||||
onProviderTomlDraftChange,
|
||||
onProviderHeadersConfigChange,
|
||||
onResetProviderToml,
|
||||
onSaveProvider,
|
||||
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "selectedPresetId" | "selectedPresetVariantId" | "onPresetSelect" | "onPresetVariantSelect" | "providerForm" | "providerModelMappings" | "onProviderModelMappingsChange" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onWireApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onResetProviderToml" | "onSaveProvider">) {
|
||||
}: Pick<ProvidersPageProps, "lang" | "copy" | "creatingProvider" | "selectedPresetId" | "selectedPresetVariantId" | "onPresetSelect" | "onPresetVariantSelect" | "providerForm" | "providerModelMappings" | "onProviderModelMappingsChange" | "loading" | "editingProviderId" | "providerAuthPreview" | "providerTomlDraft" | "providerTomlRef" | "apiKeyVisible" | "availableModels" | "fetchingModels" | "onCancelMode" | "onApiKeyChange" | "onBaseUrlChange" | "onProviderNameChange" | "onProviderModelChange" | "onFetchModels" | "onUpstreamApiChange" | "onRequiresAuthChange" | "onToggleApiKeyVisibility" | "onProviderTomlDraftChange" | "onProviderHeadersConfigChange" | "onResetProviderToml" | "onSaveProvider">) {
|
||||
const modelListId = useId();
|
||||
const [contextWindowBusy, setContextWindowBusy] = useState(false);
|
||||
const canFetchModels = Boolean(providerForm.baseUrl.trim() && providerForm.apiKey.trim());
|
||||
const formBusy = loading || fetchingModels || contextWindowBusy;
|
||||
const [headersBusy, setHeadersBusy] = useState(false);
|
||||
const [headersValid, setHeadersValid] = useState(true);
|
||||
const [headersRevision, setHeadersRevision] = useState(0);
|
||||
const canFetchModels = Boolean(providerForm.baseUrl.trim());
|
||||
const formBusy = loading || fetchingModels || contextWindowBusy || headersBusy;
|
||||
const mappingsValid = validateProviderModelMappings(providerModelMappings, providerForm.model, lang).valid;
|
||||
|
||||
return (
|
||||
@@ -803,7 +972,7 @@ function ProviderForm({
|
||||
type="button"
|
||||
className="cx-providers-button cx-providers-button--secondary cx-providers-button--small cx-providers-fetch-models"
|
||||
onClick={onFetchModels}
|
||||
disabled={loading || fetchingModels || !canFetchModels}
|
||||
disabled={formBusy || !canFetchModels || !headersValid}
|
||||
title={copy.fetchModelsLabel}
|
||||
aria-label={copy.fetchModelsLabel}
|
||||
>
|
||||
@@ -833,12 +1002,22 @@ function ProviderForm({
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
<Field label={copy.wireApiLabel}>
|
||||
<select value={providerForm.wireApi} onChange={(event) => onWireApiChange(event.target.value)} disabled={formBusy}>
|
||||
<option value="responses">responses</option>
|
||||
<option value="chat">chat</option>
|
||||
<Field label={lang === "zh" ? "上游接口协议" : "Upstream API protocol"}>
|
||||
<select value={providerUpstreamApi(providerForm)} onChange={(event) => onUpstreamApiChange(event.target.value)} disabled={formBusy}>
|
||||
<option value="responses">OpenAI Responses</option>
|
||||
<option value="chat_completions">OpenAI Chat Completions</option>
|
||||
<option value="anthropic_messages">Claude Messages</option>
|
||||
<option value="gemini">Gemini generateContent</option>
|
||||
{!["responses", "chat_completions", "anthropic_messages", "gemini"].includes(providerUpstreamApi(providerForm)) && <option value={providerUpstreamApi(providerForm)} disabled>{lang === "zh" ? "不支持的协议,请重新选择" : "Unsupported protocol — choose a supported API"}</option>}
|
||||
</select>
|
||||
</Field>
|
||||
{providerUpstreamApi(providerForm) !== "responses" && (
|
||||
<p className="cx-providers-field--full cx-providers-protocol-hint">
|
||||
{lang === "zh"
|
||||
? "Codex 仍使用 Responses。本协议需在设置中开启本地路由和配置接管;使用期间请保持 Codex-X 运行。关闭路由前请切回 Responses 供应商或官方账号;退出期间请求会暂停,重开后自动恢复。"
|
||||
: "Codex still uses Responses. Enable the local router and config takeover in Settings, and keep Codex-X running. Switch to a Responses provider or official account before disabling the router. Requests pause while Codex-X is closed and resume after reopening."}
|
||||
</p>
|
||||
)}
|
||||
<Checkbox
|
||||
className="cx-providers-checkbox cx-providers-checkbox--full"
|
||||
checked={providerForm.requiresOpenaiAuth}
|
||||
@@ -849,6 +1028,16 @@ function ProviderForm({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ProviderHeadersControl
|
||||
key={`headers:${editingProviderId ?? "new-provider"}:${selectedPresetId}:${selectedPresetVariantId}:${headersRevision}`}
|
||||
lang={lang}
|
||||
configText={providerTomlDraft}
|
||||
disabled={loading || fetchingModels || contextWindowBusy}
|
||||
onConfigChange={onProviderHeadersConfigChange}
|
||||
onBusyChange={setHeadersBusy}
|
||||
onValidityChange={setHeadersValid}
|
||||
/>
|
||||
|
||||
<ProviderModelMappings
|
||||
key={editingProviderId ?? "new-provider"}
|
||||
lang={lang}
|
||||
@@ -868,16 +1057,17 @@ function ProviderForm({
|
||||
<div className="cx-providers-section-heading cx-providers-section-heading--with-action">
|
||||
<div><h3>{copy.tomlTitle}</h3><p>{copy.tomlDescription}</p></div>
|
||||
<div className="cx-providers-context-actions">
|
||||
<ContextWindowControl lang={lang} configText={providerTomlDraft} onConfigChange={onProviderTomlDraftChange} disabled={formBusy} onBusyChange={setContextWindowBusy} />
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={onResetProviderToml} disabled={formBusy}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
|
||||
<ContextWindowControl lang={lang} configText={providerTomlDraft} onConfigChange={(value) => onProviderTomlDraftChange(value, "context")} disabled={formBusy} onBusyChange={setContextWindowBusy} />
|
||||
<button type="button" className="cx-providers-button cx-providers-button--secondary cx-providers-button--small" onClick={() => { setHeadersRevision((value) => value + 1); onResetProviderToml(); }} disabled={formBusy}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea ref={providerTomlRef} className="cx-providers-code-editor cx-providers-toml-editor" aria-label={copy.tomlTitle} value={providerTomlDraft} onChange={(event) => onProviderTomlDraftChange(event.target.value)} disabled={formBusy} spellCheck={false} />
|
||||
<textarea ref={providerTomlRef} className="cx-providers-code-editor cx-providers-toml-editor" aria-label={copy.tomlTitle} value={providerTomlDraft} onChange={(event) => { setHeadersRevision((value) => value + 1); onProviderTomlDraftChange(event.target.value); }} disabled={formBusy} spellCheck={false} />
|
||||
</section>
|
||||
|
||||
<div className="cx-providers-form-actions cx-providers-form-actions--save">
|
||||
{!headersValid && <span className="cx-provider-mappings-save-hint">{lang === "zh" ? "请先修正请求头中的错误。" : "Correct the HTTP header errors before saving."}</span>}
|
||||
{!mappingsValid && <span className="cx-provider-mappings-save-hint">{lang === "zh" ? "请先修正模型映射中的错误。" : "Correct the model mapping errors before saving."}</span>}
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={formBusy || !mappingsValid}>
|
||||
<button type="button" className="cx-providers-button cx-providers-button--primary" onClick={onSaveProvider} disabled={formBusy || !mappingsValid || !headersValid}>
|
||||
{loading ? <Loader2 size={15} className="cx-providers-spin" aria-hidden="true" /> : <CheckCircle2 size={15} aria-hidden="true" />}
|
||||
{loading ? copy.savingLabel : copy.saveLabel}
|
||||
</button>
|
||||
|
||||
@@ -1,18 +1,70 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
Download,
|
||||
Folder,
|
||||
FolderTree,
|
||||
History,
|
||||
Info,
|
||||
Loader2,
|
||||
Pin,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, Checkbox, ModalShell, cx } from "../components/ui";
|
||||
import {
|
||||
mergeSessionDirectoryGroups,
|
||||
pinnedSessionDirectoryKeys,
|
||||
readSessionPinPreferences,
|
||||
sessionPinStorageKey,
|
||||
sessionsForDirectoryGroup,
|
||||
toggleFolderPins,
|
||||
type SessionDirectoryIdentities,
|
||||
type SessionPinPreferences,
|
||||
} from "../sessionDirectoryIdentity";
|
||||
import "../styles/session-management.css";
|
||||
|
||||
const SESSION_ROW_HEIGHT = 58;
|
||||
const SESSION_COLUMN_HEADER_HEIGHT = 34;
|
||||
const SESSION_VIRTUAL_OVERSCAN_ROWS = 8;
|
||||
const DEFAULT_SESSION_VIEWPORT_HEIGHT = 640;
|
||||
const SESSION_ALL_FOLDERS_KEY = "__codexx_all_folders__";
|
||||
// Defer the browser storage getter so unavailable storage is caught by the reader.
|
||||
const sessionPinStorage = { getItem: (key: string) => localStorage.getItem(key) };
|
||||
|
||||
type ScopedSessionPinPreferences = SessionPinPreferences & {
|
||||
storageKey: string;
|
||||
};
|
||||
|
||||
function folderDisplayName(value: string, missing: string) {
|
||||
if (!value) return missing;
|
||||
const parts = value.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
return parts[parts.length - 1] || missing;
|
||||
}
|
||||
|
||||
type SessionVirtualRow = {
|
||||
kind: "session";
|
||||
key: string;
|
||||
top: number;
|
||||
height: number;
|
||||
item: SessionPreview;
|
||||
};
|
||||
|
||||
function findVirtualRowIndex(rows: SessionVirtualRow[], offset: number) {
|
||||
let low = 0;
|
||||
let high = rows.length;
|
||||
while (low < high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
if (rows[middle].top + rows[middle].height <= offset) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
export type Lang = "zh" | "en";
|
||||
|
||||
export type SessionPreview = {
|
||||
@@ -31,6 +83,8 @@ export type SessionPreview = {
|
||||
|
||||
export type SessionSyncStatus = {
|
||||
codexDir: string;
|
||||
directoryIdentities?: SessionDirectoryIdentities;
|
||||
pinScopeKey?: string | null;
|
||||
targetProvider: string;
|
||||
rolloutFiles: number;
|
||||
sessionMetaCount: number;
|
||||
@@ -73,6 +127,7 @@ type SessionManagementPageProps = {
|
||||
actionBusy: string;
|
||||
sessionDeleteConfirmOpen: boolean;
|
||||
sessionDeleteBusy: boolean;
|
||||
sessionExportBusy: boolean;
|
||||
sessionDeleteSafetyConfirmed: boolean;
|
||||
onCheckSessions: () => void;
|
||||
onSyncSessions: () => void;
|
||||
@@ -84,6 +139,7 @@ type SessionManagementPageProps = {
|
||||
onSetSessionGroupSelected: (sessions: SessionPreview[], checked: boolean) => void;
|
||||
onCloseDeleteConfirm: () => void;
|
||||
onDeleteSelectedSessions: () => void;
|
||||
onExportSessions: (ids: string[]) => void;
|
||||
onDeleteSafetyConfirmedChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
@@ -138,6 +194,7 @@ export function SessionManagementPage({
|
||||
actionBusy,
|
||||
sessionDeleteConfirmOpen,
|
||||
sessionDeleteBusy,
|
||||
sessionExportBusy,
|
||||
sessionDeleteSafetyConfirmed,
|
||||
onCheckSessions,
|
||||
onSyncSessions,
|
||||
@@ -149,14 +206,16 @@ export function SessionManagementPage({
|
||||
onSetSessionGroupSelected,
|
||||
onCloseDeleteConfirm,
|
||||
onDeleteSelectedSessions,
|
||||
onExportSessions,
|
||||
onDeleteSafetyConfirmedChange,
|
||||
}: SessionManagementPageProps) {
|
||||
const isChinese = lang === "zh";
|
||||
const codexDir = sessionStatus?.codexDir || "default";
|
||||
const copy = isChinese
|
||||
? {
|
||||
syncEyebrow: "会话同步",
|
||||
title: "会话管理",
|
||||
description: "检查本地会话是否位于官方与中转共用的会话列表,需要时一键同步。不会修改聊天内容。",
|
||||
description: "同步普通会话到共享列表,内部任务不参与同步。不会修改聊天内容。",
|
||||
syncTo: "同步到",
|
||||
check: "检查会话",
|
||||
checking: "检查中...",
|
||||
@@ -168,26 +227,38 @@ export function SessionManagementPage({
|
||||
allSynced: "全部会话已同步",
|
||||
sessionCount: (count: number) => `${count} 条会话`,
|
||||
local: "本地会话",
|
||||
list: "会话列表",
|
||||
list: "文件夹与会话",
|
||||
shown: (shown: number, total: number) => `展示 ${shown} / ${total} 条`,
|
||||
loaded: (count: number) => `当前加载 ${count} 条`,
|
||||
search: "搜索标题 / 项目 / 供应商 / ID",
|
||||
groupByProject: "按项目路径分组",
|
||||
groupByProject: "文件夹视图",
|
||||
showInternal: (count: number) => `显示内部会话 (${count})`,
|
||||
deleteSelected: "删除选中",
|
||||
exportSelected: "导出选中",
|
||||
exportOne: "导出为 Markdown",
|
||||
exporting: "导出中…",
|
||||
exportHint: "单个会话保存为 Markdown;多个会话打包为 ZIP",
|
||||
deleteMany: (count: number) => `永久删除 ${count} 条`,
|
||||
selectAll: "选择当前列表中的全部会话",
|
||||
selectProject: (path: string, count: number) => `选择项目 ${path} 的 ${count} 条会话`,
|
||||
projectCount: (count: number, truncated: boolean) => `${truncated ? "已加载 " : ""}${count} 条`,
|
||||
projectShown: (shown: number, total: number) => `显示 ${shown} / 共 ${total} 条`,
|
||||
pinFolder: "置顶文件夹",
|
||||
unpinFolder: "取消置顶文件夹",
|
||||
pinSession: "置顶会话",
|
||||
unpinSession: "取消置顶会话",
|
||||
allSessions: "全部会话",
|
||||
folders: "工作文件夹",
|
||||
selectSession: "选择会话",
|
||||
archived: "已归档",
|
||||
internal: "内部",
|
||||
internalHint: "内部任务仅供查看,不参与会话同步。",
|
||||
pending: "待同步",
|
||||
unknownProvider: "未知供应商",
|
||||
noModel: "未记录",
|
||||
noMatch: "没有匹配的会话。",
|
||||
noSessions: "还没有读取到会话。点击右上角“检查会话”刷新。",
|
||||
loadingMore: "正在加载更多会话…",
|
||||
diagnostics: "诊断信息",
|
||||
diagnosticsCount: (count: number) => `${count} 条 · 点击查看`,
|
||||
deleteTitle: (count: number) => `永久删除 ${count} 条会话`,
|
||||
@@ -205,7 +276,7 @@ export function SessionManagementPage({
|
||||
: {
|
||||
syncEyebrow: "SESSION SYNC",
|
||||
title: "Session management",
|
||||
description: "Keep local sessions in one history shared by official and third-party providers. Chat content is not changed.",
|
||||
description: "Keep user conversations in one shared history. Internal tasks are excluded from sync; chat content is unchanged.",
|
||||
syncTo: "Sync to",
|
||||
check: "Check sessions",
|
||||
checking: "Checking...",
|
||||
@@ -217,26 +288,38 @@ export function SessionManagementPage({
|
||||
allSynced: "All sessions are synced",
|
||||
sessionCount: (count: number) => `${count} sessions`,
|
||||
local: "LOCAL SESSIONS",
|
||||
list: "Sessions",
|
||||
list: "Folders & sessions",
|
||||
shown: (shown: number, total: number) => `${shown} / ${total} shown`,
|
||||
loaded: (count: number) => `${count} loaded`,
|
||||
search: "Search title / project / provider / ID",
|
||||
groupByProject: "Group by project path",
|
||||
groupByProject: "Folder view",
|
||||
showInternal: (count: number) => `Show internal sessions (${count})`,
|
||||
deleteSelected: "Delete selected",
|
||||
exportSelected: "Export selected",
|
||||
exportOne: "Export as Markdown",
|
||||
exporting: "Exporting…",
|
||||
exportHint: "Save one session as Markdown, or multiple sessions in a ZIP",
|
||||
deleteMany: (count: number) => `Delete ${count} permanently`,
|
||||
selectAll: "Select all sessions in the current list",
|
||||
selectProject: (path: string, count: number) => `Select ${count} sessions in ${path}`,
|
||||
projectCount: (count: number, truncated: boolean) => `${count}${truncated ? " loaded" : ""}`,
|
||||
projectShown: (shown: number, total: number) => `${shown} / ${total} shown`,
|
||||
pinFolder: "Pin folder",
|
||||
unpinFolder: "Unpin folder",
|
||||
pinSession: "Pin session",
|
||||
unpinSession: "Unpin session",
|
||||
allSessions: "All sessions",
|
||||
folders: "Workspace folders",
|
||||
selectSession: "Select session",
|
||||
archived: "Archived",
|
||||
internal: "Internal",
|
||||
internalHint: "Internal tasks are available for inspection and excluded from session sync.",
|
||||
pending: "Needs sync",
|
||||
unknownProvider: "Unknown provider",
|
||||
noModel: "Not recorded",
|
||||
noMatch: "No matching sessions.",
|
||||
noSessions: "No sessions loaded. Click Check sessions to refresh.",
|
||||
loadingMore: "Loading more sessions…",
|
||||
diagnostics: "Diagnostics",
|
||||
diagnosticsCount: (count: number) => `${count} · click to view`,
|
||||
deleteTitle: (count: number) => `Permanently delete ${count} session(s)`,
|
||||
@@ -258,10 +341,173 @@ export function SessionManagementPage({
|
||||
...(sessionStatus?.warnings || []).map((message) => ({ message, blocking: false })),
|
||||
];
|
||||
const dialogOpen = sessionDeleteConfirmOpen && selectedSessions.length > 0;
|
||||
const selectedVisibleCount = filteredSessions.filter((item) => selectedSessionSet.has(item.id)).length;
|
||||
const allVisibleSelected = filteredSessions.length > 0 && selectedVisibleCount === filteredSessions.length;
|
||||
const sessionScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const directoryIdentities = sessionStatus?.directoryIdentities;
|
||||
const pinScopeKey = sessionStatus?.pinScopeKey;
|
||||
const pinStorageKey = useMemo(() => sessionPinStorageKey(codexDir, pinScopeKey), [codexDir, pinScopeKey]);
|
||||
const [pinPreferences, setPinPreferences] = useState<ScopedSessionPinPreferences>({
|
||||
storageKey: "",
|
||||
folders: [],
|
||||
sessions: [],
|
||||
});
|
||||
const [selectedFolderKey, setSelectedFolderKey] = useState(SESSION_ALL_FOLDERS_KEY);
|
||||
const [sessionScrollMetrics, setSessionScrollMetrics] = useState({
|
||||
scrollTop: 0,
|
||||
viewportHeight: DEFAULT_SESSION_VIEWPORT_HEIGHT,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setPinPreferences({ storageKey: pinStorageKey, ...readSessionPinPreferences(pinStorageKey, sessionPinStorage) });
|
||||
setSelectedFolderKey(SESSION_ALL_FOLDERS_KEY);
|
||||
}, [pinStorageKey]);
|
||||
|
||||
const activePinPreferences = pinPreferences.storageKey === pinStorageKey
|
||||
? pinPreferences
|
||||
: { storageKey: pinStorageKey, folders: [], sessions: [] };
|
||||
const pinnedFolderSet = useMemo(() => pinnedSessionDirectoryKeys(activePinPreferences.folders, directoryIdentities), [activePinPreferences.folders, directoryIdentities]);
|
||||
const pinnedSessionSet = useMemo(() => new Set(activePinPreferences.sessions), [activePinPreferences.sessions]);
|
||||
|
||||
const updatePins = useCallback((kind: "folders" | "sessions", key: string) => {
|
||||
setPinPreferences((current) => {
|
||||
const base = current.storageKey === pinStorageKey
|
||||
? current
|
||||
: { storageKey: pinStorageKey, ...readSessionPinPreferences(pinStorageKey, sessionPinStorage) };
|
||||
const values = new Set(base.sessions);
|
||||
if (kind === "sessions") {
|
||||
if (values.has(key)) values.delete(key);
|
||||
else values.add(key);
|
||||
}
|
||||
const next = kind === "folders"
|
||||
? { ...base, folders: toggleFolderPins(base.folders, key, directoryIdentities) }
|
||||
: { ...base, sessions: Array.from(values) };
|
||||
try {
|
||||
localStorage.setItem(pinStorageKey, JSON.stringify({ folders: next.folders, sessions: next.sessions }));
|
||||
} catch {
|
||||
// Pinning remains available for this run even when localStorage is unavailable.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [pinStorageKey, directoryIdentities]);
|
||||
|
||||
const orderedSessionGroups = useMemo(() => mergeSessionDirectoryGroups(groupedSessions, directoryIdentities).map(({ group, folderKey, items }) => {
|
||||
const orderedItems = [...items].sort((left, right) => {
|
||||
const pinnedOrder = Number(pinnedSessionSet.has(right.id)) - Number(pinnedSessionSet.has(left.id));
|
||||
if (pinnedOrder) return pinnedOrder;
|
||||
const recencyOrder = (right.updatedAtMs || 0) - (left.updatedAtMs || 0);
|
||||
return recencyOrder || left.id.localeCompare(right.id);
|
||||
});
|
||||
return {
|
||||
group,
|
||||
folderKey,
|
||||
items: orderedItems,
|
||||
newestAt: orderedItems.reduce((latest, item) => Math.max(latest, item.updatedAtMs || 0), 0),
|
||||
pinned: pinnedFolderSet.has(folderKey),
|
||||
};
|
||||
}).sort((left, right) => {
|
||||
if (!sessionGroupByCwd) return 0;
|
||||
const pinnedOrder = Number(right.pinned) - Number(left.pinned);
|
||||
return pinnedOrder || right.newestAt - left.newestAt || left.group.localeCompare(right.group);
|
||||
}), [groupedSessions, directoryIdentities, pinnedFolderSet, pinnedSessionSet, sessionGroupByCwd]);
|
||||
|
||||
const orderedAllSessions = useMemo(() => [...filteredSessions].sort((left, right) => {
|
||||
const pinnedOrder = Number(pinnedSessionSet.has(right.id)) - Number(pinnedSessionSet.has(left.id));
|
||||
if (pinnedOrder) return pinnedOrder;
|
||||
const recencyOrder = (right.updatedAtMs || 0) - (left.updatedAtMs || 0);
|
||||
return recencyOrder || left.id.localeCompare(right.id);
|
||||
}), [filteredSessions, pinnedSessionSet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionGroupByCwd || selectedFolderKey === SESSION_ALL_FOLDERS_KEY) return;
|
||||
if (!orderedSessionGroups.some((group) => group.folderKey === selectedFolderKey)) {
|
||||
setSelectedFolderKey(SESSION_ALL_FOLDERS_KEY);
|
||||
}
|
||||
}, [orderedSessionGroups, selectedFolderKey, sessionGroupByCwd]);
|
||||
|
||||
const displayedSessions = useMemo(() => {
|
||||
if (!sessionGroupByCwd || selectedFolderKey === SESSION_ALL_FOLDERS_KEY) return orderedAllSessions;
|
||||
return sessionsForDirectoryGroup(orderedSessionGroups, selectedFolderKey);
|
||||
}, [orderedAllSessions, orderedSessionGroups, selectedFolderKey, sessionGroupByCwd]);
|
||||
const selectedVisibleCount = displayedSessions.filter((item) => selectedSessionSet.has(item.id)).length;
|
||||
const allVisibleSelected = displayedSessions.length > 0 && selectedVisibleCount === displayedSessions.length;
|
||||
const visibleSelectionIsPartial = selectedVisibleCount > 0 && !allVisibleSelected;
|
||||
|
||||
const virtualSessionModel = useMemo(() => {
|
||||
const rows: SessionVirtualRow[] = [];
|
||||
let top = 0;
|
||||
displayedSessions.forEach((item) => {
|
||||
rows.push({
|
||||
kind: "session",
|
||||
key: `session:${item.id}`,
|
||||
top,
|
||||
height: SESSION_ROW_HEIGHT,
|
||||
item,
|
||||
});
|
||||
top += SESSION_ROW_HEIGHT;
|
||||
});
|
||||
return { rows, totalHeight: top };
|
||||
}, [displayedSessions]);
|
||||
|
||||
const visibleSessionRows = useMemo(() => {
|
||||
const viewportHeight = sessionScrollMetrics.viewportHeight || DEFAULT_SESSION_VIEWPORT_HEIGHT;
|
||||
const bodyScrollTop = Math.max(0, sessionScrollMetrics.scrollTop - SESSION_COLUMN_HEADER_HEIGHT);
|
||||
const firstVisibleIndex = findVirtualRowIndex(virtualSessionModel.rows, bodyScrollTop);
|
||||
const lastVisibleIndex = findVirtualRowIndex(virtualSessionModel.rows, bodyScrollTop + viewportHeight);
|
||||
const firstIndex = Math.max(0, firstVisibleIndex - SESSION_VIRTUAL_OVERSCAN_ROWS);
|
||||
const lastIndex = Math.min(
|
||||
virtualSessionModel.rows.length,
|
||||
lastVisibleIndex + SESSION_VIRTUAL_OVERSCAN_ROWS + 1,
|
||||
);
|
||||
return virtualSessionModel.rows.slice(firstIndex, lastIndex);
|
||||
}, [sessionScrollMetrics, virtualSessionModel]);
|
||||
|
||||
const syncSessionScrollMetrics = useCallback(() => {
|
||||
const element = sessionScrollRef.current;
|
||||
if (!element) return;
|
||||
setSessionScrollMetrics((current) => {
|
||||
const next = {
|
||||
scrollTop: element.scrollTop,
|
||||
viewportHeight: element.clientHeight,
|
||||
};
|
||||
return current.scrollTop === next.scrollTop && current.viewportHeight === next.viewportHeight
|
||||
? current
|
||||
: next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSessionScroll = useCallback(() => {
|
||||
syncSessionScrollMetrics();
|
||||
}, [syncSessionScrollMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
syncSessionScrollMetrics();
|
||||
const element = sessionScrollRef.current;
|
||||
if (!element) return undefined;
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
window.addEventListener("resize", syncSessionScrollMetrics);
|
||||
return () => window.removeEventListener("resize", syncSessionScrollMetrics);
|
||||
}
|
||||
const observer = new ResizeObserver(syncSessionScrollMetrics);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [active, syncSessionScrollMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = sessionScrollRef.current;
|
||||
if (!element) return;
|
||||
const maxScrollTop = Math.max(0, element.scrollHeight - element.clientHeight);
|
||||
if (element.scrollTop > maxScrollTop) {
|
||||
element.scrollTop = maxScrollTop;
|
||||
syncSessionScrollMetrics();
|
||||
}
|
||||
}, [syncSessionScrollMetrics, virtualSessionModel.totalHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = sessionScrollRef.current;
|
||||
if (!element) return;
|
||||
element.scrollTop = 0;
|
||||
syncSessionScrollMetrics();
|
||||
}, [selectedFolderKey, sessionGroupByCwd, syncSessionScrollMetrics]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalShell
|
||||
@@ -387,14 +633,26 @@ export function SessionManagementPage({
|
||||
className={cx("cx-session-toggle", showInternalSessions && "cx-session-toggle--active")}
|
||||
checked={showInternalSessions}
|
||||
onCheckedChange={onShowInternalSessionsChange}
|
||||
title={copy.internalHint}
|
||||
label={copy.showInternal(sessionStatus?.subagentThreads ?? 0)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="cx-session-button cx-session-button--secondary"
|
||||
onClick={() => onExportSessions(selectedSessionIds)}
|
||||
disabled={loading || sessionDeleteBusy || sessionExportBusy || selectedSessionIds.length === 0}
|
||||
title={copy.exportHint}
|
||||
aria-busy={sessionExportBusy}
|
||||
>
|
||||
{sessionExportBusy ? <Loader2 size={15} className="cx-session-spin" aria-hidden="true" /> : <Download size={15} strokeWidth={1.9} aria-hidden="true" />}
|
||||
{sessionExportBusy ? copy.exporting : copy.exportSelected}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cx("cx-session-button cx-session-delete-trigger", selectedSessionIds.length > 0 ? "cx-session-button--danger" : "cx-session-button--secondary")}
|
||||
onClick={onOpenDeleteConfirm}
|
||||
disabled={loading || sessionDeleteBusy || selectedSessionIds.length === 0}
|
||||
disabled={loading || sessionDeleteBusy || sessionExportBusy || selectedSessionIds.length === 0}
|
||||
title={selectedSessionIds.length > 0 ? undefined : copy.deleteSelected}
|
||||
>
|
||||
<Trash2 size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
@@ -402,57 +660,112 @@ export function SessionManagementPage({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filteredSessions.length > 0 ? (
|
||||
<div className="cx-session-scroll" role="table" aria-label={copy.list}>
|
||||
<div className="cx-session-column-head" role="row">
|
||||
<Checkbox
|
||||
className="cx-session-select-all"
|
||||
checked={allVisibleSelected}
|
||||
indeterminate={visibleSelectionIsPartial}
|
||||
onCheckedChange={(checked) => onSetSessionGroupSelected(filteredSessions, checked)}
|
||||
aria-label={copy.selectAll}
|
||||
disabled={loading || sessionDeleteBusy}
|
||||
/>
|
||||
<span>{isChinese ? "会话" : "Session"}</span>
|
||||
<span>{isChinese ? "更新时间" : "Updated"}</span>
|
||||
<span>{isChinese ? "供应商" : "Provider"}</span>
|
||||
<span>{isChinese ? "模型" : "Model"}</span>
|
||||
<span>ID</span>
|
||||
</div>
|
||||
<div className="cx-session-table-body">
|
||||
{groupedSessions.map(([group, items]) => {
|
||||
const projectSessions = allSessionsByCwd.get(group) || items;
|
||||
const selectedProjectCount = projectSessions.filter((item) => selectedSessionSet.has(item.id)).length;
|
||||
const projectSelected = projectSessions.length > 0 && selectedProjectCount === projectSessions.length;
|
||||
const projectPartiallySelected = selectedProjectCount > 0 && !projectSelected;
|
||||
const groupCountLabel = items.length === projectSessions.length
|
||||
? copy.projectCount(projectSessions.length, sessionPreviewTruncated)
|
||||
: copy.projectShown(items.length, projectSessions.length);
|
||||
return (
|
||||
<div className="cx-session-group" key={group}>
|
||||
{sessionGroupByCwd && (
|
||||
<label className={cx("cx-session-group-heading", projectSelected && "cx-session-group-heading--selected", projectPartiallySelected && "cx-session-group-heading--partial")}>
|
||||
<input
|
||||
className="cx-session-checkbox"
|
||||
type="checkbox"
|
||||
ref={(input) => {
|
||||
if (input) input.indeterminate = projectPartiallySelected;
|
||||
}}
|
||||
checked={projectSelected}
|
||||
onChange={(event) => onSetSessionGroupSelected(projectSessions, event.target.checked)}
|
||||
aria-label={copy.selectProject(group, projectSessions.length)}
|
||||
/>
|
||||
<span title={group}>{compactPath(group, 96, isChinese ? "未记录路径" : "No path recorded")}</span>
|
||||
<em>{groupCountLabel}</em>
|
||||
</label>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<label className={cx("cx-session-row", item.needsSync && "cx-session-row--needs-sync", selectedSessionSet.has(item.id) && "cx-session-row--selected")} key={item.id}>
|
||||
<div className={cx("cx-session-browser", !sessionGroupByCwd && "cx-session-browser--flat")}>
|
||||
{sessionGroupByCwd && (
|
||||
<aside className="cx-session-folder-tree" aria-label={copy.folders}>
|
||||
<div className="cx-session-tree-heading">
|
||||
<ChevronDown size={14} strokeWidth={2} aria-hidden="true" />
|
||||
<FolderTree size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
<span>{copy.folders}</span>
|
||||
</div>
|
||||
<div className="cx-session-tree-items" role="tree">
|
||||
<button
|
||||
type="button"
|
||||
role="treeitem"
|
||||
aria-selected={selectedFolderKey === SESSION_ALL_FOLDERS_KEY}
|
||||
className={cx("cx-session-tree-all", selectedFolderKey === SESSION_ALL_FOLDERS_KEY && "cx-session-tree-item--active")}
|
||||
onClick={() => setSelectedFolderKey(SESSION_ALL_FOLDERS_KEY)}
|
||||
>
|
||||
<History size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
<span>{copy.allSessions}</span>
|
||||
<em>{filteredSessions.length}</em>
|
||||
</button>
|
||||
{orderedSessionGroups.map((folder) => (
|
||||
<div
|
||||
className={cx("cx-session-tree-item", selectedFolderKey === folder.folderKey && "cx-session-tree-item--active")}
|
||||
role="treeitem"
|
||||
aria-selected={selectedFolderKey === folder.folderKey}
|
||||
key={folder.folderKey}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="cx-session-tree-select"
|
||||
onClick={() => setSelectedFolderKey(folder.folderKey)}
|
||||
title={folder.group}
|
||||
>
|
||||
<Folder size={15} strokeWidth={1.8} aria-hidden="true" />
|
||||
<span className="cx-session-folder-copy">
|
||||
<strong>{folderDisplayName(folder.group, isChinese ? "未记录路径" : "No path recorded")}</strong>
|
||||
<small>{compactPath(folder.group, 34, isChinese ? "未记录路径" : "No path recorded")}</small>
|
||||
</span>
|
||||
<em>{folder.items.length}</em>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cx("cx-session-pin-action", folder.pinned && "cx-session-pin-action--active")}
|
||||
onClick={() => updatePins("folders", folder.items.find((item) => item.cwd)?.cwd || "")}
|
||||
aria-label={folder.pinned ? copy.unpinFolder : copy.pinFolder}
|
||||
title={folder.pinned ? copy.unpinFolder : copy.pinFolder}
|
||||
>
|
||||
<Pin size={13} strokeWidth={1.9} fill={folder.pinned ? "currentColor" : "none"} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<div className="cx-session-results">
|
||||
{displayedSessions.length > 0 ? (
|
||||
<div
|
||||
ref={sessionScrollRef}
|
||||
className="cx-session-scroll"
|
||||
role="table"
|
||||
aria-label={copy.list}
|
||||
onScroll={handleSessionScroll}
|
||||
>
|
||||
<div className="cx-session-column-head" role="row">
|
||||
<Checkbox
|
||||
className="cx-session-select-all"
|
||||
checked={allVisibleSelected}
|
||||
indeterminate={visibleSelectionIsPartial}
|
||||
onCheckedChange={(checked) => onSetSessionGroupSelected(displayedSessions, checked)}
|
||||
aria-label={copy.selectAll}
|
||||
disabled={loading || sessionDeleteBusy}
|
||||
/>
|
||||
<span>{isChinese ? "会话" : "Session"}</span>
|
||||
<span>{isChinese ? "更新时间" : "Updated"}</span>
|
||||
<span>{isChinese ? "供应商" : "Provider"}</span>
|
||||
<span>{isChinese ? "模型" : "Model"}</span>
|
||||
<span className="cx-session-id-heading">ID</span>
|
||||
<span className="cx-session-actions-heading">{isChinese ? "操作" : "Actions"}</span>
|
||||
</div>
|
||||
<div className="cx-session-table-body" style={{ height: virtualSessionModel.totalHeight }}>
|
||||
{visibleSessionRows.map((row) => {
|
||||
const { item } = row;
|
||||
const sessionPinned = pinnedSessionSet.has(item.id);
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
"cx-session-row",
|
||||
"cx-session-virtual-item",
|
||||
item.needsSync && !item.isSubagent && "cx-session-row--needs-sync",
|
||||
selectedSessionSet.has(item.id) && "cx-session-row--selected",
|
||||
sessionPinned && "cx-session-row--pinned",
|
||||
)}
|
||||
key={row.key}
|
||||
role="row"
|
||||
style={{ top: row.top, height: row.height }}
|
||||
onClick={(event) => {
|
||||
if (!(event.target as HTMLElement).closest("button, input") && !loading && !sessionDeleteBusy) onToggleSessionSelected(item.id);
|
||||
}}
|
||||
>
|
||||
<span className="cx-session-select-box" title={copy.selectSession}>
|
||||
<input
|
||||
className="cx-session-checkbox"
|
||||
type="checkbox"
|
||||
checked={selectedSessionSet.has(item.id)}
|
||||
disabled={loading || sessionDeleteBusy}
|
||||
onChange={() => onToggleSessionSelected(item.id)}
|
||||
aria-label={`${copy.selectSession}: ${item.title || (isChinese ? "未命名会话" : "Untitled session")} (#${shortId(item.id)})`}
|
||||
/>
|
||||
@@ -461,28 +774,49 @@ export function SessionManagementPage({
|
||||
<div className="cx-session-row-title">
|
||||
<strong title={item.title}>{item.title || (isChinese ? "未命名会话" : "Untitled session")}</strong>
|
||||
{item.archived && <span className="cx-session-state">{copy.archived}</span>}
|
||||
{item.isSubagent && <span className="cx-session-state">{copy.internal}</span>}
|
||||
{item.needsSync && <span className="cx-session-state cx-session-state--warn">{copy.pending}</span>}
|
||||
{item.isSubagent && <span className="cx-session-state" title={copy.internalHint}>{copy.internal}</span>}
|
||||
{item.needsSync && !item.isSubagent && <span className="cx-session-state cx-session-state--warn">{copy.pending}</span>}
|
||||
</div>
|
||||
{!sessionGroupByCwd && <p title={item.cwd || item.rolloutPath || undefined}>{compactPath(item.cwd || item.rolloutPath, 72, isChinese ? "未记录路径" : "No path recorded")}</p>}
|
||||
{(!sessionGroupByCwd || selectedFolderKey === SESSION_ALL_FOLDERS_KEY) && <p title={item.cwd || item.rolloutPath || undefined}>{compactPath(item.cwd || item.rolloutPath, 72, isChinese ? "未记录路径" : "No path recorded")}</p>}
|
||||
</div>
|
||||
<span className="cx-session-meta cx-session-meta--time" title={item.updatedAtMs ? new Date(item.updatedAtMs).toLocaleString() : undefined}>{formatSessionTime(item.updatedAtMs, lang)}</span>
|
||||
<code className="cx-session-meta cx-session-meta--provider" title={item.modelProvider || undefined}>{item.modelProvider || copy.unknownProvider}</code>
|
||||
<span className="cx-session-meta cx-session-meta--model" title={item.model || undefined}>{item.model || copy.noModel}</span>
|
||||
<small className="cx-session-meta cx-session-meta--id" title={item.id}>#{shortId(item.id)}</small>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="cx-session-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={cx("cx-session-pin-action", sessionPinned && "cx-session-pin-action--active")}
|
||||
onClick={() => updatePins("sessions", item.id)}
|
||||
aria-label={sessionPinned ? copy.unpinSession : copy.pinSession}
|
||||
title={sessionPinned ? copy.unpinSession : copy.pinSession}
|
||||
>
|
||||
<Pin size={14} strokeWidth={1.9} fill={sessionPinned ? "currentColor" : "none"} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="cx-session-row-export"
|
||||
onClick={() => onExportSessions([item.id])}
|
||||
disabled={loading || sessionDeleteBusy || sessionExportBusy}
|
||||
aria-label={`${copy.exportOne}: ${item.title || (isChinese ? "未命名会话" : "Untitled session")}`}
|
||||
title={copy.exportOne}
|
||||
>
|
||||
<Download size={15} strokeWidth={1.9} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cx-session-empty">
|
||||
<History size={22} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>{sessionQuery ? copy.noMatch : copy.noSessions}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="cx-session-empty">
|
||||
<History size={22} strokeWidth={1.7} aria-hidden="true" />
|
||||
<span>{sessionQuery ? copy.noMatch : copy.noSessions}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{diagnostics.length ? (
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useId, useState } from "react";
|
||||
import type { ChangeEvent, RefObject } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Blocks,
|
||||
@@ -32,14 +31,14 @@ export type SkillsMcpPageProps = {
|
||||
noteBusyKey: string;
|
||||
importOpen: boolean;
|
||||
importPreview: SkillsMcpImportPreview | null;
|
||||
zipInputRef: RefObject<HTMLInputElement>;
|
||||
className?: string;
|
||||
onTabChange: (tab: SkillsMcpTab) => void;
|
||||
onLoad: MaybeAsyncAction;
|
||||
onOpenImportPreview: MaybeAsyncAction;
|
||||
onCloseImportPreview: () => void;
|
||||
onConfirmImport: MaybeAsyncAction;
|
||||
onInstallZip: (file?: File | null) => void | Promise<void>;
|
||||
onInstallZip: MaybeAsyncAction;
|
||||
onExport: (kind: SkillsMcpTab) => void | Promise<void>;
|
||||
onCheckUpdates: MaybeAsyncAction;
|
||||
onToggleSkill: (id: string, enabled: boolean) => void | Promise<void>;
|
||||
onToggleMcp: (id: string, enabled: boolean) => void | Promise<void>;
|
||||
@@ -63,7 +62,9 @@ function getCopy(lang: Lang) {
|
||||
description: "管理 Codex 当前可用的 Skills 与 MCP,导入已有内容、安装技能包并控制启用状态。",
|
||||
refresh: "刷新",
|
||||
importExisting: "导入已有",
|
||||
installZip: "从 ZIP 安装",
|
||||
installZip: "从 ZIP 导入",
|
||||
exportZip: "导出 ZIP",
|
||||
exportHint: "导出当前标签页的全部内容,可通过 ZIP 再次导入;MCP 包可能包含连接密钥,请妥善保存。",
|
||||
checkUpdates: "检查更新",
|
||||
loading: "正在读取本地 Skills / MCP...",
|
||||
mcpHelp: (count: number) => `当前共有 ${count} 个 MCP,启用后会写入 Codex config.toml。`,
|
||||
@@ -102,7 +103,9 @@ function getCopy(lang: Lang) {
|
||||
description: "Manage the Skills and MCP servers available to Codex, import existing items, install packages, and control their state.",
|
||||
refresh: "Refresh",
|
||||
importExisting: "Import existing",
|
||||
installZip: "Install ZIP",
|
||||
installZip: "Import ZIP",
|
||||
exportZip: "Export ZIP",
|
||||
exportHint: "Export all items in this tab for ZIP import. MCP archives may contain connection credentials; keep them private.",
|
||||
checkUpdates: "Check updates",
|
||||
loading: "Loading local Skills / MCP...",
|
||||
mcpHelp: (count: number) => `${count} MCP server(s). Enabling one writes it to Codex config.toml.`,
|
||||
@@ -311,7 +314,6 @@ export function SkillsMcpPage({
|
||||
noteBusyKey,
|
||||
importOpen,
|
||||
importPreview,
|
||||
zipInputRef,
|
||||
className,
|
||||
onTabChange,
|
||||
onLoad,
|
||||
@@ -319,6 +321,7 @@ export function SkillsMcpPage({
|
||||
onCloseImportPreview,
|
||||
onConfirmImport,
|
||||
onInstallZip,
|
||||
onExport,
|
||||
onCheckUpdates,
|
||||
onToggleSkill,
|
||||
onToggleMcp,
|
||||
@@ -350,9 +353,6 @@ export function SkillsMcpPage({
|
||||
setNoteTarget(null);
|
||||
}
|
||||
};
|
||||
const handleZipChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
void onInstallZip(event.currentTarget.files?.[0]);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={cx("cx-skills-page", className)} aria-label={copy.title}>
|
||||
@@ -363,16 +363,6 @@ export function SkillsMcpPage({
|
||||
<span>{copy.description}</span>
|
||||
</div>
|
||||
<div className="cx-skills-actions">
|
||||
<input
|
||||
ref={zipInputRef}
|
||||
className="cx-skills-file-input"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={handleZipChange}
|
||||
disabled={anyBusy}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={actionBusy === "loadSkillsMcp" ? <Loader2 className="cx-skills-spin" /> : <RefreshCw />}
|
||||
@@ -392,7 +382,7 @@ export function SkillsMcpPage({
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={actionBusy === "installSkillZip" ? <Loader2 className="cx-skills-spin" /> : <Upload />}
|
||||
onClick={() => zipInputRef.current?.click()}
|
||||
onClick={() => run(onInstallZip)}
|
||||
disabled={anyBusy}
|
||||
>
|
||||
{copy.installZip}
|
||||
@@ -456,7 +446,12 @@ export function SkillsMcpPage({
|
||||
{activeTab === "mcp" ? <PlugZap size={17} aria-hidden="true" /> : <PackageOpen size={17} aria-hidden="true" />}
|
||||
<h3>{activeTab === "mcp" ? "MCP" : "Skills"}</h3>
|
||||
</div>
|
||||
<span>{copy.total(activeItems.length)}</span>
|
||||
<div className="cx-skills-list-tools">
|
||||
<span>{copy.total(activeItems.length)}</span>
|
||||
<Button variant="ghost" size="sm" icon={actionBusy === "exportSkillsMcp" ? <Loader2 className="cx-skills-spin" /> : <Download />}
|
||||
title={copy.exportHint} disabled={anyBusy || activeItems.length === 0}
|
||||
onClick={() => void onExport(activeTab)}>{copy.exportZip}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cx-skills-list">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useId, useRef, useState } from "react";
|
||||
import { createContext, useContext, useEffect, useId, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
@@ -11,11 +11,15 @@ import {
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
SlidersHorizontal,
|
||||
Shuffle,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { PageTransition } from "../components/PageTransition";
|
||||
import { Button, ModalShell } from "../components/ui";
|
||||
import { UsageStatisticsPage } from "./UsageStatisticsPage";
|
||||
import { ProviderFailoverPage } from "./ProviderFailoverPage";
|
||||
import type { CodexLanguageStatus } from "../codexLanguageTypes";
|
||||
import { CodexLanguageControl } from "../components/CodexLanguageControl";
|
||||
import "../styles/utility-pages.css";
|
||||
|
||||
export type UtilityLanguage = "zh" | "en";
|
||||
@@ -112,18 +116,30 @@ export type SettingsPageProps = {
|
||||
onRestartCodex: () => Promise<boolean>;
|
||||
recheckBusy?: boolean;
|
||||
restartBusy?: boolean;
|
||||
interactionBusy?: boolean;
|
||||
onLanguageBusyChange?: (busy: boolean) => void;
|
||||
onCodexLanguageSaved?: (status: CodexLanguageStatus) => void;
|
||||
generalRequest?: number;
|
||||
configHealthStatus?: ReactNode;
|
||||
onChange?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
// PageTransition keeps the previous content during its exit animation. Context
|
||||
// still propagates the current activity state so requests stop immediately,
|
||||
// while the retained usage component keeps its filters between tabs.
|
||||
const SettingsUsageActiveContext = createContext(false);
|
||||
const SettingsFailoverActiveContext = createContext(false);
|
||||
|
||||
function SettingsUsagePanel({ lang, configDir }: Pick<SettingsPageProps, "lang" | "configDir">) {
|
||||
const active = useContext(SettingsUsageActiveContext);
|
||||
return <UsageStatisticsPage lang={lang} configDir={configDir} active={active} />;
|
||||
}
|
||||
|
||||
function SettingsFailoverPanel({ lang, configDir, onChange }: Pick<SettingsPageProps, "lang" | "configDir" | "onChange">) {
|
||||
const active = useContext(SettingsFailoverActiveContext);
|
||||
return <ProviderFailoverPage lang={lang} configDir={configDir} active={active} onChange={onChange} />;
|
||||
}
|
||||
|
||||
type SettingRowProps = {
|
||||
icon: LucideIcon;
|
||||
title: ReactNode;
|
||||
@@ -156,9 +172,17 @@ export function SettingsPage({
|
||||
onRestartCodex,
|
||||
recheckBusy = false,
|
||||
restartBusy = false,
|
||||
interactionBusy = false,
|
||||
onLanguageBusyChange,
|
||||
onCodexLanguageSaved,
|
||||
generalRequest = 0,
|
||||
configHealthStatus,
|
||||
onChange,
|
||||
}: SettingsPageProps) {
|
||||
const [tab, setTab] = useState<"general" | "usage">("general");
|
||||
const [tab, setTab] = useState<"general" | "usage" | "failover">("general");
|
||||
useEffect(() => { setTab("general"); }, [generalRequest]);
|
||||
const [usageOpened, setUsageOpened] = useState(false);
|
||||
const [failoverOpened, setFailoverOpened] = useState(false);
|
||||
const tabId = useId();
|
||||
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
|
||||
@@ -166,7 +190,7 @@ export function SettingsPage({
|
||||
if (!restartBusy) setRestartConfirmOpen(false);
|
||||
};
|
||||
const confirmRestart = async () => {
|
||||
if (restartBusy) return;
|
||||
if (restartBusy || interactionBusy) return;
|
||||
if (await onRestartCodex()) setRestartConfirmOpen(false);
|
||||
};
|
||||
|
||||
@@ -174,19 +198,20 @@ export function SettingsPage({
|
||||
<section className="cx-utility cx-page cx-page--settings">
|
||||
<PageHeader eyebrow={copy.eyebrow} title={copy.title} />
|
||||
<div className="cx-settings-tabs" role="tablist" aria-label={lang === "zh" ? "设置页面" : "Settings pages"}>
|
||||
{(["general", "usage"] as const).map((value, index) => {
|
||||
const Icon = value === "general" ? SlidersHorizontal : BarChart3;
|
||||
const select = () => { setTab(value); if (value === "usage") setUsageOpened(true); };
|
||||
{(["general", "usage", "failover"] as const).map((value, index, tabs) => {
|
||||
const Icon = value === "general" ? SlidersHorizontal : value === "usage" ? BarChart3 : Shuffle;
|
||||
const select = () => { setTab(value); if (value === "usage") setUsageOpened(true); if (value === "failover") setFailoverOpened(true); };
|
||||
return <button key={value} ref={(element) => { tabRefs.current[index] = element; }} type="button" role="tab" id={`${tabId}-${value}-tab`} aria-controls={`${tabId}-${value}-panel`} aria-selected={tab === value} tabIndex={tab === value ? 0 : -1} className="cx-settings-tab" onClick={select} onKeyDown={(event) => {
|
||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? 1 : 1 - index;
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : (index + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
|
||||
tabRefs.current[next]?.click();
|
||||
tabRefs.current[next]?.focus();
|
||||
}}><Icon size={14} aria-hidden="true" />{value === "general" ? (lang === "zh" ? "通用设置" : "General") : (lang === "zh" ? "用量统计" : "Usage statistics")}</button>;
|
||||
}}><Icon size={14} aria-hidden="true" />{value === "general" ? (lang === "zh" ? "通用设置" : "General") : value === "usage" ? (lang === "zh" ? "用量统计" : "Usage statistics") : (lang === "zh" ? "路由与故障转移" : "Routing & failover")}</button>;
|
||||
})}
|
||||
</div>
|
||||
<SettingsUsageActiveContext.Provider value={active && tab === "usage"}>
|
||||
<SettingsFailoverActiveContext.Provider value={active && tab === "failover"}>
|
||||
<PageTransition pageKey={`settings:${tab}`}>
|
||||
<div className="cx-settings-panel cx-page-settings-list" role="tabpanel" id={`${tabId}-general-panel`} aria-labelledby={`${tabId}-general-tab`} hidden={tab !== "general"}>
|
||||
<SettingRow
|
||||
@@ -215,6 +240,8 @@ export function SettingsPage({
|
||||
)}
|
||||
/>
|
||||
|
||||
<CodexLanguageControl lang={lang} configDir={configDir} active={active && tab === "general"} disabled={interactionBusy} onBusyChange={onLanguageBusyChange} onPreferencesSaved={onCodexLanguageSaved} />
|
||||
|
||||
<SettingRow
|
||||
icon={Sparkles}
|
||||
title={copy.productTitle}
|
||||
@@ -227,6 +254,8 @@ export function SettingsPage({
|
||||
title={copy.recheckTitle}
|
||||
description={copy.recheckDescription}
|
||||
action={(
|
||||
<div className="cx-settings-check-actions">
|
||||
{configHealthStatus}
|
||||
<button
|
||||
type="button"
|
||||
className="cx-page-button cx-page-button--secondary"
|
||||
@@ -236,6 +265,7 @@ export function SettingsPage({
|
||||
{recheckBusy && <Loader2 size={15} className="cx-page-spin" aria-hidden="true" />}
|
||||
{copy.recheckLabel}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -248,7 +278,7 @@ export function SettingsPage({
|
||||
type="button"
|
||||
className="cx-page-button cx-page-button--secondary"
|
||||
onClick={() => setRestartConfirmOpen(true)}
|
||||
disabled={restartBusy}
|
||||
disabled={restartBusy || interactionBusy}
|
||||
>
|
||||
{restartBusy ? <Loader2 size={15} className="cx-page-spin" aria-hidden="true" /> : <Power size={15} aria-hidden="true" />}
|
||||
{restartBusy ? copy.restartingLabel : copy.restartLabel}
|
||||
@@ -260,7 +290,11 @@ export function SettingsPage({
|
||||
<div className="cx-settings-panel" role="tabpanel" id={`${tabId}-usage-panel`} aria-labelledby={`${tabId}-usage-tab`} hidden={tab !== "usage"}>
|
||||
{usageOpened && <SettingsUsagePanel lang={lang} configDir={configDir} />}
|
||||
</div>
|
||||
<div className="cx-settings-panel" role="tabpanel" id={`${tabId}-failover-panel`} aria-labelledby={`${tabId}-failover-tab`} hidden={tab !== "failover"}>
|
||||
{failoverOpened && <SettingsFailoverPanel lang={lang} configDir={configDir} onChange={onChange} />}
|
||||
</div>
|
||||
</PageTransition>
|
||||
</SettingsFailoverActiveContext.Provider>
|
||||
</SettingsUsageActiveContext.Provider>
|
||||
|
||||
<ModalShell
|
||||
@@ -282,7 +316,7 @@ export function SettingsPage({
|
||||
variant="danger"
|
||||
icon={restartBusy ? <Loader2 size={16} className="cx-page-spin" /> : <Power size={16} />}
|
||||
onClick={() => void confirmRestart()}
|
||||
disabled={restartBusy}
|
||||
disabled={restartBusy || interactionBusy}
|
||||
>
|
||||
{restartBusy ? copy.restartingLabel : copy.restartConfirmLabel}
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export type ProviderBalance = {
|
||||
source: "local" | "detected";
|
||||
providerId: string;
|
||||
vendor: "deepseek" | "openrouter" | "siliconflow" | "moonshot" | null;
|
||||
supported: boolean;
|
||||
balanceKind: "balance" | "credit_limit" | null;
|
||||
balances: { currency: string; amount: string }[];
|
||||
checkedAt: string | null;
|
||||
errorCode: string | null;
|
||||
};
|
||||
export type ProviderBalanceState = {
|
||||
balances: ProviderBalance[];
|
||||
failed: boolean;
|
||||
loading: boolean;
|
||||
refreshingKeys: string[];
|
||||
refreshBalance: (source: "local" | "detected", providerId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
function keepLastBalance(previous: ProviderBalance | undefined, next: ProviderBalance): ProviderBalance {
|
||||
if (next.errorCode && previous?.balances.length) {
|
||||
return { ...next, balances: previous.balances, balanceKind: previous.balanceKind, checkedAt: previous.checkedAt };
|
||||
}
|
||||
if (!next.errorCode && previous?.checkedAt && next.checkedAt && previous.checkedAt > next.checkedAt) return previous;
|
||||
return next;
|
||||
}
|
||||
const REFRESH_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
// Lives in App, so navigating away from Providers does not stop refreshing.
|
||||
export function useProviderBalances(configDir: string, revision: string, ready: boolean) {
|
||||
const [snapshot, setSnapshot] = useState({ configDir, revision, balances: [] as ProviderBalance[], failed: false, loading: true });
|
||||
const scope = JSON.stringify([configDir, revision]);
|
||||
const scopeRef = useRef(scope);
|
||||
scopeRef.current = scope;
|
||||
const manualInFlight = useRef(new Set<string>());
|
||||
const manualVersions = useRef(new Map<string, number>());
|
||||
const [manualBusy, setManualBusy] = useState<Record<string, boolean>>({});
|
||||
const refreshBalance = useCallback(async (source: "local" | "detected", providerId: string) => {
|
||||
if (!ready) return;
|
||||
const token = JSON.stringify([scope, source, providerId]);
|
||||
if (manualInFlight.current.has(token)) return;
|
||||
manualInFlight.current.add(token);
|
||||
manualVersions.current.set(token, (manualVersions.current.get(token) ?? 0) + 1);
|
||||
setManualBusy(previous => ({ ...previous, [token]: true }));
|
||||
try {
|
||||
const result = await invoke<ProviderBalance>("refresh_provider_balance", { configDir, source, providerId });
|
||||
if (scopeRef.current !== scope) return;
|
||||
setSnapshot(previous => {
|
||||
const matches = (item: ProviderBalance) => item.source === source && item.providerId === providerId;
|
||||
const updated = keepLastBalance(previous.balances.find(matches), result);
|
||||
return { ...previous, failed: false, balances: [...previous.balances.filter(item => !matches(item)), updated] };
|
||||
});
|
||||
} catch {
|
||||
if (scopeRef.current === scope) setSnapshot(previous => ({ ...previous, balances: previous.balances.some(item => item.source === source && item.providerId === providerId)
|
||||
? previous.balances.map(item => item.source === source && item.providerId === providerId ? { ...item, errorCode: "refresh_failed" } : item)
|
||||
: [...previous.balances, { source, providerId, supported: true, vendor: null, balanceKind: null, balances: [], checkedAt: null, errorCode: "refresh_failed" }] }));
|
||||
} finally {
|
||||
manualVersions.current.set(token, (manualVersions.current.get(token) ?? 0) + 1);
|
||||
manualInFlight.current.delete(token);
|
||||
setManualBusy(previous => { const next = { ...previous }; delete next[token]; return next; });
|
||||
}
|
||||
}, [configDir, ready, scope]);
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
let disposed = false;
|
||||
let inFlight = false;
|
||||
let lastAttempt = 0;
|
||||
let timer: number | undefined;
|
||||
setSnapshot(previous => previous.configDir === configDir && previous.revision === revision
|
||||
? { ...previous, loading: previous.balances.length === 0 }
|
||||
: { configDir, revision, balances: [], failed: false, loading: true });
|
||||
const refresh = async () => {
|
||||
if (disposed || inFlight) return;
|
||||
inFlight = true;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
lastAttempt = Date.now();
|
||||
let nextDelay = REFRESH_INTERVAL;
|
||||
const versionsAtStart = new Map(manualVersions.current);
|
||||
const shouldPreserveManual = (item: ProviderBalance) => {
|
||||
const token = JSON.stringify([scope, item.source, item.providerId]);
|
||||
return manualInFlight.current.has(token) || (manualVersions.current.get(token) ?? 0) !== (versionsAtStart.get(token) ?? 0);
|
||||
};
|
||||
try {
|
||||
const balances = await invoke<ProviderBalance[]>("get_provider_balances", { configDir });
|
||||
// A manual query can warm the cache between automatic reads. Follow
|
||||
// the data's timestamp rather than restarting a full five-minute wait.
|
||||
const now = Date.now();
|
||||
for (const item of balances.filter(item => item.supported)) {
|
||||
const ttl = item.errorCode ? 60_000 : REFRESH_INTERVAL;
|
||||
const checked = item.checkedAt ? Date.parse(item.checkedAt) : NaN;
|
||||
const age = Number.isFinite(checked) ? Math.max(0, now - checked) : 0;
|
||||
nextDelay = Math.min(nextDelay, Math.max(1_000, ttl - age + 1_000));
|
||||
}
|
||||
if (!disposed) setSnapshot(previous => ({ configDir, revision, balances: balances.map(next => {
|
||||
const current = previous.balances.find(item => item.source === next.source && item.providerId === next.providerId);
|
||||
return current && shouldPreserveManual(next) ? current : keepLastBalance(current, next);
|
||||
}), failed: false, loading: false }));
|
||||
} catch {
|
||||
nextDelay = 60_000;
|
||||
if (!disposed) setSnapshot(previous => ({ ...previous, loading: false, failed: previous.balances.length === 0, balances: previous.balances.map(item => item.supported && !shouldPreserveManual(item) ? { ...item, errorCode: "refresh_failed" } : item) }));
|
||||
} finally {
|
||||
inFlight = false;
|
||||
lastAttempt = Date.now();
|
||||
if (!disposed) timer = window.setTimeout(() => { void refresh(); }, nextDelay);
|
||||
}
|
||||
};
|
||||
const onWake = () => { if (Date.now() - lastAttempt >= REFRESH_INTERVAL) void refresh(); };
|
||||
void refresh();
|
||||
window.addEventListener("focus", onWake);
|
||||
document.addEventListener("visibilitychange", onWake);
|
||||
return () => { disposed = true; if (timer !== undefined) window.clearTimeout(timer); window.removeEventListener("focus", onWake); document.removeEventListener("visibilitychange", onWake); };
|
||||
}, [configDir, revision, ready, scope]);
|
||||
const visible = snapshot.configDir === configDir && snapshot.revision === revision
|
||||
? snapshot : { configDir, revision, balances: [] as ProviderBalance[], loading: true, failed: false };
|
||||
const refreshingKeys = Object.keys(manualBusy).flatMap(token => {
|
||||
const [requestScope, source, id] = JSON.parse(token) as string[];
|
||||
return requestScope === scope ? [`${source}:${id}`] : [];
|
||||
});
|
||||
return { ...visible, refreshingKeys, refreshBalance };
|
||||
}
|
||||
|
||||
export function supportsProviderBalance(baseUrl: string) {
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
return url.protocol === "https:" && ["api.deepseek.com", "openrouter.ai", "api.siliconflow.cn", "api.moonshot.cn"].includes(url.hostname);
|
||||
} catch { return false; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ProviderHeader } from "./providerHeaders";
|
||||
|
||||
export const PROVIDER_HEADER_PRESETS = [
|
||||
{
|
||||
id: "claude_code",
|
||||
label: { zh: "Claude Code", en: "Claude Code" },
|
||||
name: "User-Agent",
|
||||
value: "claude-cli/2.1.161 (external, cli)",
|
||||
},
|
||||
{
|
||||
id: "kilo_code",
|
||||
label: { zh: "Kilo Code", en: "Kilo Code" },
|
||||
name: "User-Agent",
|
||||
value: "Kilo-Code/1.0",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type ProviderHeaderPresetId = typeof PROVIDER_HEADER_PRESETS[number]["id"];
|
||||
|
||||
/** Unknown IDs are a no-op. A preset changes one UA row and preserves drafts. */
|
||||
export function applyProviderHeaderPreset(rows: readonly ProviderHeader[], id: string): ProviderHeader[] {
|
||||
const preset = PROVIDER_HEADER_PRESETS.find((entry) => entry.id === id);
|
||||
if (!preset) return [...rows];
|
||||
|
||||
let index = rows.findIndex((row) => row.name.trim().toLowerCase() === "user-agent");
|
||||
if (index < 0) index = rows.findIndex((row) => !row.name.trim() && row.value === "");
|
||||
const header: ProviderHeader = { name: preset.name, value: preset.value, source: "static" };
|
||||
if (index < 0) return [...rows, header];
|
||||
return rows.map((row, position) => position === index ? { ...row, ...header } : row);
|
||||
}
|
||||
|
||||
/** Literal examples describe the named header's purpose; environment rows use their own hint. */
|
||||
export function providerHeaderValuePlaceholder(name: string, lang: "zh" | "en"): string {
|
||||
switch (name.trim().toLowerCase()) {
|
||||
case "user-agent":
|
||||
return lang === "zh" ? "例如 claude-cli/2.1.161 (external, cli)" : "e.g. claude-cli/2.1.161 (external, cli)";
|
||||
case "http-referer":
|
||||
return lang === "zh" ? "例如 https://your-app.example" : "e.g. https://your-app.example";
|
||||
case "x-title":
|
||||
case "x-openrouter-title":
|
||||
return lang === "zh" ? "例如 你的应用名称" : "e.g. Your app name";
|
||||
case "authorization":
|
||||
return lang === "zh" ? "例如 Bearer <访问令牌>" : "e.g. Bearer <access token>";
|
||||
case "x-api-key":
|
||||
case "x-goog-api-key":
|
||||
return lang === "zh" ? "例如 供应商提供的 API Key" : "e.g. API key from your provider";
|
||||
default:
|
||||
return lang === "zh" ? "填写该请求头的值" : "Enter this header's value";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export type ProviderHeaderSource = "static" | "env";
|
||||
|
||||
export type ProviderHeader = {
|
||||
name: string;
|
||||
value: string;
|
||||
source: ProviderHeaderSource;
|
||||
};
|
||||
|
||||
export type ProviderHeaderError = { name?: string; value?: string; source?: string };
|
||||
type Language = "zh" | "en";
|
||||
|
||||
// RFC 9110 field-name tokens. Header values may contain horizontal tabs and
|
||||
// UTF-8 text, matching reqwest's HeaderValue validation on the backend.
|
||||
const HEADER_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
||||
const INVALID_HEADER_VALUE = /[\u0000-\u0008\u000a-\u001f\u007f]/;
|
||||
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
export function createProviderHeader(source: ProviderHeaderSource = "static"): ProviderHeader {
|
||||
return { name: "", value: "", source };
|
||||
}
|
||||
|
||||
export function updateProviderHeader(rows: readonly ProviderHeader[], index: number, patch: Partial<ProviderHeader>): ProviderHeader[] {
|
||||
return rows.map((row, position) => position === index ? { ...row, ...patch } : row);
|
||||
}
|
||||
|
||||
export function removeProviderHeader(rows: readonly ProviderHeader[], index: number): ProviderHeader[] {
|
||||
return rows.filter((_, position) => position !== index);
|
||||
}
|
||||
|
||||
export function validateProviderHeaders(rows: readonly ProviderHeader[], lang: Language = "en") {
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
const name = row.name.trim().toLowerCase();
|
||||
if (name) counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
}
|
||||
const errors: ProviderHeaderError[] = rows.map((row) => {
|
||||
const error: ProviderHeaderError = {};
|
||||
const name = row.name.trim();
|
||||
if (!name) error.name = lang === "zh" ? "请填写 Header 名称" : "Enter a header name";
|
||||
else if (!HEADER_NAME.test(name)) error.name = lang === "zh" ? "名称只能使用英文字母、数字和 HTTP 允许的符号" : "Use letters, numbers and valid HTTP header symbols";
|
||||
else if ((counts.get(name.toLowerCase()) ?? 0) > 1) error.name = lang === "zh" ? "此名称重复(不区分大小写),请删除或重命名" : "Duplicate header name (case insensitive). Remove or rename it";
|
||||
if (row.source === "env") {
|
||||
if (!ENV_NAME.test(row.value)) error.value = lang === "zh" ? "请填写环境变量名,如 PROVIDER_PROJECT;仅允许字母、数字和下划线,且不能以数字开头" : "Enter an environment variable name, e.g. PROVIDER_PROJECT, using letters, numbers and underscores, without a leading digit";
|
||||
} else if (row.source === "static") {
|
||||
if (INVALID_HEADER_VALUE.test(row.value)) error.value = lang === "zh" ? "Header 值不能包含换行或控制字符" : "Header values cannot contain line breaks or control characters";
|
||||
} else {
|
||||
error.source = lang === "zh" ? "请选择固定值或环境变量" : "Choose a literal value or environment variable";
|
||||
}
|
||||
return error;
|
||||
});
|
||||
return { errors, valid: errors.every((error) => !error.name && !error.value && !error.source) };
|
||||
}
|
||||
|
||||
/** Normalize only names; changing a value's whitespace can change its meaning. */
|
||||
export function normalizeProviderHeaders(rows: readonly ProviderHeader[]): ProviderHeader[] {
|
||||
if (!validateProviderHeaders(rows).valid) throw new Error("Invalid provider headers");
|
||||
return rows.map((row) => ({ ...row, name: row.name.trim() }));
|
||||
}
|
||||
|
||||
export function findProviderHeader(rows: readonly ProviderHeader[], name: string): ProviderHeader | undefined {
|
||||
const target = name.trim().toLowerCase();
|
||||
return rows.find((row) => row.name.trim().toLowerCase() === target);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { ProviderHeader } from "./providerHeaders";
|
||||
|
||||
export type ProviderHeadersControlState = {
|
||||
busy: boolean;
|
||||
reading: boolean;
|
||||
valid: boolean;
|
||||
error: "read" | "write" | null;
|
||||
};
|
||||
|
||||
type Options = {
|
||||
read: (configText: string) => Promise<ProviderHeader[]>;
|
||||
update: (configText: string, headers: ProviderHeader[]) => Promise<string>;
|
||||
getConfigText: () => string;
|
||||
isValid: (rows: readonly ProviderHeader[]) => boolean;
|
||||
normalize: (rows: readonly ProviderHeader[]) => ProviderHeader[];
|
||||
onRowsChange: (rows: ProviderHeader[]) => void;
|
||||
onConfigChange: (configText: string) => void;
|
||||
onStateChange: (state: ProviderHeadersControlState) => void;
|
||||
};
|
||||
|
||||
/** Keep local drafts and asynchronous TOML patches within the current form. */
|
||||
export function createProviderHeadersControl(options: Options) {
|
||||
let disposed = false;
|
||||
let request = 0;
|
||||
let revision = 0;
|
||||
let source: string | null = null;
|
||||
let rows: ProviderHeader[] = [];
|
||||
let dirty = false;
|
||||
let published: { text: string; revision: number } | null = null;
|
||||
|
||||
const emit = (state: ProviderHeadersControlState) => {
|
||||
if (!disposed) options.onStateChange(state);
|
||||
};
|
||||
const current = (id: number, text: string) => !disposed && request === id && options.getConfigText() === text;
|
||||
const idle = (valid: boolean) => emit({ busy: false, reading: false, valid, error: null });
|
||||
|
||||
async function read(text: string) {
|
||||
const id = ++request;
|
||||
emit({ busy: true, reading: true, valid: false, error: null });
|
||||
try {
|
||||
const result = await options.read(text);
|
||||
if (!current(id, text)) return;
|
||||
rows = result.map((row) => ({ ...row }));
|
||||
options.onRowsChange(rows);
|
||||
idle(options.isValid(rows));
|
||||
} catch {
|
||||
// IPC errors can contain configuration snippets. Only expose the failed
|
||||
// operation, never the raw exception or a resolved environment value.
|
||||
if (current(id, text)) emit({ busy: false, reading: false, valid: false, error: "read" });
|
||||
}
|
||||
}
|
||||
|
||||
async function write(text: string) {
|
||||
const id = ++request;
|
||||
const editedRevision = revision;
|
||||
const headers = options.normalize(rows);
|
||||
emit({ busy: true, reading: false, valid: true, error: null });
|
||||
try {
|
||||
const result = await options.update(text, headers);
|
||||
if (!current(id, text)) return;
|
||||
dirty = false;
|
||||
if (result !== text) {
|
||||
published = { text: result, revision: editedRevision };
|
||||
options.onConfigChange(result);
|
||||
}
|
||||
idle(true);
|
||||
} catch {
|
||||
if (current(id, text)) emit({ busy: false, reading: false, valid: false, error: "write" });
|
||||
}
|
||||
}
|
||||
|
||||
async function load(configText: string) {
|
||||
if (disposed || source === configText) return;
|
||||
source = configText;
|
||||
++request;
|
||||
const echo = published;
|
||||
published = null;
|
||||
if (echo?.text === configText && echo.revision === revision && !dirty) {
|
||||
idle(options.isValid(rows));
|
||||
return;
|
||||
}
|
||||
if (dirty) {
|
||||
// Reapply only edited header fields to the latest source, preserving an
|
||||
// unrelated model, endpoint or TOML change. Invalid rows stay editable.
|
||||
if (options.isValid(rows)) await write(configText);
|
||||
else idle(false);
|
||||
return;
|
||||
}
|
||||
if (!configText.trim()) {
|
||||
emit({ busy: false, reading: true, valid: false, error: null });
|
||||
return;
|
||||
}
|
||||
await read(configText);
|
||||
}
|
||||
|
||||
async function changeRows(next: readonly ProviderHeader[]) {
|
||||
if (disposed) return;
|
||||
++request;
|
||||
++revision;
|
||||
rows = next.map((row) => ({ ...row }));
|
||||
dirty = true;
|
||||
options.onRowsChange(rows);
|
||||
if (!options.isValid(rows)) {
|
||||
idle(false);
|
||||
return;
|
||||
}
|
||||
await write(options.getConfigText());
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
if (disposed) return;
|
||||
const text = options.getConfigText();
|
||||
if (dirty && options.isValid(rows)) await write(text);
|
||||
else if (!dirty) await read(text);
|
||||
}
|
||||
|
||||
return {
|
||||
load,
|
||||
changeRows,
|
||||
retry,
|
||||
dispose() { disposed = true; ++request; },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export type UpstreamApi = "responses" | "chat_completions" | "anthropic_messages" | "gemini";
|
||||
|
||||
export function providerUpstreamApi(provider: { upstreamApi?: string | null; wireApi?: string }): string {
|
||||
const configured = provider.upstreamApi?.trim().toLowerCase();
|
||||
if (configured === "chat_completions" || configured === "openai_chat") return "chat_completions";
|
||||
if (configured === "anthropic_messages" || configured === "anthropic") return "anthropic_messages";
|
||||
if (configured === "gemini" || configured === "gemini_native") return "gemini";
|
||||
if (configured === "responses") return "responses";
|
||||
if (configured) return configured;
|
||||
const legacy = provider.wireApi?.trim();
|
||||
if (legacy === "chat" || legacy === "chat_completions") return "chat_completions";
|
||||
return !legacy || legacy === "official" ? "responses" : legacy;
|
||||
}
|
||||
|
||||
export function providerRequiresConversion(provider: { upstreamApi?: string | null; wireApi?: string }): boolean {
|
||||
return providerUpstreamApi(provider) !== "responses";
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user