feat: add cdxs CLI implementation and docs

This commit is contained in:
chuan
2026-04-30 00:42:24 +08:00
Unverified
parent f1024115b8
commit 01449ef788
27 changed files with 8443 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
target
.git
.gitignore
README.md
docker-compose.yml
+1
View File
@@ -0,0 +1 @@
target/
Generated
+2553
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "cdxs"
version = "0.1.0"
edition = "2021"
description = "Codex account switcher CLI"
[dependencies]
anyhow = "1"
axum = "0.8"
base64 = "0.22"
chrono = { version = "0.4", features = ["serde"] }
clap = { version = "4.5", features = ["derive"] }
dirs = "5"
hex = "0.4"
rand = "0.8"
reqwest = { version = "0.12", features = ["json", "gzip", "brotli", "deflate", "zstd"] }
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "io-util", "time"] }
toml = "0.8"
url = "2.5"
urlencoding = "2.1"
uuid = { version = "1", features = ["v4", "serde"] }
+19
View File
@@ -0,0 +1,19 @@
FROM rust:1-bookworm AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/cdxs /usr/local/bin/cdxs
EXPOSE 8765
VOLUME ["/data"]
CMD ["cdxs", "server", "run", "--bind", "0.0.0.0:8765", "--data", "/data/cdxs.toml"]
+339
View File
@@ -0,0 +1,339 @@
# cdxs
`cdxs` 是一个 Codex 账号与 `CODEX_HOME` 切换工具。它可以保存多个 Codex OAuth 账号或 API Key 账号,把指定账号写入 Codex 的 `auth.json`,并提供配额查询、会话查看/回收/修复、多 `CODEX_HOME` 管理和简单的配置同步服务。
## 功能概览
- 保存和切换多个 Codex 账号。
- 支持从已有 `auth.json` 导入 OAuth 或 API Key 认证信息。
- 支持通过 OpenAI OAuth 登录并保存 token。
- 支持直接添加 API Key 账号。
- 支持为不同项目创建独立的 `CODEX_HOME`,并绑定不同账号。
- 支持用指定账号或 home 启动外部命令,例如 `codex`
- 支持查询 OAuth 账号的 Codex 使用配额。
- 支持查看 Codex 会话、统计 token、移入垃圾箱、恢复和修复可见性。
- 支持把本地 `cdxs.toml` 推送到自建同步服务,或从同步服务拉取。
## 安装与构建
本项目是 Rust CLI。需要先安装 Rust 工具链。
```powershell
cargo build --release
```
构建后的可执行文件位于:
```text
target\release\cdxs.exe
```
开发时可直接运行:
```powershell
cargo run -- --help
```
如果希望全局使用,可以把 `target\release` 加入 `PATH`,或者把 `cdxs.exe` 复制到已有的命令目录。
## 数据文件
默认读取当前 `CODEX_HOME` 环境变量;如果没有设置,则使用用户目录下的 `.codex`
```text
%USERPROFILE%\.codex
```
主要文件:
- `auth.json`Codex 原生认证文件,`cdxs switch` 会写入这里。
- `cdxs.toml``cdxs` 自己的配置文件,保存账号、home、同步信息。
- `cdxs-backups\`:写入 `auth.json``cdxs.toml`、会话索引或状态库前的备份目录。
- `cdxs-trash\`:被 `cdxs session trash` 移入垃圾箱的会话。
多数命令支持通过 `CODEX_HOME` 控制配置位置;部分命令还提供 `--codex-home` 参数指定目标 Codex home。
## 快速开始
从当前 Codex 的 `auth.json` 导入账号:
```powershell
cdxs import auth
```
导入后立即切换为当前账号:
```powershell
cdxs import auth --switch
```
查看已保存账号:
```powershell
cdxs list
```
切换账号:
```powershell
cdxs switch <账号ID或邮箱前缀>
```
用指定账号启动 Codex
```powershell
cdxs run --account <账号ID或邮箱前缀> -- codex
```
## 账号管理
OAuth 登录:
```powershell
cdxs login oauth
```
默认会监听 `127.0.0.1:1455` 等待浏览器回调。可指定端口:
```powershell
cdxs login oauth --port 1456
```
如果不能自动接收回调,可以手动粘贴回调 URL:
```powershell
cdxs login oauth --manual
```
添加 API Key 账号:
```powershell
cdxs account add-api-key --key sk-...
```
使用自定义 API base URL
```powershell
cdxs account add-api-key --key sk-... --base-url https://example.com/v1
```
常用账号命令:
```powershell
cdxs account list
cdxs account current
cdxs account show <账号ID或邮箱前缀>
cdxs account remove <账号ID或邮箱前缀>
cdxs refresh-token <账号ID或邮箱前缀>
```
支持 JSON 输出的命令:
```powershell
cdxs list --json
cdxs account current --json
cdxs account show <账号> --json
```
说明:`switch --apply-fingerprint` 参数目前只会输出提示,实际不会应用设备指纹。
## 配额查询
查询当前账号或第一个账号的 Codex 配额:
```powershell
cdxs quota
```
查询指定账号:
```powershell
cdxs quota <账号ID或邮箱前缀>
```
查询所有账号:
```powershell
cdxs quota --all
```
JSON 输出:
```powershell
cdxs quota --all --json
```
注意:配额查询调用的是 ChatGPT/Codex OAuth 后端接口,只支持 OAuth 账号;API Key 账号不支持该配额查询。
## 多 CODEX_HOME 管理
创建一个独立 home
```powershell
cdxs home create work --path D:\codex-homes\work
```
创建时绑定账号,并把账号写入该 home 的 `auth.json`
```powershell
cdxs home create work --path D:\codex-homes\work --account <账号>
```
绑定已有 home 到账号:
```powershell
cdxs home bind work <账号>
```
查看 home
```powershell
cdxs home list
cdxs home path work
```
用某个 home 启动命令:
```powershell
cdxs run --home work -- codex
```
删除 home 记录:
```powershell
cdxs home remove work
```
说明:`home remove` 只删除 `cdxs.toml` 里的 home 记录,不会删除实际目录;`default` home 不能删除。
## 会话管理
列出默认 home 的 Codex 会话:
```powershell
cdxs session list
```
列出所有受管理 home 的会话:
```powershell
cdxs session list --all-homes
```
查看某个会话的统计信息:
```powershell
cdxs session stats <session_id>
```
移入 `cdxs` 垃圾箱,并从 Codex 会话索引和 SQLite 状态库中隐藏:
```powershell
cdxs session trash <session_id>
```
查看垃圾箱:
```powershell
cdxs session trash-list
```
恢复会话:
```powershell
cdxs session restore <session_id>
```
检查会话可见性问题:
```powershell
cdxs session visibility check
```
自动修复可见性问题:
```powershell
cdxs session visibility repair
```
把缺失的会话线程复制到其他受管理 home:
```powershell
cdxs session sync-threads --all-homes
```
预览同步动作,不实际写入:
```powershell
cdxs session sync-threads --all-homes --dry-run
```
会话相关命令会读取 Codex 的 `state_5.sqlite``session_index.jsonl` 以及 `sessions` / `archived_sessions` 下的 rollout 文件。写入前会尽量在 `cdxs-backups` 中备份相关文件。
## 同步服务
`cdxs` 内置一个简单同步服务,用于在多台机器之间同步 `cdxs.toml` 中的账号、home 等便携状态。
在服务端添加用户:
```powershell
cdxs server user add alice --password your-password
```
启动服务:
```powershell
cdxs server run --bind 127.0.0.1:8765
```
客户端登录:
```powershell
cdxs sync login --server http://127.0.0.1:8765 --user alice --password your-password
```
推送本地状态到服务端:
```powershell
cdxs sync push
```
从服务端拉取状态到本地:
```powershell
cdxs sync pull
```
查看同步配置:
```powershell
cdxs sync status
```
说明:服务端会保存用户密码哈希和登录 session;客户端拉取/推送的状态会排除服务端用户和同步 token。同步会覆盖账号、home 和 meta 状态,使用前建议先备份当前 `.codex\cdxs.toml`
## 常用命令速查
```powershell
cdxs --help
cdxs list
cdxs import auth --switch
cdxs login oauth --switch
cdxs account add-api-key --key sk-... --switch
cdxs switch <账号>
cdxs run --account <账号> -- codex
cdxs quota --all
cdxs home list
cdxs session list --all-homes
cdxs session visibility check --all-homes
```
## 开发验证
当前代码没有单元测试,但可以运行:
```powershell
cargo test
```
当前实际结果为 0 个测试通过,命令本身成功完成。
+13
View File
@@ -0,0 +1,13 @@
services:
cdxs-server:
build: .
image: cdxs-server:latest
container_name: cdxs-server
restart: unless-stopped
ports:
- "8765:8765"
volumes:
- cdxs-data:/data
volumes:
cdxs-data:
+422
View File
@@ -0,0 +1,422 @@
# 账号管理
本文说明 `cdxs` 当前账号管理功能的实现方式、调用机制和运行过程。
## 功能范围
账号管理主要覆盖以下能力:
- 从 Codex 原生 `auth.json` 导入账号。
- 添加 API Key 账号。
- 列出所有已保存账号。
- 查看当前账号。
- 查看指定账号详情。
- 删除指定账号。
- 将指定账号切换为当前账号并写入目标 `auth.json`
## 核心文件
- `src/main.rs`:CLI 入口,负责把命令分发到具体模块。
- `src/cli.rs`:定义账号相关命令和参数。
- `src/account.rs`:账号管理主逻辑。
- `src/config_store.rs``cdxs.toml` 的数据模型、加载、保存和查询。
- `src/auth_file.rs`Codex 原生 `auth.json` 的读取和写入。
- `src/paths.rs`:解析 `CODEX_HOME``auth.json``cdxs.toml` 路径。
- `src/atomic.rs`:写入配置或认证文件前备份,并使用原子写入。
- `src/jwt.rs`:从 OAuth `id_token` 中解析邮箱、计划、组织等账号元数据。
- `src/token.rs`:切换 OAuth 账号前按需刷新 token。
## 数据保存方式
`cdxs` 自己的账号状态保存在当前 `CODEX_HOME` 下的 `cdxs.toml`
```text
<CODEX_HOME>\cdxs.toml
```
如果没有设置 `CODEX_HOME`,默认使用:
```text
%USERPROFILE%\.codex
```
账号列表保存在 `Store.accounts` 中,当前账号 ID 保存在:
```text
Store.meta.current_account_id
```
每个账号使用 `Account` 结构保存,关键字段包括:
- `id`:稳定账号 ID。
- `email`:显示用邮箱或 API Key 虚拟邮箱。
- `auth_mode``oauth``api_key`
- `tokens`OAuth 账号的 token。
- `openai_api_key`API Key 账号的 key。
- `api_base_url`API Key 账号的可选 base URL。
- `plan_type`:账号套餐类型。
- `account_id`OAuth 账号 ID。
- `organization_id`OAuth 组织 ID。
- `quota`:最近一次配额查询结果。
- `requires_reauth`OAuth refresh 失败后标记是否需要重新登录。
## 账号 ID 生成原理
账号 ID 由 `account.rs` 中的 `stable_id` 生成。
OAuth 账号使用以下信息生成稳定 ID:
- 固定前缀:`oauth`
- 邮箱
- `account_id`
- `organization_id`
API Key 账号使用以下信息生成稳定 ID:
- 固定前缀:`apikey`
- API Key
- `base_url`
生成方式是对这些字段做 SHA-256,然后取前 16 位十六进制字符串:
```text
oauth_xxxxxxxxxxxxxxxx
apikey_xxxxxxxxxxxxxxxx
```
这样重复导入同一个账号时,会更新原有记录,而不是创建重复账号。
## 命令调用机制
账号相关命令由 `src/cli.rs` 定义,再由 `src/main.rs` 分发到 `src/account.rs`
```mermaid
flowchart TD
A[用户执行 cdxs 命令] --> B[Cli::parse]
B --> C[src/main.rs match Commands]
C --> D{账号相关命令}
D -->|cdxs import auth| E[account::import_auth]
D -->|cdxs account add-api-key| F[account::add_api_key]
D -->|cdxs list / account list| G[account::list_accounts]
D -->|cdxs account current| H[account::current_account]
D -->|cdxs account show| I[account::show_account]
D -->|cdxs account remove| J[account::remove_account]
D -->|cdxs switch| K[account::switch_account]
```
## 导入 auth.json 运行过程
命令:
```powershell
cdxs import auth
```
可选参数:
```powershell
cdxs import auth --file <auth.json路径>
cdxs import auth --codex-home <路径>
cdxs import auth --switch
```
运行过程:
1. 解析主配置 home,也就是保存 `cdxs.toml` 的位置。
2. 解析来源 home,用于定位要导入的 `auth.json`
3. 调用 `auth_file::read_auth_file` 读取并解析 `auth.json`
4. 调用 `account_from_auth` 判断是 OAuth 账号还是 API Key 账号。
5. OAuth 账号会解析 token,并从 `id_token` 中读取邮箱、计划、账号 ID、组织 ID。
6. API Key 账号会读取 `OPENAI_API_KEY` 和可选 base URL。
7. 生成稳定账号 ID。
8. 调用 `Store::upsert_account` 写入或更新账号。
9. 如果带 `--switch`,同时写入目标 home 的 `auth.json`,并设置当前账号。
10. 调用 `Store::save` 保存 `cdxs.toml`
```mermaid
sequenceDiagram
participant U as 用户
participant M as main.rs
participant A as account.rs
participant P as paths.rs
participant AF as auth_file.rs
participant S as config_store.rs
participant J as jwt.rs
U->>M: cdxs import auth
M->>A: import_auth(file, codex_home, switch)
A->>P: codex_home(None)
P-->>A: 配置 home
A->>P: codex_home(codex_home)
P-->>A: 来源 home
A->>AF: read_auth_file(auth_path)
AF-->>A: CodexAuthFile
A->>A: account_from_auth
alt OAuth 账号
A->>J: decode_payload(id_token)
J-->>A: email / plan / account_id / organization_id
A->>A: oauth_account
else API Key 账号
A->>AF: extract_api_key / api_base_url
AF-->>A: key / base_url
A->>A: api_key_account
end
A->>S: Store::load
S-->>A: Store
A->>S: upsert_account
opt --switch
A->>AF: write_account_to_auth
A->>A: 设置 current_account_id
end
A->>S: save
S-->>U: 导入完成
```
## 添加 API Key 账号运行过程
命令:
```powershell
cdxs account add-api-key --key sk-...
```
可选参数:
```powershell
cdxs account add-api-key --key sk-... --base-url https://example.com/v1
cdxs account add-api-key --key sk-... --switch
```
运行过程:
1. 解析当前 `CODEX_HOME`
2. 加载 `cdxs.toml`
3. 校验 API Key 非空。
4. 根据 API Key 和 base URL 生成稳定账号 ID。
5. 生成显示用邮箱,格式类似 `api-key-xxxxxxxx`
6. 调用 `Store::upsert_account` 保存账号。
7. 如果带 `--switch`,写入当前 home 的 `auth.json`
8. 保存 `cdxs.toml`
```mermaid
flowchart TD
A[cdxs account add-api-key] --> B[paths::codex_home]
B --> C[Store::load]
C --> D[api_key_account]
D --> E[stable_id]
E --> F[Store::upsert_account]
F --> G{是否 --switch}
G -->|是| H[auth_file::write_account_to_auth]
G -->|否| I[跳过 auth.json 写入]
H --> J[Store::save]
I --> J
```
## 列出账号运行过程
命令:
```powershell
cdxs list
cdxs account list
```
JSON 输出:
```powershell
cdxs list --json
cdxs account list --json
```
运行过程:
1. 解析当前 `CODEX_HOME`
2. 加载 `cdxs.toml`
3. 如果 `--json`,直接输出 `store.accounts` 的 JSON。
4. 如果不是 JSON,按表格输出账号 ID、邮箱、认证模式、套餐和配额。
5. 当前账号会用 `*` 标记。
## 查看当前账号运行过程
命令:
```powershell
cdxs account current
```
JSON 输出:
```powershell
cdxs account current --json
```
运行过程:
1. 加载 `cdxs.toml`
2. 读取 `Store.meta.current_account_id`
3. 如果没有当前账号,输出未设置账号。
4. 如果当前账号 ID 不存在,返回错误。
5. 找到账号后输出详情或 JSON。
## 查看指定账号运行过程
命令:
```powershell
cdxs account show <账号ID或邮箱前缀>
```
查找账号使用 `Store::find_account`,支持三种匹配方式:
- 完整账号 ID。
- 完整邮箱。
- 邮箱前缀。
运行过程:
1. 加载 `cdxs.toml`
2. 调用 `find_account` 查找账号。
3. 找不到则返回错误。
4. 找到后输出详情或 JSON。
## 删除账号运行过程
命令:
```powershell
cdxs account remove <账号ID或邮箱前缀>
```
运行过程:
1. 加载 `cdxs.toml`
2. 用账号 ID、邮箱或邮箱前缀查找账号。
3.`Store.accounts` 中删除该账号。
4. 如果该账号是当前账号,清空 `current_account_id`
5. 如果有 home 绑定该账号,清空对应 `bound_account_id`
6. 保存 `cdxs.toml`
```mermaid
flowchart TD
A[cdxs account remove] --> B[Store::load]
B --> C[Store::find_account]
C --> D{是否存在}
D -->|否| E[返回账号不存在错误]
D -->|是| F[accounts.retain 删除账号]
F --> G{是否当前账号}
G -->|是| H[清空 current_account_id]
G -->|否| I[保持 current_account_id]
H --> J[清空 homes 中相关 bound_account_id]
I --> J
J --> K[Store::save]
```
## 切换账号运行过程
命令:
```powershell
cdxs switch <账号ID或邮箱前缀>
```
可选指定目标 home
```powershell
cdxs switch <账号> --codex-home <路径>
```
运行过程:
1. 加载主配置 home 的 `cdxs.toml`
2. 解析目标 home,用于确定要写入哪个 `auth.json`
3. 用账号 ID、邮箱或邮箱前缀查找账号。
4. 如果是 OAuth 账号,调用 `token::refresh_account_if_needed` 检查 access token 是否即将过期。
5. 如果 token 需要刷新,则先刷新并更新 `cdxs.toml` 中的账号 token。
6. 调用 `auth_file::write_account_to_auth` 把账号写入目标 home 的 `auth.json`
7. 更新该账号的 `last_used_at`
8. 设置 `Store.meta.current_account_id`
9. 保存 `cdxs.toml`
```mermaid
sequenceDiagram
participant U as 用户
participant M as main.rs
participant A as account.rs
participant S as config_store.rs
participant T as token.rs
participant AF as auth_file.rs
U->>M: cdxs switch <account>
M->>A: switch_account
A->>S: Store::load
S-->>A: Store
A->>S: find_account
S-->>A: Account
A->>T: refresh_account_if_needed
alt OAuth token 即将过期
T->>T: refresh_account
T-->>A: 已更新 tokens
else 不需要刷新或 API Key
T-->>A: 无需刷新
end
A->>AF: write_account_to_auth
AF-->>A: 写入 auth.json
A->>A: 更新 last_used_at/current_account_id
A->>S: save
S-->>U: 切换完成
```
## auth.json 写入规则
账号切换或带 `--switch` 保存账号时,会调用 `auth_file::write_account_to_auth`
OAuth 账号写入格式:
```json
{
"OPENAI_API_KEY": null,
"tokens": {
"id_token": "...",
"access_token": "...",
"refresh_token": "...",
"account_id": "..."
},
"last_refresh": "..."
}
```
API Key 账号写入格式:
```json
{
"auth_mode": "apikey",
"OPENAI_API_KEY": "sk-..."
}
```
## 写入安全机制
`cdxs.toml``auth.json` 写入时都使用同一套安全机制:
1. 如果目标文件已存在,先备份到:
```text
<CODEX_HOME>\cdxs-backups\
```
2. 写入时先写到同目录临时文件:
```text
.<原文件名>.tmp
```
3. 再用 rename 替换目标文件。
这样可以降低写入中断导致配置文件损坏的风险。
## 当前实现边界
- `switch --apply-fingerprint` 当前只输出提示,实际不会应用设备指纹。
- API Key 账号没有 OAuth token,也不会参与 token refresh。
- 账号查找支持邮箱前缀,但如果多个邮箱前缀相同,当前实现会返回第一个匹配项。
- `remove_account` 只删除 `cdxs.toml` 中的账号记录,不会主动清理已经写入某个 home 的 `auth.json`
+454
View File
@@ -0,0 +1,454 @@
# 认证与 Token、配额查询
本文说明 `cdxs` 当前“认证与 Token、配额查询”功能的实现方式、调用机制和运行过程。
## 功能范围
本功能主要覆盖以下能力:
- 通过 OpenAI OAuth PKCE 流程登录 Codex 账号。
- 支持浏览器本地回调和手动粘贴回调 URL 两种 OAuth 完成方式。
- 将 OAuth 返回的 token 保存为 `cdxs` 账号。
- 将已保存账号写入 Codex 原生 `auth.json`
- 在切换、运行、配额查询前按需刷新 OAuth access token。
- 手动刷新指定 OAuth 账号 token。
- 解码 JWT payload,用于读取邮箱、套餐、账号 ID、组织 ID 和过期时间。
- 查询 OAuth 账号 Codex 配额,并缓存最近一次配额结果。
## 核心文件
- `src/main.rs`:CLI 入口,负责把认证、刷新和配额命令分发到具体模块。
- `src/cli.rs`:定义 `login oauth``refresh-token``quota` 等命令参数。
- `src/oauth.rs`:实现 OAuth PKCE 登录、回调解析和授权码换 token。
- `src/token.rs`:实现 OAuth token 过期检查和 refresh token 刷新。
- `src/quota.rs`:实现 Codex 配额接口调用、配额解析和展示。
- `src/auth_file.rs`:负责 Codex 原生 `auth.json` 的读取和写入。
- `src/jwt.rs`:本地解码 JWT payload,提取账号元数据和过期时间。
- `src/config_store.rs`:定义账号、token、quota 的持久化模型,并读写 `cdxs.toml`
## 数据保存方式
`cdxs` 自己管理的账号和 token 保存在当前 `CODEX_HOME` 下的 `cdxs.toml`
```text
<CODEX_HOME>\cdxs.toml
```
OAuth 账号的 token 保存在 `Account.tokens`
- `id_token`:主要用于本地解析账号元数据。
- `access_token`:用于调用 ChatGPT/Codex 后端接口。
- `refresh_token`:用于刷新 access token。
配额结果保存在 `Account.quota`
- `primary_remaining_percent`:主窗口剩余百分比。
- `primary_reset_time`:主窗口重置时间戳,可为空。
- `secondary_remaining_percent`:次窗口剩余百分比。
- `secondary_reset_time`:次窗口重置时间戳,可为空。
- `updated_at`:本地更新时间戳。
Codex CLI 实际读取的认证文件仍然是原生 `auth.json`
```text
<CODEX_HOME>\auth.json
```
`cdxs` 在切换账号或导入登录时,只负责把选中的账号转换成 Codex 兼容格式写入该文件。
## 命令调用机制
认证与配额相关命令由 `src/cli.rs` 定义,再由 `src/main.rs` 分发:
```mermaid
flowchart TD
A[用户执行 cdxs 命令] --> B[Cli::parse]
B --> C[src/main.rs match Commands]
C --> D{认证与配额命令}
D -->|cdxs login oauth| E[oauth::login_oauth]
D -->|cdxs refresh-token| F[token::refresh_token_command]
D -->|cdxs quota| G[quota::quota_command]
D -->|cdxs switch| H[account::switch_account]
D -->|cdxs run| I[run_cmd::run_with_account_or_home]
H --> J[token::refresh_account_if_needed]
I --> J
G --> J
```
## OAuth 登录实现原理
`cdxs login oauth` 使用 PKCE 授权码流程,不需要本地保存 client secret。
关键常量在 `src/oauth.rs` 中定义:
- `CLIENT_ID`Codex 使用的 OAuth client id。
- `AUTH_ENDPOINT``https://auth.openai.com/oauth/authorize`
- `TOKEN_ENDPOINT``https://auth.openai.com/oauth/token`
- `SCOPES``openid profile email offline_access`
- `ORIGINATOR``codex_vscode`
运行时会生成三类临时值:
- `code_verifier`:随机 32 字节 base64url 字符串。
- `code_challenge`:对 `code_verifier` 做 SHA-256 后 base64url 编码。
- `state`:随机字符串,用来校验回调是否属于本次登录。
然后拼出授权 URL,用户在浏览器打开 URL 完成登录。登录成功后,OpenAI 会把浏览器重定向到:
```text
http://localhost:<port>/auth/callback?code=...&state=...
```
默认端口是 `1455`
## OAuth 登录运行过程
命令:
```powershell
cdxs login oauth
```
可选参数:
```powershell
cdxs login oauth --manual
cdxs login oauth --port 1455
cdxs login oauth --switch
```
运行过程:
1. 生成 `code_verifier``code_challenge``state`
2. 构造 OpenAI OAuth 授权 URL 并打印到终端。
3. 如果没有 `--manual`,在 `127.0.0.1:<port>` 启动一次性 HTTP listener 等待回调。
4. 如果使用 `--manual`,从标准输入读取用户粘贴的完整回调 URL 或查询字符串。
5. 调用 `parse_callback_code` 校验回调路径必须是 `/auth/callback`
6. 校验回调中的 `state` 必须和本次登录生成的 `state` 一致。
7. 提取授权码 `code`
8. 调用 token endpoint,用 `authorization_code``client_id``redirect_uri``code_verifier` 换取 token。
9. 要求响应中必须包含 `id_token``access_token``refresh_token` 可选。
10. 调用 `account::upsert_oauth_tokens` 保存或更新账号。
11. 如果带 `--switch`,保存账号后会切换到该账号。
```mermaid
sequenceDiagram
participant U as 用户
participant M as main.rs
participant O as oauth.rs
participant B as 浏览器
participant OA as OpenAI OAuth
participant A as account.rs
participant S as cdxs.toml
U->>M: cdxs login oauth
M->>O: login_oauth(manual, port, switch)
O->>O: 生成 verifier/challenge/state
O-->>U: 打印授权 URL
U->>B: 打开授权 URL
B->>OA: 登录并授权
OA-->>B: redirect 到 localhost callback
alt 自动回调模式
B->>O: GET /auth/callback?code&state
O->>O: 校验 path/state 并提取 code
else --manual 模式
U->>O: 粘贴回调 URL 或 query
O->>O: 校验 path/state 并提取 code
end
O->>OA: POST /oauth/token grant_type=authorization_code
OA-->>O: id_token/access_token/refresh_token
O->>A: upsert_oauth_tokens(tokens, None, switch)
A->>S: 保存账号和 token
opt --switch
A->>A: 写入 auth.json 并设置当前账号
end
```
## 回调解析机制
`parse_callback_code` 支持三种输入形式:
- 完整 URL,例如 `http://localhost:1455/auth/callback?code=...&state=...`
- 路径和查询,例如 `/auth/callback?code=...&state=...`
- 纯查询字符串,例如 `?code=...&state=...``code=...&state=...`
当前实现会拒绝以下情况:
- URL 格式无效。
- 路径不是 `/auth/callback`
- `state` 不匹配。
- 缺少 `code``code` 为空。
自动回调模式最多等待 300 秒,超时后返回错误。
## auth.json 写入机制
`auth_file::write_account_to_auth``cdxs` 和 Codex 原生认证文件之间的兼容边界。
OAuth 账号写入格式:
```json
{
"OPENAI_API_KEY": null,
"tokens": {
"id_token": "...",
"access_token": "...",
"refresh_token": "...",
"account_id": "..."
},
"last_refresh": "..."
}
```
API Key 账号写入格式:
```json
{
"auth_mode": "apikey",
"OPENAI_API_KEY": "sk-..."
}
```
写入前会调用 `atomic::backup_if_exists` 备份已有文件,然后通过 `atomic::write_atomic` 原子替换目标文件。
## JWT 解析机制
`src/jwt.rs` 只解码 JWT payload,不验证签名。签名有效性由 OpenAI 服务端在 token 被使用时校验。
本地解析的字段包括:
- `email`:账号邮箱。
- `sub`JWT subject。
- `exp`:过期时间戳。
- `https://api.openai.com/auth.chatgpt_plan_type`:套餐类型。
- `https://api.openai.com/auth.account_id`ChatGPT/Codex 账号 ID。
- `https://api.openai.com/auth.organization_id`:组织 ID。
`token_expired` 使用 `exp` 判断 access token 是否过期。当前刷新提前量是 300 秒,也就是 token 距离过期不足 5 分钟时会被视为需要刷新。无法解析的 token 会被视为已过期。
## Token 刷新实现原理
`src/token.rs` 提供两类刷新入口:
- `refresh_token_command`:用户显式执行 `cdxs refresh-token <account>`
- `refresh_account_if_needed`:切换、运行、配额查询等流程内部按需调用。
只支持 OAuth 账号刷新。API Key 账号没有 OAuth token`refresh_account_if_needed` 对 API Key 返回无需刷新,显式刷新 API Key 账号会返回错误。
刷新请求使用:
```text
POST https://auth.openai.com/oauth/token
grant_type=refresh_token
refresh_token=<refresh_token>
client_id=<CLIENT_ID>
```
刷新响应必须包含新的 `access_token`。如果响应没有新的 `id_token`,当前实现会沿用旧的 `id_token`,因为旧 `id_token` 仍可用于本地展示账号元数据。
如果响应没有新的 `refresh_token`,当前实现会继续保存旧的 `refresh_token`
## 手动刷新 Token 运行过程
命令:
```powershell
cdxs refresh-token <账号ID或邮箱前缀>
```
运行过程:
1. 解析当前 `CODEX_HOME`
2. 加载 `cdxs.toml`
3. 通过账号 ID、完整邮箱或邮箱前缀查找账号。
4. 校验账号必须是 OAuth 账号。
5. 校验账号必须保存了 `refresh_token`
6. 调用 OpenAI token endpoint 刷新 token。
7. 更新账号的 `tokens``updated_at``plan_type``account_id``organization_id`
8.`requires_reauth` 设置为 `false`
9. 保存 `cdxs.toml`
如果刷新失败,当前实现会把该账号的 `requires_reauth` 标记为 `true`,并返回错误。
```mermaid
flowchart TD
A[cdxs refresh-token account] --> B[Store::load]
B --> C[Store::find_account]
C --> D{OAuth 账号?}
D -->|否| E[返回 API Key 不支持刷新]
D -->|是| F{有 refresh_token?}
F -->|否| G[返回需要重新登录]
F -->|是| H[POST OAuth token endpoint]
H --> I{刷新成功?}
I -->|是| J[更新 tokens 和账号元数据]
I -->|否| K[标记 requires_reauth=true]
J --> L[Store::save]
K --> L
```
## 按需刷新运行过程
切换账号、运行命令、查询配额前会尽量避免写入或使用即将过期的 access token。
按需刷新逻辑:
1. 找到目标账号。
2. 如果账号不是 OAuth,直接返回无需刷新。
3. 如果 OAuth 账号缺少 `tokens`,返回错误。
4.`jwt::token_expired(access_token, 300)` 判断 access token 是否即将过期。
5. 如果没有过期,返回无需刷新。
6. 如果即将过期,调用 `refresh_account` 刷新并更新 store。
```mermaid
sequenceDiagram
participant Caller as switch/run/quota
participant T as token.rs
participant J as jwt.rs
participant OA as OpenAI OAuth
participant S as cdxs.toml
Caller->>T: refresh_account_if_needed(store, account_id)
T->>T: 查找账号并检查 auth_mode
alt API Key 账号
T-->>Caller: false
else OAuth 账号
T->>J: token_expired(access_token, 300)
alt 未过期
J-->>T: false
T-->>Caller: false
else 即将过期或无法解析
J-->>T: true
T->>OA: POST grant_type=refresh_token
OA-->>T: 新 tokens
T->>S: 更新内存 store,调用方之后保存
T-->>Caller: true
end
end
```
## 配额查询实现原理
配额查询由 `src/quota.rs` 实现,只支持 OAuth 账号。API Key 账号会被拒绝,因为配额接口依赖 ChatGPT/Codex 账号上下文。
当前调用的后端接口是:
```text
GET https://chatgpt.com/backend-api/wham/usage
```
请求头包括:
- `Authorization: Bearer <access_token>`
- `Accept: application/json`
- `ChatGPT-Account-Id: <account_id>`,仅当账号保存了非空 `account_id` 时添加。
`ChatGPT-Account-Id` 用于多账号或组织场景,避免后端选择错误账号上下文。
接口响应中的 `used_percent` 会被转换成剩余百分比:
```text
remaining_percent = 100 - clamp(used_percent, 0, 100)
```
主窗口来自 `rate_limit.primary_window`,次窗口来自 `rate_limit.secondary_window`。重置时间优先使用接口返回的 `reset_at`;如果没有 `reset_at`,则用当前时间加 `reset_after_seconds` 计算。
## 配额查询运行过程
命令:
```powershell
cdxs quota
```
可选参数:
```powershell
cdxs quota <账号ID或邮箱前缀>
cdxs quota --all
cdxs quota --json
```
账号选择顺序:
1. 如果传入 `--all`,查询所有保存账号。
2. 如果传入指定账号,查询该账号。
3. 如果存在 `Store.meta.current_account_id`,查询当前账号。
4. 否则查询保存的第一个账号。
运行过程:
1. 解析当前 `CODEX_HOME`
2. 加载 `cdxs.toml`
3. 根据参数选择要查询的账号 ID 列表。
4. 对每个账号调用 `refresh_one_quota`
5. 查询前先调用 `token::refresh_account_if_needed`
6. 校验账号必须是 OAuth 账号。
7. 使用 access token 调用 usage 接口。
8. 如果请求失败且错误文本包含 `401``token_invalidated``authentication token has been invalidated`,强制刷新一次 token 后重试。
9. 解析配额并更新账号的 `plan_type``quota``updated_at`
10. 保存 `cdxs.toml`
11. 根据 `--json` 决定输出 JSON 或表格。
```mermaid
sequenceDiagram
participant U as 用户
participant M as main.rs
participant Q as quota.rs
participant T as token.rs
participant API as ChatGPT Usage API
participant S as cdxs.toml
U->>M: cdxs quota [account|--all|--json]
M->>Q: quota_command(account, all, json)
Q->>S: Store::load
Q->>Q: 选择账号列表
loop 每个账号
Q->>T: refresh_account_if_needed
T-->>Q: 已刷新或无需刷新
Q->>Q: 校验 OAuth 账号和 tokens
Q->>API: GET /backend-api/wham/usage
alt token 失效类错误
API-->>Q: 401/token_invalidated
Q->>T: refresh_account
T-->>Q: 新 tokens
Q->>API: 重新 GET usage
end
API-->>Q: usage JSON
Q->>Q: parse_quota
Q->>S: 更新账号 quota/plan/updated_at
end
Q->>S: Store::save
Q-->>U: 输出表格或 JSON
```
## 配额输出格式
默认表格输出字段:
- `ID`:账号 ID,过长会被截断显示。
- `Email`:账号邮箱,过长会被截断显示。
- `Plan`:套餐类型。
- `5h`:主窗口剩余百分比。
- `Weekly`:次窗口剩余百分比。
- `Status``ok` 或错误信息。
JSON 输出会输出数组,每个元素包括:
- `id`
- `email`
- `plan_type`
- `quota`
- `error`
如果查询多个账号时存在失败项,非 JSON 和 JSON 输出都会先展示可用结果,最后返回“部分账号配额刷新失败”的错误。
## 当前实现边界
- OAuth 登录不会自动打开浏览器,只打印授权 URL。
- 自动 OAuth 回调只监听 `127.0.0.1:<port>`,并且只处理一次回调请求。
- 自动 OAuth 回调等待时间是 300 秒。
- `id_token` 只做本地 payload 解码,不做签名校验。
- 显式 `refresh-token` 只支持 OAuth 账号,不支持 API Key 账号。
- 配额查询只支持 OAuth 账号,不支持 API Key 账号。
- 配额接口错误信息不会输出完整响应体,只输出 HTTP status 和 body 长度,避免泄露响应内容。
- 如果 token 刷新失败,账号会被标记为 `requires_reauth=true`,但不会自动重新发起 OAuth 登录。
+319
View File
@@ -0,0 +1,319 @@
# 多 CODEX_HOME 管理与会话
本文说明当前代码中“多 CODEX_HOME 管理”和“会话管理”的实现方式、调用机制与运行过程。相关入口主要在 `src/main.rs``src/cli.rs`,实现分布在 `src/account.rs``src/run_cmd.rs``src/session.rs``src/config_store.rs``src/paths.rs``src/atomic.rs`
## 功能边界
当前实现把 `CODEX_HOME` 当作 Codex 原生状态目录使用,每个 home 下可以有自己的 `auth.json``state_5.sqlite``session_index.jsonl``sessions/``archived_sessions/` 等文件。`cdxs` 额外在主 home 的 `cdxs.toml` 中保存已管理 home 列表,并支持把账号写入指定 home、用指定 home 启动子命令,以及检查、修复、回收、恢复和跨 home 补齐会话。
当前代码没有实现 `current_home` 的切换命令;`Store.meta.current_home` 只是配置模型字段,`list_homes` 会用它标记当前 home,但现有命令不会修改它。
## 数据模型
`src/config_store.rs` 中的 `Store``cdxs.toml` 的内存模型,其中和本功能相关的字段是:
- `meta.current_home`:默认值为 `default`,当前没有命令写入它。
- `homes`:已管理的 home 列表,每项是 `Home { name, path, bound_account_id }`
- `accounts`:账号列表,home 绑定账号时通过 `bound_account_id` 引用这里的账号。
配置加载逻辑会自动保证存在一个名为 `default` 的 home。这个默认 home 的路径来自 `paths::codex_home(None)`,也就是优先使用环境变量 `CODEX_HOME`,否则使用用户目录下的 `.codex`
```mermaid
flowchart TD
A[paths::codex_home(None)] --> B{CODEX_HOME 是否存在}
B -->|存在且非空| C[展开并使用 CODEX_HOME]
B -->|不存在| D[使用用户目录/.codex]
C --> E[Store::load]
D --> E
E --> F{cdxs.toml 是否存在}
F -->|不存在或为空| G[Store::with_default_home]
F -->|存在| H[解析 TOML]
G --> I[ensure_default_home]
H --> I
I --> J[得到 homes 列表]
```
路径处理由 `src/paths.rs` 完成:
- `codex_home(override_path)`:命令显式传入路径时优先使用;否则读取 `CODEX_HOME`;再否则使用 `~/.codex`
- `config_path(home)`:返回 `<home>/cdxs.toml`
- `auth_path(home)`:返回 `<home>/auth.json`
- `expand_home(path)`:支持把 `~``~/``~\` 展开为用户主目录。
写配置和关键状态文件时,`src/atomic.rs` 提供两类基础能力:
- `write_atomic`:先写同目录临时文件,再 rename 到目标路径,避免半写入。
- `backup_if_exists`:把已有文件复制到 `<codex_home>/cdxs-backups/`,文件名带时间戳。
## 多 CODEX_HOME 管理
### 命令入口
`src/cli.rs` 定义 `cdxs home` 子命令,`src/main.rs` 负责分发到 `account.rs`
- `cdxs home list [--json]` -> `account::list_homes`
- `cdxs home create <name> --path <path> [--account <account>]` -> `account::create_home`
- `cdxs home bind <name> <account>` -> `account::bind_home`
- `cdxs home path <name>` -> `account::home_path`
- `cdxs home remove <name>` -> `account::remove_home`
### 创建 home
`create_home` 总是加载主 home 下的 `cdxs.toml`,不会把新 home 当作配置源。它会检查名称是否重复,解析并展开目标路径,创建目录,然后把新记录写入 `Store.homes`
如果创建时传入 `--account`,代码会先在主配置的账号列表里解析账号,拿到稳定账号 ID,随后立即把该账号写入新 home 的 `auth.json`。这样新 home 创建完成后已经具备 Codex 可读取的认证文件。
```mermaid
sequenceDiagram
participant CLI as cdxs home create
participant Main as main.rs
participant Account as account::create_home
participant Store as cdxs.toml Store
participant FS as 文件系统
participant Auth as auth.json
CLI->>Main: HomeCommands::Create
Main->>Account: create_home(name, path, account)
Account->>Store: Store::load(main_home)
Account->>Store: 检查 home 名称是否已存在
Account->>FS: expand_home(path) 并 create_dir_all
alt 传入 --account
Account->>Store: find_account(account)
Account->>Auth: write_account_to_auth(new_home/auth.json)
end
Account->>Store: push Home{name,path,bound_account_id}
Account->>Store: save(main_home)
```
### 绑定、查看和删除 home
`bind_home` 只修改 `cdxs.toml` 中指定 home 的 `bound_account_id`,不会立即改写该 home 的 `auth.json`。真正运行命令时,`run` 会根据绑定账号刷新 token 并写入目标 home 的 `auth.json`
`home_path` 只查找并打印配置中记录的路径。
`remove_home` 只从 `Store.homes` 中删除记录,不删除磁盘上的 home 目录。代码明确禁止删除名为 `default` 的 home。
## 用指定账号或 home 运行命令
### 命令入口
`cdxs run` 的参数定义在 `RunArgs`
- `--account <account>`:指定账号,在目标 `CODEX_HOME` 中准备 `auth.json`
- `--home <home>`:指定已管理 home,如果该 home 绑定了账号,则先准备认证。
- `--codex-home <path>`:和 `--account` 一起使用,用作目标 home 路径。
- `-- <command...>`:实际启动的子命令,例如 `codex`
`--account``--home` 互斥。没有命令参数时会报错。
### 运行机制
`src/run_cmd.rs``run_with_account_or_home` 只做三件事:
1. 解析目标 home。
2. 必要时调用 `account::prepare_account_in_home` 写入目标 home 的 `auth.json`
3. 启动子进程,并给子进程设置 `CODEX_HOME=<target_home>`
子进程环境被设置后,Codex 自己会从这个 home 读取 `auth.json` 和会话状态文件。父进程的 shell 环境不会被永久修改。
```mermaid
flowchart TD
A[cdxs run] --> B{使用 --home?}
B -->|是| C[resolve_home_for_run]
C --> D{home 是否绑定账号}
D -->|是| E[prepare_account_in_home]
D -->|否| F[使用 home.path]
E --> F
B -->|否| G{是否有 --account}
G -->|否| H[报错: run 需要 --account 或 --home]
G -->|是| I[paths::codex_home(--codex-home)]
I --> J[prepare_account_in_home]
J --> F
F --> K[Command::new]
K --> L[child.env CODEX_HOME=target_home]
L --> M[启动子命令并等待退出]
```
`prepare_account_in_home` 的流程是:从主 home 加载 `cdxs.toml`,按 ID、邮箱或邮箱前缀查找账号,调用 `token::refresh_account_if_needed` 按需刷新 OAuth token,然后把账号写入目标 home 的 `auth.json`,更新账号 `last_used_at`,最后保存主配置。
这里的设计重点是:账号库只存在于主 home 的 `cdxs.toml`,而运行时认证材料会被物化到目标 home 的 `auth.json`
## 会话管理
### Codex 会话状态来源
`src/session.rs` 认为 Codex 会话可见性由三类文件共同决定:
- `<home>/state_5.sqlite`SQLite 数据库,读取 `threads` 表。
- `<home>/session_index.jsonl`:会话选择器索引,每行 JSON 包含会话 ID 等信息。
- `<home>/sessions/``<home>/archived_sessions/`rollout JSONL 文件目录。
`SessionSummary` 由 SQLite 的 `threads` 行转换而来,包含 home 名称、home 路径、会话 ID、标题、cwd、更新时间、token 数、rollout 路径和 archived 标记。
### home 选择规则
会话命令都有 `--all-homes` 选项。底层通过 `homes(all_homes)` 选择扫描范围:
- 不传 `--all-homes`:只扫描当前解析出来的默认 home,名称固定为 `default`
-`--all-homes`:加载主 home 的 `cdxs.toml`,遍历 `Store.homes`,展开每个路径,并按路径去重。
```mermaid
flowchart TD
A[session 命令] --> B{--all-homes?}
B -->|否| C[只返回 paths::codex_home(None)]
B -->|是| D[Store::load(default_home)]
D --> E[遍历 store.homes]
E --> F[expand_home]
F --> G[按路径去重]
C --> H[扫描会话]
G --> H
```
### 会话命令入口
`src/cli.rs` 定义 `cdxs session` 子命令,`src/main.rs` 分发到 `session.rs`
- `cdxs session list [--all-homes] [--json]`:列出会话。
- `cdxs session stats <session_id> [--all-homes] [--json]`:显示单个会话的 token 和文件统计。
- `cdxs session trash <session_ids...> [--all-homes]`:把会话移入 `cdxs` 自己的垃圾箱。
- `cdxs session trash-list [--all-homes] [--json]`:列出垃圾箱条目。
- `cdxs session restore <session_ids...> [--all-homes]`:从垃圾箱恢复会话。
- `cdxs session visibility check [--all-homes] [--json]`:检查 SQLite、索引、rollout 的一致性。
- `cdxs session visibility repair [--all-homes] [--json]`:修复可见性问题。
- `cdxs session sync-threads [--all-homes] [--dry-run] [--json]`:在多个 home 之间补齐缺失线程。
### 列表与统计
`list_sessions` 遍历选中的 home,调用 `read_sessions_for_home` 打开 `<home>/state_5.sqlite`,读取 `threads` 表,然后按 `updated_at_ms` 倒序输出。若数据库不存在,该 home 返回空列表,不报错。
`session_stats` 先用 `find_thread` 查找指定会话,再解析对应 rollout 文件:
- 文件存在时读取大小和行数。
- 遍历 JSONL 行,递归查找最后一个 `total_token_usage`,提取 total、input、output token。
- SQLite 中的 `tokens_used` 和 rollout 中的 token 统计会分别显示。
### 垃圾箱与恢复
`trash_sessions` 对每个会话调用 `trash_one`。垃圾箱目录位于 `<home>/cdxs-trash/<timestamp>-<session_id>-<home_name>/`
移动到垃圾箱时会保存三类可恢复材料:
- rollout 文件副本,如果原 rollout 存在。
-`session_index.jsonl` 删除的原始行。
- SQLite `threads` 原始行,写入 `manifest.json`
随后代码会从 SQLite `threads` 表删除该会话,并删除原 rollout 文件。这样 Codex 不再能看到该会话。
```mermaid
sequenceDiagram
participant Cmd as cdxs session trash
participant S as session.rs
participant DB as state_5.sqlite
participant IDX as session_index.jsonl
participant Rollout as rollout jsonl
participant Trash as cdxs-trash
Cmd->>S: trash_sessions(ids)
S->>DB: find_thread(session_id)
S->>Trash: 创建垃圾箱目录
S->>Rollout: 复制 rollout 到垃圾箱
S->>IDX: 删除匹配 session_id 的索引行
S->>Trash: 写 manifest.json
S->>DB: DELETE FROM threads
S->>Rollout: 删除原 rollout 文件
```
`restore_sessions` 会扫描垃圾箱 manifest,找到匹配会话后调用 `restore_one`
1. 把 manifest 中保存的 `ThreadRowData` 插回 SQLite。
2. 把备份 rollout 复制回原始 rollout 路径。
3. 把保存的 `session_index.jsonl` 行追加回索引,已有同 ID 时不会重复追加。
4. 删除对应垃圾箱目录。
### 可见性检查与修复
`visibility_check` 会同时读取 SQLite thread、`session_index.jsonl` 和 rollout 文件,报告以下问题:
- `missing_rollout`SQLite 中有线程,但 `rollout_path` 指向的文件不存在。
- `missing_index`SQLite 中有线程,但 `session_index.jsonl` 没有该 ID。
- `orphan_index`:索引里有 ID,但 SQLite 没有线程。
- `orphan_rollout`rollout 文件里有 `session_meta`,但 SQLite 没有线程。
rollout 扫描只处理 `sessions/``archived_sessions/` 下的 `.jsonl` 文件,并只读取每个文件前 25 行查找 `type == "session_meta"` 的记录。
`visibility_repair` 的修复策略是以 SQLite 现有线程为优先来源:
- 如果 SQLite 线程的 rollout 路径不存在,但扫描到了同 ID 的 rollout,则更新 SQLite 中的 `rollout_path`
- 如果 SQLite 线程缺少索引行,则根据 thread 生成紧凑 JSONL 索引并追加。
- 如果存在 orphan rollout,则从 rollout 的 `session_meta` 构造一个最小 `ThreadRowData` 并插入 SQLite,同时补索引。
修复前会备份 `state_5.sqlite``session_index.jsonl``<home>/cdxs-backups/`
```mermaid
flowchart TD
A[visibility repair] --> B[读取 threads]
B --> C[读取 session_index.jsonl]
C --> D[扫描 sessions 和 archived_sessions]
D --> E{线程 rollout 缺失但找到同 ID rollout?}
E -->|是| F[备份状态文件并更新 rollout_path]
E -->|否| G[继续]
F --> G
G --> H{线程缺少索引?}
H -->|是| I[备份并追加索引行]
H -->|否| J[继续]
I --> J
J --> K{rollout 没有 SQLite 线程?}
K -->|是| L[从 session_meta 构造最小 thread 并插入]
K -->|否| M[结束]
L --> M
```
### 跨 home 补齐会话
`sync_threads` 用于在多个已管理 home 之间复制缺失的会话线程。它要求选中 home 数量至少为 2;通常需要配合 `--all-homes` 使用,否则只会选到默认 home 并报错。
运行过程:
1. 读取每个 home 的 SQLite threads 和索引 ID。
2. 按 session ID 建立全局映射,第一次看到的线程作为源。
3. 对每个目标 home,检查是否缺少某个 session ID。
4. 如果源 rollout 不存在,则记录 `skip`
5. 如果不是 `--dry-run`,则备份目标状态文件,复制 rollout,插入 SQLite thread,并按需追加索引行。
当目标路径上已经存在同名 rollout 时,`portable_rollout_path` 会把目标路径改为 `sessions/cdxs-sync/<source_home>/<file_name>`,避免覆盖目标 home 的已有文件。
```mermaid
sequenceDiagram
participant Cmd as cdxs session sync-threads
participant S as session.rs
participant Src as 源 home
participant Tgt as 目标 home
Cmd->>S: sync_threads(all_homes, dry_run, json)
S->>S: homes(all_homes) 并要求至少两个
S->>Src: 读取 threads 和 session_index
S->>Tgt: 读取 threads 和 session_index
S->>S: 第一次看到的 session 作为源
alt 目标缺少 session 且源 rollout 存在
S->>Tgt: 备份 state_5.sqlite 和 session_index.jsonl
S->>Tgt: 复制 rollout
S->>Tgt: INSERT OR REPLACE thread
S->>Tgt: 必要时追加 index
else dry-run
S->>S: 只记录 would_sync
else 源 rollout 缺失
S->>S: 记录 skip
end
```
## 安全性与持久化细节
配置保存和会话修复会尽量避免直接覆盖关键文件:
- `Store::save` 保存 `cdxs.toml` 前会先备份旧文件,再原子写入新文件。
- `remove_session_index_entries` 修改 `session_index.jsonl` 前会备份旧索引。
- `restore_session_index_entries` 恢复索引前会备份旧索引。
- `backup_state_files` 会备份 `state_5.sqlite``session_index.jsonl`,供修复和同步使用。
需要注意的是,`trash_one` 删除 SQLite 行和 rollout 文件本身不是一个数据库事务加文件事务的整体原子操作;它通过先写 manifest 和备份 rollout 来保证后续可以用 `restore` 尝试恢复。
+438
View File
@@ -0,0 +1,438 @@
# 配置同步/服务端
本文说明 `cdxs` 当前配置同步和同步服务端功能的实现方式、调用机制和运行过程。
## 功能范围
配置同步/服务端主要覆盖以下能力:
- 启动一个最小 HTTP 同步服务。
- 在服务端配置文件中添加或更新登录用户。
- 客户端使用用户名和密码登录同步服务。
- 客户端把本地可移植配置推送到服务端。
- 客户端从服务端拉取可移植配置并覆盖本地状态。
- 查看当前客户端同步配置状态。
- 使用 Docker 或 docker-compose 运行同步服务。
当前同步的数据只包含 `cdxs.toml` 中的可移植状态:
- `meta`
- `accounts`
- `homes`
不会同步客户端本地的 `sync` 配置,也不会把服务端的 `server.users``server.sessions` 下发给客户端。
## 核心文件
- `src/main.rs`CLI 入口,负责把 `server``sync` 命令分发到具体模块。
- `src/cli.rs`:定义同步服务端和客户端命令参数。
- `src/server.rs`:HTTP 同步服务端实现,包含用户管理、登录、鉴权、状态读写。
- `src/sync_client.rs`:客户端同步逻辑,包含登录、拉取、推送和状态查看。
- `src/config_store.rs``cdxs.toml` 数据模型、加载、保存和默认 home 初始化。
- `Dockerfile`:构建并运行 `cdxs server run` 的容器镜像。
- `docker-compose.yml`:使用具名 volume 持久化 `/data/cdxs.toml` 并暴露 `8765` 端口。
## 数据保存方式
同步功能复用 `Store` 结构,也就是 `cdxs.toml` 的配置模型。
客户端默认读取当前 `CODEX_HOME` 下的配置:
```text
<CODEX_HOME>\cdxs.toml
```
如果没有设置 `CODEX_HOME`,默认使用:
```text
%USERPROFILE%\.codex\cdxs.toml
```
服务端有两种数据路径:
- 如果 `server run --data <路径>` 被指定,服务端直接使用这个路径保存数据。
- 如果未指定 `--data`,服务端使用当前 `CODEX_HOME` 下的 `cdxs.toml`
`server user add` 也使用同一套路径规则,因此添加用户和启动服务必须指向同一个数据文件,才能让服务端读取到该用户。
## Store 中的相关字段
`Store` 中和同步相关的字段包括:
- `meta`:当前账号、当前 home 等元数据。
- `accounts`:账号列表。
- `homes`:受管理的 `CODEX_HOME` 列表。
- `server`:服务端用户和 bearer session,仅服务端使用。
- `sync`:客户端保存的服务端地址、用户名、token、最近拉取/推送时间。
服务端用户保存在:
```text
Store.server.users
```
服务端登录 session 保存在:
```text
Store.server.sessions
```
客户端同步配置保存在:
```text
Store.sync
```
## 服务端用户与 Token 原理
添加用户时,`server::add_user` 会生成随机 salt,并把密码保存为哈希值。
密码哈希方式:
```text
sha256(salt + ":" + password)
```
登录成功后,服务端生成一个随机 bearer token,并只保存 token 的哈希值。
token 哈希方式:
```text
sha256("cdxs-token:" + token)
```
原始 token 只在 `/v1/login` 响应中返回一次,由客户端保存到本地 `Store.sync.token`。后续 `pull``push` 使用:
```http
Authorization: Bearer <token>
```
服务端收到请求后,对请求中的 token 重新计算哈希,并检查是否存在于 `Store.server.sessions`
```mermaid
flowchart TD
A[server user add] --> B[生成 salt]
B --> C[计算 password_hash]
C --> D[写入 Store.server.users]
E[sync login] --> F[校验 username/password]
F --> G[生成 bearer token]
G --> H[保存 token_hash 到 Store.server.sessions]
H --> I[原始 token 返回给客户端]
J[sync pull/push] --> K[Authorization Bearer token]
K --> L[服务端计算 token_hash]
L --> M{sessions 中是否存在}
M -->|是| N[允许访问 /v1/state]
M -->|否| O[返回 401]
```
## HTTP API
同步服务端使用 `axum` 提供三个接口:
- `GET /health`:健康检查,返回 `ok`
- `POST /v1/login`:使用用户名和密码登录,返回 bearer token。
- `GET /v1/state`:鉴权后返回服务端保存的可移植状态。
- `PUT /v1/state`:鉴权后用客户端提交的可移植状态替换服务端状态。
`/v1/state` 的读写都会经过 `authorize` 鉴权。
服务端返回给客户端前会调用 `sanitized_for_client`,把以下字段清空:
- `server`
- `sync`
这样客户端不会拿到服务端用户、密码哈希、已签发 session,也不会拿到服务端自己的同步配置。
## 命令调用机制
同步相关命令由 `src/cli.rs` 定义,再由 `src/main.rs` 分发。
```mermaid
flowchart TD
A[用户执行 cdxs 命令] --> B[Cli::parse]
B --> C[src/main.rs match Commands]
C --> D{命令类型}
D -->|cdxs server run| E[server::run_server]
D -->|cdxs server user add| F[server::add_user]
D -->|cdxs sync login| G[sync_client::login]
D -->|cdxs sync pull| H[sync_client::pull]
D -->|cdxs sync push| I[sync_client::push]
D -->|cdxs sync status| J[sync_client::status]
```
## 添加服务端用户运行过程
命令:
```powershell
cdxs server user add <username> --password <password>
```
可选指定服务端数据文件:
```powershell
cdxs server user add <username> --password <password> --data <cdxs.toml路径>
```
运行过程:
1. 调用 `resolve_data_path` 解析服务端数据文件路径。
2. 调用 `Store::load_from_path` 加载服务端 `cdxs.toml`,文件不存在时生成默认 store。
3. 校验用户名和密码不能为空。
4. 生成随机 salt。
5. 使用 salt 和密码计算 `password_hash`
6. 如果用户已存在,替换原用户记录。
7. 如果用户不存在,追加到 `Store.server.users`
8. 调用 `Store::save_to_path` 保存服务端配置。
```mermaid
sequenceDiagram
participant U as 用户
participant M as main.rs
participant S as server.rs
participant C as config_store.rs
U->>M: cdxs server user add alice --password ***
M->>S: add_user(data, username, password)
S->>S: resolve_data_path
S->>C: Store::load_from_path
C-->>S: Store
S->>S: random_token 生成 salt
S->>S: hash_secret
S->>S: upsert ServerUser
S->>C: save_to_path
C-->>U: 用户已添加/更新
```
## 启动服务端运行过程
命令:
```powershell
cdxs server run
```
默认监听:
```text
127.0.0.1:8765
```
可选参数:
```powershell
cdxs server run --bind 0.0.0.0:8765
cdxs server run --data <cdxs.toml路径>
```
运行过程:
1. 调用 `resolve_data_path` 解析服务端数据文件和默认 home。
2. 解析 `--bind``SocketAddr`
3. 创建 `AppState`,保存数据路径、默认 home 和一个异步 `Mutex`
4. 注册 `/health``/v1/login``/v1/state` 路由。
5. 绑定 TCP listener。
6. 调用 `axum::serve` 持续处理请求。
服务端在每个会读写配置文件的请求中都会先获取 `AppState.lock`。这个锁用于串行化文件读写,避免并发请求同时读写同一个 `cdxs.toml`
```mermaid
flowchart TD
A[cdxs server run] --> B[resolve_data_path]
B --> C[解析 bind 地址]
C --> D[创建 AppState]
D --> E[注册 axum Router]
E --> F[绑定 TcpListener]
F --> G[axum::serve]
G --> H[处理 /health /v1/login /v1/state]
```
## 客户端登录运行过程
命令:
```powershell
cdxs sync login --server http://127.0.0.1:8765 --user alice --password <password>
```
运行过程:
1. 客户端规范化 server URL,去掉末尾 `/`
2.`<server>/v1/login` 发送 JSON 请求。
3. 服务端加载 `cdxs.toml`
4. 服务端按用户名查找 `Store.server.users`
5. 服务端校验密码哈希。
6. 登录成功后生成 bearer token。
7. 服务端保存 token 哈希到 `Store.server.sessions`
8. 客户端解析响应中的原始 token。
9. 客户端加载本地 `cdxs.toml`
10. 客户端写入 `Store.sync.server_url``Store.sync.username``Store.sync.token`
11. 客户端保存本地配置。
```mermaid
sequenceDiagram
participant U as 用户
participant C as sync_client.rs
participant H as HTTP
participant S as server.rs
participant FS as cdxs.toml
U->>C: cdxs sync login
C->>H: POST /v1/login username/password
H->>S: login_handler
S->>FS: 加载服务端 Store
S->>S: 校验 password_hash
S->>S: 生成 bearer token 并保存 token_hash
S->>FS: 保存服务端 Store
S-->>C: token
C->>FS: 加载本地 Store
C->>FS: 保存 sync.server_url / username / token
C-->>U: sync login 成功
```
## 拉取配置运行过程
命令:
```powershell
cdxs sync pull
```
运行过程:
1. 加载本地 `cdxs.toml`
2.`Store.sync` 读取 server URL 和 token。
3. 如果未登录或缺少 token,返回错误。
4.`<server>/v1/state` 发送 `GET` 请求,并附带 `Authorization` header。
5. 服务端校验 bearer token。
6. 服务端加载服务端 `Store`
7. 服务端清空 `server``sync` 字段后返回 JSON。
8. 客户端解析远端 `Store`
9. 客户端用远端 `meta``accounts``homes` 覆盖本地对应字段。
10. 客户端保留本地 `sync` 配置,并更新 `last_pull_at`
11. 保存本地 `cdxs.toml`
```mermaid
flowchart TD
A[cdxs sync pull] --> B[加载本地 Store]
B --> C[sync_endpoint 读取 server/token]
C --> D[GET /v1/state]
D --> E[服务端 authorize]
E --> F[服务端 sanitized_for_client]
F --> G[返回远端 Store]
G --> H[覆盖本地 meta/accounts/homes]
H --> I[更新 last_pull_at]
I --> J[保存本地 cdxs.toml]
```
## 推送配置运行过程
命令:
```powershell
cdxs sync push
```
运行过程:
1. 加载本地 `cdxs.toml`
2.`Store.sync` 读取 server URL 和 token。
3. 克隆本地 `Store` 作为上传 payload。
4. 上传前把 payload 中的 `server``sync` 字段清空。
5.`<server>/v1/state` 发送 `PUT` 请求,并附带 `Authorization` header。
6. 服务端校验 bearer token。
7. 服务端加载原服务端 `Store`
8. 服务端只用 payload 中的 `meta``accounts``homes` 替换服务端状态。
9. 服务端把 `sync` 置为默认值,保留原服务端的 `server.users``server.sessions`
10. 服务端保存配置。
11. 客户端更新本地 `last_push_at`
12. 客户端保存本地 `cdxs.toml`
```mermaid
sequenceDiagram
participant C as sync_client.rs
participant S as server.rs
participant FS as cdxs.toml
C->>FS: 加载本地 Store
C->>C: payload.server/default, payload.sync/default
C->>S: PUT /v1/state + Bearer token
S->>FS: 加载服务端 Store
S->>S: authorize
S->>S: 替换 meta/accounts/homes
S->>FS: 保存服务端 Store
S-->>C: sanitized Store
C->>FS: 更新 last_push_at 并保存
```
## 查看同步状态
命令:
```powershell
cdxs sync status
```
输出内容来自本地 `Store.sync`
- `server`
- `user`
- `token`
- `last_pull_at`
- `last_push_at`
如果本地保存了 token,输出只显示:
```text
<stored>
```
不会打印 token 明文。
## Docker 运行方式
`Dockerfile` 使用两阶段构建:
1. 使用 `rust:1-bookworm` 构建 release 版本 `cdxs`
2. 使用 `debian:bookworm-slim` 作为运行镜像。
3. 安装 `ca-certificates`
4.`cdxs` 复制到 `/usr/local/bin/cdxs`
5. 暴露 `8765` 端口。
6. 声明 `/data` volume。
容器默认命令:
```text
cdxs server run --bind 0.0.0.0:8765 --data /data/cdxs.toml
```
`docker-compose.yml` 会:
- 构建当前目录镜像。
- 将容器命名为 `cdxs-server`
- 设置 `restart: unless-stopped`
- 映射宿主机 `8765` 到容器 `8765`
- 使用具名 volume `cdxs-data` 挂载到 `/data`
## 写入安全机制
服务端和客户端保存 `cdxs.toml` 都调用 `Store::save``Store::save_to_path`
保存时会:
1. 如果目标文件已存在,先备份到对应 home 下的 `cdxs-backups`
2.`Store` 序列化为 TOML。
3. 写入同目录临时文件。
4. 使用 rename 替换目标文件。
服务端请求还会使用 `tokio::sync::Mutex` 串行化文件读写,降低并发请求导致状态覆盖或文件损坏的风险。
## 当前实现边界
- 同步没有字段级合并或冲突解决,`pull` 会用远端 `meta/accounts/homes` 覆盖本地,`push` 会用本地 `meta/accounts/homes` 覆盖服务端。
- bearer session 当前没有过期时间和撤销命令。
- 服务端用户只有添加/更新命令,没有删除、列表或改密命令。
- 服务端没有多租户隔离;所有通过鉴权的用户访问同一份服务端状态。
- HTTP 服务没有在代码中直接配置 TLS,需要由外部反向代理或部署环境处理。
- `sync push` 不上传客户端本地 `sync` token,也不上传客户端本地 `server` 配置。
- `sync pull` 不会写入远端 `server` 或远端 `sync` 字段,只更新本地可移植状态并保留本地同步登录信息。
+31
View File
@@ -0,0 +1,31 @@
# Features
- 保存和管理多个 Codex OAuth 账号。
- 保存和管理多个 Codex API Key 账号。
- 从现有 Codex `auth.json` 导入账号。
- 通过 OpenAI OAuth 登录并保存 Codex token。
- 将指定账号切换写入 Codex `auth.json`
- 自动刷新 OAuth access token。
- 查看已保存账号列表和当前账号。
- 查看指定账号详情。
- 删除已保存账号。
- 查询 OAuth 账号 Codex 使用配额。
- 支持配额结果 JSON 输出。
- 创建和管理多个独立 `CODEX_HOME`
- 将 home 绑定到指定账号。
- 用指定账号或 home 启动外部命令。
- 查看 Codex 会话列表。
- 查看单个会话 token 和 rollout 统计。
- 将会话移入可恢复垃圾箱。
- 列出垃圾箱中的会话。
- 从垃圾箱恢复会话。
- 检查 Codex 会话可见性问题。
- 修复缺失的会话索引或 SQLite 线程记录。
- 在多个受管理 home 之间同步缺失会话线程。
- 运行内置 HTTP 同步服务。
- 添加或更新同步服务用户。
- 登录同步服务并保存客户端 token。
- 将本地 `cdxs.toml` 推送到同步服务。
- 从同步服务拉取远端 `cdxs.toml` 状态。
- 查看当前同步配置状态。
- 提供 Docker 和 docker-compose 部署同步服务。
+474
View File
@@ -0,0 +1,474 @@
//! Account and managed `CODEX_HOME` operations.
//!
//! This module owns the high-level workflows that mutate `cdxs.toml` and
//! Codex `auth.json`: importing credentials, switching accounts, creating
//! named homes, and preparing an isolated home before running Codex.
use std::path::PathBuf;
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use sha2::{Digest, Sha256};
use crate::auth_file;
use crate::config_store::{self, Account, AuthMode, Home, Store, Tokens};
use crate::{jwt, paths, token};
pub fn import_auth(file: Option<PathBuf>, codex_home: Option<PathBuf>, switch: bool) -> Result<()> {
// Store imported credentials in the main cdxs config, even when the source
// auth file came from another CODEX_HOME.
let config_home = paths::codex_home(None)?;
let source_home = paths::codex_home(codex_home)?;
let auth_path = file.unwrap_or_else(|| paths::auth_path(&source_home));
let auth = auth_file::read_auth_file(&auth_path)?;
let mut store = Store::load(&config_home)?;
let account = account_from_auth(auth)?;
let id = account.id.clone();
let email = account.email.clone();
store.upsert_account(account);
if switch {
store.meta.current_account_id = Some(id.clone());
let account = store.find_account(&id).expect("just inserted account");
auth_file::write_account_to_auth(&paths::auth_path(&source_home), &source_home, account)?;
}
store.save(&config_home)?;
println!("已导入账号: {email} ({id})");
Ok(())
}
pub fn add_api_key(key: String, base_url: Option<String>, switch: bool) -> Result<()> {
let home = paths::codex_home(None)?;
let mut store = Store::load(&home)?;
let account = api_key_account(key, base_url)?;
let id = account.id.clone();
let email = account.email.clone();
store.upsert_account(account);
if switch {
store.meta.current_account_id = Some(id.clone());
let account = store.find_account(&id).expect("just inserted account");
auth_file::write_account_to_auth(&paths::auth_path(&home), &home, account)?;
}
store.save(&home)?;
println!("已保存 API Key 账号: {email} ({id})");
Ok(())
}
pub fn list_accounts(json: bool) -> Result<()> {
let home = paths::codex_home(None)?;
let store = Store::load(&home)?;
if json {
println!("{}", serde_json::to_string_pretty(&store.accounts)?);
return Ok(());
}
if store.accounts.is_empty() {
println!("没有保存的账号。可使用 `cdxs import auth` 导入。");
return Ok(());
}
println!(
"{:<3} {:<22} {:<34} {:<10} {:<12} {}",
"", "ID", "Email", "Mode", "Plan", "Quota"
);
for account in &store.accounts {
let current = if store.meta.current_account_id.as_deref() == Some(account.id.as_str()) {
"*"
} else {
""
};
let quota = account
.quota
.as_ref()
.map(|q| {
format!(
"5h={}%, weekly={}%",
q.primary_remaining_percent, q.secondary_remaining_percent
)
})
.unwrap_or_else(|| "-".to_string());
println!(
"{:<3} {:<22} {:<34} {:<10} {:<12} {}",
current,
shorten(&account.id, 22),
shorten(&account.email, 34),
auth_file::account_auth_mode_name(account),
account.plan_type.as_deref().unwrap_or("-"),
quota
);
}
Ok(())
}
pub fn current_account(json: bool) -> Result<()> {
let home = paths::codex_home(None)?;
let store = Store::load(&home)?;
let Some(id) = store.meta.current_account_id.as_deref() else {
println!("当前未设置账号。");
return Ok(());
};
let account = store
.find_account(id)
.ok_or_else(|| anyhow!("current_account_id 指向不存在的账号: {id}"))?;
if json {
println!("{}", serde_json::to_string_pretty(account)?);
} else {
print_account(account);
}
Ok(())
}
pub fn show_account(query: &str, json: bool) -> Result<()> {
let home = paths::codex_home(None)?;
let store = Store::load(&home)?;
let account = store
.find_account(query)
.ok_or_else(|| anyhow!("账号不存在: {query}"))?;
if json {
println!("{}", serde_json::to_string_pretty(account)?);
} else {
print_account(account);
}
Ok(())
}
pub fn remove_account(query: &str) -> Result<()> {
let home = paths::codex_home(None)?;
let mut store = Store::load(&home)?;
let id = store
.find_account(query)
.ok_or_else(|| anyhow!("账号不存在: {query}"))?
.id
.clone();
store.accounts.retain(|account| account.id != id);
if store.meta.current_account_id.as_deref() == Some(id.as_str()) {
store.meta.current_account_id = None;
}
for home in &mut store.homes {
if home.bound_account_id.as_deref() == Some(id.as_str()) {
home.bound_account_id = None;
}
}
store.save(&home)?;
println!("已删除账号: {id}");
Ok(())
}
pub async fn switch_account(
query: &str,
codex_home: Option<PathBuf>,
apply_fingerprint: bool,
) -> Result<()> {
if apply_fingerprint {
eprintln!("提示: M1 尚未实现设备指纹应用,已忽略 --apply-fingerprint。");
}
// The saved account list is always loaded from the main cdxs config, while
// the target auth.json can be redirected with --codex-home.
let config_home = paths::codex_home(None)?;
let target_home = paths::codex_home(codex_home)?;
let mut store = Store::load(&config_home)?;
let account_id = store
.find_account(query)
.ok_or_else(|| anyhow!("账号不存在: {query}"))?
.id
.clone();
token::refresh_account_if_needed(&mut store, &account_id).await?;
let account = store
.find_account(&account_id)
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?;
auth_file::write_account_to_auth(&paths::auth_path(&target_home), &target_home, account)?;
if let Some(account) = store.find_account_mut(&account_id) {
account.last_used_at = Utc::now().timestamp();
}
store.meta.current_account_id = Some(account_id.clone());
store.save(&config_home)?;
println!("已切换 Codex 账号: {account_id}");
Ok(())
}
pub fn list_homes(json: bool) -> Result<()> {
let home = paths::codex_home(None)?;
let store = Store::load(&home)?;
if json {
println!("{}", serde_json::to_string_pretty(&store.homes)?);
return Ok(());
}
for item in &store.homes {
let current = if store.meta.current_home.as_deref() == Some(item.name.as_str()) {
"*"
} else {
" "
};
println!(
"{} {:<18} {:<48} {}",
current,
item.name,
item.path,
item.bound_account_id.as_deref().unwrap_or("-")
);
}
Ok(())
}
pub fn create_home(name: &str, path: PathBuf, account: Option<String>) -> Result<()> {
let home = paths::codex_home(None)?;
let mut store = Store::load(&home)?;
if store.homes.iter().any(|item| item.name == name) {
return Err(anyhow!("home 已存在: {name}"));
}
let bound_account_id = if let Some(query) = account {
Some(
store
.find_account(&query)
.ok_or_else(|| anyhow!("账号不存在: {query}"))?
.id
.clone(),
)
} else {
None
};
let path = paths::expand_home(path);
std::fs::create_dir_all(&path)
.with_context(|| format!("创建 CODEX_HOME 目录失败: {}", path.display()))?;
// When an account is bound at creation time, make the new home immediately
// runnable by writing its auth.json now.
if let Some(account_id) = bound_account_id.as_deref() {
let account = store.find_account(account_id).expect("checked account");
auth_file::write_account_to_auth(&paths::auth_path(&path), &path, account)?;
}
store.homes.push(Home {
name: name.to_string(),
path: config_store::path_to_string(&path),
bound_account_id,
});
store.save(&home)?;
println!("已创建 home: {name} -> {}", path.display());
Ok(())
}
pub fn bind_home(name: &str, account: &str) -> Result<()> {
let home = paths::codex_home(None)?;
let mut store = Store::load(&home)?;
let account_id = store
.find_account(account)
.ok_or_else(|| anyhow!("账号不存在: {account}"))?
.id
.clone();
let target_home = store
.homes
.iter_mut()
.find(|item| item.name == name)
.ok_or_else(|| anyhow!("home 不存在: {name}"))?;
target_home.bound_account_id = Some(account_id.clone());
store.save(&home)?;
println!("已绑定 home {name} -> {account_id}");
Ok(())
}
pub fn home_path(name: &str) -> Result<()> {
let home = paths::codex_home(None)?;
let store = Store::load(&home)?;
let item = store
.find_home(name)
.ok_or_else(|| anyhow!("home 不存在: {name}"))?;
println!("{}", item.path);
Ok(())
}
pub fn remove_home(name: &str) -> Result<()> {
if name == "default" {
return Err(anyhow!("不能删除 default home"));
}
let home = paths::codex_home(None)?;
let mut store = Store::load(&home)?;
let before = store.homes.len();
store.homes.retain(|item| item.name != name);
if store.homes.len() == before {
return Err(anyhow!("home 不存在: {name}"));
}
store.save(&home)?;
println!("已删除 home: {name}");
Ok(())
}
pub async fn prepare_account_in_home(query: &str, codex_home: PathBuf) -> Result<String> {
// Used by `cdxs run`: refresh the account if needed, write auth.json into
// the selected home, then let the caller execute a child process there.
let main_home = paths::codex_home(None)?;
let mut store = Store::load(&main_home)?;
let account_id = store
.find_account(query)
.ok_or_else(|| anyhow!("账号不存在: {query}"))?
.id
.clone();
token::refresh_account_if_needed(&mut store, &account_id).await?;
let (account_id, email) = {
let account = store
.find_account(&account_id)
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?;
auth_file::write_account_to_auth(&paths::auth_path(&codex_home), &codex_home, account)?;
(account.id.clone(), account.email.clone())
};
if let Some(account) = store.find_account_mut(&account_id) {
account.last_used_at = Utc::now().timestamp();
}
store.save(&main_home)?;
Ok(email)
}
pub fn resolve_home_for_run(name: &str) -> Result<(PathBuf, Option<String>)> {
let main_home = paths::codex_home(None)?;
let store = Store::load(&main_home)?;
let home = store
.find_home(name)
.ok_or_else(|| anyhow!("home 不存在: {name}"))?;
Ok((
paths::expand_home(PathBuf::from(&home.path)),
home.bound_account_id.clone(),
))
}
fn account_from_auth(auth: auth_file::CodexAuthFile) -> Result<Account> {
// Codex auth.json can represent either API-key mode or OAuth-token mode.
// Normalize both shapes into one stored Account record.
if auth_file::is_api_key_mode(&auth) {
let key = auth_file::extract_api_key(&auth)
.ok_or_else(|| anyhow!("auth.json 缺少 OPENAI_API_KEY"))?;
return api_key_account(key, auth_file::api_base_url(&auth));
}
let tokens = auth
.tokens
.ok_or_else(|| anyhow!("auth.json 缺少 tokens 或 OPENAI_API_KEY"))?;
let account_id_hint = tokens.account_id.clone();
let store_tokens = auth_file::auth_tokens_to_store(tokens);
oauth_account(store_tokens, account_id_hint)
}
pub fn upsert_oauth_tokens(
tokens: Tokens,
account_id_hint: Option<String>,
switch: bool,
) -> Result<Account> {
let home = paths::codex_home(None)?;
let mut store = Store::load(&home)?;
let account = oauth_account(tokens, account_id_hint)?;
let saved = account.clone();
store.upsert_account(account);
if switch {
store.meta.current_account_id = Some(saved.id.clone());
let account = store
.find_account(&saved.id)
.expect("just inserted account");
auth_file::write_account_to_auth(&paths::auth_path(&home), &home, account)?;
}
store.save(&home)?;
Ok(saved)
}
fn oauth_account(tokens: Tokens, account_id_hint: Option<String>) -> Result<Account> {
// The id_token carries email, plan and organization hints. Account ids are
// deterministic so re-importing the same auth updates the existing record.
let payload = jwt::decode_payload(&tokens.id_token)?;
let auth = payload.auth.clone();
let email = payload
.email
.or_else(|| payload.sub.map(|sub| format!("{sub}@unknown.local")))
.unwrap_or_else(|| "unknown@unknown.local".to_string());
let account_id =
account_id_hint.or_else(|| auth.as_ref().and_then(|item| item.account_id.clone()));
let organization_id = auth.as_ref().and_then(|item| item.organization_id.clone());
let plan_type = auth
.as_ref()
.and_then(|item| item.chatgpt_plan_type.clone());
let id = stable_id(
"oauth",
&email,
account_id.as_deref(),
organization_id.as_deref(),
);
let now = Utc::now().timestamp();
Ok(Account {
id,
email,
auth_mode: AuthMode::Oauth,
plan_type,
account_id,
organization_id,
tokens: Some(tokens),
openai_api_key: None,
api_base_url: None,
quota: None,
fingerprint_id: None,
created_at: now,
updated_at: now,
last_used_at: now,
requires_reauth: false,
})
}
fn api_key_account(key: String, base_url: Option<String>) -> Result<Account> {
let key = key.trim();
if key.is_empty() {
return Err(anyhow!("API Key 不能为空"));
}
let id = stable_id("apikey", key, base_url.as_deref(), None);
let email = format!("api-key-{}", &id[id.len().saturating_sub(8)..]);
let now = Utc::now().timestamp();
Ok(Account {
id,
email,
auth_mode: AuthMode::ApiKey,
plan_type: Some("API_KEY".to_string()),
account_id: None,
organization_id: None,
tokens: None,
openai_api_key: Some(key.to_string()),
api_base_url: base_url,
quota: None,
fingerprint_id: None,
created_at: now,
updated_at: now,
last_used_at: now,
requires_reauth: false,
})
}
fn stable_id(kind: &str, a: &str, b: Option<&str>, c: Option<&str>) -> String {
// Avoid storing secrets in ids while still making repeated imports stable.
let mut hasher = Sha256::new();
hasher.update(kind.as_bytes());
hasher.update([0]);
hasher.update(a.as_bytes());
hasher.update([0]);
hasher.update(b.unwrap_or_default().as_bytes());
hasher.update([0]);
hasher.update(c.unwrap_or_default().as_bytes());
let hex = hex::encode(hasher.finalize());
format!("{kind}_{}", &hex[..16])
}
fn shorten(value: &str, width: usize) -> String {
if value.chars().count() <= width {
return value.to_string();
}
if width <= 1 {
return "".to_string();
}
let mut out = value.chars().take(width - 1).collect::<String>();
out.push('…');
out
}
fn print_account(account: &Account) {
println!("id: {}", account.id);
println!("email: {}", account.email);
println!("auth_mode: {}", auth_file::account_auth_mode_name(account));
println!("plan_type: {}", account.plan_type.as_deref().unwrap_or("-"));
println!(
"account_id: {}",
account.account_id.as_deref().unwrap_or("-")
);
println!(
"organization_id: {}",
account.organization_id.as_deref().unwrap_or("-")
);
println!("requires_reauth: {}", account.requires_reauth);
}
+53
View File
@@ -0,0 +1,53 @@
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::Utc;
/// Write a file through a sibling temporary file and then rename it into place.
/// This keeps auth/config files from being left half-written after failures.
pub fn write_atomic(path: &Path, content: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
}
let tmp = temp_path(path);
fs::write(&tmp, content).with_context(|| format!("写入临时文件失败: {}", tmp.display()))?;
fs::rename(&tmp, path).with_context(|| {
format!(
"替换目标文件失败: tmp={}, target={}",
tmp.display(),
path.display()
)
})?;
Ok(())
}
/// Backup an existing file into `<codex_home>/cdxs-backups/`.
pub fn backup_if_exists(path: &Path, codex_home: &Path, label: &str) -> Result<Option<PathBuf>> {
if !path.exists() {
return Ok(None);
}
let backup_dir = codex_home.join("cdxs-backups");
fs::create_dir_all(&backup_dir)
.with_context(|| format!("创建备份目录失败: {}", backup_dir.display()))?;
let stamp = Utc::now().format("%Y%m%d-%H%M%S%.3f");
let backup = backup_dir.join(format!("{label}-{stamp}.bak"));
fs::copy(path, &backup).with_context(|| {
format!(
"备份文件失败: source={}, backup={}",
path.display(),
backup.display()
)
})?;
Ok(Some(backup))
}
fn temp_path(path: &Path) -> PathBuf {
let file_name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("cdxs.tmp");
path.with_file_name(format!(".{file_name}.tmp"))
}
+122
View File
@@ -0,0 +1,122 @@
//! Read and write Codex `auth.json`.
//!
//! The file format is not owned by cdxs, so this module keeps the compatibility
//! boundary small and converts between Codex's JSON shape and cdxs account
//! records.
use std::path::Path;
use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};
use crate::config_store::{Account, AuthMode, Tokens};
use crate::{atomic, config_store};
#[derive(Debug, Deserialize)]
pub struct CodexAuthFile {
/// Present for API-key based auth files.
#[serde(default)]
pub auth_mode: Option<String>,
#[serde(rename = "OPENAI_API_KEY", default)]
pub openai_api_key: Option<serde_json::Value>,
#[serde(default, alias = "api_base_url", alias = "apiBaseUrl")]
pub base_url: Option<String>,
#[serde(default)]
pub tokens: Option<CodexAuthTokens>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CodexAuthTokens {
pub id_token: String,
pub access_token: String,
#[serde(default)]
pub refresh_token: Option<String>,
#[serde(default)]
pub account_id: Option<String>,
}
pub fn read_auth_file(path: &Path) -> Result<CodexAuthFile> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("读取 auth.json 失败: {}", path.display()))?;
serde_json::from_str(&content)
.with_context(|| format!("解析 auth.json 失败: {}", path.display()))
}
pub fn write_account_to_auth(path: &Path, codex_home: &Path, account: &Account) -> Result<()> {
// Preserve Codex's expected auth.json shape for each auth mode. Existing
// files are backed up before replacement by the atomic helper.
let value = match account.auth_mode {
AuthMode::Oauth => {
let tokens = account
.tokens
.as_ref()
.ok_or_else(|| anyhow!("OAuth 账号缺少 tokens"))?;
serde_json::json!({
"OPENAI_API_KEY": null,
"tokens": {
"id_token": tokens.id_token,
"access_token": tokens.access_token,
"refresh_token": tokens.refresh_token,
"account_id": account.account_id,
},
"last_refresh": chrono::Utc::now().to_rfc3339(),
})
}
AuthMode::ApiKey => {
let key = account
.openai_api_key
.as_deref()
.ok_or_else(|| anyhow!("API Key 账号缺少 OPENAI_API_KEY"))?;
serde_json::json!({
"auth_mode": "apikey",
"OPENAI_API_KEY": key,
})
}
};
atomic::backup_if_exists(path, codex_home, "auth.json")?;
let content = serde_json::to_string_pretty(&value).context("序列化 auth.json 失败")?;
atomic::write_atomic(path, &content)
}
pub fn is_api_key_mode(auth: &CodexAuthFile) -> bool {
// Some auth files explicitly say apikey, while older/minimal files simply
// omit tokens and include OPENAI_API_KEY.
auth.auth_mode
.as_deref()
.map(|mode| mode.eq_ignore_ascii_case("apikey"))
.unwrap_or(false)
|| (auth.tokens.is_none() && extract_api_key(auth).is_some())
}
pub fn extract_api_key(auth: &CodexAuthFile) -> Option<String> {
auth.openai_api_key
.as_ref()
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
pub fn auth_tokens_to_store(tokens: CodexAuthTokens) -> Tokens {
Tokens {
id_token: tokens.id_token,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
}
}
pub fn api_base_url(auth: &CodexAuthFile) -> Option<String> {
auth.base_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.trim_end_matches('/').to_string())
}
pub fn account_auth_mode_name(account: &config_store::Account) -> &'static str {
match account.auth_mode {
AuthMode::Oauth => "oauth",
AuthMode::ApiKey => "api_key",
}
}
+274
View File
@@ -0,0 +1,274 @@
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand};
#[derive(Parser)]
#[command(name = "cdxs", version, about = "Codex Switch CLI")]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand)]
pub enum Commands {
/// Login to Codex through OpenAI OAuth.
Login {
#[command(subcommand)]
command: LoginCommands,
},
/// Import an existing Codex auth file.
Import {
#[command(subcommand)]
command: ImportCommands,
},
/// List saved accounts.
List {
#[arg(long)]
json: bool,
},
/// Switch Codex auth.json to a saved account.
Switch {
account: String,
#[arg(long)]
codex_home: Option<PathBuf>,
#[arg(long)]
apply_fingerprint: bool,
},
/// Prepare auth, set CODEX_HOME, and execute a command.
Run(RunArgs),
/// Refresh and display Codex quota.
Quota {
account: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
json: bool,
},
/// Refresh OAuth tokens for a saved account.
RefreshToken { account: String },
/// Account management commands.
Account {
#[command(subcommand)]
command: AccountCommands,
},
/// Managed CODEX_HOME commands.
Home {
#[command(subcommand)]
command: HomeCommands,
},
/// Run or manage the cdxs sync server.
Server {
#[command(subcommand)]
command: ServerCommands,
},
/// Sync local cdxs.toml with a server.
Sync {
#[command(subcommand)]
command: SyncCommands,
},
/// Inspect and manage Codex sessions.
Session {
#[command(subcommand)]
command: SessionCommands,
},
}
#[derive(Subcommand)]
pub enum LoginCommands {
/// Start OpenAI OAuth login for Codex.
Oauth {
#[arg(long)]
manual: bool,
#[arg(long, default_value_t = 1455)]
port: u16,
#[arg(long)]
switch: bool,
},
}
#[derive(Subcommand)]
pub enum ImportCommands {
/// Import OAuth/API-key auth from auth.json.
Auth {
#[arg(long)]
file: Option<PathBuf>,
#[arg(long)]
codex_home: Option<PathBuf>,
#[arg(long)]
switch: bool,
},
}
#[derive(Subcommand)]
pub enum AccountCommands {
List {
#[arg(long)]
json: bool,
},
Current {
#[arg(long)]
json: bool,
},
Show {
account: String,
#[arg(long)]
json: bool,
},
Remove {
account: String,
},
AddApiKey {
#[arg(long)]
key: String,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
switch: bool,
},
}
#[derive(Subcommand)]
pub enum HomeCommands {
List {
#[arg(long)]
json: bool,
},
Create {
name: String,
#[arg(long)]
path: PathBuf,
#[arg(long)]
account: Option<String>,
},
Bind {
name: String,
account: String,
},
Path {
name: String,
},
Remove {
name: String,
},
}
#[derive(Subcommand)]
pub enum ServerCommands {
Run {
#[arg(long, default_value = "127.0.0.1:8765")]
bind: String,
#[arg(long)]
data: Option<PathBuf>,
},
User {
#[command(subcommand)]
command: ServerUserCommands,
},
}
#[derive(Subcommand)]
pub enum ServerUserCommands {
Add {
username: String,
#[arg(long)]
password: String,
#[arg(long)]
data: Option<PathBuf>,
},
}
#[derive(Subcommand)]
pub enum SyncCommands {
Login {
#[arg(long)]
server: String,
#[arg(long)]
user: String,
#[arg(long)]
password: String,
},
Pull,
Push,
Status,
}
#[derive(Subcommand)]
pub enum SessionCommands {
/// List Codex sessions from state_5.sqlite.
List {
#[arg(long)]
all_homes: bool,
#[arg(long)]
json: bool,
},
/// Show token and file statistics for one session.
Stats {
session_id: String,
#[arg(long)]
all_homes: bool,
#[arg(long)]
json: bool,
},
/// Move sessions into the cdxs trash and hide them from Codex.
Trash {
session_ids: Vec<String>,
#[arg(long)]
all_homes: bool,
},
/// List sessions stored in cdxs trash.
TrashList {
#[arg(long)]
all_homes: bool,
#[arg(long)]
json: bool,
},
/// Restore sessions from cdxs trash.
Restore {
session_ids: Vec<String>,
#[arg(long)]
all_homes: bool,
},
/// Check or repair Codex session visibility.
Visibility {
#[command(subcommand)]
command: SessionVisibilityCommands,
},
/// Copy missing session threads across managed CODEX_HOME directories.
SyncThreads {
#[arg(long)]
all_homes: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand)]
pub enum SessionVisibilityCommands {
Check {
#[arg(long)]
all_homes: bool,
#[arg(long)]
json: bool,
},
Repair {
#[arg(long)]
all_homes: bool,
#[arg(long)]
json: bool,
},
}
#[derive(Args)]
pub struct RunArgs {
#[arg(long, conflicts_with = "home")]
pub account: Option<String>,
#[arg(long, conflicts_with = "account")]
pub home: Option<String>,
#[arg(long)]
pub codex_home: Option<PathBuf>,
/// Command after `--`, for example: cdxs run --account me -- codex
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
pub command: Vec<String>,
}
+250
View File
@@ -0,0 +1,250 @@
//! Persistent cdxs configuration model.
//!
//! `Store` is serialized to `cdxs.toml`. It intentionally contains only the
//! portable state cdxs manages: accounts, named homes, sync settings and server
//! credentials when this instance is used as a sync server.
use std::path::Path;
use anyhow::{Context, Result};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::{atomic, paths};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Store {
/// Schema and current-selection metadata.
#[serde(default)]
pub meta: Meta,
#[serde(default)]
pub accounts: Vec<Account>,
#[serde(default)]
pub homes: Vec<Home>,
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub sync: SyncConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Meta {
#[serde(default = "default_version")]
pub version: u32,
#[serde(default)]
pub current_account_id: Option<String>,
#[serde(default)]
pub current_home: Option<String>,
}
impl Default for Meta {
fn default() -> Self {
Self {
version: default_version(),
current_account_id: None,
current_home: Some("default".to_string()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Account {
/// Stable, deterministic id generated from non-displayed credential traits.
pub id: String,
pub email: String,
pub auth_mode: AuthMode,
#[serde(default)]
pub plan_type: Option<String>,
#[serde(default)]
pub account_id: Option<String>,
#[serde(default)]
pub organization_id: Option<String>,
#[serde(default)]
pub tokens: Option<Tokens>,
#[serde(default)]
pub openai_api_key: Option<String>,
#[serde(default)]
pub api_base_url: Option<String>,
#[serde(default)]
pub quota: Option<Quota>,
#[serde(default)]
pub fingerprint_id: Option<String>,
pub created_at: i64,
pub updated_at: i64,
pub last_used_at: i64,
#[serde(default)]
pub requires_reauth: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AuthMode {
Oauth,
ApiKey,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tokens {
pub id_token: String,
pub access_token: String,
#[serde(default)]
pub refresh_token: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Quota {
pub primary_remaining_percent: i32,
#[serde(default)]
pub primary_reset_time: Option<i64>,
pub secondary_remaining_percent: i32,
#[serde(default)]
pub secondary_reset_time: Option<i64>,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Home {
pub name: String,
pub path: String,
#[serde(default)]
pub bound_account_id: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ServerConfig {
#[serde(default)]
pub users: Vec<ServerUser>,
#[serde(default)]
pub sessions: Vec<ServerSession>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerUser {
pub username: String,
pub salt: String,
pub password_hash: String,
pub created_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerSession {
pub username: String,
pub token_hash: String,
pub created_at: i64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SyncConfig {
#[serde(default)]
pub server_url: Option<String>,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub last_pull_at: Option<i64>,
#[serde(default)]
pub last_push_at: Option<i64>,
}
impl Store {
/// Load cdxs.toml, creating an in-memory default store when it is missing.
pub fn load(codex_home: &Path) -> Result<Self> {
let path = paths::config_path(codex_home);
Self::load_from_path(&path, codex_home)
}
pub fn load_from_path(path: &Path, default_home: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::with_default_home(default_home));
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("读取配置失败: {}", path.display()))?;
if content.trim().is_empty() {
return Ok(Self::with_default_home(default_home));
}
let mut store: Store = toml::from_str(&content)
.with_context(|| format!("解析配置失败: {}", path.display()))?;
store.ensure_default_home(default_home);
Ok(store)
}
pub fn save(&self, codex_home: &Path) -> Result<()> {
let path = paths::config_path(codex_home);
self.save_to_path(&path, codex_home)
}
pub fn save_to_path(&self, path: &Path, backup_home: &Path) -> Result<()> {
// Back up before every save because this file can contain all accounts.
atomic::backup_if_exists(path, backup_home, "cdxs.toml")?;
let content = toml::to_string_pretty(self).context("序列化 cdxs.toml 失败")?;
atomic::write_atomic(path, &content)?;
Ok(())
}
pub fn upsert_account(&mut self, mut account: Account) {
// Preserve original creation time when replacing an existing account.
let now = Utc::now().timestamp();
account.updated_at = now;
if let Some(existing) = self.accounts.iter_mut().find(|item| item.id == account.id) {
let created_at = existing.created_at;
*existing = account;
existing.created_at = created_at;
} else {
self.accounts.push(account);
}
}
pub fn find_account(&self, query: &str) -> Option<&Account> {
// Commands accept exact id, exact email, or email prefix for convenience.
let query_lower = query.to_ascii_lowercase();
self.accounts.iter().find(|account| {
account.id == query
|| account.email.eq_ignore_ascii_case(query)
|| account
.email
.to_ascii_lowercase()
.starts_with(query_lower.as_str())
})
}
pub fn find_account_mut(&mut self, query: &str) -> Option<&mut Account> {
let id = self.find_account(query)?.id.clone();
self.accounts.iter_mut().find(|account| account.id == id)
}
pub fn find_home(&self, name: &str) -> Option<&Home> {
self.homes.iter().find(|home| home.name == name)
}
fn with_default_home(codex_home: &Path) -> Self {
let mut store = Self {
meta: Meta::default(),
accounts: Vec::new(),
homes: Vec::new(),
server: ServerConfig::default(),
sync: SyncConfig::default(),
};
store.ensure_default_home(codex_home);
store
}
fn ensure_default_home(&mut self, codex_home: &Path) {
// The default home mirrors the active CODEX_HOME or ~/.codex.
if self.homes.iter().all(|home| home.name != "default") {
self.homes.push(Home {
name: "default".to_string(),
path: path_to_string(codex_home),
bound_account_id: None,
});
}
}
}
pub fn path_to_string(path: &Path) -> String {
path.to_string_lossy().to_string()
}
fn default_version() -> u32 {
1
}
+55
View File
@@ -0,0 +1,55 @@
//! Minimal JWT helpers used for local token inspection.
//!
//! cdxs only decodes the payload to extract account metadata and expiry. It
//! does not verify signatures because validation is performed by OpenAI when
//! the token is used against the network APIs.
use anyhow::{anyhow, Context, Result};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize)]
pub struct JwtPayload {
#[serde(default)]
pub email: Option<String>,
#[serde(default)]
pub sub: Option<String>,
#[serde(default)]
pub exp: Option<i64>,
#[serde(rename = "https://api.openai.com/auth", default)]
pub auth: Option<OpenAiAuth>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct OpenAiAuth {
#[serde(default)]
pub chatgpt_plan_type: Option<String>,
#[serde(default)]
pub account_id: Option<String>,
#[serde(default)]
pub organization_id: Option<String>,
}
pub fn decode_payload(token: &str) -> Result<JwtPayload> {
// JWT shape is header.payload.signature; only the base64url payload is
// needed for cdxs metadata.
let payload = token
.split('.')
.nth(1)
.ok_or_else(|| anyhow!("JWT 格式无效"))?;
let bytes = URL_SAFE_NO_PAD
.decode(payload)
.context("JWT payload base64 解码失败")?;
serde_json::from_slice(&bytes).context("JWT payload JSON 解析失败")
}
pub fn token_expired(token: &str, skew_seconds: i64) -> bool {
// Treat malformed tokens as expired so callers attempt refresh or reauth.
let Ok(payload) = decode_payload(token) else {
return true;
};
match payload.exp {
Some(exp) => chrono::Utc::now().timestamp() + skew_seconds >= exp,
None => false,
}
}
+137
View File
@@ -0,0 +1,137 @@
//! CLI entrypoint.
//!
//! Keep command dispatch thin here. Each subcommand should delegate to its
//! feature module so the side effects remain localized and easier to audit.
mod account;
mod atomic;
mod auth_file;
mod cli;
mod config_store;
mod jwt;
mod oauth;
mod paths;
mod quota;
mod run_cmd;
mod server;
mod session;
mod sync_client;
mod token;
use anyhow::Result;
use clap::Parser;
use crate::cli::{
AccountCommands, Cli, Commands, HomeCommands, ImportCommands, LoginCommands, ServerCommands,
ServerUserCommands, SessionCommands, SessionVisibilityCommands, SyncCommands,
};
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command.unwrap_or(Commands::List { json: false }) {
Commands::Login { command } => match command {
LoginCommands::Oauth {
manual,
port,
switch,
} => oauth::login_oauth(manual, port, switch).await,
},
Commands::Import { command } => match command {
ImportCommands::Auth {
file,
codex_home,
switch,
} => account::import_auth(file, codex_home, switch),
},
Commands::List { json } => account::list_accounts(json),
Commands::Switch {
account,
codex_home,
apply_fingerprint,
} => account::switch_account(&account, codex_home, apply_fingerprint).await,
Commands::Run(args) => {
run_cmd::run_with_account_or_home(
args.account,
args.home,
args.codex_home,
args.command,
)
.await
}
Commands::Quota { account, all, json } => quota::quota_command(account, all, json).await,
Commands::RefreshToken { account } => token::refresh_token_command(&account).await,
Commands::Account { command } => match command {
AccountCommands::List { json } => account::list_accounts(json),
AccountCommands::Current { json } => account::current_account(json),
AccountCommands::Show { account, json } => account::show_account(&account, json),
AccountCommands::Remove { account } => account::remove_account(&account),
AccountCommands::AddApiKey {
key,
base_url,
switch,
} => account::add_api_key(key, base_url, switch),
},
Commands::Home { command } => match command {
HomeCommands::List { json } => account::list_homes(json),
HomeCommands::Create {
name,
path,
account,
} => account::create_home(&name, path, account),
HomeCommands::Bind { name, account } => account::bind_home(&name, &account),
HomeCommands::Path { name } => account::home_path(&name),
HomeCommands::Remove { name } => account::remove_home(&name),
},
Commands::Server { command } => match command {
ServerCommands::Run { bind, data } => server::run_server(bind, data).await,
ServerCommands::User { command } => match command {
ServerUserCommands::Add {
username,
password,
data,
} => server::add_user(data, &username, &password),
},
},
Commands::Sync { command } => match command {
SyncCommands::Login {
server,
user,
password,
} => sync_client::login(&server, &user, &password).await,
SyncCommands::Pull => sync_client::pull().await,
SyncCommands::Push => sync_client::push().await,
SyncCommands::Status => sync_client::status(),
},
Commands::Session { command } => match command {
SessionCommands::List { all_homes, json } => session::list_sessions(all_homes, json),
SessionCommands::Stats {
session_id,
all_homes,
json,
} => session::session_stats(&session_id, all_homes, json),
SessionCommands::Trash {
session_ids,
all_homes,
} => session::trash_sessions(session_ids, all_homes),
SessionCommands::TrashList { all_homes, json } => session::list_trash(all_homes, json),
SessionCommands::Restore {
session_ids,
all_homes,
} => session::restore_sessions(session_ids, all_homes),
SessionCommands::Visibility { command } => match command {
SessionVisibilityCommands::Check { all_homes, json } => {
session::visibility_check(all_homes, json)
}
SessionVisibilityCommands::Repair { all_homes, json } => {
session::visibility_repair(all_homes, json)
}
},
SessionCommands::SyncThreads {
all_homes,
dry_run,
json,
} => session::sync_threads(all_homes, dry_run, json),
},
}
}
+197
View File
@@ -0,0 +1,197 @@
//! OpenAI OAuth login flow for Codex credentials.
//!
//! Implements the PKCE flow used by Codex-compatible clients. The command can
//! either listen for the localhost callback or accept a pasted callback URL.
use std::io::{self, Write};
use anyhow::{anyhow, Context, Result};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use rand::RngCore;
use sha2::{Digest, Sha256};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::time::{timeout, Duration};
use url::Url;
use crate::config_store::Tokens;
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
const AUTH_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize";
const TOKEN_ENDPOINT: &str = "https://auth.openai.com/oauth/token";
const SCOPES: &str = "openid profile email offline_access";
const ORIGINATOR: &str = "codex_vscode";
pub async fn login_oauth(manual: bool, port: u16, switch: bool) -> Result<()> {
// PKCE keeps the authorization code exchange bound to this CLI process
// without requiring a client secret.
let code_verifier = random_base64url_token();
let code_challenge = code_challenge(&code_verifier);
let state = random_base64url_token();
let redirect_uri = format!("http://localhost:{port}/auth/callback");
let auth_url = build_auth_url(&redirect_uri, &code_challenge, &state);
println!("请打开以下 URL 完成 Codex OAuth 登录:\n{auth_url}\n");
let code = if manual {
read_manual_callback_code(&state, port)?
} else {
println!("等待浏览器回调: {redirect_uri}");
wait_for_callback(port, &state).await?
};
let tokens = exchange_code_for_token(&code, &code_verifier, port).await?;
let account = crate::account::upsert_oauth_tokens(tokens, None, switch)?;
println!("OAuth 登录完成: {} ({})", account.email, account.id);
Ok(())
}
fn build_auth_url(redirect_uri: &str, code_challenge: &str, state: &str) -> String {
format!(
"{}?response_type=code&client_id={}&redirect_uri={}&scope={}&code_challenge={}&code_challenge_method=S256&id_token_add_organizations=true&codex_cli_simplified_flow=true&state={}&originator={}",
AUTH_ENDPOINT,
CLIENT_ID,
urlencoding::encode(redirect_uri),
urlencoding::encode(SCOPES),
code_challenge,
state,
urlencoding::encode(ORIGINATOR)
)
}
fn random_base64url_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn code_challenge(code_verifier: &str) -> String {
let digest = Sha256::digest(code_verifier.as_bytes());
URL_SAFE_NO_PAD.encode(digest)
}
fn read_manual_callback_code(expected_state: &str, port: u16) -> Result<String> {
print!("请粘贴完整回调 URL 或 ?code=...&state=...");
io::stdout().flush().ok();
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.context("读取回调 URL 失败")?;
parse_callback_code(input.trim(), expected_state, port)
}
async fn wait_for_callback(port: u16, expected_state: &str) -> Result<String> {
// A tiny one-request HTTP listener is enough for the browser redirect.
let listener = TcpListener::bind(("127.0.0.1", port))
.await
.with_context(|| format!("绑定 OAuth 回调端口失败: 127.0.0.1:{port}"))?;
let expected_state = expected_state.to_string();
timeout(Duration::from_secs(300), async move {
loop {
let (mut stream, _) = listener.accept().await.context("接收 OAuth 回调失败")?;
let mut buffer = vec![0u8; 8192];
let n = stream.read(&mut buffer).await.context("读取 OAuth 回调失败")?;
let request = String::from_utf8_lossy(&buffer[..n]);
let Some(first_line) = request.lines().next() else {
continue;
};
let Some(path) = first_line.split_whitespace().nth(1) else {
continue;
};
let result = parse_callback_code(path, &expected_state, port);
let (status, body) = match &result {
Ok(_) => ("200 OK", "Codex OAuth 登录完成,可以关闭此页面。"),
Err(_) => ("400 Bad Request", "Codex OAuth 回调无效,请回到终端查看错误。"),
};
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.as_bytes().len()
);
let _ = stream.write_all(response.as_bytes()).await;
return result;
}
})
.await
.map_err(|_| anyhow!("OAuth 回调等待超时"))?
}
fn parse_callback_code(raw: &str, expected_state: &str, port: u16) -> Result<String> {
// Accept a full URL, a path, or a raw query string to support manual mode.
let trimmed = raw.trim();
let url = if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
Url::parse(trimmed).context("回调 URL 格式无效")?
} else if trimmed.starts_with('/') {
Url::parse(&format!("http://localhost:{port}{trimmed}")).context("回调 URL 格式无效")?
} else {
Url::parse(&format!(
"http://localhost:{port}/auth/callback?{}",
trimmed.trim_start_matches('?')
))
.context("回调 URL 格式无效")?
};
if url.path() != "/auth/callback" {
return Err(anyhow!("回调路径无效: {}", url.path()));
}
let mut code = None;
let mut state = None;
for (key, value) in url.query_pairs() {
match key.as_ref() {
"code" => code = Some(value.to_string()),
"state" => state = Some(value.to_string()),
_ => {}
}
}
if state.as_deref() != Some(expected_state) {
return Err(anyhow!("OAuth state 不匹配"));
}
code.filter(|value| !value.trim().is_empty())
.ok_or_else(|| anyhow!("回调 URL 缺少 code"))
}
async fn exchange_code_for_token(code: &str, code_verifier: &str, port: u16) -> Result<Tokens> {
// Store the refresh token when the provider returns one so cdxs can refresh
// access tokens before switch/run/quota operations.
let redirect_uri = format!("http://localhost:{port}/auth/callback");
let response = reqwest::Client::new()
.post(TOKEN_ENDPOINT)
.form(&[
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", &redirect_uri),
("client_id", CLIENT_ID),
("code_verifier", code_verifier),
])
.send()
.await
.context("Token 交换请求失败")?;
let status = response.status();
let body = response.text().await.context("读取 Token 交换响应失败")?;
if !status.is_success() {
return Err(anyhow!(
"Token 交换失败: status={}, body_len={}",
status,
body.len()
));
}
let value: serde_json::Value =
serde_json::from_str(&body).context("解析 Token 交换响应失败")?;
Ok(Tokens {
id_token: value
.get("id_token")
.and_then(|value| value.as_str())
.ok_or_else(|| anyhow!("Token 响应缺少 id_token"))?
.to_string(),
access_token: value
.get("access_token")
.and_then(|value| value.as_str())
.ok_or_else(|| anyhow!("Token 响应缺少 access_token"))?
.to_string(),
refresh_token: value
.get("refresh_token")
.and_then(|value| value.as_str())
.map(ToOwned::to_owned),
})
}
+42
View File
@@ -0,0 +1,42 @@
use std::path::PathBuf;
use anyhow::{anyhow, Result};
/// Resolve the Codex home that owns both Codex auth.json and cdxs.toml.
pub fn codex_home(override_path: Option<PathBuf>) -> Result<PathBuf> {
if let Some(path) = override_path {
return Ok(expand_home(path));
}
if let Ok(raw) = std::env::var("CODEX_HOME") {
let trimmed = raw.trim().trim_matches('"').trim_matches('\'').trim();
if !trimmed.is_empty() {
return Ok(expand_home(PathBuf::from(trimmed)));
}
}
let home = dirs::home_dir().ok_or_else(|| anyhow!("无法获取用户主目录"))?;
Ok(home.join(".codex"))
}
pub fn config_path(codex_home: &std::path::Path) -> PathBuf {
// Keep cdxs state next to Codex state so CODEX_HOME fully scopes an install.
codex_home.join("cdxs.toml")
}
pub fn auth_path(codex_home: &std::path::Path) -> PathBuf {
// This is the file Codex itself reads for authentication.
codex_home.join("auth.json")
}
pub fn expand_home(path: PathBuf) -> PathBuf {
// PathBuf does not expand ~ on Windows or Unix, so handle the common cases.
let raw = path.to_string_lossy();
if raw == "~" {
return dirs::home_dir().unwrap_or(path);
}
if let Some(rest) = raw.strip_prefix("~/").or_else(|| raw.strip_prefix("~\\")) {
if let Some(home) = dirs::home_dir() {
return home.join(rest);
}
}
path
}
+284
View File
@@ -0,0 +1,284 @@
//! Codex usage quota lookup.
//!
//! Quota data is available only for OAuth-backed ChatGPT/Codex accounts. API
//! key accounts are intentionally rejected because they do not carry the
//! ChatGPT account context used by the usage endpoint.
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, AUTHORIZATION};
use serde::{Deserialize, Serialize};
use crate::config_store::{AuthMode, Quota, Store};
const USAGE_URL: &str = "https://chatgpt.com/backend-api/wham/usage";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct WindowInfo {
used_percent: Option<i32>,
limit_window_seconds: Option<i64>,
reset_after_seconds: Option<i64>,
reset_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RateLimitInfo {
primary_window: Option<WindowInfo>,
secondary_window: Option<WindowInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct UsageResponse {
plan_type: Option<String>,
rate_limit: Option<RateLimitInfo>,
}
#[derive(Debug, Serialize)]
struct QuotaDisplay<'a> {
id: &'a str,
email: &'a str,
plan_type: Option<&'a str>,
quota: Option<&'a Quota>,
error: Option<String>,
}
pub async fn quota_command(account: Option<String>, all: bool, json: bool) -> Result<()> {
let home = crate::paths::codex_home(None)?;
let mut store = Store::load(&home)?;
// Selection order matches CLI expectations: explicit --all, explicit
// account, current account, then the first saved account.
let ids = if all {
store
.accounts
.iter()
.map(|account| account.id.clone())
.collect()
} else if let Some(query) = account {
vec![store
.find_account(&query)
.ok_or_else(|| anyhow!("账号不存在: {query}"))?
.id
.clone()]
} else if let Some(current) = store.meta.current_account_id.clone() {
vec![current]
} else {
store
.accounts
.iter()
.take(1)
.map(|account| account.id.clone())
.collect()
};
if ids.is_empty() {
return Err(anyhow!("没有可查询的账号"));
}
let mut errors = Vec::<(String, String)>::new();
for id in &ids {
match refresh_one_quota(&mut store, id).await {
Ok(()) => {}
Err(error) => errors.push((id.clone(), error.to_string())),
}
}
store.save(&home)?;
if json {
let rows = ids
.iter()
.filter_map(|id| store.find_account(id))
.map(|account| QuotaDisplay {
id: &account.id,
email: &account.email,
plan_type: account.plan_type.as_deref(),
quota: account.quota.as_ref(),
error: errors
.iter()
.find(|(id, _)| id == &account.id)
.map(|(_, error)| error.clone()),
})
.collect::<Vec<_>>();
println!("{}", serde_json::to_string_pretty(&rows)?);
return Ok(());
}
println!(
"{:<22} {:<34} {:<12} {:<10} {:<10} {}",
"ID", "Email", "Plan", "5h", "Weekly", "Status"
);
for id in &ids {
let Some(account) = store.find_account(id) else {
continue;
};
let error = errors.iter().find(|(err_id, _)| err_id == id);
let (primary, secondary) = account
.quota
.as_ref()
.map(|quota| {
(
format!("{}%", quota.primary_remaining_percent),
format!("{}%", quota.secondary_remaining_percent),
)
})
.unwrap_or_else(|| ("-".to_string(), "-".to_string()));
println!(
"{:<22} {:<34} {:<12} {:<10} {:<10} {}",
shorten(&account.id, 22),
shorten(&account.email, 34),
account.plan_type.as_deref().unwrap_or("-"),
primary,
secondary,
error.map(|(_, error)| error.as_str()).unwrap_or("ok")
);
}
if !errors.is_empty() {
return Err(anyhow!("部分账号配额刷新失败: {} 个", errors.len()));
}
Ok(())
}
async fn refresh_one_quota(store: &mut Store, account_id: &str) -> Result<()> {
// Quota requests need a fresh access token and, when available, the
// ChatGPT-Account-Id header for multi-account organizations.
crate::token::refresh_account_if_needed(store, account_id).await?;
let account = store
.find_account(account_id)
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?
.clone();
if account.auth_mode != AuthMode::Oauth {
return Err(anyhow!("API Key 账号不支持 Codex OAuth 配额查询"));
}
let tokens = account
.tokens
.as_ref()
.ok_or_else(|| anyhow!("OAuth 账号缺少 tokens: {account_id}"))?;
let result = fetch_quota(tokens.access_token.as_str(), account.account_id.as_deref()).await;
let quota = match result {
Ok(value) => value,
Err(error) if should_retry_with_refresh(&error.to_string()) => {
crate::token::refresh_account(store, account_id).await?;
let refreshed = store
.find_account(account_id)
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?;
let tokens = refreshed
.tokens
.as_ref()
.ok_or_else(|| anyhow!("OAuth 账号缺少 tokens: {account_id}"))?;
fetch_quota(
tokens.access_token.as_str(),
refreshed.account_id.as_deref(),
)
.await?
}
Err(error) => return Err(error),
};
let account = store
.find_account_mut(account_id)
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?;
if let Some(plan) = quota.plan_type {
account.plan_type = Some(plan);
}
account.quota = Some(quota.quota);
account.updated_at = Utc::now().timestamp();
Ok(())
}
struct FetchQuotaResult {
quota: Quota,
plan_type: Option<String>,
}
async fn fetch_quota(access_token: &str, account_id: Option<&str>) -> Result<FetchQuotaResult> {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {access_token}"))
.context("构建 Authorization 头失败")?,
);
headers.insert(ACCEPT, HeaderValue::from_static("application/json"));
if let Some(account_id) = account_id.filter(|value| !value.trim().is_empty()) {
// ChatGPT may select the wrong account without this header when the
// token has access to multiple accounts.
headers.insert(
"ChatGPT-Account-Id",
HeaderValue::from_str(account_id).context("构建 ChatGPT-Account-Id 头失败")?,
);
}
let response = reqwest::Client::new()
.get(USAGE_URL)
.headers(headers)
.send()
.await
.context("配额请求失败")?;
let status = response.status();
let body = response.text().await.context("读取配额响应失败")?;
if !status.is_success() {
return Err(anyhow!(
"配额接口错误: status={}, body_len={}",
status,
body.len()
));
}
let usage: UsageResponse = serde_json::from_str(&body).context("解析配额响应失败")?;
Ok(FetchQuotaResult {
quota: parse_quota(&usage),
plan_type: usage.plan_type,
})
}
fn parse_quota(usage: &UsageResponse) -> Quota {
// The API reports used percentage. cdxs stores remaining percentage because
// that is what the account list displays.
let primary = usage
.rate_limit
.as_ref()
.and_then(|rate| rate.primary_window.as_ref());
let secondary = usage
.rate_limit
.as_ref()
.and_then(|rate| rate.secondary_window.as_ref());
Quota {
primary_remaining_percent: primary.map(remaining_percent).unwrap_or(100),
primary_reset_time: primary.and_then(reset_time),
secondary_remaining_percent: secondary.map(remaining_percent).unwrap_or(100),
secondary_reset_time: secondary.and_then(reset_time),
updated_at: Utc::now().timestamp(),
}
}
fn remaining_percent(window: &WindowInfo) -> i32 {
100 - window.used_percent.unwrap_or(0).clamp(0, 100)
}
fn reset_time(window: &WindowInfo) -> Option<i64> {
window.reset_at.or_else(|| {
window
.reset_after_seconds
.filter(|seconds| *seconds >= 0)
.map(|seconds| Utc::now().timestamp() + seconds)
})
}
fn should_retry_with_refresh(message: &str) -> bool {
// Some invalid-token responses arrive as body text rather than structured
// errors, so this helper intentionally matches conservatively on text.
let lower = message.to_ascii_lowercase();
lower.contains("401")
|| lower.contains("token_invalidated")
|| lower.contains("authentication token has been invalidated")
}
fn shorten(value: &str, width: usize) -> String {
if value.chars().count() <= width {
return value.to_string();
}
let mut out = value
.chars()
.take(width.saturating_sub(1))
.collect::<String>();
out.push('…');
out
}
+48
View File
@@ -0,0 +1,48 @@
//! Execute a child command with a prepared Codex authentication context.
use std::path::PathBuf;
use std::process::Command;
use anyhow::{anyhow, Context, Result};
use crate::{account, paths};
pub async fn run_with_account_or_home(
account_query: Option<String>,
home_name: Option<String>,
codex_home: Option<PathBuf>,
command: Vec<String>,
) -> Result<()> {
if command.is_empty() {
return Err(anyhow!("缺少要执行的命令,请使用 `-- codex` 形式"));
}
let target_home = if let Some(name) = home_name {
// Named homes may have a bound account. If so, refresh and materialize
// auth.json before launching the child command.
let (home_path, bound_account_id) = account::resolve_home_for_run(&name)?;
if let Some(account_id) = bound_account_id {
account::prepare_account_in_home(&account_id, home_path.clone()).await?;
}
home_path
} else {
let account = account_query.ok_or_else(|| anyhow!("run 需要 --account 或 --home"))?;
let home_path = paths::codex_home(codex_home)?;
account::prepare_account_in_home(&account, home_path.clone()).await?;
home_path
};
let mut child = Command::new(&command[0]);
child.args(&command[1..]);
// The child process sees only the selected home, so Codex reads the right
// auth.json and state files without changing the parent shell.
child.env("CODEX_HOME", &target_home);
let status = child
.status()
.with_context(|| format!("启动命令失败: {}", command.join(" ")))?;
if !status.success() {
return Err(anyhow!("命令退出失败: status={status}"));
}
Ok(())
}
+260
View File
@@ -0,0 +1,260 @@
//! Minimal HTTP sync server for cdxs state.
//!
//! The server stores users, bearer sessions and shared cdxs state in one TOML
//! file. It is intentionally small: clients login, then GET or PUT portable
//! state through `/v1/state`.
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{anyhow, Context, Result};
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::{Json, Router};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
use chrono::Utc;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use crate::config_store::{ServerSession, ServerUser, Store};
#[derive(Clone)]
struct AppState {
/// Path to the server-side cdxs.toml.
data_path: PathBuf,
/// Used for resolving default home entries when the TOML does not exist.
default_home: PathBuf,
/// Serialize file reads/writes so concurrent requests do not race.
lock: Arc<Mutex<()>>,
}
#[derive(Debug, Deserialize)]
struct LoginRequest {
username: String,
password: String,
}
#[derive(Debug, Serialize)]
struct LoginResponse {
token: String,
}
pub fn add_user(data: Option<PathBuf>, username: &str, password: &str) -> Result<()> {
// User management is file-backed just like normal cdxs state, so the same
// --data path must be used by `server run`.
let (data_path, default_home) = resolve_data_path(data)?;
let mut store = Store::load_from_path(&data_path, &default_home)?;
let username = username.trim();
if username.is_empty() {
return Err(anyhow!("用户名不能为空"));
}
if password.is_empty() {
return Err(anyhow!("密码不能为空"));
}
let salt = random_token();
let user = ServerUser {
username: username.to_string(),
salt: salt.clone(),
password_hash: hash_secret(&salt, password),
created_at: Utc::now().timestamp(),
};
if let Some(existing) = store
.server
.users
.iter_mut()
.find(|item| item.username == username)
{
*existing = user;
} else {
store.server.users.push(user);
}
store.save_to_path(&data_path, &default_home)?;
println!("已添加/更新 server 用户: {username}");
Ok(())
}
pub async fn run_server(bind: String, data: Option<PathBuf>) -> Result<()> {
// Bind defaults to localhost for safety; Docker deployments usually pass
// 0.0.0.0 through compose.
let (data_path, default_home) = resolve_data_path(data)?;
let addr: SocketAddr = bind
.parse()
.with_context(|| format!("监听地址无效: {bind}"))?;
let state = AppState {
data_path,
default_home,
lock: Arc::new(Mutex::new(())),
};
let app = Router::new()
.route("/health", get(health))
.route("/v1/login", post(login_handler))
.route("/v1/state", get(get_state_handler).put(put_state_handler))
.with_state(state);
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("绑定 server 失败: {bind}"))?;
println!("cdxs server listening on http://{bind}");
axum::serve(listener, app)
.await
.context("server 运行失败")?;
Ok(())
}
async fn health() -> &'static str {
"ok"
}
async fn login_handler(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, ApiError> {
let _guard = state.lock.lock().await;
let mut store = Store::load_from_path(&state.data_path, &state.default_home)?;
let user = store
.server
.users
.iter()
.find(|user| user.username == req.username)
.ok_or_else(|| ApiError::unauthorized("用户名或密码错误"))?;
if user.password_hash != hash_secret(&user.salt, &req.password) {
return Err(ApiError::unauthorized("用户名或密码错误"));
}
let token = random_token();
store.server.sessions.push(ServerSession {
username: req.username,
token_hash: hash_token(&token),
created_at: Utc::now().timestamp(),
});
store.save_to_path(&state.data_path, &state.default_home)?;
Ok(Json(LoginResponse { token }))
}
async fn get_state_handler(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Store>, ApiError> {
let _guard = state.lock.lock().await;
let store = Store::load_from_path(&state.data_path, &state.default_home)?;
authorize(&store, &headers)?;
Ok(Json(sanitized_for_client(&store)))
}
async fn put_state_handler(
State(state): State<AppState>,
headers: HeaderMap,
Json(incoming): Json<Store>,
) -> Result<Json<Store>, ApiError> {
let _guard = state.lock.lock().await;
let mut store = Store::load_from_path(&state.data_path, &state.default_home)?;
authorize(&store, &headers)?;
// Client sync can replace portable state, but server credentials stay server-side.
store.meta = incoming.meta;
store.accounts = incoming.accounts;
store.homes = incoming.homes;
store.sync = Default::default();
store.save_to_path(&state.data_path, &state.default_home)?;
Ok(Json(sanitized_for_client(&store)))
}
fn authorize(store: &Store, headers: &HeaderMap) -> Result<(), ApiError> {
// Only token hashes are stored server-side. The raw bearer token is returned
// once during login and then kept by the client in its local cdxs.toml.
let Some(value) = headers.get("authorization").and_then(|v| v.to_str().ok()) else {
return Err(ApiError::unauthorized("缺少 Authorization header"));
};
let Some(token) = value.strip_prefix("Bearer ").map(str::trim) else {
return Err(ApiError::unauthorized("Authorization 格式无效"));
};
let token_hash = hash_token(token);
if store
.server
.sessions
.iter()
.any(|session| session.token_hash == token_hash)
{
Ok(())
} else {
Err(ApiError::unauthorized("Token 无效"))
}
}
fn sanitized_for_client(store: &Store) -> Store {
// Never leak server users, password hashes or issued bearer sessions to
// clients during pull/login responses.
let mut copy = store.clone();
copy.server = Default::default();
copy.sync = Default::default();
copy
}
fn resolve_data_path(data: Option<PathBuf>) -> Result<(PathBuf, PathBuf)> {
// With --data, the parent directory acts as backup/default-home base. Without
// it, the active CODEX_HOME owns the server's cdxs.toml.
if let Some(path) = data {
let path = crate::paths::expand_home(path);
let default_home = path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
return Ok((path, default_home));
}
let home = crate::paths::codex_home(None)?;
Ok((crate::paths::config_path(&home), home))
}
fn random_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn hash_secret(salt: &str, secret: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(salt.as_bytes());
hasher.update(b":");
hasher.update(secret.as_bytes());
hex::encode(hasher.finalize())
}
fn hash_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(b"cdxs-token:");
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
struct ApiError {
status: StatusCode,
message: String,
}
impl ApiError {
fn unauthorized(message: &str) -> Self {
Self {
status: StatusCode::UNAUTHORIZED,
message: message.to_string(),
}
}
}
impl From<anyhow::Error> for ApiError {
fn from(error: anyhow::Error) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: error.to_string(),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> axum::response::Response {
(self.status, self.message).into_response()
}
}
+1296
View File
File diff suppressed because it is too large Load Diff
+170
View File
@@ -0,0 +1,170 @@
//! Client side of cdxs state synchronization.
//!
//! Sync only exchanges portable cdxs state. Server credentials stay on the
//! server, and the client's sync token remains local.
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use serde::{Deserialize, Serialize};
use crate::config_store::Store;
#[derive(Debug, Serialize)]
struct LoginRequest<'a> {
username: &'a str,
password: &'a str,
}
#[derive(Debug, Deserialize)]
struct LoginResponse {
token: String,
}
pub async fn login(server: &str, user: &str, password: &str) -> Result<()> {
// Normalize the server URL once so later pull/push can simply append API
// paths without double slashes.
let server = server.trim().trim_end_matches('/');
if server.is_empty() {
return Err(anyhow!("server URL 不能为空"));
}
let response = reqwest::Client::new()
.post(format!("{server}/v1/login"))
.json(&LoginRequest {
username: user,
password,
})
.send()
.await
.context("sync login 请求失败")?;
let status = response.status();
let body = response.text().await.context("读取 sync login 响应失败")?;
if !status.is_success() {
return Err(anyhow!("sync login 失败: status={}, body={}", status, body));
}
let login: LoginResponse = serde_json::from_str(&body).context("解析 sync login 响应失败")?;
let home = crate::paths::codex_home(None)?;
let mut store = Store::load(&home)?;
store.sync.server_url = Some(server.to_string());
store.sync.username = Some(user.to_string());
store.sync.token = Some(login.token);
store.save(&home)?;
println!("sync login 成功: {server} ({user})");
Ok(())
}
pub async fn pull() -> Result<()> {
let home = crate::paths::codex_home(None)?;
let mut local = Store::load(&home)?;
let (server, token) = sync_endpoint(&local)?;
let remote = reqwest::Client::new()
.get(format!("{server}/v1/state"))
.headers(auth_headers(&token)?)
.send()
.await
.context("sync pull 请求失败")?;
let status = remote.status();
let body = remote.text().await.context("读取 sync pull 响应失败")?;
if !status.is_success() {
return Err(anyhow!("sync pull 失败: status={}, body={}", status, body));
}
let remote_store: Store = serde_json::from_str(&body).context("解析 sync pull 响应失败")?;
// Pull replaces local portable state, but keeps local sync endpoint/token.
local.meta = remote_store.meta;
local.accounts = remote_store.accounts;
local.homes = remote_store.homes;
local.sync.last_pull_at = Some(Utc::now().timestamp());
local.save(&home)?;
println!("sync pull 完成");
Ok(())
}
pub async fn push() -> Result<()> {
let home = crate::paths::codex_home(None)?;
let mut local = Store::load(&home)?;
let (server, token) = sync_endpoint(&local)?;
let mut payload = local.clone();
// Do not upload local server users or sync token back into the shared state.
payload.server = Default::default();
payload.sync = Default::default();
let response = reqwest::Client::new()
.put(format!("{server}/v1/state"))
.headers(auth_headers(&token)?)
.json(&payload)
.send()
.await
.context("sync push 请求失败")?;
let status = response.status();
let body = response.text().await.context("读取 sync push 响应失败")?;
if !status.is_success() {
return Err(anyhow!("sync push 失败: status={}, body={}", status, body));
}
local.sync.last_push_at = Some(Utc::now().timestamp());
local.save(&home)?;
println!("sync push 完成");
Ok(())
}
pub fn status() -> Result<()> {
let home = crate::paths::codex_home(None)?;
let store = Store::load(&home)?;
println!(
"server: {}",
store.sync.server_url.as_deref().unwrap_or("-")
);
println!("user: {}", store.sync.username.as_deref().unwrap_or("-"));
println!(
"token: {}",
if store.sync.token.as_deref().is_some() {
"<stored>"
} else {
"-"
}
);
println!(
"last_pull_at: {}",
store
.sync
.last_pull_at
.map(|v| v.to_string())
.unwrap_or_else(|| "-".to_string())
);
println!(
"last_push_at: {}",
store
.sync
.last_push_at
.map(|v| v.to_string())
.unwrap_or_else(|| "-".to_string())
);
Ok(())
}
fn sync_endpoint(store: &Store) -> Result<(String, String)> {
// Centralize validation so pull and push produce the same user-facing errors.
let server = store
.sync
.server_url
.as_deref()
.map(str::trim)
.filter(|v| !v.is_empty())
.ok_or_else(|| anyhow!("未登录 sync server,请先运行 sync login"))?
.trim_end_matches('/')
.to_string();
let token = store
.sync
.token
.clone()
.ok_or_else(|| anyhow!("缺少 sync token,请重新 sync login"))?;
Ok((server, token))
}
fn auth_headers(token: &str) -> Result<HeaderMap> {
let mut headers = HeaderMap::new();
headers.insert(
AUTHORIZATION,
HeaderValue::from_str(&format!("Bearer {token}")).context("构建 Authorization 头失败")?,
);
Ok(headers)
}
+162
View File
@@ -0,0 +1,162 @@
//! OAuth token refresh helpers.
//!
//! Switch, run and quota operations call into this module to keep saved OAuth
//! accounts usable without forcing a full login whenever the access token
//! expires.
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use crate::config_store::{AuthMode, Store, Tokens};
use crate::jwt;
const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
const TOKEN_ENDPOINT: &str = "https://auth.openai.com/oauth/token";
const TOKEN_REFRESH_SKEW_SECONDS: i64 = 300;
pub async fn refresh_token_command(query: &str) -> Result<()> {
let home = crate::paths::codex_home(None)?;
let mut store = Store::load(&home)?;
let account_id = store
.find_account(query)
.ok_or_else(|| anyhow!("账号不存在: {query}"))?
.id
.clone();
refresh_account(&mut store, &account_id).await?;
store.save(&home)?;
println!("已刷新账号 token: {account_id}");
Ok(())
}
pub async fn refresh_account_if_needed(store: &mut Store, account_id: &str) -> Result<bool> {
// Refresh shortly before expiry to avoid writing an auth.json that Codex
// immediately rejects.
let should_refresh = {
let account = store
.find_account(account_id)
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?;
if account.auth_mode != AuthMode::Oauth {
return Ok(false);
}
let tokens = account
.tokens
.as_ref()
.ok_or_else(|| anyhow!("OAuth 账号缺少 tokens: {account_id}"))?;
jwt::token_expired(&tokens.access_token, TOKEN_REFRESH_SKEW_SECONDS)
};
if should_refresh {
refresh_account(store, account_id).await?;
return Ok(true);
}
Ok(false)
}
pub async fn refresh_account(store: &mut Store, account_id: &str) -> Result<()> {
// Mark requires_reauth on refresh failure so list/show can surface that the
// saved account needs a new OAuth login.
let (refresh_token, current_id_token) = {
let account = store
.find_account(account_id)
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?;
if account.auth_mode != AuthMode::Oauth {
return Err(anyhow!("API Key 账号不支持 token refresh: {account_id}"));
}
let tokens = account
.tokens
.as_ref()
.ok_or_else(|| anyhow!("OAuth 账号缺少 tokens: {account_id}"))?;
let refresh_token = tokens
.refresh_token
.clone()
.ok_or_else(|| anyhow!("OAuth 账号缺少 refresh_token,需要重新登录: {account_id}"))?;
(refresh_token, tokens.id_token.clone())
};
match refresh_access_token(&refresh_token, Some(&current_id_token)).await {
Ok(tokens) => {
let payload = jwt::decode_payload(&tokens.id_token).ok();
let auth = payload.as_ref().and_then(|payload| payload.auth.clone());
let account = store
.find_account_mut(account_id)
.ok_or_else(|| anyhow!("账号不存在: {account_id}"))?;
account.tokens = Some(tokens);
account.requires_reauth = false;
account.updated_at = Utc::now().timestamp();
if let Some(plan) = auth
.as_ref()
.and_then(|auth| auth.chatgpt_plan_type.clone())
{
account.plan_type = Some(plan);
}
if account.account_id.is_none() {
account.account_id = auth.as_ref().and_then(|auth| auth.account_id.clone());
}
if account.organization_id.is_none() {
account.organization_id =
auth.as_ref().and_then(|auth| auth.organization_id.clone());
}
Ok(())
}
Err(error) => {
if let Some(account) = store.find_account_mut(account_id) {
account.requires_reauth = true;
account.updated_at = Utc::now().timestamp();
}
Err(error)
}
}
}
pub async fn refresh_access_token(
refresh_token: &str,
current_id_token: Option<&str>,
) -> Result<Tokens> {
// Some refresh responses omit id_token; keep the previous one when possible
// because it still contains useful local metadata.
let response = reqwest::Client::new()
.post(TOKEN_ENDPOINT)
.form(&[
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", CLIENT_ID),
])
.send()
.await
.context("Token 刷新请求失败")?;
let status = response.status();
let body = response.text().await.context("读取 Token 刷新响应失败")?;
if !status.is_success() {
return Err(anyhow!(
"Token 刷新失败: status={}, body_len={}",
status,
body.len()
));
}
let value: serde_json::Value =
serde_json::from_str(&body).context("解析 Token 刷新响应失败")?;
let id_token = value
.get("id_token")
.and_then(|value| value.as_str())
.map(ToOwned::to_owned)
.or_else(|| current_id_token.map(ToOwned::to_owned))
.ok_or_else(|| anyhow!("Token 刷新响应缺少 id_token,且本地没有旧 id_token"))?;
let access_token = value
.get("access_token")
.and_then(|value| value.as_str())
.ok_or_else(|| anyhow!("Token 刷新响应缺少 access_token"))?
.to_string();
let refresh_token = value
.get("refresh_token")
.and_then(|value| value.as_str())
.map(ToOwned::to_owned)
.or_else(|| Some(refresh_token.to_string()));
Ok(Tokens {
id_token,
access_token,
refresh_token,
})
}