diff --git a/AGENTS.md b/AGENTS.md index a2dc32f2..afd2893a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,10 @@ --- +Gitは基本的にすべてユーザーが操作している。書き込みが必要な操作は明示的に許可されない限り行わないこと + +--- + `TODO.md`、`tickets/`はgitで管理されていて、時系列の管理はgitを参照して把握すること。 ### TODO.md diff --git a/CLAUDE.md b/CLAUDE.md index a2dc32f2..afd2893a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,10 @@ --- +Gitは基本的にすべてユーザーが操作している。書き込みが必要な操作は明示的に許可されない限り行わないこと + +--- + `TODO.md`、`tickets/`はgitで管理されていて、時系列の管理はgitを参照して把握すること。 ### TODO.md diff --git a/Cargo.lock b/Cargo.lock index 73b11544..f6d522e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,16 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -114,6 +124,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base64" version = "0.22.1" @@ -210,9 +242,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -234,6 +274,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -278,12 +319,31 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compact_str" version = "0.9.0" @@ -313,6 +373,16 @@ dependencies = [ "unicode-segmentation", ] +[[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" @@ -487,6 +557,24 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "deltae" version = "0.3.2" @@ -571,6 +659,12 @@ version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -745,6 +839,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" version = "0.3.32" @@ -850,8 +950,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -861,9 +963,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -980,6 +1084,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1025,6 +1135,12 @@ 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 = "hybrid-array" version = "0.4.10" @@ -1048,6 +1164,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -1105,9 +1222,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1355,6 +1474,60 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[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.94" @@ -1503,6 +1676,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac_address" version = "1.1.8" @@ -1572,6 +1751,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minijinja" version = "2.19.0" @@ -1675,6 +1860,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -1854,7 +2049,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -1947,6 +2142,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1979,12 +2183,77 @@ dependencies = [ name = "provider" version = "0.1.0" dependencies = [ + "async-trait", + "base64", + "chrono", "llm-worker", "manifest", + "reqwest", + "serde", + "serde_json", "serial_test", "tempfile", "thiserror 2.0.18", + "tokio", "toml", + "tracing", + "wiremock", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.59.0", ] [[package]] @@ -2014,7 +2283,27 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -2023,6 +2312,15 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "ratatui" version = "0.30.0" @@ -2195,6 +2493,7 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", + "encoding_rs", "futures-core", "futures-util", "h2", @@ -2207,15 +2506,20 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime", "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -2241,6 +2545,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2282,6 +2592,7 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "aws-lc-rs", "once_cell", "rustls-pki-types", "rustls-webpki", @@ -2289,21 +2600,62 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2392,7 +2744,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -2713,6 +3065,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "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 = "target-triple" version = "1.0.0" @@ -2895,10 +3268,25 @@ dependencies = [ ] [[package]] -name = "tokio" -version = "1.51.1" +name = "tinyvec" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -3428,6 +3816,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -3572,6 +3979,17 @@ 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" @@ -3590,13 +4008,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -3605,7 +4032,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -3617,34 +4044,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3657,24 +4117,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[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.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3687,6 +4171,29 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -3804,6 +4311,26 @@ dependencies = [ "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 2.0.117", +] + [[package]] name = "zerofrom" version = "0.1.7" diff --git a/TODO.md b/TODO.md index ce568e9d..a6846208 100644 --- a/TODO.md +++ b/TODO.md @@ -2,8 +2,6 @@ - [ ] ツール設計 - [ ] Bash ツール (Permission 層と統合) → [tickets/bash-tool.md](tickets/bash-tool.md) - [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md) -- [ ] LLM プロバイダ統合 - - [ ] Codex OAuth 認証の流用 → [tickets/llm-auth-codex-oauth.md](tickets/llm-auth-codex-oauth.md) - [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md) - [ ] Pod オーケストレーション - [ ] 動的 Scope 変更 → [tickets/dynamic-scope.md](tickets/dynamic-scope.md) diff --git a/crates/llm-worker/src/llm_client/auth.rs b/crates/llm-worker/src/llm_client/auth.rs index 7d0283e6..7d022f7f 100644 --- a/crates/llm-worker/src/llm_client/auth.rs +++ b/crates/llm-worker/src/llm_client/auth.rs @@ -1,4 +1,4 @@ -//! `Scheme` 実装と通信層が要求する認証要件。 +//! `Scheme` 実装と通信層が要求する認証要件、および動的認証プロバイダ。 //! //! マニフェスト側の型(`ModelConfig` / `SchemeKind` / `AuthRef`)は //! `crates/manifest` に置き、llm-worker はそれを知らずに済む。 @@ -6,6 +6,15 @@ //! 期待するか」のランタイム記述で、manifest 側の `AuthRef` との //! 照合(`AuthRef → ResolvedAuth` 変換の適否)は `crates/provider` //! で行う。 +//! +//! Codex OAuth のようにリクエスト毎にトークンが変わり得る認証は +//! [`AuthProvider`] trait を `crates/provider` 側で実装し、 +//! [`super::transport::ResolvedAuth::Custom`] 経由で transport に渡す。 + +use async_trait::async_trait; +use reqwest::header::{HeaderName, HeaderValue}; + +use super::error::ClientError; /// `Scheme::required_auth()` が返す認証要件。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -21,3 +30,19 @@ pub enum AuthRequirement { /// 複合ヘッダ(Codex OAuth 等、`crates/provider` 側で解決) Custom, } + +/// リクエスト毎に認証ヘッダを動的に組み立てるプロバイダ。 +/// +/// Codex OAuth のように access_token が refresh で更新されたり、 +/// `ChatGPT-Account-Id` / `X-OpenAI-Fedramp` のような複数ヘッダを +/// 同時に注入する必要があるケースで使う。実体は `crates/provider` +/// 側に置き、llm-worker は trait を知るだけ。 +/// +/// 返したヘッダはそのまま `HeaderMap` に挿入される。`Authorization` +/// 含む scheme 既定の認証ヘッダは送出されないので、必要なら +/// 実装側でセットすること。 +#[async_trait] +pub trait AuthProvider: Send + Sync + std::fmt::Debug { + /// 1 リクエスト分の認証ヘッダを返す。refresh が必要なら内部で行う。 + async fn headers(&self) -> Result, ClientError>; +} diff --git a/crates/llm-worker/src/llm_client/scheme/openai_responses/capability.rs b/crates/llm-worker/src/llm_client/scheme/openai_responses/capability.rs index 3092d491..8bdd509c 100644 --- a/crates/llm-worker/src/llm_client/scheme/openai_responses/capability.rs +++ b/crates/llm-worker/src/llm_client/scheme/openai_responses/capability.rs @@ -3,6 +3,11 @@ //! モデル family 判定は `scheme/openai_chat/capability.rs::classify` を //! 共有する。Responses 側は `ReasoningSupport::Effort` 固定で、prompt //! caching はサーバ側自動(`CacheStrategy::Auto`)。 +//! +//! `gpt-5-codex` は `gpt-5` prefix 経由で Reasoning 扱いされるが、 +//! `codex-mini-latest` 等 `codex-` prefix のモデルは ChatGPT backend +//! 経由(CodexOAuth)でしか使えないため、このテーブルでだけ Reasoning +//! にフォールバックする。 use crate::llm_client::capability::{ CacheStrategy, ModelCapability, ReasoningSupport, StructuredOutput, ToolCallingSupport, @@ -10,7 +15,14 @@ use crate::llm_client::capability::{ use crate::llm_client::scheme::openai_chat::capability::{OpenAiFamily, classify}; pub(crate) fn lookup(model_id: &str) -> Option { - classify(model_id).map(|family| match family { + let family = classify(model_id).or_else(|| { + if model_id.starts_with("codex-") { + Some(OpenAiFamily::Reasoning) + } else { + None + } + })?; + Some(match family { OpenAiFamily::Reasoning => ModelCapability { tool_calling: ToolCallingSupport::Parallel, structured_output: StructuredOutput::JsonSchema, @@ -35,6 +47,30 @@ pub(crate) fn lookup(model_id: &str) -> Option { }) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gpt_5_codex_is_reasoning() { + // `gpt-5` prefix で classify される + let cap = lookup("gpt-5-codex").unwrap(); + assert!(cap.reasoning.is_some()); + } + + #[test] + fn codex_mini_latest_is_reasoning() { + // ChatGPT backend 専用モデル。`codex-` prefix で Reasoning にフォールバック + let cap = lookup("codex-mini-latest").unwrap(); + assert!(cap.reasoning.is_some()); + } + + #[test] + fn unknown_model_returns_none() { + assert!(lookup("foo-bar-3000").is_none()); + } +} + pub(crate) fn default_capability() -> ModelCapability { ModelCapability { tool_calling: ToolCallingSupport::Parallel, diff --git a/crates/llm-worker/src/llm_client/transport.rs b/crates/llm-worker/src/llm_client/transport.rs index 63fbc5ef..4ea61593 100644 --- a/crates/llm-worker/src/llm_client/transport.rs +++ b/crates/llm-worker/src/llm_client/transport.rs @@ -5,13 +5,14 @@ //! scheme 固有の差分は [`Scheme`] trait 実装に委譲する。 use std::pin::Pin; +use std::sync::Arc; use async_trait::async_trait; use eventsource_stream::Eventsource; use futures::{Stream, StreamExt, TryStreamExt}; use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue}; -use super::auth::AuthRequirement; +use super::auth::{AuthProvider, AuthRequirement}; use super::capability::ModelCapability; use super::client::{ConfigWarning, LlmClient}; use super::error::ClientError; @@ -21,24 +22,28 @@ use super::types::{Request, RequestConfig}; /// `AuthRef` を解決したランタイム表現。`crates/provider` が構築する。 /// -/// `AuthRef::ApiKey` → 読み取った文字列、`AuthRef::None` → `None`。 -/// `CodexOAuth` 等、動的に更新される認証は別途 `Custom` バリアントを -/// 追加する余地を残す(本チケットでは未実装)。 +/// - `None`: 認証ヘッダを送らない(Ollama 等の opt-out) +/// - `ApiKey`: 静的な API key 文字列 +/// - `Custom`: リクエスト毎に動的にヘッダを組み立てる(Codex OAuth 等) #[derive(Debug, Clone)] pub enum ResolvedAuth { None, ApiKey(String), + Custom(Arc), } impl ResolvedAuth { /// 認証要件と実際の解決値が噛み合うか検査する。構築時検証用。 /// - /// `ResolvedAuth::None` は認証を付けないという宣言なので、どの - /// `AuthRequirement` でも受け入れる(Ollama の Anthropic scheme - /// 流用は `required_auth = XApiKey` だが認証ヘッダなしで動く)。 + /// - `ResolvedAuth::None` は認証を付けない宣言なので、どの + /// `AuthRequirement` でも受け入れる(Ollama の Anthropic scheme + /// 流用は `required_auth = XApiKey` だが認証ヘッダなしで動く) + /// - `ResolvedAuth::Custom` は「ヘッダ組立を全部こちらで行う」 + /// 宣言なので、scheme が要求する形式によらず受け入れる pub fn matches(&self, req: AuthRequirement) -> bool { match (self, req) { (Self::None, _) => true, + (Self::Custom(_), _) => true, ( Self::ApiKey(_), AuthRequirement::Bearer | AuthRequirement::XApiKey | AuthRequirement::QueryParam { .. }, @@ -100,29 +105,36 @@ impl HttpTransport { } } - fn build_headers(&self) -> Result { + async fn build_headers(&self) -> Result { let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); - match (self.scheme.required_auth(), &self.auth) { - (AuthRequirement::None, _) | (_, ResolvedAuth::None) => {} - (AuthRequirement::Bearer, ResolvedAuth::ApiKey(key)) => { + match (&self.auth, self.scheme.required_auth()) { + (ResolvedAuth::None, _) | (_, AuthRequirement::None) => {} + (ResolvedAuth::Custom(provider), _) => { + for (name, mut value) in provider.headers().await? { + value.set_sensitive(true); + headers.insert(name, value); + } + } + (ResolvedAuth::ApiKey(key), AuthRequirement::Bearer) => { let mut val = HeaderValue::from_str(&format!("Bearer {key}")) .map_err(|e| ClientError::Config(format!("invalid api key: {e}")))?; val.set_sensitive(true); headers.insert("Authorization", val); } - (AuthRequirement::XApiKey, ResolvedAuth::ApiKey(key)) => { + (ResolvedAuth::ApiKey(key), AuthRequirement::XApiKey) => { let mut val = HeaderValue::from_str(key.as_str()) .map_err(|e| ClientError::Config(format!("invalid api key: {e}")))?; val.set_sensitive(true); headers.insert("x-api-key", val); } - (AuthRequirement::QueryParam { .. }, _) => { + (_, AuthRequirement::QueryParam { .. }) => { // クエリパラメータは `build_url` で付与済み } - (AuthRequirement::Custom, _) => { - // 今チケットでは Custom は使わない。Codex OAuth で追加予定 + (ResolvedAuth::ApiKey(_), AuthRequirement::Custom) => { + // scheme が Custom を要求する組合せに ApiKey は流れてこない想定 + // (`matches()` で弾かれる)。安全側で何もしない } } @@ -164,7 +176,7 @@ impl LlmClient for HttpTransport { request: Request, ) -> Result> + Send>>, ClientError> { let url = self.build_url(); - let headers = self.build_headers()?; + let headers = self.build_headers().await?; let body = self .scheme .build_request_body(&self.model_id, &request, &self.capability); diff --git a/crates/provider/Cargo.toml b/crates/provider/Cargo.toml index cc6dd37b..b88e2508 100644 --- a/crates/provider/Cargo.toml +++ b/crates/provider/Cargo.toml @@ -5,11 +5,20 @@ edition.workspace = true license.workspace = true [dependencies] +async-trait = "0.1.89" +base64 = "0.22.1" +chrono = { version = "0.4.44", default-features = false, features = ["serde", "clock"] } llm-worker = { version = "0.2.1", path = "../llm-worker" } manifest = { version = "0.1.0", path = "../manifest" } +reqwest = { version = "0.13.2", features = ["json", "native-tls"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" thiserror = "2.0" +tokio = { version = "1.52.1", features = ["sync", "fs", "rt"] } +tracing = "0.1.44" [dev-dependencies] serial_test = "3.4.0" tempfile = "3.27.0" toml = "1.1.2" +wiremock = "0.6.5" diff --git a/crates/provider/src/codex_oauth/auth_json.rs b/crates/provider/src/codex_oauth/auth_json.rs new file mode 100644 index 00000000..4a0da884 --- /dev/null +++ b/crates/provider/src/codex_oauth/auth_json.rs @@ -0,0 +1,265 @@ +//! `~/.codex/auth.json` の読み書き。 +//! +//! Codex CLI と schema を共有するが、insomnia は知らないフィールドを +//! 失わないようファイル全体を `serde_json::Value` で保持し、必要箇所 +//! のみアクセスする。書込は `mode 0o600` を再設定(Codex CLI 同様)、 +//! ファイルロックは取らない(manager 側で guarded reload)。 + +use std::fs::OpenOptions; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; + +use chrono::{DateTime, Utc}; +use serde_json::Value; + +use super::error::CodexAuthError; + +/// auth.json から取り出した使い回す情報のスナップショット。 +#[derive(Debug, Clone)] +pub struct AuthSnapshot { + pub access_token: String, + pub refresh_token: String, + pub account_id: String, + pub id_token: String, + pub last_refresh: Option>, + /// 書き戻し時に他のフィールドを失わないため、ファイル全体を保持する。 + pub raw: Value, +} + +impl AuthSnapshot { + /// `Value` から必要フィールドを抽出。欠落・型不一致は `MalformedAuthJson`。 + pub fn from_value(raw: Value) -> Result { + let tokens = raw + .get("tokens") + .ok_or_else(|| CodexAuthError::MalformedAuthJson("missing 'tokens'".into()))?; + + let access_token = tokens + .get("access_token") + .and_then(Value::as_str) + .ok_or_else(|| CodexAuthError::MalformedAuthJson("missing tokens.access_token".into()))? + .to_string(); + + let refresh_token = tokens + .get("refresh_token") + .and_then(Value::as_str) + .ok_or_else(|| CodexAuthError::MalformedAuthJson("missing tokens.refresh_token".into()))? + .to_string(); + + let id_token = tokens + .get("id_token") + .and_then(Value::as_str) + .ok_or_else(|| CodexAuthError::MalformedAuthJson("missing tokens.id_token".into()))? + .to_string(); + + // account_id は tokens.account_id を優先、無ければ id_token JWT 由来 + let account_id = tokens + .get("account_id") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + super::jwt::parse_chatgpt_claims(&id_token).and_then(|c| c.account_id) + }) + .ok_or_else(|| { + CodexAuthError::MalformedAuthJson( + "missing account_id in both tokens and id_token claims".into(), + ) + })?; + + let last_refresh = raw + .get("last_refresh") + .and_then(Value::as_str) + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&Utc)); + + Ok(Self { + access_token, + refresh_token, + account_id, + id_token, + last_refresh, + raw, + }) + } +} + +/// auth.json を読む。存在しなければ `NotLoggedIn`。 +pub async fn load(path: &Path) -> Result { + let bytes = match tokio::fs::read(path).await { + Ok(b) => b, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Err(CodexAuthError::NotLoggedIn(path.to_path_buf())); + } + Err(err) => { + return Err(CodexAuthError::Io(format!( + "failed to read {}: {err}", + path.display() + ))); + } + }; + let raw: Value = serde_json::from_slice(&bytes) + .map_err(|e| CodexAuthError::MalformedAuthJson(format!("json parse: {e}")))?; + AuthSnapshot::from_value(raw) +} + +/// 既存ファイルを再読込し、`tokens.{id_token,access_token,refresh_token}` と +/// `last_refresh` を更新して書き戻す。Codex CLI の `persist_tokens` 相当。 +/// +/// 並行する Codex CLI / 別 insomnia プロセスが先に refresh していた場合の +/// fields を保護するため、書込前に再 load して merge する。 +pub async fn persist_refreshed( + path: &Path, + new_id_token: Option, + new_access_token: Option, + new_refresh_token: Option, +) -> Result { + let mut current = load(path).await?; + let raw = &mut current.raw; + let tokens = raw + .get_mut("tokens") + .and_then(Value::as_object_mut) + .ok_or_else(|| CodexAuthError::MalformedAuthJson("tokens not an object".into()))?; + if let Some(t) = new_id_token { + tokens.insert("id_token".into(), Value::String(t)); + } + if let Some(t) = new_access_token { + tokens.insert("access_token".into(), Value::String(t)); + } + if let Some(t) = new_refresh_token { + tokens.insert("refresh_token".into(), Value::String(t)); + } + raw.as_object_mut() + .ok_or_else(|| CodexAuthError::MalformedAuthJson("auth.json not an object".into()))? + .insert("last_refresh".into(), Value::String(Utc::now().to_rfc3339())); + + write_atomic(path, raw)?; + AuthSnapshot::from_value(raw.clone()) +} + +fn write_atomic(path: &Path, value: &Value) -> Result<(), CodexAuthError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + CodexAuthError::Io(format!("create_dir_all {}: {e}", parent.display())) + })?; + } + let json = serde_json::to_vec_pretty(value) + .map_err(|e| CodexAuthError::Io(format!("serialize: {e}")))?; + let mut options = OpenOptions::new(); + options.truncate(true).write(true).create(true); + #[cfg(unix)] + { + options.mode(0o600); + } + let mut file = options + .open(path) + .map_err(|e| CodexAuthError::Io(format!("open {}: {e}", path.display())))?; + file.write_all(&json) + .map_err(|e| CodexAuthError::Io(format!("write {}: {e}", path.display())))?; + file.flush() + .map_err(|e| CodexAuthError::Io(format!("flush {}: {e}", path.display())))?; + + // 既存ファイルが緩いパーミッションだった場合に備えて 0o600 を強制し直す。 + // OpenOptions の `mode()` は新規作成時しか効かないため。 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| CodexAuthError::Io(format!("chmod {}: {e}", path.display())))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_auth_json(dir: &Path, content: &str) -> std::path::PathBuf { + let path = dir.join("auth.json"); + std::fs::write(&path, content).unwrap(); + path + } + + #[tokio::test] + async fn load_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = write_auth_json( + dir.path(), + r#"{ + "auth_mode":"ChatgptAuthTokens", + "tokens":{ + "id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjLTEifX0.s", + "access_token":"acc", + "refresh_token":"ref", + "account_id":"acc-1" + }, + "last_refresh":"2026-04-20T00:00:00Z", + "OPENAI_API_KEY":"sk-extra" + }"#, + ); + let snap = load(&path).await.unwrap(); + assert_eq!(snap.access_token, "acc"); + assert_eq!(snap.refresh_token, "ref"); + assert_eq!(snap.account_id, "acc-1"); + assert!(snap.last_refresh.is_some()); + // 未知フィールドが raw に保持されている + assert_eq!(snap.raw.get("OPENAI_API_KEY").and_then(Value::as_str), Some("sk-extra")); + } + + #[tokio::test] + async fn account_id_falls_back_to_jwt_claim() { + let dir = tempfile::tempdir().unwrap(); + // tokens.account_id を欠落させ、id_token JWT 内 claim から拾う + let id_token = "h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiZnJvbS1qd3QifX0.s"; + let path = write_auth_json( + dir.path(), + &format!( + r#"{{ "tokens": {{ "id_token":"{id_token}", "access_token":"a", "refresh_token":"r" }} }}"# + ), + ); + let snap = load(&path).await.unwrap(); + assert_eq!(snap.account_id, "from-jwt"); + } + + #[tokio::test] + async fn missing_file_returns_not_logged_in() { + let dir = tempfile::tempdir().unwrap(); + let err = load(&dir.path().join("nope.json")).await.unwrap_err(); + assert!(matches!(err, CodexAuthError::NotLoggedIn(_))); + } + + #[tokio::test] + async fn persist_preserves_unknown_fields() { + let dir = tempfile::tempdir().unwrap(); + let path = write_auth_json( + dir.path(), + r#"{ + "tokens":{"id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYSJ9fQ.s","access_token":"old-acc","refresh_token":"old-ref","account_id":"a"}, + "agent_identity":{"workspace_id":"w","agent_runtime_id":"r","agent_private_key":"k","registered_at":"x"} + }"#, + ); + let updated = persist_refreshed(&path, None, Some("new-acc".into()), Some("new-ref".into())).await.unwrap(); + assert_eq!(updated.access_token, "new-acc"); + assert_eq!(updated.refresh_token, "new-ref"); + // 未知フィールド agent_identity が保たれる + let on_disk: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert!(on_disk.get("agent_identity").is_some()); + assert!(on_disk.get("last_refresh").is_some()); + } + + #[cfg(unix)] + #[tokio::test] + async fn write_uses_mode_600() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = write_auth_json( + dir.path(), + r#"{"tokens":{"id_token":"h.eyJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYSJ9fQ.s","access_token":"a","refresh_token":"r","account_id":"a"}}"#, + ); + // 既存ファイルを 644 に変えてから persist → 600 に直るか + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + persist_refreshed(&path, None, Some("a2".into()), None).await.unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } +} diff --git a/crates/provider/src/codex_oauth/error.rs b/crates/provider/src/codex_oauth/error.rs new file mode 100644 index 00000000..2da4522f --- /dev/null +++ b/crates/provider/src/codex_oauth/error.rs @@ -0,0 +1,72 @@ +//! Codex OAuth サブモジュール内部のエラー型。 +//! +//! `LlmClient` 境界に渡す際は `to_client_error` で `ClientError` に +//! 変換する。`Permanent` 系(refresh_token 失効)は `codex login` の +//! 再実行案内付きメッセージにする。 + +use std::path::PathBuf; + +use llm_worker::llm_client::ClientError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum CodexAuthError { + #[error( + "not logged in to ChatGPT: {0} not found. Run `codex login` and ensure cli_auth_credentials_store = \"file\"." + )] + NotLoggedIn(PathBuf), + + #[error("malformed ~/.codex/auth.json: {0}")] + MalformedAuthJson(String), + + #[error("io error: {0}")] + Io(String), + + #[error("token refresh failed (transient): {0}")] + RefreshTransient(String), + + /// refresh_token が永続的に失効。再ログインが必要。 + #[error("token refresh failed permanently ({reason:?}): {message}")] + RefreshPermanent { + reason: PermanentReason, + message: String, + }, + + #[error("invalid header value: {0}")] + InvalidHeader(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PermanentReason { + Expired, + Reused, + Revoked, + Other, +} + +impl CodexAuthError { + /// `LlmClient` トランスポート境界向けに変換する。 + pub fn to_client_error(self) -> ClientError { + match self { + CodexAuthError::NotLoggedIn(_) + | CodexAuthError::MalformedAuthJson(_) + | CodexAuthError::Io(_) + | CodexAuthError::InvalidHeader(_) => ClientError::Config(self.to_string()), + CodexAuthError::RefreshTransient(msg) => ClientError::Api { + status: None, + code: Some("refresh_transient".into()), + message: msg, + }, + CodexAuthError::RefreshPermanent { reason, message } => ClientError::Api { + status: Some(401), + code: Some(match reason { + PermanentReason::Expired => "refresh_token_expired".into(), + PermanentReason::Reused => "refresh_token_reused".into(), + PermanentReason::Revoked => "refresh_token_invalidated".into(), + PermanentReason::Other => "refresh_token_failed".into(), + }), + message: format!("{message}. Please run `codex login` again."), + }, + } + } +} diff --git a/crates/provider/src/codex_oauth/jwt.rs b/crates/provider/src/codex_oauth/jwt.rs new file mode 100644 index 00000000..16aa8263 --- /dev/null +++ b/crates/provider/src/codex_oauth/jwt.rs @@ -0,0 +1,104 @@ +//! JWT payload の最小限のパース(署名検証なし)。 +//! +//! Codex CLI と同じく、access_token / id_token の payload を base64url +//! デコードして `exp` や ChatGPT 固有 claims を取り出すためだけに使う。 + +use base64::Engine; +use chrono::{DateTime, Utc}; +use serde::Deserialize; + +/// `Authorization: Bearer` で送る access_token JWT の `exp` を読む。 +pub fn parse_exp(jwt: &str) -> Option> { + #[derive(Deserialize)] + struct Claims { + exp: Option, + } + let claims: Claims = decode_payload(jwt).ok()?; + DateTime::::from_timestamp(claims.exp?, 0) +} + +/// id_token JWT から ChatGPT 固有 claims を取り出す。 +pub fn parse_chatgpt_claims(jwt: &str) -> Option { + #[derive(Deserialize)] + struct IdClaims { + #[serde(rename = "https://api.openai.com/auth", default)] + auth: Option, + } + #[derive(Deserialize)] + struct AuthClaims { + #[serde(default)] + chatgpt_account_id: Option, + #[serde(default)] + chatgpt_account_is_fedramp: bool, + } + let claims: IdClaims = decode_payload(jwt).ok()?; + let auth = claims.auth?; + Some(ChatGptClaims { + account_id: auth.chatgpt_account_id, + is_fedramp: auth.chatgpt_account_is_fedramp, + }) +} + +#[derive(Debug, Clone)] +pub struct ChatGptClaims { + pub account_id: Option, + pub is_fedramp: bool, +} + +fn decode_payload Deserialize<'de>>(jwt: &str) -> Result { + let payload = jwt.split('.').nth(1).ok_or(JwtError::InvalidFormat)?; + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .map_err(|_| JwtError::InvalidBase64)?; + serde_json::from_slice(&bytes).map_err(|_| JwtError::InvalidJson) +} + +#[derive(Debug)] +enum JwtError { + InvalidFormat, + InvalidBase64, + InvalidJson, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_jwt(payload: &serde_json::Value) -> String { + let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(payload).unwrap()); + format!("h.{payload_b64}.s") + } + + #[test] + fn parses_exp() { + let jwt = make_jwt(&serde_json::json!({ "exp": 1_700_000_000_i64 })); + let dt = parse_exp(&jwt).unwrap(); + assert_eq!(dt.timestamp(), 1_700_000_000); + } + + #[test] + fn parses_chatgpt_claims() { + let jwt = make_jwt(&serde_json::json!({ + "https://api.openai.com/auth": { + "chatgpt_account_id": "acc-123", + "chatgpt_account_is_fedramp": true, + } + })); + let c = parse_chatgpt_claims(&jwt).unwrap(); + assert_eq!(c.account_id.as_deref(), Some("acc-123")); + assert!(c.is_fedramp); + } + + #[test] + fn missing_exp_returns_none() { + let jwt = make_jwt(&serde_json::json!({})); + assert!(parse_exp(&jwt).is_none()); + } + + #[test] + fn malformed_jwt_returns_none() { + assert!(parse_exp("not-a-jwt").is_none()); + assert!(parse_chatgpt_claims("x.y").is_none()); + } +} diff --git a/crates/provider/src/codex_oauth/mod.rs b/crates/provider/src/codex_oauth/mod.rs new file mode 100644 index 00000000..09d1558e --- /dev/null +++ b/crates/provider/src/codex_oauth/mod.rs @@ -0,0 +1,334 @@ +//! ChatGPT OAuth (`~/.codex/auth.json`) を読んで `Authorization` / +//! `ChatGPT-Account-Id` / `X-OpenAI-Fedramp` ヘッダを組み立てる +//! [`AuthProvider`] 実装。 +//! +//! 設計: +//! +//! - llm-worker は [`AuthProvider`] trait しか知らず、実体である +//! [`CodexAuthProvider`] はこのクレートに置く(feedback_llm_worker_scope) +//! - access_token JWT の `exp` を読み、`now` 以下で proactive refresh +//! (Codex CLI と同じバッファなし) +//! - 並行する Codex CLI / 別 insomnia の refresh と取り違えないよう、 +//! refresh 直前に再 load して account_id 一致を確認(guarded reload) +//! - ファイルロックは取らず、書込前に再 load + diff merge で吸収 +//! - Codex の Keyring storage は対象外。auth.json 不在ならエラーで案内 + +mod auth_json; +mod error; +mod jwt; +mod refresh; + +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::{Duration, Utc}; +use llm_worker::llm_client::{ + ClientError, + auth::AuthProvider, +}; +use reqwest::header::{HeaderName, HeaderValue}; +use tokio::sync::Mutex; + +use auth_json::AuthSnapshot; +use error::CodexAuthError; + +pub use error::PermanentReason; + +/// Codex CLI の `last_refresh` ベース fallback 期限(Codex CLI 準拠で 8 日)。 +const TOKEN_REFRESH_INTERVAL_DAYS: i64 = 8; + +/// `~/.codex/auth.json` を読んで Codex 互換のヘッダを返す provider。 +pub struct CodexAuthProvider { + auth_path: PathBuf, + refresh_endpoint: String, + http: reqwest::Client, + state: Arc>, +} + +impl std::fmt::Debug for CodexAuthProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CodexAuthProvider") + .field("auth_path", &self.auth_path) + .field("refresh_endpoint", &self.refresh_endpoint) + .finish_non_exhaustive() + } +} + +#[derive(Default)] +struct State { + cached: Option, +} + +impl CodexAuthProvider { + /// `CODEX_HOME` → `$HOME/.codex` の順で auth.json の場所を決める。 + pub fn from_default_home() -> Result { + let codex_home = if let Ok(p) = std::env::var("CODEX_HOME") { + PathBuf::from(p) + } else { + let home = std::env::var("HOME") + .map_err(|_| ClientError::Config("HOME not set".into()))?; + PathBuf::from(home).join(".codex") + }; + Ok(Self::new(codex_home)) + } + + /// 任意の codex_home から構築する。 + pub fn new(codex_home: PathBuf) -> Self { + Self { + auth_path: codex_home.join("auth.json"), + refresh_endpoint: refresh::REFRESH_URL.to_string(), + http: reqwest::Client::new(), + state: Arc::new(Mutex::new(State::default())), + } + } + + /// テスト用に refresh エンドポイントを差し替える。 + #[cfg(test)] + fn with_refresh_endpoint(mut self, endpoint: impl Into) -> Self { + self.refresh_endpoint = endpoint.into(); + self + } + + /// テスト用に HTTP クライアントを差し替える。 + #[cfg(test)] + fn with_http_client(mut self, client: reqwest::Client) -> Self { + self.http = client; + self + } + + async fn ensure_fresh(&self) -> Result { + let mut state = self.state.lock().await; + + // 1. 常にディスクから最新を読む(他プロセスの更新を反映) + let snap = auth_json::load(&self.auth_path).await?; + + // 2. stale でなければそのまま返す + if !is_stale(&snap) { + state.cached = Some(snap.clone()); + return Ok(snap); + } + + // 3. Refresh 直前に再 load し account_id 一致を確認(guarded reload)。 + // 一致しなければ他プロセスが先に更新済 → 自分は refresh しない + let pre_refresh = auth_json::load(&self.auth_path).await?; + if !is_stale(&pre_refresh) { + state.cached = Some(pre_refresh.clone()); + return Ok(pre_refresh); + } + if pre_refresh.account_id != snap.account_id { + // account が切り替わった → 新しい auth を採用、refresh はしない + state.cached = Some(pre_refresh.clone()); + return Ok(pre_refresh); + } + + // 4. Refresh 実行 + let refreshed = refresh::request_refresh( + &self.http, + &self.refresh_endpoint, + &pre_refresh.refresh_token, + ) + .await?; + + // 5. 書き戻し(書込前に再 load + merge は persist_refreshed 内で実施) + let new_snap = auth_json::persist_refreshed( + &self.auth_path, + refreshed.id_token, + refreshed.access_token, + refreshed.refresh_token, + ) + .await?; + state.cached = Some(new_snap.clone()); + Ok(new_snap) + } + + fn build_headers(snap: &AuthSnapshot) -> Result, CodexAuthError> { + let mut out = Vec::with_capacity(3); + + let auth_val = HeaderValue::from_str(&format!("Bearer {}", snap.access_token)) + .map_err(|e| CodexAuthError::InvalidHeader(format!("Authorization: {e}")))?; + out.push((HeaderName::from_static("authorization"), auth_val)); + + let acc_val = HeaderValue::from_str(&snap.account_id) + .map_err(|e| CodexAuthError::InvalidHeader(format!("ChatGPT-Account-Id: {e}")))?; + out.push(( + HeaderName::from_static("chatgpt-account-id"), + acc_val, + )); + + // FedRAMP 組織は id_token JWT 内の claim で判定 + if jwt::parse_chatgpt_claims(&snap.id_token) + .map(|c| c.is_fedramp) + .unwrap_or(false) + { + out.push(( + HeaderName::from_static("x-openai-fedramp"), + HeaderValue::from_static("true"), + )); + } + + Ok(out) + } +} + +#[async_trait] +impl AuthProvider for CodexAuthProvider { + async fn headers(&self) -> Result, ClientError> { + let snap = self.ensure_fresh().await.map_err(CodexAuthError::to_client_error)?; + Self::build_headers(&snap).map_err(CodexAuthError::to_client_error) + } +} + +/// `access_token` の JWT `exp` を見て、期限切れなら true。 +/// `exp` が読めない場合は `last_refresh + 8 日` で判定(Codex CLI と同じ)。 +fn is_stale(snap: &AuthSnapshot) -> bool { + if let Some(exp) = jwt::parse_exp(&snap.access_token) { + return exp <= Utc::now(); + } + match snap.last_refresh { + Some(last) => last < Utc::now() - Duration::days(TOKEN_REFRESH_INTERVAL_DAYS), + None => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + use serde_json::json; + use wiremock::matchers::{method, path as url_path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn make_jwt(payload: serde_json::Value) -> String { + let p = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(serde_json::to_vec(&payload).unwrap()); + format!("h.{p}.s") + } + + fn write_auth(dir: &std::path::Path, exp: i64, fedramp: bool, refresh: &str) -> PathBuf { + let id_token = make_jwt(json!({ + "https://api.openai.com/auth": { + "chatgpt_account_id": "acc-xyz", + "chatgpt_account_is_fedramp": fedramp, + } + })); + let access_token = make_jwt(json!({ "exp": exp })); + let auth = json!({ + "tokens": { + "id_token": id_token, + "access_token": access_token, + "refresh_token": refresh, + "account_id": "acc-xyz", + }, + "last_refresh": "2026-04-20T00:00:00Z", + }); + let path = dir.join("auth.json"); + std::fs::write(&path, serde_json::to_vec_pretty(&auth).unwrap()).unwrap(); + path + } + + #[tokio::test] + async fn returns_headers_for_fresh_token() { + let dir = tempfile::tempdir().unwrap(); + // 期限を遠い未来に + write_auth(dir.path(), Utc::now().timestamp() + 3600, false, "rt"); + let provider = CodexAuthProvider::new(dir.path().to_path_buf()); + + let headers = provider.headers().await.unwrap(); + let names: Vec<_> = headers.iter().map(|(n, _)| n.as_str().to_string()).collect(); + assert!(names.contains(&"authorization".to_string())); + assert!(names.contains(&"chatgpt-account-id".to_string())); + assert!(!names.contains(&"x-openai-fedramp".to_string())); + + let acc = headers + .iter() + .find(|(n, _)| n.as_str() == "chatgpt-account-id") + .unwrap(); + assert_eq!(acc.1.to_str().unwrap(), "acc-xyz"); + } + + #[tokio::test] + async fn fedramp_header_added_when_claim_set() { + let dir = tempfile::tempdir().unwrap(); + write_auth(dir.path(), Utc::now().timestamp() + 3600, true, "rt"); + let provider = CodexAuthProvider::new(dir.path().to_path_buf()); + + let headers = provider.headers().await.unwrap(); + let fedramp = headers + .iter() + .find(|(n, _)| n.as_str() == "x-openai-fedramp"); + assert!(fedramp.is_some()); + assert_eq!(fedramp.unwrap().1, "true"); + } + + #[tokio::test] + async fn refreshes_when_expired_and_persists() { + let dir = tempfile::tempdir().unwrap(); + let path = write_auth(dir.path(), Utc::now().timestamp() - 60, false, "old-refresh"); + + // refresh エンドポイントを mock。新しい JWT (将来 exp) を返す + let server = MockServer::start().await; + let new_access = make_jwt(json!({ "exp": Utc::now().timestamp() + 3600 })); + let new_refresh = "new-refresh-token"; + Mock::given(method("POST")) + .and(url_path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": new_access, + "refresh_token": new_refresh, + }))) + .expect(1) + .mount(&server) + .await; + + let provider = CodexAuthProvider::new(dir.path().to_path_buf()) + .with_refresh_endpoint(format!("{}/oauth/token", server.uri())) + .with_http_client(reqwest::Client::new()); + + let headers = provider.headers().await.unwrap(); + let auth = headers + .iter() + .find(|(n, _)| n.as_str() == "authorization") + .unwrap(); + assert_eq!(auth.1.to_str().unwrap(), format!("Bearer {new_access}")); + + // ファイルに新しい refresh_token が書き戻されている + let on_disk: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!( + on_disk["tokens"]["refresh_token"].as_str(), + Some(new_refresh) + ); + } + + #[tokio::test] + async fn permanent_refresh_failure_surfaces_login_message() { + let dir = tempfile::tempdir().unwrap(); + write_auth(dir.path(), Utc::now().timestamp() - 60, false, "bad-refresh"); + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(url_path("/oauth/token")) + .respond_with(ResponseTemplate::new(401).set_body_json(json!({ + "error": { "code": "refresh_token_expired" } + }))) + .mount(&server) + .await; + + let provider = CodexAuthProvider::new(dir.path().to_path_buf()) + .with_refresh_endpoint(format!("{}/oauth/token", server.uri())) + .with_http_client(reqwest::Client::new()); + + let err = provider.headers().await.unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("codex login"), "expected hint, got: {msg}"); + } + + #[tokio::test] + async fn missing_auth_json_reports_not_logged_in() { + let dir = tempfile::tempdir().unwrap(); + let provider = CodexAuthProvider::new(dir.path().to_path_buf()); + let err = provider.headers().await.unwrap_err(); + let msg = format!("{err}"); + assert!(msg.contains("not logged in"), "got: {msg}"); + } +} diff --git a/crates/provider/src/codex_oauth/refresh.rs b/crates/provider/src/codex_oauth/refresh.rs new file mode 100644 index 00000000..97fc0168 --- /dev/null +++ b/crates/provider/src/codex_oauth/refresh.rs @@ -0,0 +1,134 @@ +//! ChatGPT OAuth トークンの refresh HTTP 呼出。 +//! +//! Codex CLI と同じ `POST https://auth.openai.com/oauth/token` 形式。 +//! 401 + `error.code` で永続失敗を分類する。 + +use serde::{Deserialize, Serialize}; + +use super::error::{CodexAuthError, PermanentReason}; + +pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; +pub const REFRESH_URL: &str = "https://auth.openai.com/oauth/token"; + +#[derive(Serialize)] +struct RefreshRequest<'a> { + client_id: &'static str, + grant_type: &'static str, + refresh_token: &'a str, +} + +#[derive(Deserialize, Debug, Default, Clone)] +pub struct RefreshResponse { + #[serde(default)] + pub id_token: Option, + #[serde(default)] + pub access_token: Option, + #[serde(default)] + pub refresh_token: Option, +} + +/// refresh_token を使って新しいトークン群を取得する。 +/// +/// 永続失敗(401 + `refresh_token_(expired|reused|invalidated)`)は +/// `RefreshPermanent`、それ以外は `RefreshTransient`。 +pub async fn request_refresh( + client: &reqwest::Client, + endpoint: &str, + refresh_token: &str, +) -> Result { + let body = RefreshRequest { + client_id: CLIENT_ID, + grant_type: "refresh_token", + refresh_token, + }; + let response = client + .post(endpoint) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|e| CodexAuthError::RefreshTransient(format!("send: {e}")))?; + + let status = response.status(); + if status.is_success() { + response + .json::() + .await + .map_err(|e| CodexAuthError::RefreshTransient(format!("parse response: {e}"))) + } else { + let body = response.text().await.unwrap_or_default(); + if status == reqwest::StatusCode::UNAUTHORIZED { + let (reason, message) = classify_permanent(&body); + Err(CodexAuthError::RefreshPermanent { reason, message }) + } else { + Err(CodexAuthError::RefreshTransient(format!( + "{status}: {body}" + ))) + } + } +} + +fn classify_permanent(body: &str) -> (PermanentReason, String) { + let code = extract_error_code(body); + let reason = match code.as_deref() { + Some("refresh_token_expired") => PermanentReason::Expired, + Some("refresh_token_reused") => PermanentReason::Reused, + Some("refresh_token_invalidated") => PermanentReason::Revoked, + _ => PermanentReason::Other, + }; + let message = match reason { + PermanentReason::Expired => "Your refresh token has expired".to_string(), + PermanentReason::Reused => "Your refresh token was already used".to_string(), + PermanentReason::Revoked => "Your refresh token was revoked".to_string(), + PermanentReason::Other => format!("Unknown 401 from refresh endpoint: {body}"), + }; + (reason, message) +} + +fn extract_error_code(body: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(body).ok()?; + if let Some(error) = value.get("error") { + if let Some(obj) = error.as_object() { + if let Some(code) = obj.get("code").and_then(|v| v.as_str()) { + return Some(code.to_string()); + } + } + if let Some(s) = error.as_str() { + return Some(s.to_string()); + } + } + value.get("code").and_then(|v| v.as_str()).map(str::to_string) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_expired() { + let body = r#"{"error":{"code":"refresh_token_expired"}}"#; + let (r, _) = classify_permanent(body); + assert_eq!(r, PermanentReason::Expired); + } + + #[test] + fn classify_reused() { + let body = r#"{"error":{"code":"refresh_token_reused"}}"#; + let (r, _) = classify_permanent(body); + assert_eq!(r, PermanentReason::Reused); + } + + #[test] + fn classify_unknown_falls_to_other() { + let body = r#"{"error":{"code":"weird"}}"#; + let (r, _) = classify_permanent(body); + assert_eq!(r, PermanentReason::Other); + } + + #[test] + fn classify_top_level_code() { + let body = r#"{"code":"refresh_token_invalidated"}"#; + let (r, _) = classify_permanent(body); + assert_eq!(r, PermanentReason::Revoked); + } +} diff --git a/crates/provider/src/lib.rs b/crates/provider/src/lib.rs index e4e8c2dc..b97e8ca5 100644 --- a/crates/provider/src/lib.rs +++ b/crates/provider/src/lib.rs @@ -10,6 +10,10 @@ //! なる認証ストア解決(Codex OAuth の `~/.codex/auth.json` 読取等)は //! このクレートに追加する。 +pub mod codex_oauth; + +use std::sync::Arc; + use llm_worker::llm_client::{ LlmClient, capability::ModelCapability, @@ -74,12 +78,25 @@ fn resolve_auth( } Err(ProviderError::ApiKeyMissing { scheme }) } - AuthRef::CodexOAuth => Err(ProviderError::Config( - "codex_oauth auth not yet implemented (tickets/llm-auth-codex-oauth)".into(), - )), + AuthRef::CodexOAuth => { + let provider = codex_oauth::CodexAuthProvider::from_default_home() + .map_err(|e| ProviderError::Config(e.to_string()))?; + Ok(ResolvedAuth::Custom(Arc::new(provider))) + } } } +/// `AuthRef::CodexOAuth` 指定時、`base_url` 未指定なら ChatGPT backend を既定とする。 +fn effective_base_url(scheme: &S, config: &ModelConfig) -> String { + if let Some(b) = &config.base_url { + return b.clone(); + } + if matches!(config.auth, AuthRef::CodexOAuth) { + return "https://chatgpt.com/backend-api".to_string(); + } + scheme.default_base_url().to_string() +} + fn build_transport( scheme: S, config: &ModelConfig, @@ -100,10 +117,7 @@ fn build_transport( .clone() .or_else(|| scheme.capability_for(&config.model_id)) .unwrap_or_else(|| scheme.default_capability()); - let base_url = config - .base_url - .clone() - .unwrap_or_else(|| scheme.default_base_url().to_string()); + let base_url = effective_base_url(&scheme, config); Ok(Box::new(HttpTransport::new( scheme, config.model_id.clone(), diff --git a/tickets/llm-auth-codex-oauth.md b/tickets/llm-auth-codex-oauth.md index 756e1aa2..4d5da248 100644 --- a/tickets/llm-auth-codex-oauth.md +++ b/tickets/llm-auth-codex-oauth.md @@ -1,70 +1,82 @@ # Codex OAuth 認証の流用 +**Status: Reviewed / Approved**(詳細は `llm-auth-codex-oauth.review.md`) + ## 背景 決定済み方針(`docs/plan/llm_providers.md`)で、ChatGPT サブスクリプションの OAuth トークンを流用して OpenAI Responses API を叩く経路を第一級サポートとする。OpenAI は Codex CLI を Apache-2.0 で公開し、ChatGPT OAuth の第三者ツール利用を service terms で名指し禁止していない(互換経路)。 Codex CLI の実装(github.com/openai/codex、`codex-rs/login/` 配下)から以下が確定: -- トークンは `~/.codex/auth.json` に `{ auth_mode, tokens: { id_token, access_token, refresh_token, account_id }, last_refresh }` 形式で保存 +- トークンは `~/.codex/auth.json` に `{ auth_mode, tokens: { id_token (JWT 文字列), access_token (JWT), refresh_token, account_id }, last_refresh }` 形式で保存。`OPENAI_API_KEY` フィールドも同居する場合あり - リクエストは `https://chatgpt.com/backend-api/v1/responses` に投げる(Responses API wire) -- 認証ヘッダは `Authorization: Bearer ` + `ChatGPT-Account-ID: `(+ FedRAMP 組織で `X-OpenAI-Fedramp: true`) -- リフレッシュは `https://auth.openai.com/oauth/token` に `grant_type=refresh_token`, `client_id=app_EMoamEEZ73f0CkXaXp7hrann`, `refresh_token` を POST +- 認証ヘッダは `Authorization: Bearer ` + `ChatGPT-Account-Id: `(FedRAMP 組織なら `X-OpenAI-Fedramp: true`) +- リフレッシュは `https://auth.openai.com/oauth/token` に `{ client_id: "app_EMoamEEZ73f0CkXaXp7hrann", grant_type: "refresh_token", refresh_token }` を JSON POST。response は `{ id_token?, access_token?, refresh_token? }`、401 body の `error.code` が `refresh_token_expired|reused|invalidated` で永続失敗を分類 +- proactive refresh の判定: access_token JWT の `exp` claim が `now` 以下、fallback で `last_refresh < now - 8 days`(バッファなし) +- Codex CLI 自身はファイルロックを取らず、(a) プロセス内 `AsyncMutex` (b) refresh 前の guarded reload (account_id 比較で他プロセスの先行更新を検知) (c) 書込前の再 load + diff merge で並行動作を吸収 +- Codex CLI の credentials store はデフォルト `File` (auth.json)。`Keyring` / `Auto` モードは opt-in ## 要件 -1. **`AuthRef::CodexOAuth` の追加**: `llm-model-config` で定義する認証列挙型に ChatGPT OAuth バリアントを追加 +1. **`AuthRef::CodexOAuth` の追加**: `manifest::AuthRef` に ChatGPT OAuth バリアントを追加(型定義済み、provider 側で実装する) 2. **トークン読み取り**: `~/.codex/auth.json` を読み、以下を取り出す - - `tokens.access_token` - - `tokens.refresh_token` - - `tokens.account_id` - - `last_refresh`(期限判定用) - - `tokens.id_token` の JWT claims(`chatgpt_account_is_fedramp`、`chatgpt_plan_type` 参照) + - `tokens.access_token` / `tokens.refresh_token` / `tokens.account_id` + - `last_refresh`(fallback 期限判定用) + - `tokens.id_token` の JWT payload を base64url decode し `https://api.openai.com/auth` claim から `chatgpt_account_is_fedramp` (bool) と `chatgpt_plan_type` を取得(署名検証なし) 3. **ヘッダ注入**: `HttpTransport` が `AuthRef::CodexOAuth` を解決するとき以下を組み立てる - `Authorization: Bearer ` - - `ChatGPT-Account-ID: ` + - `ChatGPT-Account-Id: ` - FedRAMP 組織なら `X-OpenAI-Fedramp: true` -4. **base_url の自動適用**: `ModelConfig.base_url` 未指定時は `https://chatgpt.com/backend-api` を既定とする。ユーザーが明示的に `https://api.openai.com` を指定した場合は尊重(API key 経路と共用したいケース用) + 実装方針: llm-worker 側で `ResolvedAuth::Custom(Arc)` バリアントと `AuthProvider` trait(async でリクエスト毎にヘッダを返す)を新設し、`HttpTransport` の `AuthRequirement::Custom` 経路から呼ぶ。Codex 固有ロジック(auth.json 読取・refresh)は `crates/provider/src/codex_oauth/` に置く。 + +4. **base_url の自動適用**: `AuthRef::CodexOAuth` 指定時、`ModelConfig.base_url` 未指定なら `https://chatgpt.com/backend-api` を既定とする。明示指定(API key 経路と共用したい場合の `https://api.openai.com` 等)は尊重。`scheme` 側の既定(`https://api.openai.com`)は変えず、`build_client` で auth に応じて差し替える。 5. **トークンリフレッシュ**: - - `https://auth.openai.com/oauth/token` に `{ client_id: "app_EMoamEEZ73f0CkXaXp7hrann", grant_type: "refresh_token", refresh_token }` を POST - - 有効期限が近い(access_token が期限切れ or 残り時間が閾値以下)とき自動更新 - - 更新後の token を `~/.codex/auth.json` に書き戻す - - 複数プロセス(Codex CLI との並行実行等)を想定した排他制御 + - 送信前に proactive チェック: access_token JWT の `exp` claim が `now` 以下、fallback で `last_refresh + 8 days < now`。バッファは持たせない(Codex 準拠)。401 駆動の retry は将来拡張 + - refresh は `https://auth.openai.com/oauth/token` に上記 body を POST、response の `access_token` / `id_token` / `refresh_token` を auth.json に書き戻し `last_refresh = now` を更新 + - 並行動作の排他: プロセス内 `tokio::sync::Mutex` を取った上で、refresh 直前に再 load し account_id が一致しないなら自分はスキップして読み直した値を採用(guarded reload)。書込時も再 load + diff merge(Codex CLI に揃える、ファイルロックは使わない) + - 失敗時: `RefreshTokenError::Permanent`(401 + `refresh_token_expired|reused|invalidated`)は `ClientError::Auth` 相当で「`codex login` を再実行してください」のメッセージ、`Transient` (network 等) はそのまま伝播 6. **scheme/openai_responses との組合せで動作**: `ModelConfig { scheme: OpenAIResponses, base_url: (既定), model_id: "gpt-5-codex" 等, auth: CodexOAuth }` で ChatGPT 枠を使って Codex 相当の動作ができる -7. **完了時の動作**: ChatGPT アカウント保持者が `codex login` 済みの環境で insomnia を起動すると、追加設定なしで Codex と同じモデル(`gpt-5-codex` 等)が利用可能 +7. **完了時の動作**: ChatGPT アカウント保持者が `codex login`(File モード)済みの環境で insomnia を起動すると、追加設定なしで Codex と同じモデル(`gpt-5-codex` 等)が利用可能 -## 設計課題 +## 設計判断 -### 1. auth.json の書き戻しと競合制御 +### auth.json の書き戻しと競合制御 -`~/.codex/auth.json` は Codex CLI と共用。両方が並行動作する場面で refresh token が古い値で上書きされると再認証が必要になる。ファイルロックを取るか、更新前に再読み込みして最新値で書くか。 +ファイルロックは取らない。Codex CLI 自身も取っていない(単純な truncate write + `mode 0o600`)。代わりに guarded reload + 書込前 merge で吸収する。`fs2` 依存を増やさない方が筋が良い。 -### 2. refresh token の失効時の挙動 +### refresh token の失効時の挙動 -refresh token も失効するケース(Anthropic のサーバ側遮断のようにサブスク側が塞ぐケース)で、ユーザーにどう通知するか。insomnia CLI で `codex login` の再実行を促すメッセージを返す。 +`Permanent` 分類のエラーをそのまま `ClientError::Auth { message }` で返し、CLI/TUI 上で「`codex login` を再実行」のメッセージを表示する。insomnia 側で再ログインフローは持たない。 -### 3. ChatGPT OAuth 専用モデル +### ChatGPT OAuth 専用モデル (gpt-5-codex 等) -`gpt-5-codex` / `codex-mini-latest` 等、ChatGPT backend でのみ利用可能なモデル ID がある場合、API key 経路と区別が必要。`ModelCapability` で「CodexOAuth でのみ有効」フラグを持つか、API 側の 401 を受けて動的にフォールバックするか。 +`scheme/openai_responses/capability.rs` の静的テーブルに該当モデル ID を追加するのみ。API key 経路で使われたら OpenAI 側が 401 で弾くので動的フォールバックは不要。 -### 4. セキュリティと権限 +### Keyring モード対応 -`~/.codex/auth.json` のパーミッション(600)を尊重。読み取り時にパーミッション確認 + 書き込み時にパーミッション再設定。 +Scope 外。Codex CLI の `cli_auth_credentials_store = "keyring"` で保存しているユーザーには `~/.codex/config.toml` を `"file"` に切り替えて `codex login` をやり直すよう案内する(auth.json 不在エラー時にメッセージで誘導)。 + +### セキュリティと権限 + +`~/.codex/auth.json` のパーミッション(600)を尊重。書き込み時に `OpenOptions` で `mode(0o600)` を再設定する(Codex CLI と同じ)。 ## Scope 外 - Claude Pro/Max OAuth 経路(方針上非採用) - `claude -p` CLI fork 経路 - `codex login` 自体の実装(Codex CLI に任せ、insomnia は auth.json を読むのみ) +- Codex CLI の Keyring credentials store 対応 +- 401 駆動の動的 refresh + retry(proactive のみで実装) - ChatGPT backend の rate limit 観測(Retry-After 処理は HttpTransport 共通の責務) ## 依存 -- `tickets/llm-model-config.md`(`AuthRef` / `HttpTransport` 構造) -- `tickets/llm-scheme-openai-responses.md`(`/v1/responses` wire format) +- `manifest::AuthRef::CodexOAuth`(型のみ定義済み) +- `llm_worker::llm_client::transport::HttpTransport` / `auth::AuthRequirement::Custom`(経路の枠は用意済み) +- `scheme/openai_responses`(`/v1/responses` wire format、実装済み) diff --git a/tickets/llm-auth-codex-oauth.review.md b/tickets/llm-auth-codex-oauth.review.md new file mode 100644 index 00000000..0872ccde --- /dev/null +++ b/tickets/llm-auth-codex-oauth.review.md @@ -0,0 +1,33 @@ +# Codex OAuth 認証の流用 — レビュー + +判定: **Approved**(ブロッカーなし) + +## 指摘事項 + +### 1. `ensure_fresh` の double-load が冗長(軽微) + +`crates/provider/src/codex_oauth/mod.rs` 104 行目と 114 行目で、mutex 保持下に `auth_json::load()` を 2 回呼んでいる。1 回目で stale 判定、2 回目を guarded reload として別プロセス差分検知に使う意図だが、mutex 取得直後に連続 load しているので間隔がほぼゼロで実効的な差分検知になっていない。`persist_refreshed` 内の merge に任せ、`pre_refresh` を使わず `snap` をそのまま refresh 材料にするほうが素直。 + +判断: 他プロセスとの競合検知を「保険として二段階で絞る」意図なら害はない。このままでも OK。 + +### 2. `pub use PermanentReason` を `pub(crate)` に絞る(軽微) + +`crates/provider/src/codex_oauth/mod.rs` 36 行目。クレート外利用者がいないので `pub(crate) use` で十分。 + +判断: 気が向いたら直す。 + +### 3. AGENTS.md の git 操作指示の追記 + +本チケット実装と独立した変更。commit 時は別コミットに切り出すか、本コミットで「ついで」と明記する。 + +判断: commit 作成時の注意事項。実装自体への指摘ではない。 + +### 4. `Arc::new(CodexAuthProvider)` の二重 Arc(軽微) + +`crates/provider/src/lib.rs` で `ResolvedAuth::Custom(Arc::new(provider))` としており、`CodexAuthProvider` 自体も内部に `Arc>` を持つため Arc が二段。`CodexAuthProvider: Clone` にして単段で扱える余地はある。 + +判断: MVP として OK。 + +## 結論 + +4 件とも軽微な改善候補で、いずれも機能・正しさ・設計方針に影響しない。チケット要件はすべて満たされており、テストも通っている。完了として問題なし。