Compare commits

...
23 Commits
Author SHA1 Message Date
yynxxxxxandzjjszmx 4c2cb51c59 Release v0.3.23
Publish the verified large-session streaming and folder-browser directory
identity changes with the original contributor and maintainer attribution.

Co-authored-by: zjjszmx <81135758+zjjszmx@users.noreply.github.com>
2026-10-01 14:31:18 +08:00
yynxxxxxandzjjszmx b796b4d2cd fix(sessions): respect filesystem directory identities in folder browser
Complete PR #52 directory identity handling, folder navigation and scoped
pin storage while retaining its original folder browser contribution.

Co-authored-by: zjjszmx <81135758+zjjszmx@users.noreply.github.com>
2026-10-01 14:24:49 +08:00
zjjszmx 16f64e3982 feat: add virtualized session folder browser 2026-10-01 14:24:27 +08:00
yynxxxxxandzjjszmx 4772287fe1 fix(sessions): stream large logs and preserve sync consistency
Complete the large-rollout work discussed in PR #51 with bounded-memory
streaming, private disk snapshots, guarded index commits and safe rollback.

Co-authored-by: zjjszmx <81135758+zjjszmx@users.noreply.github.com>
2026-10-01 14:24:27 +08:00
sky 163285cf32 docs: record verified v0.3.22 release [skip ci] 2026-09-30 19:17:31 +08:00
sky d5f4a6572b Release v0.3.22 2026-09-30 18:54:40 +08:00
sky bca9d0af55 Fix image input and add provider card ordering and model labels
Resolve model catalog text-only gating, migrate verified legacy catalogs, and clarify the Codex Ultra setting. Persist cross-source provider card order with pointer and keyboard controls and display selected model names. Refs #61, #62, #63, #64, #65.
2026-09-30 18:54:31 +08:00
sky ed5bdad63f Release v0.3.21 2026-09-23 00:17:31 +08:00
sky 47a13c8674 Merge Windows updater migration and installation verification 2026-09-23 00:16:07 +08:00
sky 5dd375ce97 docs: record verified reboot-free Windows migration [skip ci] 2026-09-22 23:44:43 +08:00
sky 16380882a0 fix: normalize absent installer paths without losing directory identity 2026-09-22 23:37:30 +08:00
sky 9c75c4295d fix: compare MSI identity as text before migration 2026-09-22 23:34:06 +08:00
sky b0585e61be test: verify native MSI calls with null SID and preserved logs 2026-09-22 23:22:06 +08:00
sky f2bf8bc73f test: capture native MSI discovery and isolate legacy fixtures 2026-09-22 23:16:15 +08:00
sky 96b24f7b81 fix: continue isolated MSI migration without requiring a reboot 2026-09-22 23:06:23 +08:00
sky 392b2b1bd5 fix: migrate Windows updates to a monitored per-user installer 2026-09-22 22:54:02 +08:00
yynxxxxx 0e28260197 docs: remove misplaced AI credit from README.en.md 2026-09-22 22:51:50 +08:00
yynxxxxx edd399c079 docs: remove misplaced AI credit from README.md 2026-09-22 22:51:47 +08:00
yynxxxxx 460f629925 docs: credit AI development assistants in README.en.md 2026-09-22 22:49:25 +08:00
yynxxxxx 3970bbeb10 docs: credit AI development assistants in README.md 2026-09-22 22:49:22 +08:00
sky 999b66a901 fix: restore Windows routing paths and release v0.3.20 2026-09-18 11:37:45 +08:00
sky 46fece2f44 feat: add routing failover and fix config/session sync for v0.3.19 2026-09-18 11:26:36 +08:00
sky 71b97bf6b4 feat: improve provider compatibility and add exports for v0.3.18 2026-09-17 14:00:00 +08:00
96 changed files with 26826 additions and 1304 deletions
+13 -3
View File
@@ -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:
@@ -316,7 +316,7 @@ jobs:
releaseAssetNamePattern: ${{ matrix.asset_pattern }}
uploadUpdaterJson: true
uploadUpdaterSignatures: true
updaterJsonPreferNsis: false
updaterJsonPreferNsis: true
retryAttempts: 3
- name: Verify updater signatures were generated
@@ -328,6 +328,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 +363,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 +443,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,51 @@
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: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --lib --locked -- --test-threads=1
- 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
+24
View File
@@ -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
+30 -1
View File
@@ -2,7 +2,36 @@
记录 Codex-X 的新功能、体验改进和问题修复。
## [Unreleased]
## [v0.3.23] - 2026-10-01
- 会话管理新增工作文件夹侧栏、文件夹与会话置顶,并使用虚拟列表减少大量会话的渲染开销。
- 会话日志改为流式读取和磁盘快照处理,大日志及超长单条记录也能同步;读取、写入或复查失败时明确报错并执行安全回滚,避免日志、数据库索引与同步状态不一致。
- 修复文件夹路径大小写混淆导致显示错误会话、置顶联动和缓存串用的问题,按实际文件系统身份区分目录或合并同目录别名。
- 置顶按 Codex 配置目录分别保存,文件夹暂时不可读或重建后按原始路径保留置顶。旧 PR 预览版的 v1 置顶缓存不自动迁移,需重新置顶。
## [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
+2 -2
View File
@@ -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>
@@ -335,7 +335,7 @@ Codex-X 现在不只是“几套内置 Prompt”的启动器,而是一个可
- macOS Apple Silicon `.dmg`
- macOS Intel `.dmg`
- Windows `.msi`
- Windows `.exe`(安装到当前用户,后续更新无需管理员权限)
- Windows Portable `.zip`
- Linux `.deb` / `.rpm` / `.AppImage`
- GitHub Releases 自动构建发布
+59
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codex-x",
"version": "0.3.17",
"version": "0.3.23",
"private": true,
"description": "Codex Switch & Instruct desktop manager",
"type": "module",
+79 -1
View File
@@ -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.23"
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,14 @@ dependencies = [
"tauri-plugin-process",
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml_edit 0.22.27",
"ureq",
"windows-sys 0.61.2",
"zip 2.4.2",
"zstd",
]
[[package]]
@@ -1953,6 +1963,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 +2699,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"
@@ -2917,6 +2943,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"
@@ -5606,6 +5656,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"
+11 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "codex-x"
version = "0.3.17"
version = "0.3.23"
description = "Codex Switch & Instruct desktop manager"
authors = ["yynxxxxx"]
license = "MIT"
@@ -38,4 +38,14 @@ 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"
[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
+85 -2
View File
@@ -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 = 8;
struct DatabaseInitializer {
migration_lock: Mutex<()>,
@@ -208,6 +208,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 +228,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()))?;
@@ -392,7 +405,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 +439,66 @@ 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_one_database_migrates_new_metadata_tables_without_data_loss() {
let path = test_db_path("skills-mcp-notes-migration");
+453
View File
@@ -0,0 +1,453 @@
//! 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, 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);
const DOWNLOAD_TIMEOUT_MS: u64 = 10 * 60 * 1000;
#[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()),
}
}
/// 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)
}
/// 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 update = webview
.resources_table()
.get::<Update>(update_rid)
.map_err(|_| {
failure(
FailureStage::Prepare,
"更新信息已失效,请重新检查更新。",
None,
)
})?;
let mut update = (*update).clone();
// The 15s 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 finished = AtomicBool::new(false);
let mut started = false;
let bytes = 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) {
log.record("signature_verification_failed");
failure(
FailureStage::Verify,
"更新包校验未通过,安装尚未启动。请重新下载或前往下载页。",
Some(&log),
)
} else {
log.record("download_failed");
failure(
FailureStage::Download,
"更新下载未完成或连接超时。请检查网络后重试。",
Some(&log),
)
}
})?;
log.record("signature_verified");
#[cfg(target_os = "windows")]
{
let app = webview.app_handle().clone();
let result = install_windows(app, bytes, on_event, log).await;
if result.is_ok() {
_lease.handoff();
}
result
}
#[cfg(not(target_os = "windows"))]
{
let _ = on_event.send(AppUpdateEvent::Installing);
log.record("install_started");
tauri::async_runtime::spawn_blocking(move || {
update.install(bytes).map_err(|_| {
log.record("install_failed");
failure(
FailureStage::Install,
"安装更新未完成,请重试或前往下载页安装。",
Some(&log),
)
})?;
log.record("install_finished_restart_required");
Ok(InstallResult {
restart_required: true,
})
})
.await
.map_err(|_| {
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,
) -> 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");
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.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.
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 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
-2
View File
@@ -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,37 @@ 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 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,14 @@
mod circuit_breaker;
pub(crate) mod config;
mod controller;
pub(crate) mod native_official;
mod proxy;
#[cfg(any(test, target_os = "windows"))]
pub(crate) use controller::resume_after_failed_update;
pub(crate) use controller::{
attach_app_handle, direct_document, get_status, initialize, recover_stale_route,
refresh_saved_routes, reset_health, save_settings, shutdown_all, with_provider_change,
FailoverSettings, FailoverStatus,
};
@@ -0,0 +1,577 @@
//! 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 {
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(&current, "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(&current, "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,824 @@
use super::*;
use std::fs;
use std::sync::atomic::AtomicU64;
struct Fixture {
dir: PathBuf,
providers: Vec<SavedProvider>,
original: String,
port: u16,
}
impl Fixture {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let number = NEXT.fetch_add(1, Ordering::Relaxed);
let tag = format!("failover-test-{}-{number}", std::process::id());
let dir = std::env::temp_dir().join(&tag);
fs::create_dir_all(&dir).unwrap();
let dir = dir.canonicalize().unwrap();
let mut providers = Vec::new();
for index in 0..3 {
let provider = SavedProvider {
id: format!("{tag}-{index}"),
provider_name: format!("Provider {index}"),
base_url: format!("https://{tag}-{index}.example.test/v1"),
model: "same-model".into(),
api_key: Some(format!("fixture-provider-secret-{index}")),
toml_config: None,
wire_api: "responses".into(),
requires_openai_auth: false,
model_mappings: vec![],
};
providers.push(crate::providers::save_provider_inner(provider).unwrap());
}
let primary = &providers[0];
let original = format!("# preserve user config\nmodel_provider = \"custom\"\nmodel = \"same-model\"\nmodel_reasoning_effort = \"high\"\n\n[model_providers.custom]\nname = \"Provider 0\"\nbase_url = {:?}\nwire_api = \"responses\"\nrequires_openai_auth = false\nsupports_websockets = true\nexperimental_bearer_token = {:?}\nrequest_max_retries = 4\n\n[model_providers.custom.http_headers]\nx-fixture = \"primary-header\"\n\n[mcp_servers.fixture]\ncommand = \"fixture-tool\"\n", primary.base_url, primary.api_key.as_deref().unwrap());
fs::write(crate::config_path(&dir), &original).unwrap();
fs::write(
dir.join("auth.json"),
"{\"OPENAI_API_KEY\":\"untouched-official-auth-fixture\"}",
)
.unwrap();
crate::providers::remember_active_provider_on_connection(
&crate::app_db::open().unwrap(),
&dir,
&primary.id,
)
.unwrap();
Self {
dir,
providers,
original,
port: free_port(),
}
}
fn scope(&self) -> Option<String> {
Some(self.dir.display().to_string())
}
fn settings(&self) -> FailoverSettings {
FailoverSettings {
router_enabled: true,
takeover_enabled: true,
auto_failover_enabled: true,
listen_port: self.port,
provider_ids: vec![self.providers[0].id.clone(), self.providers[1].id.clone()],
..Default::default()
}
}
fn text(&self) -> String {
fs::read_to_string(crate::config_path(&self.dir)).unwrap()
}
fn enable(&self) -> FailoverStatus {
save_settings(self.scope(), self.settings()).unwrap()
}
}
impl Drop for Fixture {
fn drop(&mut self) {
SHUTTING_DOWN.store(false, Ordering::Release);
// If a test deliberately left invalid TOML, repair only its private fixture.
if let Ok(record) = load_record(&self.dir) {
if let Ok(mut runtimes) = lock_manager() {
if let Some(runtime) = runtimes.remove(&self.dir) {
runtime.proxy.shutdown();
}
}
let _ = record;
}
if let Ok(conn) = crate::app_db::open() {
let _ = conn.execute(
"DELETE FROM provider_failover WHERE codex_dir = ?1",
[normalized_path_scope(&self.dir)],
);
let _ = conn.execute(
"DELETE FROM active_provider_selections WHERE codex_dir = ?1",
[normalized_path_scope(&self.dir)],
);
for provider in &self.providers {
let _ = conn.execute("DELETE FROM providers WHERE id = ?1", [&provider.id]);
}
}
let _ = fs::remove_dir_all(&self.dir);
}
}
fn free_port() -> u16 {
std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.unwrap()
.local_addr()
.unwrap()
.port()
}
fn stop(fixture: &Fixture) {
let mut settings = load_record(&fixture.dir).unwrap().settings;
settings.router_enabled = false;
save_settings(fixture.scope(), settings).unwrap();
}
fn parsed(fixture: &Fixture) -> DocumentMut {
fixture.text().parse().unwrap()
}
#[test]
fn listener_takeover_and_auto_switches_are_independent() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let auth = fs::read(fixture.dir.join("auth.json")).unwrap();
let mut settings = fixture.settings();
settings.takeover_enabled = false;
settings.auto_failover_enabled = false;
let listening = save_settings(fixture.scope(), settings.clone()).unwrap();
assert!(listening.running);
assert!(!listening.takeover_active);
assert!(!listening.auto_failover_active);
assert_eq!(fixture.text(), fixture.original);
settings.takeover_enabled = true;
let single = save_settings(fixture.scope(), settings.clone()).unwrap();
assert!(single.takeover_active);
assert!(!single.auto_failover_active);
assert_eq!(single.runtime.providers.len(), 1);
assert_eq!(single.primary.as_ref().unwrap().id, fixture.providers[0].id);
settings.auto_failover_enabled = true;
let automatic = save_settings(fixture.scope(), settings).unwrap();
assert!(automatic.auto_failover_active);
assert_eq!(automatic.runtime.providers.len(), 2);
assert_eq!(fs::read(fixture.dir.join("auth.json")).unwrap(), auth);
stop(&fixture);
let stopped = get_status(fixture.scope()).unwrap();
assert!(!stopped.running && !stopped.takeover_active);
assert!(stopped.settings.auto_failover_enabled);
assert_eq!(stopped.settings.provider_ids.len(), 2);
assert_eq!(
parsed(&fixture)["mcp_servers"]["fixture"]["command"].as_str(),
Some("fixture-tool")
);
assert_eq!(
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
Some(fixture.providers[0].base_url.as_str())
);
}
#[test]
fn enabling_auto_switches_to_p1_even_if_current_is_elsewhere() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let mut settings = fixture.settings();
settings.provider_ids = vec![
fixture.providers[2].id.clone(),
fixture.providers[0].id.clone(),
];
let status = save_settings(fixture.scope(), settings).unwrap();
assert!(status.auto_failover_active);
assert_eq!(status.primary.as_ref().unwrap().id, fixture.providers[2].id);
assert_eq!(status.runtime.providers[0].id, fixture.providers[2].id);
assert_eq!(status.settings.provider_ids[0], fixture.providers[2].id);
stop(&fixture);
assert_eq!(
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
Some(fixture.providers[2].base_url.as_str())
);
}
#[test]
fn a_single_provider_queue_and_an_empty_running_queue_are_valid() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let mut settings = fixture.settings();
settings.provider_ids.clear();
let single = save_settings(fixture.scope(), settings).unwrap();
assert_eq!(
single.settings.provider_ids,
vec![fixture.providers[0].id.clone()]
);
assert!(single.auto_failover_active);
let mut settings = single.settings;
settings.provider_ids.clear();
let empty = save_settings(fixture.scope(), settings).unwrap();
assert!(empty.running && empty.auto_failover_active);
assert!(empty.runtime.providers.is_empty());
}
#[test]
fn queue_accepts_different_models_and_can_be_prepared_while_stopped() {
let _guard = crate::app_db::test_db_guard();
let mut fixture = Fixture::new();
fixture.providers[1].model = "another-model".into();
crate::providers::save_provider_inner(fixture.providers[1].clone()).unwrap();
let status = get_status(fixture.scope()).unwrap();
assert!(
status
.providers
.iter()
.find(|p| p.id == fixture.providers[1].id)
.unwrap()
.eligible
);
let mut settings = fixture.settings();
settings.router_enabled = false;
settings.takeover_enabled = false;
settings.auto_failover_enabled = false;
let saved = save_settings(fixture.scope(), settings).unwrap();
assert!(!saved.running);
assert_eq!(fixture.text(), fixture.original);
assert_eq!(saved.settings.provider_ids.len(), 2);
fixture.enable();
}
#[test]
fn settings_and_scope_validation_have_no_live_side_effects() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let mut invalid = fixture.settings();
invalid.listen_port = 1000;
assert!(save_settings(fixture.scope(), invalid).is_err());
let mut invalid = fixture.settings();
invalid.provider_ids.push(invalid.provider_ids[0].clone());
assert!(save_settings(fixture.scope(), invalid).is_err());
let mut invalid = fixture.settings();
invalid.provider_ids = vec!["official:openai-official".into()];
assert!(save_settings(fixture.scope(), invalid).is_err());
let mut invalid = fixture.settings();
invalid.takeover_enabled = false;
assert!(save_settings(fixture.scope(), invalid).is_err());
assert_eq!(fixture.text(), fixture.original);
}
#[test]
fn failed_bind_and_missing_p1_leave_configuration_and_settings_unchanged() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let blocker =
std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, fixture.port)).unwrap();
assert!(save_settings(fixture.scope(), fixture.settings()).is_err());
assert_eq!(fixture.text(), fixture.original);
assert!(!get_status(fixture.scope()).unwrap().running);
drop(blocker);
let mut settings = fixture.settings();
settings.provider_ids = vec!["missing-p1".into()];
assert!(save_settings(fixture.scope(), settings).is_err());
assert_eq!(fixture.text(), fixture.original);
assert!(!get_status(fixture.scope()).unwrap().settings.router_enabled);
}
#[test]
fn queue_and_tuning_updates_keep_the_endpoint_and_update_all_parameters() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let before = fixture.text();
let mut settings = fixture.settings();
settings.provider_ids.reverse();
settings.tuning = RoutingTuning {
max_retries: 8,
streaming_first_byte_timeout: 10,
streaming_idle_timeout: 0,
non_streaming_timeout: 900,
circuit_failure_threshold: 7,
circuit_success_threshold: 4,
circuit_timeout_seconds: 45,
circuit_error_rate_threshold: 0.75,
circuit_min_requests: 15,
};
let status = save_settings(fixture.scope(), settings.clone()).unwrap();
assert_eq!(status.settings, settings);
assert_eq!(fixture.text(), before);
assert_eq!(status.runtime.providers[0].id, fixture.providers[1].id);
let mut bad = status.settings;
bad.listen_port = free_port();
assert!(save_settings(fixture.scope(), bad).is_err());
assert_eq!(fixture.text(), before);
}
#[test]
fn manual_switch_keeps_routing_and_auto_but_official_never_joins_api_queue() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
with_provider_change(fixture.scope(), || {
crate::providers::activate_saved_provider_inner(
fixture.scope(),
fixture.providers[2].id.clone(),
)
})
.unwrap();
let switched = get_status(fixture.scope()).unwrap();
assert!(switched.running && switched.auto_failover_active);
assert_eq!(switched.primary.unwrap().id, fixture.providers[2].id);
with_provider_change(fixture.scope(), || {
crate::providers::official_profiles::switch_official_profile_inner(
fixture.scope(),
crate::providers::official_profiles::DEFAULT_OFFICIAL_PROFILE_ID.into(),
)
})
.unwrap();
let official = get_status(fixture.scope()).unwrap();
assert!(official.running && official.takeover_active);
assert!(!official.auto_failover_active);
assert!(official.settings.auto_failover_enabled);
assert!(official.primary.unwrap().official);
assert!(official
.runtime
.providers
.iter()
.all(|p| p.id.starts_with("official:")));
assert!(official.providers.iter().all(|p| !p.official));
}
#[test]
fn official_takeover_preserves_auth_and_can_restore_a_builtin_route() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let original = "model='official-model'\n[mcp_servers.test]\ncommand='tool'\n";
fs::write(crate::config_path(&fixture.dir), original).unwrap();
let auth = fs::read(fixture.dir.join("auth.json")).unwrap();
let mut settings = fixture.settings();
settings.auto_failover_enabled = false;
settings.provider_ids.clear();
let status = save_settings(fixture.scope(), settings).unwrap();
assert!(status.primary.unwrap().official);
assert!(status.takeover_active && !status.auto_failover_active);
let doc = parsed(&fixture);
assert_eq!(
doc["model_providers"]["openai"]["requires_openai_auth"].as_bool(),
Some(true)
);
assert!(doc["model_providers"]["openai"]
.get("experimental_bearer_token")
.is_none());
assert!(doc["model_providers"]["openai"]["http_headers"]
.get(ROUTE_TOKEN_HEADER)
.is_some());
assert_eq!(fs::read(fixture.dir.join("auth.json")).unwrap(), auth);
stop(&fixture);
assert!(parsed(&fixture).get("model_providers").is_none());
assert_eq!(
parsed(&fixture)["mcp_servers"]["test"]["command"].as_str(),
Some("tool")
);
assert_eq!(fs::read(fixture.dir.join("auth.json")).unwrap(), auth);
}
#[test]
fn failed_manual_switch_reattaches_original_route() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let error = with_provider_change::<()>(fixture.scope(), || {
Err(CodexxError::Config("expected switch failure".into()))
})
.unwrap_err();
assert!(error.to_string().contains("expected switch failure"));
let status = get_status(fixture.scope()).unwrap();
assert!(status.running && status.takeover_active);
assert_eq!(status.primary.unwrap().id, fixture.providers[0].id);
}
#[test]
fn restore_keeps_external_edits_and_does_not_leave_local_headers() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let mut doc = parsed(&fixture);
doc["model_reasoning_effort"] = value("max");
doc["model_providers"]["custom"]["request_max_retries"] = value(9);
doc["model_providers"]["custom"]["external_new_setting"] = value("preserved");
fs::write(crate::config_path(&fixture.dir), doc.to_string()).unwrap();
stop(&fixture);
let restored = parsed(&fixture);
assert_eq!(restored["model_reasoning_effort"].as_str(), Some("max"));
assert_eq!(
restored["model_providers"]["custom"]["request_max_retries"].as_integer(),
Some(9)
);
assert_eq!(
restored["model_providers"]["custom"]["external_new_setting"].as_str(),
Some("preserved")
);
assert_eq!(
restored["model_providers"]["custom"]["http_headers"]["x-fixture"].as_str(),
Some("primary-header")
);
assert!(!fixture.text().contains(ROUTE_GENERATION_HEADER));
}
#[test]
fn external_provider_change_detaches_without_stopping_the_listener() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let external = fixture
.original
.replace(
&fixture.providers[0].base_url,
&fixture.providers[2].base_url,
)
.replace("fixture-provider-secret-0", "fixture-provider-secret-2");
fs::write(crate::config_path(&fixture.dir), &external).unwrap();
let status = get_status(fixture.scope()).unwrap();
assert!(status.running && !status.takeover_active);
assert!(!status.settings.takeover_enabled);
assert_eq!(fixture.text(), external);
}
#[test]
fn failed_restore_keeps_the_listener_alive() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let managed = fixture.text();
fs::write(crate::config_path(&fixture.dir), "invalid = [").unwrap();
let mut off = fixture.settings();
off.router_enabled = false;
assert!(save_settings(fixture.scope(), off).is_err());
assert!(lock_manager().unwrap().contains_key(&fixture.dir));
fs::write(crate::config_path(&fixture.dir), managed).unwrap();
stop(&fixture);
}
#[test]
fn failed_update_handoff_resumes_saved_routes_and_reopens_mutation_gate() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let before = fixture.enable();
let auth = fs::read(crate::auth_path(&fixture.dir)).unwrap();
shutdown_all().unwrap();
assert!(!get_status(fixture.scope()).unwrap().running);
assert!(!fixture.text().contains("http://127.0.0.1:"));
resume_after_failed_update().unwrap();
let after = get_status(fixture.scope()).unwrap();
assert!(after.running && after.takeover_active);
assert_eq!(after.settings, before.settings);
assert_eq!(fs::read(crate::auth_path(&fixture.dir)).unwrap(), auth);
stop(&fixture);
assert!(!get_status(fixture.scope()).unwrap().running);
}
#[test]
fn failed_update_recovery_reports_busy_port_without_erasing_saved_preferences() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let before = fixture.enable();
shutdown_all().unwrap();
let occupied =
std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, fixture.port)).unwrap();
assert!(resume_after_failed_update().is_err());
let status = get_status(fixture.scope()).unwrap();
assert!(!status.running);
assert_eq!(status.settings, before.settings);
assert!(!fixture.text().contains("http://127.0.0.1:"));
drop(occupied);
resume_after_failed_update().unwrap();
assert!(get_status(fixture.scope()).unwrap().takeover_active);
}
#[test]
fn persisted_directory_scopes_resolve_to_the_interactive_runtime_key() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let scope = normalized_path_scope(&fixture.dir);
let stored = stored_directories().unwrap();
let restored = stored
.iter()
.find(|dir| normalized_path_scope(dir) == scope)
.unwrap();
assert_eq!(*restored, directory(fixture.scope()).unwrap());
assert!(lock_manager().unwrap().contains_key(restored));
}
#[test]
fn exit_restores_and_rejects_queued_enabling_then_restart_resumes_same_port() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
shutdown_all().unwrap();
assert!(!fixture.text().contains("http://127.0.0.1:"));
let record = load_record(&fixture.dir).unwrap();
assert!(record.settings.router_enabled && record.settings.takeover_enabled);
assert!(save_settings(fixture.scope(), fixture.settings()).is_err());
SHUTTING_DOWN.store(false, Ordering::Release);
initialize().unwrap();
let status = get_status(fixture.scope()).unwrap();
assert!(status.takeover_active);
assert_eq!(status.settings.listen_port, fixture.port);
}
#[test]
fn old_managed_backups_keep_their_original_provider_after_later_takeovers() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let old = fixture.text();
with_provider_change(fixture.scope(), || {
crate::providers::activate_saved_provider_inner(
fixture.scope(),
fixture.providers[2].id.clone(),
)
})
.unwrap();
stop(&fixture);
fs::write(crate::config_path(&fixture.dir), old).unwrap();
recover_stale_route(fixture.scope()).unwrap();
assert_eq!(
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
Some(fixture.providers[0].base_url.as_str())
);
}
#[test]
fn old_enabled_backups_settings_migrate_to_full_p1_queue() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let old = serde_json::json!({"settings":{"enabled":true,"providerIds":[fixture.providers[1].id]},"journals":[{"primaryId":fixture.providers[0].id,"providerKey":"custom","originalTable":"name='old'","port":fixture.port,"token":"old-only-test-token"}]});
crate::app_db::open()
.unwrap()
.execute(
"INSERT INTO provider_failover(codex_dir,record_json) VALUES(?1,?2)",
params![normalized_path_scope(&fixture.dir), old.to_string()],
)
.unwrap();
let migrated = load_record(&fixture.dir).unwrap();
assert_eq!(migrated.version, 2);
assert!(
migrated.settings.router_enabled
&& migrated.settings.takeover_enabled
&& migrated.settings.auto_failover_enabled
);
assert_eq!(
migrated.settings.provider_ids,
vec![
fixture.providers[0].id.clone(),
fixture.providers[1].id.clone()
]
);
assert_eq!(migrated.settings.tuning, RoutingTuning::default());
}
#[test]
fn status_never_serializes_provider_credentials_or_local_tokens() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let status = fixture.enable();
let text = serde_json::to_string(&status).unwrap();
assert!(!text.contains("fixture-provider-secret"));
let record = load_record(&fixture.dir).unwrap();
assert!(!text.contains(&record.journals.last().unwrap().token));
}
#[test]
fn deleting_one_queue_member_keeps_the_rest_and_listener() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
crate::providers::delete_provider_inner(&fixture.providers[1].id).unwrap();
refresh_saved_routes().unwrap();
let status = get_status(fixture.scope()).unwrap();
assert!(status.running && status.takeover_active);
assert_eq!(status.runtime.providers.len(), 1);
assert_eq!(status.runtime.providers[0].id, fixture.providers[0].id);
}
#[test]
fn listener_can_run_without_a_configured_provider() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fs::remove_file(crate::config_path(&fixture.dir)).unwrap();
let status = save_settings(
fixture.scope(),
FailoverSettings {
router_enabled: true,
listen_port: fixture.port,
..Default::default()
},
)
.unwrap();
assert!(status.running && !status.takeover_active);
assert!(!crate::config_path(&fixture.dir).exists());
}
#[test]
fn successful_fallback_updates_logical_provider_and_preserves_old_backup_identity() {
let _guard = crate::app_db::test_db_guard();
let mut fixture = Fixture::new();
fixture.providers[1].model = "different-default".into();
crate::providers::save_provider_inner(fixture.providers[1].clone()).unwrap();
fixture.enable();
let earlier_backup = fixture.text();
let mut runtimes = lock_manager().unwrap();
let runtime = runtimes.get(&fixture.dir).unwrap();
let notice = SelectionNotice {
dir: fixture.dir.clone(),
instance_id: runtime.instance_id,
token: runtime.token.clone(),
event: ProxySelectionEvent {
provider_id: fixture.providers[1].id.clone(),
revision: runtime.proxy.revision(),
},
};
record_selection(notice, &mut runtimes).unwrap();
drop(runtimes);
let selected = get_status(fixture.scope()).unwrap();
assert_eq!(selected.primary.unwrap().id, fixture.providers[1].id);
assert_eq!(parsed(&fixture)["model"].as_str(), Some("same-model"));
let direct = direct_document(&fixture.dir, &parsed(&fixture)).unwrap();
assert_eq!(
direct["model_providers"]["custom"]["name"].as_str(),
Some("Provider 1")
);
assert_eq!(
direct["model_providers"]["custom"]["experimental_bearer_token"].as_str(),
Some("fixture-provider-secret-1")
);
stop(&fixture);
assert_eq!(
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
Some(fixture.providers[1].base_url.as_str())
);
fs::write(crate::config_path(&fixture.dir), earlier_backup).unwrap();
recover_stale_route(fixture.scope()).unwrap();
assert_eq!(
parsed(&fixture)["model_providers"]["custom"]["base_url"].as_str(),
Some(fixture.providers[0].base_url.as_str())
);
}
#[test]
fn late_success_cannot_override_a_manual_switch_or_a_changed_queue() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let notice = {
let runtimes = lock_manager().unwrap();
let runtime = runtimes.get(&fixture.dir).unwrap();
SelectionNotice {
dir: fixture.dir.clone(),
instance_id: runtime.instance_id,
token: runtime.token.clone(),
event: ProxySelectionEvent {
provider_id: fixture.providers[1].id.clone(),
revision: runtime.proxy.revision(),
},
}
};
with_provider_change(fixture.scope(), || {
crate::providers::activate_saved_provider_inner(
fixture.scope(),
fixture.providers[2].id.clone(),
)
})
.unwrap();
record_selection(notice, &mut lock_manager().unwrap()).unwrap();
assert_eq!(
get_status(fixture.scope()).unwrap().primary.unwrap().id,
fixture.providers[2].id
);
}
#[test]
fn a_late_success_from_a_stopped_listener_cannot_change_the_restarted_route() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let notice = {
let runtimes = lock_manager().unwrap();
let runtime = runtimes.get(&fixture.dir).unwrap();
SelectionNotice {
dir: fixture.dir.clone(),
instance_id: runtime.instance_id,
token: runtime.token.clone(),
event: ProxySelectionEvent {
provider_id: fixture.providers[1].id.clone(),
revision: runtime.proxy.revision(),
},
}
};
stop(&fixture);
fixture.enable();
let mut runtimes = lock_manager().unwrap();
let restarted = runtimes.get(&fixture.dir).unwrap();
assert_eq!(notice.token, restarted.token);
assert_eq!(notice.event.revision, restarted.proxy.revision());
assert_ne!(notice.instance_id, restarted.instance_id);
record_selection(notice, &mut runtimes).unwrap();
drop(runtimes);
assert_eq!(
get_status(fixture.scope()).unwrap().primary.unwrap().id,
fixture.providers[0].id
);
}
#[test]
fn a_journal_write_failure_after_p1_rolls_back_files_and_common_recovery_marker() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
let before = FileCheckpoint::capture(&fixture.dir).unwrap();
let target = &fixture.providers[2].id;
crate::app_db::open().unwrap().execute_batch(&format!("CREATE TRIGGER reject_p1_journal BEFORE UPDATE ON provider_failover WHEN json_extract(NEW.record_json,'$.journals[#-1].primaryId') = '{target}' BEGIN SELECT RAISE(ABORT,'fixture-journal-failure'); END;")).unwrap();
let mut settings = fixture.settings();
settings.provider_ids = vec![target.clone()];
let result = save_settings(fixture.scope(), settings);
crate::app_db::open()
.unwrap()
.execute_batch("DROP TRIGGER reject_p1_journal")
.unwrap();
assert!(result.is_err());
let after = FileCheckpoint::capture(&fixture.dir).unwrap();
assert_eq!(before.config, after.config);
assert_eq!(before.auth, after.auth);
assert_eq!(before.selected, after.selected);
assert_eq!(before.common_handled, after.common_handled);
assert!(!get_status(fixture.scope()).unwrap().running);
}
#[test]
fn p1_journal_failure_restores_official_endpoint_before_oauth() {
let _guard = crate::app_db::test_db_guard();
let mut fixture = Fixture::new();
fs::write(crate::config_path(&fixture.dir), "model='official-model'\n").unwrap();
fs::write(crate::auth_path(&fixture.dir),r#"{"auth_mode":"chatgpt","tokens":{"access_token":"fixture-oauth","account_id":"fixture-account"}}"#).unwrap();
fixture.providers[2].requires_openai_auth = true;
fixture.providers[2] =
crate::providers::save_provider_inner(fixture.providers[2].clone()).unwrap();
let before = FileCheckpoint::capture(&fixture.dir).unwrap();
let mut settings = fixture.settings();
settings.provider_ids = vec![fixture.providers[2].id.clone()];
// Verify the prepared direct route really reads the global auth file. This
// is the interval a failed journal write must undo before publishing OAuth.
switch_to_p1(&fixture.dir, &mut settings).unwrap();
let prepared = FileCheckpoint::capture(&fixture.dir).unwrap();
let doc = parsed(&fixture);
let key = doc["model_provider"].as_str().unwrap();
assert_eq!(
doc["model_providers"][key]["requires_openai_auth"].as_bool(),
Some(true)
);
assert!(doc["model_providers"][key]
.get("experimental_bearer_token")
.is_none());
assert_eq!(
crate::providers::replacement_write_order(
prepared.config.as_deref(),
before.config.as_deref()
),
crate::providers::LiveWriteOrder::ConfigFirst
);
before.restore(&fixture.dir, &prepared).unwrap();
let target = &fixture.providers[2].id;
crate::app_db::open().unwrap().execute_batch(&format!("CREATE TRIGGER reject_official_p1 BEFORE UPDATE ON provider_failover WHEN json_extract(NEW.record_json,'$.journals[#-1].primaryId') = '{target}' BEGIN SELECT RAISE(ABORT,'fixture-journal-failure'); END;")).unwrap();
let result = save_settings(fixture.scope(), settings);
crate::app_db::open()
.unwrap()
.execute_batch("DROP TRIGGER reject_official_p1")
.unwrap();
assert!(result
.err()
.unwrap()
.to_string()
.contains("fixture-journal-failure"));
let after = FileCheckpoint::capture(&fixture.dir).unwrap();
assert_eq!(before.config, after.config);
assert_eq!(before.auth, after.auth);
assert_eq!(before.selected, after.selected);
assert_eq!(before.common_handled, after.common_handled);
assert!(!get_status(fixture.scope()).unwrap().running);
}
#[test]
fn invalid_persisted_settings_restore_direct_without_restarting_listener() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
let runtime = lock_manager().unwrap().remove(&fixture.dir).unwrap();
runtime.proxy.shutdown();
let mut record = load_record(&fixture.dir).unwrap();
record.settings.tuning.max_retries = 11;
save_record(&fixture.dir, &record).unwrap();
initialize().unwrap();
let status = get_status(fixture.scope()).unwrap();
assert!(!status.running && !status.takeover_active);
assert!(!fixture.text().contains("http://127.0.0.1:"));
assert!(status.message.unwrap().contains("路由设置需要检查"));
}
#[test]
fn startup_status_write_failure_keeps_the_attached_listener_alive() {
let _guard = crate::app_db::test_db_guard();
let fixture = Fixture::new();
fixture.enable();
shutdown_all().unwrap();
let conn = crate::app_db::open().unwrap();
conn.execute_batch("CREATE TRIGGER reject_redundant_status BEFORE UPDATE ON provider_failover WHEN NEW.record_json=OLD.record_json BEGIN SELECT RAISE(ABORT,'fixture-status-failure'); END;").unwrap();
SHUTTING_DOWN.store(false, Ordering::Release);
let result = initialize();
conn.execute_batch("DROP TRIGGER reject_redundant_status")
.unwrap();
assert!(result
.unwrap_err()
.to_string()
.contains("fixture-status-failure"));
let status = get_status(fixture.scope()).unwrap();
assert!(status.running && status.takeover_active);
assert!(fixture.text().contains("http://127.0.0.1:"));
assert!(std::net::TcpStream::connect((std::net::Ipv4Addr::LOCALHOST, fixture.port)).is_ok());
stop(&fixture);
}
@@ -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();
}
+303 -69
View File
@@ -14,13 +14,16 @@ use std::path::{Path, PathBuf};
use std::process::Command;
mod app_db;
mod app_update;
mod backups;
mod ccswitch;
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;
@@ -33,6 +36,7 @@ mod skills_mcp;
mod sqlite_utils;
mod state;
mod toml_utils;
mod transfers;
mod updates;
mod usage;
@@ -92,8 +96,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 +238,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 +542,38 @@ 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 get_session_sync_status(
config_dir: Option<String>,
@@ -574,9 +616,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> {
@@ -896,6 +944,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 +1011,38 @@ 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}")))?
}
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 +1088,46 @@ 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),
))
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 +1137,25 @@ 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 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 +1163,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 +1206,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 +1264,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 +1305,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 +1325,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 +1340,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 +1530,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
@@ -1398,13 +1594,16 @@ async fn fetch_provider_models(
#[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 +1694,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 +1741,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 +1799,19 @@ 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::install_app_update,
get_about_info,
restart_codex_desktop,
check_app_update,
@@ -1604,8 +1822,14 @@ 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,
get_session_sync_status,
sync_sessions_provider,
delete_codex_sessions,
@@ -1621,15 +1845,22 @@ 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,
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 +1892,6 @@ pub fn run() {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod failover_integration_tests;
File diff suppressed because it is too large Load Diff
+14 -10
View File
@@ -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;
@@ -26,12 +27,15 @@ pub(crate) use connection::{
fetch_provider_models_inner, test_provider_connection_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,12 +59,12 @@ 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,
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, 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,
upsert_ccswitch_provider_on_connection, DuplicateProviderResult, ProviderStoreRollback,
ProviderUpsertKind, SavedProvider,
};
@@ -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();
+261 -2
View File
@@ -1,7 +1,8 @@
use super::{clear_provider_selections_on_connection, open_store as open_db};
use crate::error::{CodexxError, Result};
use crate::{now_rfc3339, sanitize_id};
use rusqlite::{params, Connection, TransactionBehavior};
use crate::paths::normalized_path_scope;
use crate::{now_rfc3339, resolve_codex_dir, sanitize_id};
use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use toml_edit::{value, DocumentMut};
@@ -411,6 +412,94 @@ pub(crate) fn list_saved_providers_inner() -> Result<Vec<SavedProvider>> {
list_saved_providers_on_connection(&conn)
}
fn validate_provider_card_order(order: &[String]) -> Result<()> {
if order.len() > 4096 {
return Err(CodexxError::Config("供应商排序项过多".to_string()));
}
let mut seen = HashSet::with_capacity(order.len());
for key in order {
let valid_key = key.split_once(':').is_some_and(|(source, id)| {
matches!(source, "official" | "local" | "detected")
&& !id.trim().is_empty()
&& id.trim() == id
&& key.len() <= 512
&& !key.chars().any(char::is_control)
});
if !valid_key {
return Err(CodexxError::Config(
"供应商排序包含无效卡片标识".to_string(),
));
}
if !seen.insert(key) {
return Err(CodexxError::Config("供应商排序包含重复卡片".to_string()));
}
}
Ok(())
}
fn get_provider_order_on_connection(
conn: &Connection,
codex_dir: &std::path::Path,
) -> Result<Vec<String>> {
let json: Option<String> = conn
.query_row(
"SELECT order_json FROM provider_card_orders WHERE codex_dir = ?1",
[normalized_path_scope(codex_dir)],
|row| row.get(0),
)
.optional()
.map_err(|error| CodexxError::Database(error.to_string()))?;
json.map(|json| {
serde_json::from_str(&json)
.map_err(|error| CodexxError::Database(format!("读取供应商排序失败: {error}")))
})
.transpose()
.map(Option::unwrap_or_default)
}
pub(crate) fn get_provider_order_inner(config_dir: Option<String>) -> Result<Vec<String>> {
let codex_dir = resolve_codex_dir(config_dir)?;
get_provider_order_on_connection(&open_db()?, &codex_dir)
}
fn save_provider_order_on_connection(
conn: &mut Connection,
codex_dir: &std::path::Path,
order: Vec<String>,
) -> Result<Vec<String>> {
validate_provider_card_order(&order)?;
let json = serde_json::to_string(&order)
.map_err(|error| CodexxError::Config(format!("序列化供应商排序失败: {error}")))?;
let transaction = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(|error| CodexxError::Database(error.to_string()))?;
// Keep the preference independent of provider records. A refresh, import,
// edit, or temporary detected row must not change the user's chosen order;
// the UI ignores absent keys and appends newly added cards.
transaction
.execute(
"INSERT INTO provider_card_orders (codex_dir, order_json, updated_at)
VALUES (?1, ?2, ?3)
ON CONFLICT(codex_dir) DO UPDATE SET
order_json = excluded.order_json,
updated_at = excluded.updated_at",
params![normalized_path_scope(codex_dir), json, now_rfc3339()],
)
.map_err(|error| CodexxError::Database(error.to_string()))?;
transaction
.commit()
.map_err(|error| CodexxError::Database(error.to_string()))?;
Ok(order)
}
pub(crate) fn save_provider_order_inner(
config_dir: Option<String>,
order: Vec<String>,
) -> Result<Vec<String>> {
let codex_dir = resolve_codex_dir(config_dir)?;
save_provider_order_on_connection(&mut open_db()?, &codex_dir, order)
}
pub(crate) fn provider_by_id_on_connection(
conn: &Connection,
id: &str,
@@ -781,6 +870,7 @@ fn sync_provider_toml_from_fields(provider: &mut SavedProvider) -> Result<()> {
CodexxError::Config(format!("供应商 TOML 缺少 [model_providers.{provider_id}]"))
})?;
table["name"] = value(provider.provider_name.clone());
super::transport::configure_third_party_transport(table, &provider.base_url, true);
table["base_url"] = value(provider.base_url.clone());
table["wire_api"] = value(provider.wire_api.clone());
table["requires_openai_auth"] = value(provider.requires_openai_auth);
@@ -1234,6 +1324,175 @@ mod tests {
conn
}
fn provider_order_test_connection() -> Connection {
let conn = test_connection();
conn.execute_batch(
"CREATE TABLE provider_card_orders (
codex_dir TEXT PRIMARY KEY, order_json TEXT NOT NULL, updated_at TEXT NOT NULL);",
)
.unwrap();
conn
}
#[test]
fn provider_card_order_persists_across_connections_and_keeps_config_scopes_independent() {
let database_file = tempfile::NamedTempFile::new().unwrap();
let directory = tempfile::tempdir().unwrap();
let other_directory = tempfile::tempdir().unwrap();
let mut conn = Connection::open(database_file.path()).unwrap();
conn.execute_batch(
"CREATE TABLE provider_card_orders (
codex_dir TEXT PRIMARY KEY, order_json TEXT NOT NULL, updated_at TEXT NOT NULL);",
)
.unwrap();
assert!(get_provider_order_on_connection(&conn, directory.path())
.unwrap()
.is_empty());
let chosen = vec![
"local:shared-id".to_string(),
"official:shared-id".to_string(),
"detected:detected-自定义".to_string(),
"official:openai-official".to_string(),
];
assert_eq!(
save_provider_order_on_connection(&mut conn, directory.path(), chosen.clone()).unwrap(),
chosen
);
let second = vec!["official:another-account".to_string()];
save_provider_order_on_connection(&mut conn, other_directory.path(), second.clone())
.unwrap();
drop(conn);
let reopened = Connection::open(database_file.path()).unwrap();
assert_eq!(
get_provider_order_on_connection(&reopened, directory.path()).unwrap(),
chosen
);
assert_eq!(
get_provider_order_on_connection(&reopened, other_directory.path()).unwrap(),
second
);
}
#[cfg(unix)]
#[test]
fn provider_card_order_resolves_linked_config_directory_to_the_same_scope() {
let directory = tempfile::tempdir().unwrap();
let alias_root = tempfile::tempdir().unwrap();
let alias = alias_root.path().join("linked-config");
std::os::unix::fs::symlink(directory.path(), &alias).unwrap();
let mut conn = provider_order_test_connection();
let order = vec!["local:linked-provider".to_string()];
save_provider_order_on_connection(&mut conn, &alias, order.clone()).unwrap();
assert_eq!(
get_provider_order_on_connection(&conn, directory.path()).unwrap(),
order
);
}
#[test]
fn provider_card_order_survives_provider_refresh_edits_additions_and_deletions() {
let mut conn = provider_order_test_connection();
let directory = std::path::Path::new("/fixture/provider-order-lifecycle");
let original = provider("ordered-provider", "Imported", Some("fixture-key"));
upsert_ccswitch_provider_on_connection(&conn, original.clone(), "ordered-source").unwrap();
let order = vec![
"local:ordered-provider".to_string(),
"official:openai-official".to_string(),
"detected:detected-temporary".to_string(),
];
save_provider_order_on_connection(&mut conn, directory, order.clone()).unwrap();
assert_eq!(
provider_by_id_on_connection(&conn, &original.id).unwrap(),
Some(original)
);
let fresh = provider("external-id", "Refreshed import", Some("fixture-key"));
let refreshed = upsert_ccswitch_provider_on_connection(&conn, fresh, "ordered-source")
.unwrap()
.provider;
assert_eq!(refreshed.id, "ordered-provider");
let mut edited = refreshed;
edited.model = "other-model".to_string();
save_manual_provider_on_connection(&conn, edited).unwrap();
save_manual_provider_on_connection(
&conn,
provider("new-provider", "New provider", Some("new-fixture-key")),
)
.unwrap();
conn.execute("DELETE FROM providers WHERE id = 'ordered-provider'", [])
.unwrap();
assert_eq!(
get_provider_order_on_connection(&conn, directory).unwrap(),
order
);
save_provider_order_on_connection(&mut conn, directory, Vec::new()).unwrap();
assert!(get_provider_order_on_connection(&conn, directory)
.unwrap()
.is_empty());
assert!(provider_by_id_on_connection(&conn, "new-provider")
.unwrap()
.is_some());
}
#[test]
fn provider_card_order_rejects_invalid_or_duplicate_keys_without_overwriting_saved_order() {
let mut conn = provider_order_test_connection();
let directory = std::path::Path::new("/fixture/provider-order-validation");
let order = vec!["local:kept-provider".to_string()];
save_provider_order_on_connection(&mut conn, directory, order.clone()).unwrap();
for invalid in [
vec![
"local:kept-provider".to_string(),
"local:kept-provider".to_string(),
],
vec!["unknown:provider".to_string()],
vec!["local:".to_string()],
vec!["official: account".to_string()],
vec!["detected:invalid\nprovider".to_string()],
vec![format!("local:{}", "x".repeat(512))],
(0..4097).map(|id| format!("local:{id}")).collect(),
] {
assert!(save_provider_order_on_connection(&mut conn, directory, invalid).is_err());
assert_eq!(
get_provider_order_on_connection(&conn, directory).unwrap(),
order
);
}
}
#[test]
fn provider_card_order_database_failure_rolls_back_the_entire_order() {
let mut conn = provider_order_test_connection();
let directory = std::path::Path::new("/fixture/provider-order-rollback");
let before = vec![
"official:openai-official".to_string(),
"local:kept".to_string(),
];
save_provider_order_on_connection(&mut conn, directory, before.clone()).unwrap();
conn.execute_batch(
"CREATE TRIGGER fail_provider_card_order BEFORE UPDATE ON provider_card_orders
BEGIN SELECT RAISE(ABORT, 'fixture order persistence failure'); END;",
)
.unwrap();
assert!(save_provider_order_on_connection(
&mut conn,
directory,
vec![
"local:kept".to_string(),
"official:openai-official".to_string()
],
)
.is_err());
assert_eq!(
get_provider_order_on_connection(&conn, directory).unwrap(),
before
);
assert!(
conn.is_autocommit(),
"failed save must release its transaction"
);
}
#[test]
fn model_mappings_roundtrip_and_copy_preserve_independent_records() {
let mut conn = copy_test_connection();
@@ -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));
}
}
}
+112 -7
View File
@@ -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);
}
}
+843 -64
View File
@@ -1,24 +1,29 @@
use super::app_server::delete_sessions_via_codex_app_server;
use super::rollout_stream::{for_each_jsonl_record, record_matches_top_level_strings};
use super::storage::{
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
is_canonical_rollout_storage_path, rollout_filename_matches_id, scan_rollouts,
split_line_ending, sqlite_subagent_thread_ids, sqlite_thread_needs_alignment, SqliteDiscovery,
sqlite_subagent_thread_ids, sqlite_thread_needs_alignment, SqliteDiscovery,
SqliteThreadIndexState,
};
use super::sync::{acquire_session_maintenance_lock, session_sync_status_with_discovery};
use super::types::SessionSyncStatus;
use crate::error::{CodexxError, Result};
use crate::file_io::{io_err, write_text};
use crate::file_io::io_err;
use crate::resolve_codex_dir;
use crate::sqlite_utils::{sql_select_column, sqlite_has_table, table_column_set};
use rusqlite::{Connection, OpenFlags, TransactionBehavior};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use std::time::{Duration, SystemTime};
use tempfile::NamedTempFile;
#[cfg(test)]
use super::storage::hash_rollout_file;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -481,88 +486,447 @@ fn selected_rollout_paths(
Ok(paths)
}
const CLEANUP_IO_BUFFER_BYTES: usize = 64 * 1024;
#[derive(Clone, Debug, PartialEq, Eq)]
struct CleanupFileStamp {
len: u64,
modified: Option<SystemTime>,
created: Option<SystemTime>,
unix_identity: Option<(u64, u64)>,
}
impl CleanupFileStamp {
fn from_metadata(metadata: &fs::Metadata) -> Self {
#[cfg(unix)]
let unix_identity = {
use std::os::unix::fs::MetadataExt;
Some((metadata.dev(), metadata.ino()))
};
#[cfg(not(unix))]
let unix_identity = None;
Self {
len: metadata.len(),
modified: metadata.modified().ok(),
created: metadata.created().ok(),
unix_identity,
}
}
}
struct CleanupHashingReader<'a> {
source: &'a mut fs::File,
backup: Option<&'a mut fs::File>,
digest: &'a mut Sha256,
consumed: u64,
}
impl Read for CleanupHashingReader<'_> {
fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
let count = self.source.read(bytes)?;
if let Some(backup) = self.backup.as_mut() {
backup.write_all(&bytes[..count])?;
}
self.digest.update(&bytes[..count]);
self.consumed += count as u64;
Ok(count)
}
}
fn copy_cleanup_bytes(reader: &mut impl Read, writer: &mut impl Write) -> std::io::Result<u64> {
let mut bytes = [0u8; CLEANUP_IO_BUFFER_BYTES];
let mut copied = 0u64;
loop {
let count = reader.read(&mut bytes)?;
if count == 0 {
return Ok(copied);
}
writer.write_all(&bytes[..count])?;
copied += count as u64;
}
}
fn filter_cleanup_records<R: Read, W: Write>(
reader: R,
writer: &mut W,
path: &Path,
id_keys: &[&str],
session_ids: &HashSet<String>,
) -> Result<usize> {
let mut removed = 0usize;
for_each_jsonl_record(reader, |record, _| {
record
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
let matches = if session_ids.is_empty() {
false
} else {
match record_matches_top_level_strings(record, id_keys, session_ids, 128) {
Ok(matches) => matches,
Err(error @ CodexxError::Io { .. }) => return Err(error),
// Invalid JSON is preserved exactly as in the previous filter.
Err(_) => false,
}
};
if matches {
removed += 1;
} else {
record
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
copy_cleanup_bytes(record, writer).map_err(|error| io_err(path, error))?;
}
Ok(())
})?;
Ok(removed)
}
struct PreparedJsonlCleanup {
output: NamedTempFile,
original_backup: Option<NamedTempFile>,
original_stamp: CleanupFileStamp,
original_hash: [u8; 32],
removed: usize,
}
fn cleanup_source_changed(path: &Path) -> CodexxError {
CodexxError::Config(format!(
"会话索引或历史记录在清理期间发生变化,已取消替换: {}",
path.display()
))
}
fn hash_open_cleanup_file(path: &Path, source: &fs::File) -> Result<[u8; 32]> {
// Read the existing handle: an exclusive history lock can reject a second
// handle's read on Windows, even when it is opened by this same process.
let mut reader = source;
reader
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
let mut digest = Sha256::new();
let mut buffer = [0u8; CLEANUP_IO_BUFFER_BYTES];
loop {
let count = reader
.read(&mut buffer)
.map_err(|error| io_err(path, error))?;
if count == 0 {
return Ok(digest.finalize().into());
}
digest.update(&buffer[..count]);
}
}
fn ensure_cleanup_source_unchanged(
path: &Path,
source: &fs::File,
stamp: &CleanupFileStamp,
hash: &[u8; 32],
) -> Result<()> {
let source_stamp = || -> Result<CleanupFileStamp> {
source
.metadata()
.map(|metadata| CleanupFileStamp::from_metadata(&metadata))
.map_err(|error| io_err(path, error))
};
let path_stamp = || -> Result<CleanupFileStamp> {
fs::metadata(path)
.map(|metadata| CleanupFileStamp::from_metadata(&metadata))
.map_err(|error| io_err(path, error))
};
if &source_stamp()? != stamp || &path_stamp()? != stamp {
return Err(cleanup_source_changed(path));
}
if &hash_open_cleanup_file(path, source)? != hash
|| &source_stamp()? != stamp
|| &path_stamp()? != stamp
{
return Err(cleanup_source_changed(path));
}
Ok(())
}
fn prepare_jsonl_cleanup(
path: &Path,
source: &mut fs::File,
id_keys: &[&str],
session_ids: &HashSet<String>,
retain_backup: bool,
) -> Result<PreparedJsonlCleanup> {
let metadata = source.metadata().map_err(|error| io_err(path, error))?;
if !metadata.is_file() {
return Err(CodexxError::Config(format!(
"会话日志不是普通文件: {}",
path.display()
)));
}
let original_stamp = CleanupFileStamp::from_metadata(&metadata);
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let mut output = NamedTempFile::new_in(parent).map_err(|error| io_err(path, error))?;
let mut original_backup = if retain_backup {
Some(NamedTempFile::new_in(parent).map_err(|error| io_err(path, error))?)
} else {
None
};
source
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
let mut digest = Sha256::new();
let mut reader = CleanupHashingReader {
source: &mut *source,
backup: original_backup.as_mut().map(NamedTempFile::as_file_mut),
digest: &mut digest,
consumed: 0,
};
let removed = filter_cleanup_records(
&mut reader,
output.as_file_mut(),
path,
id_keys,
session_ids,
)?;
let consumed = reader.consumed;
drop(reader);
if consumed != original_stamp.len
|| CleanupFileStamp::from_metadata(&source.metadata().map_err(|error| io_err(path, error))?)
!= original_stamp
{
return Err(cleanup_source_changed(path));
}
output
.as_file()
.sync_all()
.map_err(|error| io_err(path, error))?;
if let Some(backup) = original_backup.as_ref() {
backup
.as_file()
.sync_all()
.map_err(|error| io_err(path, error))?;
}
let original_hash = digest.finalize().into();
ensure_cleanup_source_unchanged(path, source, &original_stamp, &original_hash)?;
Ok(PreparedJsonlCleanup {
output,
original_backup,
original_stamp,
original_hash,
removed,
})
}
fn preflight_session_jsonl_cleanup(codex_dir: &Path) -> Result<()> {
// Framing and staging use fixed memory even for a very large valid history.
// Read/write failures still occur before the irreversible deletion API.
for filename in ["session_index.jsonl", "history.jsonl"] {
let path = codex_dir.join(filename);
let mut source = match fs::File::open(&path) {
Ok(source) => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(io_err(&path, error)),
};
prepare_jsonl_cleanup(&path, &mut source, &[], &HashSet::new(), false)?;
}
Ok(())
}
fn commit_index_cleanup(
path: &Path,
source: &fs::File,
prepared: PreparedJsonlCleanup,
) -> Result<usize> {
if prepared.removed == 0 {
return Ok(0);
}
ensure_cleanup_source_unchanged(
path,
source,
&prepared.original_stamp,
&prepared.original_hash,
)?;
let permissions = source
.metadata()
.map_err(|error| io_err(path, error))?
.permissions();
prepared
.output
.as_file()
.set_permissions(permissions)
.map_err(|error| io_err(path, error))?;
prepared
.output
.persist(path)
.map_err(|error| io_err(path, error.error))?;
Ok(prepared.removed)
}
fn remove_jsonl_session_entries(
path: &Path,
id_keys: &[&str],
session_ids: &HashSet<String>,
) -> Result<usize> {
if !path.exists() {
return Ok(0);
}
let text = fs::read_to_string(path).map_err(|e| io_err(path, e))?;
let (next, removed) = filter_jsonl_session_entries(&text, id_keys, session_ids);
if removed > 0 {
write_text(path, &next)?;
}
Ok(removed)
}
fn filter_jsonl_session_entries(
text: &str,
id_keys: &[&str],
session_ids: &HashSet<String>,
) -> (String, usize) {
let mut next = String::with_capacity(text.len());
let mut removed = 0usize;
for segment in text.split_inclusive('\n') {
let (line, ending) = split_line_ending(segment);
let matches = serde_json::from_str::<Value>(line)
.ok()
.and_then(|value| {
id_keys.iter().find_map(|key| {
value
.get(*key)
.and_then(Value::as_str)
.map(ToString::to_string)
})
})
.is_some_and(|id| session_ids.contains(&id));
if matches {
removed += 1;
} else {
next.push_str(line);
next.push_str(ending);
}
}
(next, removed)
let mut source = match fs::File::open(path) {
Ok(source) => source,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(error) => return Err(io_err(path, error)),
};
let prepared = prepare_jsonl_cleanup(path, &mut source, id_keys, session_ids, false)?;
commit_index_cleanup(path, &source, prepared)
}
fn remove_session_index_entries(codex_dir: &Path, session_ids: &HashSet<String>) -> Result<usize> {
remove_jsonl_session_entries(&codex_dir.join("session_index.jsonl"), &["id"], session_ids)
}
fn restore_history_backup(
path: &Path,
source: &mut fs::File,
backup: &mut fs::File,
original_len: u64,
original_hash: &[u8; 32],
) -> Result<()> {
backup
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
source
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
if copy_cleanup_bytes(backup, source).map_err(|error| io_err(path, error))? != original_len {
return Err(CodexxError::Config(
"历史记录备份不完整,无法安全回滚".into(),
));
}
// Filtering never grows the file. Replaying the complete backup restores its
// original length after a failed truncate, and does not truncate a later
// append beyond the original EOF from a writer that ignored the lock.
source.sync_all().map_err(|error| io_err(path, error))?;
source
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(path, error))?;
let mut digest = Sha256::new();
let mut buffer = [0u8; CLEANUP_IO_BUFFER_BYTES];
let mut remaining = original_len;
while remaining > 0 {
let allowed = buffer.len().min(remaining as usize);
let count = source
.read(&mut buffer[..allowed])
.map_err(|error| io_err(path, error))?;
if count == 0 {
return Err(CodexxError::Config("历史记录回滚后长度不足".into()));
}
digest.update(&buffer[..count]);
remaining -= count as u64;
}
let restored: [u8; 32] = digest.finalize().into();
if &restored != original_hash {
return Err(CodexxError::Config("历史记录回滚校验失败".into()));
}
Ok(())
}
fn commit_history_cleanup_with_writer<F>(
path: &Path,
source: &mut fs::File,
mut prepared: PreparedJsonlCleanup,
write_output: F,
) -> Result<usize>
where
F: FnOnce(&mut fs::File, &mut fs::File, u64, u64) -> Result<()>,
{
if prepared.removed == 0 {
return Ok(0);
}
ensure_cleanup_source_unchanged(
path,
source,
&prepared.original_stamp,
&prepared.original_hash,
)?;
let output_len = prepared
.output
.as_file()
.metadata()
.map_err(|error| io_err(path, error))?
.len();
if output_len > prepared.original_stamp.len {
return Err(CodexxError::Config(
"历史记录过滤结果异常,未写入原文件".into(),
));
}
let mut backup = prepared
.original_backup
.take()
.ok_or_else(|| CodexxError::Config("历史记录缺少完整磁盘备份,未写入原文件".into()))?;
if let Err(error) = write_output(
source,
prepared.output.as_file_mut(),
output_len,
prepared.original_stamp.len,
) {
if let Err(restore_error) = restore_history_backup(
path,
source,
backup.as_file_mut(),
prepared.original_stamp.len,
&prepared.original_hash,
) {
return match backup.keep() {
Ok((_, retained)) => Err(CodexxError::Config(format!(
"{error};历史记录回滚失败:{restore_error};已保留私有备份:{}",
retained.display()
))),
Err(keep_error) => Err(CodexxError::Config(format!(
"{error};历史记录回滚失败:{restore_error};保留备份失败:{keep_error}"
))),
};
}
return Err(error);
}
Ok(prepared.removed)
}
fn remove_session_history_entries(
codex_dir: &Path,
session_ids: &HashSet<String>,
) -> Result<usize> {
let path = codex_dir.join("history.jsonl");
if !path.exists() {
return Ok(0);
}
let mut file = fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.map_err(|e| io_err(&path, e))?;
let mut file = match fs::OpenOptions::new().read(true).write(true).open(&path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(error) => return Err(io_err(&path, error)),
};
file.try_lock().map_err(|error| {
CodexxError::Config(format!(
"历史记录正在被其他 Codex 进程使用,请关闭相关 Codex 窗口或 CLI 后重试: {error}"
))
})?;
let result = (|| -> Result<usize> {
let mut text = String::new();
file.read_to_string(&mut text)
.map_err(|e| io_err(&path, e))?;
let (next, removed) = filter_jsonl_session_entries(&text, &["session_id"], session_ids);
if removed > 0 {
file.set_len(0).map_err(|e| io_err(&path, e))?;
file.seek(SeekFrom::Start(0))
.map_err(|e| io_err(&path, e))?;
file.write_all(next.as_bytes())
.map_err(|e| io_err(&path, e))?;
file.sync_all().map_err(|e| io_err(&path, e))?;
}
Ok(removed)
let prepared = prepare_jsonl_cleanup(&path, &mut file, &["session_id"], session_ids, true)?;
commit_history_cleanup_with_writer(
&path,
&mut file,
prepared,
|source, output, output_len, original_len| {
output
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(&path, error))?;
source
.seek(SeekFrom::Start(0))
.map_err(|error| io_err(&path, error))?;
copy_cleanup_bytes(output, source).map_err(|error| io_err(&path, error))?;
if source
.metadata()
.map_err(|error| io_err(&path, error))?
.len()
!= original_len
{
return Err(cleanup_source_changed(&path));
}
source
.set_len(output_len)
.map_err(|error| io_err(&path, error))?;
source.sync_all().map_err(|error| io_err(&path, error))?;
Ok(())
},
)
})();
let _ = file.unlock();
result
@@ -787,6 +1151,7 @@ pub(crate) fn hard_delete_sessions_locally(
let relationship_sources = relationship_database_sources(&discovery, roots)?;
let session_ids = session_ids_with_descendants(&relationship_sources, roots)?;
let rollout_paths = selected_rollout_paths(codex_dir, &discovery.thread_paths, &session_ids)?;
preflight_session_jsonl_cleanup(codex_dir)?;
Ok(delete_exact_session_ids_locally(
codex_dir,
&discovery.related_paths,
@@ -834,6 +1199,7 @@ pub(crate) fn delete_codex_sessions_inner(
// make the deletion irreversible.
let expected_rollout_paths =
selected_rollout_paths(&codex_dir, &discovery.thread_paths, &expected_ids)?;
preflight_session_jsonl_cleanup(&codex_dir)?;
let mut counts = LocalSessionDeleteCounts::default();
let mut failed_roots = Vec::new();
@@ -1121,4 +1487,417 @@ mod tests {
let _ = fs::remove_dir_all(codex_dir);
}
#[test]
fn unreadable_cleanup_logs_cancel_native_and_local_deletion_before_any_mutation() {
for filename in ["session_index.jsonl", "history.jsonl"] {
let codex_dir = temp_codex_dir();
let id = "019f6000-0000-7000-8000-000000000371";
let database = codex_dir.join("state_10.sqlite");
create_thread_database(&database, id);
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
fs::create_dir_all(rollout.parent().unwrap()).unwrap();
let rollout_text = format!(
"{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"model_provider\":\"openai\"}}}}\n"
);
fs::write(&rollout, &rollout_text).unwrap();
for (name, key) in [
("session_index.jsonl", "id"),
("history.jsonl", "session_id"),
] {
fs::write(codex_dir.join(name), format!("{{\"{key}\":\"{id}\"}}\n")).unwrap();
}
let unreadable = codex_dir.join(filename);
fs::remove_file(&unreadable).unwrap();
fs::create_dir(&unreadable).unwrap();
let database_before = fs::read(&database).unwrap();
let untouched_name = if filename == "history.jsonl" {
"session_index.jsonl"
} else {
"history.jsonl"
};
let untouched_path = codex_dir.join(untouched_name);
let untouched_before = fs::read(&untouched_path).unwrap();
let local_error = hard_delete_sessions_locally(&codex_dir, &[id.to_string()])
.expect_err("local deletion must reject an unreadable cleanup log");
assert!(local_error.to_string().contains(filename));
let native_error = delete_codex_sessions_inner(SessionDeleteInput {
config_dir: Some(codex_dir.display().to_string()),
session_ids: vec![id.to_string()],
})
.expect_err("native deletion must stop at preflight before starting Codex");
assert!(native_error.to_string().contains(filename));
assert_eq!(fs::read(&rollout).unwrap(), rollout_text.as_bytes());
assert_eq!(fs::read(&database).unwrap(), database_before);
assert_eq!(fs::read(&untouched_path).unwrap(), untouched_before);
assert!(unreadable.is_dir());
let present =
active_session_ids_present(&[database], &HashSet::from([id.to_string()])).unwrap();
assert!(present.contains(id));
fs::remove_dir_all(codex_dir).unwrap();
}
}
#[test]
fn large_cleanup_logs_stream_without_a_total_size_limit() {
for filename in ["session_index.jsonl", "history.jsonl"] {
let codex_dir = temp_codex_dir();
let id = "019f6000-0000-7000-8000-000000000381";
let database = codex_dir.join("state_10.sqlite");
create_thread_database(&database, id);
let rollout = codex_dir.join(format!("sessions/rollout-test-{id}.jsonl"));
fs::create_dir_all(rollout.parent().unwrap()).unwrap();
fs::write(&rollout, format!("{{\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"model_provider\":\"openai\"}}}}\n")).unwrap();
let path = codex_dir.join(filename);
let key = if filename == "history.jsonl" {
"session_id"
} else {
"id"
};
let remove_large_record = filename == "history.jsonl";
let mut file = fs::File::create(&path).unwrap();
let large_id = if remove_large_record { id } else { "kept" };
let prefix = format!("{{\"{key}\":\"{large_id}\",\"text\":\"");
let mut expected = Sha256::new();
file.write_all(prefix.as_bytes()).unwrap();
if !remove_large_record {
expected.update(prefix.as_bytes());
}
let bytes = [b'x'; CLEANUP_IO_BUFFER_BYTES];
for _ in 0..(33 * 1024 * 1024 / CLEANUP_IO_BUFFER_BYTES) {
file.write_all(&bytes).unwrap();
if !remove_large_record {
expected.update(bytes);
}
}
file.write_all(b"\"}\r\n").unwrap();
if !remove_large_record {
expected.update(b"\"}\r\n");
file.write_all(format!("{{\"{key}\":\"{id}\"}}\n").as_bytes())
.unwrap();
}
let tail = format!("not-json\r\n{{\"{key}\":\"{id}\",\"{key}\":\"kept-last\"}}\n{{\"{key}\":\"kept-final\"}}");
file.write_all(tail.as_bytes()).unwrap();
expected.update(tail.as_bytes());
drop(file);
assert!(fs::metadata(&path).unwrap().len() > 32 * 1024 * 1024);
let other_name = if filename == "history.jsonl" {
"session_index.jsonl"
} else {
"history.jsonl"
};
let other_key = if key == "id" { "session_id" } else { "id" };
fs::write(
codex_dir.join(other_name),
format!("{{\"{other_key}\":\"{id}\"}}\r\n{{\"{other_key}\":\"untouched\"}}"),
)
.unwrap();
preflight_session_jsonl_cleanup(&codex_dir)
.expect("large valid logs must pass streaming preflight");
let result = hard_delete_sessions_locally(&codex_dir, &[id.to_string()]).unwrap();
assert!(result.errors.is_empty(), "{:?}", result.errors);
assert!(!rollout.exists());
assert!(
!active_session_ids_present(&[database], &HashSet::from([id.to_string()]))
.unwrap()
.contains(id)
);
let expected_hash: [u8; 32] = expected.finalize().into();
assert_eq!(hash_rollout_file(&path).unwrap(), expected_hash);
assert_eq!(
fs::read(codex_dir.join(other_name)).unwrap(),
format!("{{\"{other_key}\":\"untouched\"}}").as_bytes()
);
fs::remove_dir_all(codex_dir).unwrap();
}
}
#[test]
fn streaming_cleanup_preserves_missing_files_and_normal_jsonl_filtering() {
let codex_dir = temp_codex_dir();
preflight_session_jsonl_cleanup(&codex_dir).unwrap();
assert!(!codex_dir.join("session_index.jsonl").exists());
assert!(!codex_dir.join("history.jsonl").exists());
let ids = HashSet::from(["selected".to_string()]);
for (filename, key) in [
("session_index.jsonl", "id"),
("history.jsonl", "session_id"),
] {
fs::write(
codex_dir.join(filename),
format!("{{\"{key}\":\"selected\"}}\r\nnot-json\r\n{{\"{key}\":\"kept\"}}"),
)
.unwrap();
}
preflight_session_jsonl_cleanup(&codex_dir).unwrap();
assert_eq!(remove_session_index_entries(&codex_dir, &ids).unwrap(), 1);
assert_eq!(remove_session_history_entries(&codex_dir, &ids).unwrap(), 1);
for (filename, key) in [
("session_index.jsonl", "id"),
("history.jsonl", "session_id"),
] {
assert_eq!(
fs::read_to_string(codex_dir.join(filename)).unwrap(),
format!("not-json\r\n{{\"{key}\":\"kept\"}}")
);
}
fs::remove_dir_all(codex_dir).unwrap();
}
#[test]
fn streaming_cleanup_only_matches_valid_top_level_ids_and_preserves_other_bytes() {
let codex_dir = temp_codex_dir();
let path = codex_dir.join("session_index.jsonl");
let kept = b"not-json\r\n{\"nested\":{\"id\":\"selected\"},\"id\":\"kept\"}\n{\"id\":\"selected\",\"id\":null}\r\n\xff\n";
let mut original = kept.to_vec();
original.extend_from_slice(b"{\"id\":\"selected\"}\r\n");
original.extend_from_slice(b"{\"id\":\"last-kept\"}");
fs::write(&path, original).unwrap();
assert_eq!(
remove_session_index_entries(&codex_dir, &HashSet::from(["selected".to_string()]))
.unwrap(),
1
);
let mut expected = kept.to_vec();
expected.extend_from_slice(b"{\"id\":\"last-kept\"}");
assert_eq!(fs::read(path).unwrap(), expected);
fs::remove_dir_all(codex_dir).unwrap();
}
struct FailingCleanupRead<R> {
inner: R,
remaining: usize,
}
impl<R: Read> Read for FailingCleanupRead<R> {
fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
if self.remaining == 0 {
return Err(std::io::Error::other("injected cleanup read failure"));
}
let allowed = bytes.len().min(self.remaining);
let count = self.inner.read(&mut bytes[..allowed])?;
self.remaining -= count;
Ok(count)
}
}
struct FailingCleanupWrite<W> {
inner: W,
remaining: usize,
}
impl<W: Write> Write for FailingCleanupWrite<W> {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
if self.remaining == 0 {
return Err(std::io::Error::other("injected cleanup write failure"));
}
let count = self
.inner
.write(&bytes[..bytes.len().min(self.remaining)])?;
self.remaining -= count;
Ok(count)
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
#[test]
fn streaming_cleanup_read_or_staging_write_failure_preserves_the_source() {
let codex_dir = temp_codex_dir();
let path = codex_dir.join("session_index.jsonl");
let original = b"{\"id\":\"kept\",\"text\":\"retained\"}\r\n{\"id\":\"selected\"}\n";
fs::write(&path, original).unwrap();
let ids = HashSet::from(["selected".to_string()]);
let mut target = NamedTempFile::new_in(&codex_dir).unwrap();
let error = filter_cleanup_records(
FailingCleanupRead {
inner: fs::File::open(&path).unwrap(),
remaining: 7,
},
target.as_file_mut(),
&path,
&["id"],
&ids,
)
.expect_err("read errors must propagate, not become a malformed record");
assert!(matches!(error, CodexxError::Io { .. }));
assert_eq!(fs::read(&path).unwrap(), original);
let mut writer = FailingCleanupWrite {
inner: target.as_file_mut(),
remaining: 3,
};
let error = filter_cleanup_records(
fs::File::open(&path).unwrap(),
&mut writer,
&path,
&["id"],
&ids,
)
.expect_err("a failed private output must never be installed");
assert!(matches!(error, CodexxError::Io { .. }));
assert_eq!(fs::read(&path).unwrap(), original);
drop(target);
fs::remove_dir_all(codex_dir).unwrap();
}
#[test]
fn staged_cleanup_detects_growth_before_index_or_history_commit() {
for history in [false, true] {
let codex_dir = temp_codex_dir();
let path = codex_dir.join(if history {
"history.jsonl"
} else {
"session_index.jsonl"
});
let key = if history { "session_id" } else { "id" };
fs::write(
&path,
format!("{{\"{key}\":\"selected\"}}\n{{\"{key}\":\"kept\"}}\n"),
)
.unwrap();
let mut source = fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
let prepared = prepare_jsonl_cleanup(
&path,
&mut source,
&[key],
&HashSet::from(["selected".to_string()]),
history,
)
.unwrap();
let mut external = fs::OpenOptions::new().append(true).open(&path).unwrap();
external
.write_all(format!("{{\"{key}\":\"new-append\"}}\n").as_bytes())
.unwrap();
external.sync_all().unwrap();
drop(external);
let grown = fs::read(&path).unwrap();
let result = if history {
commit_history_cleanup_with_writer(&path, &mut source, prepared, |_, _, _, _| {
panic!("growth must be rejected before writing history")
})
} else {
commit_index_cleanup(&path, &source, prepared)
};
assert!(result
.expect_err("a stale staged result must not replace appended data")
.to_string()
.contains("发生变化"));
assert_eq!(fs::read(&path).unwrap(), grown);
drop(source);
fs::remove_dir_all(codex_dir).unwrap();
}
}
#[test]
fn history_commit_write_failure_restores_backup_and_keeps_its_lock_and_inode() {
for fail_after_truncate in [false, true] {
let codex_dir = temp_codex_dir();
let path = codex_dir.join("history.jsonl");
let original = b"{\"session_id\":\"selected\",\"text\":\"private fixture\"}\r\n{\"session_id\":\"kept\"}";
fs::write(&path, original).unwrap();
let before = CleanupFileStamp::from_metadata(&fs::metadata(&path).unwrap());
let mut source = fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
source.try_lock().unwrap();
let probe = fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
assert!(probe.try_lock().is_err());
let prepared = prepare_jsonl_cleanup(
&path,
&mut source,
&["session_id"],
&HashSet::from(["selected".to_string()]),
true,
)
.unwrap();
let error = commit_history_cleanup_with_writer(
&path,
&mut source,
prepared,
|source, output, output_len, _| {
source.seek(SeekFrom::Start(0)).unwrap();
output.seek(SeekFrom::Start(0)).unwrap();
if fail_after_truncate {
copy_cleanup_bytes(output, source).unwrap();
source.set_len(output_len).unwrap();
} else {
source.write_all(b"partial failed write").unwrap();
}
Err(io_err(
&path,
std::io::Error::other("injected commit write/sync failure"),
))
},
)
.expect_err("failed commit must report the failure after restoring the disk backup");
assert!(error.to_string().contains("injected commit"));
let expected_hash: [u8; 32] = Sha256::digest(original).into();
assert_eq!(
hash_open_cleanup_file(&path, &source).unwrap(),
expected_hash
);
assert_eq!(source.metadata().unwrap().len(), original.len() as u64);
assert_eq!(
CleanupFileStamp::from_metadata(&source.metadata().unwrap()).unix_identity,
before.unix_identity
);
assert!(
probe.try_lock().is_err(),
"rollback must not release the live history lock"
);
source.unlock().unwrap();
assert!(probe.try_lock().is_ok());
probe.unlock().unwrap();
drop(probe);
drop(source);
assert_eq!(fs::read(&path).unwrap(), original);
fs::remove_dir_all(codex_dir).unwrap();
}
}
#[test]
fn successful_history_cleanup_keeps_an_already_open_append_handle_usable() {
let codex_dir = temp_codex_dir();
let path = codex_dir.join("history.jsonl");
fs::write(
&path,
b"{\"session_id\":\"selected\"}\n{\"session_id\":\"kept\"}\n",
)
.unwrap();
let before = CleanupFileStamp::from_metadata(&fs::metadata(&path).unwrap());
let mut old_append_handle = fs::OpenOptions::new()
.read(true)
.append(true)
.open(&path)
.unwrap();
assert_eq!(
remove_session_history_entries(&codex_dir, &HashSet::from(["selected".to_string()]))
.unwrap(),
1
);
old_append_handle.try_lock().unwrap();
old_append_handle
.write_all(b"{\"session_id\":\"later\"}\r\n")
.unwrap();
old_append_handle.unlock().unwrap();
drop(old_append_handle);
let after = CleanupFileStamp::from_metadata(&fs::metadata(&path).unwrap());
assert_eq!(after.unix_identity, before.unix_identity);
assert_eq!(
fs::read(&path).unwrap(),
b"{\"session_id\":\"kept\"}\n{\"session_id\":\"later\"}\r\n"
);
fs::remove_dir_all(codex_dir).unwrap();
}
}
@@ -0,0 +1,458 @@
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))
}
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}")
}
}
/// Resolve the paths represented by this status only. The raw spelling remains
/// the map key. Persistent home scope uses native identity only when birth time
/// was verified; otherwise it conservatively follows the original home path.
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(|| directory_identity(path));
}
(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;
let created = UNIX_EPOCH + Duration::new(1234, 17);
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, 18),
UNIX_EPOCH + Duration::new(1234 + (1u64 << 32), 17),
UNIX_EPOCH - Duration::new(1234, 17),
] {
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(1)))
);
}
#[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
+7 -2
View File
@@ -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,16 @@ 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};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+779 -59
View File
@@ -1,9 +1,10 @@
use super::backup::{create_provider_sync_backup, prune_provider_sync_backups};
use super::catalog::{scan_catalog_sync, CatalogSyncScan};
use super::catalog::{scan_catalog_internal_thread_ids, scan_catalog_sync, CatalogSyncScan};
use super::directory_identity::session_directory_identities;
use super::storage::{
current_model_provider, discover_sqlite_databases, ensure_sqlite_discovery_writable,
list_session_previews_with_paths, scan_provider_rollouts, scan_rollouts_for_thread_ids,
scan_sqlite_with_paths, SqliteDiscovery,
list_session_previews_with_paths, rollout_internal_thread_ids, scan_provider_rollouts,
scan_rollouts_for_thread_ids, scan_sqlite_with_paths, SqliteDiscovery,
};
use super::transaction::{
execute_provider_sync_mutation, mutation_error, prepare_sqlite_updates, rollback_mutation,
@@ -85,16 +86,22 @@ fn scan_provider_sync_data(
target_provider: &str,
discovery: &SqliteDiscovery,
) -> Result<ProviderSyncScan> {
let active_sqlite = scan_sqlite_with_paths(
let active_unclassified = scan_sqlite_with_paths(
&discovery.active_paths,
&RolloutScan::default(),
target_provider,
)?;
let mut sqlite = scan_sqlite_with_paths(
&discovery.thread_paths,
&RolloutScan::default(),
target_provider,
)?;
let (mut internal_thread_ids, _classification_failures) =
rollout_internal_thread_ids(codex_dir, &active_unclassified.rollout_paths_by_thread_id);
internal_thread_ids.extend(scan_catalog_internal_thread_ids(&discovery.related_paths)?);
let classification = RolloutScan {
internal_thread_ids,
..RolloutScan::default()
};
let active_sqlite =
scan_sqlite_with_paths(&discovery.active_paths, &classification, target_provider)?;
let mut sqlite =
scan_sqlite_with_paths(&discovery.thread_paths, &classification, target_provider)?;
let mut syncable_thread_ids = sqlite.syncable_thread_ids.clone();
let mut archived_thread_ids = sqlite.archived_thread_ids.clone();
let mut subagent_thread_ids = sqlite.subagent_thread_ids.clone();
@@ -111,6 +118,7 @@ fn scan_provider_sync_data(
}
let mut excluded_thread_ids = archived_thread_ids.clone();
excluded_thread_ids.extend(subagent_thread_ids.iter().cloned());
excluded_thread_ids.extend(classification.internal_thread_ids.iter().cloned());
// Without an active authority, conflicting legacy classifications stay conservatively excluded.
syncable_thread_ids.retain(|id| !excluded_thread_ids.contains(id));
mismatched_thread_ids.retain(|id| syncable_thread_ids.contains(id));
@@ -120,15 +128,41 @@ fn scan_provider_sync_data(
sqlite.mismatched_thread_ids = mismatched_thread_ids;
sqlite.mismatched_threads = sqlite.mismatched_thread_ids.len();
let indexed_rollouts = if active_sqlite.sqlite_dbs > 0 {
scan_provider_buckets(codex_dir, target_provider, &active_sqlite)?
} else {
scan_provider_buckets(codex_dir, target_provider, &sqlite)?
// Only diagnostics from this authority scan are needed below. Drop its
// planned file copies before scanning the complete provider history.
let (indexed_rollout_failures, indexed_blocked_failures) = {
let rollouts = if active_sqlite.sqlite_dbs > 0 {
scan_provider_buckets(codex_dir, target_provider, &active_sqlite)?
} else {
scan_provider_buckets(codex_dir, target_provider, &sqlite)?
};
(rollouts.scan_failures, rollouts.blocked_failures)
};
let legacy_index_warnings = scan_legacy_index_warnings(codex_dir, target_provider, discovery);
let mut rollouts = scan_provider_rollouts(codex_dir, target_provider, &excluded_thread_ids)?;
let mut rollouts = scan_provider_rollouts(
codex_dir,
target_provider,
&excluded_thread_ids,
&active_unclassified.rollout_paths_by_thread_id,
)?;
// Provider synchronization changes provider routing only; cwd remains independently managed.
rollouts.cwd_by_thread_id.clear();
// Rollout metadata can reveal an internal thread even when the SQLite row
// has no source column (or an older index still calls it a user thread).
excluded_thread_ids.extend(rollouts.internal_thread_ids.iter().cloned());
rollouts
.internal_thread_ids
.extend(classification.internal_thread_ids.iter().cloned());
sqlite
.subagent_thread_ids
.extend(rollouts.internal_thread_ids.iter().cloned());
sqlite
.syncable_thread_ids
.retain(|id| !excluded_thread_ids.contains(id));
sqlite
.mismatched_thread_ids
.retain(|id| !excluded_thread_ids.contains(id));
sqlite.mismatched_threads = sqlite.mismatched_thread_ids.len();
syncable_thread_ids.extend(rollouts.thread_ids.iter().cloned());
syncable_thread_ids.retain(|id| !excluded_thread_ids.contains(id));
let catalog = scan_catalog_sync(
@@ -141,27 +175,24 @@ fn scan_provider_sync_data(
let mut scan_failures = discovery.active_scan_failures.clone();
scan_failures.extend(active_sqlite.scan_failures.iter().cloned());
scan_failures.extend(sqlite.scan_failures.iter().cloned());
scan_failures.extend(indexed_rollouts.scan_failures.iter().cloned());
scan_failures.extend(indexed_rollout_failures.iter().cloned());
scan_failures.extend(indexed_blocked_failures);
// A size/buffer limit means the rollout was not completely inspected.
// This is blocking even for an orphan, rather than a success with a warning.
scan_failures.extend(rollouts.blocked_failures.iter().cloned());
// A failed eligible rollout read/parse is an incomplete check, including
// unindexed files. It must not become an apparently successful sync.
scan_failures.extend(rollouts.scan_failures.iter().cloned());
for path in &discovery.unreadable_paths {
scan_failures.push(format!("无法读取会话数据库: {}", path.display()));
}
let indexed_failures = indexed_rollouts
.scan_failures
.iter()
.cloned()
.collect::<HashSet<_>>();
let mut warnings = rollouts.warnings.clone();
// The full provider/index scans below own warnings and blocking failures;
// repeating the lightweight identity scan's messages would duplicate them.
warnings.extend(active_sqlite.warnings.iter().cloned());
warnings.extend(sqlite.warnings.iter().cloned());
warnings.extend(legacy_index_warnings);
warnings.extend(
rollouts
.scan_failures
.iter()
.filter(|failure| !indexed_failures.contains(*failure))
.map(|failure| format!("已跳过未进入会话索引的异常文件:{failure}")),
);
let mut seen = HashSet::new();
scan_failures.retain(|failure| seen.insert(failure.clone()));
seen.clear();
@@ -215,8 +246,12 @@ pub(super) fn session_sync_status_with_discovery(
Err(error) => {
let mut failures = scan.scan_failures.clone();
failures.push(format!("无法读取当前活动会话列表: {error}"));
let (directory_identities, pin_scope_key) =
session_directory_identities(codex_dir, &[]);
return Ok(SessionSyncStatus {
codex_dir: codex_dir.display().to_string(),
directory_identities,
pin_scope_key: Some(pin_scope_key),
target_provider: target,
rollout_files: scan.rollouts.rollout_files,
session_meta_count: scan.rollouts.session_meta_count,
@@ -247,15 +282,21 @@ pub(super) fn session_sync_status_with_discovery(
let mut mismatched_ids = sqlite_mismatch_ids.clone();
mismatched_ids.extend(scan.rollouts.mismatched_thread_ids.iter().cloned());
for session in &mut sessions {
if !session.archived && !session.is_subagent && mismatched_ids.contains(&session.id) {
session.needs_sync = true;
}
// The same eligibility set drives both UI status and every mutation path.
session.needs_sync = !session.archived
&& !session.is_subagent
&& scan.syncable_thread_ids.contains(&session.id)
&& mismatched_ids.contains(&session.id);
}
let needs_sync = !scan.rollouts.changes.is_empty()
|| !scan.rollouts.mismatched_thread_ids.is_empty()
|| scan.sqlite.mismatched_threads > 0
|| scan.catalog.total_updates() > 0;
let (directory_identities, pin_scope_key) = session_directory_identities(codex_dir, &sessions);
Ok(SessionSyncStatus {
codex_dir: codex_dir.display().to_string(),
directory_identities,
pin_scope_key: Some(pin_scope_key),
target_provider: target,
rollout_files: scan.rollouts.rollout_files,
session_meta_count: scan.rollouts.session_meta_count,
@@ -359,6 +400,7 @@ where
backup_dir: String::new(),
});
}
drop(preflight_scan);
hook(MutationPoint::BeforeSqliteLock)?;
let mut pending_sqlite = prepare_sqlite_updates(&discovery.related_paths)?;
@@ -379,6 +421,14 @@ where
{
rollback_open_transactions(&mut pending_sqlite);
let status = session_sync_status_with_discovery(&codex_dir, target_provider, &discovery)?;
if !status.scan_complete {
return Err(scan_failure_error(&status.scan_failures));
}
if status.needs_sync {
return Err(CodexxError::Config(
"会话状态在检查后发生变化,未完成同步;请重新检查。".to_string(),
));
}
return Ok(SessionSyncResult {
status,
updated_rollouts: 0,
@@ -426,25 +476,32 @@ where
}
};
let prune_warning = prune_provider_sync_backups(&codex_dir).err();
let mut status = session_sync_status_with_discovery(&codex_dir, target_provider, &discovery)
.and_then(|status| {
if !status.scan_complete {
return Err(scan_failure_error(&status.scan_failures));
}
if status.needs_sync {
return Err(CodexxError::Config(
"会话日志与索引仍不一致,未完成同步;请重新检查。".to_string(),
));
}
Ok(status)
})
.map_err(|error| {
CodexxError::Config(format!(
"同步已完成,但刷新会话列表失败,请重新进入页面:{error}"
))
let recovery_errors = rollback_mutation(&journal, &mut pending_sqlite);
mutation_error(
CodexxError::Config(format!("同步后复查未通过,本轮同步未完成:{error}")),
recovery_errors,
)
})?;
let prune_warning = prune_provider_sync_backups(&codex_dir).err();
status.backup_dir = Some(backup.dir.display().to_string());
if prune_warning.is_some() {
status
.warnings
.push("同步已完成,但旧备份暂未清理。".to_string());
}
if !mutation.skipped_rollouts.is_empty() {
status.warnings.push(format!(
"有 {} 个会话正在使用,已跳过;退出 Codex 后再同步即可。",
mutation.skipped_rollouts.len()
));
}
Ok(SessionSyncResult {
status,
updated_rollouts: mutation.applied_rollouts,
@@ -457,9 +514,12 @@ where
mod tests {
use super::*;
use rusqlite::Connection;
use sha2::{Digest, Sha256};
use std::io::{BufRead, Read, Seek, SeekFrom};
use std::sync::atomic::{AtomicU64, Ordering};
const SHARED_SESSION_PROVIDER: &str = "custom";
const LEGACY_LIMIT: u64 = 32 * 1024 * 1024;
fn temp_codex_dir(label: &str) -> std::path::PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -644,6 +704,383 @@ mod tests {
.expect("write rollout");
}
fn pad_rollout_to_size(path: &Path, size: u64) {
let mut file = fs::OpenOptions::new().append(true).open(path).unwrap();
let mut remaining = size.checked_sub(file.metadata().unwrap().len()).unwrap();
let padding = [b' '; 64 * 1024];
while remaining > 0 {
let count = remaining.min(padding.len() as u64) as usize;
file.write_all(&padding[..count]).unwrap();
remaining -= count as u64;
}
}
fn rollout_digest(path: &Path) -> Vec<u8> {
rollout_digest_from_offset(path, 0)
}
fn rollout_digest_from_offset(path: &Path, offset: u64) -> Vec<u8> {
let mut file = fs::File::open(path).unwrap();
file.seek(SeekFrom::Start(offset)).unwrap();
let mut digest = Sha256::new();
let mut buffer = [0; 64 * 1024];
loop {
let count = file.read(&mut buffer).unwrap();
if count == 0 {
return digest.finalize().to_vec();
}
digest.update(&buffer[..count]);
}
}
fn append_chat_record(path: &Path) {
fs::OpenOptions::new().append(true).open(path).unwrap()
.write_all(b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"keep this conversation\"}}\n")
.unwrap();
}
fn read_first_rollout_record(path: &Path) -> String {
let mut first_record = String::new();
std::io::BufReader::new(fs::File::open(path).unwrap())
.read_line(&mut first_record)
.unwrap();
first_record
}
#[test]
fn streamed_rollouts_below_at_and_above_legacy_limit_sync_logs_databases_and_catalogs() {
for (name, size) in [
("normal", 512),
("below-old-limit", LEGACY_LIMIT - 1),
("exact-old-limit", LEGACY_LIMIT),
("above-old-limit", LEGACY_LIMIT + 1),
] {
let dir = temp_codex_dir(name);
write_config(&dir, "custom");
let id = "019f6000-0000-7000-8000-000000000701";
let rollout = write_rollout(&dir, id, "openai");
let old_record_len = read_first_rollout_record(&rollout).len() as u64;
append_chat_record(&rollout);
pad_rollout_to_size(&rollout, size);
let conversation_before = rollout_digest_from_offset(&rollout, old_record_len);
let database = dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, id, "openai", &rollout);
let catalog = dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[(id, "openai")]);
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
assert!(status.scan_complete, "{name}: {:?}", status.scan_failures);
assert!(status.needs_sync, "{name}");
let result =
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap();
assert!(
result.status.scan_complete && !result.status.needs_sync,
"{name}"
);
assert_eq!(result.updated_rollouts, 1, "{name}");
assert_eq!(thread_provider(&database, id), "custom", "{name}");
assert_eq!(catalog_provider(&catalog, id), "custom", "{name}");
let first_record = read_first_rollout_record(&rollout);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&first_record).unwrap()["payload"]
["model_provider"],
"custom"
);
assert_eq!(fs::metadata(&rollout).unwrap().len(), size, "{name}");
assert_eq!(
rollout_digest_from_offset(&rollout, first_record.len() as u64),
conversation_before,
"conversation bytes must be preserved: {name}"
);
fs::remove_dir_all(dir).unwrap();
}
}
#[test]
fn streamed_ninety_seven_mib_single_chat_line_syncs_without_losing_conversation_bytes() {
let dir = temp_codex_dir("streamed-97-mib-chat-record");
write_config(&dir, "custom");
let id = "019f6000-0000-7000-8000-000000000709";
let rollout = write_rollout(&dir, id, "openai");
let first_record_len = read_first_rollout_record(&rollout).len() as u64;
let total_size = 97 * 1024 * 1024u64;
let prefix =
b"{\"type\":\"event_msg\",\"payload\":{\"type\":\"user_message\",\"message\":\"";
let suffix = b"\"}}\n";
let mut file = fs::OpenOptions::new().append(true).open(&rollout).unwrap();
file.write_all(prefix).unwrap();
let mut remaining =
total_size - first_record_len - prefix.len() as u64 - suffix.len() as u64;
let body_chunk = [b'x'; 64 * 1024];
while remaining > 0 {
let count = remaining.min(body_chunk.len() as u64) as usize;
file.write_all(&body_chunk[..count]).unwrap();
remaining -= count as u64;
}
file.write_all(suffix).unwrap();
file.sync_all().unwrap();
drop(file);
let conversation_before = rollout_digest_from_offset(&rollout, first_record_len);
let database = dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, id, "openai", &rollout);
let catalog = dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[(id, "openai")]);
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None)
.expect("a large valid single JSONL record must synchronize successfully");
assert!(
result.status.scan_complete && !result.status.needs_sync,
"{:?}",
result.status.scan_failures
);
assert_eq!(result.updated_rollouts, 1);
assert_eq!(thread_provider(&database, id), "custom");
assert_eq!(catalog_provider(&catalog, id), "custom");
let first_record = read_first_rollout_record(&rollout);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&first_record).unwrap()["payload"]
["model_provider"],
"custom"
);
assert_eq!(fs::metadata(&rollout).unwrap().len(), total_size);
assert_eq!(
rollout_digest_from_offset(&rollout, first_record.len() as u64),
conversation_before,
"the large chat record must retain every original byte"
);
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn streamed_multiple_rollouts_exceeding_legacy_snapshot_budget_sync_together() {
let dir = temp_codex_dir("streamed-multi-large-rollout-budget");
write_config(&dir, "custom");
let ids = [
"019f6000-0000-7000-8000-000000000711",
"019f6000-0000-7000-8000-000000000712",
"019f6000-0000-7000-8000-000000000713",
];
let file_size = 45 * 1024 * 1024u64;
assert!(file_size * ids.len() as u64 > 128 * 1024 * 1024);
let mut rollouts = Vec::new();
let mut conversation_digests = Vec::new();
for id in ids {
let rollout = write_rollout(&dir, id, "openai");
let first_record_len = read_first_rollout_record(&rollout).len() as u64;
append_chat_record(&rollout);
pad_rollout_to_size(&rollout, file_size);
conversation_digests.push(rollout_digest_from_offset(&rollout, first_record_len));
rollouts.push(rollout);
}
let database = dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, ids[0], "openai", &rollouts[0]);
let conn = Connection::open(&database).unwrap();
for index in 1..ids.len() {
conn.execute(
"INSERT INTO threads (id, model_provider, title, rollout_path) VALUES (?1, 'openai', 'large history', ?2)",
(ids[index], rollouts[index].display().to_string()),
).unwrap();
}
drop(conn);
let catalog = dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &ids.map(|id| (id, "openai")));
let result = sync_sessions_provider_inner(Some(dir.display().to_string()), None)
.expect("the combined size of valid rollouts must not block synchronization");
assert!(
result.status.scan_complete && !result.status.needs_sync,
"{:?}",
result.status.scan_failures
);
assert_eq!(result.updated_rollouts, ids.len());
for index in 0..ids.len() {
assert_eq!(thread_provider(&database, ids[index]), "custom");
assert_eq!(catalog_provider(&catalog, ids[index]), "custom");
let first_record = read_first_rollout_record(&rollouts[index]);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&first_record).unwrap()["payload"]
["model_provider"],
"custom"
);
assert_eq!(fs::metadata(&rollouts[index]).unwrap().len(), file_size);
assert_eq!(
rollout_digest_from_offset(&rollouts[index], first_record.len() as u64),
conversation_digests[index],
"conversation bytes must remain unchanged for {}",
ids[index]
);
}
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn large_invalid_jsonl_rollout_reports_parse_failure_and_keeps_indexes_unchanged() {
for (name, file_provider, database_provider) in [
("file-and-index-old", "openai", "openai"),
("index-already-target", "openai", "custom"),
("first-record-matches", "custom", "custom"),
] {
let dir = temp_codex_dir(name);
write_config(&dir, "custom");
let id = "019f6000-0000-7000-8000-000000000702";
let rollout = write_rollout(&dir, id, file_provider);
fs::OpenOptions::new()
.write(true)
.open(&rollout)
.unwrap()
.set_len(LEGACY_LIMIT + 1)
.unwrap();
let before = rollout_digest(&rollout);
let database = dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, id, database_provider, &rollout);
let catalog = dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[(id, database_provider)]);
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
assert!(!status.scan_complete, "{name}");
assert!(
status
.scan_failures
.iter()
.any(|failure| failure.contains("JSONL")),
"{name}: {:?}",
status.scan_failures
);
let error =
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap_err();
assert!(error.to_string().contains("JSONL"), "{name}: {error}");
assert_eq!(thread_provider(&database, id), database_provider, "{name}");
assert_eq!(catalog_provider(&catalog, id), database_provider, "{name}");
assert_eq!(rollout_digest(&rollout), before, "{name}");
fs::remove_dir_all(dir).unwrap();
}
}
#[test]
fn large_invalid_orphan_blocks_sync_without_changing_normal_logs_or_indexes() {
let dir = temp_codex_dir("large-invalid-orphan-blocked");
write_config(&dir, "custom");
let ordinary_id = "019f6000-0000-7000-8000-000000000703";
let ordinary = write_rollout(&dir, ordinary_id, "openai");
let before = fs::read(&ordinary).unwrap();
let database = dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, ordinary_id, "openai", &ordinary);
let catalog = dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[(ordinary_id, "openai")]);
let orphan_id = "019f6000-0000-7000-8000-000000000704";
let invalid_orphan = write_rollout(&dir, orphan_id, "openai");
fs::OpenOptions::new()
.write(true)
.open(&invalid_orphan)
.unwrap()
.set_len(LEGACY_LIMIT + 1)
.unwrap();
let orphan_before = rollout_digest(&invalid_orphan);
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
assert!(!status.scan_complete && status.needs_sync);
let error =
sync_sessions_provider_inner(Some(dir.display().to_string()), None).unwrap_err();
assert!(error.to_string().contains("JSONL"), "{error}");
assert_eq!(fs::read(&ordinary).unwrap(), before);
assert_eq!(rollout_digest(&invalid_orphan), orphan_before);
assert_eq!(thread_provider(&database, ordinary_id), "openai");
assert_eq!(catalog_provider(&catalog, ordinary_id), "openai");
assert!(catalog_provider_and_visibility(&catalog, orphan_id).is_none());
fs::remove_dir_all(dir).unwrap();
}
#[test]
fn failed_orphan_read_or_parse_cannot_be_reported_as_successful_sync() {
for suffix in [b"\xff".as_slice(), b"not-json\n".as_slice()] {
let dir = temp_codex_dir("failed-orphan-check-blocks-sync");
write_config(&dir, "custom");
let id = "019f6000-0000-7000-8000-000000000705";
let ordinary = write_rollout(&dir, id, "openai");
let original = fs::read(&ordinary).unwrap();
let database = dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, id, "openai", &ordinary);
let catalog = dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[(id, "openai")]);
let orphan = write_rollout(&dir, "019f6000-0000-7000-8000-000000000706", "openai");
fs::OpenOptions::new()
.append(true)
.open(&orphan)
.unwrap()
.write_all(suffix)
.unwrap();
let orphan_original = fs::read(&orphan).unwrap();
let status = session_sync_status_inner(Some(dir.display().to_string()), None).unwrap();
assert!(!status.scan_complete && !status.scan_failures.is_empty());
assert!(sync_sessions_provider_inner(Some(dir.display().to_string()), None).is_err());
assert_eq!(thread_provider(&database, id), "openai");
assert_eq!(catalog_provider(&catalog, id), "openai");
assert_eq!(fs::read(&ordinary).unwrap(), original);
assert_eq!(fs::read(&orphan).unwrap(), orphan_original);
fs::remove_dir_all(dir).unwrap();
}
}
#[test]
fn failed_final_recheck_rolls_back_committed_logs_and_indexes() {
for scenario in ["invalid", "large-invalid", "new-mismatch"] {
let dir = temp_codex_dir("failed-final-recheck");
write_config(&dir, "custom");
let id = "019f6000-0000-7000-8000-000000000707";
let ordinary = write_rollout(&dir, id, "openai");
let original = fs::read(&ordinary).unwrap();
let database = dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, id, "openai", &ordinary);
let catalog = dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[(id, "openai")]);
let late = dir.join("sessions/rollout-test-019f6000-0000-7000-8000-000000000708.jsonl");
let mut late_digest = None;
let error =
sync_sessions_provider_with_hook(Some(dir.display().to_string()), None, |point| {
if matches!(point, MutationPoint::AfterSqliteCommit(_)) && !late.exists() {
if scenario == "invalid" {
fs::write(&late, b"\xff").unwrap();
} else {
write_rollout_at(
&late,
"019f6000-0000-7000-8000-000000000708",
"openai",
);
if scenario == "large-invalid" {
fs::OpenOptions::new()
.write(true)
.open(&late)
.unwrap()
.set_len(LEGACY_LIMIT + 1)
.unwrap();
}
}
late_digest = Some(rollout_digest(&late));
}
Ok(())
})
.expect_err("a failed final check must not return a successful sync");
assert!(
error.to_string().contains("同步后复查未通过"),
"{scenario}: {error}"
);
assert_eq!(thread_provider(&database, id), "openai", "{scenario}");
assert_eq!(catalog_provider(&catalog, id), "openai", "{scenario}");
assert_eq!(fs::read(&ordinary).unwrap(), original, "{scenario}");
assert_eq!(
rollout_digest(&late),
late_digest.unwrap(),
"preserve external file: {scenario}"
);
fs::remove_dir_all(dir).unwrap();
}
}
fn write_subagent_rollout(
codex_dir: &Path,
id: &str,
@@ -672,6 +1109,176 @@ mod tests {
path
}
fn rows_except_parent(
path: &Path,
table: &str,
id_column: &str,
parent: &str,
) -> Vec<Vec<rusqlite::types::Value>> {
let conn = Connection::open(path).expect("open snapshot database");
let mut stmt = conn
.prepare(&format!(
"SELECT * FROM {table} WHERE {id_column} <> ?1 ORDER BY {id_column}"
))
.expect("prepare row snapshot");
let column_count = stmt.column_count();
stmt.query_map([parent], |row| {
(0..column_count).map(|index| row.get(index)).collect()
})
.expect("read row snapshots")
.collect::<std::result::Result<Vec<_>, _>>()
.expect("collect row snapshots")
}
#[test]
fn internal_rollout_evidence_prevents_catalog_reactivation_and_insertion() {
let codex_dir = temp_codex_dir("internal-catalog-regression");
write_config(&codex_dir, "custom");
let parent = "019f6000-0000-7000-8000-000000000900";
let hidden = "019f6000-0000-7000-8000-000000000901";
let missing = "019f6000-0000-7000-8000-000000000902";
let catalog_only = "019f6000-0000-7000-8000-000000000903";
let source_only = "019f6000-0000-7000-8000-000000000904";
let parent_rollout = write_rollout(&codex_dir, parent, "openai");
let state = codex_dir.join("state_10.sqlite");
create_thread_database_with_rollout(&state, parent, "openai", &parent_rollout);
let conn = Connection::open(&state).expect("open regression index");
conn.execute_batch(
"ALTER TABLE threads ADD COLUMN source TEXT;
ALTER TABLE threads ADD COLUMN thread_source TEXT;
ALTER TABLE threads ADD COLUMN parent_thread_id TEXT;
ALTER TABLE threads ADD COLUMN cwd TEXT;",
)
.expect("extend source metadata");
conn.execute("UPDATE threads SET title = ?1, source = 'cli', thread_source = 'user', cwd = '/tmp/project' WHERE id = ?2",
("Review this error: The following is the Codex agent history whose request action you are assessing", parent))
.expect("set genuine parent title");
let mut internal_files = Vec::new();
for (id, rollout_source, sqlite_source) in [
(hidden, serde_json::json!({"internal": "guardian"}), "cli"),
(
missing,
serde_json::json!({"subagent": {"review": null}}),
"cli",
),
(catalog_only, serde_json::json!("cli"), "cli"),
(source_only, serde_json::json!("cli"), "subagent_review"),
] {
let path = write_rollout(&codex_dir, id, "openai");
let record = serde_json::json!({"type":"session_meta", "payload":{
"id": id, "model_provider":"openai", "source":rollout_source, "cwd":"/tmp/private"
}});
fs::write(&path, format!("{record}\n")).expect("write source metadata");
conn.execute("INSERT INTO threads (id, model_provider, title, rollout_path, source, thread_source, cwd)
VALUES (?1, 'openai', 'codex-auto-review', ?2, ?3, NULL, '/tmp/private')",
(id, path.display().to_string(), sqlite_source)).expect("insert internal source");
internal_files.push((
path.clone(),
fs::read(&path).expect("snapshot internal rollout"),
));
}
drop(conn);
let catalog = codex_dir.join("sqlite/codex-dev.db");
create_catalog_database(
&catalog,
&[
(parent, "openai"),
(hidden, "openai"),
(catalog_only, "openai"),
],
);
let conn = Connection::open(&catalog).expect("open regression catalog");
conn.execute(
"UPDATE local_thread_catalog SET missing_candidate = 1 WHERE thread_id = ?1",
[hidden],
)
.expect("keep review hidden");
conn.execute("UPDATE local_thread_catalog SET source_kind = 'internal', thread_source = 'guardian_review', missing_candidate = 1 WHERE thread_id = ?1", [catalog_only])
.expect("mark catalog-only internal source");
drop(conn);
let before_threads = rows_except_parent(&state, "threads", "id", parent);
let before_catalog =
rows_except_parent(&catalog, "local_thread_catalog", "thread_id", parent);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan mixed sources");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert_eq!(status.mismatched_sessions, 1, "{status:?}");
assert!(
status
.sessions
.iter()
.find(|session| session.id == parent)
.expect("parent preview")
.needs_sync
);
for session in status
.sessions
.iter()
.filter(|session| session.id != parent)
{
assert!(session.is_subagent, "internal preview {}", session.id);
assert!(
!session.needs_sync,
"internal must not be pending {}",
session.id
);
}
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("sync only parent");
assert!(!result.status.needs_sync);
assert_eq!(result.updated_rollouts, 1);
assert_eq!(thread_provider(&state, parent), "custom");
assert_eq!(catalog_provider(&catalog, parent), "custom");
assert_eq!(
rows_except_parent(&state, "threads", "id", parent),
before_threads
);
assert_eq!(
rows_except_parent(&catalog, "local_thread_catalog", "thread_id", parent),
before_catalog
);
assert!(catalog_provider_and_visibility(&catalog, missing).is_none());
assert!(catalog_provider_and_visibility(&catalog, source_only).is_none());
for (path, original) in internal_files {
assert_eq!(
fs::read(path).expect("read preserved internal rollout"),
original
);
}
fs::remove_dir_all(codex_dir).expect("remove regression fixture");
}
#[test]
fn authoritative_user_rollout_is_not_hidden_by_an_internal_duplicate() {
let codex_dir = temp_codex_dir("user-internal-duplicate");
write_config(&codex_dir, "custom");
let id = "019f6000-0000-7000-8000-000000000905";
let active = codex_dir.join("sessions/rollout-active-name.jsonl");
write_rollout_at(&active, id, "openai");
let stale = write_subagent_rollout(&codex_dir, id, "old-parent", "openai");
let stale_bytes = fs::read(&stale).expect("snapshot stale internal duplicate");
let database = codex_dir.join("state_10.sqlite");
create_thread_database_with_rollout(&database, id, "openai", &active);
let catalog = codex_dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[]);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan authoritative user");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert_eq!(status.subagent_threads, 0);
assert_eq!(status.mismatched_sessions, 1);
assert!(!status.sessions[0].is_subagent);
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("sync authoritative user");
assert_eq!(result.updated_rollouts, 1);
assert_eq!(thread_provider(&database, id), "custom");
assert_eq!(catalog_provider(&catalog, id), "custom");
assert_eq!(
fs::read(stale).expect("read untouched internal duplicate"),
stale_bytes
);
fs::remove_dir_all(codex_dir).expect("remove duplicate fixture");
}
#[test]
fn live_provider_is_used_as_the_sync_target() {
let codex_dir = temp_codex_dir("live-provider-target");
@@ -773,7 +1380,7 @@ mod tests {
}
#[test]
fn malformed_standard_orphan_rollout_does_not_block_active_sessions() {
fn malformed_standard_orphan_keeps_check_incomplete_without_mutation() {
let codex_dir = temp_codex_dir("malformed-standard-orphan");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let active_id = "019f6000-0000-7000-8000-000000000502";
@@ -783,26 +1390,30 @@ mod tests {
active_id,
SHARED_SESSION_PROVIDER,
);
write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
fs::write(
codex_dir.join(format!("sessions/rollout-test-{orphan_id}.jsonl")),
b"\xff",
)
.expect("write invalid orphan rollout");
let active = write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
let active_before = fs::read(&active).unwrap();
let database = codex_dir.join("state_5.sqlite");
let database_before = fs::read(&database).unwrap();
let orphan = codex_dir.join(format!("sessions/rollout-test-{orphan_id}.jsonl"));
fs::write(&orphan, b"\xff").expect("write invalid orphan rollout");
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan active sessions only");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert!(!status.scan_complete);
assert!(!status.scan_failures.is_empty());
assert_eq!(status.rollout_files, 2);
assert_eq!(status.session_meta_count, 1);
assert!(!status.needs_sync);
assert_eq!(status.warnings.len(), 1);
assert!(sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None).is_err());
assert_eq!(fs::read(&active).unwrap(), active_before);
assert_eq!(fs::read(&database).unwrap(), database_before);
assert_eq!(fs::read(&orphan).unwrap(), b"\xff");
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn malformed_unreferenced_nonstandard_rollout_does_not_block_active_sessions() {
fn malformed_unreferenced_rollout_keeps_check_incomplete_without_mutation() {
let codex_dir = temp_codex_dir("malformed-unreferenced-rollout");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let active_id = "019f6000-0000-7000-8000-000000000504";
@@ -811,19 +1422,23 @@ mod tests {
active_id,
SHARED_SESSION_PROVIDER,
);
write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
fs::write(
codex_dir.join("sessions/rollout-imported-orphan.jsonl"),
b"\xff",
)
.expect("write invalid unreferenced rollout");
let active = write_rollout(&codex_dir, active_id, SHARED_SESSION_PROVIDER);
let active_before = fs::read(&active).unwrap();
let database = codex_dir.join("state_5.sqlite");
let database_before = fs::read(&database).unwrap();
let orphan = codex_dir.join("sessions/rollout-imported-orphan.jsonl");
fs::write(&orphan, b"\xff").expect("write invalid unreferenced rollout");
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("scan referenced sessions only");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert!(!status.scan_complete);
assert!(!status.scan_failures.is_empty());
assert_eq!(status.rollout_files, 2);
assert_eq!(status.session_meta_count, 1);
assert_eq!(status.warnings.len(), 1);
assert!(sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None).is_err());
assert_eq!(fs::read(&active).unwrap(), active_before);
assert_eq!(fs::read(&database).unwrap(), database_before);
assert_eq!(fs::read(&orphan).unwrap(), b"\xff");
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
@@ -860,6 +1475,111 @@ mod tests {
fs::remove_dir_all(codex_dir).expect("remove test directory");
}
#[test]
fn reverted_rollout_sync_uses_stable_thread_id_and_keeps_internal_threads_hidden() {
let codex_dir = temp_codex_dir("reverted-rollout-sync");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let parent = "019f6000-0000-7000-8000-000000000920";
let parent_rollout_id = "019f6000-0000-7000-8000-000000000921";
let internal = "019f6000-0000-7000-8000-000000000922";
let internal_rollout_id = "019f6000-0000-7000-8000-000000000923";
let parent_path = codex_dir.join(format!(
"sessions/rollout-2026-09-17T13-10-16-{parent}_{parent_rollout_id}.jsonl"
));
write_rollout_at(&parent_path, parent, "openai");
let internal_path = codex_dir.join(format!(
"sessions/rollout-2026-09-17T13-10-17-{internal}_{internal_rollout_id}.jsonl"
));
let internal_metadata = serde_json::json!({"type":"session_meta","payload":{
"id":internal, "model_provider":"openai", "source":{"internal":"guardian"}
}});
fs::write(&internal_path, format!("{internal_metadata}\n"))
.expect("write reverted internal rollout");
let internal_bytes = fs::read(&internal_path).expect("snapshot internal rollout");
let database = codex_dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, parent, "openai", &parent_path);
Connection::open(&database).expect("open reverted thread index").execute(
"INSERT INTO threads (id, model_provider, title, rollout_path) VALUES (?1, 'openai', 'internal review', ?2)",
(internal, internal_path.display().to_string()),
).expect("insert internal thread without database source markers");
let before_internal = rows_except_parent(&database, "threads", "id", parent);
let catalog = codex_dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[]);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("check reverted rollouts");
assert!(status.scan_complete, "{:?}", status.scan_failures);
assert!(status
.warnings
.iter()
.all(|warning| !warning.contains("线程 ID 不一致")));
assert_eq!(status.mismatched_sessions, 1);
assert_eq!(status.subagent_threads, 1);
for preview in &status.sessions {
assert_eq!(preview.is_subagent, preview.id == internal);
assert_eq!(preview.needs_sync, preview.id == parent);
}
let result = sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect("synchronize reverted user rollout through transaction guards");
assert!(result.status.scan_complete);
assert!(!result.status.needs_sync);
assert_eq!(result.updated_rollouts, 1);
assert_eq!(thread_provider(&database, parent), SHARED_SESSION_PROVIDER);
assert_eq!(catalog_provider(&catalog, parent), SHARED_SESSION_PROVIDER);
for excluded in [parent_rollout_id, internal, internal_rollout_id] {
assert!(catalog_provider_and_visibility(&catalog, excluded).is_none());
}
assert_eq!(
rows_except_parent(&database, "threads", "id", parent),
before_internal
);
assert_eq!(
fs::read(&internal_path).expect("read unchanged internal rollout"),
internal_bytes
);
let record: serde_json::Value = serde_json::from_str(
fs::read_to_string(&parent_path)
.expect("read synchronized parent")
.trim(),
)
.expect("parse synchronized parent metadata");
assert_eq!(record["payload"]["id"], parent);
assert_eq!(record["payload"]["model_provider"], SHARED_SESSION_PROVIDER);
fs::remove_dir_all(codex_dir).expect("remove reverted rollout fixture");
}
#[test]
fn reverted_rollout_cannot_be_indexed_by_its_distinct_rollout_id() {
let codex_dir = temp_codex_dir("reverted-rollout-wrong-index-id");
write_config(&codex_dir, SHARED_SESSION_PROVIDER);
let thread_id = "019f6000-0000-7000-8000-000000000924";
let rollout_id = "019f6000-0000-7000-8000-000000000925";
let path = codex_dir.join(format!(
"sessions/rollout-2026-09-17T13-10-16-{thread_id}_{rollout_id}.jsonl"
));
write_rollout_at(&path, thread_id, "openai");
let original = fs::read(&path).expect("snapshot original rollout");
let database = codex_dir.join("state_5.sqlite");
create_thread_database_with_rollout(&database, rollout_id, "openai", &path);
let catalog = codex_dir.join("sqlite/codex-dev.db");
create_catalog_database(&catalog, &[]);
let status = session_sync_status_inner(Some(codex_dir.display().to_string()), None)
.expect("check mismatched reverted rollout reference");
assert!(!status.scan_complete);
assert!(status
.scan_failures
.iter()
.any(|failure| failure.contains("线程 ID 不一致")));
sync_sessions_provider_inner(Some(codex_dir.display().to_string()), None)
.expect_err("rollout ID must not substitute for its stable thread ID");
assert_eq!(thread_provider(&database, rollout_id), "openai");
assert_eq!(fs::read(&path).expect("read protected rollout"), original);
assert!(catalog_provider_and_visibility(&catalog, thread_id).is_none());
assert!(catalog_provider_and_visibility(&catalog, rollout_id).is_none());
fs::remove_dir_all(codex_dir).expect("remove invalid reverted reference fixture");
}
#[test]
fn sqlite_referenced_rollout_with_a_different_session_id_blocks_sync() {
let codex_dir = temp_codex_dir("referenced-rollout-id-mismatch");
@@ -1125,7 +1845,7 @@ mod tests {
assert_eq!(result.updated_rollouts, 1);
assert!(result.updated_threads > 0);
assert_eq!(thread_provider(&active, id), SHARED_SESSION_PROVIDER);
assert_eq!(thread_provider(&legacy, id), SHARED_SESSION_PROVIDER);
assert_eq!(thread_provider(&legacy, id), "openai");
assert_eq!(catalog_provider(&catalog, id), SHARED_SESSION_PROVIDER);
assert!(fs::read_to_string(rollout)
.expect("read synchronized active user rollout")
@@ -2,7 +2,10 @@ 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::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 +37,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 +186,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 +212,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 +246,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 +259,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 +286,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 +313,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 +422,86 @@ 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 {
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 +515,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 +554,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");
}
}
}
+277 -95
View File
@@ -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();
+27 -32
View File
@@ -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,
+10 -81
View File
@@ -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,
}
+3 -1
View File
@@ -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)?
};
+27 -7
View File
@@ -4571,7 +4571,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 +4580,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 +4590,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 +4601,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 +4628,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 +4657,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(),
])
);
+176
View File
@@ -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()))?
}
+507 -62
View File
@@ -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", &current);
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"]
);
}
}
+16 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Codex-X",
"version": "0.3.17",
"version": "0.3.23",
"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": {
+20 -261
View File
@@ -1,270 +1,29 @@
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 { check as checkForTauriUpdate } 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: checkForTauriUpdate,
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.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 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;
}
+213
View File
@@ -0,0 +1,213 @@
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 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;
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, 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;
}
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" });
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, logPath: null, takingLonger: false });
try {
const nextUpdate = await this.dependencies.check(options);
await this.replaceUpdate(nextUpdate);
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 {
await this.replaceUpdate(null);
this.setState({ phase: "error", failure: "check" });
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, 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, 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());
}
}
+135 -64
View File
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import {
AlertCircle,
CheckCircle2,
@@ -9,7 +10,7 @@ import {
Sparkles,
} from "lucide-react";
import { INITIAL_APP_UPDATER_STATE, type AppUpdaterState } from "../appUpdater";
import { INITIAL_APP_UPDATER_STATE, isAppUpdateBusy, type AppUpdaterState } from "../appUpdater";
import type { Lang, StartupDiagnostics } from "../types";
import { Button, ModalShell } from "./ui";
@@ -101,7 +102,7 @@ export function UpdateDialog({
latestVersion: latestVersion ?? null,
};
const phase = updaterState.phase;
const isBusy = phase === "downloading" || phase === "installing";
const isBusy = isAppUpdateBusy(phase);
const totalBytes = updaterState.totalBytes;
const hasKnownProgress = totalBytes !== null && totalBytes > 0;
const progress = totalBytes !== null && totalBytes > 0
@@ -118,8 +119,14 @@ export function UpdateDialog({
: "检测到新版本,可前往下载页获取对应平台的安装包。",
downloadingTitle: "正在下载更新",
downloadingDescription: "请保持 Codex-X 打开,下载完成后会自动安装。",
installingTitle: "正在安装更新",
installingDescription: "即将完成,请暂时不要关闭软件。",
verifyingTitle: "正在验证安装包",
verifyingDescription: "下载已完成,正在确认安装包完整可靠。",
preparingTitle: "正在准备更新",
preparingDescription: "正在保存状态并恢复连接配置,完成后会自动退出并开始安装。",
installingTitle: "正在启动安装程序",
installingDescription: "请稍候。如系统询问是否允许安装,请确认授权。",
handedOffTitle: "安装程序已启动",
handedOffDescription: "Codex-X 即将退出,请在安装窗口继续。首次升级旧版时,系统可能需要一次管理员授权。",
readyTitle: "更新已准备好",
readyDescription: "重新启动 Codex-X 即可使用新版本。",
errorTitle: "更新没有完成",
@@ -134,7 +141,16 @@ export function UpdateDialog({
close: "关闭",
updateNow: "立即更新",
downloading: "正在下载",
installing: "正在安装",
verifying: "验证安装包",
preparing: "准备退出",
installing: "启动安装程序",
handedOff: "等待安装程序",
slowTitle: "这一步比平时慢一些",
slowDescription: phase === "downloading"
? "暂时没有收到新的下载数据,正在等待网络恢复。请勿重复点击更新。"
: "仍在等待当前步骤完成。请查看是否有系统授权或安装窗口等待操作,不要重复启动安装。",
detailLabel: "失败原因",
logLabel: "安装日志",
restart: "重新启动",
retry: "重试",
downloadPage: "打开下载页",
@@ -149,8 +165,14 @@ 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",
@@ -165,39 +187,39 @@ 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",
downloadPage: "Open download page",
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();
@@ -238,7 +260,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 +293,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 +310,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" && updaterState.errorMessage && (
<section className="cx-update-notes" role="alert">
<strong>{copy.detailLabel}</strong>
<p>{updaterState.errorMessage}</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 +348,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 +365,11 @@ export type StartupWizardDialogProps = {
export function StartupWizardDialog({
open,
closing,
mode = "startup",
lang,
diagnostics,
diagnosticsError,
configHealthPanel,
configDir,
loading,
onConfigDirChange,
@@ -338,48 +379,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 +472,9 @@ export function StartupWizardDialog({
</article>
);
})}
</div>
</div>}
{configHealthPanel && <div className="cx-startup-config-health">{configHealthPanel}</div>}
</ModalShell>
);
}
+3 -4
View File
@@ -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,87 @@
import { useId } from "react";
import { AlertTriangle, CheckCircle2, FileSearch, FolderOpen, Loader2, RefreshCw, Wrench } from "lucide-react";
import type { 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 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></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 ? "部分问题需要手动检查。为保留你的配置,软件不会猜测或覆盖无法确认的内容。" : "Some issues need a manual check. Settings that cannot be determined safely will be left for you to review."}</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>
{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,34 @@
import { AlertTriangle, ArrowRight, Loader2, Wrench, X } from "lucide-react";
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";
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 ? report.issues[0]?.title : report.issues[0]?.description) || (zh ? "部分配置可能影响 Codex 正常使用。" : "Some settings may prevent Codex from working correctly.")}</p>
{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>;
}
@@ -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>
+122
View File
@@ -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 }) => [code, title, description]).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(); }
},
};
}
+22
View File
@@ -0,0 +1,22 @@
export type ConfigHealthIssue = {
code: string;
title: string;
description: string;
repairable: boolean;
};
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;
};
+356 -65
View File
@@ -2,6 +2,7 @@ import React from "react";
import { flushSync } from "react-dom";
import ReactDOM from "react-dom/client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { Loader2 } from "lucide-react";
import {
SessionManagementPage,
@@ -21,12 +22,15 @@ import {
} from "./components/AppDialogs";
import { PageTransition } from "./components/PageTransition";
import { cx } from "./components/ui";
import { appUpdater, useAppUpdater } from "./appUpdater";
import { appUpdater, isAppUpdateBusy, useAppUpdater } from "./appUpdater";
import { providerProfilesMatch, type ProviderProfile } from "./providerProfiles";
import { orderProviderRows } from "./providerRowOrder";
import { applyProviderRowOrder, orderProviderRows } from "./providerRowOrder";
import { createPresetProvider, getProviderPreset, getProviderPresetVariant } from "./providerPresets";
import { validateProviderModelMappings } from "./components/ProviderModelMappings";
import { createOfficialProfileMonitor } from "./officialProfileMonitor";
import { useConfigHealth } from "./useConfigHealth";
import { ConfigHealthPanel, ConfigHealthStatus } from "./components/ConfigHealthPanel";
import { ConfigHealthToast } from "./components/ConfigHealthToast";
import type {
AboutInfo,
ActionResult,
@@ -574,6 +578,7 @@ function buildProviderTomlPreview(provider: SavedProvider) {
`base_url = "${tomlEscape(baseUrl)}"`,
`wire_api = "${tomlEscape(wireApi)}"`,
`requires_openai_auth = ${provider.requiresOpenaiAuth ? "true" : "false"}`,
`supports_websockets = false`,
].join("\n");
}
@@ -750,9 +755,11 @@ function App() {
const [editingPromptId, setEditingPromptId] = React.useState<string | null>(null);
const [editingBuiltinPrompt, setEditingBuiltinPrompt] = React.useState<BuiltinPromptDetail | null>(null);
const [savedProviders, setSavedProviders] = React.useState<SavedProvider[]>([]);
const [providerOrder, setProviderOrder] = React.useState<{ directory: string; order: string[] }>({ directory: "", order: [] });
const [officialProfiles, setOfficialProfiles] = React.useState<OfficialProfileSummary[]>([]);
const [editingOfficialProfileId, setEditingOfficialProfileId] = React.useState<string | null>(DEFAULT_OFFICIAL_PROFILE_ID);
const [creatingProvider, setCreatingProvider] = React.useState(false);
const [providerCreationBase, setProviderCreationBase] = React.useState("");
const [selectedPresetId, setSelectedPresetId] = React.useState("custom");
const [selectedPresetVariantId, setSelectedPresetVariantId] = React.useState("");
const [officialAuthDirty, setOfficialAuthDirty] = React.useState(false);
@@ -768,6 +775,9 @@ function App() {
const [skillsMcpImportPreview, setSkillsMcpImportPreview] = React.useState<SkillsMcpImportPreview | null>(null);
const [skillsMcpImportOpen, setSkillsMcpImportOpen] = React.useState(false);
const [startupDiagnostics, setStartupDiagnostics] = React.useState<StartupDiagnostics | null>(null);
const [startupDiagnosticsError, setStartupDiagnosticsError] = React.useState("");
const [startupDiagnosticsLoading, setStartupDiagnosticsLoading] = React.useState(false);
const [startupCheckMode, setStartupCheckMode] = React.useState<"startup" | "manual">("startup");
const [startupWizardOpen, setStartupWizardOpen] = React.useState(() => localStorage.getItem(STARTUP_WIZARD_SEEN_KEY) !== "1");
const [startupClosing, setStartupClosing] = React.useState(false);
const [sessionQuery, setSessionQuery] = React.useState("");
@@ -781,6 +791,8 @@ function App() {
const [state, setState] = React.useState<CodexState | null>(null);
const [configDir, setConfigDir] = React.useState("");
const [configDirDraft, setConfigDirDraft] = React.useState("");
const [healthConfigDir, setHealthConfigDir] = React.useState("");
const [settingsGeneralRequest, setSettingsGeneralRequest] = React.useState(0);
const [loading, setLoading] = React.useState(false);
const [refreshing, setRefreshing] = React.useState(true);
const [toast, setToast] = React.useState<string>("");
@@ -788,6 +800,7 @@ function App() {
const [providerForm, setProviderForm] = React.useState<SavedProvider>(defaultProviderForm);
const [providerTomlDraft, setProviderTomlDraft] = React.useState("");
const [providerTomlDirty, setProviderTomlDirty] = React.useState(false);
const [providerCommonConfigDirty, setProviderCommonConfigDirty] = React.useState(false);
const [providerDraftRefreshToken, setProviderDraftRefreshToken] = React.useState(0);
const [providerApiKeyVisible, setProviderApiKeyVisible] = React.useState(false);
const [providerTestingId, setProviderTestingId] = React.useState("");
@@ -809,12 +822,14 @@ function App() {
const [promptModeHelpOpen, setPromptModeHelpOpen] = React.useState(false);
const autoUpdateCheckedRef = React.useRef(false);
const promptImportRef = React.useRef<HTMLInputElement | null>(null);
const skillZipImportRef = React.useRef<HTMLInputElement | null>(null);
const nativeTransferBusyRef = React.useRef(false);
const [sessionExportBusy, setSessionExportBusy] = React.useState(false);
const officialAuthEditorRef = React.useRef<HTMLTextAreaElement | null>(null);
const officialTomlEditorRef = React.useRef<HTMLTextAreaElement | null>(null);
const providerTomlEditorRef = React.useRef<HTMLTextAreaElement | null>(null);
const providerModelsRequestRef = React.useRef(0);
const providerDraftRequestRef = React.useRef(0);
const providerCreationRequestRef = React.useRef(0);
const officialDraftRequestRef = React.useRef(0);
const officialProfilesRequestRef = React.useRef(0);
const officialProfilesLiveRef = React.useRef(officialProfiles);
@@ -822,6 +837,7 @@ function App() {
const officialMonitorReadyRef = React.useRef(false);
officialMonitorReadyRef.current = Boolean(state) && !refreshing;
const savedProvidersRequestRef = React.useRef(0);
const providerOrderRequestRef = React.useRef(0);
const loadingGenerationRef = React.useRef(0);
const loadingTokensRef = React.useRef(new Set<number>());
const actionBusyGenerationRef = React.useRef(0);
@@ -843,6 +859,11 @@ function App() {
const skillsMcpAutoLoadAttemptedRef = React.useRef("");
const skillsMcpRequestRef = React.useRef(0);
const activeConfigDirKeyRef = React.useRef("");
const routingWakeRef = React.useRef<() => void>(() => {});
const routingRequestRefreshRef = React.useRef<() => void>(() => {});
const routingUiRef = React.useRef({ ready: false, editing: false });
routingUiRef.current = { ready: Boolean(state) && !refreshing, editing: providerMode !== "list" };
const themeTransitionTimerRef = React.useRef<number | null>(null);
const providerTomlPreview = React.useMemo(() => buildProviderTomlPreview(providerForm), [providerForm]);
const providerAuthPreview = React.useMemo(() => buildProviderAuthPreview(providerForm), [providerForm]);
@@ -914,10 +935,24 @@ function App() {
savedProvidersRequestRef.current += 1;
setSavedProviders(providers);
}, []);
React.useEffect(() => {
const directory = state?.codexDir || "";
const request = ++providerOrderRequestRef.current;
if (!directory) { setProviderOrder({ directory: "", order: [] }); return; }
void invoke<string[]>("get_provider_order", { configDir: directory }).then((order) => {
if (request === providerOrderRequestRef.current) setProviderOrder({ directory, order });
}).catch((nextError) => {
if (request !== providerOrderRequestRef.current) return;
setProviderOrder({ directory, order: [] });
setError(String(nextError));
});
}, [state?.codexDir]);
const currentInstructionId = instructionIdFromPath(state?.instructionFile, instructionTemplates);
const releaseStatusLabel = React.useMemo(() => {
if (updater.state.phase === "downloading") return lang === "zh" ? "下载中" : "Downloading";
if (updater.state.phase === "installing") return lang === "zh" ? "安装中" : "Installing";
if (updater.state.phase === "verifying") return lang === "zh" ? "验证中" : "Verifying";
if (updater.state.phase === "preparing") return lang === "zh" ? "准备更新" : "Preparing update";
if (updater.state.phase === "installing" || updater.state.phase === "handed-off") return lang === "zh" ? "安装中" : "Installing";
if (updater.state.phase === "ready") return lang === "zh" ? "等待重启" : "Restart required";
if (releaseInfo.status === "checking") return lang === "zh" ? "检查中" : "Checking";
if (releaseInfo.status === "error") return lang === "zh" ? "失败" : "Failed";
@@ -1035,6 +1070,7 @@ function App() {
...providerForm,
id: providerForm.id || customProviderId(providerForm.providerName || providerForm.baseUrl),
},
newProvider: creatingProvider,
configDir: configDir || null,
})
.then((draft) => {
@@ -1043,7 +1079,7 @@ function App() {
.catch(() => {
if (requestId === providerDraftRequestRef.current) setProviderTomlDraft(fallback);
});
}, [configDir, providerDraftRefreshToken, providerForm, providerMode, providerTomlDirty, providerTomlPreview, state?.configText]);
}, [configDir, creatingProvider, providerDraftRefreshToken, providerForm, providerMode, providerTomlDirty, providerTomlPreview, state?.configText]);
React.useEffect(() => {
if (tab === "provider" && providerMode === "form") return;
@@ -1153,10 +1189,11 @@ function App() {
const localRows = React.useMemo(() => {
return savedProviders.map((p) => ({
...p,
model: effectiveActiveProviderId === p.id ? state?.model || p.model : p.model,
source: "local" as const,
isCurrent: effectiveActiveProviderId === p.id,
}));
}, [effectiveActiveProviderId, savedProviders]);
}, [effectiveActiveProviderId, savedProviders, state?.model]);
const currentOfficialProfileId = state?.isOfficialProvider
? state.activeOfficialProfileId || DEFAULT_OFFICIAL_PROFILE_ID
@@ -1179,7 +1216,7 @@ function App() {
source: "official",
providerName: profile.providerName,
baseUrl: "https://chatgpt.com/codex",
model: profile.model || "official",
model: profile.id === currentOfficialProfileId ? profile.model || state?.model || "" : profile.model || "",
apiKey: "",
wireApi: "official",
requiresOpenaiAuth: true,
@@ -1192,7 +1229,7 @@ function App() {
});
const rows = orderProviderRows(officialRow(defaultProfile), detectedRows, localRows);
return [rows[0], ...officialProfiles.filter((profile) => !profile.isDefault).map(officialRow), ...rows.slice(1)];
}, [currentOfficialProfileId, detectedRows, lang, localRows, officialProfiles, state?.officialAuthAvailable]);
}, [currentOfficialProfileId, detectedRows, lang, localRows, officialProfiles, state?.officialAuthAvailable, state?.model]);
const findLocalProviderForRow = React.useCallback((row: ProviderRow) => {
if (row.source === "official") return undefined;
@@ -1219,7 +1256,8 @@ function App() {
};
}, [findLocalProviderForRow, state?.configText]);
const providerPageRows = React.useMemo<ProviderRow[]>(() => providerRows.map((row) => {
const providerPageRows = React.useMemo<ProviderRow[]>(() => applyProviderRowOrder(providerRows,
providerOrder.directory === state?.codexDir ? providerOrder.order : []).map((row) => {
const local = findLocalProviderForRow(row);
return {
id: row.id,
@@ -1227,6 +1265,7 @@ function App() {
providerName: row.providerName,
baseUrl: row.baseUrl,
model: row.model,
modelDisplayName: local?.modelMappings?.find((mapping) => mapping.model === row.model)?.displayName,
apiKey: row.apiKey,
wireApi: row.wireApi,
requiresOpenaiAuth: row.requiresOpenaiAuth,
@@ -1244,7 +1283,32 @@ function App() {
testable: row.source !== "official",
testingKey: `${row.source}-${row.id}`,
};
}), [findLocalProviderForRow, lang, providerCopySourceForRow, providerRows]);
}), [findLocalProviderForRow, lang, providerCopySourceForRow, providerRows, providerOrder, state?.codexDir]);
const saveProviderOrder = async (order: string[]): Promise<boolean> => {
const directory = state?.codexDir;
if (!directory || providerOrder.directory !== directory || loadingTokensRef.current.size || actionBusyTokensRef.current.size) return false;
const previous = providerOrder;
const request = ++providerOrderRequestRef.current;
const actionToken = beginActionBusy("reorderProviders");
setProviderOrder({ directory, order });
try {
const saved = await invoke<string[]>("save_provider_order", { configDir: directory, order });
if (request === providerOrderRequestRef.current && normalizedConfigDirForComparison(directory) === activeConfigDirKeyRef.current) {
setProviderOrder({ directory, order: saved });
setToast(lang === "zh" ? "供应商顺序已保存" : "Provider order saved");
}
return true;
} catch (nextError) {
if (request === providerOrderRequestRef.current && normalizedConfigDirForComparison(directory) === activeConfigDirKeyRef.current) {
setProviderOrder(previous);
setError(String(nextError));
}
return false;
} finally {
endActionBusy(actionToken);
}
};
const visibleSessions = React.useMemo(
() => (sessionStatus?.sessions || []).filter((item) => showInternalSessions || !item.isSubagent),
@@ -1320,6 +1384,7 @@ function App() {
}, [beginLoading, endLoading]);
const refresh = React.useCallback((includeDiagnostics: boolean) => {
if (Array.from(actionBusyTokensRef.current.values()).includes("reorderProviders")) return;
invalidatePromptDetail();
const requestId = ++refreshRequestRef.current;
const profilesRequestId = ++officialProfilesRequestRef.current;
@@ -1330,6 +1395,7 @@ function App() {
skillsMcpLoadedRef.current = "";
skillsMcpAutoLoadAttemptedRef.current = "";
const requestedConfigDir = configDirDraft.trim();
setHealthConfigDir(requestedConfigDir);
const resolvedConfigDir = requestedConfigDir || null;
const activeCodexDir = state?.codexDir || configDir;
setRefreshing(true);
@@ -1346,16 +1412,36 @@ function App() {
sessionLoadRequestRef.current += 1;
if (includeDiagnostics) {
setStartupDiagnostics(null);
setStartupDiagnosticsError("");
setStartupDiagnosticsLoading(true);
void invoke<StartupDiagnostics>("get_startup_diagnostics", { configDir: resolvedConfigDir })
.then((diagnostics) => {
if (requestId === refreshRequestRef.current) setStartupDiagnostics(diagnostics);
if (requestId === refreshRequestRef.current) {
setStartupDiagnostics(diagnostics);
setStartupDiagnosticsLoading(false);
}
})
.catch(() => {
if (requestId === refreshRequestRef.current) setStartupDiagnostics(null);
if (requestId === refreshRequestRef.current) {
setStartupDiagnosticsError("unavailable");
setStartupDiagnosticsLoading(false);
}
});
} else {
setStartupDiagnosticsLoading(false);
}
void invoke<CodexState>("get_codex_state", { configDir: resolvedConfigDir })
void invoke<{ changed: boolean }>("repair_provider_model_catalog", { configDir: resolvedConfigDir })
.then((repair) => {
if (repair.changed && requestId === refreshRequestRef.current) {
setToast(lang === "zh" ? "已修复第三方模型的图片输入能力,请重启 Codex 加载新菜单" : "Third-party model image input repaired. Restart Codex to reload the model menu.");
}
})
.catch((repairError) => {
if (requestId === refreshRequestRef.current) setError(String(repairError));
})
.then(() => invoke<CodexState>("get_codex_state", { configDir: resolvedConfigDir }))
.then((next) => {
if (requestId !== refreshRequestRef.current) return;
if (normalizedConfigDirForComparison(next.codexDir)
@@ -1368,12 +1454,14 @@ function App() {
setCreatingProvider(false);
setEditingDetectedProvider(false);
setProviderTomlDirty(false);
setProviderCommonConfigDirty(false);
setProviderTomlDraft("");
setAvailableProviderModels([]);
setProviderModelsLoading(false);
}
activeConfigDirKeyRef.current = normalizedConfigDirForComparison(next.codexDir);
setConfigDir(next.codexDir);
setHealthConfigDir(next.codexDir);
setConfigDirDraft(next.codexDir);
setState(next);
setRefreshing(false);
@@ -1401,13 +1489,110 @@ function App() {
setRefreshing(false);
setError(String(nextError));
});
}, [clearActionBusy, configDir, configDirDraft, invalidatePromptDetail, state?.codexDir]);
}, [clearActionBusy, configDir, configDirDraft, invalidatePromptDetail, lang, state?.codexDir]);
// Independent of get_codex_state: this must still work when a broken TOML
// prevents the normal app state from loading. No shared loading flags change.
const configHealth = useConfigHealth({
configDir: healthConfigDir,
canCheck: !refreshing && !loading && !actionBusy
&& !(tab === "provider" && providerMode !== "list") && tab !== "toml",
canNotify: !toast && !error && !startupWizardOpen && !updatePromptOpen
&& !loading && !refreshing && !actionBusy
&& !(tab === "provider" && providerMode !== "list") && tab !== "toml",
lang,
reviewing: startupWizardOpen,
onHint: setToast,
onRepaired: () => refresh(startupWizardOpen),
});
React.useEffect(() => {
refresh(startupWizardOpen);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
const directory = configDir;
const scope = normalizedConfigDirForComparison(directory);
if (!scope) return;
let disposed = false;
let inFlight = false;
let pending = false;
let eventRevision = 0;
let unlisten: (() => void) | undefined;
const canRead = () => !disposed
&& routingUiRef.current.ready && !routingUiRef.current.editing
&& document.visibilityState !== "hidden"
&& scope === activeConfigDirKeyRef.current
&& loadingTokensRef.current.size === 0 && actionBusyTokensRef.current.size === 0;
const drain = async () => {
if (!pending || inFlight || !canRead()) return;
pending = false;
inFlight = true;
const eventGeneration = eventRevision;
const refreshGeneration = refreshRequestRef.current;
const loadingGeneration = loadingGenerationRef.current;
const actionGeneration = actionBusyGenerationRef.current;
const profileGeneration = officialProfilesRequestRef.current;
const stillCurrent = () => canRead()
&& refreshGeneration === refreshRequestRef.current
&& loadingGeneration === loadingGenerationRef.current
&& actionGeneration === actionBusyGenerationRef.current
&& eventGeneration === eventRevision;
try {
const [next, profiles] = await Promise.allSettled([
invoke<CodexState>("get_codex_state", { configDir: directory }),
invoke<OfficialProfileSummary[]>("list_official_profiles", { configDir: directory }),
]);
if (!stillCurrent()) {
pending = !disposed;
return;
}
if (next.status === "fulfilled" && normalizedConfigDirForComparison(next.value.codexDir) === scope) {
setState((current) => current && stillCurrent()
&& normalizedConfigDirForComparison(current.codexDir) === scope ? next.value : current);
}
if (profiles.status === "fulfilled" && profileGeneration === officialProfilesRequestRef.current) {
officialProfilesRequestRef.current += 1;
officialProfilesLiveRef.current = profiles.value;
setOfficialProfiles(profiles.value);
}
} catch {
// A transient bridge failure leaves the current screen and drafts intact.
} finally {
inFlight = false;
// Coalesce events arriving during a read. A busy action/editor will
// resume this pending update after it closes, preserving unsaved drafts.
if (pending && canRead()) void drain();
}
};
const wake = () => { void drain(); };
const request = () => { pending = true; eventRevision += 1; wake(); };
routingWakeRef.current = wake;
routingRequestRefreshRef.current = request;
const onFocus = () => request();
const onVisibility = () => { if (document.visibilityState !== "hidden") request(); };
// Backend paths may be canonical while this home is a symlink/alias. Events
// are hints only: always re-read this configured home, never the payload path.
void listen<{ codexDir: string }>("provider-routing-changed", () => request())
.then((release) => { if (disposed) release(); else unlisten = release; }).catch(() => {});
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisibility);
return () => {
disposed = true;
pending = false;
unlisten?.();
if (routingWakeRef.current === wake) routingWakeRef.current = () => {};
if (routingRequestRefreshRef.current === request) routingRequestRefreshRef.current = () => {};
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [configDir]);
React.useEffect(() => {
routingWakeRef.current();
}, [loading, actionBusy, refreshing, providerMode]);
React.useEffect(() => {
const directory = state?.codexDir;
if (!directory || !(tab === "dashboard" || (tab === "provider" && providerMode === "list"))) return;
@@ -1779,10 +1964,11 @@ function App() {
async () => {
let provider = pendingProvider;
if (!providerTomlDirty) {
const draftSource = providerForm.tomlConfig?.trim() || state?.configText?.trim() || "";
const draftSource = providerForm.tomlConfig?.trim() || "";
const providerForDraft = normalizedProviderForm(draftSource);
const latestDraft = await invoke<string>("build_provider_toml_draft", {
provider: providerForDraft,
newProvider: creatingProvider,
configDir: configDir || null,
});
provider = { ...providerForDraft, tomlConfig: latestDraft.trimEnd() };
@@ -1791,7 +1977,7 @@ function App() {
const applyAfterSave = editingDetectedProvider
|| Boolean(editingProviderId && editingProviderId === effectiveActiveProviderId);
const applied = applyAfterSave
? await invoke<ActionResult>("save_active_provider", { provider, configDir: configDir || null })
? await invoke<ActionResult>("save_active_provider", { provider, configDir: configDir || null, applyCommonConfig: providerCommonConfigDirty })
: null;
if (!applyAfterSave) await invoke<SavedProvider>("save_provider", { provider });
const providerList = await invoke<SavedProvider[]>("list_saved_providers");
@@ -1804,6 +1990,7 @@ function App() {
setEditingProviderId(null);
setEditingDetectedProvider(false);
setProviderTomlDirty(false);
setProviderCommonConfigDirty(false);
setToast(applied && pendingProvider.modelMappings?.length
? (lang === "zh" ? "已保存,请重启 Codex 更新模型菜单" : "Saved. Restart Codex to update its model menu")
: applied
@@ -2260,24 +2447,15 @@ function App() {
}
};
const installSkillZipFile = async (file?: File | null) => {
if (!file) return;
if (!file.name.toLowerCase().endsWith(".zip")) {
setError(lang === "zh" ? "请选择 .zip 技能包" : "Please choose a .zip skill package");
return;
}
if (file.size > 20 * 1024 * 1024) {
setError(lang === "zh" ? "ZIP 技能包不能超过 20MB" : "Skill ZIP must be smaller than 20MB");
return;
}
const installSkillZipFile = async () => {
if (nativeTransferBusyRef.current) return;
nativeTransferBusyRef.current = true;
const request = beginSkillsMcpRequest();
const actionToken = beginActionBusy("installSkillZip");
setError("");
try {
const bytes = Array.from(new Uint8Array(await file.arrayBuffer()));
if (!isCurrentSkillsMcpRequest(request.requestId, request.configDirKey)) return;
const result = await invoke<SkillsMcpActionResult>("install_skill_zip", { configDir: request.configDir, fileName: file.name, bytes });
if (!isCurrentSkillsMcpRequest(request.requestId, request.configDirKey)) return;
const result = await invoke<SkillsMcpActionResult | null>("import_skills_mcp_archive", { configDir: request.configDir, lang });
if (!result || !isCurrentSkillsMcpRequest(request.requestId, request.configDirKey)) return;
skillsMcpLoadedRef.current = request.configDirKey;
setSkillsMcpState(result.state);
setToast(result.message);
@@ -2285,11 +2463,59 @@ function App() {
if (isCurrentSkillsMcpRequest(request.requestId, request.configDirKey)) setError(String(e));
} finally {
endActionBusy(actionToken);
if (skillZipImportRef.current) skillZipImportRef.current.value = "";
nativeTransferBusyRef.current = false;
}
};
const exportSkillsMcp = async (kind: "mcp" | "skills") => {
if (nativeTransferBusyRef.current) return;
nativeTransferBusyRef.current = true;
const request = beginSkillsMcpRequest();
const actionToken = beginActionBusy("exportSkillsMcp");
setError("");
try {
const result = await invoke<{ path: string; exportedSkills: number; exportedMcp: number } | null>("export_skills_mcp_archive", { configDir: request.configDir, kind, lang });
if (!result || !isCurrentSkillsMcpRequest(request.requestId, request.configDirKey)) return;
const count = kind === "mcp" ? result.exportedMcp : result.exportedSkills;
setToast(lang === "zh" ? `已导出 ${count} 个 ${kind === "mcp" ? "MCP" : "Skills"}` : `Exported ${count} ${kind === "mcp" ? "MCP servers" : "Skills"}`);
} catch (e) {
if (isCurrentSkillsMcpRequest(request.requestId, request.configDirKey)) setError(String(e));
} finally {
endActionBusy(actionToken);
nativeTransferBusyRef.current = false;
}
};
const exportSessions = async (ids: string[]) => {
if (!ids.length || nativeTransferBusyRef.current) return;
nativeTransferBusyRef.current = true;
setSessionExportBusy(true);
const directory = configDir;
const directoryKey = normalizedConfigDirForComparison(directory);
const generation = refreshRequestRef.current;
const isCurrentExport = () => generation === refreshRequestRef.current && activeConfigDirKeyRef.current === directoryKey;
const actionToken = beginActionBusy("exportSessions");
setError("");
try {
const suggestedName = ids.length === 1
? sessionStatus?.sessions.find((session) => session.id === ids[0])?.title || "Codex-session"
: `Codex-sessions-${new Date().toISOString().slice(0, 10)}`;
const result = await invoke<{ path: string; exportedSessions: number; failedSessions: number; warnings: string[] } | null>("export_codex_sessions", { configDir: directory || null, sessionIds: ids, suggestedName, lang });
if (!result || !isCurrentExport()) return;
const message = lang === "zh" ? `已导出 ${result.exportedSessions} 个会话` : `Exported ${result.exportedSessions} conversation(s)`;
if (result.failedSessions || result.warnings.length) setError(`${message};${result.warnings.join(";")}`);
else setToast(message);
} catch (e) {
if (isCurrentExport()) setError(String(e));
} finally {
endActionBusy(actionToken);
setSessionExportBusy(false);
nativeTransferBusyRef.current = false;
}
};
const openOfficialEdit = async (profileId = DEFAULT_OFFICIAL_PROFILE_ID) => {
providerCreationRequestRef.current += 1;
const requestId = ++officialDraftRequestRef.current;
const actionToken = beginActionBusy("loadOfficialDraft");
const profile = officialProfiles.find((item) => item.id === profileId);
@@ -2420,30 +2646,44 @@ function App() {
}
};
const newCustomProviderForm = (): SavedProvider => ({
const newCustomProviderForm = (configText = providerCreationBase): SavedProvider => ({
...blankProviderForm,
model: state?.model?.trim() || blankProviderForm.model,
wireApi: currentProvider?.wireApi?.trim() || blankProviderForm.wireApi,
requiresOpenaiAuth: currentProvider?.requiresOpenaiAuth ?? blankProviderForm.requiresOpenaiAuth,
tomlConfig: state?.configText?.trim() || "",
tomlConfig: configText.trim(),
});
const openAddProvider = () => {
officialDraftRequestRef.current += 1;
setCreatingProvider(true);
setSelectedPresetId("custom");
setSelectedPresetVariantId("");
setEditingOfficialProfileId(null);
setOfficialAuthDirty(false);
setOfficialForm({ providerName: "", model: state?.model || "gpt-5.5", configText: "", authJson: "" });
const next = newCustomProviderForm();
resetAvailableProviderModels();
setEditingProviderId(null);
setEditingDetectedProvider(false);
setProviderForm(next);
setProviderTomlDraft(next.tomlConfig || buildProviderTomlPreview(next));
setProviderTomlDirty(false);
setProviderMode("form");
const requestId = ++providerCreationRequestRef.current;
const requestedDir = activeConfigDirKeyRef.current;
const contextGeneration = refreshRequestRef.current;
// Read a fresh, shared configuration once for this creation flow. The UI's
// last state may be stale or a legacy provider-only template.
return call(
() => invoke<string>("get_provider_config_base", { configDir: configDir || null }),
(configText) => {
if (requestId !== providerCreationRequestRef.current || requestedDir !== activeConfigDirKeyRef.current
|| contextGeneration !== refreshRequestRef.current) return;
officialDraftRequestRef.current += 1;
setProviderCreationBase(configText);
setCreatingProvider(true);
setSelectedPresetId("custom");
setSelectedPresetVariantId("");
setEditingOfficialProfileId(null);
setOfficialAuthDirty(false);
setOfficialForm({ providerName: "", model: state?.model || "gpt-5.5", configText: "", authJson: "" });
const next = newCustomProviderForm(configText);
resetAvailableProviderModels();
setEditingProviderId(null);
setEditingDetectedProvider(false);
setProviderForm(next);
setProviderTomlDraft(next.tomlConfig || buildProviderTomlPreview(next));
setProviderTomlDirty(false);
setProviderCommonConfigDirty(false);
setProviderMode("form");
},
);
};
const applyProviderPreset = (presetId: string, variantId: string) => {
@@ -2473,12 +2713,14 @@ function App() {
setProviderForm(next);
setProviderTomlDraft(next.tomlConfig || buildProviderTomlPreview(next));
setProviderTomlDirty(false);
setProviderCommonConfigDirty(false);
setProviderApiKeyVisible(false);
setAvailableProviderModels(variant?.models.map((entry) => ({ id: entry.model })) || []);
setProviderMode("form");
};
const openEditProvider = (provider: SavedProvider) => {
providerCreationRequestRef.current += 1;
setCreatingProvider(false);
resetAvailableProviderModels();
setEditingProviderId(provider.id);
@@ -2486,6 +2728,7 @@ function App() {
setProviderForm(provider);
setProviderTomlDraft(provider.tomlConfig?.trim() || buildProviderTomlPreview(provider));
setProviderTomlDirty(false);
setProviderCommonConfigDirty(false);
setProviderMode("form");
};
@@ -2512,6 +2755,7 @@ function App() {
};
const openEditDetectedProvider = (provider: { id: string; providerName: string; baseUrl: string; model: string; apiKey?: string; wireApi: string; requiresOpenaiAuth: boolean }) => {
providerCreationRequestRef.current += 1;
setCreatingProvider(false);
resetAvailableProviderModels();
const id = uniqueId(
@@ -2533,6 +2777,7 @@ function App() {
setProviderForm(next);
setProviderTomlDraft(next.tomlConfig || buildProviderTomlPreview(next));
setProviderTomlDirty(false);
setProviderCommonConfigDirty(false);
setProviderMode("form");
};
@@ -2599,7 +2844,7 @@ function App() {
const syncCount = sessionMismatchCount(status);
setToast(hasMismatches
? (lang === "zh" ? `有 ${syncCount} 条会话需要同步` : `${syncCount} session(s) need syncing`)
: (lang === "zh" ? "全部会话已同步" : "All sessions are synced"));
: (lang === "zh" ? "普通会话已同步" : "User conversations are synced"));
},
);
endActionBusy(actionToken);
@@ -2710,7 +2955,7 @@ function App() {
};
const closeStartupWizard = () => {
localStorage.setItem(STARTUP_WIZARD_SEEN_KEY, "1");
if (startupCheckMode === "startup") localStorage.setItem(STARTUP_WIZARD_SEEN_KEY, "1");
setStartupClosing(true);
window.setTimeout(() => {
setStartupWizardOpen(false);
@@ -2728,6 +2973,14 @@ function App() {
setTab(nextTab);
};
const openConfigurationChecks = () => {
configHealth.dismiss();
setStartupCheckMode("manual");
setStartupClosing(false);
setStartupWizardOpen(true);
refresh(true);
};
return (
<AppShell
activeTab={tab}
@@ -2759,6 +3012,19 @@ function App() {
onDismissMessage={() => setToast("")}
onDismissError={() => setError("")}
/>
{configHealth.noticeVisible && configHealth.notice && <ConfigHealthToast
lang={lang}
report={configHealth.notice}
repairing={configHealth.repairing}
onRepair={() => void configHealth.repair()}
onDismiss={() => configHealth.dismiss(true)}
onOpenSettings={() => {
configHealth.dismiss();
setSettingsGeneralRequest((value) => value + 1);
changeTab("settings");
openConfigurationChecks();
}}
/>}
<UpdateDialog
open={updatePromptOpen && Boolean(releaseInfo.hasUpdate)}
lang={lang}
@@ -2776,11 +3042,25 @@ function App() {
/>
<StartupWizardDialog
open={startupWizardOpen}
mode={startupCheckMode}
closing={startupClosing}
lang={lang}
diagnostics={startupDiagnostics}
diagnosticsError={startupDiagnosticsError ? (lang === "zh" ? "暂时无法读取环境信息,请重新检查。配置检查仍可使用。" : "Environment information is unavailable. Try again; configuration checks remain available.") : ""}
configDir={configDirDraft}
loading={loading || refreshing}
loading={loading || refreshing || startupDiagnosticsLoading || configHealth.checking || configHealth.repairing}
configHealthPanel={normalizedConfigDirForComparison(configDirDraft.trim()) !== normalizedConfigDirForComparison(healthConfigDir)
? <p className="cx-config-health-note" role="status">{lang === "zh" ? "目录已更改,请先点击「重新检查」查看此目录的配置。" : "The directory has changed. Choose Recheck to review its configuration."}</p>
: <ConfigHealthPanel
lang={lang}
report={configHealth.report}
checking={configHealth.checking || loading || refreshing || Boolean(actionBusy)}
repairing={configHealth.repairing}
error={configHealth.error}
onCheck={() => void configHealth.check()}
onRepair={() => void configHealth.repair()}
onOpenConfig={() => void configHealth.openConfig()}
/>}
onConfigDirChange={setConfigDirDraft}
onRecheck={() => refresh(true)}
onSkip={closeStartupWizard}
@@ -2815,7 +3095,7 @@ function App() {
? `${state.agentsPath} (${lang === "zh" ? "追加模式" : "append"})`
: state.instructionFile)
: null}
loading={loading || refreshing}
loading={loading || refreshing || actionBusy === "reorderProviders"}
hasUpdate={Boolean(releaseInfo.status === "ok" && releaseInfo.hasUpdate)}
latestVersion={releaseInfo.latestVersion}
onConfigDirChange={setConfigDirDraft}
@@ -2841,6 +3121,8 @@ function App() {
loading={loading}
testingId={providerTestingId}
actionBusy={actionBusy}
orderBusy={providerOrder.directory !== state.codexDir}
onReorderProviders={saveProviderOrder}
editingProviderId={editingProviderId || (editingDetectedProvider ? providerForm.id : null)}
providerForm={{
apiKey: providerForm.apiKey || "",
@@ -2918,6 +3200,7 @@ function App() {
setCreatingProvider(false);
setEditingDetectedProvider(false);
setProviderTomlDirty(false);
setProviderCommonConfigDirty(false);
}}
onOfficialModelChange={(value) => setOfficialForm((current) => ({ ...current, model: value }))}
onOfficialNameChange={(value) => setOfficialForm((current) => ({ ...current, providerName: value }))}
@@ -2946,10 +3229,11 @@ function App() {
onWireApiChange={(value) => setProviderForm((current) => ({ ...current, wireApi: value }))}
onRequiresAuthChange={(value) => setProviderForm((current) => ({ ...current, requiresOpenaiAuth: value }))}
onToggleApiKeyVisibility={() => setProviderApiKeyVisible((value) => !value)}
onProviderTomlDraftChange={(value) => {
onProviderTomlDraftChange={(value, origin = "manual") => {
providerDraftRequestRef.current += 1;
setProviderTomlDraft(value);
setProviderTomlDirty(true);
if (origin === "manual") setProviderCommonConfigDirty(true);
}}
onResetProviderToml={() => {
providerDraftRequestRef.current += 1;
@@ -2959,6 +3243,7 @@ function App() {
|| providerTomlPreview,
);
setProviderTomlDirty(false);
setProviderCommonConfigDirty(false);
setProviderDraftRefreshToken((token) => token + 1);
}}
onSaveProvider={saveProviderConfig}
@@ -2990,6 +3275,8 @@ function App() {
sessionDeleteConfirmOpen={sessionDeleteConfirmOpen}
sessionDeleteBusy={sessionDeleteBusy}
sessionDeleteSafetyConfirmed={sessionDeleteSafetyConfirmed}
sessionExportBusy={sessionExportBusy}
onExportSessions={exportSessions}
onCheckSessions={checkSessions}
onSyncSessions={syncSessions}
onSessionQueryChange={(value) => {
@@ -3023,7 +3310,6 @@ function App() {
actionBusy={actionBusy}
importOpen={skillsMcpImportOpen}
importPreview={skillsMcpImportPreview}
zipInputRef={skillZipImportRef}
className={tab !== "skillsMcp" ? "page-pane-hidden" : undefined}
onTabChange={setSkillsMcpTab}
onLoad={loadSkillsMcp}
@@ -3031,6 +3317,7 @@ function App() {
onCloseImportPreview={() => setSkillsMcpImportOpen(false)}
onConfirmImport={importExistingSkillsMcp}
onInstallZip={installSkillZipFile}
onExport={exportSkillsMcp}
onCheckUpdates={checkSkillUpdatesAction}
onToggleSkill={toggleSkillEnabled}
onToggleMcp={toggleMcpEnabled}
@@ -3159,8 +3446,7 @@ function App() {
? "success"
: "neutral",
checking: releaseInfo.status === "checking"
|| updater.state.phase === "downloading"
|| updater.state.phase === "installing",
|| isAppUpdateBusy(updater.state.phase),
canOpenReleases: Boolean(releaseInfo.htmlUrl),
}}
onOpenProject={() => openExternalUrl(aboutInfo?.projectUrl || `https://github.com/${FALLBACK_GITHUB_REPO}`)}
@@ -3174,6 +3460,15 @@ function App() {
<SettingsPage
lang={lang}
configDir={configDir}
onChange={async () => { routingRequestRefreshRef.current(); }}
generalRequest={settingsGeneralRequest}
configHealthStatus={<ConfigHealthStatus
lang={lang}
report={configHealth.report}
checking={configHealth.checking || loading || refreshing || Boolean(actionBusy)}
repairing={configHealth.repairing}
error={configHealth.error}
/>}
copy={{
eyebrow: "Settings",
title: t.settings.title,
@@ -3184,11 +3479,11 @@ function App() {
productTitle: t.settings.productName,
productDescription: t.settings.productDesc,
productValue: "Codex-X",
recheckTitle: lang === "zh" ? "首次启动向导" : "First-run wizard",
recheckTitle: lang === "zh" ? "环境与配置检查" : "Environment & configuration check",
recheckDescription: lang === "zh"
? "重新检测 CODEX_HOME、config.toml、auth.json 和 SQLite 会话库。"
: "Recheck CODEX_HOME, config.toml, auth.json and SQLite session stores.",
recheckLabel: lang === "zh" ? "重新检测" : "Recheck",
? "查看 Codex 环境与配置状态,按需检查和修复。"
: "Review your Codex environment and configuration, and repair issues when needed.",
recheckLabel: lang === "zh" ? "检查" : "Check",
restartTitle: lang === "zh" ? "Codex 桌面客户端" : "Codex desktop app",
restartDescription: lang === "zh"
? "重新启动本机的 Codex(ChatGPT)桌面客户端,不会重启 Codex-X。"
@@ -3207,11 +3502,7 @@ function App() {
recheckBusy={loading || refreshing}
restartBusy={restartCodexBusy}
onRestartCodex={restartCodexDesktop}
onRecheck={() => {
localStorage.removeItem(STARTUP_WIZARD_SEEN_KEY);
setStartupWizardOpen(true);
refresh(true);
}}
onRecheck={openConfigurationChecks}
/>
)}
</PageTransition>
@@ -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>;
}
+120 -5
View File
@@ -12,6 +12,7 @@ import {
EyeOff,
FilePlus2,
Gauge,
GripVertical,
Loader2,
PencilLine,
Plus,
@@ -28,6 +29,7 @@ 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,6 +40,7 @@ export type ProviderRow = {
providerName: string;
baseUrl: string;
model: string;
modelDisplayName?: string;
apiKey?: string;
wireApi: string;
requiresOpenaiAuth: boolean;
@@ -149,6 +152,8 @@ export type ProvidersPageProps = {
loading: boolean;
testingId: string;
actionBusy?: string;
orderBusy?: boolean;
onReorderProviders: (order: string[]) => Promise<boolean>;
editingProviderId: string | null;
creatingProvider: boolean;
selectedPresetId: string;
@@ -194,7 +199,7 @@ export type ProvidersPageProps = {
onWireApiChange: (value: string) => void;
onRequiresAuthChange: (value: boolean) => void;
onToggleApiKeyVisibility: () => void;
onProviderTomlDraftChange: (value: string) => void;
onProviderTomlDraftChange: (value: string, origin?: "manual" | "context") => void;
onResetProviderToml: () => void;
onSaveProvider: () => void;
};
@@ -394,6 +399,7 @@ function ListPage({
loading,
testingId,
actionBusy,
orderBusy,
onImportCcSwitch,
onAddProvider,
onEnableProvider,
@@ -401,11 +407,76 @@ 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" | "loading" | "testingId" | "actionBusy" | "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 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 +524,58 @@ 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 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" : ""}${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,6 +588,7 @@ 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>
@@ -868,7 +983,7 @@ 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} />
<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={onResetProviderToml} disabled={formBusy}><RefreshCw size={14} aria-hidden="true" />{copy.resetTomlLabel}</button>
</div>
</div>
+404 -70
View File
@@ -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 ? (
+16 -21
View File
@@ -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">
+31 -7
View File
@@ -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,13 @@ 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 "../styles/utility-pages.css";
export type UtilityLanguage = "zh" | "en";
@@ -112,18 +114,27 @@ export type SettingsPageProps = {
onRestartCodex: () => Promise<boolean>;
recheckBusy?: boolean;
restartBusy?: boolean;
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 +167,14 @@ export function SettingsPage({
onRestartCodex,
recheckBusy = false,
restartBusy = false,
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);
@@ -174,19 +190,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
@@ -227,6 +244,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 +255,7 @@ export function SettingsPage({
{recheckBusy && <Loader2 size={15} className="cx-page-spin" aria-hidden="true" />}
{copy.recheckLabel}
</button>
</div>
)}
/>
@@ -260,7 +280,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
+27
View File
@@ -2,6 +2,33 @@ type OrderedProviderRow = {
isCurrent: boolean;
};
type IdentifiedProviderRow = { source: string; id: string };
export function providerRowKey(row: IdentifiedProviderRow): string {
return `${row.source}:${row.id}`;
}
export function applyProviderRowOrder<Row extends IdentifiedProviderRow>(rows: readonly Row[], order: readonly string[]): Row[] {
const remaining = new Map(rows.map((row) => [providerRowKey(row), row]));
const sorted: Row[] = [];
for (const key of order) {
const row = remaining.get(key);
if (row) {
sorted.push(row);
remaining.delete(key);
}
}
return [...sorted, ...remaining.values()];
}
export function moveProviderRow(order: readonly string[], source: string, target: string, position: "before" | "after"): string[] {
if (source === target || !order.includes(source) || !order.includes(target)) return [...order];
const next = order.filter((key) => key !== source);
const targetIndex = next.indexOf(target);
next.splice(targetIndex + (position === "after" ? 1 : 0), 0, source);
return next;
}
export function orderProviderRows<
Official extends OrderedProviderRow,
Detected extends OrderedProviderRow,
+58
View File
@@ -0,0 +1,58 @@
// Parameter ranges and save/reset semantics adapted from CC Switch (MIT).
// Copyright (c) 2025 Jason Young. See THIRD_PARTY_NOTICES.md.
export const routingFields = [
{ key: "maxRetries", min: 0, max: 10, value: 3, group: "retry", zh: "最大重试次数", en: "Maximum retries", hintZh: "失败后最多再尝试几家供应商,0 表示不重试。", hintEn: "Additional providers to try after a failure. 0 disables retries." },
{ key: "circuitFailureThreshold", min: 1, max: 20, value: 4, group: "retry", zh: "连续失败阈值", en: "Consecutive failures", hintZh: "连续失败达到此次数后,暂时跳过该供应商。", hintEn: "Temporarily skip a provider after this many consecutive failures." },
{ key: "streamingFirstByteTimeout", min: 1, max: 120, value: 60, group: "timeout", zh: "流式首字节超时", en: "First response timeout", hintZh: "等待开始返回内容的时间,单位为秒。", hintEn: "Seconds to wait for the first response data." },
{ key: "streamingIdleTimeout", min: 0, max: 600, value: 120, group: "timeout", zh: "流式静默超时", en: "Stream idle timeout", hintZh: "回复过程中等待下一段内容的秒数;0 表示不限制。", hintEn: "Seconds between response chunks. 0 disables this timeout." },
{ key: "nonStreamingTimeout", min: 60, max: 1200, value: 600, group: "timeout", zh: "非流式总超时", en: "Non-streaming timeout", hintZh: "等待完整回复的最长时间,单位为秒。", hintEn: "Total seconds to wait for a complete non-streaming response." },
{ key: "circuitSuccessThreshold", min: 1, max: 10, value: 2, group: "recovery", zh: "恢复成功次数", en: "Recovery successes", hintZh: "试探恢复时,连续成功几次后恢复正常。", hintEn: "Successful trial requests required to restore normal routing." },
{ key: "circuitTimeoutSeconds", min: 0, max: 300, value: 60, group: "recovery", zh: "恢复等待时间", en: "Recovery wait", hintZh: "暂时跳过后,隔多少秒再尝试。", hintEn: "Seconds to wait before trying an unavailable provider again." },
{ key: "circuitErrorRateThreshold", min: 0, max: 100, value: 60, group: "recovery", zh: "错误率阈值 (%)", en: "Error rate threshold (%)", hintZh: "达到此比例时,也会暂时跳过供应商。", hintEn: "Also skip a provider when its failure rate reaches this percentage." },
{ key: "circuitMinRequests", min: 5, max: 100, value: 10, group: "recovery", zh: "最小请求数", en: "Minimum requests", hintZh: "累计达到此请求数后,才开始判断错误率。", hintEn: "Requests required before evaluating the error rate." },
] as const;
export type RoutingField = typeof routingFields[number]["key"];
export type RoutingSettings = Record<RoutingField, number> & {
version: number; routerEnabled: boolean; takeoverEnabled: boolean; autoFailoverEnabled: boolean;
listenAddress: string; listenPort: number; providerIds: string[];
};
export type RoutingDraft = Omit<RoutingSettings, RoutingField | "listenPort"> & Record<RoutingField | "listenPort", string>;
export function routingDraft(settings: RoutingSettings): RoutingDraft {
const values = Object.fromEntries(routingFields.map(({ key }) => [key, String(key === "circuitErrorRateThreshold" ? Number((settings[key] * 100).toFixed(6)) : settings[key])])) as Record<RoutingField, string>;
return { ...settings, ...values, listenPort: String(settings.listenPort), providerIds: [...settings.providerIds] };
}
export function sameRoutingDraft(a: RoutingDraft, b: RoutingDraft): boolean {
return a.routerEnabled === b.routerEnabled && a.takeoverEnabled === b.takeoverEnabled
&& a.autoFailoverEnabled === b.autoFailoverEnabled && a.listenAddress === b.listenAddress && a.listenPort === b.listenPort
&& routingFields.every(({ key }) => a[key] === b[key])
&& a.providerIds.length === b.providerIds.length && a.providerIds.every((id, index) => id === b.providerIds[index]);
}
export function parseRoutingDraft(draft: RoutingDraft): { settings: RoutingSettings | null; errors: Partial<Record<RoutingField | "listenAddress" | "listenPort" | "providerIds", string>> } {
const errors: ReturnType<typeof parseRoutingDraft>["errors"] = {};
const numbers = {} as Record<RoutingField, number>;
for (const field of routingFields) {
const text = draft[field.key].trim();
const pattern = field.key === "circuitErrorRateThreshold" ? /^\d+(?:\.\d+)?$/ : /^\d+$/;
const value = Number(text);
if (!pattern.test(text) || !Number.isFinite(value) || value < field.min || value > field.max) errors[field.key] = `${field.min}–${field.max}`;
numbers[field.key] = field.key === "circuitErrorRateThreshold" ? value / 100 : value;
}
let address = draft.listenAddress.trim();
if (address.toLowerCase() === "localhost") address = "127.0.0.1";
const ipv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(address) && address.split(".").every((part) => Number(part) <= 255 && String(Number(part)) === part);
let ipv6 = false;
if (address.includes(":")) {
try { ipv6 = new URL(`http://[${address}]/`).hostname.startsWith("["); } catch { /* invalid literal */ }
}
if (!ipv4 && !ipv6) errors.listenAddress = "IPv4 / IPv6 / localhost";
const port = Number(draft.listenPort);
if (!/^\d+$/.test(draft.listenPort.trim()) || port < 1024 || port > 65535) errors.listenPort = "1024–65535";
if (draft.providerIds.length > 64 || new Set(draft.providerIds).size !== draft.providerIds.length) errors.providerIds = "1–64";
return { errors, settings: Object.keys(errors).length ? null : {
...draft, ...numbers, version: 2, listenAddress: address, listenPort: port, providerIds: [...draft.providerIds],
} };
}
@@ -0,0 +1,80 @@
export const SESSION_FOLDER_UNKNOWN_KEY = "__codexx_no_workspace__";
const SESSION_PINS_STORAGE_PREFIX = "codexx.sessionPins.v2";
export type SessionDirectoryIdentities = Readonly<Record<string, string>>;
export type SessionPinPreferences = { folders: string[]; sessions: string[] };
export type SessionDirectoryGroup<T> = { group: string; folderKey: string; items: T[] };
/** Only the backend can confirm that differently spelled paths name one directory. */
export function sessionDirectoryKey(
path?: string | null,
identities?: SessionDirectoryIdentities,
): string {
if (path == null || path === "") return SESSION_FOLDER_UNKNOWN_KEY;
if (identities && Object.prototype.hasOwnProperty.call(identities, path)) {
return identities[path];
}
// Missing/offline directories keep their exact spelling. Case, backslashes,
// roots and spaces can all distinguish legitimate paths on some filesystems.
return `path:${path}`;
}
export function sessionPinStorageKey(codexDir: string, verifiedScope?: string | null) {
// v1 discarded case and cannot safely assign its pins to a particular home.
// A snapshot's file ID alone is not a permanent home identity after deletion.
return `${SESSION_PINS_STORAGE_PREFIX}:${encodeURIComponent(verifiedScope || sessionDirectoryKey(codexDir))}`;
}
export function pinnedSessionDirectoryKeys(folders: readonly string[], identities?: SessionDirectoryIdentities): Set<string> {
const keys = new Set<string>();
for (const stored of folders) {
if (stored === SESSION_FOLDER_UNKNOWN_KEY) keys.add(stored);
else if (stored.startsWith("path:")) keys.add(sessionDirectoryKey(stored.slice(5), identities));
}
return keys;
}
/** Persist original paths so offline/recreated folders retain their pins. */
export function toggleFolderPins(
folders: readonly string[],
path?: string | null,
identities?: SessionDirectoryIdentities,
): string[] {
const currentKey = sessionDirectoryKey(path, identities);
const belongsToCurrentFolder = (stored: string) => pinnedSessionDirectoryKeys([stored], identities).has(currentKey);
if (folders.some(belongsToCurrentFolder)) return folders.filter((stored) => !belongsToCurrentFolder(stored));
return [...new Set([...folders, sessionDirectoryKey(path)])];
}
export function readSessionPinPreferences(
storageKey: string,
storage: Pick<Storage, "getItem">,
): SessionPinPreferences {
try {
const parsed = JSON.parse(storage.getItem(storageKey) || "{}") as Partial<SessionPinPreferences>;
return {
folders: Array.isArray(parsed.folders) ? parsed.folders.filter((value): value is string => typeof value === "string") : [],
sessions: Array.isArray(parsed.sessions) ? parsed.sessions.filter((value): value is string => typeof value === "string") : [],
};
} catch {
return { folders: [], sessions: [] };
}
}
export function mergeSessionDirectoryGroups<T extends { cwd?: string | null }>(
groups: ReadonlyArray<readonly [string, readonly T[]]>,
identities?: SessionDirectoryIdentities,
): SessionDirectoryGroup<T>[] {
const result = new Map<string, SessionDirectoryGroup<T>>();
for (const [group, items] of groups) {
const folderKey = sessionDirectoryKey(items.find((item) => item.cwd)?.cwd, identities);
const existing = result.get(folderKey);
if (existing) existing.items.push(...items);
else result.set(folderKey, { group, folderKey, items: [...items] });
}
return [...result.values()];
}
export function sessionsForDirectoryGroup<T>(groups: ReadonlyArray<SessionDirectoryGroup<T>>, key: string): T[] {
return groups.find((group) => group.folderKey === key)?.items || [];
}
+62
View File
@@ -224,6 +224,12 @@
white-space: pre-wrap;
}
.cx-update-notes .cx-update-log-path {
margin-top: 8px;
overflow-wrap: anywhere;
user-select: text;
}
@keyframes cx-update-progress-indeterminate {
from { transform: translateX(-110%); }
to { transform: translateX(300%); }
@@ -250,6 +256,61 @@
background: #f7f8fa;
}
.cx-startup-path-control--manual {
grid-template-columns: auto minmax(0, 1fr);
}
.cx-startup-config-health {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--ui-border-subtle);
}
.cx-startup-diagnostics-notice {
display: flex;
align-items: flex-start;
gap: 9px;
padding: 12px;
border: 1px solid var(--ui-border-subtle);
border-radius: 8px;
color: var(--ui-text-muted);
background: var(--ui-surface-elevated);
font-size: 12px;
line-height: 1.5;
}
.cx-startup-diagnostics-notice > svg {
flex: none;
margin-top: 1px;
}
.cx-startup-diagnostics-notice strong {
color: var(--ui-text);
font-weight: 650;
}
.cx-startup-diagnostics-notice p {
margin: 0;
overflow-wrap: anywhere;
}
.cx-startup-diagnostics-notice strong + p {
margin-top: 3px;
}
.cx-startup-diagnostics-notice--error {
border-color: color-mix(in srgb, var(--ui-warning) 25%, transparent);
background: color-mix(in srgb, var(--ui-warning) 8%, transparent);
}
.cx-startup-diagnostics-notice--error > svg {
color: var(--ui-warning);
}
.cx-startup-diagnostics-notice + .cx-startup-checks {
margin-top: 10px;
}
.cx-startup-path-control label {
padding-left: 5px;
color: #777d86;
@@ -340,6 +401,7 @@
@media (max-width: 680px) {
.cx-startup-path-control { grid-template-columns: 1fr auto; }
.cx-startup-path-control--manual { grid-template-columns: minmax(0, 1fr); }
.cx-startup-path-control label { grid-column: 1 / -1; }
.cx-startup-checks { grid-template-columns: 1fr; }
}
+127
View File
@@ -0,0 +1,127 @@
.cx-config-health,
.cx-settings-check-actions,
.cx-config-health-toast {
--cx-health-surface: #fff;
--cx-health-text: #303640;
--cx-health-muted: #6b7280;
--cx-health-border: #e3e6eb;
--cx-health-blue: #2563eb;
--cx-health-blue-soft: #eef4ff;
--cx-health-warning: #976216;
--cx-health-warning-soft: #fff8e9;
--cx-health-warning-border: #efdab2;
--cx-health-success: #17845d;
--cx-health-success-soft: #edf8f2;
}
.cx-config-health,
.cx-config-health-toast { color: var(--cx-health-text); background: var(--cx-health-surface); }
.cx-settings-check-actions { display: flex; align-items: center; gap: 12px; }
.cx-config-health {
min-width: 0;
padding: 16px;
border: 1px solid var(--cx-health-border);
border-radius: 8px;
box-shadow: 0 3px 12px rgb(27 35 48 / 3.5%);
}
.cx-config-health-heading {
display: flex;
align-items: center;
gap: 14px;
}
.cx-config-health-icon {
display: grid;
flex: 0 0 38px;
width: 38px;
height: 38px;
place-items: center;
border: 1px solid color-mix(in srgb, var(--cx-health-blue) 16%, var(--cx-health-border));
border-radius: 8px;
color: var(--cx-health-blue);
background: var(--cx-health-blue-soft);
}
.cx-config-health-heading-copy { flex: 1; min-width: 0; }
.cx-config-health h3 { margin: 0; font-size: 14px; font-weight: 680; line-height: 1.4; }
.cx-config-health-heading-copy p,
.cx-config-health-note,
.cx-config-health-error { margin: 5px 0 0; color: var(--cx-health-muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
.cx-config-health-status { display: inline-flex; align-items: center; gap: 5px; flex-shrink: 0; padding: 5px 9px; border-radius: 6px; color: var(--cx-health-muted); background: var(--cx-health-blue-soft); font-size: 12px; font-weight: 600; }
.cx-config-health-status--warning { color: var(--cx-health-warning); background: var(--cx-health-warning-soft); }
.cx-config-health-status--success { color: var(--cx-health-success); background: var(--cx-health-success-soft); }
.cx-config-health-error { margin-top: 14px; color: var(--cx-health-warning); }
.cx-config-health-details { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--cx-health-border); }
.cx-config-health-issues { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
.cx-config-health-issues > li { display: flex; align-items: flex-start; gap: 8px; min-width: 0; }
.cx-config-health-issues > li > svg { flex-shrink: 0; margin-top: 3px; color: var(--cx-health-warning); }
.cx-config-health-issues strong { font-size: 13px; font-weight: 650; line-height: 1.5; overflow-wrap: anywhere; }
.cx-config-health-issues p { margin: 3px 0 0; color: var(--cx-health-muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
.cx-config-health-plan { margin-top: 13px; padding: 11px 13px; border-radius: 7px; background: var(--cx-health-blue-soft); font-size: 12px; line-height: 1.7; }
.cx-config-health-plan strong { font-weight: 600; }
.cx-config-health-plan ul { display: grid; gap: 3px; margin: 5px 0 0; padding-left: 18px; overflow-wrap: anywhere; }
.cx-config-health-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; margin-top: 14px; }
.cx-config-health-footer > p { flex: 1 1 220px; margin: 0; }
.cx-config-health-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.cx-config-health-toast {
position: fixed;
top: 18px;
inset-inline: 0;
z-index: 1200;
display: flex;
align-items: flex-start;
gap: 11px;
width: min(560px, calc(100vw - 32px));
max-height: calc(100vh - 100px);
overflow: auto;
margin-inline: auto;
padding: 16px;
border: 1px solid var(--cx-health-warning-border);
border-radius: 12px;
box-shadow: 0 12px 32px rgb(25 32 44 / 15%), 0 2px 7px rgb(25 32 44 / 8%);
pointer-events: auto;
animation: cx-config-health-enter 180ms ease-out both;
}
.cx-app-shell--mac .cx-config-health-toast { top: 52px; }
.cx-config-health-toast-icon { display: grid; flex: 0 0 34px; width: 34px; height: 34px; place-items: center; border-radius: 9px; color: var(--cx-health-warning); background: var(--cx-health-warning-soft); }
.cx-config-health-toast-content { min-width: 0; flex: 1; }
.cx-config-health-toast-content strong { display: block; color: inherit; font-size: 14px; font-weight: 650; line-height: 1.5; }
.cx-config-health-toast-content p { margin: 5px 0 0; color: var(--cx-health-muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
.cx-config-health-toast-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
.cx-config-health-toast-content p.cx-config-health-toast-plan { margin-top: 9px; padding: 8px 10px; border-radius: 6px; color: var(--cx-health-text); background: var(--cx-health-blue-soft); }
.cx-config-health-toast-content p.cx-config-health-toast-hint { margin-top: 10px; font-size: 11px; }
.cx-config-health-toast-close { display: grid; flex: 0 0 25px; width: 25px; height: 25px; place-items: center; padding: 0; border: 0; border-radius: 5px; color: var(--cx-health-muted); background: transparent; }
.cx-config-health-toast-close:hover:not(:disabled) { color: var(--cx-health-text); background: var(--cx-health-blue-soft); }
.cx-config-health-toast-close:focus-visible { outline: 2px solid var(--cx-health-blue); outline-offset: 2px; }
.cx-config-health-spinner { animation: cx-config-health-spin 900ms linear infinite; }
:root[data-theme="dark"] .cx-config-health,
:root[data-theme="dark"] .cx-settings-check-actions,
:root[data-theme="dark"] .cx-config-health-toast {
--cx-health-surface: #30343c;
--cx-health-text: #e5e7eb;
--cx-health-muted: #acb2be;
--cx-health-border: #454b56;
--cx-health-blue: #91b8fa;
--cx-health-blue-soft: #2b3d59;
--cx-health-warning: #ebc083;
--cx-health-warning-soft: #443c30;
--cx-health-warning-border: #6b573a;
--cx-health-success: #80d4af;
--cx-health-success-soft: #273f38;
}
@media (max-width: 700px) {
.cx-config-health-heading { flex-wrap: wrap; gap: 10px; }
.cx-config-health-heading > .cx-config-health-status { margin-left: 48px; }
.cx-config-health-heading-copy { flex-basis: calc(100% - 48px); }
.cx-config-health-actions { width: 100%; }
}
@keyframes cx-config-health-enter { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } }
@keyframes cx-config-health-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .cx-config-health-toast, .cx-config-health-spinner { animation: none; } }
+83
View File
@@ -603,10 +603,56 @@
background-clip: padding-box;
}
:root[data-theme="dark"] .cx-session-folder-tree,
:root[data-theme="dark"] .cx-session-tree-heading {
color: var(--cx-dark-muted);
background: var(--cx-dark-surface-raised);
border-color: var(--cx-dark-border);
}
:root[data-theme="dark"] .cx-session-results {
background: var(--cx-dark-surface);
}
:root[data-theme="dark"] .cx-session-tree-all,
:root[data-theme="dark"] .cx-session-tree-item {
color: var(--cx-dark-muted);
}
:root[data-theme="dark"] .cx-session-tree-all:hover,
:root[data-theme="dark"] .cx-session-tree-item:hover {
color: var(--cx-dark-text);
background: var(--cx-dark-surface-hover);
}
:root[data-theme="dark"] .cx-session-tree-item--active {
color: var(--cx-dark-blue);
background: var(--cx-dark-blue-surface);
border-color: #5879aa;
}
:root[data-theme="dark"] .cx-session-tree-item--active .cx-session-folder-copy strong {
color: var(--cx-dark-blue);
}
:root[data-theme="dark"] .cx-session-group + .cx-session-group {
border-top-color: var(--cx-dark-surface-recessed);
}
:root[data-theme="dark"] .cx-session-group-heading--selected,
:root[data-theme="dark"] .cx-session-group-heading--partial {
color: var(--cx-dark-blue);
background: var(--cx-dark-blue-surface);
}
:root[data-theme="dark"] .cx-session-folder-copy strong {
color: var(--cx-dark-text);
}
:root[data-theme="dark"] .cx-session-folder-copy small {
color: var(--cx-dark-muted);
}
:root[data-theme="dark"] .cx-session-row {
background: var(--cx-dark-surface);
border-color: var(--cx-dark-border);
@@ -830,6 +876,43 @@
border-right-color: var(--cx-dark-border);
}
:root[data-theme="dark"] .cx-skills-import-summary > div {
background: var(--cx-dark-surface-raised);
border-color: var(--cx-dark-border);
}
:root[data-theme="dark"] .cx-skills-import-summary strong {
color: #9fc2ff;
}
:root[data-theme="dark"] .cx-skills-import-list {
border-color: var(--cx-dark-border);
background: var(--cx-dark-surface);
}
:root[data-theme="dark"] .cx-skills-import-section-head strong,
:root[data-theme="dark"] .cx-skills-import-row strong {
color: #e5e7eb;
}
:root[data-theme="dark"] .cx-skills-import-summary span,
:root[data-theme="dark"] .cx-skills-import-section-head span,
:root[data-theme="dark"] .cx-skills-import-list > p,
:root[data-theme="dark"] .cx-skills-import-row span,
:root[data-theme="dark"] .cx-skills-import-row em {
color: #b6bac2;
}
:root[data-theme="dark"] .cx-skills-import-warnings {
background: #3c352b;
border-color: #655238;
}
:root[data-theme="dark"] .cx-skills-import-warnings > strong,
:root[data-theme="dark"] .cx-skills-import-warnings p {
color: #e8c58d;
}
:root[data-theme="dark"] .cx-prompts-mode-title strong {
color: #d8dbe0;
}
@@ -0,0 +1,128 @@
.cx-failover {
--rf-text: #202a3a; --rf-muted: #566378; --rf-border: #ced7e4; --rf-surface: #ffffff; --rf-inset: #f4f7fb; --rf-input: #ffffff;
--rf-blue: #215adb; --rf-blue-bg: #edf3ff; --rf-blue-border: #a4bff3;
--rf-green: #17643f; --rf-green-bg: #e9f8ef; --rf-green-border: #9cceb0;
--rf-amber: #815109; --rf-amber-bg: #fff5df; --rf-amber-border: #e3c27b;
--rf-red: #b32d37; --rf-red-bg: #fff0f0; --rf-red-border: #e2a6aa;
display: grid; gap: 18px; max-width: 1120px; color: var(--rf-text); font-size: 14px;
}
:root[data-theme="dark"] .cx-failover {
--rf-text: #f1f5fb; --rf-muted: #c0cad9; --rf-border: #525e70; --rf-surface: #303744; --rf-inset: #272e39; --rf-input: #202731;
--rf-blue: #a3c4ff; --rf-blue-bg: #263e65; --rf-blue-border: #6089ca;
--rf-green: #a2e6b9; --rf-green-bg: #233e31; --rf-green-border: #4e8162;
--rf-amber: #f5d191; --rf-amber-bg: #453927; --rf-amber-border: #957647;
--rf-red: #ffb3bc; --rf-red-bg: #4b2c35; --rf-red-border: #9b5d6b;
}
.cx-failover h3, .cx-failover h4, .cx-failover h5, .cx-failover p { margin: 0; }
.cx-failover h3 { color: var(--rf-text); font-size: 20px; font-weight: 750; }
.cx-failover h4 { color: var(--rf-text); font-size: 15px; font-weight: 720; }
.cx-failover-heading { display: flex; justify-content: space-between; gap: 20px; align-items: center; }
.cx-failover-heading p { margin-top: 6px; color: var(--rf-muted); line-height: 1.6; font-size: 13px; }
.cx-failover-card { overflow: hidden; border: 1px solid var(--rf-border); border-radius: 13px; background: var(--rf-surface); box-shadow: 0 3px 12px #00000006; }
.cx-failover-card-heading { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 16px 20px; border-bottom: 1px solid var(--rf-border); background: var(--rf-inset); }
.cx-failover-card-heading h4 { display: flex; align-items: center; gap: 9px; }
.cx-failover-card-heading h4 svg { color: var(--rf-blue); }
.cx-failover-section-note { color: var(--rf-muted); font-size: 12px; }
.cx-failover-toggle-row { display: flex; align-items: center; gap: 13px; padding: 19px 20px; }
.cx-failover-toggle-copy { min-width: 0; flex: 1; }
.cx-failover-toggle-copy label { display: inline-block; color: var(--rf-text); font-size: 14px; font-weight: 710; cursor: pointer; }
.cx-failover-toggle-copy p { margin-top: 5px; line-height: 1.6; color: var(--rf-muted); font-size: 12px; }
.cx-failover-symbol { display: grid; place-items: center; flex: 0 0 40px; width: 40px; height: 40px; color: var(--rf-blue); background: var(--rf-blue-bg); border: 1px solid var(--rf-blue-border); border-radius: 11px; }
.cx-failover-switch { position: relative; flex: 0 0 44px; width: 44px; height: 26px; border: 1px solid #738198; border-radius: 20px; background: #78859a; cursor: pointer; transition: background-color 130ms ease; }
.cx-failover-switch span { position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 4px #0003; transition: transform 130ms ease; }
.cx-failover-switch[aria-checked="true"] { background: #2866ed; border-color: #2866ed; }
.cx-failover-switch[aria-checked="true"] span { transform: translateX(18px); }
.cx-failover-switch:disabled { cursor: not-allowed; border-style: dashed; }
.cx-failover-switch:disabled:not([aria-checked="true"]) { background: #697383; }
.cx-failover-switch:focus-visible, .cx-failover-candidate:focus-visible, .cx-failover input:focus-visible { outline: 2px solid var(--rf-blue); outline-offset: 3px; }
.cx-failover-address-grid { display: grid; grid-template-columns: 1.5fr 1fr; gap: 16px; padding: 0 20px 18px 73px; border-bottom: 1px solid var(--rf-border); }
.cx-failover-address-grid label, .cx-failover-fields label { display: grid; gap: 8px; align-content: start; color: var(--rf-text); font-size: 13px; font-weight: 650; }
.cx-failover input { box-sizing: border-box; width: 100%; min-width: 0; height: 39px; padding: 8px 11px; color: var(--rf-text); background: var(--rf-input); border: 1px solid var(--rf-border); border-radius: 7px; font: inherit; font-size: 14px; font-variant-numeric: tabular-nums; }
.cx-failover input:disabled { color: var(--rf-muted); background: var(--rf-inset); cursor: not-allowed; }
.cx-failover input[aria-invalid="true"] { border-color: var(--rf-red); box-shadow: 0 0 0 1px var(--rf-red); }
.cx-failover label small { font-size: 12px; font-weight: 400; color: var(--rf-muted); line-height: 1.55; }
.cx-failover .cx-failover-field-error { color: var(--rf-red); }
.cx-failover p.cx-failover-inline-hint { padding: 0 20px 16px; color: var(--rf-muted); line-height: 1.6; font-size: 12px; }
.cx-failover-primary { display: flex; gap: 12px; align-items: center; margin: 0 20px 18px; padding: 14px; border-radius: 9px; border: 1px solid var(--rf-border); background: var(--rf-inset); }
.cx-failover-provider-copy { flex: 1; min-width: 0; display: grid; gap: 5px; }
.cx-failover-provider-copy strong, .cx-failover-candidate strong { color: var(--rf-text); font-size: 14px; font-weight: 740; overflow-wrap: anywhere; }
.cx-failover-provider-copy small, .cx-failover-candidate small { color: var(--rf-muted); font-size: 12px; line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cx-failover-label { color: var(--rf-muted); font-size: 12px; }
.cx-failover-state { display: inline-flex; align-items: center; gap: 6px; padding: 5px 9px; flex: 0 0 auto; border-radius: 7px; color: var(--rf-muted); background: var(--rf-inset); border: 1px solid var(--rf-border); font-size: 12px; font-weight: 650; }
.cx-failover-state i, .cx-failover-health i { width: 6px; height: 6px; flex: 0 0 6px; border-radius: 50%; background: currentColor; }
.cx-failover-state--running { color: var(--rf-green); background: var(--rf-green-bg); border-color: var(--rf-green-border); }
.cx-failover-service-address { display: flex; gap: 12px; align-items: center; padding: 0 20px 18px; color: var(--rf-muted); font-size: 12px; }
.cx-failover-service-address code { padding: 6px 10px; border-radius: 6px; background: var(--rf-inset); color: var(--rf-text); overflow-wrap: anywhere; }
.cx-failover-queue-heading { display: flex; align-items: flex-start; gap: 16px; justify-content: space-between; padding: 3px 20px 14px; }
.cx-failover-queue-heading p { margin-top: 7px; font-size: 12px; color: var(--rf-muted); line-height: 1.6; }
.cx-failover-queue-heading > span { color: var(--rf-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.cx-failover-empty { display: grid; gap: 8px; margin: 0 20px; padding: 20px; border-radius: 9px; border: 1px dashed var(--rf-border); background: var(--rf-inset); }
.cx-failover-empty strong { font-size: 14px; font-weight: 650; }
.cx-failover-empty span { color: var(--rf-muted); font-size: 12px; line-height: 1.6; }
.cx-failover-queue { display: grid; gap: 9px; list-style: none; margin: 0 20px; padding: 0; }
.cx-failover-queue > li { display: flex; align-items: center; gap: 12px; padding: 13px; border: 1px solid var(--rf-border); border-radius: 9px; background: var(--rf-surface); }
.cx-failover-queue > li.cx-failover-provider--first { border-color: var(--rf-blue-border); background: var(--rf-blue-bg); }
.cx-failover-order { display: grid; place-items: center; flex: 0 0 34px; width: 34px; height: 30px; border-radius: 7px; color: var(--rf-muted); background: var(--rf-inset); font-size: 12px; font-weight: 750; }
.cx-failover-provider--first .cx-failover-order { background: #2866ed; color: #fff; }
.cx-failover-queue > li.cx-failover-provider--invalid { border-color: var(--rf-red-border); }
.cx-failover-provider--invalid .cx-failover-provider-copy small { color: var(--rf-red); white-space: normal; }
.cx-failover-row-actions { display: flex; gap: 4px; flex: 0 0 auto; }
.cx-failover-health { display: inline-flex; align-items: center; flex: 0 0 auto; gap: 5px; padding: 5px 8px; border-radius: 7px; border: 1px solid var(--rf-border); color: var(--rf-muted); background: var(--rf-inset); font-size: 12px; font-weight: 620; }
.cx-failover-health--closed { background: var(--rf-green-bg); color: var(--rf-green); border-color: var(--rf-green-border); }
.cx-failover-health--half_open { background: var(--rf-amber-bg); color: var(--rf-amber); border-color: var(--rf-amber-border); }
.cx-failover-health--open { background: var(--rf-red-bg); color: var(--rf-red); border-color: var(--rf-red-border); }
.cx-failover-candidates { padding: 18px 20px 20px; }
.cx-failover-candidates > p { padding-top: 10px; font-size: 12px; line-height: 1.6; color: var(--rf-muted); }
.cx-failover-candidate-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; margin-top: 11px; }
.cx-failover-candidate { display: flex; justify-content: space-between; align-items: center; gap: 12px; min-width: 0; padding: 12px; border: 1px solid var(--rf-border); border-radius: 8px; text-align: left; background: var(--rf-inset); color: var(--rf-text); cursor: pointer; }
.cx-failover-candidate > div { min-width: 0; display: grid; gap: 5px; }
.cx-failover-candidate > svg { flex: 0 0 auto; color: var(--rf-blue); }
.cx-failover-candidate:hover:not(:disabled) { border-color: var(--rf-blue); background: var(--rf-blue-bg); }
.cx-failover-candidate:disabled { cursor: not-allowed; border-style: dashed; }
.cx-failover-candidate:disabled svg { color: var(--rf-muted); }
.cx-failover-field-group { padding: 18px 20px 20px; }
.cx-failover-field-group + .cx-failover-field-group { border-top: 1px solid var(--rf-border); }
.cx-failover-field-group h5 { color: var(--rf-blue); margin-bottom: 12px; font-size: 13px; font-weight: 700; }
.cx-failover-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 20px; }
.cx-failover-field-group--timeout .cx-failover-fields { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.cx-failover-field-group--recovery .cx-failover-fields { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.cx-failover-field-input { position: relative; }
.cx-failover-field-input input { padding-right: 68px; }
.cx-failover-field-input > span { position: absolute; right: 24px; top: 11px; color: var(--rf-muted); font-size: 11px; font-weight: 400; pointer-events: none; }
.cx-failover-runtime dl { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; margin: 0; padding: 20px; }
.cx-failover-runtime dt { color: var(--rf-muted); font-size: 12px; }
.cx-failover-runtime dd { margin: 8px 0 0; font-size: 23px; font-weight: 740; font-variant-numeric: tabular-nums; }
.cx-failover-runtime dd span { color: var(--rf-muted); font-size: 18px; font-weight: 500; }
.cx-failover-runtime-current { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; padding: 14px 20px; border-top: 1px solid var(--rf-border); color: var(--rf-muted); font-size: 12px; }
.cx-failover-runtime-current strong { color: var(--rf-text); font-size: 14px; }
.cx-failover-runtime-current time { margin-left: auto; }
.cx-failover-runtime p.cx-failover-runtime-note { display: flex; gap: 7px; margin: 0; padding: 0 20px 16px; color: var(--rf-muted); line-height: 1.65; font-size: 12px; overflow-wrap: anywhere; }
.cx-failover-runtime-note svg { flex: 0 0 auto; margin-top: 2px; }
.cx-failover-message { display: flex; align-items: center; gap: 9px; padding: 13px 15px; border: 1px solid var(--rf-border); border-radius: 9px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.cx-failover-message svg { flex: 0 0 auto; }
.cx-failover-message > div { flex: 1; min-width: 0; display: grid; gap: 3px; }
.cx-failover-message--warning { color: var(--rf-amber); background: var(--rf-amber-bg); border-color: var(--rf-amber-border); }
.cx-failover-message--success { color: var(--rf-green); background: var(--rf-green-bg); border-color: var(--rf-green-border); }
.cx-failover-bottom-note { color: var(--rf-muted); font-size: 12px; line-height: 1.7; }
.cx-failover-save { position: sticky; bottom: 0; z-index: 2; display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 14px 18px; border: 1px solid var(--rf-border); border-radius: 10px; background: var(--rf-surface); box-shadow: 0 -4px 18px #0000000a; }
.cx-failover-save > div:first-child { color: var(--rf-muted); font-size: 12px; line-height: 1.6; }
.cx-failover-save > div:first-child.cx-failover-field-error { color: var(--rf-red); }
.cx-failover-save > div:last-child { display: flex; flex: 0 0 auto; gap: 8px; }
.cx-failover-loading { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 180px; color: var(--rf-muted); font-size: 14px; }
.cx-failover-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); }
@media (max-width: 850px) {
.cx-failover-field-group--recovery .cx-failover-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.cx-failover-queue > li { flex-wrap: wrap; }
.cx-failover-queue .cx-failover-provider-copy { flex-basis: calc(100% - 52px); }
.cx-failover-row-actions { margin-left: auto; }
}
@media (max-width: 620px) {
.cx-failover-address-grid { padding-left: 20px; grid-template-columns: 1fr; }
.cx-failover-candidate-list, .cx-failover-fields, .cx-failover-field-group--timeout .cx-failover-fields { grid-template-columns: 1fr; }
.cx-failover-primary { flex-wrap: wrap; }
.cx-failover-service-address { align-items: flex-start; flex-direction: column; }
.cx-failover-runtime dl { grid-template-columns: 1fr 1fr; }
.cx-failover-save { align-items: stretch; flex-direction: column; }
.cx-failover-save > div:last-child { justify-content: flex-end; }
}
@media (prefers-reduced-motion: reduce) { .cx-failover-switch, .cx-failover-switch span { transition: none; } }
+19 -5
View File
@@ -200,7 +200,7 @@
min-width: 0;
min-height: 78px;
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
grid-template-columns: 18px 42px minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
padding: 14px 16px;
@@ -225,9 +225,23 @@
/* Give official account emails their own centered column. Current-state
badges can change the actions without moving the email in any row. */
.cx-providers-row--official {
grid-template-columns: 42px minmax(0, 1fr) minmax(200px, 300px) minmax(310px, 1fr);
grid-template-columns: 18px 42px minmax(0, 1fr) minmax(200px, 300px) minmax(310px, 1fr);
}
.cx-providers-sort-hint { margin: -7px 1px 12px; color: var(--cx-provider-muted); font-size: 11px; line-height: 1.5; }
.cx-providers-drag-handle { display: grid; place-items: center; width: 22px; height: 34px; margin-left: -2px; padding: 0; border: 0; border-radius: 5px; background: transparent; color: var(--cx-provider-muted); cursor: grab; touch-action: none; user-select: none; }
.cx-providers-drag-handle:hover:not(:disabled) { background: var(--cx-provider-blue-soft); color: var(--cx-provider-blue); }
.cx-providers-drag-handle:active { cursor: grabbing; }
.cx-providers-drag-handle:focus-visible { outline: 2px solid var(--cx-provider-blue); outline-offset: 2px; }
.cx-providers-drag-handle:disabled { opacity: .35; cursor: default; }
.cx-providers-row--dragging { opacity: .45; }
.cx-providers-row--drop-before { box-shadow: inset 0 3px 0 var(--cx-provider-blue); }
.cx-providers-row--drop-after { box-shadow: inset 0 -3px 0 var(--cx-provider-blue); }
.cx-providers-row-model { display: flex; align-items: baseline; gap: 6px; margin-top: 5px; min-width: 0; font-size: 11px; line-height: 1.5; }
.cx-providers-row-model>span { flex-shrink: 0; color: var(--cx-provider-muted); }
.cx-providers-row-model>b { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--cx-provider-text); }
.cx-providers-row-model>code { margin: 0; flex: 0 1 auto; }
.cx-providers-row--official > .cx-providers-login-badge {
width: 100%;
max-width: 100%;
@@ -915,17 +929,17 @@
@media (max-width: 760px) {
.cx-providers-row,
.cx-providers-row--official {
grid-template-columns: 40px minmax(0, 1fr);
grid-template-columns: 18px 40px minmax(0, 1fr);
}
.cx-providers-row--official > .cx-providers-login-badge {
grid-column: 2;
grid-column: 3;
width: min(300px, 100%);
justify-self: start;
}
.cx-providers-row-actions {
grid-column: 2;
grid-column: 3;
justify-content: flex-start;
}
+301 -13
View File
@@ -387,6 +387,146 @@
flex: 0 0 auto;
}
.cx-session-browser {
min-width: 0;
min-height: 0;
height: 0;
flex: 1 1 0;
display: grid;
grid-template-columns: 238px minmax(0, 1fr);
overflow: hidden;
}
.cx-session-browser--flat {
grid-template-columns: minmax(0, 1fr);
}
.cx-session-folder-tree {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid #e6e9ed;
background: #f8f9fb;
}
.cx-session-tree-heading {
min-height: 34px;
display: flex;
align-items: center;
gap: 7px;
padding: 0 11px;
border-bottom: 1px solid #e6e9ed;
color: #767e89;
background: #fafbfc;
font-size: 10px;
font-weight: 720;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.cx-session-tree-items {
min-height: 0;
display: grid;
align-content: start;
gap: 2px;
overflow-y: auto;
padding: 7px;
scrollbar-gutter: stable;
}
.cx-session-tree-all,
.cx-session-tree-item {
min-width: 0;
min-height: 36px;
border: 1px solid transparent;
border-radius: 7px;
color: #626a75;
background: transparent;
}
.cx-session-tree-all {
width: 100%;
display: grid;
grid-template-columns: 17px minmax(0, 1fr) auto;
gap: 7px;
align-items: center;
padding: 0 8px;
font-size: 11px;
font-weight: 690;
text-align: left;
cursor: pointer;
}
.cx-session-tree-all em,
.cx-session-tree-select em {
color: #969da7;
font-size: 10px;
font-style: normal;
font-weight: 650;
}
.cx-session-tree-item {
display: grid;
grid-template-columns: minmax(0, 1fr) 27px;
align-items: center;
}
.cx-session-tree-select {
min-width: 0;
min-height: 42px;
display: grid;
grid-template-columns: 17px minmax(0, 1fr) auto;
gap: 7px;
align-items: center;
padding: 4px 3px 4px 8px;
overflow: hidden;
border: 0;
color: inherit;
background: transparent;
text-align: left;
cursor: pointer;
}
.cx-session-tree-item > .cx-session-pin-action {
width: 25px;
height: 25px;
border-color: transparent;
background: transparent;
}
.cx-session-tree-all:hover,
.cx-session-tree-item:hover {
color: #354052;
background: #f0f3f7;
}
.cx-session-tree-item--active {
border-color: #cfdfff;
color: #2454b3;
background: #edf4ff;
}
.cx-session-tree-item--active .cx-session-folder-copy strong {
color: #2454b3;
}
.cx-session-tree-all:focus-visible,
.cx-session-tree-select:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: -2px;
}
.cx-session-results {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: #ffffff;
}
.cx-session-scroll {
min-width: 0;
min-height: 0;
@@ -426,7 +566,7 @@
.cx-session-column-head,
.cx-session-row {
display: grid;
grid-template-columns: 26px minmax(240px, 1fr) 104px 104px 112px 112px;
grid-template-columns: 26px minmax(200px, 1fr) 104px 104px 112px 104px 70px;
gap: 10px;
align-items: center;
}
@@ -456,9 +596,16 @@
}
.cx-session-table-body {
position: relative;
min-width: 0;
}
.cx-session-virtual-item {
position: absolute;
right: 0;
left: 0;
}
.cx-session-group {
min-width: 0;
}
@@ -470,17 +617,20 @@
.cx-session-group-heading {
min-width: 0;
display: grid;
grid-template-columns: 26px minmax(0, 1fr) auto;
grid-template-columns: 26px minmax(0, 1fr) auto 30px;
gap: 10px;
align-items: center;
min-height: 36px;
min-height: 44px;
padding: 0 12px;
border-bottom: 1px solid #eef0f2;
color: #737b86;
background: #fcfcfd;
font-size: 11px;
font-weight: 680;
cursor: pointer;
}
.cx-session-group-heading--separated {
border-top: 7px solid #f7f8f9;
}
.cx-session-group-heading--selected,
@@ -489,13 +639,61 @@
background: #f5f8ff;
}
.cx-session-group-heading > span {
.cx-session-group-heading--pinned {
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--ui-accent) 72%, transparent);
}
.cx-session-folder-toggle {
min-width: 0;
display: flex;
align-items: center;
gap: 7px;
padding: 0;
overflow: hidden;
border: 0;
color: inherit;
background: transparent;
text-align: left;
cursor: pointer;
}
.cx-session-folder-toggle > svg {
flex: 0 0 auto;
}
.cx-session-folder-copy {
min-width: 0;
display: grid;
gap: 1px;
}
.cx-session-folder-copy strong,
.cx-session-folder-copy small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cx-session-folder-copy strong {
color: #3f4650;
font-size: 11.5px;
font-weight: 720;
}
.cx-session-folder-copy small {
color: #9299a3;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 9.5px;
font-weight: 500;
}
.cx-session-folder-toggle:focus-visible {
border-radius: 4px;
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.cx-session-group-heading em {
color: #969ca5;
font-size: 10px;
@@ -534,6 +732,14 @@
background: #edf4ff;
}
.cx-session-row--pinned:not(.cx-session-row--selected) {
box-shadow: inset 3px 0 0 color-mix(in srgb, var(--ui-accent) 52%, transparent);
}
.cx-session-row--nested .cx-session-row-copy {
padding-left: 4px;
}
.cx-session-row:focus-within {
outline: 1px solid rgba(37, 99, 235, 0.38);
outline-offset: -1px;
@@ -664,6 +870,66 @@
color: #4669a8;
}
.cx-session-row-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
}
.cx-session-row-export,
.cx-session-pin-action {
width: 30px;
height: 30px;
justify-self: end;
display: inline-grid;
place-items: center;
padding: 0;
border: 1px solid var(--ui-border);
border-radius: 7px;
color: var(--ui-text-muted);
background: var(--ui-surface);
cursor: pointer;
}
.cx-session-row-export:hover:not(:disabled),
.cx-session-pin-action:hover:not(:disabled) {
color: var(--ui-accent);
border-color: var(--ui-accent);
background: var(--ui-accent-soft);
}
.cx-session-row-export:focus-visible,
.cx-session-pin-action:focus-visible {
outline: 2px solid var(--ui-accent);
outline-offset: 2px;
}
.cx-session-row-export:disabled,
.cx-session-pin-action:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.cx-session-pin-action--active {
color: var(--ui-accent);
border-color: color-mix(in srgb, var(--ui-accent) 42%, var(--ui-border));
background: var(--ui-accent-soft);
}
.cx-session-load-more {
min-height: 36px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 8px 12px;
border-top: 1px solid #eef0f2;
color: #7e8793;
background: #fafbfc;
font-size: 11px;
}
.cx-session-empty {
min-height: 120px;
flex: 1 1 auto;
@@ -881,12 +1147,20 @@
}
@media (max-width: 1240px) {
.cx-session-column-head,
.cx-session-row {
grid-template-columns: 26px minmax(220px, 1fr) 96px 96px 104px;
.cx-session-browser {
grid-template-columns: 210px minmax(0, 1fr);
}
.cx-session-column-head span:last-child,
.cx-session-browser--flat {
grid-template-columns: minmax(0, 1fr);
}
.cx-session-column-head,
.cx-session-row {
grid-template-columns: 26px minmax(180px, 1fr) 96px 96px 104px 70px;
}
.cx-session-id-heading,
.cx-session-meta--id {
display: none;
}
@@ -927,12 +1201,26 @@
flex: 1 1 auto;
}
.cx-session-column-head,
.cx-session-row {
grid-template-columns: 26px minmax(0, 1fr) 82px;
.cx-session-browser {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(110px, 30%) minmax(0, 1fr);
}
.cx-session-column-head span:nth-child(n + 4),
.cx-session-browser--flat {
grid-template-rows: minmax(0, 1fr);
}
.cx-session-folder-tree {
border-right: 0;
border-bottom: 1px solid #e6e9ed;
}
.cx-session-column-head,
.cx-session-row {
grid-template-columns: 26px minmax(0, 1fr) 82px 70px;
}
.cx-session-column-head span:nth-child(n + 4):not(.cx-session-actions-heading),
.cx-session-meta--provider,
.cx-session-meta--model,
.cx-session-meta--id {
@@ -136,6 +136,10 @@
white-space: nowrap;
}
.cx-skills-list-tools { display: flex; align-items: center; gap: 12px; }
.cx-skills-list-tools > span { color: var(--cx-tool-muted); font-size: 12px; }
.cx-skills-list-tools .ui-button { min-height: 30px; padding: 4px 9px; font-size: 12px; box-shadow: none; }
.cx-skills-spin,
.cx-prompts-spin {
animation: cx-tool-spin 0.8s linear infinite;
+177
View File
@@ -0,0 +1,177 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { configHealthIssueKey, createConfigHealthMonitor, createConfigHealthNoticeRegistry } from "./configHealthMonitor";
import type { ConfigHealthRepairResult, ConfigHealthReport } from "./configHealthTypes";
export function useConfigHealth(options: {
configDir: string;
canCheck: boolean;
canNotify: boolean;
reviewing?: boolean;
lang: "zh" | "en";
onHint: (text: string) => void;
onRepaired: () => void;
}) {
const runtime = useRef(options); runtime.current = options;
const scope = useRef(options.configDir); scope.current = options.configDir;
const mounted = useRef(true);
const revision = useRef(0);
const busyRef = useRef(false);
const monitor = useRef<ReturnType<typeof createConfigHealthMonitor> | null>(null);
const registry = useRef<ReturnType<typeof createConfigHealthNoticeRegistry> | null>(null);
if (!registry.current) {
let storage: Storage | undefined;
try { storage = window.localStorage; } catch { /* In-memory fallback. */ }
registry.current = createConfigHealthNoticeRegistry(storage);
}
const [report, setReport] = useState<ConfigHealthReport | null>(null);
const [notice, setNotice] = useState<ConfigHealthReport | null>(null);
const [checking, setChecking] = useState(false);
const [repairing, setRepairing] = useState(false);
const [error, setError] = useState("");
const [visible, setVisible] = useState(document.visibilityState !== "hidden");
const [modalOpen, setModalOpen] = useState(false);
const modalOpenRef = useRef(modalOpen); modalOpenRef.current = modalOpen;
const reportRef = useRef(report); reportRef.current = report;
const noticeRef = useRef(notice); noticeRef.current = notice;
const noticeTime = useRef<{ issue: string; remaining: number } | null>(null);
const accept = useCallback((next: ConfigHealthReport) => {
if (runtime.current.reviewing && next.status === "issues") registry.current?.mark(next);
setReport((previous) => previous?.fingerprint === next.fingerprint && previous.status === next.status ? previous : next);
const existing = noticeRef.current;
if (next.status === "issues" && existing && configHealthIssueKey(next) === configHealthIssueKey(existing)) {
// Refresh the repair token without shortening an existing notice merely
// because an editor changed a comment or another unrelated setting.
setNotice((previous) => previous?.fingerprint === next.fingerprint ? previous : next);
}
}, []);
useEffect(() => {
if (options.reviewing && report?.status === "issues") registry.current?.mark(report);
}, [options.reviewing, report]);
useEffect(() => {
const update = () => setModalOpen(Boolean(document.querySelector('[aria-modal="true"]')));
const observer = new MutationObserver(update);
observer.observe(document.body, { childList: true, subtree: true });
update();
return () => observer.disconnect();
}, []);
useEffect(() => {
mounted.current = true;
return () => { mounted.current = false; revision.current += 1; };
}, []);
useEffect(() => {
revision.current += 1;
busyRef.current = false;
setReport(null); setNotice(null); setChecking(false); setRepairing(false); setError("");
const directory = options.configDir;
const instance = createConfigHealthMonitor({
read: () => invoke<ConfigHealthReport>("check_codex_config", { configDir: directory || null }),
revision: () => revision.current,
isVisible: () => document.visibilityState !== "hidden",
canRead: () => !busyRef.current && runtime.current.canCheck && scope.current === directory,
canNotify: () => runtime.current.canNotify && !modalOpenRef.current,
onReport: accept,
onRecovered: (next) => { registry.current?.clear(next); setNotice(null); },
onProblem: (next) => {
if (registry.current?.has(next)) return;
registry.current?.mark(next);
noticeTime.current = { issue: configHealthIssueKey(next), remaining: 30_000 };
setNotice(next);
},
});
monitor.current = instance;
const wake = () => {
setVisible(document.visibilityState !== "hidden");
instance.wake();
};
window.addEventListener("focus", wake);
document.addEventListener("visibilitychange", wake);
instance.wake();
return () => {
instance.stop();
if (monitor.current === instance) monitor.current = null;
window.removeEventListener("focus", wake);
document.removeEventListener("visibilitychange", wake);
};
}, [options.configDir, accept]);
useEffect(() => { if (options.canCheck) monitor.current?.wake(); }, [options.canCheck, options.canNotify, modalOpen]);
const dismiss = useCallback((hint = false) => {
setNotice(null); noticeTime.current = null;
if (hint) runtime.current.onHint(runtime.current.lang === "zh"
? "稍后可到「设置 → 通用设置 → 环境与配置检查」处理。"
: "You can check and repair this later in Settings → General → Environment & configuration check.");
}, []);
const noticeVisible = Boolean(notice && report?.status === "issues"
&& configHealthIssueKey(notice) === configHealthIssueKey(report))
&& options.canNotify && visible && !modalOpen;
useEffect(() => {
if (!notice || !noticeVisible || repairing || checking) return;
const issue = configHealthIssueKey(notice);
if (noticeTime.current?.issue !== issue) noticeTime.current = { issue, remaining: 30_000 };
const started = Date.now();
const timer = window.setTimeout(() => dismiss(true), noticeTime.current.remaining);
return () => {
window.clearTimeout(timer);
if (noticeTime.current?.issue === issue) noticeTime.current.remaining = Math.max(0, noticeTime.current.remaining - (Date.now() - started));
};
}, [notice, noticeVisible, repairing, checking, dismiss]);
const check = useCallback(async () => {
if (busyRef.current || !runtime.current.canCheck) return;
const directory = scope.current;
const request = ++revision.current;
busyRef.current = true; setChecking(true); setError("");
const current = () => mounted.current && scope.current === directory && request === revision.current;
try {
const next = await invoke<ConfigHealthReport>("check_codex_config", { configDir: directory || null });
if (current()) {
accept(next);
if (next.status === "issues") registry.current?.mark(next);
if (next.status === "healthy") { registry.current?.clear(next); dismiss(); }
}
} catch (cause) { if (current()) setError(String(cause)); }
finally { if (current()) { busyRef.current = false; setChecking(false); } }
}, [accept, dismiss]);
const repair = useCallback(async () => {
const before = reportRef.current ?? noticeRef.current;
if (!before?.canRepair || busyRef.current || !runtime.current.canCheck) return;
const directory = scope.current;
const request = ++revision.current;
busyRef.current = true; setRepairing(true); setError("");
const current = () => mounted.current && scope.current === directory && request === revision.current;
try {
const result = await invoke<ConfigHealthRepairResult>("repair_codex_config", { configDir: before.codexDir, expectedFingerprint: before.fingerprint });
if (!current()) return;
dismiss(); accept(result.report);
if (result.report.status === "healthy") registry.current?.clear(result.report);
const zh = runtime.current.lang === "zh";
runtime.current.onHint(result.report.status === "healthy"
? (zh ? "配置已检查并修复,请重新打开原来的 Codex 对话。" : "Configuration checked and repaired. Reopen the Codex conversation.")
: (zh ? "可自动处理的部分已修复,其余问题可在通用设置中查看。" : "Supported repairs are complete. Review the remaining issues in General settings."));
runtime.current.onRepaired();
} catch (cause) {
if (current()) {
dismiss(); setError(String(cause));
runtime.current.onHint(runtime.current.lang === "zh" ? "配置未修复,请到「设置 → 通用设置」查看原因或重新检查。" : "Configuration was not repaired. Review the issue or check again in Settings → General.");
}
} finally { if (current()) { busyRef.current = false; setRepairing(false); } }
}, [accept, dismiss]);
const openConfig = useCallback(async () => {
try { await invoke("open_codex_config_file", { configDir: reportRef.current?.codexDir || scope.current || null }); }
catch (cause) { if (mounted.current) setError(String(cause)); }
}, []);
return { report, notice, checking, repairing, error, check, repair, dismiss, openConfig,
noticeVisible };
}
@@ -0,0 +1,200 @@
import assert from "node:assert/strict";
import test from "node:test";
import { AppUpdaterController, isAppUpdateBusy, UPDATER_SLOW_NOTICE_MS } from "../src/appUpdaterController.ts";
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
function fixture() {
const counts = { checks: 0, installs: 0, restarts: 0, closes: 0 };
const timers = new Map();
let timerId = 0;
let update = { rid: 12, currentVersion: "0.3.20", version: "0.3.21", body: "Update notes", close: async () => { counts.closes++; } };
let operation;
let checkOperation;
let restartOperation;
const controller = new AppUpdaterController({
check: async () => { counts.checks++; return checkOperation ? checkOperation.promise : update; },
install: (value, onEvent, options) => {
counts.installs++;
operation = { ...deferred(), value, onEvent, options };
return operation.promise;
},
restart: () => { counts.restarts++; return restartOperation?.promise || Promise.resolve(); },
setTimer: (callback, delay) => { assert.equal(delay, UPDATER_SLOW_NOTICE_MS); timers.set(++timerId, callback); return timerId; },
clearTimer: (id) => { timers.delete(id); },
});
return {
controller, counts, timers,
get operation() { return operation; },
setCheckOperation(value) { checkOperation = value; },
setRestartOperation(value) { restartOperation = value; },
setUpdate(value) { update = value; },
waitLonger() { for (const [id, callback] of timers) { timers.delete(id); callback(); } },
};
}
test("download, verification and shutdown preparation are distinct; ready requires installation result", async () => {
const f = fixture();
assert.equal(await f.controller.check(), "available");
const job = f.controller.downloadAndInstall({ timeout: 4000 });
assert.equal(f.operation.options.timeout, 4000);
f.operation.onEvent({ event: "Started", data: { contentLength: 100 } });
f.operation.onEvent({ event: "Progress", data: { chunkLength: 100 } });
assert.equal(f.controller.getSnapshot().phase, "downloading");
for (const [event, phase] of [["Verifying", "verifying"], ["Preparing", "preparing"], ["Installing", "installing"]]) {
f.operation.onEvent({ event });
assert.equal(f.controller.getSnapshot().phase, phase);
assert.equal(isAppUpdateBusy(phase), true);
}
f.operation.resolve({ restartRequired: true });
assert.equal(await job, "ready");
assert.equal(f.controller.getSnapshot().phase, "ready");
assert.equal(f.timers.size, 0);
});
test("clicking repeatedly and checking during an update cannot launch a second installer", async () => {
const f = fixture();
await f.controller.check();
const job = f.controller.downloadAndInstall();
assert.equal(f.controller.downloadAndInstall(), job);
assert.equal(f.controller.retry(), job);
assert.equal(await f.controller.check({ force: true }), "available");
assert.equal(f.counts.checks, 1);
f.operation.onEvent({ event: "Preparing" });
assert.equal(f.controller.downloadAndInstall(), job);
f.operation.resolve({ restartRequired: true });
await job;
await f.controller.downloadAndInstall();
await f.controller.check({ force: true });
assert.equal(f.counts.installs, 1);
assert.equal(f.counts.checks, 1);
assert.equal(f.counts.closes, 0);
});
test("preparation failure is not success; shows safe details and allows a deliberate retry", async () => {
const f = fixture();
await f.controller.check();
const job = f.controller.downloadAndInstall();
f.operation.onEvent({ event: "Preparing" });
f.operation.reject({ stage: "prepare", message: "无法恢复连接配置,尚未启动安装程序。", logPath: "C:\\Logs\\update.log" });
assert.equal(await job, "error");
assert.equal(f.controller.getSnapshot().failure, "prepare");
assert.equal(f.controller.getSnapshot().errorMessage, "无法恢复连接配置,尚未启动安装程序。");
assert.equal(f.controller.getSnapshot().logPath, "C:\\Logs\\update.log");
assert.equal(f.timers.size, 0);
const retry = f.controller.retry();
assert.equal(f.counts.installs, 2);
assert.equal(f.controller.getSnapshot().errorMessage, null);
f.operation.resolve({ restartRequired: true });
assert.equal(await retry, "ready");
});
test("signature rejection never displays success or exposes an arbitrary plugin error", async () => {
const f = fixture();
await f.controller.check();
const job = f.controller.downloadAndInstall();
f.operation.onEvent({ event: "Verifying" });
f.operation.reject(new Error("signature failed at https://private.example.test?token=secret"));
assert.equal(await job, "error");
assert.equal(f.controller.getSnapshot().failure, "verify");
assert.equal(f.controller.getSnapshot().errorMessage, null);
});
test("successful handoff followed by an IPC disconnect must not offer or start another installation", async () => {
const f = fixture();
await f.controller.check();
const job = f.controller.downloadAndInstall();
f.operation.onEvent({ event: "HandedOff" });
f.operation.reject(new Error("IPC disconnected as the app exits"));
assert.equal(await job, "handed-off");
assert.equal(f.controller.getSnapshot().failure, null);
assert.equal(isAppUpdateBusy(f.controller.getSnapshot().phase), true);
assert.equal(await f.controller.retry(), "handed-off");
assert.equal(await f.controller.restart(), "handed-off");
f.waitLonger();
assert.equal(f.controller.getSnapshot().takingLonger, true);
assert.equal(f.counts.installs, 1);
assert.equal(f.counts.restarts, 0);
});
test("handoff result without restart remains pending installer completion, not installed", async () => {
const f = fixture();
await f.controller.check();
const job = f.controller.downloadAndInstall();
f.operation.resolve({ restartRequired: false });
assert.equal(await job, "handed-off");
assert.equal(await f.controller.downloadAndInstall(), "handed-off");
assert.equal(f.counts.installs, 1);
});
test("slow notices never abort a pending operation and new progress clears the notice", async () => {
const f = fixture();
await f.controller.check();
const job = f.controller.downloadAndInstall();
f.waitLonger();
assert.equal(f.controller.getSnapshot().takingLonger, true);
assert.equal(f.controller.getSnapshot().phase, "downloading");
assert.equal(f.controller.retry(), job);
f.operation.onEvent({ event: "Progress", data: { chunkLength: 12 } });
assert.equal(f.controller.getSnapshot().takingLonger, false);
f.operation.onEvent({ event: "Preparing" });
f.waitLonger();
assert.equal(f.controller.getSnapshot().takingLonger, true);
assert.equal(f.controller.getSnapshot().phase, "preparing");
assert.equal(f.counts.installs, 1);
f.operation.resolve({ restartRequired: true });
await job;
assert.equal(f.controller.getSnapshot().takingLonger, false);
});
test("late events from a failed attempt do not corrupt the retried download", async () => {
const f = fixture();
await f.controller.check();
const first = f.controller.downloadAndInstall();
const old = f.operation;
old.reject({ stage: "download", message: "Network unavailable" });
await first;
const second = f.controller.retry();
old.onEvent({ event: "HandedOff" });
old.onEvent({ event: "Progress", data: { chunkLength: 900 } });
assert.equal(f.controller.getSnapshot().phase, "downloading");
assert.equal(f.controller.getSnapshot().downloadedBytes, 0);
f.operation.resolve({ restartRequired: true });
await second;
});
test("only a confirmed ready update can relaunch, and failed relaunch retries only relaunch", async () => {
const f = fixture();
await f.controller.check();
assert.equal(await f.controller.restart(), "available");
const job = f.controller.downloadAndInstall();
f.operation.resolve({ restartRequired: true });
await job;
const failed = deferred();
f.setRestartOperation(failed);
const restart = f.controller.restart();
failed.reject(new Error("blocked"));
assert.equal(await restart, "error");
assert.equal(f.controller.getSnapshot().failure, "restart");
f.setRestartOperation(null);
assert.equal(await f.controller.retry(), "ready");
assert.equal(f.controller.getSnapshot().phase, "ready");
assert.equal(f.counts.restarts, 2);
assert.equal(f.counts.installs, 1);
});
test("a pending forced check cannot install its stale update resource", async () => {
const f = fixture();
await f.controller.check();
const next = deferred();
f.setCheckOperation(next);
const check = f.controller.check({ force: true });
assert.equal(await f.controller.downloadAndInstall(), "checking");
assert.equal(f.counts.installs, 0);
next.resolve(null);
await check;
assert.equal(f.counts.closes, 1);
});
@@ -0,0 +1,323 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createConfigHealthMonitor, createConfigHealthNoticeRegistry } from "../src/configHealthMonitor.ts";
function deferred() {
let resolve, reject;
const promise = new Promise((done, fail) => { resolve = done; reject = fail; });
return { promise, resolve, reject };
}
const flush = async () => { await Promise.resolve(); await Promise.resolve(); };
const issue = { code: "missing-provider", title: "供应商配置缺失", description: "选中的供应商没有对应配置。", repairable: true };
function report(status = "issues", overrides = {}) {
return {
codexDir: "/fixtures/codex", fingerprint: `${status}-file`, status,
issues: status === "issues" ? [issue] : [], canRepair: status === "issues",
repairSummary: status === "issues" ? ["恢复选中的供应商配置"] : [],
checkedAt: "2026-09-10T00:00:00Z", ...overrides,
};
}
function harness(callbacks = {}) {
const requests = [], applied = [], problems = [], timers = new Map();
let timerId = 0;
const state = { visible: true, readable: true, notifiable: true, revision: 0, now: 0 };
const monitor = createConfigHealthMonitor({
read: () => { const request = deferred(); requests.push(request); return request.promise; },
onReport: (value) => { applied.push(value); callbacks.onReport?.(value); },
onProblem: (value) => { problems.push(value); callbacks.onProblem?.(value); },
onRecovered: callbacks.onRecovered,
revision: () => state.revision,
isVisible: () => state.visible,
canRead: () => state.readable,
canNotify: () => state.notifiable,
now: () => state.now,
intervalMs: 20_000,
settleMs: 1_000,
schedule: (callback, delay) => {
const id = ++timerId;
timers.set(id, { callback, delay, at: state.now + delay });
return () => timers.delete(id);
},
});
function tick() {
assert.equal(timers.size, 1, "only one background wake should be scheduled");
const timer = timers.values().next().value;
timers.clear();
state.now = timer.at;
timer.callback();
}
async function finish(value, index = requests.length - 1) { requests[index].resolve(value); await flush(); }
async function fail(index = requests.length - 1) { requests[index].reject(new Error("configuration is being replaced")); await flush(); }
return { ...monitor, requests, applied, problems, timers, state, tick, finish, fail };
}
test("healthy, missing, and unreadable reports update settings without a background warning", async () => {
const h = harness();
h.wake();
for (const status of ["healthy", "healthy", "missing", "unavailable"]) {
await h.finish(report(status));
assert.deepEqual(h.problems, []);
assert.equal(h.timers.values().next().value.delay, 20_000);
h.tick();
}
assert.deepEqual(h.applied.map((value) => value.status), ["healthy", "healthy", "missing", "unavailable"]);
h.stop();
await h.finish(report());
assert.equal(h.timers.size, 0);
});
test("recovery needs a stable healthy file; a temporary deletion or empty rewrite cannot reset notification history", async () => {
const recovered = [];
const h = harness({ onRecovered: (value) => recovered.push(value) });
h.wake();
await h.finish(report());
h.tick(); await h.finish(report("missing"));
h.tick(); await h.finish(report("healthy", { fingerprint: "temporary-empty-file" }));
h.tick(); await h.finish(report());
assert.equal(recovered.length, 0);
h.tick(); await h.finish(report("healthy"));
h.state.now += 300; h.wake(); await h.finish(report("healthy"));
assert.equal(recovered.length, 0);
h.tick(); await h.finish(report("healthy"));
assert.deepEqual(recovered.map((value) => value.fingerprint), ["healthy-file"]);
h.stop();
});
test("temporary editor errors stay silent and a stable problem needs two separated reads", async () => {
const h = harness();
h.wake();
await h.finish(report());
assert.equal(h.problems.length, 0);
assert.equal(h.timers.values().next().value.delay, 1_000);
h.tick();
await h.finish(report("healthy"));
assert.equal(h.problems.length, 0);
h.tick();
await h.finish(report());
assert.equal(h.problems.length, 0, "an earlier repaired problem must not count as confirmation");
h.tick();
await h.finish(report());
assert.equal(h.problems.length, 1);
h.stop();
});
test("a changing file restarts confirmation and rapid focus events do not bypass settling", async () => {
const h = harness();
const first = report("issues", { fingerprint: "partial-write-a" });
const final = report("issues", { fingerprint: "final-write-b" });
h.wake();
await h.finish(first);
h.state.now = 400;
h.wake();
await h.finish(final);
h.state.now = 600;
h.wake();
await h.finish(final);
assert.equal(h.problems.length, 0);
assert.equal(h.timers.values().next().value.delay, 800);
h.tick();
await h.finish(final);
assert.deepEqual(h.problems, [final]);
assert.equal(h.state.now, 1_400);
h.stop();
});
test("focus and visibility wakeups coalesce into one request and discard superseded results", async () => {
const h = harness();
h.wake(); h.wake(); h.wake();
assert.equal(h.requests.length, 1);
await h.finish(report());
assert.equal(h.applied.length, 0);
assert.equal(h.problems.length, 0);
assert.equal(h.requests.length, 2, "pending wakeups must produce only one new request");
await h.finish(report("healthy"));
assert.deepEqual(h.applied.map((value) => value.status), ["healthy"]);
assert.equal(h.timers.size, 1);
h.stop();
});
test("provider edits and directory revisions discard stale results and restart confirmation", async () => {
const h = harness();
h.wake();
h.state.revision += 1;
await h.finish(report());
assert.equal(h.applied.length, 0);
h.tick();
await h.finish(report());
assert.equal(h.problems.length, 0);
h.state.revision += 1;
h.tick();
await h.finish(report());
assert.equal(h.problems.length, 0, "a candidate from the previous revision is not confirmation");
h.tick();
await h.finish(report());
assert.equal(h.problems.length, 1);
h.stop();
});
test("hidden windows cancel scheduled reads and ignore a read that finishes while hidden", async () => {
const h = harness();
h.wake();
await h.finish(report("healthy"));
h.state.visible = false;
h.wake();
assert.equal(h.requests.length, 1);
assert.equal(h.timers.size, 0);
h.state.visible = true;
h.wake();
assert.equal(h.requests.length, 2);
h.state.visible = false;
await h.finish(report());
assert.equal(h.applied.length, 1);
assert.equal(h.problems.length, 0);
assert.equal(h.timers.size, 0);
h.state.visible = true;
h.wake();
await h.finish(report("healthy"));
assert.equal(h.applied.length, 2);
h.stop();
});
test("configuration mutations block both starting and accepting background reads", async () => {
const h = harness();
h.state.readable = false;
h.wake();
assert.equal(h.requests.length, 0);
h.state.readable = true;
h.tick();
h.state.readable = false;
await h.finish(report());
assert.equal(h.applied.length, 0);
assert.equal(h.problems.length, 0);
h.state.readable = true;
h.tick();
await h.finish(report());
assert.equal(h.problems.length, 0);
h.tick();
await h.finish(report());
assert.equal(h.problems.length, 1);
h.stop();
});
test("an active dialog can defer the toast without stopping background reports", async () => {
const h = harness();
h.state.notifiable = false;
h.wake();
await h.finish(report());
h.tick();
await h.finish(report());
assert.equal(h.applied.length, 2);
assert.equal(h.problems.length, 0);
h.state.notifiable = true;
h.wake();
await h.finish(report());
assert.equal(h.problems.length, 1);
h.stop();
});
test("transient IPC failures stay silent and break the confirmation streak", async () => {
const h = harness();
h.wake();
await h.finish(report());
h.tick();
await h.fail();
assert.equal(h.problems.length, 0);
assert.equal(h.applied.length, 1);
h.tick();
await h.finish(report());
assert.equal(h.problems.length, 0);
h.tick();
await h.finish(report());
assert.equal(h.problems.length, 1);
h.stop();
});
test("stopping during a pending wake prevents all late reports and future timers", async () => {
const h = harness();
h.wake(); h.wake();
h.stop();
await h.finish(report());
h.wake();
assert.equal(h.requests.length, 1);
assert.equal(h.applied.length, 0);
assert.equal(h.problems.length, 0);
assert.equal(h.timers.size, 0);
});
function memoryStorage(initial) {
const values = new Map(initial ? [["codexx.configHealth.notified.v1", initial]] : []);
return { values, getItem: (key) => values.get(key) ?? null, setItem: (key, value) => values.set(key, value) };
}
test("notice suppression survives restarts and ignores unrelated TOML edits and issue ordering", () => {
const storage = memoryStorage();
const original = report("issues", { issues: [issue, { ...issue, code: "invalid-wire-api", title: "接口配置错误" }] });
const first = createConfigHealthNoticeRegistry(storage);
assert.equal(first.has(original), false);
first.mark(original);
const reopened = createConfigHealthNoticeRegistry(storage);
assert.equal(reopened.has(original), true);
assert.equal(reopened.has({ ...original, fingerprint: "different-comment", checkedAt: "2026-09-11T00:00:00Z", issues: [...original.issues].reverse() }), true);
assert.equal(reopened.has({ ...original, issues: [issue] }), false);
const persisted = [...storage.values.values()].join("");
assert.equal(persisted.includes(original.codexDir), false, "notification storage does not need raw configuration paths");
assert.equal(persisted.includes(issue.description), false, "notification storage does not need issue details");
});
test("suppression is scoped to the configuration directory and recovery permits a later warning", async () => {
const storage = memoryStorage();
const seen = createConfigHealthNoticeRegistry(storage);
const notices = [];
const h = harness({
onReport: (value) => { if (value.status === "healthy") seen.clear(value); },
onProblem: (value) => { if (!seen.has(value)) { seen.mark(value); notices.push(value); } },
});
h.wake();
await h.finish(report());
h.tick();
await h.finish(report());
h.tick();
await h.finish(report());
assert.equal(notices.length, 1);
assert.equal(seen.has(report("issues", { codexDir: "/fixtures/other-codex" })), false);
h.tick();
await h.finish(report("healthy"));
assert.equal(createConfigHealthNoticeRegistry(storage).has(report()), false);
h.tick();
await h.finish(report());
assert.equal(notices.length, 1, "a recovered issue must still be confirmed again");
h.tick();
await h.finish(report());
assert.equal(notices.length, 2);
h.stop();
});
test("registry recovery only clears its own directory and normalizes Windows path separators", () => {
const seen = createConfigHealthNoticeRegistry(memoryStorage());
const windows = report("issues", { codexDir: "C:\\Users\\Sample\\.codex" });
const other = report("issues", { codexDir: "/fixtures/other-codex" });
seen.mark(windows);
seen.mark(other);
assert.equal(seen.has({ ...windows, codexDir: "C:/Users/Sample/.codex" }), true);
seen.clear({ ...windows, status: "healthy", issues: [] });
assert.equal(seen.has(windows), false);
assert.equal(seen.has(other), true);
});
test("blocked or malformed browser storage falls back to in-memory notification suppression", () => {
for (const storage of [
undefined,
{ getItem() { throw new Error("storage blocked"); }, setItem() { throw new Error("storage blocked"); } },
memoryStorage("invalid JSON"),
memoryStorage(JSON.stringify({ scope: "not-an-array" })),
memoryStorage(JSON.stringify([null, {}, "bad-entry", { scope: 1, issue: [] }])),
]) {
const seen = createConfigHealthNoticeRegistry(storage);
assert.equal(seen.has(report()), false);
seen.mark(report());
assert.equal(seen.has(report()), true);
seen.clear(report("healthy"));
assert.equal(seen.has(report()), false);
}
});
+47
View File
@@ -199,6 +199,49 @@ invalid context sizes disable Save. Reopening a saved item retains its mappings;
enabling it shows the restart notice. Actual catalog files, outbound model IDs,
authentication and rollback are covered by the Rust and native Codex checks.
## Provider card model names and drag ordering (#61 / #62)
1. Reset the fixture. The initial **Fixture CC Switch** model is
`fixture-model-a`, **Fixture Saved Provider** uses `fixture-model-b`, and the
default official profile uses `fixture-official-model`. The provider cards
should show these model names, including the current detected provider.
2. Copy a saved provider twice and copy an official profile once to create
several reorderable rows. Copies keep their selected model; editing a copy's
selected model must update its own card when returning to the list.
3. Drag the card handles up and down, including moving the default official
profile. Check the new visual order, leave the provider page, return, and
reload. The order must survive. Enabling another row must keep the order and
move only the current-provider indicator. Copying or deleting a row must keep
the relative order of the remaining rows.
4. Set **Fixture:排序保存** to **保存失败** and drag a card. The list should
return to its previous saved order and show an error. Diagnostics must keep
the original `providerOrders` value and no provider switch may occur.
5. Select **延迟成功**, drag a card, and inspect `pendingProviderOrder: 1`.
Use **Fixture:完成排序保存** to settle it. Select **延迟失败** and repeat:
settling must reject the request and preserve the original saved order.
**Fixture:使排序保存失败** rejects any pending order save explicitly.
Changing the selector while a request is pending does not change its outcome.
6. Check the handles and model labels in both themes, with long provider/model
names and keyboard focus. When drag ordering is unavailable during another
provider action, the controls should communicate that state.
The fixture implements `get_provider_order({ configDir }) -> string[]` and
`save_provider_order({ configDir, order }) -> string[]`. Keys use
`official:<id>`, `local:<id>`, and `detected:<id>`. Duplicate or malformed keys
reject without mutation; valid stale keys are kept for the renderer to ignore.
Ordering is stored by the normalized virtual config directory and persisted in
`codexx.fixture.backend.v2` alongside providers. It does not reorder the backing
provider arrays or affect failover priority. The virtual scope normalizer handles
simple slash paths, `.` and `..`; filesystem aliases, migration, and atomic SQL
writes are covered by the Rust tests. Other fixture data still models one shared
configuration directory.
Browser assertions may use `window.__CODEX_X_FIXTURE__.setProviderOrderMode(mode)`,
`.completeProviderOrder()`, `.failProviderOrder()`, and
`.getProviderOrder(configDir)`. Diagnostics expose `providerOrders`,
`providerOrderMode`, and `pendingProviderOrder`. Modes reset to `normal` on reload;
saved order persists. Restart the fixture server after changing `ui-runtime.js`.
## Provider presets and retained usage results
The add-provider form exposes eight preset cards. Cards show only brand marks and names; switching among all presets, including official sign-in, keeps the same add-page transition key. The initial custom TOML includes a reasoning setting, a fixture MCP server and a project entry for inheritance checks. Selecting a vendor fills its
@@ -251,3 +294,7 @@ limitation applies to official-profile capture, independence, and storage: these
responses are models for frontend verification. Use the Rust regression tests
for backend guarantees. All persisted fixture credentials are synthetic and
scoped to the local fixture origin and browser session.
### 在线更新流程
在 fixture 地址后加 `?updater=1`,通过侧栏更新按钮打开模拟更新窗口。点击“立即更新”后可使用控制面板依次触发验证、准备退出、启动安装及安装交接,也可模拟准备失败和更新完成。所有文件、安装器和重启操作均为内存模拟,不会下载或安装软件。暂停操作 30 秒可检查等待提示;失败时应允许重试,安装交接后不应再次启动安装。
+344 -18
View File
@@ -9,8 +9,10 @@
const counts = {};
const switches = [];
const commands = [];
const pending = { sync: [], detail: [], quota: [], resetCredits: [], usage: [] };
const pending = { sync: [], detail: [], quota: [], resetCredits: [], usage: [], providerOrder: [] };
const providerOrders = new Map();
const callbacks = new Map();
const eventListeners = new Map();
let nextCallback = 1;
let output;
let loginButton;
@@ -21,6 +23,55 @@
let pauseUsage = false;
let quotaMode = "dual";
let resetCreditsMode = "available";
let configHealthMode = new URLSearchParams(location.search).get("health") || "healthy";
let configStateError = new URLSearchParams(location.search).has("stateError");
let transferMode = "success";
let lastTransfer = null;
let existingImportAvailable = true;
const defaultRoutingSettings = () => ({ version: 2, routerEnabled: false, takeoverEnabled: false, autoFailoverEnabled: false,
listenAddress: "127.0.0.1", listenPort: 15721, providerIds: [], maxRetries: 3, streamingFirstByteTimeout: 60,
streamingIdleTimeout: 120, nonStreamingTimeout: 600, circuitFailureThreshold: 4, circuitSuccessThreshold: 2,
circuitTimeoutSeconds: 60, circuitErrorRateThreshold: 0.6, circuitMinRequests: 10 });
let failoverSettings = defaultRoutingSettings();
const resetRoutingHealth = new Set();
let failoverMode = "normal";
let providerBaseReadFailure = false;
let providerOrderMode = "normal";
const updaterEnabled = new URLSearchParams(location.search).has("updater");
let updateRequest = null;
function updateStage(event) { updateRequest?.onEvent.onmessage(event); }
function finishUpdate(failure = false) {
if (!updateRequest) return;
const request = updateRequest;
updateRequest = null;
if (failure) request.reject({ stage: "prepare", message: "Fixture:无法恢复连接配置,尚未启动安装程序。", logPath: "C:\\Users\\Fixture\\AppData\\Local\\Codex-X\\logs\\update.log" });
else request.resolve({ restartRequired: true });
render();
}
const sharedProviderTables = '\n[mcp_servers.fixture_docs]\ncommand = "fixture-docs-server"\n'
+ '\n[mcp_servers.fixture_docs.env]\nFIXTURE_TIMEOUT = "1000"\n'
+ '\n[desktop]\nsansFontSize = 14\ncodeFontSize = 13\n'
+ '\n[marketplaces.fixture]\nsource_type = "local"\nsource = "/fixture/marketplace"\n'
+ '\n[plugins."browser@fixture"]\nenabled = true\n'
+ '\n[projects."/fixture/project"]\ntrust_level = "trusted"\n';
const fixtureSkill = (id, name, enabled = true) => ({ id, name, description: "Fixture skill", note: "合成测试备注", directory: id, enabled, source: "Codex", path: `${codexDir}/skills/${id}`, contentHash: null, updateStatus: "未检查" });
const fixtureMcp = (id, name, enabled = true) => ({ id, name, enabled, transport: "stdio", source: "Codex", note: "合成测试备注", summary: "npx fixture-tools", command: "npx", url: null, configJson: { command: "npx", args: ["fixture-tools"] } });
const transferState = { codexDir, codexSkillsDir: `${codexDir}/skills`, disabledSkillsDir: `${codexDir}/disabled-skills`, skills: [fixtureSkill("writing", "写作助手")], mcpServers: [fixtureMcp("docs", "项目文档")], warnings: [] };
const fixtureSessions = ["项目排错记录", "功能设计讨论"].map((title, index) => ({ id: `fixture-session-${index + 1}`, title, modelProvider: "custom", model: "fixture-model", cwd: "/fixture/project", rolloutPath: `${codexDir}/sessions/fixture-${index}.jsonl`, updatedAtMs: Date.now() - index * 60000, archived: false, hasUserEvent: true, isSubagent: false, needsSync: false }));
fixtureSessions.push({ id: "fixture-internal-guardian", title: "The following is the Codex agent history whose request action you are assessing...", modelProvider: "openai", model: "codex-auto-review", cwd: "/fixture/project", rolloutPath: `${codexDir}/sessions/guardian.jsonl`, updatedAtMs: Date.now() - 120000, archived: false, hasUserEvent: false, isSubagent: true, needsSync: false });
function healthReport() {
const broken = configHealthMode !== "healthy";
const manual = configHealthMode === "syntax";
return {
codexDir, fingerprint: `fixture-health-${configHealthMode}`,
status: broken ? "issues" : "healthy", checkedAt: new Date().toISOString(),
canRepair: broken && !manual,
issues: !broken ? [] : [{ code: manual ? "syntax" : "missing-provider", title: manual ? "配置文件格式不正确" : "旧会话的供应商配置缺失",
description: manual ? "配置文件第 4 行附近的格式不正确,需要手动检查。" : "部分旧会话仍在使用 my_codex,但配置中已找不到这条供应商。", repairable: !manual }],
repairSummary: broken && !manual ? ["为 my_codex 补齐配置,继续使用当前供应商 Fixture Saved Provider。", "修复前自动备份现有配置。"] : [],
};
}
function officialToml(model) {
return `model_provider = "openai"\nmodel = ${JSON.stringify(model || "fixture-official-model")}\n`;
@@ -47,7 +98,7 @@
providerName: "OpenAI Official",
model: "fixture-official-model",
authJson: officialAuth("one"),
configText: officialToml("fixture-official-model"),
configText: officialToml("fixture-official-model") + sharedProviderTables,
isDefault: true,
}]]);
@@ -112,7 +163,7 @@
// Visible inherited settings for provider-preset regression checks.
detectedProvider.tomlConfig = '# fixture inherited settings\nmodel_reasoning_effort = "high"\napproval_policy = "on-request"\n'
+ toml(detectedProvider)
+ '\n[mcp_servers.fixture_docs]\ncommand = "fixture-docs-server"\n\n[features]\nshell_snapshot = true\n\n[projects."/fixture/project"]\ntrust_level = "trusted"\n';
+ sharedProviderTables + '\n[features]\nshell_snapshot = true\n';
let savedProviders = [{
id: "fixture-saved",
providerName: "Fixture Saved Provider",
@@ -181,7 +232,13 @@
&& Array.isArray(saved.officialProfiles)
&& saved.officialProfiles.some((profile) => profile.id === defaultOfficialId)) {
state = saved.state;
if (saved.failoverSettings?.version === 2) failoverSettings = { ...defaultRoutingSettings(), ...saved.failoverSettings };
savedProviders = saved.savedProviders;
for (const [scope, order] of Object.entries(saved.providerOrders || {})) {
if (Array.isArray(order) && order.every((key) => typeof key === "string")) {
providerOrders.set(scope, clone(order));
}
}
savedPrompts = saved.savedPrompts;
nextOfficialId = saved.nextOfficialId;
officialProfiles.clear();
@@ -197,8 +254,10 @@
version: 2,
state,
savedProviders,
providerOrders: Object.fromEntries(providerOrders),
savedPrompts,
nextOfficialId,
failoverSettings,
officialProfiles: Array.from(officialProfiles.values()),
}));
} catch {
@@ -207,12 +266,19 @@
}
localStorage.setItem("codexx.lang", "zh");
localStorage.setItem("codexx.startupWizardSeen", "1");
const firstRunFixture = new URLSearchParams(location.search).has("firstRun");
if (firstRunFixture && sessionStorage.getItem("codexx.fixture.firstRunStarted") !== "1") {
localStorage.removeItem("codexx.startupWizardSeen");
sessionStorage.setItem("codexx.fixture.firstRunStarted", "1");
} else if (!firstRunFixture) localStorage.setItem("codexx.startupWizardSeen", "1");
localStorage.removeItem("codexx.activeProviderId");
localStorage.removeItem("codexx.promptCategories.v1");
function snapshot() {
return {
startupWizardSeen: localStorage.getItem("codexx.startupWizardSeen"),
transferMode, lastTransfer,
failoverSettings: clone(failoverSettings), failoverMode,
pendingSync: pending.sync.length,
pendingDetail: pending.detail.length,
pendingQuota: pending.quota.length,
@@ -232,6 +298,9 @@
usageMode,
pendingUsage: pending.usage.length,
savedProviderIds: savedProviders.map((provider) => provider.id),
providerOrders: Object.fromEntries(providerOrders),
providerOrderMode,
pendingProviderOrder: pending.providerOrder.length,
commands: clone(commands),
};
}
@@ -317,6 +386,52 @@
return next;
}
function providerOrderScope(configDir) {
// Virtual fixture paths have no filesystem or symlinks. Normalize simple
// absolute paths only; Rust tests cover real canonical paths and aliases.
const raw = typeof configDir === "string" && configDir.trim() ? configDir.trim() : codexDir;
const parts = [];
for (const part of raw.replaceAll("\\", "/").split("/")) {
if (!part || part === ".") continue;
if (part === "..") parts.pop();
else parts.push(part);
}
return `/${parts.join("/")}`;
}
function getProviderOrder(configDir) {
return clone(providerOrders.get(providerOrderScope(configDir)) || []);
}
function setProviderOrderMode(mode) {
if (!["normal", "error", "pending", "pending-error"].includes(mode)) throw new Error("Unknown fixture provider-order mode");
providerOrderMode = mode;
render();
}
function saveProviderOrder({ configDir, order }) {
if (!Array.isArray(order) || order.some((key) => {
if (typeof key !== "string" || /[\u0000-\u001f\u007f-\u009f]/.test(key)) return true;
const separator = key.indexOf(":");
const source = key.slice(0, separator);
const id = key.slice(separator + 1);
return !["official", "local", "detected"].includes(source) || !id || id !== id.trim();
}) || new Set(order).size !== order.length) throw new Error("Fixture:排序包含无效或重复供应商");
const scope = providerOrderScope(configDir);
const next = clone(order);
const mode = providerOrderMode;
const failure = "Fixture:供应商排序保存失败,原顺序未改变";
if (mode === "error") throw new Error(failure);
const save = () => {
providerOrders.set(scope, next);
return next;
};
if (mode === "pending" || mode === "pending-error") {
return defer("providerOrder", save, mode === "pending-error" ? failure : null);
}
return clone(save());
}
function uniqueCopyName(requested, names) {
const base = requested?.trim() || "Fixture 副本";
const used = new Set(names);
@@ -665,6 +780,50 @@
});
}
function emitRoutingChanged() {
for (const [eventId, listener] of eventListeners) {
if (listener.event === "provider-routing-changed") callbacks.get(listener.handler)?.({ event: listener.event, id: eventId, payload: { codexDir } });
}
}
function failoverStatus() {
const summary = (provider) => ({ id: provider.id, providerName: provider.providerName, model: provider.model,
baseUrl: provider.baseUrl, official: false, models: [...new Set([provider.model, ...(provider.modelMappings || []).map((mapping) => mapping.model)])] });
const providers = savedProviders.map((provider) => {
const reason = provider.wireApi !== "responses" ? "此供应商暂不支持 Responses 接口" : !provider.apiKey ? "请先填写 API Key" : null;
return { ...summary(provider), eligible: !reason, reason };
});
const profile = state.isOfficialProvider ? officialProfiles.get(state.activeOfficialProfileId || defaultOfficialId) : null;
const primary = profile ? { id: `official:${profile.id}`, providerName: profile.providerName, model: profile.model, models: [profile.model],
baseUrl: "https://chatgpt.com/codex", official: true, eligible: Boolean(profile.authJson), reason: profile.authJson ? null : "请先登录官方账号" }
: providers.find((provider) => provider.id === state.activeSavedProviderId) || null;
const running = failoverSettings.routerEnabled;
const takeoverActive = Boolean(running && failoverSettings.takeoverEnabled && primary?.eligible);
const autoFailoverActive = Boolean(takeoverActive && failoverSettings.autoFailoverEnabled && !primary.official);
const routes = !takeoverActive ? [] : autoFailoverActive ? failoverSettings.providerIds.map((id) => providers.find((provider) => provider.id === id)).filter(Boolean) : [primary];
const samples = running && ["cooling", "recovering"].includes(failoverMode);
const host = failoverSettings.listenAddress === "0.0.0.0" ? "127.0.0.1" : failoverSettings.listenAddress === "::" ? "[::1]" : failoverSettings.listenAddress.includes(":") ? `[${failoverSettings.listenAddress}]` : failoverSettings.listenAddress;
return clone({ settings: failoverSettings, running, takeoverActive, autoFailoverActive, address: running ? `http://${host}:${failoverSettings.listenPort}/v1` : null, primary, providers,
runtime: { requestCount: samples ? 28 : 0, failoverCount: samples ? 2 : 0, inFlight: 0, successCount: samples ? 26 : 0, failureCount: samples ? 2 : 0,
uptimeSeconds: running ? 365 : 0, lastRequestAt: samples ? new Date().toISOString() : null,
lastProviderId: samples ? routes.at(-1)?.id || null : null, lastError: null,
providers: routes.map((provider, index) => { const state = resetRoutingHealth.has(provider.id) || !samples ? "closed" : index === 0 ? (failoverMode === "recovering" ? "half_open" : "open") : "closed";
return { id: provider.id, state, cooldownSeconds: state === "open" ? 25 : 0, lastStatus: state === "closed" ? 200 : 503,
consecutiveFailures: state === "open" ? 4 : 0, consecutiveSuccesses: state === "half_open" ? 1 : 0, totalRequests: samples ? 14 : 0, failedRequests: state === "closed" ? 0 : 4 }; }) },
message: primary?.official && failoverSettings.autoFailoverEnabled ? "当前为官方登录,仅使用当前账号;自动故障转移暂不运行。" : autoFailoverActive && !routes.length ? "队列为空,请添加 API 供应商。" : null });
}
function seedFailover() {
for (const [id, providerName, model] of [["fixture-failover-primary", "主供应商", "gpt-example"], ["fixture-failover-backup-a", "备用一", "gpt-example"], ["fixture-failover-backup-b", "备用二", "gpt-example"], ["fixture-failover-other", "不同模型供应商", "other-model"]]) {
saveProvider({ id, providerName, model, apiKey: "sk-fixture-only-failover", baseUrl: `https://${id}.example.test/v1`, wireApi: "responses", requiresOpenaiAuth: false, tomlConfig: "", modelMappings: [] });
}
switchProvider(savedProviders.find((provider) => provider.id === "fixture-failover-primary"));
failoverMode = "normal";
failoverSettings = defaultRoutingSettings();
resetRoutingHealth.clear();
persist(); render(); emitRoutingChanged();
}
async function invoke(command, args = {}) {
counts[command] = (counts[command] || 0) + 1;
commands.push(command);
@@ -679,8 +838,71 @@
async function dispatch(command, args) {
switch (command) {
case "get_codex_state": return clone(state);
case "get_provider_config_base": {
if (providerBaseReadFailure) throw new Error("Fixture:无法读取供应商配置底稿");
// Model a legacy route-only file with the official integration snapshot
// still available. Rust tests cover the actual parser/recovery rules.
const config = state.configText.includes("[mcp_servers")
? state.configText : state.configText + sharedProviderTables;
return config.replace(/^\s*experimental_bearer_token\s*=.*\n?/gm, "");
}
case "get_provider_failover":
if (failoverMode === "error") throw new Error("Fixture:暂时无法读取运行状态");
return failoverStatus();
case "save_provider_failover": {
if (failoverMode === "save-error") throw new Error("Fixture:设置保存失败,原设置未改变");
const next = clone(args.settings);
const status = failoverStatus();
const ranges = { maxRetries: [0, 10], streamingFirstByteTimeout: [1, 120], streamingIdleTimeout: [0, 600], nonStreamingTimeout: [60, 1200], circuitFailureThreshold: [1, 20], circuitSuccessThreshold: [1, 10], circuitTimeoutSeconds: [0, 300], circuitErrorRateThreshold: [0, 1], circuitMinRequests: [5, 100] };
for (const [key, [min, max]] of Object.entries(ranges)) if (!Number.isFinite(next[key]) || next[key] < min || next[key] > max || key !== "circuitErrorRateThreshold" && !Number.isInteger(next[key])) throw new Error(`Fixture:参数 ${key} 超出范围`);
if (!Number.isInteger(next.listenPort) || next.listenPort < 1024 || next.listenPort > 65535) throw new Error("Fixture:监听端口无效");
const host = next.listenAddress.trim();
let validAddress = /^(\d{1,3}\.){3}\d{1,3}$/.test(host) && host.split(".").every((part) => Number(part) <= 255 && String(Number(part)) === part);
if (host.includes(":")) { try { validAddress = new URL(`http://[${host}]/`).hostname.startsWith("["); } catch {} }
if (host === "localhost") validAddress = true;
if (!validAddress) throw new Error("Fixture:监听地址无效");
next.listenAddress = host === "localhost" ? "127.0.0.1" : host;
if (status.running && (next.listenAddress !== failoverSettings.listenAddress || next.listenPort !== failoverSettings.listenPort)) throw new Error("Fixture:停止路由后再修改监听地址");
if (!next.routerEnabled) next.takeoverEnabled = false;
if (next.takeoverEnabled && !status.primary?.eligible) throw new Error("Fixture:请先选择可用供应商或登录官方账号");
if (next.providerIds.length > 64 || new Set(next.providerIds).size !== next.providerIds.length || next.providerIds.some((id) => !status.providers.find((provider) => provider.id === id)?.eligible)) throw new Error("Fixture:队列包含无效或重复供应商");
const enablingAuto = next.autoFailoverEnabled && !failoverSettings.autoFailoverEnabled;
if (enablingAuto && (!next.routerEnabled || !next.takeoverEnabled)) throw new Error("Fixture:请先开启本地路由和 Codex 路由");
if (enablingAuto && !next.providerIds.length) {
if (!status.primary?.eligible || status.primary.official) throw new Error("Fixture:请添加 API 供应商到队列");
next.providerIds.push(status.primary.id);
}
if (enablingAuto) switchProvider(savedProviders.find((provider) => provider.id === next.providerIds[0]));
failoverSettings = next;
emitRoutingChanged();
return failoverStatus();
}
case "reset_provider_failover_health":
if (args.providerId) resetRoutingHealth.add(args.providerId); else { failoverMode = "normal"; resetRoutingHealth.clear(); }
return failoverStatus();
case "plugin:event|listen": {
const eventId = nextCallback++;
eventListeners.set(eventId, { event: args.event, handler: args.handler });
return eventId;
}
case "plugin:event|unlisten": eventListeners.delete(args.eventId); return null;
case "check_codex_config": return healthReport();
case "repair_codex_config": {
if (args.expectedFingerprint !== healthReport().fingerprint) throw new Error("配置已变更,请重新检查。");
if (!healthReport().canRepair) throw new Error("此配置需要手动检查。");
await new Promise((resolve) => setTimeout(resolve, 300));
configHealthMode = "healthy";
configStateError = false;
return { report: healthReport(), changed: true, backupId: "fixture-backup" };
}
case "open_codex_config_file": return null;
case "get_codex_state":
if (configStateError) throw new Error("Fixture:Codex 无法加载配置,Model provider my_codex not found。");
return clone(state);
case "list_saved_providers": return clone(savedProviders);
case "get_provider_order": return getProviderOrder(args.configDir);
case "save_provider_order": return saveProviderOrder(args);
case "repair_provider_model_catalog": return { changed: false };
case "list_saved_prompts": return clone(savedPrompts);
case "get_builtin_prompt_status": return clone(statuses);
case "list_official_profiles":
@@ -705,15 +927,35 @@
case "get_about_info": return {
appVersion: "0.3.15", codexVersion: "fixture-cli", codexDir,
projectUrl: "https://project.example.test/", githubRepo: "fixture/example",
nativeUpdaterSupported: false,
};
case "get_startup_diagnostics": return {
codexDir, needsManualSelect: false, summary: "Fixture ready", items: [],
nativeUpdaterSupported: updaterEnabled,
};
case "get_startup_diagnostics": {
if (new URLSearchParams(location.search).has("environmentError")) throw new Error("Fixture: environment check unavailable");
return {
codexDir, needsManualSelect: false, summary: "Fixture ready",
items: [
{ key: "home", label: "Codex 配置目录", status: "ok", message: "已找到", path: codexDir },
{ key: "config", label: "配置文件", status: "ok", message: "已找到", path: `${codexDir}/config.toml` },
{ key: "auth", label: "登录信息", status: "ok", message: "已找到", path: `${codexDir}/auth.json` },
{ key: "sessions", label: "会话记录", status: "ok", message: "已找到", path: `${codexDir}/state_5.sqlite` },
],
};
}
case "check_app_update": return {
latestVersion: "0.3.15", htmlUrl: "https://releases.example.test/", hasUpdate: false,
};
case "plugin:updater|check": return null;
case "plugin:updater|check": return updaterEnabled ? {
rid: 123, currentVersion: "0.3.20", version: "0.3.21", date: "2026-09-22T00:00:00Z", body: "优化 Windows 在线更新。", rawJson: {},
} : null;
case "install_app_update": return new Promise((resolve, reject) => {
if (updateRequest) { reject({ stage: "install", message: "Fixture:更新已经进行中。" }); return; }
updateRequest = { resolve, reject, onEvent: args.onEvent };
updateStage({ event: "Started", data: { contentLength: 1024 * 1024 * 20 } });
updateStage({ event: "Progress", data: { chunkLength: 1024 * 1024 * 10 } });
render();
});
case "plugin:resources|close": return null;
case "plugin:process|restart": return null;
case "refresh_builtin_prompts": return defer("sync", () => statuses);
case "get_builtin_prompt_detail": {
if (!details.has(args.templateId)) throw new Error("Unknown fixture template");
@@ -796,20 +1038,40 @@
case "test_provider_connection": return { ok: true, status: 200, message: "Fixture connection OK", durationMs: 1 };
case "fetch_provider_models": return { models: [{ id: "fixture-model-a" }, { id: "fixture-model-b" }], status: 200, durationMs: 1 };
case "get_session_sync_status": return {
codexDir, targetProvider: state.modelProvider, rolloutFiles: 0, sessionMetaCount: 0,
mismatchedRollouts: 0, mismatchedSessionMeta: 0, sqliteDbs: 1, sqliteThreads: 0,
topLevelThreads: 0, subagentThreads: 0, mismatchedThreads: 0, mismatchedSessions: 0,
needsSync: false, scanComplete: true, scanFailures: [], warnings: [], sessions: [],
};
case "get_skills_mcp_state": return {
codexDir, codexSkillsDir: `${codexDir}/skills`, disabledSkillsDir: `${codexDir}/disabled-skills`,
skills: [], mcpServers: [], warnings: [],
codexDir, targetProvider: state.modelProvider, rolloutFiles: 2, sessionMetaCount: 2,
mismatchedRollouts: 0, mismatchedSessionMeta: 0, sqliteDbs: 1, sqliteThreads: 3,
topLevelThreads: 2, subagentThreads: 1, mismatchedThreads: 0, mismatchedSessions: 0,
needsSync: false, scanComplete: true, scanFailures: [], warnings: [], sessions: clone(fixtureSessions),
};
case "get_skills_mcp_state": return clone(transferState);
case "preview_existing_skills_mcp": return existingImportAvailable
? { skills: [fixtureSkill("research", "资料整理")], mcpServers: [fixtureMcp("files", "本地文件"), fixtureMcp("search", "网页检索")], warnings: ["Fixture:一条无效配置已跳过。"] }
: { skills: [], mcpServers: [], warnings: [] };
case "import_existing_skills_mcp":
if (existingImportAvailable) {
transferState.skills.push(fixtureSkill("research", "资料整理"));
transferState.mcpServers.push(fixtureMcp("files", "本地文件"), fixtureMcp("search", "网页检索"));
existingImportAvailable = false;
return { importedSkills: 1, importedMcp: 2, message: "已导入 1 个 Skill、2 个 MCP", state: clone(transferState) };
}
return { importedSkills: 0, importedMcp: 0, message: "没有需要新导入的内容", state: clone(transferState) };
case "export_codex_sessions":
case "export_skills_mcp_archive":
case "import_skills_mcp_archive": {
await new Promise((resolve) => setTimeout(resolve, 250));
if (transferMode === "cancel") return null;
if (transferMode === "error") throw new Error("Fixture:无法写入所选位置,请选择其他位置。");
lastTransfer = { command, kind: args.kind || null, sessionIds: args.sessionIds || null };
if (command === "export_codex_sessions") return { path: `/fixture/exports/${args.suggestedName}.${args.sessionIds.length === 1 ? "md" : "zip"}`, exportedSessions: args.sessionIds.length, failedSessions: 0, warnings: [] };
if (command === "export_skills_mcp_archive") return { path: `/fixture/exports/${args.kind}.zip`, exportedSkills: args.kind === "skills" ? transferState.skills.length : 0, exportedMcp: args.kind === "mcp" ? transferState.mcpServers.length : 0 };
return { importedSkills: 0, importedMcp: 0, message: "内容已存在,无需重复导入", state: clone(transferState) };
}
case "open_url": return undefined;
default: throw new Error(`Fixture has no handler for command: ${command}`);
}
}
window.__TAURI_EVENT_PLUGIN_INTERNALS__ = { unregisterListener(event, eventId) { eventListeners.delete(eventId); } };
window.__TAURI_INTERNALS__ = {
invoke,
transformCallback(callback, once = false) {
@@ -828,6 +1090,10 @@
loginSecondOfficialAccount,
setUsageMode,
setQuotaMode,
setProviderOrderMode,
getProviderOrder,
completeProviderOrder: () => settle("providerOrder", false),
failProviderOrder: () => settle("providerOrder", true),
completeQuota: () => settle("quota", false),
failQuota: () => settle("quota", true),
completeSync: () => settle("sync", false),
@@ -852,6 +1118,31 @@
panel.append(panelBody);
const controls = document.createElement("div");
controls.style.cssText = "display:flex;flex-wrap:wrap;gap:4px;margin-top:6px";
if (updaterEnabled) {
for (const [label, action] of [
["Fixture:验证更新", () => updateStage({ event: "Verifying" })],
["Fixture:准备退出", () => updateStage({ event: "Preparing" })],
["Fixture:启动安装", () => updateStage({ event: "Installing" })],
["Fixture:安装已交接", () => { updateStage({ event: "HandedOff" }); updateRequest?.resolve({ restartRequired: false }); updateRequest = null; render(); }],
["Fixture:更新准备失败", () => finishUpdate(true)],
["Fixture:更新完成待重启", () => finishUpdate(false)],
]) {
const button = document.createElement("button"); button.type = "button"; button.textContent = label;
button.addEventListener("click", action); controls.append(button);
}
}
const transferSelect = document.createElement("select");
transferSelect.setAttribute("aria-label", "Fixture:文件操作");
for (const [value, label] of [["success", "文件操作成功"], ["cancel", "取消文件选择"], ["error", "文件操作失败"]]) {
const option = document.createElement("option"); option.value = value; option.textContent = label; transferSelect.append(option);
}
transferSelect.addEventListener("change", () => { transferMode = transferSelect.value; render(); }); controls.append(transferSelect);
for (const [text, mode] of [["Fixture:配置正常", "healthy"], ["Fixture:缺失供应商配置", "missing"], ["Fixture:配置语法错误", "syntax"]]) {
const button = document.createElement("button");
button.type = "button"; button.textContent = text;
button.addEventListener("click", () => { configHealthMode = mode; window.dispatchEvent(new Event("focus")); });
controls.append(button);
}
for (const [text, kind, fail] of [
["Fixture:完成模板同步", "sync", false],
["Fixture:使模板同步失败", "sync", true],
@@ -909,6 +1200,22 @@
resetCompleteButton.style.cssText = loginButton.style.cssText;
resetCompleteButton.addEventListener("click", () => settle("resetCredits", false));
controls.append(resetCompleteButton);
const orderModeLabel = document.createElement("label");
orderModeLabel.textContent = "Fixture:排序保存 ";
const orderModeSelect = document.createElement("select");
orderModeSelect.setAttribute("aria-label", "Fixture:排序保存");
orderModeSelect.style.cssText = quotaModeSelect.style.cssText;
for (const [value, text] of [["normal", "正常"], ["error", "保存失败"], ["pending", "延迟成功"], ["pending-error", "延迟失败"]]) {
const option = document.createElement("option");
option.value = value; option.textContent = text; orderModeSelect.append(option);
}
orderModeSelect.addEventListener("change", () => setProviderOrderMode(orderModeSelect.value));
orderModeLabel.append(orderModeSelect); controls.append(orderModeLabel);
for (const [text, failure] of [["Fixture:完成排序保存", false], ["Fixture:使排序保存失败", true]]) {
const button = document.createElement("button");
button.type = "button"; button.textContent = text; button.style.cssText = loginButton.style.cssText;
button.addEventListener("click", () => settle("providerOrder", failure)); controls.append(button);
}
const pauseUsageLabel = document.createElement("label");
const pauseUsageInput = document.createElement("input");
pauseUsageInput.type = "checkbox";
@@ -940,6 +1247,25 @@
button.addEventListener("click", () => setUsageMode(mode));
controls.append(button);
}
for (const [text, action] of [
["Fixture:精简的供应商配置", () => { state.configText = 'model_reasoning_effort = "high"\ndisable_response_storage = true\n' + toml(detectedProvider); providerBaseReadFailure = false; render(); }],
["Fixture:供应商底稿读取失败", () => { providerBaseReadFailure = true; render(); }],
["Fixture:供应商底稿读取正常", () => { providerBaseReadFailure = false; render(); }],
["Fixture:准备自动切换数据", seedFailover],
["Fixture:自动切换状态正常", () => { failoverMode = "normal"; render(); }],
["Fixture:自动切换状态失败", () => { failoverMode = "error"; render(); }],
["Fixture:自动切换保存失败", () => { failoverMode = "save-error"; render(); }],
["Fixture:自动切换冷却状态", () => { failoverMode = "cooling"; resetRoutingHealth.clear(); render(); }],
["Fixture:自动切换恢复状态", () => { failoverMode = "recovering"; resetRoutingHealth.clear(); render(); }],
["Fixture:路由外部设置变化", () => { failoverSettings.maxRetries = failoverSettings.maxRetries === 3 ? 4 : 3; render(); emitRoutingChanged(); }],
["Fixture:路由切换官方账号", () => { switchOfficial(defaultOfficialId); emitRoutingChanged(); }],
["Fixture:路由切换其他模型", () => { const provider = savedProviders.find((item) => item.id === "fixture-failover-other"); if (provider) switchProvider(provider); emitRoutingChanged(); }],
["Fixture:删除第一备用", () => { savedProviders = savedProviders.filter((provider) => provider.id !== failoverSettings.providerIds[0]); render(); }],
]) {
const button = document.createElement("button");
button.type = "button"; button.textContent = text; button.style.cssText = loginButton.style.cssText;
button.addEventListener("click", action); controls.append(button);
}
const resetButton = document.createElement("button");
resetButton.type = "button";
resetButton.textContent = "Fixture:重置测试数据";
+20 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { orderProviderRows } from "../src/providerRowOrder.ts";
import { applyProviderRowOrder, moveProviderRow, orderProviderRows, providerRowKey } from "../src/providerRowOrder.ts";
const official = { id: "official", isCurrent: false };
const detected = { id: "detected", isCurrent: true };
@@ -25,3 +25,22 @@ test("an unresolved detected current remains visible before saved rows", () => {
assert.deepEqual(rows.map((row) => row.id), ["official", "detected", "original", "copy"]);
assert.deepEqual(rows.filter((row) => row.isCurrent).map((row) => row.id), ["detected"]);
});
test("custom order crosses official/local boundaries and appends new cards", () => {
const rows = [{ source: "official", id: "default" }, { source: "local", id: "one" }, { source: "local", id: "new" }];
assert.deepEqual(applyProviderRowOrder(rows, ["local:one", "official:default"]).map(providerRowKey), ["local:one", "official:default", "local:new"]);
});
test("missing and duplicate stored keys never hide or duplicate cards", () => {
const rows = [{ source: "official", id: "same" }, { source: "local", id: "same" }];
assert.deepEqual(applyProviderRowOrder(rows, ["detected:missing", "local:same", "local:same"]).map(providerRowKey), ["local:same", "official:same"]);
});
test("moving up and down inserts at the intended edge without losing entries", () => {
const original = ["official:default", "local:one", "local:two", "local:three"];
assert.deepEqual(moveProviderRow(original, "local:three", "official:default", "before"), ["local:three", "official:default", "local:one", "local:two"]);
assert.deepEqual(moveProviderRow(original, "official:default", "local:three", "after"), ["local:one", "local:two", "local:three", "official:default"]);
assert.deepEqual(moveProviderRow(original, "local:one", "local:two", "before"), original);
assert.deepEqual(moveProviderRow(original, "missing", "local:one", "after"), original);
assert.deepEqual(original, ["official:default", "local:one", "local:two", "local:three"]);
});
@@ -0,0 +1,69 @@
import assert from "node:assert/strict";
import test from "node:test";
import { parseRoutingDraft, routingDraft, routingFields, sameRoutingDraft } from "../src/routingSettings.ts";
const saved = () => ({ version: 2, routerEnabled: false, takeoverEnabled: false, autoFailoverEnabled: false, listenAddress: "127.0.0.1", listenPort: 15721,
providerIds: [], maxRetries: 3, streamingFirstByteTimeout: 60, streamingIdleTimeout: 120, nonStreamingTimeout: 600,
circuitFailureThreshold: 4, circuitSuccessThreshold: 2, circuitTimeoutSeconds: 60, circuitErrorRateThreshold: 0.6, circuitMinRequests: 10 });
test("saved routing settings roundtrip without changing switches, queue or percentage", () => {
const settings = { ...saved(), routerEnabled: true, takeoverEnabled: true, autoFailoverEnabled: true, providerIds: ["p2", "p1"], circuitErrorRateThreshold: 0.625 };
const draft = routingDraft(settings);
assert.equal(draft.circuitErrorRateThreshold, "62.5");
assert.deepEqual(parseRoutingDraft(draft), { settings, errors: {} });
draft.providerIds.reverse();
assert.deepEqual(settings.providerIds, ["p2", "p1"]);
});
test("routing and saved failover preference remain independent when the service is stopped", () => {
const settings = { ...saved(), autoFailoverEnabled: true, providerIds: ["only-one"], streamingIdleTimeout: 0 };
assert.deepEqual(parseRoutingDraft(routingDraft(settings)).settings, settings);
assert.ok(parseRoutingDraft(routingDraft({ ...settings, providerIds: [] })).settings);
});
test("numeric fields accept their documented boundaries and reject empty, fractional or out-of-range values", () => {
for (const field of routingFields) {
for (const boundary of [field.min, field.max]) {
const result = parseRoutingDraft({ ...routingDraft(saved()), [field.key]: String(boundary) });
assert.ok(result.settings, `${field.key}: ${boundary}`);
}
const invalid = ["", " ", "NaN", "1e2", String(field.min - 1), String(field.max + 1)];
if (field.key !== "circuitErrorRateThreshold") invalid.push("1.5");
for (const value of invalid) {
const result = parseRoutingDraft({ ...routingDraft(saved()), [field.key]: value });
assert.equal(result.settings, null, `${field.key}: ${value}`);
assert.ok(result.errors[field.key]);
}
}
});
test("listen settings allow IPv4, IPv6 and localhost but never coerce URLs or invalid ports", () => {
for (const address of ["127.0.0.1", "0.0.0.0", "::1", "::", "2001:db8::1", "localhost"]) {
const result = parseRoutingDraft({ ...routingDraft(saved()), listenAddress: address });
assert.ok(result.settings, address);
assert.equal(result.settings.listenAddress, address === "localhost" ? "127.0.0.1" : address);
}
for (const address of ["", "999.0.0.1", "127.000.0.1", "https://localhost", "::1/path", "example.com", "[::1]"]) {
assert.ok(parseRoutingDraft({ ...routingDraft(saved()), listenAddress: address }).errors.listenAddress, address);
}
for (const port of ["", "12x", "1023", "65536", "15721.5"]) {
assert.ok(parseRoutingDraft({ ...routingDraft(saved()), listenPort: port }).errors.listenPort, port);
}
for (const port of ["1024", "65535"]) assert.ok(parseRoutingDraft({ ...routingDraft(saved()), listenPort: port }).settings);
});
test("the queue accepts one through 64 providers without an eight-provider limit and rejects duplicates", () => {
const draft = routingDraft(saved());
assert.ok(parseRoutingDraft({ ...draft, providerIds: ["single"] }).settings);
assert.ok(parseRoutingDraft({ ...draft, providerIds: Array.from({ length: 64 }, (_, i) => String(i)) }).settings);
assert.ok(parseRoutingDraft({ ...draft, providerIds: Array.from({ length: 65 }, (_, i) => String(i)) }).errors.providerIds);
assert.ok(parseRoutingDraft({ ...draft, providerIds: ["same", "same"] }).errors.providerIds);
});
test("draft comparison detects independent switch, listening, tuning and queue changes", () => {
const original = routingDraft({ ...saved(), providerIds: ["a", "b"] });
assert.ok(sameRoutingDraft(original, routingDraft({ ...saved(), providerIds: ["a", "b"] })));
for (const change of [{ routerEnabled: true }, { takeoverEnabled: true }, { autoFailoverEnabled: true }, { listenAddress: "::1" }, { listenPort: "15722" }, { providerIds: ["b", "a"] }, ...routingFields.map(({ key }) => ({ [key]: original[key] + "0" }))]) {
assert.equal(sameRoutingDraft(original, { ...original, ...change }), false);
}
});
@@ -0,0 +1,249 @@
import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, statSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import {
SESSION_FOLDER_UNKNOWN_KEY,
mergeSessionDirectoryGroups,
pinnedSessionDirectoryKeys,
readSessionPinPreferences,
sessionDirectoryKey,
sessionPinStorageKey,
sessionsForDirectoryGroup,
toggleFolderPins,
} from "../src/sessionDirectoryIdentity.ts";
const sessionIds = (items) => items.map((item) => item.id);
test("case-sensitive directories have unique folder identities and accurate selection", () => {
const upper = "/case-sensitive/Foo";
const lower = "/case-sensitive/foo";
const identities = { [upper]: "verified:upper", [lower]: "verified:lower" };
const groups = mergeSessionDirectoryGroups([
[upper, [{ id: "upper-one", cwd: upper }, { id: "upper-two", cwd: upper }]],
[lower, [{ id: "lower-one", cwd: lower }, { id: "lower-two", cwd: lower }]],
], identities);
assert.equal(groups.length, 2);
assert.equal(new Set(groups.map((group) => group.folderKey)).size, 2, "React folder keys must be unique");
assert.notEqual(sessionDirectoryKey(upper, identities), sessionDirectoryKey(lower, identities));
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey(upper, identities))), ["upper-one", "upper-two"]);
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey(lower, identities))), ["lower-one", "lower-two"]);
});
test("case-distinct directories stay separate before filesystem identities are available", () => {
const upper = "/offline/Foo";
const lower = "/offline/foo";
const groups = mergeSessionDirectoryGroups([
[upper, [{ id: "upper", cwd: upper }]],
[lower, [{ id: "lower", cwd: lower }]],
]);
assert.equal(groups.length, 2);
assert.equal(new Set(groups.map((group) => group.folderKey)).size, 2);
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey(lower))), ["lower"]);
assert.deepEqual(sessionsForDirectoryGroup(groups, "unknown-selection"), []);
});
test("filesystem-verified aliases merge into one folder containing every spelling's sessions", () => {
const root = mkdtempSync(join(tmpdir(), "codex-x-directory-identity-"));
try {
const target = join(root, "Workspace");
const alias = join(root, "Workspace-alias");
mkdirSync(target);
symlinkSync(target, alias, process.platform === "win32" ? "junction" : "dir");
const verifiedKey = (path) => {
const metadata = statSync(path, { bigint: true });
assert(metadata.isDirectory());
return `verified:${metadata.dev}:${metadata.ino}:${realpathSync.native(path)}`;
};
const identities = { [target]: verifiedKey(target), [alias]: verifiedKey(alias) };
assert.equal(identities[target], identities[alias], "the temporary filesystem must confirm the alias");
const original = Object.freeze([
Object.freeze([target, Object.freeze([{ id: "target-one", cwd: target }, { id: "target-two", cwd: target }])]),
Object.freeze([alias, Object.freeze([{ id: "alias-one", cwd: alias }])]),
]);
const groups = mergeSessionDirectoryGroups(original, identities);
assert.equal(groups.length, 1);
assert.equal(new Set(groups.map((group) => group.folderKey)).size, 1);
assert.equal(groups[0].group, target, "the first folder label is retained");
for (const spelling of [target, alias]) {
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey(spelling, identities))), ["target-one", "target-two", "alias-one"]);
}
assert.equal(original[0][1].length, 2, "merging must not mutate parent session arrays");
assert.equal(original[1][1].length, 1);
assert.equal(sessionPinStorageKey(target, identities[target]), sessionPinStorageKey(alias, identities[alias]));
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("unresolved paths preserve case, backslashes, trailing spaces and roots", () => {
const paths = [
"/missing/Foo", "/missing/foo", "/missing/Foo/",
"/missing/name", "/missing/name ", "/missing/name ",
"/missing/with\\backslash", "/missing/with/backslash",
"/", "//", "C:\\", "C:/", " ",
SESSION_FOLDER_UNKNOWN_KEY,
];
const keys = paths.map((path) => sessionDirectoryKey(path));
assert.equal(new Set(keys).size, paths.length);
for (const path of paths) {
assert.equal(sessionDirectoryKey(path), sessionDirectoryKey(path, {}));
assert.notEqual(sessionDirectoryKey(path), SESSION_FOLDER_UNKNOWN_KEY);
}
for (const missing of [undefined, null, ""]) {
assert.equal(sessionDirectoryKey(missing), SESSION_FOLDER_UNKNOWN_KEY);
}
assert.notEqual(sessionDirectoryKey("/"), sessionDirectoryKey(null));
});
test("a nonempty raw path cannot borrow an inherited identity-table property", () => {
const identities = Object.create({ "offline/Foo": "verified:unrelated" });
assert.equal(sessionDirectoryKey("offline/Foo", identities), sessionDirectoryKey("offline/Foo"));
assert.notEqual(sessionDirectoryKey("offline/Foo", identities), "verified:unrelated");
});
test("directory pins remain independent for case-distinct folders", () => {
const identities = { "/work/Foo": "verified:Foo", "/work/foo": "verified:foo" };
const upper = sessionDirectoryKey("/work/Foo", identities);
const lower = sessionDirectoryKey("/work/foo", identities);
let stored = toggleFolderPins([], "/work/Foo", identities);
assert.deepEqual(stored, ["path:/work/Foo"], "persistent pins must retain the original path");
assert(pinnedSessionDirectoryKeys(stored, identities).has(upper));
assert(!pinnedSessionDirectoryKeys(stored, identities).has(lower));
stored = toggleFolderPins(stored, "/work/foo", identities);
assert.deepEqual(stored, ["path:/work/Foo", "path:/work/foo"]);
stored = toggleFolderPins(stored, "/work/Foo", identities);
assert.deepEqual(stored, ["path:/work/foo"]);
assert(!pinnedSessionDirectoryKeys(stored, identities).has(upper));
assert(pinnedSessionDirectoryKeys(stored, identities).has(lower));
assert.deepEqual(toggleFolderPins(stored, "/work/foo", identities), []);
});
test("persistent raw-path pins survive online, offline and recreated directory identities", () => {
const path = "/work/Project ";
const online = { [path]: "verified:original-inode" };
const recreated = { [path]: "verified:new-inode" };
const stored = Object.freeze(toggleFolderPins([], path, online));
assert.deepEqual(stored, ["path:/work/Project "]);
for (const identities of [online, undefined, {}, recreated]) {
assert(pinnedSessionDirectoryKeys(stored, identities).has(sessionDirectoryKey(path, identities)));
assert.deepEqual(toggleFolderPins(stored, path, identities), []);
}
assert(!pinnedSessionDirectoryKeys(stored, recreated).has(online[path]));
assert.deepEqual(stored, ["path:/work/Project "], "resolving or toggling must not mutate the saved pins");
});
test("unpinning a verified alias removes every matching raw-path pin and keeps unrelated pins", () => {
const target = "/work/Project";
const alias = "/links/Project";
const other = "/work/Other";
const identities = { [target]: "verified:shared", [alias]: "verified:shared", [other]: "verified:other" };
const stored = Object.freeze(["path:/work/Project", "path:/links/Project", "path:/work/Other"]);
for (const spelling of [target, alias]) {
const next = toggleFolderPins(stored, spelling, identities);
assert.deepEqual(next, ["path:/work/Other"]);
assert(!pinnedSessionDirectoryKeys(next, identities).has(identities[target]));
assert(pinnedSessionDirectoryKeys(next, identities).has(identities[other]));
}
assert.deepEqual(toggleFolderPins([], alias, identities), ["path:/links/Project"]);
assert.deepEqual(toggleFolderPins(["path:/work/Project"], alias, identities), []);
assert.equal(stored.length, 3);
});
test("case-distinct CODEX_HOME values have independent v2 pin storage", () => {
const upper = "/homes/Foo";
const lower = "/homes/foo";
for (const identities of [undefined, { [upper]: "verified:home-upper", [lower]: "verified:home-lower" }]) {
const upperKey = sessionPinStorageKey(upper, identities?.[upper]);
const lowerKey = sessionPinStorageKey(lower, identities?.[lower]);
assert.notEqual(upperKey, lowerKey);
assert(upperKey.startsWith("codexx.sessionPins.v2:"));
assert(lowerKey.startsWith("codexx.sessionPins.v2:"));
const cache = new Map([
[upperKey, JSON.stringify({ folders: ["upper-folder"], sessions: ["upper-session"] })],
[lowerKey, JSON.stringify({ folders: ["lower-folder"], sessions: ["lower-session"] })],
]);
const storage = { getItem: (key) => cache.get(key) ?? null };
assert.deepEqual(readSessionPinPreferences(upperKey, storage), { folders: ["upper-folder"], sessions: ["upper-session"] });
assert.deepEqual(readSessionPinPreferences(lowerKey, storage), { folders: ["lower-folder"], sessions: ["lower-session"] });
}
});
test("an unverified home scope falls back to case-preserving paths without borrowing snapshot IDs", () => {
const upper = "/homes/Foo";
const lower = "/homes/foo";
const snapshotIds = { [upper]: "snapshot:reused-file-id", [lower]: "snapshot:reused-file-id" };
assert.equal(sessionDirectoryKey(upper, snapshotIds), sessionDirectoryKey(lower, snapshotIds));
const upperKey = sessionPinStorageKey(upper);
const lowerKey = sessionPinStorageKey(lower);
assert.notEqual(upperKey, lowerKey, "snapshot identities alone must not merge persistent home scopes");
assert.equal(sessionPinStorageKey(upper, null), upperKey);
assert.equal(sessionPinStorageKey(lower, null), lowerKey);
const cache = new Map([[upperKey, JSON.stringify({ folders: ["path:/work/Foo"], sessions: ["upper-only"] })]]);
assert.deepEqual(readSessionPinPreferences(lowerKey, { getItem: (key) => cache.get(key) ?? null }), { folders: [], sessions: [] });
});
test("reused physical home IDs with different verified birth scopes have isolated caches", () => {
const home = "/homes/Project";
const oldScope = "verified-home:device-1:file-42:birth-100";
const newScope = "verified-home:device-1:file-42:birth-200";
const oldKey = sessionPinStorageKey(home, oldScope);
const newKey = sessionPinStorageKey(home, newScope);
assert.notEqual(oldKey, newKey);
const cache = new Map([[oldKey, JSON.stringify({ folders: ["path:/work/old"], sessions: ["old-generation"] })]]);
const storage = { getItem: (key) => cache.get(key) ?? null };
assert.deepEqual(readSessionPinPreferences(oldKey, storage), { folders: ["path:/work/old"], sessions: ["old-generation"] });
assert.deepEqual(readSessionPinPreferences(newKey, storage), { folders: [], sessions: [] });
});
test("ambiguous legacy v1 pins never populate either case's v2 preferences", () => {
const homes = ["/homes/Foo", "/homes/foo"];
const legacyKey = "codexx.sessionPins.v1:%2Fhomes%2Ffoo";
const cache = new Map([[legacyKey, JSON.stringify({ folders: ["/work/foo"], sessions: ["legacy-session"] })]]);
const reads = [];
const storage = { getItem: (key) => { reads.push(key); return cache.get(key) ?? null; } };
for (const home of homes) {
const currentKey = sessionPinStorageKey(home);
assert.notEqual(currentKey, legacyKey);
assert.deepEqual(readSessionPinPreferences(currentKey, storage), { folders: [], sessions: [] });
}
assert.equal(new Set(reads).size, 2);
assert(reads.every((key) => key.startsWith("codexx.sessionPins.v2:")));
assert(!reads.includes(legacyKey));
});
test("unavailable storage readers return empty pin preferences", () => {
const storageKey = sessionPinStorageKey("/homes/Project");
const denied = { getItem() { throw new Error("fixture storage unavailable"); } };
assert.deepEqual(readSessionPinPreferences(storageKey, denied), { folders: [], sessions: [] });
const deniedGetter = { get getItem() { throw new Error("fixture storage getter unavailable"); } };
assert.deepEqual(readSessionPinPreferences(storageKey, deniedGetter), { folders: [], sessions: [] });
});
test("missing-workspace sessions and root-directory sessions select separate folders", () => {
const groups = mergeSessionDirectoryGroups([
["No workspace", [{ id: "missing-one", cwd: null }, { id: "missing-two" }]],
["/", [{ id: "root", cwd: "/" }]],
]);
assert.equal(new Set(groups.map((group) => group.folderKey)).size, 2);
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, SESSION_FOLDER_UNKNOWN_KEY)), ["missing-one", "missing-two"]);
assert.deepEqual(sessionIds(sessionsForDirectoryGroup(groups, sessionDirectoryKey("/"))), ["root"]);
});
test("missing-workspace and root-directory pins stay independent and persist through identity changes", () => {
const rootIdentity = { "/": "verified:root" };
let stored = toggleFolderPins([], null, rootIdentity);
assert.deepEqual(stored, [SESSION_FOLDER_UNKNOWN_KEY]);
stored = toggleFolderPins(stored, "/", rootIdentity);
assert.deepEqual(stored, [SESSION_FOLDER_UNKNOWN_KEY, "path:/"]);
assert(pinnedSessionDirectoryKeys(stored, rootIdentity).has(SESSION_FOLDER_UNKNOWN_KEY));
assert(pinnedSessionDirectoryKeys(stored, rootIdentity).has(rootIdentity["/"]));
assert(pinnedSessionDirectoryKeys(stored).has(sessionDirectoryKey("/")));
stored = toggleFolderPins(stored, undefined, rootIdentity);
assert.deepEqual(stored, ["path:/"]);
assert.deepEqual(toggleFolderPins(stored, "/", rootIdentity), []);
});
+248
View File
@@ -8,6 +8,254 @@
日志不得包含 Token、认证文件内容、用户数据或仅适用于某台电脑的隐私路径。
## 2026-09-30:图片输入、模型展示与供应商卡片排序(本地验证完成)
- Issues #63/#65 的图片拦截来自自定义模型目录的 `input_modalities = [text]`。
生成目录现允许 `text` 与 `image`,保留真实模型 ID;供应商是否支持图片仍由其接口决定。
独立修复命令只升级路径、所有权标记与摘要均验证通过的旧 Codex-X 目录,生成不可变新文件,
备份并条件写入配置指针;认证、缓存、其他配置和旧目录保持可恢复。状态读取接口继续只读。
- Issue #64 的新目录原本已包含 Ultra;Codex GUI 还由“模型选择器滑块中的 Ultra 选项”及多代理设置控制。
补充客户端提示和旧能力目录迁移,不强制修改 Codex 的偏好。Codex CLI 0.159 读取目录与本地 Responses
模拟请求验证通过:实际包含图片输入,Ultra 正常映射为 API 的 Max,turn 完成,没有真实供应商请求。
- Issue #62 支持官方、已保存和检测卡片跨来源拖动;Pointer Capture 避免 WebView 原生拖放冲突,
支持键盘上下移动、边缘连续滚动和列表外取消。顺序按配置目录独立保存到 SQLite v8 偏好表;
保存期间阻止冲突刷新,失败恢复原顺序,新卡追加,缺席卡忽略。
- Issue #61 卡片显示模型,映射名称与真实 ID 同时保留;当前第三方卡使用实时配置模型,官方卡优先使用
官方登录监控读到的模型。新增显式 `CODEXX_CONFIG_DIR`,配合已有 `CODEXX_HOME` 隔离原生验证数据。
- 618 项 Rust、64 项前端测试、类型检查通过;浏览器验证明暗布局、上下拖动、列表外取消、别名与 ID 展示、
重载持久化、延迟保存及失败回滚。本地 `.app` 已构建、备份后替换安装,并通过本地签名与二进制摘要校验。
已安装 App 的旧目录迁移和独立数据库启动通过;原生 App 验证模型别名及 ID、跨来源上下拖动、SQLite
写入失败回滚和退出重启后的顺序保留,明暗主题显示正常。测试使用独立配置和数据库,真实配置/认证摘要一致。
- 用户已批准将本批修改作为 v0.3.22 推送,并由既有 Actions 完成跨平台打包、验签与发布。
- [v0.3.22 发布流水线](https://github.com/yynxxxxx/Codex-X/actions/runs/36705339645) 的四平台与最终发布验证全部成功;
Windows 全量 Rust 及 NSIS/旧 MSI 安装迁移通过,两架构 macOS 签名、公证与 DMG 校验通过。
公开更新清单的 11 个平台项验证完整,正式 Apple Silicon 更新包摘要、签名与公证票据核对通过,本地已换成正式 v0.3.22。
## 2026-09-22:Windows 更新改为用户安装并支持无重启迁移
- Windows 改用 NSIS 当前用户安装,旧 MSI 首次迁移只提升系统卸载子进程;准确核对旧产品身份、卸载结果和目录隔离,避免第二份安装。不因 MSI 返回 3010 一律要求重启;旧产品登记已移除、新目录安全隔离时继续使用新版,旧目录残留交给 Windows 后续清理。
- 更新仍由 Tauri 插件下载和验签,随后恢复路由并正常退出。启动失败恢复原路由;恢复失败保留设置并明确提示。下载、验签、准备、交接阶段分别显示,长等待提示不引发重复安装。后台和安装器均保存阶段日志。
- Windows 实测发现并修复 NSIS 的 GUID 字符串整数判空、不存在目录路径处理及日志覆盖问题;目录比较使用真实路径,保护取消后重试、子目录和 junction。测试脚本显式构造旧系统级安装,避免合成环境遗留路径干扰。
- 本机 609 项 Rust、61 项前端、8 项清单测试通过,明暗主题与等待提示已检查。Windows 原生 599 项 Rust 和安装迁移全流程通过,覆盖实际 3010 无重启场景;配置哨兵及唯一卸载项检查通过。测试环境普通更新安装阶段约 1.5–1.6 秒,不含下载。
- 修改位于独立 `codex/windows-updater-migration` 测试分支,尚未合并主分支或发布新版本。官网 EXE/MSI 下载兼容仅在本地准备,未推送官网源文件或部署。
## 2026-09-18:Windows 发布验证修正
- v0.3.19 草稿构建在 Windows 测试阶段发现路由重启恢复问题,未公开发布该版本。
数据库存储的目录比较键会统一斜线和大小写,启动恢复却直接将其用作原生路径和运行实例键;
Windows 的 verbatim 前缀及规范大小写因此与交互入口不一致。恢复时现先还原原生分隔符,
再使用交互入口相同的路径解析,新增持久化目录与运行实例一致性的回归。
- 流式停止测试在上游等待期间同步探测已关闭端口,Windows 的默认连接等待耗尽了测试的静默期限;
改为有明确短期限的连接探测,仍验证停止监听不截断已经开始的响应,不放宽运行时超时策略。
- 修正沿用新的 v0.3.20 标签重新发布,保留失败构建记录,不覆盖 v0.3.19 标签。
## 2026-09-18:按 CC Switch 对齐路由控制与故障转移
- 对照 CC Switch `06082e189d65e6d6dbadc35dacdac1ce6c79d89a` 的 UI → IPC → 数据库 → 运行时链路,
将监听服务、Codex 请求接管、自动故障转移拆成三个独立状态;设置版本升级为 2,并迁移旧单开关与
“主供应商之外的候补”记录。队列现在保存完整 P1 起始顺序,单项队列可用,不再要求模型 ID 相同。
保存设置、首次开启自动模式时先启用有效 P1;空队列只在首次开启时自动补入当前已保存的第三方供应商。
- 参数采用 Codex 对应默认值与边界:重试 3 次、响应头/流式首块分别等待 60 秒、流式静默 120 秒、
非流式单次总期限 600 秒;熔断失败/恢复阈值为 4/2,等待 60 秒,错误率 60%、最少 10 个样本。
前后端都校验;支持 IPv4/IPv6/localhost,端口或地址变更要求停止监听。通配监听写给本机客户端时
使用回环地址,绑定非回环接口也保留本地认证。
- 按上游状态机适配 Closed/Open/HalfOpen 熔断与单探测许可;参数热更新不清空未改变供应商的健康记录。
尝试上限为重试次数加一,每家每请求至多一次,熔断跳过不占次数。已提交响应后不重放整条请求;
含服务端状态的请求只尝试本次快照队首,不提供历史 response/session 的永久供应商绑定。
- 官方账号支持独立原生 HTTP/SSE 接管,始终排除第三方自动队列。Codex 负责登录和 token 刷新;
每请求验证当前选中 profile、实时 token/account,OAuth 与官方 API Key 分别固定官方上游,不从
未选中账号借用认证。官方模型端点只提供受管本地目录或空目录,不宣称完整远程模型发现。
- 手动切换或编辑当前供应商使用完整操作包装,暂时撤下覆盖后再恢复接管,不再直接关闭全部路由。
自动成功选择按监听实例身份、运行 revision、令牌和配置代次验证后更新逻辑当前项;过期返回不能覆盖
后来操作,也不能在停止重启后因 token/revision 恰好相同而污染新实例。
路由设置、供应商更新、退出及崩溃恢复继续保留条件写入、认证写入顺序和失败回滚,恢复成功后才停监听。
- 官方状态、额度、登录监控和快照读取解包后的逻辑配置;本地地址、认证令牌和代次头不能变成保存的
供应商凭据。路由事件只提示前端静默重读当前目录,使用目录/请求代次检查并延后编辑期间的更新,
不清空页面和未保存草稿。既有通用配置继承、官方/第三方认证隔离及内部会话过滤修复继续保留。
- 本轮替代 2026-09-17 初版中的同模型限制、单开关、固定 30 秒冷却和手动切换关闭功能等约束;
不包含 CC Switch 的全部协议转换器、其他应用代理、日志计费或 OAuth 设备码反代。
具体对应、适用边界和上游差异见 [路由对照说明](ROUTING_CC_SWITCH_PARITY.md)。
- 复用的熔断器与压缩处理保留 MIT 来源和版权;`THIRD_PARTY_NOTICES.md` 记录上游提交及完整许可。
验证使用合成凭据、临时 CODEX_HOME 和本机服务,没有对用户真实供应商发送测试请求。
- 598 项 Rust 测试、51 项前端测试、类型/格式/diff 检查通过,macOS `.app` 构建成功。
浏览器验证三层开关、P1 排序、不同模型队列、参数范围与撤销、静默超时设为 0、72.5% 错误率、
熔断重置/恢复状态、暗色对比,以及失败保存后的草稿保留。
解锁后已正常退出旧版、替换并启动本地 App;新旧 ZIP 的完整性、可执行文件哈希与签名均通过校验。
实际安装版确认路由页面、已有队列和参数可读取,独立监听能启动并停止,结束后恢复原有关闭状态。
全程 config.toml 与 auth.json 哈希一致,没有接管真实账号或发送供应商测试请求。
应用目录仅保留一份 Codex-X,旧版与构建副本压缩归档;尚未推送 GitHub。
## 2026-09-18:第三方供应商通用配置继承
- 已只读确认:当前 live 文件已退化为供应商/模型及少量根字段,默认官方账号的同目录可信快照仍保留
MCP、desktop、marketplaces 等完整集成配置。因此官方新增能显示完整内容,第三方只能继承精简 live。
另有启用漏洞:旧实现将 reasoning/disable_response_storage 等任意额外根字段视为完整配置,
导致带这些字段的旧模板可整体覆盖当前通用设置。
- 新增只读配置底稿接口,添加入口先读取一次,所有自定义/厂商预设共用该底稿;读取失败直接提示,
不继续用精简模板替代。目录/请求变化使旧返回失效。旧精简模板编辑时也补入当前通用设置。
- 当前文件仅有供应商相关字段时,可从同目录默认官方快照恢复 mcp_servers、desktop、marketplaces、
plugins、projects 五个集成表;不复制历史登录凭据、供应商路由、模型、安全策略、计费档位或通知命令。
正常完整 live 为通用设置的权威来源,不用历史快照覆盖它;底稿生成本身不写 live 或 auth.json。
- 普通启用/编辑供应商基于最新通用配置,只更新供应商与模型设置;旧模板缺项不会删除 MCP 等设置,
历史模板的过期通用值也不会覆盖现值。手工编辑当前供应商完整 TOML 有独立显式应用路径,保留用户
增删通用设置的能力。1M 复选框不被视作手工修改通用配置。
- 新建或换接口地址时清理上一个供应商的专属请求头、环境认证和查询参数,MCP 自己的 env 原样保留。
- 每个 CODEX_HOME 在首次成功应用修复后的配置时记录处理标记,之后不再把用户有意清空的通用配置
当成旧版损坏反复回补;预览和仅保存未启用的供应商不置标记,失败应用也不置标记。
标记写入失败沿用现有 config/auth/官方快照回滚,不留下半完成的设置。
- 往返回归另外复现了普通切回官方会重放旧配置的问题,官方切换现也保留最新通用设置,仅从目标官方
配置读取模型、官方路由与账号约束;账号认证切换顺序和显式官方编辑语义保持原有规则。
- 553 项后端测试、45 项前端测试、类型/格式检查与 macOS 构建通过。新增回归覆盖旧精简模板、已有
精简 live、跨目录快照拒绝、MCP env、未知通用字段、显式删除、失败回滚、模型上下文与账号往返隔离。
模拟界面确认自定义/千问/DeepSeek 与官方预设切换、保存后再编辑都保留集成表;读取底稿失败停留列表
并提示错误,恢复读取后可正常新增。
- 本地 App 已重新构建、签名/二进制校验、替换并启动;真实目录下只读打开自定义及千问新增草稿,
确认五个集成表与 node_repl.env 都存在。config.toml、auth.json 和官方快照前后哈希一致,
没有保存/启用测试供应商。旧 App 与构建副本已压缩归档,仅保留一份安装;尚未推送 GitHub。
## 2026-09-17:回退会话文件名的 ID 校验回归
- 上次内部类型检查增加了文件名与首条 session_meta.id 的核对,但直接取文件名末尾 UUID,
把 thread/revert 生成的 `rollout-<timestamp>-<thread-id>_<rollout-id>.jsonl` 中 rollout ID
当成线程 ID。实际首条元数据和活动 SQLite 都仍使用前面的稳定 thread ID,导致正常记录误报。
已只读核对用户报告的三个文件的身份字段与索引,不读取输出正文、不改动实际日志或数据库。
- 依据 [Codex 0.154.0 文件名定义](https://github.com/openai/codex/blob/rust-v0.154.0/codex-rs/rollout/src/rollout_file_name.rs),
文件身份与查找匹配统一解析稳定 thread ID;支持普通与回退文件名,并保留现有压缩文件名匹配。
rollout ID 不作为会话别名,不能据此选中另一条会话。索引权威路径、元数据精确匹配和内部类型检查保留。
- 新增四项回归,覆盖普通/回退文件名、日志 ID 不冒充会话 ID、真正元数据不一致、内部回退记录不修改、
从检查到事务同步成功,以及 SQLite 以错误 ID 引用文件时仍拒绝同步。
- 544 项后端测试、格式检查和 macOS 构建通过;本地 App 已校验替换并启动,旧安装和构建副本压缩归档。
本次未推送 GitHub,未对真实会话执行同步、删除或重命名。
更新后只读复查实际目录,界面显示「普通会话已同步」,上述 ID 不一致及异常文件警告已消失。
## 2026-09-17:可选的供应商故障自动切换(#49)
- 按用户要求只在设置新增第三个标签「故障自动切换」,沿用标签过渡;供应商列表不增加入口。
默认关闭,当前已启用的第三方供应商优先,最多选择 8 家备用并调整顺序。运行状态轮询保留草稿,
切页暂停、目录变化或旧响应不能覆盖新页面;开启/关闭提示重新打开 Codex。
- 对照 CC Switch 06082e189d65e6d6dbadc35dacdac1ce6c79d89a 的代理与熔断实现:故障必须由实际请求判断,
不能用模型列表测速代替。新增仅监听 127.0.0.1 的 HTTP Responses 转发,接管当前 provider 的传输字段,
不修改模型、模型目录、会话、auth.json 或全局认证;官方登录不参与候补。
- 每次请求只尝试显式选中的候补,且其配置必须包含完全相同的模型 ID。连接/超时、认证/限流及部分
服务端故障触发切换;普通参数错误不轮询。失败供应商冷却 30 秒,每家每次请求最多尝试一次。
服务端会话状态绑定的请求不跨供应商重放;已发送响应后绝不重试整条请求,避免重复生成或工具调用。
- 入站要求随机本地令牌、固定 Host,拒绝浏览器来源及任意目标转发;请求头、正文、并发与等待均有上限。
出站独立使用每家保存的凭据,不透传本地令牌或官方账号头,不跟随重定向;不记录请求正文和密钥。
- 应用数据库增加按 CODEX_HOME 隔离的设置与恢复记录,先备份/写恢复记录再原子覆盖传输字段。
关闭功能、手动切换/编辑当前供应商及退出时先恢复直连再停监听;恢复失败阻止退出并保持服务可用。
成功退出后拒绝排队的重新启用请求,避免遗留失效地址。启动按原端口恢复,无法恢复则回到直连并提示。
- 仅还原仍属于本功能的字段,保留随后编辑的其他设置;历史恢复记录也可识别旧备份里的本地地址。
状态、供应商识别、编辑草稿及旧 TOML 提交都使用真实上游,防止产生 localhost 供应商或保存本地令牌。
保存/删除/导入供应商会即时刷新运行队列,已失效的候补使自动切换暂停;后台也检查外部配置变化。
- 验证:540 项 Rust 测试和 45 项前端测试、类型/格式检查通过;其中 21 项真实本机 HTTP/SSE 测试
覆盖故障候补、流式首块边界、凭据隔离、关闭/改队列、认证与长度限制,以及持续流超过单次读取超时。
浏览器确认明暗主题、三个标签切换、添加/排序/保存、失败保留旧状态、冷却显示和离开标签停止轮询。
本次仅使用合成服务与临时配置验证,没有启用用户真实供应商的自动切换,也没有向其发送测试请求。
- 本地 macOS App 已构建、签名/二进制校验、替换并启动;旧安装与构建副本保留为校验过的 ZIP,
系统只注册 Applications 中的一份 App。尚未推送 GitHub,也未发布 Windows 安装包。
## 2026-09-17:内部会话误报待同步与侧栏污染
- 原过滤主要识别 subagent,遗漏了 internal/guardian、memory_consolidation 及其 SQLite 表示;
泛化的 thread_source=user 还会清除已有的内部来源或 spawn 关系。跳过 rollout 后,部分内部 ID
仍留在数据库同步候选中,可能被补入桌面会话目录。这些类型在 Codex 0.153.4 已存在,并非只由新版引入。
- 活动 SQLite、其权威 rollout 的首条 session_meta、spawn 关系和已有桌面目录共同提供分类依据;
当前存储中的明确内部证据不会被默认 user 标记覆盖。旧数据库的同 ID 副本不覆盖活动记录的归属。
不按标题、模型名、空正文或缺少用户事件猜测;普通父会话及仅有 forked_from_id 的用户分叉继续保留。
- 检查计数、预览、rollout/SQLite 写入和目录补建均排除内部任务;事务提交前后复核 rollout 身份,
分类发生变化时回滚。内部记录不会因同步改写 provider、工作目录或缺失标记,也不新增侧栏条目。
列表达到上限时优先保留普通会话;“显示内部会话”仅影响展示,不会把内部记录加入同步候选。
- 用量扫描共用内部类型识别,内部用量仍计入总量,有明确父会话时合并,不生成单独的用户会话行。
相同 ID 的多个日志仍按原规则合并用量,但类型和子代理父关系以当前数据库引用的文件为准,
避免旧内部副本隐藏当前用户会话;仅有首条 thread_source 标记的内部任务也按相同规则处理。
- 回归使用临时数据库与日志,覆盖自动审核、来源冲突、真实用户分叉、旧副本、超长元数据与写入竞态。
本次不对真实会话执行同步或清理;既有侧栏污染不会自动删除,防止误删用户历史。
- 505 项 Rust 测试、45 项前端测试、类型及格式检查通过。模拟界面确认默认仅显示普通会话,
开启内部会话显示后仍无待同步标记,且仅有内部差异时同步按钮不可用;尚未在 Windows 真机验证。
- macOS App 已重新构建、签名与二进制校验、替换并启动;旧安装和构建副本经校验后压缩归档,
仅保留一份应用注册。本地版本仍为 0.3.18,修复列在 Unreleased,尚未提交或推送 GitHub。
## 2026-09-17:v0.3.18 发布准备
- 汇总后台配置检查、统一检查入口、会话/MCP/Skills 导出导入、大日志统计和模型推理等级/DeepSeek WS 修复。
发布说明沿用四条面向用户的更新,根目录、桌面、Tauri、Cargo 清单及锁文件统一为 0.3.18。
- 发布前沿用已完成的 490 项后端、45 项前端回归与本地 App 验证,版本更改后复核类型、构建和锁文件。
本次停止继续调查 Codex 前端临时显示 Luna 的问题,不改历史会话或官方模型偏好。
- 官网源码和本地测试截图继续留在本机,未纳入发布提交;由版本标签触发现有四平台构建、签名与发布校验。
## 2026-09-17:模型推理等级与第三方 WebSocket 回退
- 所有第三方模型映射统一生成完整八档菜单(none/minimal/low/medium/high/xhigh/max/ultra),默认 high。
用户启用供应商后直接在 Codex 中选择,无需在 Codex-X 逐模型配置,不增加档位选择表单或厂商映射说明。
菜单可选不代表所有上游都实现了八种独立强度,实际效果仍由接口和 Codex 决定。已有模型记录无需增字段。
目录按内容生成新文件,不覆盖旧目录或模型缓存。
- 自定义目录补齐 supported_reasoning_levels 和 default_reasoning_level;有档位时为旧 Codex 保留
supports_reasoning_summaries=true,同时关闭摘要参数、默认摘要设 none。Codex 0.153/0.154 已将
effort 独立发送;ultra 在客户端可能折算成 max,并不保证独立的上游档位。
参考 CC Switch 06082e1 的 CodexFormFields/codex_config 实现及 DeepSeek 官方 Responses/Codex 文档,
没有复制上游代码、缓存提示词或伪造 GPT 的其他能力。
- WSS 405 根因是第三方草稿继承了官方 custom 表中的 supports_websockets=true。传输能力现在归属
对应端点:新建(包括携带同 URL 的继承模板)默认 false;编辑自身同端点模板保留明确值,换地址重置。
api.deepseek.com 精确匹配禁用 WS,独立代理域名不干预;官方登录仍为 true,不改全局 WS feature。
已保存 DeepSeek 重新启用也会修正,当前配置通过配置检查提示并由用户点击后备份修复。
- 验证覆盖完整档位元数据与默认值、旧记录、供应商官方/第三方切换、同端点新建与编辑区分,
以及现有错误配置的只读检查和可选修复。模型目录刷新需要重新启用供应商并重新打开 Codex。
最终 490 项后端测试、45 项前端测试、类型检查与 macOS 构建通过;本地 App 已备份、校验并替换启动,
只保留一份应用注册。未使用用户密钥请求上游,也未重启用户的 Codex 客户端。尚未推送 GitHub。
## 2026-09-12:会话导出、Skills/MCP 归档与大日志统计
- 会话列表新增单条 Markdown 导出和多选 ZIP 导出,只包含明确选择的会话。复用活动 SQLite 与权威
rollout 路径,验证存储根、普通文件和 session_meta.id,支持非标准文件名;正文按角色与事件类型筛选,
过滤系统/工具/推理记录,以计数方式消除 event_msg 与 response_item 镜像,同时保留重复对话轮次。
图片/音频使用占位,记录按行选择解析。单条记录仍有 64 MiB 保护上限,损坏或过大记录会报告部分导出。
- Skills/MCP 使用带版本 manifest 的 ZIP,保留配置、文件权限、空目录、启用状态和备注;当前标签页可导出
全部内容,同一 ZIP 入口识别普通 Skill 包与 Codex-X 归档。本地 ZIP 取消压缩与展开后的 20 MiB 大小上限,
使用 rfd 原生文件窗口和 Rust 文件流,避免大文件在前端 ArrayBuffer/整数数组及 IPC 中多次复制。
本地归档可能包含 MCP 连接凭据,导出入口有保管提示;不上传归档或会话。
- ZIP 路径、重复路径、链接与特殊文件校验保留;导入预先检查所有同名冲突,不覆盖不同内容。
MCP 配置与数据库变更共同回滚,失败移除本次创建的 Skill;导出临时文件使用受限权限、目标快照校验
和原子替换,禁止写回应用数据目录。用户取消文件选择时不执行读写。
- 导入已有的假 stdio 项来自无效/空 MCP 配置的默认展示;候选现在必须具备有效 command 或 URL,
预览与导入复用同一验证与去重路径,已存在的实时配置不再计入新增。兼容包装 JSON,正确处理
config.toml 中 enabled=false 的条目。暗色弹窗补齐统计格子、列表文字、边框及警告区域的颜色。
- 用量扫描将 2 MiB 整行跳过改为 64 KiB 缓冲快路径与超长行流式反序列化,跳过大正文/图片字段,
仍读取 token_count、模型与父子会话信息;保留增量偏移、扫描预算、坏行恢复和尾行补写规则。
- 验证:479 项 Rust 测试、45 项前端测试、类型检查和 macOS 构建通过,包含 >20 MiB ZIP、归档往返、
冲突与错误回滚、导出正文去重、非常规会话路径,以及 >2 MiB 图片/工具/计费事件等回归。
浏览器确认暗色导入弹窗、无新增提示、分标签导出、单/多会话导出与取消操作;本机确认原生打开/保存
窗口可正常取消,原先的四个已有 MCP 不再被提示重新导入。本地 App 已替换,旧 App 和构建副本均经
ZIP/二进制校验后归档,仅保留一份应用注册。尚未推送 GitHub。
## 2026-09-10:后台配置检查与可选修复
- 后续界面调整:将设置中的首次向导入口与配置检查卡片合并为同高的一行「环境与配置检查」。
行内仅保留配置状态和检查按钮;环境详情、配置问题和修复方案统一放进原检查窗口。
首次安装自动打开,普通手动检查不删除或重置首次使用标记;后台静默检查保持独立。
环境检测加载或失败时窗口仍可打开,配置检查/修复继续可用;修改目录后需重新检查再提供修复,
避免把旧目录结果误用于新目录。查看过的问题不再紧接着重复弹出后台提醒。
- 新增独立的只读检查 IPC;窗口可见且未编辑/写入配置时后台检查,回到窗口时唤醒。
不依赖 get_codex_state 成功,也不占用供应商操作的 loading 状态。稳定错误需间隔至少 1 秒确认两次。
同一目录的同一问题保存提醒记录;30 秒提醒支持直接修复和进入通用设置,忽略后提示设置入口。
弹窗、隐藏窗口和其他通知期间暂停提醒计时;无关配置编辑更新修复指纹但不缩短剩余时间。
临时缺失/空文件不会直接清除提醒记录,正常配置也经稳定确认后才允许以后再次提醒。
- 检查 TOML 语法和供应商表、名称、选择标识、认证开关及协议字段,保留 Codex 的合法默认值。
依据 [Codex 配置说明](https://learn.chatgpt.com/docs/config-file/config-reference) 和
[rust-v0.153.4 配置校验](https://github.com/openai/codex/blob/rust-v0.153.4/codex-rs/config/src/config_toml.rs):
name 对普通自定义供应商必需,省略 base_url、wire_api、认证/WebSocket 开关本身不是错误。
不把 Chat Completions 协议盲改为 Responses;无法确认的问题提供查看配置入口。
- 旧会话恢复可以使用数据库中的 model_provider,所以根配置正确时也要检查历史引用。
只查询活动 SQLite 中最近 10,000 条记录的不同标识,不读取会话正文;有效的顶层 profile 配置可解释
相应引用,避免误报。仅当缺失一个引用且有唯一完整来源时提供补齐方案,提示明确说明沿用哪个供应商。
多个来源或无法确定时不猜测,不改数据库、auth、模型或其他配置文件。
- 修复只在用户点击后执行:备份、文件锁、配置/认证判断/会话引用/profile 指纹复核及原子写入。
外部修改使旧方案失效;符号链接配置不覆盖。错误提示不包含源文件行或凭据。
这是供应商相关的有限检查,不宣称完整验证 Codex 所有扩展设置。
- 验证:445 项 Rust 测试、45 项前端测试、类型检查及构建通过。浏览器验证首页读取失败仍可进入设置修复,
提醒到期后供应商可操作,额度弹窗期间提醒隐藏,关闭提醒后的设置提示可见,手动问题无自动修复按钮。
本地 macOS App 已构建、签名校验、替换并启动;旧安装与构建副本均经完整 ZIP/二进制校验后归档,
Launch Services 只注册 Applications 内的一份安装。此次尚未发布到 GitHub。
## 2026-09-09:v0.3.17 发布准备
- 将已完成本地验证的官方账号额度/重置次数、套餐徽标、主流预设、模型映射与供应商编辑修复整理为 v0.3.17。
+208
View File
@@ -0,0 +1,208 @@
# Codex 路由与故障转移:CC Switch 对照说明
本说明记录 Codex-X 本轮路由实现与 CC Switch 的对应关系,供维护者检查行为和后续回归使用。
入口为「设置 → 路由与故障转移」。本轮对齐 Codex 的监听、接管、优先队列、请求重试、熔断和
原生官方账号路由;不等于移植 CC Switch 的全部应用适配器和请求转换功能。
参考版本固定为 CC Switch 提交
[`06082e189d65e6d6dbadc35dacdac1ce6c79d89a`](https://github.com/farion1231/cc-switch/tree/06082e189d65e6d6dbadc35dacdac1ce6c79d89a)
(2026-09-15)。以下描述以该版本源码及本项目实现为准,不能用另一版本的说明文案替代代码行为。
## 三层开关
三个状态分别持久化;界面应同时区分「保存的偏好」和「目前实际运行的状态」。
| 设置字段 | 默认值 | 行为 |
| --- | --- | --- |
| `routerEnabled` | `false` | 启动本地监听服务;单独开启不会改写 Codex 配置。 |
| `takeoverEnabled` | `false` | 将当前 Codex 供应商的传输配置指向本地监听;要求路由服务开启。 |
| `autoFailoverEnabled` | `false` | 对第三方 API 请求使用完整优先队列;关闭时只使用当前供应商。 |
- 首次开启自动故障转移要求服务与接管均已开启。保存设置、让开关从关变为开时立即启用队列 P1。
- 关闭接管会先恢复直连,监听服务可以继续运行。关闭路由总开关会恢复直连并关闭接管,
但保留自动故障转移偏好、队列和参数。
- 官方登录使用独立的原生官方路由,自动队列暂不参与;这不要求删除原有第三方队列。
- 手动切换第三方供应商、编辑当前配置、切换官方账号时,完整原操作包在
`with_provider_change` 中:暂时撤下本地配置覆盖,完成原操作,再按新的逻辑供应商恢复接管。
不再沿用初版“手动切换就关闭全部自动切换”的行为。
- 运行状态的 `running`、`takeoverActive`、`autoFailoverActive` 不能互相代替。
未接管、当前为官方账号或恢复失败时,保存的自动开关与实际自动路由状态可能不同。
CC Switch 的对应字段位于 `GlobalProxyConfig`、`AppProxyConfig`,由
[`commands/proxy.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/commands/proxy.rs)
和 [`services/proxy.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/services/proxy.rs)
实现。Codex-X 按 `CODEX_HOME` 隔离设置和运行实例;CC Switch 的设置按应用类型保存。
此外,CC Switch 在最后一个应用取消接管时会尝试停止服务,Codex-X 保留独立监听开关的含义。
## 完整队列与 P1
`providerIds` 保存 **P1、P2、P3……整个队列**,不是“当前主供应商之外的备用列表”。
当前供应商也可以加入;队列允许只有一家。新请求在自动模式下从 P1 开始,跳过暂时被熔断的项。
成功使用后面的项后,逻辑当前供应商会更新,但下一次请求仍按队列优先级选择。
开启自动模式时,空队列只会自动加入当前已保存、可用于路由的第三方供应商。
如果当前是官方账号或尚未保存的临时供应商,需要先选定有效 P1。P1 校验与启用失败不能留下
“界面已经开启、实际目标却未切换”的半完成状态;Codex-X 使用配置及状态检查点进行恢复。
队列可在停止状态编辑。已经开启自动模式后,移除最后一个成员不会偷偷重填当前供应商;
没有可用目标时请求返回 503。已删除或无效的供应商不再参与转发。供应商保存、删除、导入会刷新
运行配置,后台也定期检查外部变化。
没有“必须配置相同模型 ID”的入队限制。路由不修改请求中的 `model`,也不会把它替换为每家
供应商的默认模型;该模型是否可用仍取决于相应上游。参数不兼容等非重试错误会直接返回。
队列顺序在 Codex-X 的路由页独立保存,最多 64 项;这是资源上限。CC Switch 使用
`providers.in_failover_queue`,并按供应商列表的 `sort_index`、ID 排序,没有同模型或至少两家的限制。
对应源码为
[`commands/failover.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/commands/failover.rs)
和 [`database/dao/failover.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/database/dao/failover.rs)。
## 参数默认值与范围
配置版本为 2,IPC 使用 camelCase、平铺字段。下表是 Codex 的参数,不是 CC Switch 中 Claude
那组不同的默认值。前端与 Rust 服务端都会校验;界面错误率使用百分数,持久化使用 0–1。
| 字段 | 默认值 | 可配置范围 / 含义 |
| --- | --- | --- |
| `listenAddress` | `127.0.0.1` | IPv4、IPv6 字面量或 `localhost`;不接受任意主机名。 |
| `listenPort` | `15721` | 整数 1024–65535。 |
| `providerIds` | `[]` | 有序供应商 ID,最多 64 项,不接受重复、空值或官方账号 ID。 |
| `maxRetries` | `3` | 0–10;一次请求最多尝试 `maxRetries + 1` 个候选,每家至多一次。 |
| `streamingFirstByteTimeout` | `60` 秒 | 1–120 秒;分别用于等待响应头及首个响应数据块。 |
| `streamingIdleTimeout` | `120` 秒 | 0–600 秒;流式相邻数据读取间隔,0 表示不设置静默超时。 |
| `nonStreamingTimeout` | `600` 秒 | 60–1200 秒;单次非流式上游尝试的总期限,包括接收正文。 |
| `circuitFailureThreshold` | `4` | 1–20;达到连续失败次数即熔断。 |
| `circuitSuccessThreshold` | `2` | 1–10;半开探测达到此成功次数后关闭熔断。 |
| `circuitTimeoutSeconds` | `60` 秒 | 0–300 秒;从熔断到允许探测的等待时间。 |
| `circuitErrorRateThreshold` | `0.6` | 0–1,即界面的 0–100%;达到该错误率可触发熔断。 |
| `circuitMinRequests` | `10` | 5–100;错误率判断所需的最少样本数。 |
监听地址、端口只能在停止服务后修改。`localhost` 规范为 `127.0.0.1`;监听所有网卡时,
写入本机 Codex 的连接地址使用回环地址:`0.0.0.0 → 127.0.0.1`,`:: → ::1`。
IPv6 URL 使用方括号,实际绑定使用 `IpAddr`,不照搬上游裸 IPv6 字符串拼接的问题。
自动故障转移关闭或当前为官方账号时,运行路径只尝试当前目标,不使用上述熔断策略;
响应头等待和非流式请求采用 600 秒默认期限,不设置流式首数据块与静默期限。
本地入站读取、连接建立和向客户端写出仍有独立资源保护,不能将此理解为所有等待都无限制。
上游依据:
[`database/dao/proxy.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/database/dao/proxy.rs)、
[`proxy/types.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/proxy/types.rs)、
[`AutoFailoverConfigPanel.tsx`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src/components/proxy/AutoFailoverConfigPanel.tsx)。
## 请求重试与熔断
一次请求持有开始时的路由、凭据和参数快照。改队列或参数影响后续请求,不把一条正在处理的请求
换成另一套凭据。被熔断或被其他半开探测占用的候选不消耗实际尝试次数。
连接失败、响应头等待失败、尚未输出时的首包失败及可重试 HTTP 错误可尝试下一家。
HTTP 400–599 中,以下状态不进入候补重试:`400/405/406/413/414/415/422/501`。
这些请求错误不累计供应商熔断失败。3xx 不跟随重定向,也不携带凭据跳转到另一地址。
拿到首个流式数据块后才提交下游响应;提交响应头或开始输出后,不再重试整条请求。
随后中断只结束当前流并更新错误提示,避免重复生成和重复工具执行。非流式正文在本次尝试的
总期限内完整读取;不是每收到一块数据就重新开始总计时。
带 `previous_response_id`、`conversation`、`background=true`、输入引用、文件 ID、加密内容或
不透明轮次状态的请求,只尝试**本次配置快照的队首**,不会失败后跨候补重放。
实现没有保存“历史 response ID / session → 原供应商”的长期绑定,不能承诺识别每段历史状态的
原始账户。此前一次请求在 P2 成功,并不意味着之后所有含历史状态的请求都自动绑定到 P2。
熔断器保留上游状态机:
| 状态 | 行为 |
| --- | --- |
| `closed` | 允许请求;连续失败达到阈值,或达到最少样本后错误率达到阈值,则转为 `open`。 |
| `open` | 等待恢复时间;到期后允许进入 `half_open`。 |
| `half_open` | 同时最多放行一个真实请求进行探测;失败立即重新熔断,成功达到恢复阈值后转为 `closed`。 |
探测由后续真实请求触发,不是定时向供应商发送额外测试消息。错误率使用当前熔断器生命周期的
累计样本,不是按分钟滚动的时间窗口;恢复关闭或手动重置会清空相应统计。
单项自动队列同样使用熔断器。关闭自动模式和官方路由绕过熔断器。
修改熔断参数会热更新已有实例,不借此清空失败记录。更换供应商的实际连接或凭据会重建对应健康
状态;移出运行路由、停止监听后,也不能把旧内存统计当作持续存在的探测结果。
「撤销修改」(恢复最近保存值)与「重置健康状态」是不同操作;后者不会主动验证服务已恢复。
上游依据:
[`provider_router.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/proxy/provider_router.rs)、
[`forwarder.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/proxy/forwarder.rs)、
[`circuit_breaker.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/proxy/circuit_breaker.rs)。
## 原生官方账号隔离
官方登录可以接入本地 HTTP/SSE 路由,但不能加入第三方自动队列。切换到官方账号时,保留已保存
的自动偏好和队列,实际只运行当前官方账号;不会拿 OAuth 请求尝试第三方候补。
- Codex 继续拥有登录和 token 刷新。本模块不实现设备码登录,不主动刷新 token,也不写 `auth.json`。
- 本地官方配置使用 `requires_openai_auth=true`,关闭 WebSocket,并通过
`x-codex-x-route-token` 验证本地调用者;不把 OAuth 替换为第三方 API Key 占位符。
- 每次请求检查逻辑官方状态、当前选中的 profile、实时认证文件中的精确 token 和 account ID。
同一 Team workspace 下不同用户的 token 也不能混用;不从未选中账号快照借用认证。
- OAuth 只发往 `https://chatgpt.com/backend-api/codex`;官方 API Key 认证只发往
`https://api.openai.com/v1`。拒绝混用两种认证,实际目的地址以请求时通过验证的认证类型为准。
- `x-codex-x-route-token` 和内部配置代次头不会转发上游,供应商自定义头也不能重新注入它们。
- 官方状态、邮箱/套餐摘要、额度查询、登录监控和快照捕获读取解包后的逻辑配置;不能把 localhost
识别为新第三方供应商,也不能把本地路由令牌保存为账号凭据。
- 官方 `GET /v1/models` 只读取当前引用且经过归属检查的本地受管模型目录,否则返回 `{"models":[]}`。
不请求外部模型目录,也不宣称它是完整的官方模型发现接口。尚未登录时可建立接管配置,实际请求仍须登录。
这对应 CC Switch 的 `apply_codex_official_proxy_route`、`is_codex_official_provider` 和官方认证透传分支,
不是 `codex_oauth_auth.rs` 中为其他客户端代管设备码登录/刷新、再转换请求的 OAuth 反代模式。
参考
[`codex_config.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/codex_config.rs)
及 [`providers/codex.rs`](https://github.com/farion1231/cc-switch/blob/06082e189d65e6d6dbadc35dacdac1ce6c79d89a/src-tauri/src/proxy/providers/codex.rs)。
## 配置恢复、切换与退出
接管先保存恢复记录,再条件原子写入本地传输字段。恢复记录包含原始供应商表、监听位置、认证
令牌和配置代次标识,按 `CODEX_HOME` 隔离。恢复使用原始、已安装和当前值进行比较,保留随后
编辑的其他字段,并清除仍属于本功能的地址、令牌及代次头。历史恢复记录可识别旧备份中的本地路由。
成功转到队列其他供应商后,后台检查运行 revision、令牌、配置归属与队列成员身份,再更新逻辑
当前供应商和恢复记录。后台通知另外校验监听实例身份,停止后重新启动也不能将旧请求误认为新实例
的结果。过期请求不能覆盖之后的手动选择。不会因此重写当前请求的模型名、会话数据或 MCP、desktop 等通用配置。界面只静默读取当前目录状态,编辑中的表单暂缓刷新,避免覆盖草稿。
启动时先处理遗留的受管配置,再按保存的监听/接管偏好恢复服务。如果供应商已在外部改变,不
强行接管新配置;启动恢复失败时报告具体原因。退出先恢复直连,再停止监听;恢复失败
保留可服务的实例并阻止普通退出。成功退出后拒绝排队的重新启用请求。
关闭窗口继续驻留托盘。停止监听不主动截断已经开始输出的流,但停止后不再发起新的上游尝试;
退出整个进程仍不能保证未完成流继续。首次接管、关闭接管或更换账号后,已经运行的 Codex 可能缓存
原配置;需要重新打开客户端或新建会话的情况不能通过修改文件强制消除。
这些恢复保护是 Codex-X 保留的实现约束。参考版本的 CC Switch 在退出时先停服务再恢复,恢复失败
主要记录日志后继续退出;本项目没有照搬这种失败路径。
## 适用边界与实现差异
- 当前数据面提供 `POST /v1/responses`、`POST /v1/responses/compact`、`GET /v1/models`,使用 HTTP/SSE,
不提供 WebSocket 升级或任意 URL 转发。
- 第三方接口需兼容 Responses。没有 Chat Completions/Anthropic 协议转换、请求整流器、图片降级、
Claude/Gemini/Grok 应用接管或跨应用配置管理;不能把这些上游能力写成本项目已具备。
- 队列不限制模型名称,但不保证任意第三方都支持请求中的模型、工具或服务端状态。
厂商专用 `query_params` 目前不支持自动路由;无效认证、缺失环境变量或不适用的认证头会报告原因。
- 本地监听即使绑定非回环地址,也保留随机令牌、Host 和来源检查。不是无需认证的公开代理。
不跟随重定向,不向第三方传递官方账号头、Cookie、本地令牌或其他账户凭据。
- 入站头最多 32 KiB,正文及每层解压结果最多 64 MiB,查询串最多 4 KiB;使用 8 个有界工作线程。
支持 gzip、deflate、zstd 请求解码,不记录原始请求正文或 token。
- 运行统计是传输层统计:完整非流式响应或首个流式输出计为一次成功;之后的流中断另行报告。
这不是对完整答案质量或整个工具调用流程成功的判断。该统计也不是订阅额度或金额统计。
- 本轮未提供 CC Switch 的请求日志、成本计费及其他应用的开关。已有「用量统计」与官方额度查询
仍由各自功能负责。
## 代码与许可来源
本项目对应实现为 `failover/config.rs`、`controller.rs`、`proxy.rs`、`circuit_breaker.rs`、
`native_official.rs`,以及命令、官方配置读取和前端事件接线。
熔断器状态机与部分压缩处理按 CC Switch 上述提交的 MIT 代码适配;保留其版权声明
`Copyright (c) 2025 Jason Young`。同步锁、有界本地 HTTP 处理、状态持久化、配置恢复及界面接入
按 Codex-X 的现有结构实现。完整许可和来源见 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)。
验证使用临时目录、合成认证和本机 HTTP/SSE 服务,覆盖参数校验、P1 与队列、熔断、超时、
已提交输出后的禁止重放、并发切换、恢复失败、原生官方隔离、模型目录归属和草稿保留。
本轮 598 项 Rust 测试、51 项前端测试、类型/格式/diff 检查及 macOS `.app` 构建通过;
浏览器检查已覆盖三层开关、队列排序、不同模型、参数编辑、熔断重置和明暗主题。
本地 macOS App 已校验替换并启动,实际安装版验证了页面读取及监听服务独立启停,
结束后恢复原关闭状态,现有 config.toml 与 auth.json 哈希未变;尚未验证 Windows 原生安装包。
+39
View File
@@ -0,0 +1,39 @@
# Windows 更新与旧 MSI 迁移
Windows 新安装包使用 NSIS EXE,安装到原登录用户的本地应用目录。后续更新直接覆盖该用户的程序文件,不再每次执行 MSI major upgrade。旧 MSI 的第一次迁移仍需要 Windows Installer 和一次管理员授权,不能承诺该次系统事务即时完成。
## 更新交接
- 插件 `check` 返回的 `Update` 资源仍负责下载与签名验证。自定义 `install_app_update` 只接受本 WebView 的资源 ID,不能指定任意安装路径或未验签字节。
- 下载使用独立的 10 分钟总超时,不再继承版本检查的 15 秒超时。前端显示下载、验签、准备退出、启动安装、已交给安装器;30 秒无进展提供提示,但不并发重试或强杀安装器。
- Windows 先把已验签 EXE 写入本次私有临时目录,再恢复接管配置和停止本地监听。准备失败不启动安装;启动失败恢复保存的路由,恢复失败保留偏好并如实提示。
- 启动安装器使用原生 `Command` 参数,随后通过正常 Tauri 退出事件释放窗口、托盘和单实例锁。安装器通过 `/CODEXXPID` 句柄等待旧进程退出;30 秒未退出则停止本次安装,不杀进程、不复制新文件。
- “已交给安装器”只表示成功启动,不等于更新已完成。后续 IPC 断开不能触发第二个安装进程。
- macOS/Linux 继续使用插件原有安装逻辑,安装成功后才进入等待重启状态。
## 兼容已发布的 MSI
自定义模板基于 Tauri CLI 2.11.4。旧安装只按本项目固定 UpgradeCode `{1482A8E8-9217-517B-8528-93A6C90E0C2F}` 和 Windows Installer machine context 检查,不执行注册表中的任意卸载命令。
只提升系统 `msiexec.exe /x {ProductCode}` 子进程的权限;EXE 安装器始终留在原登录用户下,避免使用另一个管理员凭据授权后安装到管理员的个人目录。旧 MSI 保留的 Program Files 路径不作为新安装目录。
卸载未成功、取消授权、其他安装事务占用或结果未知时,停止复制新版本。移除返回 `0` 或 `3010` 后,再次验证旧产品注册已消失,并确认新目录与卸载前记录的旧目录隔离,才写入新安装。`3010` 表示旧文件可能需要 Windows 后续清理;在上述条件成立时继续安装,不要求用户重启电脑。
旧目录通过目录句柄解析到真实路径,处理 junction、短路径和驱动别名;目标不能是旧目录或其子目录。旧路径记录保留到异常退出后的重试,防止卸载记录消失后误选旧目录。不会用整机“待重启”状态阻止更新,也不查询启动时间。配置、账号、会话目录不属于迁移删除范围。
安装器按阶段记录日志,旧版卸载另外记录 MSI 详细日志。长时间等待会持续显示已等待时间和日志位置,不强制结束系统安装事务。
## 发布入口
更新清单的 `windows-x86_64`、`windows-x86_64-nsis`、`windows-x86_64-msi` 三个键必须指向同一个 EXE 和签名。旧 MSI 客户端下载后按实际文件内容识别安装器,因此可以完成过渡。发布校验禁止混入不同的 MSI 安装目标。
官网本地下载选择器已支持优先 EXE、回退旧 MSI;官网是独立部署,软件正式发布前须同步该兼容修改。便携版仍使用手动更新。
## 验证
- 本机 Rust 回归覆盖安装交接、失败恢复、端口冲突、签名失败阶段、并发更新保护和安装包暂存;前端覆盖各阶段、迟到事件、断连和长等待。
- NSIS 模板可在本机编译,但不能据此宣称已通过 Windows 安装测试。
- `Windows installer verification` 工作流在独立 Windows runner 构建测试安装器,运行后端测试和 `scripts/windows-install-smoke.ps1`。测试包括全新安装、真实 v0.3.20 MSI 迁移、后续更新、唯一卸载项和用户配置哨兵保留;不发布正式版本。
- 正式 Release 同样要求这组原生安装测试通过才发布。普通用户的管理员授权、第三方安全软件和系统安装服务仍应在实际 Windows 环境验证;无法保证所有系统环境均无等待。
2026-09-22:代码提交 `85e08b5` 的 [Windows 原生验证](https://github.com/yynxxxxx/Codex-X/actions/runs/35748671885) 全部通过:599 项后端测试、全新用户安装、真实 v0.3.20 MSI 迁移、重复更新、拒绝旧目录/子目录/junction,以及实际 MSI 返回 3010 后无需重启继续安装。该测试运行器测得全新安装 2.5 秒、普通更新 1.5–1.6 秒、首次正常迁移 3.3 秒;这些是安装阶段耗时,不含下载,不能视为所有用户电脑的保证值。配置哨兵及唯一卸载项检查通过。macOS 主机另有 609 项后端测试、61 项前端测试及 8 项更新清单校验通过。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "codex-x-workspace",
"private": true,
"version": "0.3.17",
"version": "0.3.23",
"scripts": {
"dev": "pnpm --dir apps/desktop tauri dev",
"build": "pnpm --dir apps/desktop tauri build",
+57 -2
View File
@@ -18,8 +18,9 @@ PLATFORM_ASSETS = {
"darwin-aarch64-app": "Codex-X.app.tar.gz",
"darwin-x86_64": "Codex-X-intel.app.tar.gz",
"darwin-x86_64-app": "Codex-X-intel.app.tar.gz",
"windows-x86_64": "Codex-X.msi",
"windows-x86_64-msi": "Codex-X.msi",
"windows-x86_64": "Codex-X.exe",
"windows-x86_64-nsis": "Codex-X.exe",
"windows-x86_64-msi": "Codex-X.exe",
"linux-x86_64": "Codex-X.AppImage",
"linux-x86_64-deb": "Codex-X.deb",
"linux-x86_64-rpm": "Codex-X.rpm",
@@ -131,6 +132,60 @@ class ValidateUpdaterReleaseTests(unittest.TestCase):
)
)
def test_migration_adds_legacy_msi_key_without_changing_other_platforms(self) -> None:
manifest = json.loads(self.manifest_path.read_text())
del manifest["platforms"]["windows-x86_64-msi"]
original = manifest["platforms"]
self.manifest_path.write_text(json.dumps(manifest))
result = self.run_validator("--rewrite-download-urls", "--migrate-windows-to-nsis")
self.assertEqual(result.returncode, 0, result.stderr)
actual = json.loads(self.manifest_path.read_text())["platforms"]
self.assertEqual(actual["windows-x86_64-msi"], actual["windows-x86_64-nsis"])
self.assertEqual(actual["windows-x86_64"], actual["windows-x86_64-nsis"])
for key, value in original.items():
if not key.startswith("windows-"):
self.assertEqual(actual[key]["signature"], value["signature"])
self.assertEqual(actual[key]["url"].rsplit("/", 1)[1], value["url"].rsplit("/", 1)[1])
def test_migration_requires_an_actual_nsis_source_entry(self) -> None:
manifest = json.loads(self.manifest_path.read_text())
del manifest["platforms"]["windows-x86_64-nsis"]
self.manifest_path.write_text(json.dumps(manifest))
result = self.run_validator("--rewrite-download-urls", "--migrate-windows-to-nsis")
self.assertNotEqual(result.returncode, 0)
self.assertIn("missing signed NSIS", result.stderr)
def test_rejects_windows_signature_divergence(self) -> None:
manifest = json.loads(self.manifest_path.read_text())
manifest["platforms"]["windows-x86_64-msi"]["signature"] = base64.b64encode(b"x" * 64).decode()
self.manifest_path.write_text(json.dumps(manifest))
result = self.run_validator("--rewrite-download-urls")
self.assertNotEqual(result.returncode, 0)
self.assertIn("same signed NSIS installer", result.stderr)
def test_rejects_mixed_msi_release_assets(self) -> None:
assets = json.loads(self.assets_path.read_text())
assets.append({"name": "legacy.msi", "browser_download_url": draft_url("legacy.msi")})
self.assets_path.write_text(json.dumps(assets))
result = self.run_validator("--rewrite-download-urls")
self.assertNotEqual(result.returncode, 0)
self.assertIn("must not be mixed", result.stderr)
def test_rejects_msi_payload_even_when_nsis_key_exists(self) -> None:
manifest = json.loads(self.manifest_path.read_text())
assets = json.loads(self.assets_path.read_text())
for item in manifest["platforms"].values():
item["url"] = item["url"].replace("Codex-X.exe", "Codex-X.msi")
for item in assets:
if item["name"] == "Codex-X.exe":
item["name"] = "Codex-X.msi"
item["browser_download_url"] = draft_url("Codex-X.msi")
self.manifest_path.write_text(json.dumps(manifest))
self.assets_path.write_text(json.dumps(assets))
result = self.run_validator("--rewrite-download-urls", "--migrate-windows-to-nsis")
self.assertNotEqual(result.returncode, 0)
self.assertIn("expected a .exe updater", result.stderr)
if __name__ == "__main__":
unittest.main()
+34 -2
View File
@@ -17,13 +17,30 @@ REQUIRED_PLATFORMS = {
"darwin-aarch64-app": ".app.tar.gz",
"darwin-x86_64": ".app.tar.gz",
"darwin-x86_64-app": ".app.tar.gz",
"windows-x86_64": ".msi",
"windows-x86_64-msi": ".msi",
"windows-x86_64": ".exe",
"windows-x86_64-nsis": ".exe",
# Older MSI clients select this key, then identify EXE/MSI by file content.
"windows-x86_64-msi": ".exe",
"linux-x86_64": ".AppImage",
"linux-x86_64-deb": ".deb",
"linux-x86_64-rpm": ".rpm",
"linux-x86_64-appimage": ".AppImage",
}
WINDOWS_PLATFORMS = (
"windows-x86_64", "windows-x86_64-nsis", "windows-x86_64-msi"
)
def migrate_windows_to_nsis(manifest: dict[str, Any]) -> None:
"""Serve the same signed NSIS installer to old MSI and new NSIS clients."""
platforms = manifest.get("platforms")
if not isinstance(platforms, dict):
fail("latest.json has no platforms object")
source = platforms.get("windows-x86_64-nsis")
if not isinstance(source, dict):
fail("missing signed NSIS platform for Windows migration")
for platform in WINDOWS_PLATFORMS:
platforms[platform] = dict(source)
def fail(message: str) -> None:
@@ -103,6 +120,7 @@ def main() -> None:
parser.add_argument("--repository", required=True)
parser.add_argument("--release-tag", required=True)
parser.add_argument("--rewrite-download-urls", action="store_true")
parser.add_argument("--migrate-windows-to-nsis", action="store_true")
parser.add_argument("--require-signature-assets", action="store_true")
args = parser.parse_args()
@@ -144,6 +162,11 @@ def main() -> None:
if not isinstance(platforms, dict):
fail("latest.json has no platforms object")
if args.migrate_windows_to_nsis:
if not args.rewrite_download_urls:
fail("--migrate-windows-to-nsis requires --rewrite-download-urls")
migrate_windows_to_nsis(manifest)
if args.rewrite_download_urls:
rewritten = rewrite_download_urls(
args.manifest,
@@ -185,6 +208,15 @@ def main() -> None:
if args.require_signature_assets and f"{asset_name}.sig" not in asset_names:
fail(f"signature asset is missing for {asset_name}")
windows_entries = {
(platforms[key]["url"], platforms[key]["signature"])
for key in WINDOWS_PLATFORMS
}
if len(windows_entries) != 1:
fail("all Windows updater keys must use the same signed NSIS installer")
if any(name.lower().endswith((".msi", ".msi.zip")) for name in asset_names):
fail("legacy MSI installers must not be mixed into an NSIS release")
signature_status = (
"signature assets required"
if args.require_signature_assets
+176
View File
@@ -0,0 +1,176 @@
# Run only on an ephemeral GitHub Windows runner. Never run on a user's machine.
param([Parameter(Mandatory = $true)][string]$Installer)
$ErrorActionPreference = 'Stop'
if ($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_OS -ne 'Windows') {
throw 'The installation smoke test is restricted to an ephemeral Windows Actions runner.'
}
$Installer = (Resolve-Path $Installer).Path
$UpgradeCode = '{1482A8E8-9217-517B-8528-93A6C90E0C2F}'
$LegacyProductCode = '{F71E3F3F-A463-4397-AB46-206D3FAC3FBD}'
$LegacySha256 = 'e0f2f172a31f860806a9714bab2e67eb38b85d525d7e5f0f569667cbeadfb152'
$InstallDir = Join-Path $env:LOCALAPPDATA 'Codex-X'
$UninstallKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\Codex-X'
$Work = Join-Path $env:RUNNER_TEMP 'codex-x-installer-smoke'
New-Item -ItemType Directory -Force $Work | Out-Null
Add-Type @'
using System.Runtime.InteropServices;
using System.Text;
public static class CodexXMsiSmoke {
[DllImport("msi.dll", CharSet = CharSet.Unicode)]
public static extern uint MsiEnumRelatedProducts(string code, uint reserved, uint index, StringBuilder product);
[DllImport("msi.dll", CharSet = CharSet.Unicode)]
public static extern uint MsiGetProductInfoEx(string code, System.IntPtr sid, uint context, string property, StringBuilder value, ref uint length);
}
'@
function Related-Products {
$results = @()
for ($index = 0; $index -lt 64; $index++) {
$product = [Text.StringBuilder]::new(39)
$result = [CodexXMsiSmoke]::MsiEnumRelatedProducts($UpgradeCode, 0, $index, $product)
if ($result -eq 259) { return $results }
if ($result -ne 0) { throw "MsiEnumRelatedProducts failed: $result" }
$results += $product.ToString()
}
throw 'Unexpected number of MSI registrations.'
}
function Invoke-Installer([string]$File, [string]$Arguments, [int]$ExpectedExit = 0) {
$watch = [Diagnostics.Stopwatch]::StartNew()
$process = Start-Process -FilePath $File -ArgumentList $Arguments -PassThru
if (-not $process.WaitForExit(600000)) {
# Do not kill msiexec or an installer transaction. Fail the disposable job.
throw "Installer has not finished after ten minutes (PID $($process.Id)); no forced termination was attempted."
}
$process.Refresh()
if ($process.ExitCode -ne $ExpectedExit) { throw "Installer returned $($process.ExitCode), expected $ExpectedExit" }
Write-Host "Installer completed in $([math]::Round($watch.Elapsed.TotalSeconds, 1)) seconds."
}
function Assert-Installed {
if (-not (Test-Path (Join-Path $InstallDir 'codex-x.exe'))) { throw 'EXE not installed in original user LOCALAPPDATA.' }
$registration = Get-ItemProperty $UninstallKey
if ($registration.MainBinaryName -ne 'codex-x.exe') { throw 'Invalid NSIS registration.' }
if (@(Related-Products).Count -ne 0) { throw 'Legacy MSI is still registered.' }
foreach ($root in @('HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall', 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall', 'HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall')) {
$found = @(Get-ChildItem $root -ErrorAction SilentlyContinue | Get-ItemProperty | Where-Object { $_.DisplayName -eq 'Codex-X' })
if ($root.StartsWith('HKCU:')) {
if ($found.Count -ne 1) { throw 'Expected exactly one current-user uninstall entry.' }
} elseif ($found.Count -ne 0) { throw 'A machine-wide Codex-X uninstall entry remains.' }
}
foreach ($entry in $Sentinels.GetEnumerator()) {
if (-not (Test-Path $entry.Key) -or (Get-FileHash $entry.Key -Algorithm SHA256).Hash -ne $entry.Value) {
throw "User configuration changed during installation: $($entry.Key)"
}
}
}
function Reset-FixtureInstallLocation {
# Tauri intentionally preserves this install-location value when keeping
# user data. The old MSI's AppSearch overrides command-line INSTALLDIR with
# it. Remove only values produced by the previous synthetic install so this
# fixture accurately represents a legacy default Program Files installation.
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Software\yynxxxxx\Codex-X', $true)
if ($null -ne $key) {
try {
$key.DeleteValue('', $false)
$key.DeleteValue('InstallDir', $false)
} finally { $key.Dispose() }
}
}
function Remove-TestNsis {
# _?= avoids NSIS's detached temporary uninstaller process so WaitForExit
# actually covers registry/files removal. This is NSIS's documented syntax.
$uninstaller = Join-Path $InstallDir 'uninstall.exe'
if (Test-Path $uninstaller) { Invoke-Installer $uninstaller "/S _?=$InstallDir" }
if (Test-Path $UninstallKey) { throw 'NSIS uninstall registration was not removed.' }
}
if ((Test-Path $UninstallKey) -or @(Related-Products).Count -ne 0) { throw 'Runner already has a Codex-X installation.' }
$Sentinels = @{}
foreach ($relative in @('.codex\config.toml', '.codex\auth.json', '.codexx\installer-smoke-data.txt')) {
$file = Join-Path $env:USERPROFILE $relative
if (Test-Path $file) { throw "Refusing to overwrite an existing file: $file" }
New-Item -ItemType Directory -Force (Split-Path $file) | Out-Null
[IO.File]::WriteAllText($file, "Codex-X installer sentinel: $relative")
$Sentinels[$file] = (Get-FileHash $file -Algorithm SHA256).Hash
}
try {
Write-Host 'Scenario 1: clean current-user install and subsequent NSIS update.'
Invoke-Installer $Installer '/S'
Assert-Installed
Invoke-Installer $Installer '/S /UPDATE'
Assert-Installed
Remove-TestNsis
Reset-FixtureInstallLocation
Write-Host 'Scenario 2: released v0.3.20 machine MSI -> current-user NSIS -> NSIS update.'
$legacyMsi = Join-Path $Work 'Codex-X-0.3.20.msi'
Invoke-WebRequest 'https://github.com/yynxxxxx/Codex-X/releases/download/v0.3.20/Codex-X-0.3.20-windows-x64.msi' -OutFile $legacyMsi
if ((Get-FileHash $legacyMsi -Algorithm SHA256).Hash.ToLowerInvariant() -ne $LegacySha256) { throw 'Released MSI hash mismatch.' }
Invoke-Installer (Join-Path $env:SystemRoot 'System32\msiexec.exe') "/i `"$legacyMsi`" INSTALLDIR=`"$env:ProgramFiles\Codex-X`" /qn /norestart /L*v `"$Work\legacy-install.log`""
if ($LegacyProductCode -notin @(Related-Products)) { throw 'The genuine legacy MSI did not register.' }
$oldDirectory = [Text.StringBuilder]::new(1024)
[uint32]$oldDirectoryLength = 1024
$status = [CodexXMsiSmoke]::MsiGetProductInfoEx($LegacyProductCode, [IntPtr]::Zero, 4, 'InstallLocation', $oldDirectory, [ref]$oldDirectoryLength)
if ($status -ne 0 -or $oldDirectory.Length -eq 0) { throw 'Could not read actual legacy MSI InstallLocation.' }
$oldPath = $oldDirectory.ToString().TrimEnd('\')
Write-Host "Actual legacy MSI InstallLocation: $oldPath"
if ($oldPath -ne (Join-Path $env:ProgramFiles 'Codex-X')) { throw 'The legacy fixture was not installed to Program Files.' }
$oldExe = Join-Path $oldPath 'codex-x.exe'
$oldHash = (Get-FileHash $oldExe).Hash
# Guard the old directory, a non-existent child, and a junction alias. None
# may remove the registered MSI or overwrite its files.
Invoke-Installer $Installer "/S /UPDATE /D=$oldPath" 1
Invoke-Installer $Installer "/S /UPDATE /D=$oldPath\unsafe-child" 1
$alias = Join-Path $Work 'legacy-junction'
New-Item -ItemType Junction -Path $alias -Target $oldPath | Out-Null
try {
Invoke-Installer $Installer "/S /UPDATE /D=$alias\unsafe-child" 1
} finally {
[IO.Directory]::Delete($alias)
}
if ($LegacyProductCode -notin @(Related-Products) -or (Get-FileHash $oldExe).Hash -ne $oldHash) {
throw 'A refused overlapping installation changed the legacy app.'
}
# Keep the real MSI's HKCU manufacturer path to verify that NSIS does not
# inherit Program Files when the user accepts the default directory.
Invoke-Installer $Installer '/S /UPDATE'
Assert-Installed
Invoke-Installer $Installer '/S /UPDATE'
Assert-Installed
Remove-TestNsis
Reset-FixtureInstallLocation
Write-Host 'Scenario 3: MSI returns 3010 after removal; separate NSIS install continues without reboot.'
# A separate fixture copy of the hash-verified released MSI requests a reboot
# only after REMOVE=ALL. /norestart prevents any OS reboot. This exercises the
# genuine Windows Installer 3010 result, not a mocked installer exit code.
$rebootMsi = Join-Path $Work 'legacy-deferred-cleanup-fixture.msi'
Copy-Item $legacyMsi $rebootMsi
$msiAutomation = New-Object -ComObject WindowsInstaller.Installer
$database = $msiAutomation.OpenDatabase($rebootMsi, 1)
$query = $database.OpenView('INSERT INTO `InstallExecuteSequence` (`Action`, `Condition`, `Sequence`) VALUES (''ScheduleReboot'', ''REMOVE="ALL"'', 6500)')
$query.Execute()
$query.Close()
$summary = $database.GetType().InvokeMember('SummaryInformation', [Reflection.BindingFlags]::GetProperty, $null, $database, @(1))
$newPackageCode = '{' + [guid]::NewGuid().ToString().ToUpperInvariant() + '}'
[void]$summary.GetType().InvokeMember('Property', [Reflection.BindingFlags]::SetProperty, $null, $summary, @(9, $newPackageCode))
$summary.Persist()
$database.Commit()
foreach ($comObject in @($query, $summary, $database, $msiAutomation)) {
[void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($comObject)
}
Invoke-Installer (Join-Path $env:SystemRoot 'System32\msiexec.exe') "/i `"$rebootMsi`" INSTALLDIR=`"$env:ProgramFiles\Codex-X`" /qn /norestart /L*v `"$Work\deferred-fixture-install.log`""
if ($LegacyProductCode -notin @(Related-Products)) { throw 'The deferred-cleanup MSI fixture did not register.' }
$beforeMigration = Get-Date
Invoke-Installer $Installer '/S /UPDATE'
Assert-Installed
$migrationLogs = @(Get-ChildItem (Join-Path $env:LOCALAPPDATA 'Codex-X-updates\logs') -Filter 'install-*.log' |
Where-Object { $_.LastWriteTime -ge $beforeMigration -and $_.Name -notlike '*-msi-*' })
if (-not ($migrationLogs | Select-String -SimpleMatch 'MSI exit code: 3010')) { throw 'The deferred-cleanup scenario did not actually exercise MSI exit 3010.' }
Invoke-Installer $Installer '/S /UPDATE'
Assert-Installed
Write-Host 'Windows installation smoke tests passed; no reboot needed, configuration sentinels unchanged.'
} finally {
Get-ChildItem (Join-Path $env:LOCALAPPDATA 'Codex-X-updates\logs') -Filter 'install-*.log' -ErrorAction SilentlyContinue | ForEach-Object {
Write-Host "--- $($_.Name) ---"
Get-Content $_.FullName -Tail 100
}
Remove-TestNsis
}
+84
View File
@@ -0,0 +1,84 @@
# Diagnostic only; installs/removes the hash-pinned fixture on an ephemeral runner.
$ErrorActionPreference = 'Stop'
if ($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_OS -ne 'Windows') {
throw 'This MSI diagnostic is restricted to an ephemeral Windows Actions runner.'
}
$work = Join-Path $env:RUNNER_TEMP 'codex-x-msi-probe'
New-Item -ItemType Directory -Force $work | Out-Null
$legacyMsi = Join-Path $work 'legacy.msi'
Invoke-WebRequest 'https://github.com/yynxxxxx/Codex-X/releases/download/v0.3.20/Codex-X-0.3.20-windows-x64.msi' -OutFile $legacyMsi
if ((Get-FileHash $legacyMsi -Algorithm SHA256).Hash.ToLowerInvariant() -ne 'e0f2f172a31f860806a9714bab2e67eb38b85d525d7e5f0f569667cbeadfb152') {
throw 'MSI fixture hash mismatch.'
}
$compiler = (Get-Command makensis.exe -ErrorAction SilentlyContinue).Source
if (-not $compiler) { $compiler = Join-Path ${env:ProgramFiles(x86)} 'NSIS\makensis.exe' }
if (-not (Test-Path $compiler)) { throw 'makensis.exe is required for the native MSI probe.' }
$psProbe = @'
$ErrorActionPreference = 'Stop'
Add-Type @"
using System.Runtime.InteropServices;
using System.Text;
public static class MsiProbeNative {
[DllImport("msi.dll", CharSet=CharSet.Unicode, ExactSpelling=true)]
public static extern uint MsiEnumRelatedProductsW(string upgrade, uint reserved, uint index, StringBuilder product);
[DllImport("msi.dll", CharSet=CharSet.Unicode, ExactSpelling=true)]
public static extern uint MsiGetProductInfoExW(string product, System.IntPtr sid, uint context, string property, StringBuilder value, ref uint length);
}
"@
$product = [Text.StringBuilder]::new(39)
$rc = [MsiProbeNative]::MsiEnumRelatedProductsW('{1482A8E8-9217-517B-8528-93A6C90E0C2F}', 0, 0, $product)
Write-Host "PS Is64Bit=$([Environment]::Is64BitProcess) EnumRelated rc=$rc product=$product"
foreach ($property in @('VersionString', 'InstallLocation')) {
$value = [Text.StringBuilder]::new(1024)
[uint32]$length = 1024
$rc = [MsiProbeNative]::MsiGetProductInfoExW('{F71E3F3F-A463-4397-AB46-206D3FAC3FBD}', [IntPtr]::Zero, 4, $property, $value, [ref]$length)
Write-Host "PS Is64Bit=$([Environment]::Is64BitProcess) machine $property rc=$rc value=$value"
}
'@
$psProbePath = Join-Path $work 'probe-api.ps1'
Set-Content $psProbePath $psProbe -Encoding utf8
$nsis = @'
Unicode true
RequestExecutionLevel user
OutFile "probe.exe"
Name "Codex-X MSI API probe"
SilentInstall silent
Function .onInit
FileOpen $9 "$EXEDIR\nsis-probe.log" w
FileWriteUTF16LE /BOM $9 "NSIS x86 Unicode native MSI API probe$\r$\n"
System::Call 'msi::MsiEnumRelatedProductsW(w "{1482A8E8-9217-517B-8528-93A6C90E0C2F}",i 0,i 0,w .r0)i.r1'
FileWriteUTF16LE $9 "EnumRelatedW result=$1 product=$0$\r$\n"
System::Call 'msi::MsiEnumRelatedProductsA(m "{1482A8E8-9217-517B-8528-93A6C90E0C2F}",i 0,i 0,m .r0)i.r1'
FileWriteUTF16LE $9 "EnumRelatedA result=$1 product=$0$\r$\n"
System::Call 'msi::MsiGetProductInfoExW(w "{F71E3F3F-A463-4397-AB46-206D3FAC3FBD}",p 0,i 4,w "VersionString",w .r0,*i 1024)i.r1'
FileWriteUTF16LE $9 "Machine VersionString result=$1 value=$0$\r$\n"
System::Call 'msi::MsiGetProductInfoExW(w "{F71E3F3F-A463-4397-AB46-206D3FAC3FBD}",p 0,i 4,w "InstallLocation",w .r0,*i 1024)i.r1'
FileWriteUTF16LE $9 "Machine InstallLocation result=$1 value=$0$\r$\n"
FileClose $9
SetErrorLevel 0
Quit
FunctionEnd
Section
SectionEnd
'@
$nsisPath = Join-Path $work 'probe.nsi'
Set-Content $nsisPath $nsis -Encoding utf8
function Run-Wait([string]$File, [string]$Arguments) {
$process = Start-Process $File -ArgumentList $Arguments -PassThru
if (-not $process.WaitForExit(300000)) { throw 'Probe subprocess timed out; no MSI process was terminated.' }
$process.Refresh()
Write-Host "Process completed: $File, exit=$($process.ExitCode)"
if ($process.ExitCode -ne 0 -and $process.ExitCode -ne 3010) { throw "Subprocess failure: $($process.ExitCode)" }
}
try {
Run-Wait "$env:SystemRoot\System32\msiexec.exe" "/i `"$legacyMsi`" INSTALLDIR=`"$env:ProgramFiles\Codex-X`" /qn /norestart /L*v `"$work\install.log`""
& "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File $psProbePath
& "$env:SystemRoot\SysWOW64\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -File $psProbePath
& $compiler /V3 $nsisPath
if ($LASTEXITCODE -ne 0) { throw 'Native NSIS probe compilation failed.' }
Run-Wait (Join-Path $work 'probe.exe') '/S'
} finally {
$probeLog = Join-Path $work 'nsis-probe.log'
if (Test-Path $probeLog) { Get-Content $probeLog }
Run-Wait "$env:SystemRoot\System32\msiexec.exe" "/x {F71E3F3F-A463-4397-AB46-206D3FAC3FBD} /qn /norestart /L*v `"$work\uninstall.log`""
}