From 01449ef7885feda7ec4799e43df5fbf96adb39fc Mon Sep 17 00:00:00 2001 From: chuan Date: Thu, 30 Apr 2026 00:42:24 +0800 Subject: [PATCH] feat: add cdxs CLI implementation and docs --- .dockerignore | 5 + .gitignore | 1 + Cargo.lock | 2553 ++++++++++++++++++++++++++++++++++++ Cargo.toml | 25 + Dockerfile | 19 + README.md | 339 +++++ docker-compose.yml | 13 + docs/account-management.md | 422 ++++++ docs/auth-token-quota.md | 454 +++++++ docs/home-session.md | 319 +++++ docs/sync-server.md | 438 +++++++ features.md | 31 + src/account.rs | 474 +++++++ src/atomic.rs | 53 + src/auth_file.rs | 122 ++ src/cli.rs | 274 ++++ src/config_store.rs | 250 ++++ src/jwt.rs | 55 + src/main.rs | 137 ++ src/oauth.rs | 197 +++ src/paths.rs | 42 + src/quota.rs | 284 ++++ src/run_cmd.rs | 48 + src/server.rs | 260 ++++ src/session.rs | 1296 ++++++++++++++++++ src/sync_client.rs | 170 +++ src/token.rs | 162 +++ 27 files changed, 8443 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docs/account-management.md create mode 100644 docs/auth-token-quota.md create mode 100644 docs/home-session.md create mode 100644 docs/sync-server.md create mode 100644 features.md create mode 100644 src/account.rs create mode 100644 src/atomic.rs create mode 100644 src/auth_file.rs create mode 100644 src/cli.rs create mode 100644 src/config_store.rs create mode 100644 src/jwt.rs create mode 100644 src/main.rs create mode 100644 src/oauth.rs create mode 100644 src/paths.rs create mode 100644 src/quota.rs create mode 100644 src/run_cmd.rs create mode 100644 src/server.rs create mode 100644 src/session.rs create mode 100644 src/sync_client.rs create mode 100644 src/token.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6845cba --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +target +.git +.gitignore +README.md +docker-compose.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f97022 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..16e745d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2553 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cdxs" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "base64", + "chrono", + "clap", + "dirs", + "hex", + "rand", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tokio", + "toml", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", + "zstd", + "zstd-safe", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3e9f5ff --- /dev/null +++ b/Cargo.toml @@ -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"] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..73cf005 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index e69de29..15d877e 100644 --- a/README.md +++ b/README.md @@ -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 +``` + +移入 `cdxs` 垃圾箱,并从 Codex 会话索引和 SQLite 状态库中隐藏: + +```powershell +cdxs session trash +``` + +查看垃圾箱: + +```powershell +cdxs session trash-list +``` + +恢复会话: + +```powershell +cdxs session restore +``` + +检查会话可见性问题: + +```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 个测试通过,命令本身成功完成。 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d7f3eaf --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docs/account-management.md b/docs/account-management.md new file mode 100644 index 0000000..967cb5d --- /dev/null +++ b/docs/account-management.md @@ -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 +\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 +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 + 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 +\cdxs-backups\ +``` + +2. 写入时先写到同目录临时文件: + +```text +.<原文件名>.tmp +``` + +3. 再用 rename 替换目标文件。 + +这样可以降低写入中断导致配置文件损坏的风险。 + +## 当前实现边界 + +- `switch --apply-fingerprint` 当前只输出提示,实际不会应用设备指纹。 +- API Key 账号没有 OAuth token,也不会参与 token refresh。 +- 账号查找支持邮箱前缀,但如果多个邮箱前缀相同,当前实现会返回第一个匹配项。 +- `remove_account` 只删除 `cdxs.toml` 中的账号记录,不会主动清理已经写入某个 home 的 `auth.json`。 diff --git a/docs/auth-token-quota.md b/docs/auth-token-quota.md new file mode 100644 index 0000000..b5869e2 --- /dev/null +++ b/docs/auth-token-quota.md @@ -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 +\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 +\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:/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:` 启动一次性 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 `。 +- `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= +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 `。 +- `Accept: application/json`。 +- `ChatGPT-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:`,并且只处理一次回调请求。 +- 自动 OAuth 回调等待时间是 300 秒。 +- `id_token` 只做本地 payload 解码,不做签名校验。 +- 显式 `refresh-token` 只支持 OAuth 账号,不支持 API Key 账号。 +- 配额查询只支持 OAuth 账号,不支持 API Key 账号。 +- 配额接口错误信息不会输出完整响应体,只输出 HTTP status 和 body 长度,避免泄露响应内容。 +- 如果 token 刷新失败,账号会被标记为 `requires_reauth=true`,但不会自动重新发起 OAuth 登录。 diff --git a/docs/home-session.md b/docs/home-session.md new file mode 100644 index 0000000..b397c89 --- /dev/null +++ b/docs/home-session.md @@ -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)`:返回 `/cdxs.toml`。 +- `auth_path(home)`:返回 `/auth.json`。 +- `expand_home(path)`:支持把 `~`、`~/`、`~\` 展开为用户主目录。 + +写配置和关键状态文件时,`src/atomic.rs` 提供两类基础能力: + +- `write_atomic`:先写同目录临时文件,再 rename 到目标路径,避免半写入。 +- `backup_if_exists`:把已有文件复制到 `/cdxs-backups/`,文件名带时间戳。 + +## 多 CODEX_HOME 管理 + +### 命令入口 + +`src/cli.rs` 定义 `cdxs home` 子命令,`src/main.rs` 负责分发到 `account.rs`: + +- `cdxs home list [--json]` -> `account::list_homes` +- `cdxs home create --path [--account ]` -> `account::create_home` +- `cdxs home bind ` -> `account::bind_home` +- `cdxs home path ` -> `account::home_path` +- `cdxs home remove ` -> `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 `:指定账号,在目标 `CODEX_HOME` 中准备 `auth.json`。 +- `--home `:指定已管理 home,如果该 home 绑定了账号,则先准备认证。 +- `--codex-home `:和 `--account` 一起使用,用作目标 home 路径。 +- `-- `:实际启动的子命令,例如 `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=`。 + +子进程环境被设置后,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 会话可见性由三类文件共同决定: + +- `/state_5.sqlite`:SQLite 数据库,读取 `threads` 表。 +- `/session_index.jsonl`:会话选择器索引,每行 JSON 包含会话 ID 等信息。 +- `/sessions/` 和 `/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 [--all-homes] [--json]`:显示单个会话的 token 和文件统计。 +- `cdxs session trash [--all-homes]`:把会话移入 `cdxs` 自己的垃圾箱。 +- `cdxs session trash-list [--all-homes] [--json]`:列出垃圾箱条目。 +- `cdxs session restore [--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` 打开 `/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`。垃圾箱目录位于 `/cdxs-trash/--/`。 + +移动到垃圾箱时会保存三类可恢复材料: + +- 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` 到 `/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//`,避免覆盖目标 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` 尝试恢复。 + diff --git a/docs/sync-server.md b/docs/sync-server.md new file mode 100644 index 0000000..f0d9945 --- /dev/null +++ b/docs/sync-server.md @@ -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 +\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 重新计算哈希,并检查是否存在于 `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 --password +``` + +可选指定服务端数据文件: + +```powershell +cdxs server user add --password --data +``` + +运行过程: + +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 +``` + +运行过程: + +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 +``` + +运行过程: + +1. 客户端规范化 server URL,去掉末尾 `/`。 +2. 向 `/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. 向 `/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. 向 `/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 + +``` + +不会打印 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` 字段,只更新本地可移植状态并保留本地同步登录信息。 diff --git a/features.md b/features.md new file mode 100644 index 0000000..bb33c2c --- /dev/null +++ b/features.md @@ -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 部署同步服务。 diff --git a/src/account.rs b/src/account.rs new file mode 100644 index 0000000..10a04d9 --- /dev/null +++ b/src/account.rs @@ -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, codex_home: Option, 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, 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, + 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) -> 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 { + // 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)> { + 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 { + // 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, + switch: bool, +) -> Result { + 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) -> Result { + // 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) -> Result { + 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::(); + 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); +} diff --git a/src/atomic.rs b/src/atomic.rs new file mode 100644 index 0000000..03f038a --- /dev/null +++ b/src/atomic.rs @@ -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 `/cdxs-backups/`. +pub fn backup_if_exists(path: &Path, codex_home: &Path, label: &str) -> Result> { + 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")) +} diff --git a/src/auth_file.rs b/src/auth_file.rs new file mode 100644 index 0000000..33e03fc --- /dev/null +++ b/src/auth_file.rs @@ -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, + #[serde(rename = "OPENAI_API_KEY", default)] + pub openai_api_key: Option, + #[serde(default, alias = "api_base_url", alias = "apiBaseUrl")] + pub base_url: Option, + #[serde(default)] + pub tokens: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct CodexAuthTokens { + pub id_token: String, + pub access_token: String, + #[serde(default)] + pub refresh_token: Option, + #[serde(default)] + pub account_id: Option, +} + +pub fn read_auth_file(path: &Path) -> Result { + 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 { + 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 { + 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", + } +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..a880344 --- /dev/null +++ b/src/cli.rs @@ -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, +} + +#[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, + #[arg(long)] + apply_fingerprint: bool, + }, + /// Prepare auth, set CODEX_HOME, and execute a command. + Run(RunArgs), + /// Refresh and display Codex quota. + Quota { + account: Option, + #[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, + #[arg(long)] + codex_home: Option, + #[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, + #[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, + }, + 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, + }, + User { + #[command(subcommand)] + command: ServerUserCommands, + }, +} + +#[derive(Subcommand)] +pub enum ServerUserCommands { + Add { + username: String, + #[arg(long)] + password: String, + #[arg(long)] + data: Option, + }, +} + +#[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, + #[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, + #[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, + #[arg(long, conflicts_with = "account")] + pub home: Option, + #[arg(long)] + pub codex_home: Option, + /// Command after `--`, for example: cdxs run --account me -- codex + #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)] + pub command: Vec, +} diff --git a/src/config_store.rs b/src/config_store.rs new file mode 100644 index 0000000..6c0f1b6 --- /dev/null +++ b/src/config_store.rs @@ -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, + #[serde(default)] + pub homes: Vec, + #[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, + #[serde(default)] + pub current_home: Option, +} + +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, + #[serde(default)] + pub account_id: Option, + #[serde(default)] + pub organization_id: Option, + #[serde(default)] + pub tokens: Option, + #[serde(default)] + pub openai_api_key: Option, + #[serde(default)] + pub api_base_url: Option, + #[serde(default)] + pub quota: Option, + #[serde(default)] + pub fingerprint_id: Option, + 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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Quota { + pub primary_remaining_percent: i32, + #[serde(default)] + pub primary_reset_time: Option, + pub secondary_remaining_percent: i32, + #[serde(default)] + pub secondary_reset_time: Option, + 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, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ServerConfig { + #[serde(default)] + pub users: Vec, + #[serde(default)] + pub sessions: Vec, +} + +#[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, + #[serde(default)] + pub username: Option, + #[serde(default)] + pub token: Option, + #[serde(default)] + pub last_pull_at: Option, + #[serde(default)] + pub last_push_at: Option, +} + +impl Store { + /// Load cdxs.toml, creating an in-memory default store when it is missing. + pub fn load(codex_home: &Path) -> Result { + 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 { + 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 +} diff --git a/src/jwt.rs b/src/jwt.rs new file mode 100644 index 0000000..546cffb --- /dev/null +++ b/src/jwt.rs @@ -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, + #[serde(default)] + pub sub: Option, + #[serde(default)] + pub exp: Option, + #[serde(rename = "https://api.openai.com/auth", default)] + pub auth: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct OpenAiAuth { + #[serde(default)] + pub chatgpt_plan_type: Option, + #[serde(default)] + pub account_id: Option, + #[serde(default)] + pub organization_id: Option, +} + +pub fn decode_payload(token: &str) -> Result { + // 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, + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f99b768 --- /dev/null +++ b/src/main.rs @@ -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), + }, + } +} diff --git a/src/oauth.rs b/src/oauth.rs new file mode 100644 index 0000000..6c8d36f --- /dev/null +++ b/src/oauth.rs @@ -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 { + 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 { + // 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 { + // 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 { + // 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), + }) +} diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..207ddb1 --- /dev/null +++ b/src/paths.rs @@ -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) -> Result { + 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 +} diff --git a/src/quota.rs b/src/quota.rs new file mode 100644 index 0000000..968d6fb --- /dev/null +++ b/src/quota.rs @@ -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, + limit_window_seconds: Option, + reset_after_seconds: Option, + reset_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct RateLimitInfo { + primary_window: Option, + secondary_window: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct UsageResponse { + plan_type: Option, + rate_limit: Option, +} + +#[derive(Debug, Serialize)] +struct QuotaDisplay<'a> { + id: &'a str, + email: &'a str, + plan_type: Option<&'a str>, + quota: Option<&'a Quota>, + error: Option, +} + +pub async fn quota_command(account: Option, 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::>(); + 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, +} + +async fn fetch_quota(access_token: &str, account_id: Option<&str>) -> Result { + 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 { + 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::(); + out.push('…'); + out +} diff --git a/src/run_cmd.rs b/src/run_cmd.rs new file mode 100644 index 0000000..066b604 --- /dev/null +++ b/src/run_cmd.rs @@ -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, + home_name: Option, + codex_home: Option, + command: Vec, +) -> 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(()) +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..767dc31 --- /dev/null +++ b/src/server.rs @@ -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>, +} + +#[derive(Debug, Deserialize)] +struct LoginRequest { + username: String, + password: String, +} + +#[derive(Debug, Serialize)] +struct LoginResponse { + token: String, +} + +pub fn add_user(data: Option, 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) -> 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, + Json(req): Json, +) -> Result, 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, + headers: HeaderMap, +) -> Result, 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, + headers: HeaderMap, + Json(incoming): Json, +) -> Result, 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) -> 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 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() + } +} diff --git a/src/session.rs b/src/session.rs new file mode 100644 index 0000000..fc3c310 --- /dev/null +++ b/src/session.rs @@ -0,0 +1,1296 @@ +//! Inspect and repair Codex session state. +//! +//! Codex stores session visibility across SQLite (`state_5.sqlite`), +//! `session_index.jsonl`, and rollout JSONL files. This module keeps those +//! pieces consistent, implements a reversible cdxs trash, and can copy missing +//! threads across managed homes. + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use chrono::{DateTime, Utc}; +use rusqlite::{params, Connection, Row}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::config_store::Store; +use crate::{atomic, paths}; + +#[derive(Debug, Clone, Serialize)] +pub struct SessionSummary { + /// Name of the managed home that owns this session. + pub home_name: String, + pub home_path: String, + pub id: String, + pub title: Option, + pub cwd: Option, + pub updated_at_ms: Option, + pub tokens_used: i64, + pub rollout_path: String, + pub archived: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SessionStats { + pub session: SessionSummary, + pub rollout_bytes: Option, + pub rollout_lines: Option, + pub sqlite_tokens_used: i64, + pub rollout_total_tokens: Option, + pub rollout_input_tokens: Option, + pub rollout_output_tokens: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct VisibilityIssue { + pub home_name: String, + pub home_path: String, + pub session_id: String, + pub issue: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RepairAction { + pub home_name: String, + pub session_id: String, + pub action: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SyncThreadAction { + pub session_id: String, + pub source_home: String, + pub target_home: String, + pub action: String, + pub detail: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct TrashManifest { + /// Manifest schema version for future migrations. + version: u32, + deleted_at: String, + home_name: String, + home_path: String, + session_id: String, + original_rollout_path: String, + rollout_backup_file: Option, + session_index_entries: Vec, + thread: ThreadRowData, +} + +#[derive(Debug, Clone, Serialize)] +struct TrashEntry { + home_name: String, + home_path: String, + session_id: String, + title: Option, + cwd: Option, + deleted_at: String, + trash_dir: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ThreadRowData { + // Mirrors the Codex `threads` table. Keeping this shape explicit makes + // repair/restore code independent from rusqlite row lifetimes. + id: String, + rollout_path: String, + created_at: Option, + updated_at: Option, + source: Option, + model_provider: Option, + cwd: Option, + title: Option, + sandbox_policy: Option, + approval_mode: Option, + tokens_used: Option, + has_user_event: Option, + archived: Option, + archived_at: Option, + git_sha: Option, + git_branch: Option, + git_origin_url: Option, + cli_version: Option, + first_user_message: Option, + agent_nickname: Option, + agent_role: Option, + memory_mode: Option, + model: Option, + reasoning_effort: Option, + agent_path: Option, + created_at_ms: Option, + updated_at_ms: Option, +} + +#[derive(Debug, Clone)] +struct HomeTarget { + name: String, + path: PathBuf, +} + +#[derive(Debug, Clone)] +struct RolloutMeta { + session_id: String, + relative_path: String, + cwd: Option, + model_provider: Option, + source: Option, + cli_version: Option, + created_at_ms: Option, + updated_at_ms: Option, +} + +pub fn list_sessions(all_homes: bool, json: bool) -> Result<()> { + let mut sessions = Vec::new(); + for home in homes(all_homes)? { + sessions.extend(read_sessions_for_home(&home)?); + } + sessions.sort_by(|a, b| b.updated_at_ms.cmp(&a.updated_at_ms)); + + if json { + println!("{}", serde_json::to_string_pretty(&sessions)?); + return Ok(()); + } + if sessions.is_empty() { + println!("没有找到 Codex 会话。"); + return Ok(()); + } + println!( + "{:<22} {:<12} {:<10} {:<20} {}", + "ID", "Home", "Tokens", "Updated", "Title" + ); + for item in sessions { + println!( + "{:<22} {:<12} {:<10} {:<20} {}", + shorten(&item.id, 22), + shorten(&item.home_name, 12), + item.tokens_used, + format_time(item.updated_at_ms), + shorten(item.title.as_deref().unwrap_or("-"), 80) + ); + } + Ok(()) +} + +pub fn session_stats(session_id: &str, all_homes: bool, json: bool) -> Result<()> { + let (home, row) = + find_thread(session_id, all_homes)?.ok_or_else(|| anyhow!("会话不存在: {session_id}"))?; + let summary = summary_from_thread(&home, &row); + let rollout = resolve_rollout_path(&home.path, &row.rollout_path); + let (rollout_bytes, rollout_lines, token_usage) = rollout_stats(&rollout)?; + let stats = SessionStats { + sqlite_tokens_used: row.tokens_used.unwrap_or_default(), + session: summary, + rollout_bytes, + rollout_lines, + rollout_total_tokens: token_usage.as_ref().and_then(|v| v.total_tokens), + rollout_input_tokens: token_usage.as_ref().and_then(|v| v.input_tokens), + rollout_output_tokens: token_usage.as_ref().and_then(|v| v.output_tokens), + }; + + if json { + println!("{}", serde_json::to_string_pretty(&stats)?); + return Ok(()); + } + println!("id: {}", stats.session.id); + println!( + "home: {} ({})", + stats.session.home_name, stats.session.home_path + ); + println!("title: {}", stats.session.title.as_deref().unwrap_or("-")); + println!("cwd: {}", stats.session.cwd.as_deref().unwrap_or("-")); + println!("updated: {}", format_time(stats.session.updated_at_ms)); + println!("sqlite_tokens_used: {}", stats.sqlite_tokens_used); + println!( + "rollout_tokens: total={}, input={}, output={}", + opt_i64(stats.rollout_total_tokens), + opt_i64(stats.rollout_input_tokens), + opt_i64(stats.rollout_output_tokens) + ); + println!( + "rollout_file: {} (bytes={}, lines={})", + stats.session.rollout_path, + opt_u64(stats.rollout_bytes), + stats + .rollout_lines + .map(|v| v.to_string()) + .unwrap_or_else(|| "-".to_string()) + ); + Ok(()) +} + +pub fn trash_sessions(session_ids: Vec, all_homes: bool) -> Result<()> { + if session_ids.is_empty() { + return Err(anyhow!("至少需要提供一个 session id")); + } + for session_id in session_ids { + let (home, row) = find_thread(&session_id, all_homes)? + .ok_or_else(|| anyhow!("会话不存在: {session_id}"))?; + trash_one(&home, row)?; + } + Ok(()) +} + +pub fn list_trash(all_homes: bool, json: bool) -> Result<()> { + let mut entries = Vec::new(); + for home in homes(all_homes)? { + entries.extend(read_trash_entries(&home)?); + } + entries.sort_by(|a, b| b.deleted_at.cmp(&a.deleted_at)); + if json { + println!("{}", serde_json::to_string_pretty(&entries)?); + return Ok(()); + } + if entries.is_empty() { + println!("垃圾箱为空。"); + return Ok(()); + } + println!( + "{:<22} {:<12} {:<24} {}", + "ID", "Home", "Deleted At", "Title" + ); + for item in entries { + println!( + "{:<22} {:<12} {:<24} {}", + shorten(&item.session_id, 22), + shorten(&item.home_name, 12), + item.deleted_at, + shorten(item.title.as_deref().unwrap_or("-"), 80) + ); + } + Ok(()) +} + +pub fn restore_sessions(session_ids: Vec, all_homes: bool) -> Result<()> { + if session_ids.is_empty() { + return Err(anyhow!("至少需要提供一个 session id")); + } + let manifests = trash_manifests(all_homes)?; + for session_id in session_ids { + let (manifest_path, manifest) = manifests + .iter() + .find(|(_, item)| item.session_id == session_id) + .cloned() + .ok_or_else(|| anyhow!("垃圾箱中没有找到会话: {session_id}"))?; + restore_one(&manifest_path, &manifest)?; + } + Ok(()) +} + +pub fn visibility_check(all_homes: bool, json: bool) -> Result<()> { + let issues = collect_visibility_issues(all_homes)?; + if json { + println!("{}", serde_json::to_string_pretty(&issues)?); + return Ok(()); + } + if issues.is_empty() { + println!("没有发现会话可见性问题。"); + return Ok(()); + } + println!( + "{:<12} {:<22} {:<18} {}", + "Home", "Session", "Issue", "Detail" + ); + for issue in issues { + println!( + "{:<12} {:<22} {:<18} {}", + shorten(&issue.home_name, 12), + shorten(&issue.session_id, 22), + issue.issue, + issue.detail + ); + } + Ok(()) +} + +pub fn visibility_repair(all_homes: bool, json: bool) -> Result<()> { + let mut actions = Vec::new(); + for home in homes(all_homes)? { + actions.extend(repair_home_visibility(&home)?); + } + if json { + println!("{}", serde_json::to_string_pretty(&actions)?); + return Ok(()); + } + if actions.is_empty() { + println!("没有需要修复的会话可见性问题。"); + return Ok(()); + } + for action in actions { + println!( + "{} {}: {}", + action.home_name, action.session_id, action.detail + ); + } + Ok(()) +} + +pub fn sync_threads(all_homes: bool, dry_run: bool, json: bool) -> Result<()> { + let selected_homes = homes(all_homes)?; + if selected_homes.len() < 2 { + return Err(anyhow!( + "sync-threads 至少需要两个 home,请先用 `cdxs home create` 添加实例" + )); + } + + // Choose the first seen copy of each thread as the source of truth, then + // fill in homes that do not have that thread yet. + let mut rows_by_id: HashMap = HashMap::new(); + let mut indexes: HashMap> = HashMap::new(); + for home in &selected_homes { + let rows = read_thread_rows_for_home(home)?; + indexes.insert(home.name.clone(), read_session_index_ids(&home.path)?); + for row in rows { + rows_by_id + .entry(row.id.clone()) + .or_insert((home.clone(), row)); + } + } + + let mut actions = Vec::new(); + for target in &selected_homes { + let target_rows = read_thread_rows_for_home(target)?; + let target_ids: HashSet = target_rows.into_iter().map(|row| row.id).collect(); + for (session_id, (source, source_row)) in &rows_by_id { + if target_ids.contains(session_id) { + continue; + } + let source_rollout = resolve_rollout_path(&source.path, &source_row.rollout_path); + if !source_rollout.exists() { + actions.push(SyncThreadAction { + session_id: session_id.clone(), + source_home: source.name.clone(), + target_home: target.name.clone(), + action: "skip".to_string(), + detail: "source rollout missing".to_string(), + }); + continue; + } + + let mut target_row = source_row.clone(); + let target_relative = portable_rollout_path(source_row, source, target); + let target_rollout = resolve_rollout_path(&target.path, &target_relative); + target_row.rollout_path = target_relative.clone(); + actions.push(SyncThreadAction { + session_id: session_id.clone(), + source_home: source.name.clone(), + target_home: target.name.clone(), + action: if dry_run { "would_sync" } else { "synced" }.to_string(), + detail: target_relative.clone(), + }); + if dry_run { + continue; + } + + backup_state_files(&target.path)?; + if let Some(parent) = target_rollout.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("创建 rollout 目录失败: {}", parent.display()))?; + } + fs::copy(&source_rollout, &target_rollout).with_context(|| { + format!( + "复制 rollout 失败: source={}, target={}", + source_rollout.display(), + target_rollout.display() + ) + })?; + let conn = open_state_db(&target.path)?; + insert_thread(&conn, &target_row)?; + if !indexes + .get(&target.name) + .map(|ids| ids.contains(session_id)) + .unwrap_or(false) + { + append_session_index_entry(&target.path, &index_entry_from_thread(&target_row)?)?; + } + } + } + + if json { + println!("{}", serde_json::to_string_pretty(&actions)?); + return Ok(()); + } + if actions.is_empty() { + println!("所有 home 的会话线程已经一致。"); + return Ok(()); + } + for action in actions { + println!( + "{} -> {} {}: {}", + action.source_home, action.target_home, action.session_id, action.action + ); + } + Ok(()) +} + +fn read_sessions_for_home(home: &HomeTarget) -> Result> { + let db = state_db_path(&home.path); + if !db.exists() { + return Ok(Vec::new()); + } + let conn = Connection::open(&db) + .with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?; + let rows = read_thread_rows(&conn)?; + Ok(rows + .iter() + .map(|row| summary_from_thread(home, row)) + .collect()) +} + +fn read_thread_rows_for_home(home: &HomeTarget) -> Result> { + let db = state_db_path(&home.path); + if !db.exists() { + return Ok(Vec::new()); + } + let conn = open_state_db(&home.path)?; + read_thread_rows(&conn) +} + +fn read_thread_rows(conn: &Connection) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, + sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at, + git_sha, git_branch, git_origin_url, cli_version, first_user_message, + agent_nickname, agent_role, memory_mode, model, reasoning_effort, agent_path, + created_at_ms, updated_at_ms + FROM threads", + )?; + let rows = stmt + .query_map([], thread_from_row)? + .collect::, _>>()?; + Ok(rows) +} + +fn find_thread(session_id: &str, all_homes: bool) -> Result> { + for home in homes(all_homes)? { + let db = state_db_path(&home.path); + if !db.exists() { + continue; + } + let conn = Connection::open(&db) + .with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?; + let mut stmt = conn.prepare( + "SELECT id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, + sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at, + git_sha, git_branch, git_origin_url, cli_version, first_user_message, + agent_nickname, agent_role, memory_mode, model, reasoning_effort, agent_path, + created_at_ms, updated_at_ms + FROM threads + WHERE id = ?1", + )?; + let mut rows = stmt.query(params![session_id])?; + if let Some(row) = rows.next()? { + return Ok(Some((home, thread_from_row(row)?))); + } + } + Ok(None) +} + +fn collect_visibility_issues(all_homes: bool) -> Result> { + let mut issues = Vec::new(); + for home in homes(all_homes)? { + let threads = read_thread_rows_for_home(&home)?; + let thread_ids: HashSet = threads.iter().map(|row| row.id.clone()).collect(); + let index_ids = read_session_index_ids(&home.path)?; + let rollouts = scan_rollouts(&home.path)?; + let rollout_ids: HashSet = rollouts.keys().cloned().collect(); + + // A visible Codex thread needs a SQLite row, a rollout file, and a + // session_index.jsonl entry. Report whichever side is missing. + for row in &threads { + if !resolve_rollout_path(&home.path, &row.rollout_path).exists() { + issues.push(issue( + &home, + &row.id, + "missing_rollout", + format!("rollout not found: {}", row.rollout_path), + )); + } + if !index_ids.contains(&row.id) { + issues.push(issue( + &home, + &row.id, + "missing_index", + "session_index.jsonl missing entry".to_string(), + )); + } + } + for id in index_ids.difference(&thread_ids) { + issues.push(issue( + &home, + id, + "orphan_index", + "session_index.jsonl entry has no SQLite thread".to_string(), + )); + } + for id in rollout_ids.difference(&thread_ids) { + issues.push(issue( + &home, + id, + "orphan_rollout", + "rollout file has no SQLite thread".to_string(), + )); + } + } + Ok(issues) +} + +fn repair_home_visibility(home: &HomeTarget) -> Result> { + let mut actions = Vec::new(); + let db = state_db_path(&home.path); + if !db.exists() { + return Ok(actions); + } + let conn = open_state_db(&home.path)?; + let threads = read_thread_rows(&conn)?; + let mut index_ids = read_session_index_ids(&home.path)?; + let rollouts = scan_rollouts(&home.path)?; + let thread_ids: HashSet = threads.iter().map(|row| row.id.clone()).collect(); + + // Existing SQLite rows are preferred. If their rollout path or index entry + // is missing, repair those pieces in place. + for row in &threads { + let current_rollout = resolve_rollout_path(&home.path, &row.rollout_path); + if !current_rollout.exists() { + if let Some(found) = rollouts.get(&row.id) { + backup_state_files(&home.path)?; + conn.execute( + "UPDATE threads SET rollout_path = ?1 WHERE id = ?2", + params![found.relative_path, row.id], + )?; + actions.push(repair_action( + home, + &row.id, + "repair_rollout_path", + format!("updated rollout_path to {}", found.relative_path), + )); + } + } + if !index_ids.contains(&row.id) { + backup_state_files(&home.path)?; + append_session_index_entry(&home.path, &index_entry_from_thread(row)?)?; + index_ids.insert(row.id.clone()); + actions.push(repair_action( + home, + &row.id, + "append_index", + "added session_index.jsonl entry".to_string(), + )); + } + } + + // Rollout files without SQLite rows can still be made visible by creating a + // minimal thread row from the rollout metadata. + for (session_id, rollout) in &rollouts { + if thread_ids.contains(session_id) { + continue; + } + backup_state_files(&home.path)?; + let row = thread_from_rollout(rollout); + insert_thread(&conn, &row)?; + if !index_ids.contains(session_id) { + append_session_index_entry(&home.path, &index_entry_from_thread(&row)?)?; + index_ids.insert(session_id.clone()); + } + actions.push(repair_action( + home, + session_id, + "insert_thread", + "created minimal SQLite thread from rollout metadata".to_string(), + )); + } + Ok(actions) +} + +fn trash_one(home: &HomeTarget, row: ThreadRowData) -> Result<()> { + let stamp = Utc::now().format("%Y%m%d-%H%M%S%.3f").to_string(); + let trash_dir = home.path.join("cdxs-trash").join(format!( + "{}-{}-{}", + stamp, + safe_name(&row.id), + safe_name(&home.name) + )); + fs::create_dir_all(&trash_dir) + .with_context(|| format!("创建垃圾箱目录失败: {}", trash_dir.display()))?; + + // Trash is reversible: save the rollout copy, removed index lines and the + // original SQLite row in a manifest before deleting visibility state. + let rollout = resolve_rollout_path(&home.path, &row.rollout_path); + let rollout_backup_file = if rollout.exists() { + let file_name = rollout + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("rollout.jsonl") + .to_string(); + fs::copy(&rollout, trash_dir.join(&file_name)).with_context(|| { + format!( + "备份 rollout 失败: source={}, trash={}", + rollout.display(), + trash_dir.display() + ) + })?; + Some(file_name) + } else { + None + }; + let index_entries = remove_session_index_entries(&home.path, &row.id)?; + let manifest = TrashManifest { + version: 1, + deleted_at: Utc::now().to_rfc3339(), + home_name: home.name.clone(), + home_path: home.path.to_string_lossy().to_string(), + session_id: row.id.clone(), + original_rollout_path: row.rollout_path.clone(), + rollout_backup_file, + session_index_entries: index_entries, + thread: row.clone(), + }; + let manifest_content = serde_json::to_string_pretty(&manifest)?; + atomic::write_atomic(&trash_dir.join("manifest.json"), &manifest_content)?; + + let db = state_db_path(&home.path); + let conn = Connection::open(&db) + .with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?; + conn.execute("DELETE FROM threads WHERE id = ?1", params![row.id])?; + if rollout.exists() { + fs::remove_file(&rollout) + .with_context(|| format!("删除 rollout 文件失败: {}", rollout.display()))?; + } + println!( + "已移入垃圾箱: {} ({})", + manifest.session_id, + trash_dir.display() + ); + Ok(()) +} + +fn restore_one(manifest_path: &Path, manifest: &TrashManifest) -> Result<()> { + let home_path = PathBuf::from(&manifest.home_path); + let db = state_db_path(&home_path); + let conn = Connection::open(&db) + .with_context(|| format!("打开 Codex 状态库失败: {}", db.display()))?; + // Restore SQLite first, then rollout and index entries. This makes a failed + // restore easy to retry from the manifest. + insert_thread(&conn, &manifest.thread)?; + + if let Some(file_name) = manifest.rollout_backup_file.as_deref() { + let backup = manifest_path + .parent() + .ok_or_else(|| anyhow!("manifest 路径无父目录: {}", manifest_path.display()))? + .join(file_name); + let target = resolve_rollout_path(&home_path, &manifest.original_rollout_path); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("创建 rollout 目录失败: {}", parent.display()))?; + } + fs::copy(&backup, &target).with_context(|| { + format!( + "恢复 rollout 失败: source={}, target={}", + backup.display(), + target.display() + ) + })?; + } + restore_session_index_entries(&home_path, &manifest.session_index_entries)?; + + let trash_dir = manifest_path + .parent() + .ok_or_else(|| anyhow!("manifest 路径无父目录: {}", manifest_path.display()))?; + fs::remove_dir_all(trash_dir) + .with_context(|| format!("清理垃圾箱条目失败: {}", trash_dir.display()))?; + println!("已恢复会话: {}", manifest.session_id); + Ok(()) +} + +fn remove_session_index_entries(home: &Path, session_id: &str) -> Result> { + let path = home.join("session_index.jsonl"); + if !path.exists() { + return Ok(Vec::new()); + } + atomic::backup_if_exists(&path, home, "session_index.jsonl")?; + let content = fs::read_to_string(&path) + .with_context(|| format!("读取 session_index 失败: {}", path.display()))?; + let mut kept = Vec::new(); + let mut removed = Vec::new(); + for line in content.lines() { + if line_session_id(line).as_deref() == Some(session_id) { + removed.push(line.to_string()); + } else { + kept.push(line.to_string()); + } + } + let mut output = kept.join("\n"); + if !output.is_empty() { + output.push('\n'); + } + atomic::write_atomic(&path, &output)?; + Ok(removed) +} + +fn restore_session_index_entries(home: &Path, entries: &[String]) -> Result<()> { + if entries.is_empty() { + return Ok(()); + } + let path = home.join("session_index.jsonl"); + let mut existing = if path.exists() { + fs::read_to_string(&path) + .with_context(|| format!("读取 session_index 失败: {}", path.display()))? + } else { + String::new() + }; + let existing_ids: HashSet = existing.lines().filter_map(line_session_id).collect(); + if !existing.is_empty() && !existing.ends_with('\n') { + existing.push('\n'); + } + for entry in entries { + let should_append = line_session_id(entry) + .map(|id| !existing_ids.contains(&id)) + .unwrap_or(true); + if should_append { + existing.push_str(entry); + existing.push('\n'); + } + } + atomic::backup_if_exists(&path, home, "session_index.jsonl")?; + atomic::write_atomic(&path, &existing)?; + Ok(()) +} + +fn read_session_index_ids(home: &Path) -> Result> { + let path = home.join("session_index.jsonl"); + if !path.exists() { + return Ok(HashSet::new()); + } + let content = fs::read_to_string(&path) + .with_context(|| format!("读取 session_index 失败: {}", path.display()))?; + Ok(content.lines().filter_map(line_session_id).collect()) +} + +fn append_session_index_entry(home: &Path, entry: &str) -> Result<()> { + let path = home.join("session_index.jsonl"); + let mut content = if path.exists() { + fs::read_to_string(&path) + .with_context(|| format!("读取 session_index 失败: {}", path.display()))? + } else { + String::new() + }; + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + content.push_str(entry); + content.push('\n'); + atomic::write_atomic(&path, &content)?; + Ok(()) +} + +fn index_entry_from_thread(row: &ThreadRowData) -> Result { + // Codex only needs a compact JSONL index entry for the session picker. + let title = row + .title + .as_deref() + .or(row.first_user_message.as_deref()) + .unwrap_or(&row.id); + let updated_at = row + .updated_at_ms + .or(row.updated_at.map(|value| value * 1000)) + .and_then(DateTime::::from_timestamp_millis) + .unwrap_or_else(Utc::now); + let value = serde_json::json!({ + "id": row.id, + "thread_name": title, + "updated_at": updated_at.to_rfc3339(), + }); + Ok(serde_json::to_string(&value)?) +} + +fn scan_rollouts(home: &Path) -> Result> { + let mut out = HashMap::new(); + for dirname in ["sessions", "archived_sessions"] { + let root = home.join(dirname); + if root.exists() { + scan_rollout_dir(home, &root, &mut out)?; + } + } + Ok(out) +} + +fn scan_rollout_dir(home: &Path, dir: &Path, out: &mut HashMap) -> Result<()> { + for entry in fs::read_dir(dir).with_context(|| format!("读取目录失败: {}", dir.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + scan_rollout_dir(home, &path, out)?; + continue; + } + if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { + continue; + } + if let Some(meta) = read_rollout_meta(home, &path)? { + out.entry(meta.session_id.clone()).or_insert(meta); + } + } + Ok(()) +} + +fn read_rollout_meta(home: &Path, path: &Path) -> Result> { + let content = fs::read_to_string(path) + .with_context(|| format!("读取 rollout 失败: {}", path.display()))?; + // session_meta is normally at the top of the rollout. Limit scanning so a + // malformed large transcript cannot make repair unexpectedly expensive. + for line in content.lines().take(25) { + let Ok(value) = serde_json::from_str::(line) else { + continue; + }; + if value.get("type").and_then(Value::as_str) != Some("session_meta") { + continue; + } + let payload = value.get("payload").unwrap_or(&Value::Null); + let Some(session_id) = payload.get("id").and_then(Value::as_str) else { + continue; + }; + let metadata = fs::metadata(path) + .with_context(|| format!("读取 rollout 元数据失败: {}", path.display()))?; + let updated_at_ms = metadata + .modified() + .ok() + .and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|value| value.as_millis() as i64); + return Ok(Some(RolloutMeta { + session_id: session_id.to_string(), + relative_path: relative_path(home, path), + cwd: payload + .get("cwd") + .and_then(Value::as_str) + .map(str::to_string), + model_provider: payload + .get("model_provider") + .and_then(Value::as_str) + .map(str::to_string), + source: payload + .get("source") + .and_then(Value::as_str) + .map(str::to_string), + cli_version: payload + .get("cli_version") + .and_then(Value::as_str) + .map(str::to_string), + created_at_ms: payload + .get("timestamp") + .and_then(Value::as_str) + .and_then(parse_rfc3339_ms), + updated_at_ms, + })); + } + Ok(None) +} + +fn thread_from_rollout(meta: &RolloutMeta) -> ThreadRowData { + let now_ms = Utc::now().timestamp_millis(); + let created_at_ms = meta.created_at_ms.or(meta.updated_at_ms).unwrap_or(now_ms); + let updated_at_ms = meta.updated_at_ms.unwrap_or(created_at_ms); + ThreadRowData { + id: meta.session_id.clone(), + rollout_path: meta.relative_path.clone(), + created_at: Some(created_at_ms / 1000), + updated_at: Some(updated_at_ms / 1000), + source: meta.source.clone(), + model_provider: meta + .model_provider + .clone() + .or_else(|| Some("openai".to_string())), + cwd: meta.cwd.clone(), + title: Some(meta.session_id.clone()), + sandbox_policy: None, + approval_mode: None, + tokens_used: Some(0), + has_user_event: Some(1), + archived: Some(0), + archived_at: None, + git_sha: None, + git_branch: None, + git_origin_url: None, + cli_version: meta.cli_version.clone().or_else(|| Some(String::new())), + first_user_message: Some(String::new()), + agent_nickname: None, + agent_role: None, + memory_mode: Some("enabled".to_string()), + model: None, + reasoning_effort: None, + agent_path: None, + created_at_ms: Some(created_at_ms), + updated_at_ms: Some(updated_at_ms), + } +} + +fn backup_state_files(home: &Path) -> Result<()> { + // These are the Codex files session operations may modify. + let db = state_db_path(home); + atomic::backup_if_exists(&db, home, "state_5.sqlite")?; + atomic::backup_if_exists( + &home.join("session_index.jsonl"), + home, + "session_index.jsonl", + )?; + Ok(()) +} + +fn open_state_db(home: &Path) -> Result { + let db = state_db_path(home); + Connection::open(&db).with_context(|| format!("打开 Codex 状态库失败: {}", db.display())) +} + +fn portable_rollout_path(row: &ThreadRowData, source: &HomeTarget, target: &HomeTarget) -> String { + let source_rollout = resolve_rollout_path(&source.path, &row.rollout_path); + let relative = relative_path(&source.path, &source_rollout); + let target_rollout = resolve_rollout_path(&target.path, &relative); + if target_rollout.exists() { + // Avoid overwriting an existing rollout in the target home. + let file_name = source_rollout + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("rollout.jsonl"); + format!( + "sessions/cdxs-sync/{}/{}", + safe_name(&source.name), + file_name + ) + } else { + relative + } +} + +fn relative_path(base: &Path, path: &Path) -> String { + path.strip_prefix(base) + .unwrap_or(path) + .to_string_lossy() + .replace('\\', "/") +} + +fn parse_rfc3339_ms(value: &str) -> Option { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|value| value.timestamp_millis()) +} + +fn issue(home: &HomeTarget, session_id: &str, issue: &str, detail: String) -> VisibilityIssue { + VisibilityIssue { + home_name: home.name.clone(), + home_path: home.path.to_string_lossy().to_string(), + session_id: session_id.to_string(), + issue: issue.to_string(), + detail, + } +} + +fn repair_action( + home: &HomeTarget, + session_id: &str, + action: &str, + detail: String, +) -> RepairAction { + RepairAction { + home_name: home.name.clone(), + session_id: session_id.to_string(), + action: action.to_string(), + detail, + } +} + +fn trash_manifests(all_homes: bool) -> Result> { + let mut manifests = Vec::new(); + for home in homes(all_homes)? { + let root = home.path.join("cdxs-trash"); + if !root.exists() { + continue; + } + for entry in + fs::read_dir(&root).with_context(|| format!("读取垃圾箱失败: {}", root.display()))? + { + let entry = entry?; + let manifest_path = entry.path().join("manifest.json"); + if !manifest_path.exists() { + continue; + } + let content = fs::read_to_string(&manifest_path) + .with_context(|| format!("读取 manifest 失败: {}", manifest_path.display()))?; + let manifest: TrashManifest = + serde_json::from_str(&content).context("解析 manifest 失败")?; + manifests.push((manifest_path, manifest)); + } + } + Ok(manifests) +} + +fn read_trash_entries(home: &HomeTarget) -> Result> { + let mut out = Vec::new(); + let root = home.path.join("cdxs-trash"); + if !root.exists() { + return Ok(out); + } + for entry in + fs::read_dir(&root).with_context(|| format!("读取垃圾箱失败: {}", root.display()))? + { + let entry = entry?; + let manifest_path = entry.path().join("manifest.json"); + if !manifest_path.exists() { + continue; + } + let content = fs::read_to_string(&manifest_path) + .with_context(|| format!("读取 manifest 失败: {}", manifest_path.display()))?; + let manifest: TrashManifest = + serde_json::from_str(&content).context("解析 manifest 失败")?; + out.push(TrashEntry { + home_name: manifest.home_name, + home_path: manifest.home_path, + session_id: manifest.session_id, + title: manifest.thread.title, + cwd: manifest.thread.cwd, + deleted_at: manifest.deleted_at, + trash_dir: entry.path().to_string_lossy().to_string(), + }); + } + Ok(out) +} + +fn homes(all_homes: bool) -> Result> { + let default_home = paths::codex_home(None)?; + if !all_homes { + return Ok(vec![HomeTarget { + name: "default".to_string(), + path: default_home, + }]); + } + + let store = Store::load(&default_home)?; + let mut seen = HashSet::new(); + let mut result = Vec::new(); + // De-duplicate paths because multiple names can point to the same home. + for home in &store.homes { + let path = paths::expand_home(PathBuf::from(&home.path)); + let key = path.to_string_lossy().to_string(); + if seen.insert(key) { + result.push(HomeTarget { + name: home.name.clone(), + path, + }); + } + } + Ok(result) +} + +fn thread_from_row(row: &Row<'_>) -> rusqlite::Result { + Ok(ThreadRowData { + id: row.get(0)?, + rollout_path: row.get(1)?, + created_at: row.get(2)?, + updated_at: row.get(3)?, + source: row.get(4)?, + model_provider: row.get(5)?, + cwd: row.get(6)?, + title: row.get(7)?, + sandbox_policy: row.get(8)?, + approval_mode: row.get(9)?, + tokens_used: row.get(10)?, + has_user_event: row.get(11)?, + archived: row.get(12)?, + archived_at: row.get(13)?, + git_sha: row.get(14)?, + git_branch: row.get(15)?, + git_origin_url: row.get(16)?, + cli_version: row.get(17)?, + first_user_message: row.get(18)?, + agent_nickname: row.get(19)?, + agent_role: row.get(20)?, + memory_mode: row.get(21)?, + model: row.get(22)?, + reasoning_effort: row.get(23)?, + agent_path: row.get(24)?, + created_at_ms: row.get(25)?, + updated_at_ms: row.get(26)?, + }) +} + +fn insert_thread(conn: &Connection, row: &ThreadRowData) -> Result<()> { + conn.execute( + "INSERT OR REPLACE INTO threads ( + id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, + sandbox_policy, approval_mode, tokens_used, has_user_event, archived, archived_at, + git_sha, git_branch, git_origin_url, cli_version, first_user_message, + agent_nickname, agent_role, memory_mode, model, reasoning_effort, agent_path, + created_at_ms, updated_at_ms + ) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, + ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27 + )", + params![ + row.id, + row.rollout_path, + row.created_at, + row.updated_at, + row.source, + row.model_provider, + row.cwd, + row.title, + row.sandbox_policy, + row.approval_mode, + row.tokens_used, + row.has_user_event, + row.archived, + row.archived_at, + row.git_sha, + row.git_branch, + row.git_origin_url, + row.cli_version, + row.first_user_message, + row.agent_nickname, + row.agent_role, + row.memory_mode, + row.model, + row.reasoning_effort, + row.agent_path, + row.created_at_ms, + row.updated_at_ms, + ], + )?; + Ok(()) +} + +fn summary_from_thread(home: &HomeTarget, row: &ThreadRowData) -> SessionSummary { + SessionSummary { + home_name: home.name.clone(), + home_path: home.path.to_string_lossy().to_string(), + id: row.id.clone(), + title: row.title.clone(), + cwd: row.cwd.clone(), + updated_at_ms: row + .updated_at_ms + .or(row.updated_at.map(|value| value * 1000)), + tokens_used: row.tokens_used.unwrap_or_default(), + rollout_path: row.rollout_path.clone(), + archived: row.archived.unwrap_or_default() != 0, + } +} + +fn rollout_stats(path: &Path) -> Result<(Option, Option, Option)> { + if !path.exists() { + return Ok((None, None, None)); + } + let metadata = fs::metadata(path) + .with_context(|| format!("读取 rollout 元数据失败: {}", path.display()))?; + let content = fs::read_to_string(path) + .with_context(|| format!("读取 rollout 失败: {}", path.display()))?; + let mut usage = None; + for line in content.lines() { + // Keep the last token usage record, which represents the latest model + // accounting in the rollout. + if let Ok(value) = serde_json::from_str::(line) { + if let Some(next) = find_token_usage(&value) { + usage = Some(next); + } + } + } + Ok((Some(metadata.len()), Some(content.lines().count()), usage)) +} + +#[derive(Debug, Clone)] +struct TokenUsage { + total_tokens: Option, + input_tokens: Option, + output_tokens: Option, +} + +fn find_token_usage(value: &Value) -> Option { + match value { + Value::Object(map) => { + if let Some(total) = map.get("total_token_usage") { + return Some(TokenUsage { + total_tokens: total.get("total_tokens").and_then(Value::as_i64), + input_tokens: total.get("input_tokens").and_then(Value::as_i64), + output_tokens: total.get("output_tokens").and_then(Value::as_i64), + }); + } + for child in map.values() { + if let Some(usage) = find_token_usage(child) { + return Some(usage); + } + } + None + } + Value::Array(items) => items.iter().find_map(find_token_usage), + _ => None, + } +} + +fn line_session_id(line: &str) -> Option { + serde_json::from_str::(line) + .ok() + .and_then(|value| value.get("id").and_then(Value::as_str).map(str::to_string)) +} + +fn state_db_path(home: &Path) -> PathBuf { + home.join("state_5.sqlite") +} + +fn resolve_rollout_path(home: &Path, rollout_path: &str) -> PathBuf { + let path = PathBuf::from(rollout_path); + if path.is_absolute() { + path + } else { + home.join(path) + } +} + +fn format_time(ms: Option) -> String { + let Some(ms) = ms else { + return "-".to_string(); + }; + DateTime::::from_timestamp_millis(ms) + .map(|value| value.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| ms.to_string()) +} + +fn opt_i64(value: Option) -> String { + value + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()) +} + +fn opt_u64(value: Option) -> String { + value + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()) +} + +fn safe_name(value: &str) -> String { + value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect() +} + +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.saturating_sub(3)) + .collect::(); + out.push_str("..."); + out +} diff --git a/src/sync_client.rs b/src/sync_client.rs new file mode 100644 index 0000000..d4b7d5d --- /dev/null +++ b/src/sync_client.rs @@ -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() { + "" + } 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 { + let mut headers = HeaderMap::new(); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {token}")).context("构建 Authorization 头失败")?, + ); + Ok(headers) +} diff --git a/src/token.rs b/src/token.rs new file mode 100644 index 0000000..b7d936c --- /dev/null +++ b/src/token.rs @@ -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 { + // 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(¤t_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 { + // 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, + }) +}