From aecab0b0fb3f21595d86b444b0027a005740be73 Mon Sep 17 00:00:00 2001 From: SachaMorard <2254275+SachaMorard@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:27:00 +0200 Subject: [PATCH 1/2] feat(cli): add `edgee relay` MITM proxy that reroutes inference through the gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `edgee relay [agent]` runs a local MITM proxy that reroutes LLM inference requests to the Edgee gateway (with x-edgee-* auth injected) and optionally logs the traffic. Gives local visibility into requests that otherwise go straight to the gateway. - Reroutes /v1/messages, /v1/responses, /v1/chat/completions, and Codex's /backend-api/codex/responses (remapped to /v1/responses). - Injects the agent's Edgee key: claude for `relay claude` / proxy-only, codex for `relay codex`. Per-agent default ports (claude 41100, codex 41200) so the two run side by side without `--port`. - Per-agent CA trust: NODE_EXTRA_CA_CERTS (Node/Claude) + CODEX_CA_CERTIFICATE (Rust/Codex). - Opt-in logging via --log-output (request+response, full bodies, decoded, req↔response correlation ids). - `edgee launch claude --relay` delegates to the relay. - Gated behind the `relay` feature (in default). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 494 +++++++++++++++++++++- Cargo.toml | 2 + crates/cli/Cargo.toml | 9 +- crates/cli/src/commands/launch/claude.rs | 10 + crates/cli/src/commands/launch/mod.rs | 2 +- crates/cli/src/commands/mod.rs | 8 +- crates/cli/src/commands/relay/handler.rs | 508 +++++++++++++++++++++++ crates/cli/src/commands/relay/mod.rs | 377 +++++++++++++++++ crates/cli/src/config.rs | 7 + crates/cli/src/main.rs | 2 +- 10 files changed, 1410 insertions(+), 9 deletions(-) create mode 100644 crates/cli/src/commands/relay/handler.rs create mode 100644 crates/cli/src/commands/relay/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 8190f75..a9eb796 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,6 +88,45 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-compression" version = "0.4.42" @@ -100,6 +139,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -197,6 +247,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.11.1" @@ -258,6 +317,17 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -300,6 +370,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "clap" version = "4.6.1" @@ -394,6 +475,15 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.16.3" @@ -460,6 +550,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -469,6 +568,30 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crypto-common" version = "0.1.7" @@ -513,6 +636,26 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -578,6 +721,7 @@ dependencies = [ "anyhow", "axum", "axum-core", + "bytes", "clap", "colored", "console", @@ -587,7 +731,10 @@ dependencies = [ "edgee-gateway-http", "etcetera", "http", + "http-body-util", + "hudsucker", "open", + "rcgen", "reqwest", "self_update", "serde", @@ -708,6 +855,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -845,6 +1013,21 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -891,6 +1074,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -1020,6 +1204,34 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hudsucker" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2dca99d166caa6658eb3842ab0c6672241e47027dcf6f407aa0cf61755b480" +dependencies = [ + "async-compression", + "bstr", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tungstenite", + "hyper-util", + "moka", + "rand 0.10.1", + "rcgen", + "thiserror", + "time", + "tokio", + "tokio-graceful", + "tokio-rustls", + "tokio-tungstenite", + "tokio-util", + "tracing", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1051,10 +1263,27 @@ dependencies = [ "http", "hyper", "hyper-util", + "log", "rustls", "tokio", "tokio-rustls", "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-tungstenite" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc778da281a749ed28d2be73a9f2cd13030680a1574bc729debd1195e44f00e9" +dependencies = [ + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tokio-tungstenite", + "tungstenite", ] [[package]] @@ -1379,6 +1608,22 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -1422,6 +1667,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1443,6 +1694,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1452,12 +1733,31 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1467,6 +1767,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1496,6 +1805,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1525,6 +1840,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1537,6 +1862,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.33" @@ -1631,7 +1962,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -1685,7 +2016,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -1695,7 +2037,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1707,6 +2049,26 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1816,6 +2178,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1930,6 +2301,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -2056,7 +2433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2195,6 +2572,12 @@ dependencies = [ "syn", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tempfile" version = "3.27.0" @@ -2310,6 +2693,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-graceful" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45740b38b48641855471cd402922e89156bdfbd97b69b45eeff170369cc18c7d" +dependencies = [ + "loom", + "pin-project-lite", + "slab", + "tokio", + "tracing", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -2331,6 +2727,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -2452,6 +2864,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2513,6 +2926,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + [[package]] name = "typenum" version = "1.20.0" @@ -2574,7 +3006,7 @@ dependencies = [ "socks", "ureq-proto", "utf8-zero", - "webpki-roots", + "webpki-roots 1.0.7", ] [[package]] @@ -2607,6 +3039,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -2822,6 +3260,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -2877,6 +3324,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[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-sys" version = "0.52.0" @@ -3139,6 +3595,34 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + [[package]] name = "yoke" version = "0.8.2" diff --git a/Cargo.toml b/Cargo.toml index eb7a0fc..b272df9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,9 @@ headers = "0.4.1" http = "1.4.0" http-body = "1.0.1" http-body-util = "0.1.3" +hudsucker = "0.24" open = "5.3.4" +rcgen = "0.14" reqwest = { version = "0.13.2", default-features = false } regex = "1.12.3" self_update = { version = "0.44.0", default-features = false } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 2c24656..0052604 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -38,6 +38,12 @@ self_update = { workspace = true, optional = true, features = ["reqwest", "rustl time = { workspace = true, features = ["formatting", "parsing", "serde"] } unicode-width.workspace = true +# `relay` feature: MITM proxy that reroutes LLM inference through the Edgee gateway +bytes = { workspace = true, optional = true } +http-body-util = { workspace = true, optional = true } +hudsucker = { workspace = true, optional = true } +rcgen = { workspace = true, optional = true } + [dev-dependencies] tempfile = "3" @@ -45,5 +51,6 @@ tempfile = "3" which = "8" [features] -default = ["self-update"] +default = ["self-update", "relay"] self-update = ["dep:self_update"] +relay = ["dep:hudsucker", "dep:rcgen", "dep:http-body-util", "dep:bytes"] diff --git a/crates/cli/src/commands/launch/claude.rs b/crates/cli/src/commands/launch/claude.rs index 1ac0f08..d50df77 100644 --- a/crates/cli/src/commands/launch/claude.rs +++ b/crates/cli/src/commands/launch/claude.rs @@ -10,12 +10,22 @@ pub struct Options { #[arg(long)] pub local_gateway: bool, + /// Launch through a local relay (MITM) proxy — same as `edgee relay claude`. + #[cfg(feature = "relay")] + #[arg(long)] + pub relay: bool, + /// Extra args passed through to the claude CLI #[arg(trailing_var_arg = true, allow_hyphen_values = true)] pub args: Vec, } pub async fn run(opts: Options) -> Result<()> { + #[cfg(feature = "relay")] + if opts.relay { + return crate::commands::relay::run_for_agent("claude").await; + } + let mut creds = crate::config::read()?; // Step 1: ensure we are authenticated diff --git a/crates/cli/src/commands/launch/mod.rs b/crates/cli/src/commands/launch/mod.rs index cd7c7eb..750eb39 100644 --- a/crates/cli/src/commands/launch/mod.rs +++ b/crates/cli/src/commands/launch/mod.rs @@ -3,7 +3,7 @@ pub mod codebuddy; pub mod codex; pub mod crush; pub mod opencode; -mod util; +pub(crate) mod util; use anyhow::Result; use console::style; diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 740d8f3..d744962 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -29,9 +29,15 @@ setup_commands! { Stats(stats), /// Render the Edgee statusline and manage agent statusline integrations Statusline(statusline), + [cfg(feature = "relay")] + /// Relay LLM API traffic through the Edgee gateway via a local MITM proxy + #[command(hide = true)] + Relay(relay), /// Reset Edgee credentials and connection mode Reset(reset), [cfg(feature = "self-update")] /// Update Edgee - SelfUpdate(update), + #[command(visible_alias = "self-update")] + Update(update), + } diff --git a/crates/cli/src/commands/relay/handler.rs b/crates/cli/src/commands/relay/handler.rs new file mode 100644 index 0000000..2ea6e0b --- /dev/null +++ b/crates/cli/src/commands/relay/handler.rs @@ -0,0 +1,508 @@ +//! `hudsucker` handler that logs LLM API traffic and reroutes inference requests +//! (`/v1/messages`, `/v1/responses`, `/v1/chat/completions`) to the Edgee gateway, +//! injecting Edgee auth headers. Non-matching traffic is forwarded untouched and +//! never logged. + +use std::fmt::Write as _; +use std::fs::File; +use std::io::Write; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use console::{strip_ansi_codes, style}; +use http::uri::{Authority, Scheme}; +use http::{Request, Response, Uri}; +use http_body_util::BodyExt; +use hudsucker::{Body, HttpContext, HttpHandler, RequestOrResponse}; + +/// Inference paths rerouted to the Edgee gateway (host-agnostic; gated by domain). +/// Maps the client's request path to the gateway path it should hit. Codex's +/// ChatGPT backend (`/backend-api/codex/responses`) is remapped to `/v1/responses`. +const REROUTE_MAP: &[(&str, &str)] = &[ + ("/v1/messages", "/v1/messages"), + ("/v1/responses", "/v1/responses"), + ("/v1/chat/completions", "/v1/chat/completions"), + ("/backend-api/codex/responses", "/v1/responses"), +]; + +/// Gateway path for a request path, or `None` if it isn't an inference path. +fn gateway_path_for(path: &str) -> Option<&'static str> { + REROUTE_MAP + .iter() + .find(|(from, _)| *from == path) + .map(|(_, to)| *to) +} + +/// Where rerouted inference requests are sent, plus the Edgee auth to inject. +/// The key is the active relay's provider key (claude when the agent is claude / +/// proxy-only, codex when the agent is codex). +#[derive(Clone)] +pub struct GatewayTarget { + pub scheme: Scheme, + pub authority: Authority, + /// Optional base path prefix (empty for the default `https://edgee.io`). + pub base_path: String, + pub api_key: String, + pub session_id: String, + pub repo: Option, +} + +/// Where formatted log blocks are written. Each block is emitted atomically so +/// concurrent connections don't interleave. File output is stripped of ANSI +/// color codes. +#[derive(Clone)] +pub struct Sink { + inner: Arc>>, + file: bool, +} + +impl Sink { + pub fn stdout() -> Self { + Self { + inner: Arc::new(Mutex::new(Box::new(std::io::stdout()))), + file: false, + } + } + + pub fn file(f: File) -> Self { + Self { + inner: Arc::new(Mutex::new(Box::new(f))), + file: true, + } + } + + fn emit(&self, block: &str) { + let data = if self.file { + strip_ansi_codes(block).into_owned() + } else { + block.to_string() + }; + if let Ok(mut w) = self.inner.lock() { + let _ = w.write_all(data.as_bytes()); + let _ = w.flush(); + } + } +} + +#[derive(Clone)] +pub struct RelayHandler { + /// Hosts to log (exact host or any subdomain of it). + domains: Arc>, + /// Output target for log blocks. + sink: Sink, + /// Whether to emit log blocks at all (reroute still applies when false). + log_enabled: bool, + /// Gateway to reroute inference requests to (with auth to inject). + gateway: Arc, + /// Shared monotonic counter allocating one id per logged request. + /// hudsucker clones the handler per request, so this `Arc` is shared while + /// the per-request fields below stay private to each request's clone. + counter: Arc, + /// Sequence id of the in-flight request (0 until assigned). + seq: u64, + /// `METHOD url` of the in-flight request, echoed on its response. + desc: String, + /// Whether the in-flight request on this clone matched the filter. + matched: bool, +} + +impl RelayHandler { + pub fn new( + domains: Arc>, + sink: Sink, + gateway: Arc, + log_enabled: bool, + ) -> Self { + Self { + domains, + sink, + log_enabled, + gateway, + counter: Arc::new(AtomicU64::new(1)), + seq: 0, + desc: String::new(), + matched: false, + } + } + + fn matches(&self, host: &str) -> bool { + self.domains + .iter() + .any(|d| host == d || host.ends_with(&format!(".{d}"))) + } +} + +impl HttpHandler for RelayHandler { + async fn handle_request( + &mut self, + _ctx: &HttpContext, + req: Request, + ) -> RequestOrResponse { + // CONNECT is the tunnel-establishment request; the real request follows + // after TLS termination. Skip it to avoid noise. + let host = request_host(&req); + self.matched = req.method() != http::Method::CONNECT + && host.as_deref().map(|h| self.matches(h)).unwrap_or(false); + if !self.matched { + return RequestOrResponse::Request(req); + } + + let reroute = gateway_path_for(req.uri().path()); + + // Logging disabled: reroute if needed, forward the body stream untouched + // (no buffering). + if !self.log_enabled { + if let Some(gw_path) = reroute { + let (mut parts, body) = req.into_parts(); + apply_reroute(&mut parts, &self.gateway, gw_path); + return RequestOrResponse::Request(Request::from_parts(parts, body)); + } + return RequestOrResponse::Request(req); + } + + let host = host.unwrap_or_default(); + let method = req.method().clone(); + let url = absolute_url(&req, &host); + self.seq = self.counter.fetch_add(1, Ordering::Relaxed); + self.desc = format!("{method} {url}"); + let headers = collect_headers(req.headers()); + let json_body = is_json(req.headers()); + + let title_suffix = match reroute { + Some(gw_path) => format!(" →relay→ {}{gw_path}", self.gateway.authority), + None => String::new(), + }; + + let (mut parts, body) = req.into_parts(); + let bytes = match body.collect().await { + Ok(c) => c.to_bytes(), + Err(_) => { + self.sink.emit(&format!( + "{}\n", + style(format!("━━━ #{} {} (failed to read body) ━━━", self.seq, self.desc)) + .red() + )); + let req = Request::from_parts(parts, Body::from(bytes::Bytes::new())); + return RequestOrResponse::Request(req); + } + }; + + if let Some(gw_path) = reroute { + apply_reroute(&mut parts, &self.gateway, gw_path); + } + + let mut buf = String::new(); + let _ = writeln!( + buf, + "{}", + style(format!("━━━ #{} {}{title_suffix} ━━━", self.seq, self.desc)) + .bold() + .green() + ); + fmt_headers(&mut buf, &headers); + fmt_body(&mut buf, &bytes, json_body); + self.sink.emit(&buf); + + let req = Request::from_parts(parts, Body::from(bytes)); + RequestOrResponse::Request(req) + } + + async fn handle_response( + &mut self, + _ctx: &HttpContext, + res: Response, + ) -> Response { + if !self.log_enabled || !self.matched { + return res; + } + + let status = res.status(); + let headers = collect_headers(res.headers()); + + // Streaming responses (SSE) must not be buffered: log metadata, pass through. + if is_event_stream(res.headers()) { + let mut buf = String::new(); + let _ = writeln!( + buf, + "{}", + style(format!( + "◀── #{} {} → {status} (text/event-stream, not buffered)", + self.seq, self.desc + )) + .dim() + ); + fmt_headers(&mut buf, &headers); + self.sink.emit(&buf); + return res; + } + + let json_body = is_json(res.headers()); + let (parts, body) = res.into_parts(); + let raw = match body.collect().await { + Ok(c) => c.to_bytes(), + Err(_) => return Response::from_parts(parts, Body::from(bytes::Bytes::new())), + }; + + // Decode a copy for the log if the body is compressed; forward the + // original response untouched so the client still gets exactly what the + // upstream sent. + let display = decode_for_log(&parts.headers, &raw).await; + + let mut buf = String::new(); + let _ = writeln!( + buf, + "{}", + style(format!("◀── #{} {} → {status}", self.seq, self.desc)) + .bold() + .magenta() + ); + fmt_headers(&mut buf, &headers); + fmt_body(&mut buf, &display, json_body); + self.sink.emit(&buf); + + Response::from_parts(parts, Body::from(raw)) + } +} + +/// If the response carries a supported `content-encoding`, return the decoded +/// bytes (for readable logging). Otherwise return the bytes unchanged. Never +/// fails: on any decode error it falls back to the raw bytes. +async fn decode_for_log(headers: &http::HeaderMap, raw: &bytes::Bytes) -> bytes::Bytes { + let encoding = headers + .get(http::header::CONTENT_ENCODING) + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_ascii_lowercase()); + + match encoding { + Some(enc) if needs_decode(&enc) => { + // Rebuild a throwaway response carrying just the content-encoding so + // hudsucker's decoder knows how to inflate it. + let mut tmp = Response::new(Body::from(raw.clone())); + if let Some(v) = headers.get(http::header::CONTENT_ENCODING) { + tmp.headers_mut() + .insert(http::header::CONTENT_ENCODING, v.clone()); + } + match hudsucker::decode_response(tmp) { + Ok(decoded) => match decoded.into_body().collect().await { + Ok(c) => c.to_bytes(), + Err(_) => raw.clone(), + }, + Err(_) => raw.clone(), + } + } + _ => raw.clone(), + } +} + +/// Encodings hudsucker's `decoder` feature can inflate. +fn needs_decode(enc: &str) -> bool { + enc.split(',').any(|part| { + matches!( + part.trim(), + "gzip" | "x-gzip" | "deflate" | "br" | "zstd" + ) + }) +} + +fn collect_headers(map: &http::HeaderMap) -> Vec<(String, String)> { + let mut headers: Vec<(String, String)> = map + .iter() + .map(|(n, v)| (n.to_string(), v.to_str().unwrap_or("").to_string())) + .collect(); + headers.sort_by(|a, b| a.0.cmp(&b.0)); + headers +} + +fn is_json(map: &http::HeaderMap) -> bool { + content_type(map).map(|ct| ct.contains("json")).unwrap_or(false) +} + +fn is_event_stream(map: &http::HeaderMap) -> bool { + content_type(map) + .map(|ct| ct.contains("event-stream")) + .unwrap_or(false) +} + +fn content_type(map: &http::HeaderMap) -> Option<&str> { + map.get(http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok()) +} + +/// Rewrite the request to target the Edgee gateway at `gw_path` (the client's +/// original query is preserved) and inject Edgee auth headers. `normalize_request` +/// (hudsucker) strips the `Host` header, so hyper regenerates it from the new +/// authority — we only set scheme/authority/path on the URI. +fn apply_reroute(parts: &mut http::request::Parts, gw: &GatewayTarget, gw_path: &str) { + let query = parts + .uri + .query() + .map(|q| format!("?{q}")) + .unwrap_or_default(); + let new_path = format!("{}{gw_path}{query}", gw.base_path); + + let mut builder = Uri::builder() + .scheme(gw.scheme.clone()) + .authority(gw.authority.clone()); + builder = builder.path_and_query(new_path); + if let Ok(uri) = builder.build() { + parts.uri = uri; + } + + let h = &mut parts.headers; + if let Ok(v) = http::HeaderValue::from_str(&gw.api_key) { + h.insert("x-edgee-api-key", v); + } + if let Ok(v) = http::HeaderValue::from_str(&gw.session_id) { + h.insert("x-edgee-session-id", v); + } + if let Some(repo) = &gw.repo { + if let Ok(v) = http::HeaderValue::from_str(repo) { + h.insert("x-edgee-repo", v); + } + } +} + +/// Best-effort host extraction: prefer the URI authority (HTTP/2 / absolute-form), +/// fall back to the `Host` header (origin-form after TLS termination). +fn request_host(req: &Request) -> Option { + if let Some(authority) = req.uri().authority() { + return Some(authority.host().to_string()); + } + req.headers() + .get(http::header::HOST) + .and_then(|v| v.to_str().ok()) + .map(|s| s.split(':').next().unwrap_or(s).to_string()) +} + +/// Reconstruct an absolute URL for display. After TLS MITM the URI is usually +/// origin-form (path only), so we synthesize the `https://host` prefix. +fn absolute_url(req: &Request, host: &str) -> String { + let uri = req.uri(); + if uri.scheme().is_some() && uri.authority().is_some() { + return uri.to_string(); + } + let path = uri + .path_and_query() + .map(|p| p.as_str()) + .unwrap_or_else(|| uri.path()); + format!("https://{host}{path}") +} + +fn fmt_headers(buf: &mut String, headers: &[(String, String)]) { + for (n, v) in headers { + let _ = writeln!(buf, " {}: {v}", style(n).cyan()); + } +} + +fn fmt_body(buf: &mut String, body: &[u8], json_body: bool) { + if body.is_empty() { + return; + } + let len = body.len(); + let pretty = if json_body { + serde_json::from_slice::(body) + .ok() + .and_then(|v| serde_json::to_string_pretty(&v).ok()) + } else { + None + }; + let text = pretty.unwrap_or_else(|| String::from_utf8_lossy(body).into_owned()); + + let _ = writeln!(buf, " {} ({len} bytes):", style("body").bold()); + for line in text.lines() { + let _ = writeln!(buf, " {line}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn target() -> GatewayTarget { + GatewayTarget { + scheme: Scheme::HTTPS, + authority: "edgee.io".parse().unwrap(), + base_path: String::new(), + api_key: "sk-edgee-test".into(), + session_id: "sess-1".into(), + repo: Some("git@github.com:edgee-ai/edgee.git".into()), + } + } + + #[test] + fn reroute_rewrites_uri_and_injects_headers() { + let req = Request::builder() + .method("POST") + .uri("https://api.anthropic.com/v1/messages?beta=true") + .header("authorization", "Bearer x") + .body(Body::from(bytes::Bytes::new())) + .unwrap(); + let (mut parts, _b) = req.into_parts(); + let gw_path = gateway_path_for("/v1/messages").unwrap(); + apply_reroute(&mut parts, &target(), gw_path); + + assert_eq!(parts.uri.scheme_str(), Some("https")); + assert_eq!(parts.uri.authority().unwrap().as_str(), "edgee.io"); + assert_eq!( + parts.uri.path_and_query().unwrap().as_str(), + "/v1/messages?beta=true" + ); + assert_eq!(parts.headers.get("x-edgee-api-key").unwrap(), "sk-edgee-test"); + assert_eq!(parts.headers.get("x-edgee-session-id").unwrap(), "sess-1"); + assert_eq!( + parts.headers.get("x-edgee-repo").unwrap(), + "git@github.com:edgee-ai/edgee.git" + ); + // Original provider auth is preserved for the gateway. + assert_eq!(parts.headers.get("authorization").unwrap(), "Bearer x"); + } + + #[test] + fn reroute_prepends_base_path() { + let mut t = target(); + t.base_path = "/proxy".into(); + let req = Request::builder() + .uri("https://api.openai.com/v1/responses") + .body(Body::from(bytes::Bytes::new())) + .unwrap(); + let (mut parts, _b) = req.into_parts(); + apply_reroute(&mut parts, &t, gateway_path_for("/v1/responses").unwrap()); + assert_eq!( + parts.uri.path_and_query().unwrap().as_str(), + "/proxy/v1/responses" + ); + } + + #[test] + fn codex_chatgpt_backend_remaps_to_v1_responses() { + let req = Request::builder() + .method("POST") + .uri("https://chatgpt.com/backend-api/codex/responses?foo=1") + .body(Body::from(bytes::Bytes::new())) + .unwrap(); + let (mut parts, _b) = req.into_parts(); + let gw_path = gateway_path_for("/backend-api/codex/responses").unwrap(); + apply_reroute(&mut parts, &target(), gw_path); + // Path is remapped to /v1/responses; query preserved. + assert_eq!( + parts.uri.path_and_query().unwrap().as_str(), + "/v1/responses?foo=1" + ); + assert_eq!(parts.uri.authority().unwrap().as_str(), "edgee.io"); + } + + #[test] + fn gateway_path_mapping() { + assert_eq!(gateway_path_for("/v1/messages"), Some("/v1/messages")); + assert_eq!(gateway_path_for("/v1/responses"), Some("/v1/responses")); + assert_eq!( + gateway_path_for("/v1/chat/completions"), + Some("/v1/chat/completions") + ); + assert_eq!( + gateway_path_for("/backend-api/codex/responses"), + Some("/v1/responses") + ); + assert_eq!(gateway_path_for("/api/oauth/validate"), None); + } +} + diff --git a/crates/cli/src/commands/relay/mod.rs b/crates/cli/src/commands/relay/mod.rs new file mode 100644 index 0000000..86a8737 --- /dev/null +++ b/crates/cli/src/commands/relay/mod.rs @@ -0,0 +1,377 @@ +//! `edgee relay` — a local MITM proxy that logs LLM API traffic and reroutes +//! inference requests through the Edgee gateway. +//! +//! Terminates TLS with a locally-generated CA so HTTPS headers and bodies are +//! visible. Requests to inference paths (`/v1/messages`, `/v1/responses`, +//! `/v1/chat/completions`) on known LLM hosts are rewritten to the Edgee gateway +//! (with `x-edgee-*` auth injected); everything else is forwarded to its original +//! upstream. All matching traffic is logged. + +mod handler; + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use console::style; +use http::uri::{Authority, Scheme}; +use http::Uri; +use hudsucker::certificate_authority::RcgenAuthority; +use hudsucker::rustls::crypto::aws_lc_rs; +use hudsucker::Proxy; + +use handler::{GatewayTarget, RelayHandler, Sink}; + +/// Provider hosts logged by default. Inference paths on these hosts are also the +/// only ones rerouted to the gateway. +const DEFAULT_DOMAINS: &[&str] = &[ + "api.anthropic.com", + "api.openai.com", + "chatgpt.com", +]; + +const PROVIDERS: &[&str] = &["claude", "codex"]; + +setup_command! { + /// Agent to spawn through the relay (claude|codex). Omit to run proxy-only + /// (e.g. for external clients like Claude Desktop). Selects which Edgee key + /// is injected (claude by default). + pub agent: Option, + /// Port the proxy listens on. Defaults per agent (claude 41100, codex 41200) + /// so `relay claude` and `relay codex` can run side by side. + #[arg(long)] + pub port: Option, + /// Host to log; repeatable. Defaults to the LLM provider list. + #[arg(long = "domain")] + pub domains: Vec, + /// Write relayed-traffic logs to this file (appended). If unset, logging is off. + #[arg(long)] + pub log_output: Option, +} + +pub async fn run(opts: Options) -> Result<()> { + // The injected Edgee key follows the relay's agent: claude when claude is the + // agent (or proxy-only), codex when codex is. Each spawned agent routes only + // through its own relay, so concurrent `relay claude` / `relay codex` (on + // different ports) each inject the right key without interference. + let provider = opts.agent.clone().unwrap_or_else(|| "claude".to_string()); + if !PROVIDERS.contains(&provider.as_str()) { + anyhow::bail!("unknown agent '{provider}' (expected claude|codex)"); + } + + // Auth bootstrap — same flow as `edgee launch`. + let mut creds = crate::config::read()?; + if creds.user_token.as_deref().unwrap_or("").is_empty() { + crate::commands::auth::login::perform_login().await?; + } + crate::commands::auth::login::ensure_org_selected().await?; + let reprovisioned = crate::commands::auth::login::ensure_valid_provider_key(&provider).await?; + if reprovisioned { + crate::commands::auth::login::ensure_onboarded(&provider).await?; + } + creds = crate::config::read()?; + + let api_key = provider_api_key(&creds, &provider).ok_or_else(|| { + anyhow::anyhow!("no Edgee API key for '{provider}'; run `edgee auth login`") + })?; + let session_id = uuid::Uuid::new_v4().to_string(); + let repo = crate::git::detect_origin(); + + let gateway_url = crate::commands::launch::resolve_gateway_base_url(&creds).await; + let gateway = build_gateway_target(&gateway_url, api_key, session_id.clone(), repo)?; + + let (cert_pem, key_pem, cert_path) = ensure_ca()?; + let domains: Vec = if opts.domains.is_empty() { + DEFAULT_DOMAINS.iter().map(|s| s.to_string()).collect() + } else { + opts.domains.clone() + }; + + let ca = build_ca(&cert_pem, &key_pem)?; + let port = opts.port.unwrap_or_else(|| default_port(&provider)); + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + + // Logging is opt-in: enabled only when a log file is given. + let log_enabled = opts.log_output.is_some(); + let sink = match &opts.log_output { + Some(path) => { + let f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .with_context(|| format!("opening log file {}", path.display()))?; + Sink::file(f) + } + // Placeholder sink; never written to when logging is disabled. + None => Sink::stdout(), + }; + + let handler = RelayHandler::new( + Arc::new(domains.clone()), + sink, + Arc::new(gateway.clone()), + log_enabled, + ); + + let proxy = Proxy::builder() + .with_addr(addr) + .with_ca(ca) + .with_rustls_connector(aws_lc_rs::default_provider()) + .with_http_handler(handler) + .with_graceful_shutdown(shutdown_signal()) + .build() + .context("failed to build relay proxy")?; + + print_banner( + &addr, + &cert_path, + &domains, + opts.log_output.as_deref(), + &gateway, + &session_id, + ); + + // Spawn the agent only when one is named; otherwise run proxy-only. + match opts.agent.clone() { + None => { + print_external_help(&addr, &cert_path); + proxy.start().await.context("relay proxy error")?; + } + Some(agent) => { + let task = tokio::spawn(async move { + let _ = proxy.start().await; + }); + let status = run_agent(&agent, port, &cert_path, &session_id).await?; + task.abort(); + if let Some(code) = status.code() { + std::process::exit(code); + } + } + } + + Ok(()) +} + +/// Run the relay for `agent` with default options. Entry point for +/// `edgee launch --relay`. +pub async fn run_for_agent(agent: &str) -> Result<()> { + run(Options { + agent: Some(agent.to_string()), + port: None, + domains: Vec::new(), + log_output: None, + }) + .await +} + +/// Default listen port per agent, picked from an uncommon range so two relays +/// (`relay claude` + `relay codex`) don't collide out of the box. +fn default_port(provider: &str) -> u16 { + match provider { + "codex" => 41200, + _ => 41100, // claude / proxy-only + } +} + +/// The Edgee key for `provider` from the active profile, if present. +fn provider_api_key(creds: &crate::config::Credentials, provider: &str) -> Option { + let p = match provider { + "claude" => creds.claude.as_ref(), + "codex" => creds.codex.as_ref(), + _ => None, + }?; + if p.api_key.is_empty() { + None + } else { + Some(p.api_key.clone()) + } +} + +/// Parse the resolved gateway URL into a reroute target. +fn build_gateway_target( + url: &str, + api_key: String, + session_id: String, + repo: Option, +) -> Result { + let uri: Uri = url.parse().with_context(|| format!("parsing gateway url {url}"))?; + let scheme = uri.scheme().cloned().unwrap_or(Scheme::HTTPS); + let authority: Authority = uri + .authority() + .cloned() + .ok_or_else(|| anyhow::anyhow!("gateway url has no host: {url}"))?; + let base_path = uri.path().trim_end_matches('/').to_string(); + Ok(GatewayTarget { + scheme, + authority, + base_path, + api_key, + session_id, + repo, + }) +} + +/// Load the persisted CA, generating it on first use. +fn ensure_ca() -> Result<(String, String, PathBuf)> { + let dir = crate::config::relay_ca_dir(); + let cert_path = dir.join("edgee-ca.pem"); + let key_path = dir.join("edgee-ca.key"); + + if cert_path.exists() && key_path.exists() { + let cert = std::fs::read_to_string(&cert_path) + .with_context(|| format!("reading CA cert {}", cert_path.display()))?; + let key = std::fs::read_to_string(&key_path) + .with_context(|| format!("reading CA key {}", key_path.display()))?; + return Ok((cert, key, cert_path)); + } + + std::fs::create_dir_all(&dir).with_context(|| format!("creating CA dir {}", dir.display()))?; + let (cert_pem, key_pem) = generate_ca()?; + std::fs::write(&cert_path, &cert_pem) + .with_context(|| format!("writing CA cert {}", cert_path.display()))?; + std::fs::write(&key_path, &key_pem) + .with_context(|| format!("writing CA key {}", key_path.display()))?; + Ok((cert_pem, key_pem, cert_path)) +} + +/// Generate a self-signed CA suitable for signing leaf certs at runtime. +fn generate_ca() -> Result<(String, String)> { + use rcgen::{ + BasicConstraints, CertificateParams, DistinguishedName, DnType, IsCa, KeyPair, + KeyUsagePurpose, + }; + + let mut params = + CertificateParams::new(Vec::new()).context("building CA certificate params")?; + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![ + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::CrlSign, + ]; + let mut dn = DistinguishedName::new(); + dn.push(DnType::CommonName, "Edgee CA"); + dn.push(DnType::OrganizationName, "Edgee"); + params.distinguished_name = dn; + + let key_pair = KeyPair::generate().context("generating CA key pair")?; + let cert = params.self_signed(&key_pair).context("self-signing CA")?; + Ok((cert.pem(), key_pair.serialize_pem())) +} + +/// Build a hudsucker authority from PEM material. +fn build_ca(cert_pem: &str, key_pem: &str) -> Result { + use rcgen::{Issuer, KeyPair}; + + let key_pair = KeyPair::from_pem(key_pem).context("parsing CA key")?; + let issuer = Issuer::from_ca_cert_pem(cert_pem, key_pair).context("parsing CA cert")?; + Ok(RcgenAuthority::new( + issuer, + 1_000, + aws_lc_rs::default_provider(), + )) +} + +/// Spawn the named agent wired through the proxy. The proxy injects Edgee auth on +/// reroute, so no base-URL / custom-header env is needed here. +async fn run_agent( + agent: &str, + port: u16, + ca_path: &Path, + session_id: &str, +) -> Result { + let bin = crate::commands::launch::util::resolve_binary(agent); + let proxy_url = format!("http://127.0.0.1:{port}"); + + let mut cmd = tokio::process::Command::new(bin); + cmd.env("HTTPS_PROXY", &proxy_url); + cmd.env("HTTP_PROXY", &proxy_url); + cmd.env("https_proxy", &proxy_url); + cmd.env("http_proxy", &proxy_url); + // Make each agent's TLS stack trust the relay CA without a system-store install: + // - Node agents (Claude Code) read NODE_EXTRA_CA_CERTS. + // - Codex (Rust) reads CODEX_CA_CERTIFICATE / SSL_CERT_FILE for its own client; + // it does NOT read NODE_EXTRA_CA_CERTS. + cmd.env("NODE_EXTRA_CA_CERTS", ca_path); + cmd.env("CODEX_CA_CERTIFICATE", ca_path); + cmd.env("EDGEE_SESSION_ID", session_id); + + cmd.status() + .await + .with_context(|| format!("failed to launch '{agent}'")) +} + +async fn shutdown_signal() { + let _ = tokio::signal::ctrl_c().await; +} + +fn print_banner( + addr: &SocketAddr, + cert_path: &Path, + domains: &[String], + log_output: Option<&Path>, + gateway: &GatewayTarget, + session_id: &str, +) { + println!("{}", style("edgee relay").bold().green()); + println!(" proxy: http://{addr}"); + println!(" CA cert: {}", cert_path.display()); + println!( + " gateway: {}://{} (reroute /v1/messages, /v1/responses, /v1/chat/completions)", + gateway.scheme, gateway.authority + ); + println!(" session: {session_id}"); + println!( + " console: {}/sessions/{session_id}", + crate::config::console_base_url() + ); + println!(" domains: {}", domains.join(", ")); + match log_output { + Some(p) => println!(" logs: {}", p.display()), + None => println!(" logs: disabled"), + } + println!(); +} + +fn print_external_help(addr: &SocketAddr, cert_path: &Path) { + println!("{}", style("To relay an external process:").bold()); + println!(" export HTTPS_PROXY=http://{addr}"); + println!( + " export NODE_EXTRA_CA_CERTS={} # Node/Claude Code", + cert_path.display() + ); + println!(" # GUI apps (Claude Desktop): trust the CA in the system keychain"); + println!(); + println!("{}", style("Ctrl-C to stop.").dim()); + println!(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_default_gateway() { + let gw = build_gateway_target("https://edgee.io", "k".into(), "s".into(), None).unwrap(); + assert_eq!(gw.scheme, Scheme::HTTPS); + assert_eq!(gw.authority.as_str(), "edgee.io"); + assert_eq!(gw.base_path, ""); + } + + #[test] + fn parses_local_override() { + let gw = + build_gateway_target("http://127.0.0.1:9999", "k".into(), "s".into(), None).unwrap(); + assert_eq!(gw.scheme.as_str(), "http"); + assert_eq!(gw.authority.as_str(), "127.0.0.1:9999"); + assert_eq!(gw.base_path, ""); + } + + #[test] + fn rejects_url_without_host() { + // A path-only URI has no authority → reroute target can't be built. + assert!(build_gateway_target("/no/host", "k".into(), "s".into(), None).is_err()); + } +} + diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index fece72e..655c807 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -117,6 +117,13 @@ pub fn local_gateway_log_path() -> PathBuf { global_data_dir().join("local-gateway.log") } +/// Directory holding the local MITM CA used by `edgee relay`. +/// Linux/macOS: `~/.local/share/edgee/ca` (contains `edgee-ca.pem` + `edgee-ca.key`). +#[cfg(feature = "relay")] +pub fn relay_ca_dir() -> PathBuf { + global_data_dir().join("ca") +} + /// Global credentials file path (`~/.config/edgee/credentials.toml`, etc.). pub fn global_credentials_path() -> PathBuf { global_config_dir().join("credentials.toml") diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 56e368a..e5ab7e2 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -38,7 +38,7 @@ async fn main() -> Result<()> { // Nudge about newer releases, except when the user is already updating. #[cfg(feature = "self-update")] - if !matches!(opts.command, commands::Command::SelfUpdate(_)) { + if !matches!(opts.command, commands::Command::Update(_)) { version_check::maybe_notify().await; } From 48147fff8f5b0242e6ac16a6a6416cf1ec660031 Mon Sep 17 00:00:00 2001 From: Clement Bouvet Date: Wed, 1 Jul 2026 10:33:38 +0200 Subject: [PATCH 2/2] feat(cli): relay copilot --- crates/cli/src/commands/auth/login.rs | 4 + crates/cli/src/commands/relay/handler.rs | 227 ++++++++++++++++++++--- crates/cli/src/commands/relay/mod.rs | 161 ++++++++++++---- crates/cli/src/config.rs | 1 + 4 files changed, 333 insertions(+), 60 deletions(-) diff --git a/crates/cli/src/commands/auth/login.rs b/crates/cli/src/commands/auth/login.rs index f41b868..6d327d4 100644 --- a/crates/cli/src/commands/auth/login.rs +++ b/crates/cli/src/commands/auth/login.rs @@ -208,6 +208,7 @@ pub fn agent_label(provider: &str) -> &'static str { "codex" => "Codex", "opencode" => "OpenCode", "crush" => "Crush", + "copilot" => "GitHub Copilot", _ => "your agent", } } @@ -289,6 +290,7 @@ fn coding_assistant_name(provider: &str) -> Result<&'static str> { "codex" => Ok("codex"), "opencode" => Ok("opencode"), "crush" => Ok("crush"), + "copilot" => Ok("copilot"), _ => anyhow::bail!("Unsupported provider `{provider}`"), } } @@ -303,6 +305,7 @@ fn provider_config_mut<'a>( "codex" => Ok(&mut creds.codex), "opencode" => Ok(&mut creds.opencode), "crush" => Ok(&mut creds.crush), + "copilot" => Ok(&mut creds.copilot), _ => anyhow::bail!("Unsupported provider `{provider}`"), } } @@ -317,6 +320,7 @@ fn provider_config<'a>( "codex" => Ok(creds.codex.as_ref()), "opencode" => Ok(creds.opencode.as_ref()), "crush" => Ok(creds.crush.as_ref()), + "copilot" => Ok(creds.copilot.as_ref()), _ => anyhow::bail!("Unsupported provider `{provider}`"), } } diff --git a/crates/cli/src/commands/relay/handler.rs b/crates/cli/src/commands/relay/handler.rs index 2ea6e0b..c655aa7 100644 --- a/crates/cli/src/commands/relay/handler.rs +++ b/crates/cli/src/commands/relay/handler.rs @@ -1,7 +1,7 @@ //! `hudsucker` handler that logs LLM API traffic and reroutes inference requests //! (`/v1/messages`, `/v1/responses`, `/v1/chat/completions`) to the Edgee gateway, -//! injecting Edgee auth headers. Non-matching traffic is forwarded untouched and -//! never logged. +//! injecting Edgee auth headers. All HTTPS traffic is decrypted; non-inference +//! traffic is forwarded untouched after optional logging. use std::fmt::Write as _; use std::fs::File; @@ -16,13 +16,19 @@ use http_body_util::BodyExt; use hudsucker::{Body, HttpContext, HttpHandler, RequestOrResponse}; /// Inference paths rerouted to the Edgee gateway (host-agnostic; gated by domain). -/// Maps the client's request path to the gateway path it should hit. Codex's -/// ChatGPT backend (`/backend-api/codex/responses`) is remapped to `/v1/responses`. +/// Maps the client's request path to the gateway path it should hit. Most are +/// identity; Codex's ChatGPT backend (`/backend-api/codex/responses`) is remapped +/// to the gateway's `/v1/responses`, and VS Code Copilot's `/chat/completions` to +/// the gateway's `/v1/chat/completions`. const REROUTE_MAP: &[(&str, &str)] = &[ ("/v1/messages", "/v1/messages"), ("/v1/responses", "/v1/responses"), ("/v1/chat/completions", "/v1/chat/completions"), ("/backend-api/codex/responses", "/v1/responses"), + // VS Code Copilot: chat uses the bare Responses API path, title generation + // uses the bare chat-completions path. Both map to the gateway's /v1 routes. + ("/responses", "/v1/responses"), + ("/chat/completions", "/v1/chat/completions"), ]; /// Gateway path for a request path, or `None` if it isn't an inference path. @@ -33,6 +39,11 @@ fn gateway_path_for(path: &str) -> Option<&'static str> { .map(|(_, to)| *to) } +/// The Anthropic path that Claude Code uses. When a VS Code relay sees this, the +/// request is Claude Code running inside the editor — not Copilot — so it routes +/// through the claude pipeline with the claude key instead of Copilot passthrough. +const CLAUDE_PATH: &str = "/v1/messages"; + /// Where rerouted inference requests are sent, plus the Edgee auth to inject. /// The key is the active relay's provider key (claude when the agent is claude / /// proxy-only, codex when the agent is codex). @@ -45,6 +56,30 @@ pub struct GatewayTarget { pub api_key: String, pub session_id: String, pub repo: Option, + /// When true, record the original upstream in `x-edgee-upstream-url` so the + /// gateway forwards there (preserving the caller's own auth) instead of running + /// its standard Edgee provider pipeline. Set for passthrough-only providers like + /// VS Code Copilot; left false for claude/codex, which the gateway routes itself. + pub passthrough_to_upstream: bool, + /// Edgee claude key, set only for the VS Code relay where Claude Code may run + /// alongside Copilot. When present, `/v1/messages` traffic uses this key and the + /// gateway's claude pipeline instead of the default (Copilot) key + passthrough. + pub claude_api_key: Option, +} + +impl GatewayTarget { + /// Resolve the `(api_key, passthrough_to_upstream)` to use for a request whose + /// original path is `orig_path`. Claude Code traffic (`/v1/messages`) in a VS + /// Code relay uses the claude key and the gateway pipeline; everything else uses + /// the relay's default key and passthrough setting. + fn auth_for(&self, orig_path: &str) -> (&str, bool) { + if orig_path == CLAUDE_PATH { + if let Some(key) = &self.claude_api_key { + return (key, false); + } + } + (&self.api_key, self.passthrough_to_upstream) + } } /// Where formatted log blocks are written. Each block is emitted atomically so @@ -86,12 +121,13 @@ impl Sink { #[derive(Clone)] pub struct RelayHandler { - /// Hosts to log (exact host or any subdomain of it). - domains: Arc>, /// Output target for log blocks. sink: Sink, /// Whether to emit log blocks at all (reroute still applies when false). log_enabled: bool, + /// Print a concise one-line summary (with response status) to stdout for every + /// request. On when the terminal is free (GUI client / `--no-launch`). + announce: bool, /// Gateway to reroute inference requests to (with auth to inject). gateway: Arc, /// Shared monotonic counter allocating one id per logged request. @@ -102,52 +138,68 @@ pub struct RelayHandler { seq: u64, /// `METHOD url` of the in-flight request, echoed on its response. desc: String, + /// Concise `METHOD host/path` of the in-flight request, for the one-liner. + summary: String, /// Whether the in-flight request on this clone matched the filter. matched: bool, + /// Whether the in-flight request was rerouted to the gateway. + rerouted: bool, } impl RelayHandler { pub fn new( - domains: Arc>, sink: Sink, gateway: Arc, log_enabled: bool, + announce: bool, ) -> Self { Self { - domains, sink, log_enabled, + announce, gateway, counter: Arc::new(AtomicU64::new(1)), seq: 0, desc: String::new(), + summary: String::new(), matched: false, + rerouted: false, } } - - fn matches(&self, host: &str) -> bool { - self.domains - .iter() - .any(|d| host == d || host.ends_with(&format!(".{d}"))) - } } impl HttpHandler for RelayHandler { + async fn should_intercept(&mut self, _ctx: &HttpContext, _req: &Request) -> bool { + true + } + async fn handle_request( &mut self, _ctx: &HttpContext, req: Request, ) -> RequestOrResponse { // CONNECT is the tunnel-establishment request; the real request follows - // after TLS termination. Skip it to avoid noise. + // after TLS termination. We don't log/reroute it, but its authority is + // exactly the host hudsucker will mint a leaf cert for, so surface it. let host = request_host(&req); - self.matched = req.method() != http::Method::CONNECT - && host.as_deref().map(|h| self.matches(h)).unwrap_or(false); - if !self.matched { + if req.method() == http::Method::CONNECT { + if self.announce { + if let Some(h) = host.as_deref() { + println!("{} mint cert for {h}", style("⚿").dim()); + } + } return RequestOrResponse::Request(req); } + self.matched = true; let reroute = gateway_path_for(req.uri().path()); + self.rerouted = reroute.is_some(); + // One id per matched request, used by both the one-liner and the file log. + self.seq = self.counter.fetch_add(1, Ordering::Relaxed); + if self.announce { + let h = host.as_deref().unwrap_or(""); + self.summary = format!("{} {h}{}", req.method(), req.uri().path()); + } // Logging disabled: reroute if needed, forward the body stream untouched // (no buffering). @@ -163,7 +215,6 @@ impl HttpHandler for RelayHandler { let host = host.unwrap_or_default(); let method = req.method().clone(); let url = absolute_url(&req, &host); - self.seq = self.counter.fetch_add(1, Ordering::Relaxed); self.desc = format!("{method} {url}"); let headers = collect_headers(req.headers()); let json_body = is_json(req.headers()); @@ -212,11 +263,38 @@ impl HttpHandler for RelayHandler { _ctx: &HttpContext, res: Response, ) -> Response { - if !self.log_enabled || !self.matched { + if !self.matched { return res; } let status = res.status(); + + // One line per matched request: id, route marker, summary, response status. + if self.announce { + let marker = if self.rerouted { + style("→gw ").green() + } else { + style("pass ").dim() + }; + let code = status.as_u16(); + let status_styled = if (200..300).contains(&code) { + style(status.to_string()).green() + } else if (400..600).contains(&code) { + style(status.to_string()).red() + } else { + style(status.to_string()).yellow() + }; + println!( + "{} {marker} {} → {status_styled}", + style(format!("#{}", self.seq)).dim(), + self.summary, + ); + } + + if !self.log_enabled { + return res; + } + let headers = collect_headers(res.headers()); // Streaming responses (SSE) must not be buffered: log metadata, pass through. @@ -337,6 +415,27 @@ fn apply_reroute(parts: &mut http::request::Parts, gw: &GatewayTarget, gw_path: .query() .map(|q| format!("?{q}")) .unwrap_or_default(); + + // Capture the original upstream (host + path) before rewriting the URI, so the + // gateway can passthrough to it (e.g. forward to api.business.githubcopilot.com + // with the caller's own bearer token). The Host header carries it after MITM. + let upstream_host = parts + .headers + .get(http::header::HOST) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) + .or_else(|| parts.uri.authority().map(|a| a.host().to_string())); + let upstream_path = parts + .uri + .path_and_query() + .map(|p| p.as_str().to_string()) + .unwrap_or_else(|| parts.uri.path().to_string()); + + // Pick the key + routing mode from the original path before it's rewritten: + // Claude Code (`/v1/messages`) in a VS Code relay uses the claude key + pipeline, + // everything else the relay's default key + passthrough setting. + let (api_key, passthrough_to_upstream) = gw.auth_for(parts.uri.path()); + let new_path = format!("{}{gw_path}{query}", gw.base_path); let mut builder = Uri::builder() @@ -348,7 +447,7 @@ fn apply_reroute(parts: &mut http::request::Parts, gw: &GatewayTarget, gw_path: } let h = &mut parts.headers; - if let Ok(v) = http::HeaderValue::from_str(&gw.api_key) { + if let Ok(v) = http::HeaderValue::from_str(api_key) { h.insert("x-edgee-api-key", v); } if let Ok(v) = http::HeaderValue::from_str(&gw.session_id) { @@ -359,6 +458,17 @@ fn apply_reroute(parts: &mut http::request::Parts, gw: &GatewayTarget, gw_path: h.insert("x-edgee-repo", v); } } + // For passthrough providers, tell the gateway where this inference call + // originated so it can forward to the provider's own backend, preserving the + // caller's `Authorization`. Omitted for claude/codex, which the gateway routes + // through its own pipeline using `x-edgee-api-key`. + if passthrough_to_upstream { + if let Some(host) = upstream_host { + if let Ok(v) = http::HeaderValue::from_str(&format!("https://{host}{upstream_path}")) { + h.insert("x-edgee-upstream-url", v); + } + } + } } /// Best-effort host extraction: prefer the URI authority (HTTP/2 / absolute-form), @@ -425,6 +535,8 @@ mod tests { api_key: "sk-edgee-test".into(), session_id: "sess-1".into(), repo: Some("git@github.com:edgee-ai/edgee.git".into()), + passthrough_to_upstream: true, + claude_api_key: None, } } @@ -454,6 +566,72 @@ mod tests { ); // Original provider auth is preserved for the gateway. assert_eq!(parts.headers.get("authorization").unwrap(), "Bearer x"); + // The original upstream is recorded so the gateway can passthrough to it. + assert_eq!( + parts.headers.get("x-edgee-upstream-url").unwrap(), + "https://api.anthropic.com/v1/messages?beta=true" + ); + } + + #[test] + fn reroute_omits_upstream_url_for_pipeline_providers() { + // claude/codex route through the gateway pipeline, so no upstream-url. + let mut t = target(); + t.passthrough_to_upstream = false; + let req = Request::builder() + .method("POST") + .uri("https://api.anthropic.com/v1/messages") + .body(Body::from(bytes::Bytes::new())) + .unwrap(); + let (mut parts, _b) = req.into_parts(); + apply_reroute(&mut parts, &t, gateway_path_for("/v1/messages").unwrap()); + assert!(parts.headers.get("x-edgee-upstream-url").is_none()); + } + + #[test] + fn vscode_claude_traffic_uses_claude_key_and_pipeline() { + // VS Code relay: default (Copilot) key + passthrough, plus a claude key. + let mut t = target(); + t.api_key = "sk-copilot".into(); + t.claude_api_key = Some("sk-claude".into()); + + // Claude Code's /v1/messages → claude key, no upstream-url (pipeline routing). + let req = Request::builder() + .method("POST") + .uri("https://api.anthropic.com/v1/messages") + .body(Body::from(bytes::Bytes::new())) + .unwrap(); + let (mut parts, _b) = req.into_parts(); + apply_reroute(&mut parts, &t, gateway_path_for("/v1/messages").unwrap()); + assert_eq!(parts.headers.get("x-edgee-api-key").unwrap(), "sk-claude"); + assert!(parts.headers.get("x-edgee-upstream-url").is_none()); + + // Copilot's /responses still uses the default key + passthrough. + let req = Request::builder() + .method("POST") + .uri("https://api.githubcopilot.com/responses") + .body(Body::from(bytes::Bytes::new())) + .unwrap(); + let (mut parts, _b) = req.into_parts(); + apply_reroute(&mut parts, &t, gateway_path_for("/responses").unwrap()); + assert_eq!(parts.headers.get("x-edgee-api-key").unwrap(), "sk-copilot"); + assert!(parts.headers.get("x-edgee-upstream-url").is_some()); + } + + #[test] + fn claude_path_uses_default_key_without_claude_override() { + // No claude key set (e.g. claude/codex relays): /v1/messages uses the default. + let mut t = target(); + t.api_key = "sk-default".into(); + t.claude_api_key = None; + let req = Request::builder() + .method("POST") + .uri("https://api.anthropic.com/v1/messages") + .body(Body::from(bytes::Bytes::new())) + .unwrap(); + let (mut parts, _b) = req.into_parts(); + apply_reroute(&mut parts, &t, gateway_path_for("/v1/messages").unwrap()); + assert_eq!(parts.headers.get("x-edgee-api-key").unwrap(), "sk-default"); } #[test] @@ -502,6 +680,13 @@ mod tests { gateway_path_for("/backend-api/codex/responses"), Some("/v1/responses") ); + // VS Code Copilot posts to bare /responses (chat) and /chat/completions + // (title generation); both reroute to the gateway's /v1 routes. + assert_eq!(gateway_path_for("/responses"), Some("/v1/responses")); + assert_eq!( + gateway_path_for("/chat/completions"), + Some("/v1/chat/completions") + ); assert_eq!(gateway_path_for("/api/oauth/validate"), None); } } diff --git a/crates/cli/src/commands/relay/mod.rs b/crates/cli/src/commands/relay/mod.rs index 86a8737..dcc14e6 100644 --- a/crates/cli/src/commands/relay/mod.rs +++ b/crates/cli/src/commands/relay/mod.rs @@ -23,42 +23,48 @@ use hudsucker::Proxy; use handler::{GatewayTarget, RelayHandler, Sink}; -/// Provider hosts logged by default. Inference paths on these hosts are also the -/// only ones rerouted to the gateway. -const DEFAULT_DOMAINS: &[&str] = &[ - "api.anthropic.com", - "api.openai.com", - "chatgpt.com", -]; +const PROVIDERS: &[&str] = &["claude", "codex", "vscode"]; -const PROVIDERS: &[&str] = &["claude", "codex"]; +/// True for the VS Code Copilot relay target (launches the `code` binary). +fn is_copilot(agent: &str) -> bool { + matches!(agent, "vscode-copilot" | "copilot" | "code" | "vscode") +} + +/// The Edgee provider key that backs the gateway reroute for `agent`. The VS Code +/// Copilot aliases map to the `copilot` Edgee provider; everything else uses its +/// own name. +fn key_provider(agent: &str) -> &str { + if is_copilot(agent) { + "copilot" + } else { + agent + } +} setup_command! { - /// Agent to spawn through the relay (claude|codex). Omit to run proxy-only - /// (e.g. for external clients like Claude Desktop). Selects which Edgee key - /// is injected (claude by default). + /// Agent / provider for the relay (claude|codex|vscode). Launched unless + /// --no-launch. Omit to run proxy-only with the claude key. pub agent: Option, + /// Don't spawn the agent; just run the proxy (for external clients, e.g. Claude Desktop). + #[arg(long)] + pub no_launch: bool, /// Port the proxy listens on. Defaults per agent (claude 41100, codex 41200) /// so `relay claude` and `relay codex` can run side by side. #[arg(long)] pub port: Option, - /// Host to log; repeatable. Defaults to the LLM provider list. - #[arg(long = "domain")] - pub domains: Vec, /// Write relayed-traffic logs to this file (appended). If unset, logging is off. #[arg(long)] pub log_output: Option, } pub async fn run(opts: Options) -> Result<()> { - // The injected Edgee key follows the relay's agent: claude when claude is the - // agent (or proxy-only), codex when codex is. Each spawned agent routes only - // through its own relay, so concurrent `relay claude` / `relay codex` (on - // different ports) each inject the right key without interference. - let provider = opts.agent.clone().unwrap_or_else(|| "claude".to_string()); - if !PROVIDERS.contains(&provider.as_str()) { - anyhow::bail!("unknown agent '{provider}' (expected claude|codex)"); + let agent = opts.agent.clone().unwrap_or_else(|| "claude".to_string()); + if !PROVIDERS.contains(&agent.as_str()) { + anyhow::bail!("unknown agent '{agent}' (expected claude|codex|vscode)"); } + // The Edgee provider key backing the gateway reroute. For VS Code Copilot this + // is the claude key (Copilot isn't an Edgee provider of its own). + let provider = key_provider(&agent).to_string(); // Auth bootstrap — same flow as `edgee launch`. let mut creds = crate::config::read()?; @@ -70,24 +76,43 @@ pub async fn run(opts: Options) -> Result<()> { if reprovisioned { crate::commands::auth::login::ensure_onboarded(&provider).await?; } + // VS Code can host Claude Code alongside Copilot chat. Provision the claude key + // too so Claude's `/v1/messages` traffic reroutes through the claude pipeline. + if is_copilot(&agent) { + let reprov = crate::commands::auth::login::ensure_valid_provider_key("claude").await?; + if reprov { + crate::commands::auth::login::ensure_onboarded("claude").await?; + } + } creds = crate::config::read()?; let api_key = provider_api_key(&creds, &provider).ok_or_else(|| { anyhow::anyhow!("no Edgee API key for '{provider}'; run `edgee auth login`") })?; + // Only wired for the VS Code relay; None elsewhere so `/v1/messages` keeps using + // the relay's own key. + let claude_api_key = if is_copilot(&agent) { + provider_api_key(&creds, "claude") + } else { + None + }; let session_id = uuid::Uuid::new_v4().to_string(); let repo = crate::git::detect_origin(); let gateway_url = crate::commands::launch::resolve_gateway_base_url(&creds).await; - let gateway = build_gateway_target(&gateway_url, api_key, session_id.clone(), repo)?; + // Copilot has no Edgee provider pipeline; the gateway forwards its rerouted + // calls to Copilot's own backend, so record the original upstream. + let passthrough_to_upstream = is_copilot(&agent); + let gateway = build_gateway_target( + &gateway_url, + api_key, + session_id.clone(), + repo, + passthrough_to_upstream, + claude_api_key, + )?; let (cert_pem, key_pem, cert_path) = ensure_ca()?; - let domains: Vec = if opts.domains.is_empty() { - DEFAULT_DOMAINS.iter().map(|s| s.to_string()).collect() - } else { - opts.domains.clone() - }; - let ca = build_ca(&cert_pem, &key_pem)?; let port = opts.port.unwrap_or_else(|| default_port(&provider)); let addr = SocketAddr::from(([127, 0, 0, 1], port)); @@ -107,11 +132,16 @@ pub async fn run(opts: Options) -> Result<()> { None => Sink::stdout(), }; + // Print a live one-liner per matched request (with status) when the terminal is + // free: a GUI client (VS Code Copilot) or an external process (`--no-launch`). + // TUI agents (claude/codex) own the terminal, so stay quiet there. + let announce = is_copilot(&agent) || opts.no_launch; + let handler = RelayHandler::new( - Arc::new(domains.clone()), sink, Arc::new(gateway.clone()), log_enabled, + announce, ); let proxy = Proxy::builder() @@ -126,7 +156,6 @@ pub async fn run(opts: Options) -> Result<()> { print_banner( &addr, &cert_path, - &domains, opts.log_output.as_deref(), &gateway, &session_id, @@ -139,6 +168,9 @@ pub async fn run(opts: Options) -> Result<()> { proxy.start().await.context("relay proxy error")?; } Some(agent) => { + if is_copilot(&agent) { + print_copilot_hint(); + } let task = tokio::spawn(async move { let _ = proxy.start().await; }); @@ -158,8 +190,8 @@ pub async fn run(opts: Options) -> Result<()> { pub async fn run_for_agent(agent: &str) -> Result<()> { run(Options { agent: Some(agent.to_string()), + no_launch: false, port: None, - domains: Vec::new(), log_output: None, }) .await @@ -179,6 +211,9 @@ fn provider_api_key(creds: &crate::config::Credentials, provider: &str) -> Optio let p = match provider { "claude" => creds.claude.as_ref(), "codex" => creds.codex.as_ref(), + "opencode" => creds.opencode.as_ref(), + "crush" => creds.crush.as_ref(), + "copilot" => creds.copilot.as_ref(), _ => None, }?; if p.api_key.is_empty() { @@ -194,6 +229,8 @@ fn build_gateway_target( api_key: String, session_id: String, repo: Option, + passthrough_to_upstream: bool, + claude_api_key: Option, ) -> Result { let uri: Uri = url.parse().with_context(|| format!("parsing gateway url {url}"))?; let scheme = uri.scheme().cloned().unwrap_or(Scheme::HTTPS); @@ -209,6 +246,8 @@ fn build_gateway_target( api_key, session_id, repo, + passthrough_to_upstream, + claude_api_key, }) } @@ -281,16 +320,25 @@ async fn run_agent( ca_path: &Path, session_id: &str, ) -> Result { - let bin = crate::commands::launch::util::resolve_binary(agent); + // VS Code Copilot launches the `code` binary. `--wait` keeps this process + // alive (and the proxy with it) until the editor window is closed, instead of + // `code` forking and returning immediately. + let (bin_name, args): (&str, &[&str]) = if is_copilot(agent) { + ("code", &["--wait"]) + } else { + (agent, &[]) + }; + let bin = crate::commands::launch::util::resolve_binary(bin_name); let proxy_url = format!("http://127.0.0.1:{port}"); let mut cmd = tokio::process::Command::new(bin); + cmd.args(args); cmd.env("HTTPS_PROXY", &proxy_url); cmd.env("HTTP_PROXY", &proxy_url); cmd.env("https_proxy", &proxy_url); cmd.env("http_proxy", &proxy_url); // Make each agent's TLS stack trust the relay CA without a system-store install: - // - Node agents (Claude Code) read NODE_EXTRA_CA_CERTS. + // - Node agents (Claude Code) and VS Code / Copilot read NODE_EXTRA_CA_CERTS. // - Codex (Rust) reads CODEX_CA_CERTIFICATE / SSL_CERT_FILE for its own client; // it does NOT read NODE_EXTRA_CA_CERTS. cmd.env("NODE_EXTRA_CA_CERTS", ca_path); @@ -299,7 +347,7 @@ async fn run_agent( cmd.status() .await - .with_context(|| format!("failed to launch '{agent}'")) + .with_context(|| format!("failed to launch '{bin_name}'")) } async fn shutdown_signal() { @@ -309,7 +357,6 @@ async fn shutdown_signal() { fn print_banner( addr: &SocketAddr, cert_path: &Path, - domains: &[String], log_output: Option<&Path>, gateway: &GatewayTarget, session_id: &str, @@ -326,7 +373,6 @@ fn print_banner( " console: {}/sessions/{session_id}", crate::config::console_base_url() ); - println!(" domains: {}", domains.join(", ")); match log_output { Some(p) => println!(" logs: {}", p.display()), None => println!(" logs: disabled"), @@ -334,6 +380,19 @@ fn print_banner( println!(); } +fn print_copilot_hint() { + println!("{}", style("Launching VS Code (code --wait) behind the relay.").bold()); + println!( + " {}", + style("Quit any running VS Code first — the proxy env only applies to a freshly").dim() + ); + println!( + " {}", + style("spawned instance. Copilot Chat traffic then reroutes through the gateway.").dim() + ); + println!(); +} + fn print_external_help(addr: &SocketAddr, cert_path: &Path) { println!("{}", style("To relay an external process:").bold()); println!(" export HTTPS_PROXY=http://{addr}"); @@ -353,7 +412,9 @@ mod tests { #[test] fn parses_default_gateway() { - let gw = build_gateway_target("https://edgee.io", "k".into(), "s".into(), None).unwrap(); + let gw = + build_gateway_target("https://edgee.io", "k".into(), "s".into(), None, false, None) + .unwrap(); assert_eq!(gw.scheme, Scheme::HTTPS); assert_eq!(gw.authority.as_str(), "edgee.io"); assert_eq!(gw.base_path, ""); @@ -362,16 +423,38 @@ mod tests { #[test] fn parses_local_override() { let gw = - build_gateway_target("http://127.0.0.1:9999", "k".into(), "s".into(), None).unwrap(); + build_gateway_target("http://127.0.0.1:9999", "k".into(), "s".into(), None, false, None) + .unwrap(); assert_eq!(gw.scheme.as_str(), "http"); assert_eq!(gw.authority.as_str(), "127.0.0.1:9999"); assert_eq!(gw.base_path, ""); } + #[test] + fn copilot_agent_aliases_recognized() { + for a in ["vscode-copilot", "copilot", "code", "vscode"] { + assert!(is_copilot(a), "{a} should be a copilot alias"); + } + assert!(!is_copilot("claude")); + assert!(!is_copilot("codex")); + } + + #[test] + fn copilot_reroute_uses_copilot_key() { + for a in ["vscode-copilot", "copilot", "code", "vscode"] { + assert_eq!(key_provider(a), "copilot"); + } + // Real providers back their own key. + assert_eq!(key_provider("claude"), "claude"); + assert_eq!(key_provider("codex"), "codex"); + } + #[test] fn rejects_url_without_host() { // A path-only URI has no authority → reroute target can't be built. - assert!(build_gateway_target("/no/host", "k".into(), "s".into(), None).is_err()); + assert!( + build_gateway_target("/no/host", "k".into(), "s".into(), None, false, None).is_err() + ); } } diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 655c807..53a5745 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -37,6 +37,7 @@ pub struct Profile { pub codex: Option, pub opencode: Option, pub crush: Option, + pub copilot: Option, } /// Type alias so existing call sites that use `Credentials` compile unchanged.