diff --git a/build/azure-pipelines/darwin/helper-entitlements.plist b/build/azure-pipelines/darwin/helper-entitlements.plist new file mode 100644 index 00000000000000..4efe1ce508f85d --- /dev/null +++ b/build/azure-pipelines/darwin/helper-entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + + diff --git a/build/darwin/sign.ts b/build/darwin/sign.ts index 26e22aee08c884..ed12a46473ace6 100644 --- a/build/darwin/sign.ts +++ b/build/darwin/sign.ts @@ -25,6 +25,8 @@ function getEntitlementsForFile(filePath: string): string { return path.join(baseDir, 'azure-pipelines', 'darwin', 'helper-renderer-entitlements.plist'); } else if (filePath.includes(' Helper (Plugin).app')) { return path.join(baseDir, 'azure-pipelines', 'darwin', 'helper-plugin-entitlements.plist'); + } else if (filePath.includes(' Helper.app')) { + return path.join(baseDir, 'azure-pipelines', 'darwin', 'helper-entitlements.plist'); } return path.join(baseDir, 'azure-pipelines', 'darwin', 'app-entitlements.plist'); } diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 5e8a2c7a25eab5..2981d038f2c372 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -17,6 +17,46 @@ dependencies = [ "memchr", ] +[[package]] +name = "ahp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c974ae2da70e455e9e72de59ccc5279afc660ce267ab994474734d3310401f0" +dependencies = [ + "ahp-types", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "ahp-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299233ec34afadd8dd8c24fc63c50e24eb376c9588dd5b48804e33f60b1ecaf5" +dependencies = [ + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "ahp-ws" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516267592f4f77df2cecb21b4945ae6eb5a7cf2b2d07062e6423fa2e56a1caa6" +dependencies = [ + "ahp", + "futures-util", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", + "url", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -239,9 +279,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "base64" -version = "0.21.7" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bit-vec" @@ -342,15 +382,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] -name = "chrono" -version = "0.4.43" +name = "chacha20" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link", + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -371,7 +410,7 @@ checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" dependencies = [ "anstream", "anstyle", - "clap_lex 1.0.0", + "clap_lex", "strsim", ] @@ -387,12 +426,6 @@ dependencies = [ "syn 2.0.115", ] -[[package]] -name = "clap_lex" -version = "0.7.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" - [[package]] name = "clap_lex" version = "1.0.0" @@ -403,13 +436,14 @@ checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" name = "code-cli" version = "0.1.0" dependencies = [ - "async-trait", + "ahp", + "ahp-types", + "ahp-ws", "base64", "bytes", "cfg-if", - "chrono", "clap", - "clap_lex 0.7.7", + "clap_lex", "console", "const_format", "core-foundation", @@ -418,16 +452,19 @@ dependencies = [ "flate2", "futures", "gethostname", + "http", + "http-body-util", "hyper", + "hyper-util", "indicatif", + "jiff", "keyring", - "lazy_static", "libc", + "local-ip-address", "log", "open", - "opentelemetry", "pin-project", - "rand 0.9.3", + "rand 0.10.1", "regex", "reqwest", "rmp-serde", @@ -439,14 +476,16 @@ dependencies = [ "sysinfo", "tar", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", + "tokio-tungstenite", "tokio-util", "tunnels", "url", "uuid", "winapi", - "winreg 0.50.0", + "windows-sys 0.61.2", + "winreg 0.56.0", "winresource", "zbus", "zip", @@ -526,21 +565,21 @@ dependencies = [ ] [[package]] -name = "crc32fast" -version = "1.5.0" +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ - "cfg-if", + "libc", ] [[package]] -name = "crossbeam-channel" -version = "0.5.15" +name = "crc32fast" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "crossbeam-utils", + "cfg-if", ] [[package]] @@ -559,6 +598,41 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.115", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.115", +] + [[package]] name = "data-encoding" version = "2.10.0" @@ -585,6 +659,37 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.115", +] + [[package]] name = "dialoguer" version = "0.10.4" @@ -670,6 +775,12 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1016,35 +1127,42 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core 0.10.1", "wasip2", "wasip3", ] +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "h2" -version = "0.3.27" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ + "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "futures-util", "http", - "indexmap 2.13.0", + "indexmap", "slab", "tokio", "tokio-util", "tracing", ] -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - [[package]] name = "hashbrown" version = "0.15.5" @@ -1101,23 +1219,34 @@ dependencies = [ [[package]] name = "http" -version = "0.2.12" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] [[package]] name = "http-body" -version = "0.4.6" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", "pin-project-lite", ] @@ -1135,14 +1264,14 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "0.14.32" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ + "atomic-waker", "bytes", "futures-channel", "futures-core", - "futures-util", "h2", "http", "http-body", @@ -1150,48 +1279,50 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.5.10", + "smallvec", "tokio", - "tower-service", - "tracing", "want", ] [[package]] name = "hyper-tls" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", + "http-body-util", "hyper", + "hyper-util", "native-tls", "tokio", "tokio-native-tls", + "tower-service", ] [[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "hyper-util" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "cc", + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", ] [[package]] @@ -1281,6 +1412,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1302,16 +1439,6 @@ dependencies = [ "icu_properties", ] -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", -] - [[package]] name = "indexmap" version = "2.13.0" @@ -1373,6 +1500,16 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -1404,6 +1541,30 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jiff" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "js-sys" version = "0.3.85" @@ -1442,9 +1603,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.181" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libredox" @@ -1502,6 +1663,17 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "local-ip-address" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b0187df4e614e42405b49511b82ff7a1774fbd9a816060ee465067847cac22" +dependencies = [ + "libc", + "neli", + "windows-sys 0.61.2", +] + [[package]] name = "lock_api" version = "0.4.14" @@ -1565,9 +1737,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -1591,6 +1763,35 @@ dependencies = [ "tempfile", ] +[[package]] +name = "neli" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" +dependencies = [ + "bitflags 2.10.0", + "byteorder", + "derive_builder", + "getset", + "libc", + "log", + "neli-proc-macros", + "parking_lot", +] + +[[package]] +name = "neli-proc-macros" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05d8d08c6e98f20a62417478ebf7be8e1425ec9acecc6f63e22da633f6b71609" +dependencies = [ + "either", + "proc-macro2", + "quote", + "serde", + "syn 2.0.115", +] + [[package]] name = "nix" version = "0.26.4" @@ -1936,51 +2137,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "opentelemetry" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f4b8347cc26099d3aeee044065ecc3ae11469796b4d65d065a23a584ed92a6f" -dependencies = [ - "opentelemetry_api", - "opentelemetry_sdk", -] - -[[package]] -name = "opentelemetry_api" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed41783a5bf567688eb38372f2b7a8530f5a607a4b49d38dd7573236c23ca7e2" -dependencies = [ - "futures-channel", - "futures-util", - "indexmap 1.9.3", - "once_cell", - "pin-project-lite", - "thiserror", - "urlencoding", -] - -[[package]] -name = "opentelemetry_sdk" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b3a2a91fdbfdd4d212c0dcc2ab540de2c2bcbbd90be17de7a7daf8822d010c1" -dependencies = [ - "async-trait", - "crossbeam-channel", - "futures-channel", - "futures-executor", - "futures-util", - "once_cell", - "opentelemetry_api", - "percent-encoding", - "rand 0.8.5", - "thiserror", - "tokio", - "tokio-stream", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -2138,6 +2294,15 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2182,6 +2347,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2232,14 +2419,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "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.1", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2297,6 +2495,12 @@ 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 = "rand_hc" version = "0.2.0" @@ -2332,7 +2536,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 1.0.69", ] [[package]] @@ -2366,44 +2570,42 @@ checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "reqwest" -version = "0.11.27" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64", "bytes", "encoding_rs", "futures-core", "futures-util", - "h2", "http", "http-body", + "http-body-util", "hyper", "hyper-tls", - "ipnet", + "hyper-util", "js-sys", "log", "mime", "native-tls", - "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile", + "rustls-pki-types", "serde", "serde_json", - "serde_urlencoded", "sync_wrapper", - "system-configuration", "tokio", "tokio-native-tls", "tokio-util", + "tower", + "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "wasm-streams", "web-sys", - "winreg 0.50.0", ] [[package]] @@ -2449,7 +2651,7 @@ dependencies = [ "sha1", "sha2", "subtle", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-util", ] @@ -2484,7 +2686,7 @@ dependencies = [ "russh-cryptovec", "serde", "sha2", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-stream", "yasna", @@ -2531,12 +2733,12 @@ dependencies = [ ] [[package]] -name = "rustls-pemfile" -version = "1.0.4" +name = "rustls-pki-types" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ - "base64", + "zeroize", ] [[package]] @@ -2545,12 +2747,6 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - [[package]] name = "schannel" version = "0.1.28" @@ -2684,18 +2880,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - [[package]] name = "sha1" version = "0.10.6" @@ -2703,7 +2887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2714,7 +2898,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -2776,22 +2960,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2842,9 +3016,12 @@ dependencies = [ [[package]] name = "sync_wrapper" -version = "0.1.2" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -2873,20 +3050,20 @@ dependencies = [ [[package]] name = "system-configuration" -version = "0.5.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.10.0", "core-foundation", "system-configuration-sys", ] [[package]] name = "system-configuration-sys" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ "core-foundation-sys", "libc", @@ -2922,7 +3099,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -2936,6 +3122,17 @@ dependencies = [ "syn 2.0.115", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "time" version = "0.3.47" @@ -2967,9 +3164,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.49.0" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ "bytes", "libc", @@ -2977,7 +3174,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2 0.6.3", "tokio-macros", "tracing", "windows-sys 0.61.2", @@ -2985,9 +3182,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -3017,9 +3214,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.20.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" dependencies = [ "futures-util", "log", @@ -3049,7 +3246,7 @@ version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.13.0", + "indexmap", "serde_core", "serde_spanned", "toml_datetime 0.7.5+spec-1.1.0", @@ -3079,7 +3276,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.13.0", + "indexmap", "toml_datetime 0.6.11", "winnow 0.5.40", ] @@ -3099,6 +3296,45 @@ version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -3144,48 +3380,46 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.20.1" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ - "byteorder", "bytes", "data-encoding", "http", "httparse", "log", "native-tls", - "rand 0.8.5", + "rand 0.9.4", "sha1", - "thiserror", - "url", - "utf-8", + "thiserror 2.0.18", ] [[package]] name = "tunnels" version = "0.1.0" -source = "git+https://github.com/microsoft/dev-tunnels?rev=8cae9b2a24c65c6c1958f5a0e77d72b23b5c6c30#8cae9b2a24c65c6c1958f5a0e77d72b23b5c6c30" +source = "git+https://github.com/connor4312/dev-tunnels?rev=4be50b3cc5ade8cb6beec4038c53ea4f2cdac5a2#4be50b3cc5ade8cb6beec4038c53ea4f2cdac5a2" dependencies = [ "async-trait", - "chrono", - "futures", + "futures-util", + "http-body-util", "hyper", + "hyper-util", + "jiff", "log", "os_info", + "percent-encoding", "rand 0.8.5", "reqwest", "russh", "russh-keys", "serde", "serde_json", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-tungstenite", "tokio-util", - "tungstenite", "url", - "urlencoding", "uuid", "winreg 0.8.0", ] @@ -3237,18 +3471,6 @@ dependencies = [ "serde", ] -[[package]] -name = "urlencoding" -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_iter" version = "1.0.4" @@ -3406,16 +3628,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap", "wasm-encoder", "wasmparser", ] [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -3432,7 +3654,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.10.0", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap", "semver", ] @@ -3479,46 +3701,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.115", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-interface" -version = "0.59.3" +name = "windows-registry" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.115", + "windows-link", + "windows-result", + "windows-strings", ] -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - [[package]] name = "windows-result" version = "0.4.1" @@ -3564,15 +3762,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -3606,30 +3795,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3642,12 +3814,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -3660,12 +3826,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -3678,24 +3838,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -3708,12 +3856,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -3726,12 +3868,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -3744,12 +3880,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -3762,12 +3892,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" version = "0.5.40" @@ -3794,12 +3918,12 @@ dependencies = [ [[package]] name = "winreg" -version = "0.50.0" +version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -3840,7 +3964,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap 2.13.0", + "indexmap", "prettyplease", "syn 2.0.115", "wasm-metadata", @@ -3871,7 +3995,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.10.0", - "indexmap 2.13.0", + "indexmap", "log", "serde", "serde_derive", @@ -3890,7 +4014,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap", "log", "semver", "serde", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 0389746bbeca09..c06404b7afd191 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -15,45 +15,51 @@ name = "code" futures = "0.3.28" clap = { version = "4.3.0", features = ["derive", "env"] } open = "4.1.0" -reqwest = { version = "0.11.22", default-features = false, features = ["json", "stream", "native-tls"] } -tokio = { version = "1.38.2", features = ["full"] } +reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "native-tls"] } +tokio = { version = "1.52", features = ["full"] } tokio-util = { version = "0.7.8", features = ["compat", "codec"] } flate2 = { version = "1.0.26", default-features = false, features = ["zlib"] } zip = { version = "0.6.6", default-features = false, features = ["time", "deflate-zlib"] } regex = "1.8.3" -lazy_static = "1.4.0" sysinfo = { version = "0.29.0", default-features = false } serde = { version = "1.0.163", features = ["derive"] } serde_json = "1.0.96" rmp-serde = "1.1.1" uuid = { version = "1.4", features = ["serde", "v4"] } dirs = "5.0.1" -rand = "0.9.3" -opentelemetry = { version = "0.19.0", features = ["rt-tokio"] } +rand = "0.10" serde_bytes = "0.11.9" -chrono = { version = "0.4.26", features = ["serde", "std", "clock"], default-features = false } +jiff = { version = "0.2", default-features = false, features = ["std", "serde"] } +http = "1" gethostname = "0.4.3" libc = "0.2.144" -tunnels = { git = "https://github.com/microsoft/dev-tunnels", rev = "8cae9b2a24c65c6c1958f5a0e77d72b23b5c6c30", default-features = false, features = ["connections"] } +# temporary fork pending https://github.com/microsoft/dev-tunnels/pull/626 +tunnels = { git = "https://github.com/connor4312/dev-tunnels", rev = "4be50b3cc5ade8cb6beec4038c53ea4f2cdac5a2", default-features = false, features = ["connections"] } keyring = { version = "2.0.3", default-features = false, features = ["linux-secret-service-rt-tokio-crypto-openssl", "platform-windows", "platform-macos", "linux-keyutils"] } dialoguer = "0.10.4" -hyper = { version = "0.14.26", features = ["server", "http1", "runtime"] } +hyper = { version = "1", features = ["server", "http1", "client"] } +hyper-util = { version = "0.1", features = ["tokio", "server-auto"] } +http-body-util = "0.1" +tokio-tungstenite = { version = "0.29", features = ["native-tls"] } indicatif = "0.17.4" tempfile = "3.5.0" -clap_lex = "0.7.0" +clap_lex = "1" url = "2.5.4" -async-trait = "0.1.68" log = "0.4.18" const_format = "0.2.31" sha2 = "0.10.6" -base64 = "0.21.2" +base64 = "0.22" shell-escape = "0.1.5" -thiserror = "1.0.40" +thiserror = "2" cfg-if = "1.0.0" pin-project = "1.1.0" console = "0.15.7" bytes = "1.11.1" tar = "0.4.45" +local-ip-address = "0.6" +ahp = "0.1" +ahp-types = "0.1" +ahp-ws = "0.1" [build-dependencies] serde = { version="1.0.163", features = ["derive"] } @@ -61,8 +67,9 @@ serde_json = "1.0.96" winresource = "0.1" [target.'cfg(windows)'.dependencies] -winreg = "0.50.0" +winreg = "0.56" winapi = "0.3.9" +windows-sys = { version = "0.61", features = ["Win32_System_Console", "Win32_UI_Input_KeyboardAndMouse"] } [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.9.3" diff --git a/cli/src/async_pipe.rs b/cli/src/async_pipe.rs index 78aed6fe3e7693..33c91b7dca2261 100644 --- a/cli/src/async_pipe.rs +++ b/cli/src/async_pipe.rs @@ -4,10 +4,9 @@ *--------------------------------------------------------------------------------------------*/ use crate::{constants::APPLICATION_NAME, util::errors::CodeError}; -use async_trait::async_trait; +use std::future::Future; use std::path::{Path, PathBuf}; use std::pin::Pin; -use std::task::{Context, Poll}; use tokio::io::{AsyncRead, AsyncWrite}; use tokio::net::TcpListener; use uuid::Uuid; @@ -46,6 +45,7 @@ cfg_if::cfg_if! { } else { use tokio::{time::sleep, io::ReadBuf}; use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions, NamedPipeClient, NamedPipeServer}; + use std::task::{Context, Poll}; use std::{time::Duration, io}; use pin_project::pin_project; @@ -176,57 +176,6 @@ cfg_if::cfg_if! { } } -impl AsyncPipeListener { - pub fn into_pollable(self) -> PollableAsyncListener { - PollableAsyncListener { - listener: Some(self), - write_fut: tokio_util::sync::ReusableBoxFuture::new(make_accept_fut(None)), - } - } -} - -pub struct PollableAsyncListener { - listener: Option, - write_fut: tokio_util::sync::ReusableBoxFuture< - 'static, - (AsyncPipeListener, Result), - >, -} - -async fn make_accept_fut( - data: Option, -) -> (AsyncPipeListener, Result) { - match data { - Some(mut l) => { - let c = l.accept().await; - (l, c) - } - None => unreachable!("this future should not be pollable in this state"), - } -} - -impl hyper::server::accept::Accept for PollableAsyncListener { - type Conn = AsyncPipe; - type Error = CodeError; - - fn poll_accept( - mut self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll>> { - if let Some(l) = self.listener.take() { - self.write_fut.set(make_accept_fut(Some(l))) - } - - match self.write_fut.poll(cx) { - Poll::Ready((l, cnx)) => { - self.listener = Some(l); - Poll::Ready(Some(cnx)) - } - Poll::Pending => Poll::Pending, - } - } -} - /// Gets a random name for a pipe/socket on the platform pub fn get_socket_name() -> PathBuf { cfg_if::cfg_if! { @@ -243,28 +192,41 @@ pub type AcceptedRW = ( Box, ); -#[async_trait] pub trait AsyncRWAccepter { - async fn accept_rw(&mut self) -> Result; + fn accept_rw( + &mut self, + ) -> Pin> + Send + '_>>; } -#[async_trait] impl AsyncRWAccepter for AsyncPipeListener { - async fn accept_rw(&mut self) -> Result { - let pipe = self.accept().await?; - let (read, write) = socket_stream_split(pipe); - Ok((Box::new(read), Box::new(write))) + fn accept_rw( + &mut self, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + let pipe = self.accept().await?; + let (read, write) = socket_stream_split(pipe); + Ok(( + Box::new(read) as Box, + Box::new(write) as Box, + )) + }) } } -#[async_trait] impl AsyncRWAccepter for TcpListener { - async fn accept_rw(&mut self) -> Result { - let (stream, _) = self - .accept() - .await - .map_err(CodeError::AsyncPipeListenerFailed)?; - let (read, write) = tokio::io::split(stream); - Ok((Box::new(read), Box::new(write))) + fn accept_rw( + &mut self, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + let (stream, _) = self + .accept() + .await + .map_err(CodeError::AsyncPipeListenerFailed)?; + let (read, write) = tokio::io::split(stream); + Ok(( + Box::new(read) as Box, + Box::new(write) as Box, + )) + }) } } diff --git a/cli/src/auth.rs b/cli/src/auth.rs index d4d62a8bf104ee..14eff97dfd79cb 100644 --- a/cli/src/auth.rs +++ b/cli/src/auth.rs @@ -10,18 +10,15 @@ use crate::{ trace, util::{ errors::{ - wrap, AnyError, CodeError, OAuthError, RefreshTokenNotAvailableError, StatusError, - WrappedError, + wrap, AnyError, OAuthError, RefreshTokenNotAvailableError, StatusError, WrappedError, }, input::prompt_options, }, warning, }; -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use gethostname::gethostname; +use jiff::{SignedDuration, Timestamp}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; -use std::{cell::Cell, fmt::Display, path::PathBuf, sync::Arc, thread}; +use std::{cell::Cell, fmt::Display, future::Future, path::PathBuf, pin::Pin, sync::Arc}; use tokio::time::sleep; use tunnels::{ contracts::PROD_FIRST_PARTY_APP_ID, @@ -110,7 +107,7 @@ pub struct StoredCredential { #[serde(rename = "r")] refresh_token: Option, #[serde(rename = "e")] - expires_at: Option>, + expires_at: Option, } const GH_USER_ENDPOINT: &str = "https://api.github.com/user"; @@ -128,11 +125,16 @@ async fn get_github_user( } impl StoredCredential { + /// Returns the raw access token string. + pub fn access_token(&self) -> &str { + &self.access_token + } + pub async fn is_expired(&self, log: &log::Logger, client: &reqwest::Client) -> bool { match self.provider { AuthProvider::Microsoft => self .expires_at - .map(|e| Utc::now() + chrono::Duration::minutes(5) > e) + .map(|e| Timestamp::now() + SignedDuration::from_secs(5 * 60) > e) .unwrap_or(false), // Make an auth request to Github. Mark the credential as expired @@ -166,7 +168,7 @@ impl StoredCredential { refresh_token: auth.refresh_token, expires_at: auth .expires_in - .map(|e| Utc::now() + chrono::Duration::seconds(e)), + .map(|e| Timestamp::now() + SignedDuration::from_secs(e)), } } } @@ -183,6 +185,8 @@ pub struct Auth { log: log::Logger, file_storage_path: PathBuf, storage: Arc>>, + /// Prefix for keyring entries, derived from the namespace. + keyring_prefix: String, } trait StorageImplementation: Send + Sync { @@ -226,16 +230,27 @@ const CONTINUE_MARKER: &str = ""; /// Implementation that wraps the KeyringStorage on Linux to avoid /// https://github.com/hwchen/keyring-rs/issues/132 +#[cfg(target_os = "linux")] struct ThreadKeyringStorage { s: Option, } +#[cfg(target_os = "linux")] impl ThreadKeyringStorage { + fn new(prefix: String) -> Self { + Self { + s: Some(KeyringStorage::new(prefix)), + } + } + fn thread_op(&mut self, f: Fn) -> Result where Fn: 'static + Send + FnOnce(&mut KeyringStorage) -> Result, R: 'static + Send, { + use crate::util::errors::CodeError; + use std::thread; + let mut s = match self.s.take() { Some(s) => s, None => return Err(CodeError::KeyringTimeout.into()), @@ -262,14 +277,7 @@ impl ThreadKeyringStorage { } } -impl Default for ThreadKeyringStorage { - fn default() -> Self { - Self { - s: Some(KeyringStorage::default()), - } - } -} - +#[cfg(target_os = "linux")] impl StorageImplementation for ThreadKeyringStorage { fn read(&mut self) -> Result, AnyError> { self.thread_op(|s| s.read()) @@ -284,19 +292,27 @@ impl StorageImplementation for ThreadKeyringStorage { } } -#[derive(Default)] struct KeyringStorage { - // keyring storage can be split into multiple entries due to entry length limits - // on Windows https://github.com/microsoft/vscode-cli/issues/358 + prefix: String, entries: Vec, } +impl KeyringStorage { + fn new(prefix: String) -> Self { + Self { + prefix, + entries: vec![], + } + } +} + macro_rules! get_next_entry { ($self: expr, $i: expr) => { match $self.entries.get($i) { Some(e) => e, None => { - let e = keyring::Entry::new("vscode-cli", &format!("vscode-cli-{}", $i)).unwrap(); + let e = keyring::Entry::new(&$self.prefix, &format!("{}-{}", $self.prefix, $i)) + .unwrap(); $self.entries.push(e); $self.entries.last().unwrap() } @@ -382,11 +398,31 @@ impl StorageImplementation for FileStorage { impl Auth { pub fn new(paths: &LauncherPaths, log: log::Logger) -> Auth { + Self::with_namespace(paths, log, None) + } + + /// Creates an `Auth` instance with an isolated credential namespace. + /// Credentials are stored separately from the global CLI credentials, + /// so logging in here does not affect tunnel or other global auth. + pub fn with_namespace( + paths: &LauncherPaths, + log: log::Logger, + namespace: Option, + ) -> Auth { + let filename = match &namespace { + None => "token.json".to_string(), + Some(ns) => format!("token-{ns}.json"), + }; + let keyring_prefix = match &namespace { + None => "vscode-cli".to_string(), + Some(ns) => format!("vscode-cli-{ns}"), + }; Auth { log, client: reqwest::Client::new(), - file_storage_path: paths.root().join("token.json"), + file_storage_path: paths.root().join(filename), storage: Arc::new(std::sync::Mutex::new(None)), + keyring_prefix, } } @@ -400,9 +436,9 @@ impl Auth { } #[cfg(not(target_os = "linux"))] - let mut keyring_storage = KeyringStorage::default(); + let mut keyring_storage = KeyringStorage::new(self.keyring_prefix.clone()); #[cfg(target_os = "linux")] - let mut keyring_storage = ThreadKeyringStorage::default(); + let mut keyring_storage = ThreadKeyringStorage::new(self.keyring_prefix.clone()); let mut file_storage = FileStorage(PersistedState::new_with_mode( self.file_storage_path.clone(), 0o600, @@ -494,7 +530,7 @@ impl Auth { // soon in order to get the real expiry time. expires_at: refresh_token .as_ref() - .map(|_| Utc::now() + chrono::Duration::minutes(5)), + .map(|_| Timestamp::now() + SignedDuration::from_secs(5 * 60)), refresh_token, }, None => self.do_device_code_flow_with_provider(provider).await?, @@ -504,6 +540,22 @@ impl Auth { Ok(credentials) } + /// Runs the device-flow login for a specific provider with custom OAuth + /// scopes. Unlike [`login`], this is purpose-built for agent host auth + /// where the scopes are dictated by the server's protected resource + /// metadata rather than hardcoded defaults. + pub async fn login_with_scopes( + &self, + provider: AuthProvider, + scopes: Option, + ) -> Result { + let credentials = self + .do_device_code_flow_with_scopes(provider, scopes) + .await?; + self.store_credentials(credentials.clone()); + Ok(credentials) + } + /// Gets the currently stored credentials, or asks the user to log in. pub async fn get_credential(&self) -> Result { let entry = match self.get_current_credential() { @@ -675,7 +727,7 @@ impl Auth { /// Implements the device code flow, returning the credentials upon success. async fn do_device_code_flow(&self) -> Result { let provider = self.prompt_for_provider().await?; - self.do_device_code_flow_with_provider(provider).await + self.do_device_code_flow_with_scopes(provider, None).await } async fn prompt_for_provider(&self) -> Result { @@ -700,6 +752,17 @@ impl Auth { &self, provider: AuthProvider, ) -> Result { + self.do_device_code_flow_with_scopes(provider, None).await + } + + /// Runs the OAuth device code flow with optional custom scopes. + /// If `scopes` is `None`, falls back to the provider's default scopes. + pub async fn do_device_code_flow_with_scopes( + &self, + provider: AuthProvider, + scopes: Option, + ) -> Result { + let scopes = scopes.unwrap_or_else(|| provider.get_default_scopes()); loop { let init_code = self .client @@ -708,7 +771,7 @@ impl Auth { .body(format!( "client_id={}&scope={}", provider.client_id(), - provider.get_default_scopes(), + scopes, )) .send() .await?; @@ -718,7 +781,8 @@ impl Auth { } let init_code_json = init_code.json::().await?; - let expires_at = Utc::now() + chrono::Duration::seconds(init_code_json.expires_in); + let expires_at = + Timestamp::now() + SignedDuration::from_secs(init_code_json.expires_in); match &init_code_json.message { Some(m) => self.log.result(m), @@ -735,7 +799,7 @@ impl Auth { ); let mut interval_s = 5; - while Utc::now() < expires_at { + while Timestamp::now() < expires_at { sleep(std::time::Duration::from_secs(interval_s)).await; match self.do_grant(provider, body.clone()).await { @@ -772,7 +836,19 @@ impl Auth { min_refresh } else { match credential.expires_at { - Some(d) => ((d - Utc::now()) * 2 / 3).to_std().unwrap_or(min_refresh), + Some(d) => { + let dur = d.duration_since(Timestamp::now()); + let nanos = dur.as_nanos() * 2 / 3; + let scaled = SignedDuration::new( + (nanos / 1_000_000_000) as i64, + (nanos % 1_000_000_000) as i32, + ); + if scaled.is_negative() { + min_refresh + } else { + scaled.unsigned_abs() + } + } None => default_refresh, } }; @@ -807,18 +883,25 @@ impl Auth { } } -#[async_trait] impl AuthorizationProvider for Auth { - async fn get_authorization(&self) -> Result { - self.get_tunnel_authentication() - .await - .map_err(|e| HttpError::AuthorizationError(e.to_string())) + fn get_authorization( + &self, + ) -> Pin> + Send + '_>> { + Box::pin(async move { + self.get_tunnel_authentication() + .await + .map_err(|e| HttpError::AuthorizationError(e.to_string())) + }) } } -lazy_static::lazy_static! { - static ref HOSTNAME: Vec = gethostname().to_string_lossy().bytes().collect(); -} +#[cfg(feature = "vscode-encrypt")] +static HOSTNAME: std::sync::LazyLock> = std::sync::LazyLock::new(|| { + gethostname::gethostname() + .to_string_lossy() + .bytes() + .collect() +}); #[cfg(feature = "vscode-encrypt")] fn encrypt(value: &str) -> String { diff --git a/cli/src/bin/code/main.rs b/cli/src/bin/code/main.rs index 6c301ca9502bec..8a9c38f3afd9b6 100644 --- a/cli/src/bin/code/main.rs +++ b/cli/src/bin/code/main.rs @@ -8,7 +8,10 @@ use std::process::Command; use clap::Parser; use cli::{ - commands::{agent_host, args, serve_web, tunnels, update, version, CommandContext}, + commands::{ + agent_host, agent_kill, agent_logs, agent_ps, agent_stop, args, serve_web, tunnels, update, + version, CommandContext, + }, constants::get_default_user_agent, desktop, log, state::LauncherPaths, @@ -19,8 +22,6 @@ use cli::{ }, }; use legacy_args::try_parse_legacy; -use opentelemetry::sdk::trace::TracerProvider as SdkTracerProvider; -use opentelemetry::trace::TracerProvider; #[tokio::main] async fn main() -> Result<(), std::convert::Infallible> { @@ -103,9 +104,22 @@ async fn main() -> Result<(), std::convert::Infallible> { serve_web::serve_web(context!(), sw_args).await } - Some(args::Commands::AgentHost(ah_args)) => { - agent_host::agent_host(context!(), ah_args).await - } + Some(args::Commands::Agent(agent_args)) => match agent_args.subcommand { + Some(args::AgentSubcommand::Ps(ps_args)) => { + agent_ps::agent_ps(context!(), ps_args).await + } + Some(args::AgentSubcommand::Host(ah_args)) => { + agent_host::agent_host(context!(), ah_args).await + } + Some(args::AgentSubcommand::Stop(stop_args)) => { + agent_stop::agent_stop(context!(), stop_args).await + } + Some(args::AgentSubcommand::Kill) => agent_kill::agent_kill(context!()).await, + Some(args::AgentSubcommand::Logs(logs_args)) => { + agent_logs::agent_logs(context!(), logs_args).await + } + None => agent_host::agent_host(context!(), agent_args.host_args).await, + }, Some(args::Commands::Tunnel(mut tunnel_args)) => match tunnel_args.subcommand.take() { Some(args::TunnelSubcommand::Prune) => tunnels::prune(context!()).await, @@ -143,8 +157,7 @@ fn make_logger(core: &args::CliCore) -> log::Logger { core.global_options.log.unwrap_or(log::Level::Info) }; - let tracer = SdkTracerProvider::builder().build().tracer("codecli"); - let mut log = log::Logger::new(tracer, log_level); + let mut log = log::Logger::new(log_level); if let Some(f) = &core.global_options.log_to_file { log = log .with_sink(log::FileLogSink::new(log_level, f).expect("expected to make file logger")) diff --git a/cli/src/commands.rs b/cli/src/commands.rs index 1b706653c6e044..eeb8fc53336bb4 100644 --- a/cli/src/commands.rs +++ b/cli/src/commands.rs @@ -5,8 +5,14 @@ mod context; +pub mod agent; pub mod agent_host; +pub mod agent_kill; +pub mod agent_logs; +pub mod agent_ps; +pub mod agent_stop; pub mod args; +pub mod output; pub mod serve_web; pub mod tunnels; pub mod update; diff --git a/cli/src/commands/agent.rs b/cli/src/commands/agent.rs new file mode 100644 index 00000000000000..39d5e4e2ad0902 --- /dev/null +++ b/cli/src/commands/agent.rs @@ -0,0 +1,282 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use std::fs; + +use ahp::{Client, Transport, TransportError, TransportMessage}; +use ahp_types::commands::{AuthenticateParams, AuthenticateResult}; +use ahp_types::errors::ahp_error_codes; +use ahp_types::state::ProtectedResourceMetadata; +use ahp_types::PROTOCOL_VERSION; +use futures::{SinkExt, StreamExt}; +use tokio_tungstenite::tungstenite::Message; + +use crate::auth::{Auth, AuthProvider}; +use crate::constants::AGENT_HOST_PORT; +use crate::log; +use crate::tunnels::dev_tunnels::DevTunnels; +use crate::util::errors::{wrap, AnyError, CodeError}; +use crate::util::machine::process_exists; + +use super::agent_host::AgentHostLockData; +use super::CommandContext; + +/// Connects to an agent host, initializes the AHP session, and returns +/// the ready-to-use client. If an explicit `address` is given it is used +/// directly; if `tunnel_name` is given, the tunnel is looked up via the +/// dev tunnels API; otherwise the lockfile written by `code agent host` +/// is read to discover the local instance. +/// +/// The returned client has been initialized but **not** authenticated. +/// Use [`request_with_auth`] to issue commands that may require auth. +pub async fn connect( + ctx: &CommandContext, + address: Option<&str>, + tunnel_name: Option<&str>, +) -> Result { + let client = match (address, tunnel_name) { + (Some(addr), _) => connect_ws(addr).await?, + (None, Some(name)) => connect_via_tunnel(ctx, name).await?, + (None, None) => { + let addr = resolve_address_from_lockfile(ctx)?; + connect_ws(&addr).await? + } + }; + + client + .initialize("code-cli".into(), PROTOCOL_VERSION as i64, vec![]) + .await + .map_err(|e| wrap(e, "AHP initialize failed"))?; + + Ok(client) +} + +/// Opens a WebSocket connection and creates an AHP client. +async fn connect_ws(address: &str) -> Result { + let transport = ahp_ws::WebSocketTransport::connect(address) + .await + .map_err(|e| wrap(e, format!("Failed to connect to agent host at {address}")))?; + + Client::connect(transport, ahp::ClientConfig::default()) + .await + .map_err(|e| wrap(e, "Failed to establish AHP session").into()) +} + +/// Connects to an agent host over a dev tunnel relay. Looks up the tunnel +/// by name, opens a direct-tcpip channel to the agent host port, performs +/// a WebSocket handshake over the raw stream, then creates an AHP client. +async fn connect_via_tunnel(ctx: &CommandContext, name: &str) -> Result { + let auth = Auth::new(&ctx.paths, ctx.log.clone()); + let mut dt = DevTunnels::new_remote_tunnel(&ctx.log, auth, &ctx.paths); + + let (port_conn, _relay_handle) = dt.connect_to_tunnel_port(name, AGENT_HOST_PORT).await?; + + let rw = port_conn.into_rw(); + let (ws_stream, _) = tokio_tungstenite::client_async("ws://localhost/", rw) + .await + .map_err(|e| wrap(e, "WebSocket handshake over tunnel failed"))?; + + let transport = TunnelWsTransport { + inner: ws_stream, + // Keep the relay handle alive so the SSH session isn't dropped. + _relay_handle, + }; + + Client::connect(transport, ahp::ClientConfig::default()) + .await + .map_err(|e| wrap(e, "Failed to establish AHP session over tunnel").into()) +} + +/// A [`Transport`] backed by a WebSocket stream running over a tunnel +/// relay channel (via `PortConnectionRW`). +struct TunnelWsTransport { + inner: tokio_tungstenite::WebSocketStream, + /// Prevent the relay handle from being dropped, which would close the + /// underlying SSH session. + _relay_handle: tunnels::connections::ClientRelayHandle, +} + +impl Transport for TunnelWsTransport { + async fn send(&mut self, msg: TransportMessage) -> Result<(), TransportError> { + let frame = match msg { + TransportMessage::Parsed(m) => { + let s = serde_json::to_string(&m) + .map_err(|e| TransportError::Protocol(e.to_string()))?; + Message::Text(s.into()) + } + TransportMessage::Text(s) => Message::Text(s.into()), + TransportMessage::Binary(b) => Message::Binary(b.into()), + }; + self.inner + .send(frame) + .await + .map_err(|e| TransportError::Io(e.to_string())) + } + + async fn recv(&mut self) -> Result, TransportError> { + loop { + match self.inner.next().await { + None => return Ok(None), + Some(Err(e)) => return Err(TransportError::Io(e.to_string())), + Some(Ok(Message::Text(s))) => { + return Ok(Some(TransportMessage::Text(s.to_string()))) + } + Some(Ok(Message::Binary(b))) => { + return Ok(Some(TransportMessage::Binary(b.to_vec()))) + } + Some(Ok(Message::Close(_))) => return Ok(None), + Some(Ok(_)) => continue, + } + } + } + + async fn close(&mut self) -> Result<(), TransportError> { + self.inner + .close(None) + .await + .map_err(|e| TransportError::Io(e.to_string())) + } +} + +/// Issues a JSON-RPC request, automatically handling `-32007` auth errors +/// by running the device-flow login and retrying once. +pub async fn request_with_auth( + ctx: &CommandContext, + client: &Client, + method: &str, + params: P, +) -> Result +where + P: serde::Serialize + Clone, + R: serde::de::DeserializeOwned, +{ + match client.request::(method, params.clone()).await { + Ok(r) => Ok(r), + Err(ref e) if is_auth_required(e) => { + debug!( + ctx.log, + "Server requires authentication, starting login flow..." + ); + authenticate_from_error(ctx, client, e).await?; + client + .request::(method, params) + .await + .map_err(|e| wrap(e, format!("Failed after authentication: {method}")).into()) + } + Err(e) => Err(wrap(e, format!("Request failed: {method}")).into()), + } +} + +fn is_auth_required(err: &ahp::ClientError) -> bool { + matches!(err, ahp::ClientError::Rpc(e) if e.code == ahp_error_codes::AUTH_REQUIRED) +} + +fn parse_protected_resources(err: &ahp::ClientError) -> Vec { + if let ahp::ClientError::Rpc(e) = err { + if let Some(data) = &e.data { + if let Ok(resources) = + serde_json::from_value::>(data.clone()) + { + return resources; + } + } + } + Vec::new() +} + +fn provider_for_resource(resource: &ProtectedResourceMetadata) -> Option { + for server in resource + .authorization_servers + .as_deref() + .unwrap_or_default() + { + if server.contains("github.com") { + return Some(AuthProvider::Github); + } + if server.contains("microsoftonline.com") || server.contains("login.microsoft.com") { + return Some(AuthProvider::Microsoft); + } + } + None +} + +async fn authenticate_from_error( + ctx: &CommandContext, + client: &Client, + err: &ahp::ClientError, +) -> Result<(), AnyError> { + let resources = parse_protected_resources(err); + if resources.is_empty() { + return Err(wrap( + "Server returned AuthRequired but did not include protected resource metadata", + "Cannot determine authentication provider", + ) + .into()); + } + + let auth = Auth::with_namespace(&ctx.paths, ctx.log.clone(), Some("agent-host".into())); + + for resource in &resources { + let provider = provider_for_resource(resource); + let scopes = resource.scopes_supported.as_ref().map(|s| s.join("+")); + + // Reuse a stored credential from the namespace if one exists; only + // start a device-flow login when there is nothing cached. + let credential = match auth.get_current_credential() { + Ok(Some(existing)) => existing, + _ => match provider { + Some(p) => auth.login_with_scopes(p, scopes).await?, + None => auth.get_credential().await?, + }, + }; + + let _: AuthenticateResult = client + .request( + "authenticate", + AuthenticateParams { + resource: resource.resource.clone(), + token: credential.access_token().to_string(), + }, + ) + .await + .map_err(|e| { + wrap( + e, + format!("AHP authenticate failed for {}", resource.resource), + ) + })?; + } + + Ok(()) +} + +fn resolve_address_from_lockfile(ctx: &CommandContext) -> Result { + let lockfile_path = ctx.paths.agent_host_lockfile(); + + let data = fs::read_to_string(&lockfile_path).map_err(|e| { + wrap( + e, + "No running agent host found. Start one with `code agent host` or specify --address", + ) + })?; + + let lock: AgentHostLockData = serde_json::from_str(&data).map_err(|e| { + wrap( + e, + format!("Corrupt agent host lockfile at {}", lockfile_path.display()), + ) + })?; + + if !process_exists(lock.pid) { + let _ = fs::remove_file(&lockfile_path); + return Err(CodeError::NoRunningAgentHost.into()); + } + + let mut url = lock.address; + if let Some(token) = &lock.connection_token { + url.push_str(&format!("?tkn={token}")); + } + Ok(url) +} diff --git a/cli/src/commands/agent_host.rs b/cli/src/commands/agent_host.rs index bfa0644b2ec81e..58826d98c039ca 100644 --- a/cli/src/commands/agent_host.rs +++ b/cli/src/commands/agent_host.rs @@ -9,21 +9,47 @@ use std::io::{Read, Write}; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::Instant; -use hyper::service::{make_service_fn, service_fn}; -use hyper::Server; +use ::http::{Request, Response}; +use hyper::body::Incoming; +use hyper::service::service_fn; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use hyper_util::server::conn::auto::Builder as ServerBuilder; +use serde::{Deserialize, Serialize}; +use tokio::net::TcpListener; +use crate::auth::Auth; +use crate::constants::{self, AGENT_HOST_PORT}; use crate::log; use crate::tunnels::agent_host::{handle_request, AgentHostConfig, AgentHostManager}; -use crate::tunnels::legal; +use crate::tunnels::dev_tunnels::DevTunnels; use crate::tunnels::shutdown_signal::ShutdownRequest; use crate::update_service::Platform; -use crate::util::errors::AnyError; -use crate::util::errors::CodeError; -use crate::util::http::ReqwestSimpleHttp; +use crate::util::errors::{AnyError, CodeError}; +use crate::util::http::{full_body, HyperBody, ReqwestSimpleHttp}; use crate::util::prereqs::PreReqChecker; -use super::{args::AgentHostArgs, CommandContext}; +use super::args::AgentHostArgs; +use super::output; +use super::tunnels::fulfill_existing_tunnel_args; +use super::CommandContext; + +/// Bookkeeping data written to the agent host lockfile so that other CLI +/// commands (e.g. `code agent ps`) can discover a running agent host. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentHostLockData { + /// WebSocket address the agent host is listening on (e.g. `ws://127.0.0.1:4567/`). + pub address: String, + /// PID of the CLI process running the agent host. + pub pid: u32, + /// Connection token, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub connection_token: Option, + /// Tunnel name, if `--tunnel` was used. + #[serde(skip_serializing_if = "Option::is_none")] + pub tunnel_name: Option, +} /// Runs a local agent host server. Downloads the latest VS Code server on /// demand, starts it with `--enable-remote-auto-shutdown`, and proxies @@ -31,7 +57,7 @@ use super::{args::AgentHostArgs, CommandContext}; /// socket. The server auto-shuts down when idle; the CLI checks for updates /// in the background and starts the latest version on the next connection. pub async fn agent_host(ctx: CommandContext, mut args: AgentHostArgs) -> Result { - legal::require_consent(&ctx.paths, args.accept_server_license_terms)?; + let started = Instant::now(); let platform: Platform = PreReqChecker::new().verify().await?; @@ -56,9 +82,13 @@ pub async fn agent_host(ctx: CommandContext, mut args: AgentHostArgs) -> Result< Arc::new(ReqwestSimpleHttp::with_client(ctx.http.clone())), AgentHostConfig { server_data_dir: args.server_data_dir.clone(), - without_connection_token: args.without_connection_token, - connection_token: args.connection_token.clone(), - connection_token_file: args.connection_token_file.clone(), + // The CLI proxy enforces the connection token itself, so the + // underlying server always runs without one. This lets tunnel + // connections (which bypass the proxy token check) reach the + // server without needing a token at all. + without_connection_token: true, + connection_token: None, + connection_token_file: None, }, ); @@ -88,66 +118,214 @@ pub async fn agent_host(ctx: CommandContext, mut args: AgentHostArgs) -> Result< Some(h) => SocketAddr::new(h.parse().map_err(CodeError::InvalidHostAddress)?, args.port), None => SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), args.port), }; - let builder = Server::try_bind(&addr).map_err(CodeError::CouldNotListenOnInterface)?; - let bound_addr = builder.local_addr(); + let listener = TcpListener::bind(addr) + .await + .map_err(CodeError::CouldNotListenOnInterface)?; + let bound_addr = listener + .local_addr() + .map_err(CodeError::CouldNotListenOnInterface)?; - let mut url = format!("ws://{bound_addr}"); - if let Some(ct) = &args.connection_token { - url.push_str(&format!("?tkn={ct}")); - } - ctx.log - .result(format!("Agent host proxy listening on {url}")); + let local_agent_host_url = format!("ws://{bound_addr}/"); - let manager_for_svc = manager.clone(); - let make_svc = move || { - let mgr = manager_for_svc.clone(); - let service = service_fn(move |req| { - let mgr = mgr.clone(); - async move { handle_request(mgr, req).await } + let product = constants::QUALITYLESS_PRODUCT_NAME; + let token_suffix = args + .connection_token + .as_deref() + .map(|t| format!("?tkn={t}")) + .unwrap_or_default(); + + // If --tunnel is set, create a dev tunnel and serve connections directly. + let mut _tunnel_handle: Option = None; + let mut tunnel_name: Option = None; + if args.tunnel { + let auth = Auth::new(&ctx.paths, ctx.log.clone()); + let mut dt = DevTunnels::new_remote_tunnel(&ctx.log, auth, &ctx.paths); + + let mut tunnel = if let Some(existing) = + fulfill_existing_tunnel_args(args.existing_tunnel.clone(), &args.name) + { + dt.start_existing_tunnel(existing).await + } else { + dt.start_new_launcher_tunnel(args.name.as_deref(), args.random_name, &[]) + .await + }?; + + // Receive tunnel connections directly (no TCP forwarding) and serve + // them without connection-token enforcement — the tunnel relay + // provides its own authentication. + let mut tunnel_port = tunnel.add_port_direct(AGENT_HOST_PORT).await?; + let mgr_for_tunnel = manager.clone(); + let tunnel_log = ctx.log.clone(); + tokio::spawn(async move { + while let Some(socket) = tunnel_port.recv().await { + let mgr = mgr_for_tunnel.clone(); + let log = tunnel_log.clone(); + tokio::spawn(async move { + debug!(log, "Serving tunnel agent host connection"); + let rw = socket.into_rw(); + let svc = service_fn(move |req| { + let mgr = mgr.clone(); + async move { handle_request(mgr, req).await } + }); + let io = TokioIo::new(rw); + if let Err(e) = ServerBuilder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(io, svc) + .await + { + debug!(log, "Tunnel agent host connection ended: {:?}", e); + } + }); + } }); - async move { Ok::<_, Infallible>(service) } + + tunnel_name = Some(tunnel.name.clone()); + _tunnel_handle = Some(tunnel); + } + + output::print_banner_header(&format!("{product} Agent Host"), started.elapsed()); + if let Some(name) = &tunnel_name { + let tunnel_url = match constants::EDITOR_WEB_URL { + Some(base) => format!("{base}/agents?tunnel={name}"), + None => format!("(set VSCODE_CLI_TUNNEL_EDITOR_WEB_URL)/agents?tunnel={name}"), + }; + output::print_banner_line("Tunnel", &tunnel_url); + } + output::print_network_lines(bound_addr.port(), addr.ip(), &token_suffix); + output::print_banner_footer(); + + // Write lockfile so `code agent ps` can discover this instance. + let lockfile_path = ctx.paths.agent_host_lockfile(); + let lock_data = AgentHostLockData { + address: local_agent_host_url, + pid: std::process::id(), + connection_token: args.connection_token.clone(), + tunnel_name: tunnel_name.clone(), }; + if let Err(e) = write_agent_host_lockfile(&lockfile_path, &lock_data) { + warning!(ctx.log, "Failed to write agent host lockfile: {}", e); + } - let server_future = builder - .serve(make_service_fn(|_| make_svc())) - .with_graceful_shutdown(async { - let _ = shutdown.wait().await; - }); + let manager_for_svc = manager.clone(); + let expected_token = args.connection_token.clone(); + + // Accept loop: for each incoming TCP connection, serve it with hyper. + let accept_result: Result<(), AnyError> = loop { + tokio::select! { + _ = shutdown.wait() => break Ok(()), + accepted = listener.accept() => { + let (stream, _) = match accepted { + Ok(v) => v, + Err(e) => { + warning!(ctx.log, "Failed to accept connection: {}", e); + continue; + } + }; + let mgr = manager_for_svc.clone(); + let token = expected_token.clone(); + tokio::spawn(async move { + let io = TokioIo::new(stream); + let svc = service_fn(move |req| { + let mgr = mgr.clone(); + let token = token.clone(); + async move { handle_request_with_auth(mgr, req, token).await } + }); + if let Err(e) = ServerBuilder::new(TokioExecutor::new()) + .serve_connection_with_upgrades(io, svc) + .await + { + // Connection-level errors are normal (client disconnect, etc.) + let _ = e; + } + }); + } + } + }; - let r = server_future.await; manager.kill_running_server().await; - r.map_err(CodeError::CouldNotListenOnInterface)?; + + // Close the tunnel if one was created. + if let Some(mut tunnel) = _tunnel_handle.take() { + tunnel.close().await.ok(); + } + + // Clean up the lockfile. + let _ = fs::remove_file(&lockfile_path); + + accept_result?; Ok(0) } +/// Wraps [`handle_request`] with connection-token enforcement. +/// +/// When `expected_token` is `Some`, the proxy requires `?tkn=` on +/// the request URI. This only applies to the local TCP listener; tunnel +/// connections are served directly via `add_port_direct` and bypass this +/// function entirely. +async fn handle_request_with_auth( + manager: Arc, + req: Request, + expected_token: Option, +) -> Result, Infallible> { + if let Some(ref token) = expected_token { + let uri_query = req.uri().query().unwrap_or(""); + let has_valid_token = url::form_urlencoded::parse(uri_query.as_bytes()) + .any(|(k, v)| k == "tkn" && v == token.as_str()); + + if !has_valid_token { + return Ok(Response::builder() + .status(403) + .body(full_body("Forbidden: missing or invalid connection token")) + .unwrap()); + } + } + + handle_request(manager, req).await +} + fn mint_connection_token(path: &Path, prefer_token: Option) -> std::io::Result { #[cfg(not(windows))] use std::os::unix::fs::OpenOptionsExt; - let mut f = fs::OpenOptions::new(); - f.create(true); - f.write(true); - f.read(true); + let mut file_options = fs::OpenOptions::new(); + file_options.create(true); + file_options.write(true); + file_options.read(true); #[cfg(not(windows))] - f.mode(0o600); - let mut f = f.open(path)?; + file_options.mode(0o600); + let mut file = file_options.open(path)?; if prefer_token.is_none() { - let mut t = String::new(); - f.read_to_string(&mut t)?; - let t = t.trim(); - if !t.is_empty() { - return Ok(t.to_string()); + let mut token = String::new(); + file.read_to_string(&mut token)?; + let token = token.trim(); + if !token.is_empty() { + return Ok(token.to_string()); } } - f.set_len(0)?; + file.set_len(0)?; let prefer_token = prefer_token.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - f.write_all(prefer_token.as_bytes())?; + file.write_all(prefer_token.as_bytes())?; Ok(prefer_token) } +fn write_agent_host_lockfile(path: &Path, lock_data: &AgentHostLockData) -> std::io::Result<()> { + #[cfg(not(windows))] + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut file_options = fs::OpenOptions::new(); + file_options.create(true); + file_options.write(true); + file_options.truncate(true); + #[cfg(not(windows))] + file_options.mode(0o600); + let mut file = file_options.open(path)?; + #[cfg(not(windows))] + file.set_permissions(fs::Permissions::from_mode(0o600))?; + file.write_all(serde_json::to_string(lock_data).unwrap().as_bytes()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/cli/src/commands/agent_kill.rs b/cli/src/commands/agent_kill.rs new file mode 100644 index 00000000000000..06e8b86b120b5e --- /dev/null +++ b/cli/src/commands/agent_kill.rs @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use std::fs; + +use crate::log; +use crate::util::command::kill_tree; +use crate::util::errors::{wrap, AnyError}; +use crate::util::machine::process_exists; + +use super::agent_host::AgentHostLockData; +use super::CommandContext; + +/// Forcefully kills the running agent host process tree and cleans up. +pub async fn agent_kill(ctx: CommandContext) -> Result { + let lockfile_path = ctx.paths.agent_host_lockfile(); + + let data = fs::read_to_string(&lockfile_path).map_err(|e| { + wrap( + e, + "No running agent host found. Start one with `code agent host`", + ) + })?; + + let lock: AgentHostLockData = serde_json::from_str(&data).map_err(|e| { + wrap( + e, + format!("Corrupt agent host lockfile at {}", lockfile_path.display()), + ) + })?; + + if !process_exists(lock.pid) { + let _ = fs::remove_file(&lockfile_path); + ctx.log + .result("Agent host is not running (stale lockfile cleaned up)."); + return Ok(0); + } + + debug!( + ctx.log, + "Killing agent host process tree (pid {})", lock.pid + ); + + kill_tree(lock.pid) + .await + .map_err(|e| wrap(e, "Failed to kill agent host process tree"))?; + + let _ = fs::remove_file(&lockfile_path); + + ctx.log + .result(format!("Killed agent host (pid {}).", lock.pid)); + + Ok(0) +} diff --git a/cli/src/commands/agent_logs.rs b/cli/src/commands/agent_logs.rs new file mode 100644 index 00000000000000..c52a08a81b50d9 --- /dev/null +++ b/cli/src/commands/agent_logs.rs @@ -0,0 +1,181 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use ahp::SubscriptionEvent; +use ahp_types::actions::StateAction; +use ahp_types::commands::{SubscribeParams, SubscribeResult}; +use ahp_types::state::{SnapshotState, TurnState}; +use console::Style; + +use crate::tunnels::shutdown_signal::ShutdownRequest; +use crate::util::errors::AnyError; + +use super::agent; +use super::args::AgentLogsArgs; +use super::output::Styles; +use super::CommandContext; + +/// Subscribes to a session and streams actions/notifications in real time. +pub async fn agent_logs(ctx: CommandContext, args: AgentLogsArgs) -> Result { + let client = agent::connect(&ctx, args.address.as_deref(), args.tunnel.as_deref()).await?; + + let (result, mut sub): (SubscribeResult, _) = { + let r: SubscribeResult = agent::request_with_auth( + &ctx, + &client, + "subscribe", + SubscribeParams { + resource: args.session.clone(), + }, + ) + .await?; + let s = client.attach_subscription(&args.session).await; + (r, s) + }; + + // Print initial state summary. + print_initial_state(&args.session, &result); + + let header = Styles::muted(); + println!( + "\n{}", + header.apply_to("Streaming events (Ctrl+C to quit)...") + ); + println!("{}", header.apply_to("─".repeat(50))); + + // Stream events until Ctrl+C or the subscription closes. + let mut shutdown = ShutdownRequest::create_rx([ShutdownRequest::CtrlC]); + + loop { + tokio::select! { + ev = sub.recv() => match ev { + Some(SubscriptionEvent::Action(envelope)) => { + print_action(envelope.server_seq, &envelope.action); + } + Some(SubscriptionEvent::Notification(notif)) => { + let notif_style = Style::new().magenta(); + println!("{}", notif_style.apply_to(format!("notification: {notif:?}"))); + } + None => { + println!("{}", Styles::muted().apply_to("Subscription closed.")); + break; + } + }, + _ = shutdown.wait() => { + println!("\n{}", Styles::muted().apply_to("Interrupted.")); + break; + } + } + } + + client.shutdown().await; + Ok(0) +} + +fn print_initial_state(uri: &str, result: &SubscribeResult) { + let title = Styles::title(); + let label = Styles::label(); + let uri_style = Styles::uri(); + + println!( + "\n{} {}", + title.apply_to("Session"), + uri_style.apply_to(uri) + ); + + if let SnapshotState::Session(ref session) = result.snapshot.state { + let s = &session.summary; + if !s.title.is_empty() { + println!(" {} {}", label.apply_to("title:"), s.title); + } + println!(" {} {}", label.apply_to("provider:"), s.provider); + if let Some(ref activity) = s.activity { + if !activity.is_empty() { + println!(" {} {}", label.apply_to("activity:"), activity); + } + } + println!(" {} {}", label.apply_to("turns:"), session.turns.len()); + + // Print a brief summary of past turns. + for turn in &session.turns { + let state_str = match turn.state { + TurnState::Complete => Styles::success().apply_to("✓"), + TurnState::Cancelled => Styles::warning().apply_to("⊘"), + TurnState::Error => Styles::error().apply_to("✗"), + }; + let msg = truncate(&turn.user_message.text, 80); + println!(" {} {}", state_str, Styles::muted().apply_to(msg)); + } + + // Print active turn if any. + if let Some(ref active) = session.active_turn { + let msg = truncate(&active.user_message.text, 80); + println!(" {} {}", Style::new().green().bold().apply_to("►"), msg); + } + } + + println!(" {} {}", label.apply_to("seq:"), result.snapshot.from_seq); +} + +fn print_action(seq: u64, action: &StateAction) { + let seq_str = Styles::muted().apply_to(format!("[{seq:>6}]")); + + // Serialize the action to extract the "type" tag and remaining fields. + let value = serde_json::to_value(action).unwrap_or_default(); + let type_name = value + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + + // Build a compact params string from all fields except "type". + let params = if let Some(obj) = value.as_object() { + let parts: Vec = obj + .iter() + .filter(|(k, _)| k.as_str() != "type") + .map(|(k, v)| { + let v_str = match v { + serde_json::Value::String(s) => truncate(s, 80), + other => truncate(&other.to_string(), 80), + }; + format!("{}={}", Styles::label().apply_to(k), v_str) + }) + .collect(); + parts.join(" ") + } else { + String::new() + }; + + let style = action_style(type_name); + println!("{} {} {}", seq_str, style.apply_to(type_name), params); +} + +/// Picks a color for the action type name. +fn action_style(type_name: &str) -> Style { + if type_name.contains("error") || type_name.contains("Failed") { + Styles::error() + } else if type_name.contains("Complete") || type_name.contains("complete") { + Styles::success() + } else if type_name.contains("Cancel") || type_name.contains("cancel") { + Styles::warning() + } else if type_name.contains("oolCall") || type_name.contains("oolcall") { + Style::new().blue() + } else if type_name.contains("delta") + || type_name.contains("Delta") + || type_name.contains("reasoning") + { + Styles::muted() + } else { + Style::new().cyan() + } +} + +fn truncate(s: &str, max: usize) -> String { + let s = s.replace('\n', " "); + if s.len() <= max { + s + } else { + format!("{}…", &s[..max - 1]) + } +} diff --git a/cli/src/commands/agent_ps.rs b/cli/src/commands/agent_ps.rs new file mode 100644 index 00000000000000..c8e0b8c2be7c89 --- /dev/null +++ b/cli/src/commands/agent_ps.rs @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use ahp_types::commands::{ListSessionsParams, ListSessionsResult}; +use ahp_types::state::{SessionStatus, SessionSummary}; + +use crate::util::errors::AnyError; + +use super::agent; +use super::args::AgentPsArgs; +use super::output::{self, Styles}; +use super::CommandContext; + +/// Lists active sessions on a running agent host. +pub async fn agent_ps(ctx: CommandContext, args: AgentPsArgs) -> Result { + let client = agent::connect(&ctx, args.address.as_deref(), args.tunnel.as_deref()).await?; + + let result: ListSessionsResult = + agent::request_with_auth(&ctx, &client, "listSessions", ListSessionsParams::default()) + .await?; + + client.shutdown().await; + + let mut items: Vec<&SessionSummary> = if args.all { + result.items.iter().collect() + } else { + result + .items + .iter() + .filter(|s| is_active(s.status)) + .collect() + }; + + // Most-recently-modified first. + items.sort_by_key(|b| std::cmp::Reverse(b.modified_at)); + + if args.json { + let json = serde_json::to_string_pretty(&items) + .map_err(|e| crate::util::errors::wrap(e, "Failed to serialize sessions"))?; + output::print_paged(&json); + } else if items.is_empty() { + ctx.log.result("No active sessions."); + } else { + let out = format_sessions_list(&items); + output::print_paged(&out); + } + + Ok(0) +} + +/// A session is "active" if it is in-progress, needs input, or errored +/// (i.e. not just idle/archived). +fn is_active(status: u32) -> bool { + let dominated = SessionStatus::IsRead as u32 + | SessionStatus::IsArchived as u32 + | SessionStatus::Idle as u32; + status & !dominated != 0 +} + +fn format_sessions_list(sessions: &[&SessionSummary]) -> String { + let title_style = Styles::title(); + let label_style = Styles::label(); + let uri_style = Styles::uri(); + + let mut out = String::new(); + + for (i, s) in sessions.iter().enumerate() { + if i > 0 { + out.push('\n'); + } + + let status = status_styled(s.status); + let title = if s.title.is_empty() { + "(untitled)".to_string() + } else { + s.title.clone() + }; + out.push_str(&format!(" {} {}\n", title_style.apply_to(&title), status)); + + out.push_str(&format!( + " {} {}\n", + label_style.apply_to("uri:"), + uri_style.apply_to(&s.resource), + )); + + out.push_str(&format!( + " {} {}\n", + label_style.apply_to("provider:"), + s.provider, + )); + + if let Some(activity) = &s.activity { + if !activity.is_empty() { + out.push_str(&format!( + " {} {}\n", + label_style.apply_to("activity:"), + activity, + )); + } + } + + if let Some(wd) = &s.working_directory { + out.push_str(&format!(" {} {}\n", label_style.apply_to("cwd:"), wd,)); + } + } + + out +} + +fn status_styled(status: u32) -> console::StyledObject { + if status & (SessionStatus::InputNeeded as u32) == (SessionStatus::InputNeeded as u32) { + Styles::warning().apply_to("● input needed".to_string()) + } else if status & (SessionStatus::InProgress as u32) != 0 { + Styles::success().apply_to("● in progress".to_string()) + } else if status & (SessionStatus::Error as u32) != 0 { + Styles::error().apply_to("● error".to_string()) + } else if status & (SessionStatus::Idle as u32) != 0 { + Styles::muted().apply_to("○ idle".to_string()) + } else { + Styles::muted().apply_to(format!("? unknown ({status})")) + } +} diff --git a/cli/src/commands/agent_stop.rs b/cli/src/commands/agent_stop.rs new file mode 100644 index 00000000000000..1e3fbcdcc97336 --- /dev/null +++ b/cli/src/commands/agent_stop.rs @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +use ahp_types::actions::{SessionTurnCancelledAction, StateAction}; +use ahp_types::commands::{SubscribeParams, SubscribeResult}; +use ahp_types::state::SnapshotState; + +use crate::log; +use crate::util::errors::{wrap, AnyError}; + +use super::agent; +use super::args::AgentStopArgs; +use super::CommandContext; + +/// Cancels the active turn of a session on a running agent host. +pub async fn agent_stop(ctx: CommandContext, args: AgentStopArgs) -> Result { + let client = agent::connect(&ctx, args.address.as_deref(), args.tunnel.as_deref()).await?; + + // Subscribe to the session to get its current state. + let result: SubscribeResult = agent::request_with_auth( + &ctx, + &client, + "subscribe", + SubscribeParams { + resource: args.session.clone(), + }, + ) + .await?; + + let turn_id = match result.snapshot.state { + SnapshotState::Session(session) => session.active_turn.map(|t| t.id), + _ => None, + }; + + let turn_id = match turn_id { + Some(id) => id, + None => { + ctx.log.result("No active turn to cancel."); + client.shutdown().await; + return Ok(0); + } + }; + + debug!(ctx.log, "Cancelling turn {} on {}", turn_id, args.session); + + client + .dispatch(StateAction::SessionTurnCancelled( + SessionTurnCancelledAction { + session: args.session.clone(), + turn_id: turn_id.clone(), + }, + )) + .await + .map_err(|e| wrap(e, "Failed to dispatch turn cancellation"))?; + + ctx.log + .result(format!("Cancelled turn {turn_id} on {}", args.session)); + + client.shutdown().await; + Ok(0) +} diff --git a/cli/src/commands/args.rs b/cli/src/commands/args.rs index 51e7347c4ea5cf..01844c65a544d4 100644 --- a/cli/src/commands/args.rs +++ b/cli/src/commands/args.rs @@ -186,9 +186,9 @@ pub enum Commands { #[clap(hide = true)] CommandShell(CommandShellArgs), - /// Runs a local agent host server. - #[clap(name = "agent-host")] - AgentHost(AgentHostArgs), + /// Manage agent host sessions. + #[clap(name = "agent")] + Agent(AgentArgs), } #[derive(Args, Debug, Clone)] @@ -251,12 +251,101 @@ pub struct AgentHostArgs { /// Run without a connection token. Only use this if the connection is secured by other means. #[clap(long)] pub without_connection_token: bool, - /// If set, the user accepts the server license terms and the server will be started without a user prompt. - #[clap(long)] - pub accept_server_license_terms: bool, /// Specifies the directory that server data is kept in. #[clap(long)] pub server_data_dir: Option, + + /// Expose the agent host over a dev tunnel. + #[clap(long)] + pub tunnel: bool, + /// Sets the machine name for the tunnel. + #[clap(long)] + pub name: Option, + /// Randomly name the machine for the tunnel. + #[clap(long)] + pub random_name: bool, + + /// Optional details to connect to an existing tunnel. + #[clap(flatten, next_help_heading = Some("ADVANCED TUNNEL OPTIONS"))] + pub existing_tunnel: ExistingTunnelArgs, +} + +#[derive(Args, Debug, Clone)] +pub struct AgentArgs { + #[clap(subcommand)] + pub subcommand: Option, + + /// Agent host arguments used when no subcommand is given. + #[clap(flatten)] + pub host_args: AgentHostArgs, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum AgentSubcommand { + /// Start a local agent host server. + Host(AgentHostArgs), + + /// List active sessions on a running agent host. + Ps(AgentPsArgs), + + /// Cancel the active turn of a session. + Stop(AgentStopArgs), + + /// Forcefully kill the running agent host process tree. + Kill, + + /// Stream live session events. + Logs(AgentLogsArgs), +} + +#[derive(Args, Debug, Clone)] +pub struct AgentPsArgs { + /// WebSocket address of a running agent host (e.g. ws://127.0.0.1:1234?tkn=secret). + /// If omitted, the CLI discovers a locally running agent host automatically. + #[clap(long)] + pub address: Option, + + /// Connect via a named dev tunnel instead of the local address. + #[clap(long)] + pub tunnel: Option, + + /// Output results as JSON instead of a human-readable table. + #[clap(long)] + pub json: bool, + + /// Show all sessions, including idle and archived ones. + #[clap(long, short)] + pub all: bool, +} + +#[derive(Args, Debug, Clone)] +pub struct AgentStopArgs { + /// Session URI to cancel the active turn of (e.g. copilot:/). + pub session: String, + + /// WebSocket address of a running agent host. + /// If omitted, the CLI discovers a locally running agent host automatically. + #[clap(long)] + pub address: Option, + + /// Connect via a named dev tunnel instead of the local address. + #[clap(long)] + pub tunnel: Option, +} + +#[derive(Args, Debug, Clone)] +pub struct AgentLogsArgs { + /// Session URI to stream events for (e.g. copilot:/). + pub session: String, + + /// WebSocket address of a running agent host. + /// If omitted, the CLI discovers a locally running agent host automatically. + #[clap(long)] + pub address: Option, + + /// Connect via a named dev tunnel instead of the local address. + #[clap(long)] + pub tunnel: Option, } #[derive(Args, Debug, Clone)] diff --git a/cli/src/commands/output.rs b/cli/src/commands/output.rs index 8747457889b18c..dbbe74748e45a1 100644 --- a/cli/src/commands/output.rs +++ b/cli/src/commands/output.rs @@ -3,133 +3,392 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -use std::fmt::Display; +//! Console output helpers: styled text, paging, and structured display. -use std::io::{BufWriter, Write}; +use std::io::Write; +use std::net::IpAddr; +use std::process::{Command, Stdio}; +use std::time::Duration; -use super::args::OutputFormat; +use console::{style, Style, Term}; -pub struct Column { - max_width: usize, - heading: &'static str, - data: Vec, +use crate::constants; + +// ---- Styles ----------------------------------------------------------------- + +/// Predefined styles for consistent CLI output. +pub struct Styles; + +impl Styles { + /// Bold text for headings / titles. + pub fn title() -> Style { + Style::new().bold() + } + + /// Dim text for field labels. + pub fn label() -> Style { + Style::new().dim() + } + + /// Cyan text for URIs and identifiers. + pub fn uri() -> Style { + Style::new().cyan() + } + + /// Green + bold for success / active indicators. + pub fn success() -> Style { + Style::new().green().bold() + } + + /// Yellow + bold for warnings / attention-needed indicators. + pub fn warning() -> Style { + Style::new().yellow().bold() + } + + /// Red + bold for error indicators. + pub fn error() -> Style { + Style::new().red().bold() + } + + /// Dim text for inactive / secondary information. + pub fn muted() -> Style { + Style::new().dim() + } } -impl Column { - pub fn new(heading: &'static str) -> Self { - Column { - max_width: heading.len(), - heading, - data: vec![], +// ---- Pager ------------------------------------------------------------------ + +/// Outputs `text` through a pager when stdout is a terminal and the +/// content is taller than the terminal window. +/// +/// Resolution order: +/// 1. `$PAGER` environment variable (any platform) +/// 2. `less -R` on Unix +/// 3. Built-in interactive pager (works everywhere, including Windows) +/// +/// When stdout is not a terminal (e.g. piped), the text is written +/// directly without paging. +pub fn print_paged(text: &str) { + let term = Term::stdout(); + if !term.is_term() { + print!("{text}"); + return; + } + + let (term_height, _) = term.size(); + let term_height = term_height as usize; + let line_count = text.lines().count(); + if line_count <= term_height.saturating_sub(1) { + print!("{text}"); + return; + } + + // Prefer $PAGER if explicitly set. + if let Ok(pager) = std::env::var("PAGER") { + if !pager.is_empty() && try_external_pager(&pager, text) { + return; } } - pub fn add_row(&mut self, row: String) { - self.max_width = std::cmp::max(self.max_width, row.len()); - self.data.push(row); + // On Unix, try `less -R` before falling back to built-in. + #[cfg(not(windows))] + if try_external_pager("less -R", text) { + return; } + + builtin_pager(&term, text, term_height); } -impl OutputFormat { - pub fn print_table(&self, table: OutputTable) -> Result<(), std::io::Error> { - match *self { - OutputFormat::Json => JsonTablePrinter().print(table, &mut std::io::stdout()), - OutputFormat::Text => TextTablePrinter().print(table, &mut std::io::stdout()), +/// Attempts to spawn an external pager. Returns `true` if successful. +fn try_external_pager(pager: &str, text: &str) -> bool { + let mut parts = pager.split_whitespace(); + let program = match parts.next() { + Some(p) => p, + None => return false, + }; + let pager_args: Vec<&str> = parts.collect(); + + match Command::new(program) + .args(&pager_args) + .stdin(Stdio::piped()) + .spawn() + { + Ok(mut child) => { + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(text.as_bytes()); + } + let _ = child.wait(); + true } + Err(_) => false, } } -pub struct OutputTable { - cols: Vec, +/// Built-in interactive pager with scroll support. +/// +/// Controls: +/// - **↓** / **j** / **Enter** — scroll down one line +/// - **↑** / **k** — scroll up one line +/// - **Page Down** / **Space** / **f** — scroll down one page +/// - **Page Up** / **b** — scroll up one page +/// - **Home** / **g** — jump to top +/// - **End** / **G** — jump to bottom +/// - **q** / **Escape** / **Ctrl+C** — quit +fn builtin_pager(term: &Term, text: &str, term_height: usize) { + let lines: Vec<&str> = text.lines().collect(); + let total = lines.len(); + let page = term_height.saturating_sub(1); // reserve one line for the status bar + let mut offset: usize = 0; + + // Switch to the alternate screen buffer so quitting restores the + // original terminal content (like `less` does). + let _ = term.write_str("\x1b[?1049h"); + + // Draw the initial screen. + draw_page(term, &lines, offset, page); + draw_status_bar(term, offset, total, page); + + loop { + let key = read_pager_key(term); + let new_offset = match key { + // Down one line + PagerKey::Down => offset.saturating_add(1), + // Up one line + PagerKey::Up => offset.saturating_sub(1), + // Page down + PagerKey::PageDown => offset.saturating_add(page), + // Page up + PagerKey::PageUp => offset.saturating_sub(page), + // Home + PagerKey::Home => 0, + // End + PagerKey::End => total.saturating_sub(page), + // Quit + PagerKey::Quit => { + break; + } + PagerKey::Other => offset, + }; + + // Clamp to valid range. + let max_offset = total.saturating_sub(page); + let new_offset = new_offset.min(max_offset); + + if new_offset != offset { + offset = new_offset; + // Redraw: clear the page area and status bar, then repaint. + let _ = term.clear_last_lines(page + 1); + draw_page(term, &lines, offset, page); + draw_status_bar(term, offset, total, page); + } + } + + // Leave the alternate screen buffer, restoring prior content. + let _ = term.write_str("\x1b[?1049l"); } -impl OutputTable { - pub fn new(cols: Vec) -> Self { - OutputTable { cols } +/// Writes `page` lines starting from `offset`. +fn draw_page(term: &Term, lines: &[&str], offset: usize, page: usize) { + let end = (offset + page).min(lines.len()); + for line in &lines[offset..end] { + let _ = term.write_line(line); + } + // Pad with empty lines if at the end. + for _ in (end - offset)..page { + let _ = term.write_line(""); } } -trait TablePrinter { - fn print(&self, table: OutputTable, out: &mut dyn std::io::Write) - -> Result<(), std::io::Error>; +/// Draws the status bar at the bottom showing scroll position. +fn draw_status_bar(term: &Term, offset: usize, total: usize, page: usize) { + let end = (offset + page).min(total); + let pct = (end * 100).checked_div(total).unwrap_or(100); + let bar = format!( + " lines {}-{} of {} ({pct}%) ↑↓ scroll PgUp/PgDn page q quit ", + offset + 1, + end, + total, + ); + let _ = term.write_str(&format!("{}", Style::new().reverse().apply_to(bar))); } -pub struct JsonTablePrinter(); - -impl TablePrinter for JsonTablePrinter { - fn print( - &self, - table: OutputTable, - out: &mut dyn std::io::Write, - ) -> Result<(), std::io::Error> { - let mut bw = BufWriter::new(out); - bw.write_all(b"[")?; - - if !table.cols.is_empty() { - let data_len = table.cols[0].data.len(); - for i in 0..data_len { - if i > 0 { - bw.write_all(b",{")?; - } else { - bw.write_all(b"{")?; - } - for col in &table.cols { - serde_json::to_writer(&mut bw, col.heading)?; - bw.write_all(b":")?; - serde_json::to_writer(&mut bw, &col.data[i])?; - } - } - } +// ---- Key reading ------------------------------------------------------------ - bw.write_all(b"]")?; - bw.flush() +enum PagerKey { + Up, + Down, + PageUp, + PageDown, + Home, + End, + Quit, + Other, +} + +/// Reads a single key press and maps it to a pager action. +/// +/// On Windows, uses `ReadConsoleInputW` directly because the `console` +/// crate does not map `VK_PRIOR` (Page Up) or `VK_NEXT` (Page Down). +#[cfg(not(windows))] +fn read_pager_key(term: &Term) -> PagerKey { + use console::Key; + match term.read_key() { + Ok(Key::ArrowDown) | Ok(Key::Char('j')) | Ok(Key::Enter) => PagerKey::Down, + Ok(Key::ArrowUp) | Ok(Key::Char('k')) => PagerKey::Up, + Ok(Key::PageDown) | Ok(Key::Char(' ')) | Ok(Key::Char('f')) => PagerKey::PageDown, + Ok(Key::PageUp) | Ok(Key::Char('b')) => PagerKey::PageUp, + Ok(Key::Home) | Ok(Key::Char('g')) => PagerKey::Home, + Ok(Key::End) | Ok(Key::Char('G')) => PagerKey::End, + Ok(Key::Escape) | Ok(Key::Char('q')) | Ok(Key::Char('Q')) | Ok(Key::CtrlC) => { + PagerKey::Quit + } + _ => PagerKey::Other, } } -/// Type that prints the output as an ASCII, markdown-style table. -pub struct TextTablePrinter(); - -impl TablePrinter for TextTablePrinter { - fn print( - &self, - table: OutputTable, - out: &mut dyn std::io::Write, - ) -> Result<(), std::io::Error> { - let mut bw = BufWriter::new(out); - - let sizes = table.cols.iter().map(|c| c.max_width).collect::>(); - - // print headers - write_columns(&mut bw, table.cols.iter().map(|c| c.heading), &sizes)?; - // print --- separators - write_columns( - &mut bw, - table.cols.iter().map(|c| "-".repeat(c.max_width)), - &sizes, - )?; - // print each column - if !table.cols.is_empty() { - let data_len = table.cols[0].data.len(); - for i in 0..data_len { - write_columns(&mut bw, table.cols.iter().map(|c| &c.data[i]), &sizes)?; - } +#[cfg(windows)] +fn read_pager_key(_term: &Term) -> PagerKey { + use windows_sys::Win32::System::Console::{ + GetStdHandle, ReadConsoleInputW, INPUT_RECORD, KEY_EVENT, STD_INPUT_HANDLE, + }; + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + VK_DOWN, VK_END, VK_ESCAPE, VK_HOME, VK_NEXT, VK_PRIOR, VK_RETURN, VK_UP, + }; + + loop { + let mut record: INPUT_RECORD = unsafe { std::mem::zeroed() }; + let mut count: u32 = 0; + + let ok = unsafe { + ReadConsoleInputW(GetStdHandle(STD_INPUT_HANDLE), &mut record, 1, &mut count) + }; + if ok == 0 || count == 0 { + return PagerKey::Other; + } + + // Only handle key-down events. + if record.EventType as u32 != KEY_EVENT { + continue; } + let key_event = unsafe { record.Event.KeyEvent }; + if key_event.bKeyDown == 0 { + continue; + } + + let vk = key_event.wVirtualKeyCode; + let ch = unsafe { key_event.uChar.UnicodeChar }; - bw.flush() + // Map virtual key codes first (handles keys with no unicode char). + return match vk { + VK_UP => PagerKey::Up, + VK_DOWN => PagerKey::Down, + VK_PRIOR => PagerKey::PageUp, + VK_NEXT => PagerKey::PageDown, + VK_HOME => PagerKey::Home, + VK_END => PagerKey::End, + VK_ESCAPE => PagerKey::Quit, + VK_RETURN => PagerKey::Down, + _ => { + // Fall through to character matching. + if let Some(c) = char::from_u32(ch as u32) { + match c { + 'j' => PagerKey::Down, + 'k' => PagerKey::Up, + ' ' | 'f' => PagerKey::PageDown, + 'b' => PagerKey::PageUp, + 'g' => PagerKey::Home, + 'G' => PagerKey::End, + 'q' | 'Q' => PagerKey::Quit, + '\x03' => PagerKey::Quit, // Ctrl+C + _ => PagerKey::Other, + } + } else { + PagerKey::Other + } + } + }; } } -fn write_columns( - mut w: impl Write, - cols: impl Iterator, - sizes: &[usize], -) -> Result<(), std::io::Error> -where - T: Display, -{ - w.write_all(b"|")?; - for (i, col) in cols.enumerate() { - write!(w, " {:width$} |", col, width = sizes[i])?; - } - w.write_all(b"\r\n") +// ---- Server banner ---------------------------------------------------------- + +/// Prints a styled startup header line: +/// +/// ```text +/// Code Agent Host vX.Y.Z ready in 123ms +/// ``` +pub fn print_banner_header(title: &str, elapsed: Duration) { + let version = constants::VSCODE_CLI_VERSION.unwrap_or("dev"); + let elapsed_ms = elapsed.as_millis(); + + println!(); + println!( + " {} {} {}", + style(title).cyan().bold(), + style(format!("v{version}")).dim(), + style(format!("ready in {elapsed_ms}ms")).dim(), + ); + println!(); +} + +/// Minimum label width so values align across banner lines. +const BANNER_LABEL_WIDTH: usize = 9; + +/// Prints a single `➜ Label: value` line inside a banner. +pub fn print_banner_line(label: &str, value: &str) { + println!( + " {} {} {}", + style("➜").green().bold(), + style(format!( + "{label}:{:>pad$}", + "", + pad = BANNER_LABEL_WIDTH.saturating_sub(label.len() + 1) + )) + .bold(), + style(value).cyan(), + ); +} + +/// Prints a dimmed `➜ Label: hint` line inside a banner. +pub fn print_banner_line_dim(label: &str, hint: &str) { + println!( + " {} {} {}", + style("➜").green().bold(), + style(format!( + "{label}:{:>pad$}", + "", + pad = BANNER_LABEL_WIDTH.saturating_sub(label.len() + 1) + )) + .bold(), + style(hint).dim(), + ); +} + +/// Prints a trailing blank line to close the banner. +pub fn print_banner_footer() { + println!(); +} + +/// Prints the Local / Network lines for a WebSocket server. +/// +/// When `listen_ip` is loopback, shows `use --host to expose`. +/// When it is unspecified (`0.0.0.0` / `::`), enumerates all routable +/// IPv4 interfaces. Otherwise shows the single bound address. +pub fn print_network_lines(port: u16, listen_ip: IpAddr, token_suffix: &str) { + print_banner_line("Local", &format!("ws://localhost:{port}{token_suffix}")); + + if listen_ip.is_loopback() { + print_banner_line_dim("Network", "use --host to expose"); + } else if listen_ip.is_unspecified() { + if let Ok(ifas) = local_ip_address::list_afinet_netifas() { + for (_, addr) in &ifas { + if addr.is_loopback() || addr.is_ipv6() { + continue; + } + print_banner_line("Network", &format!("ws://{addr}:{port}{token_suffix}")); + } + } + } else { + print_banner_line("Network", &format!("ws://{listen_ip}:{port}{token_suffix}")); + } } diff --git a/cli/src/commands/serve_web.rs b/cli/src/commands/serve_web.rs index d3a9a88a87dd4d..74f2abdbcea8e2 100644 --- a/cli/src/commands/serve_web.rs +++ b/cli/src/commands/serve_web.rs @@ -12,9 +12,15 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use hyper::service::{make_service_fn, service_fn}; -use hyper::{Body, Request, Response, Server}; +use crate::util::http::{empty_body, full_body, HyperBody}; +use ::http::{Request, Response}; +use http_body_util::BodyExt; +use hyper::body::Incoming; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper_util::rt::TokioIo; use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::net::TcpListener; use tokio::{pin, time}; use crate::async_pipe::{ @@ -100,30 +106,33 @@ pub async fn serve_web(ctx: CommandContext, mut args: ServeWebArgs) -> Result(service) } - }; let mut shutdown = ShutdownRequest::create_rx([ShutdownRequest::CtrlC]); - let r = if let Some(s) = args.socket_path { + if let Some(s) = args.socket_path { let s = PathBuf::from(&s); - let socket = listen_socket_rw_stream(&s).await?; + let mut socket = listen_socket_rw_stream(&s).await?; ctx.log .result(format!("Web UI available on {}", s.display())); - let r = Server::builder(socket.into_pollable()) - .serve(make_service_fn(|_| make_svc())) - .with_graceful_shutdown(async { - let _ = shutdown.wait().await; - }) - .await; + loop { + tokio::select! { + result = socket.accept() => { + let conn = match result { + Ok(c) => c, + Err(_) => continue, + }; + let ctx = HandleContext { cm: cm.clone(), log: cm.log.clone(), server_secret_key: key.clone() }; + tokio::spawn(async move { + let svc = service_fn(move |req| handle(ctx.clone(), req)); + let _ = http1::Builder::new() + .serve_connection(TokioIo::new(conn), svc) + .with_upgrades() + .await; + }); + } + _ = shutdown.wait() => break, + } + } let _ = std::fs::remove_file(&s); // cleanup - r } else { let addr: SocketAddr = match &args.host { Some(h) => { @@ -131,10 +140,14 @@ pub async fn serve_web(ctx: CommandContext, mut args: ServeWebArgs) -> Result SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), args.port), }; - let builder = Server::try_bind(&addr).map_err(CodeError::CouldNotListenOnInterface)?; + let listener = TcpListener::bind(addr) + .await + .map_err(CodeError::CouldNotListenOnInterface)?; // Get the actual bound address (important when port 0 is used for random port assignment) - let bound_addr = builder.local_addr(); + let bound_addr = listener + .local_addr() + .map_err(CodeError::CouldNotListenOnInterface)?; let mut listening = format!("Web UI available at http://{bound_addr}"); if let Some(base) = args.server_base_path { if !base.starts_with('/') { @@ -147,16 +160,27 @@ pub async fn serve_web(ctx: CommandContext, mut args: ServeWebArgs) -> Result { + let (stream, _) = match result { + Ok(r) => r, + Err(_) => continue, + }; + let ctx = HandleContext { cm: cm.clone(), log: cm.log.clone(), server_secret_key: key.clone() }; + tokio::spawn(async move { + let svc = service_fn(move |req| handle(ctx.clone(), req)); + let _ = http1::Builder::new() + .serve_connection(TokioIo::new(stream), svc) + .with_upgrades() + .await; + }); + } + _ = shutdown.wait() => break, + } + } }; - r.map_err(CodeError::CouldNotListenOnInterface)?; - Ok(0) } @@ -168,7 +192,10 @@ struct HandleContext { } /// Handler function for an inbound request -async fn handle(ctx: HandleContext, req: Request) -> Result, Infallible> { +async fn handle( + ctx: HandleContext, + req: Request, +) -> Result, Infallible> { let client_key_half = get_client_key_half(&req); let path = req.uri().path(); @@ -185,7 +212,7 @@ async fn handle(ctx: HandleContext, req: Request) -> Result Ok(res) } -async fn handle_proxied(ctx: &HandleContext, req: Request) -> Response { +async fn handle_proxied(ctx: &HandleContext, req: Request) -> Response { let release = if let Some((r, _)) = get_release_from_path(req.uri().path(), ctx.cm.platform) { r } else { @@ -200,7 +227,7 @@ async fn handle_proxied(ctx: &HandleContext, req: Request) -> Response { - if req.headers().contains_key(hyper::header::UPGRADE) { + if req.headers().contains_key(::http::header::UPGRADE) { forward_ws_req_to_server(ctx.log.clone(), rw, req).await } else { forward_http_req_to_server(rw, req).await @@ -211,7 +238,7 @@ async fn handle_proxied(ctx: &HandleContext, req: Request) -> Response) -> Response { +fn handle_secret_mint(ctx: &HandleContext, req: Request) -> Response { use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); @@ -227,18 +254,18 @@ fn handle_secret_mint(ctx: &HandleContext, req: Request) -> Response /// and maintains the http-only cookie the client will use for cookies. fn append_secret_headers( base_path: &str, - res: &mut Response, + res: &mut Response, client_key_half: &SecretKeyPart, ) { let headers = res.headers_mut(); headers.append( - hyper::header::SET_COOKIE, + ::http::header::SET_COOKIE, format!("{PATH_COOKIE_NAME}={base_path}{SECRET_KEY_MINT_PATH}; SameSite=Strict; Path=/",) .parse() .unwrap(), ); headers.append( - hyper::header::SET_COOKIE, + ::http::header::SET_COOKIE, format!( "{}={}; SameSite=Strict; HttpOnly; Max-Age=2592000; Path=/", SECRET_KEY_COOKIE_NAME, @@ -284,20 +311,20 @@ fn get_release_from_path(path: &str, platform: Platform) -> Option<(Release, Str /// Proxies the standard HTTP request to the async pipe, returning the piped response async fn forward_http_req_to_server( (rw, handle): (AsyncPipe, ConnectionHandle), - req: Request, -) -> Response { + req: Request, +) -> Response { let (mut request_sender, connection) = - match hyper::client::conn::Builder::new().handshake(rw).await { + match hyper::client::conn::http1::handshake(TokioIo::new(rw)).await { Ok(r) => r, Err(e) => return response::connection_err(e), }; tokio::spawn(connection); - let res = request_sender - .send_request(req) - .await - .unwrap_or_else(response::connection_err); + let res = match request_sender.send_request(req).await { + Ok(res) => res.map(|b| b.boxed()), + Err(e) => response::connection_err(e), + }; // technically, we should buffer the body into memory since it may not be // read at this point, but because the keepalive time is very large @@ -312,11 +339,11 @@ async fn forward_http_req_to_server( async fn forward_ws_req_to_server( log: log::Logger, (rw, handle): (AsyncPipe, ConnectionHandle), - mut req: Request, -) -> Response { + mut req: Request, +) -> Response { // splicing of client and servers inspired by https://github.com/hyperium/hyper/blob/fece9f7f50431cf9533cfe7106b53a77b48db699/examples/upgrades.rs let (mut request_sender, connection) = - match hyper::client::conn::Builder::new().handshake(rw).await { + match hyper::client::conn::http1::handshake(TokioIo::new(rw)).await { Ok(r) => r, Err(e) => return response::connection_err(e), }; @@ -328,19 +355,26 @@ async fn forward_ws_req_to_server( proxied_req = proxied_req.header(k, v); } - let mut res = request_sender - .send_request(proxied_req.body(Body::empty()).unwrap()) + let mut res = match request_sender + .send_request( + proxied_req + .body(http_body_util::Empty::::new()) + .unwrap(), + ) .await - .unwrap_or_else(response::connection_err); + { + Ok(r) => r, + Err(e) => return response::connection_err(e), + }; - let mut proxied_res = Response::new(Body::empty()); + let mut proxied_res = Response::new(empty_body()); *proxied_res.status_mut() = res.status(); for (k, v) in res.headers() { proxied_res.headers_mut().insert(k, v.clone()); } // only start upgrade at this point in case the server decides to deny socket - if res.status() == hyper::StatusCode::SWITCHING_PROTOCOLS { + if res.status() == ::http::StatusCode::SWITCHING_PROTOCOLS { tokio::spawn(async move { let (s_req, s_res) = tokio::join!(hyper::upgrade::on(&mut req), hyper::upgrade::on(&mut res)); @@ -352,8 +386,10 @@ async fn forward_ws_req_to_server( ), (Err(e1), _) => debug!(log, "client ({}) websocket upgrade failed", e1), (_, Err(e2)) => debug!(log, "server ({}) websocket upgrade failed", e2), - (Ok(mut s_req), Ok(mut s_res)) => { + (Ok(s_req), Ok(s_res)) => { trace!(log, "websocket upgrade succeeded"); + let mut s_req = TokioIo::new(s_req); + let mut s_res = TokioIo::new(s_res); let r = tokio::io::copy_bidirectional(&mut s_req, &mut s_res).await; trace!(log, "websocket closed (error: {:?})", r.err()); } @@ -372,8 +408,8 @@ fn is_commit_hash(s: &str) -> bool { } /// Gets a cookie from the request by name. -fn extract_cookie(req: &Request, name: &str) -> Option { - for h in req.headers().get_all(hyper::header::COOKIE) { +fn extract_cookie(req: &Request, name: &str) -> Option { + for h in req.headers().get_all(::http::header::COOKIE) { if let Ok(str) = h.to_str() { for pair in str.split("; ") { let i = match pair.find('=') { @@ -432,7 +468,7 @@ fn get_server_key_half(paths: &LauncherPaths) -> SecretKeyPart { } /// Gets the client's half of the secret key. -fn get_client_key_half(req: &Request) -> SecretKeyPart { +fn get_client_key_half(req: &Request) -> SecretKeyPart { if let Some(c) = extract_cookie(req, SECRET_KEY_COOKIE_NAME) { if let Ok(sk) = SecretKeyPart::decode(&c) { return sk; @@ -450,33 +486,33 @@ mod response { use super::*; - pub fn connection_err(err: hyper::Error) -> Response { + pub fn connection_err(err: hyper::Error) -> Response { Response::builder() .status(503) - .body(Body::from(format!("Error connecting to server: {err:?}"))) + .body(full_body(format!("Error connecting to server: {err:?}"))) .unwrap() } - pub fn code_err(err: CodeError) -> Response { + pub fn code_err(err: CodeError) -> Response { Response::builder() .status(500) - .body(Body::from(format!("Error serving request: {err}"))) + .body(full_body(format!("Error serving request: {err}"))) .unwrap() } - pub fn wait_for_download() -> Response { + pub fn wait_for_download() -> Response { Response::builder() .status(202) .header("Content-Type", "text/html") // todo: get latest - .body(Body::from(concatcp!("The latest version of the ", QUALITYLESS_SERVER_NAME, " is downloading, please wait a moment...", ))) + .body(full_body(concatcp!("The latest version of the ", QUALITYLESS_SERVER_NAME, " is downloading, please wait a moment...", ))) .unwrap() } - pub fn secret_key(hash: Vec) -> Response { + pub fn secret_key(hash: Vec) -> Response { Response::builder() .status(200) .header("Content-Type", "application/octet-stream") // todo: get latest - .body(Body::from(hash)) + .body(full_body(hash)) .unwrap() } } @@ -634,7 +670,7 @@ impl ConnectionManager { let target_kind = TargetKind::Web; let quality = VSCODE_CLI_QUALITY - .ok_or_else(|| CodeError::UpdatesNotConfigured("no configured quality")) + .ok_or(CodeError::UpdatesNotConfigured("no configured quality")) .and_then(|q| { Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality")) })?; diff --git a/cli/src/commands/tunnels.rs b/cli/src/commands/tunnels.rs index 8f99f8d37d6603..a3b2056faed85c 100644 --- a/cli/src/commands/tunnels.rs +++ b/cli/src/commands/tunnels.rs @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -use async_trait::async_trait; use base64::{engine::general_purpose as b64, Engine as _}; use futures::{stream::FuturesUnordered, StreamExt}; use serde::Serialize; @@ -76,7 +75,7 @@ impl From for crate::auth::AuthProvider { } } -fn fulfill_existing_tunnel_args( +pub(super) fn fulfill_existing_tunnel_args( d: ExistingTunnelArgs, name_arg: &Option, ) -> Option { @@ -118,7 +117,6 @@ impl TunnelServiceContainer { } } -#[async_trait] impl ServiceContainer for TunnelServiceContainer { async fn run_service( &mut self, @@ -639,7 +637,7 @@ async fn serve_with_csa( let mut server = make_singleton_server(log_broadcast.clone(), log.clone(), server, shutdown.clone()); - let platform = spanf!(log, log.span("prereq"), PreReqChecker::new().verify())?; + let platform = PreReqChecker::new().verify().await?; let _lock = app_mutex_name.map(AppMutex::new); let auth = Auth::new(&paths, log.clone()); diff --git a/cli/src/constants.rs b/cli/src/constants.rs index 9e2b066d741390..f95cc77a4bfb95 100644 --- a/cli/src/constants.rs +++ b/cli/src/constants.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use std::{collections::HashMap, io::IsTerminal}; use const_format::concatcp; -use lazy_static::lazy_static; +use std::sync::LazyLock; use crate::options::Quality; @@ -107,27 +107,31 @@ pub struct ServerQualityInfo { pub server_application_name: String, } -lazy_static! { - pub static ref TUNNEL_SERVICE_USER_AGENT: String = - match std::env::var(TUNNEL_SERVICE_USER_AGENT_ENV_VAR) { - Ok(ua) if !ua.is_empty() => format!("{} {}", ua, get_default_user_agent()), - _ => get_default_user_agent(), - }; - - /// Map of qualities to the server name - pub static ref SERVER_NAME_MAP: Option> = - option_env!("VSCODE_CLI_TUNNEL_SERVER_QUALITIES").and_then(|s| serde_json::from_str(s).unwrap()); - - /// Whether i/o interactions are allowed in the current CLI. - pub static ref IS_A_TTY: bool = std::io::stdin().is_terminal(); - - /// Whether i/o interactions are allowed in the current CLI. - pub static ref COLORS_ENABLED: bool = *IS_A_TTY && std::env::var(NO_COLOR_ENV).is_err(); - - /// Whether i/o interactions are allowed in the current CLI. - pub static ref IS_INTERACTIVE_CLI: bool = *IS_A_TTY && std::env::var(NONINTERACTIVE_VAR).is_err(); - - /// Map of quality names to arrays of app IDs used for them, for example, `{"stable":["ABC123"]}` - pub static ref WIN32_APP_IDS: Option> = - option_env!("VSCODE_CLI_WIN32_APP_IDS").map(|s| s.split(',').map(|s| s.to_string()).collect()); -} +pub static TUNNEL_SERVICE_USER_AGENT: LazyLock = + LazyLock::new(|| match std::env::var(TUNNEL_SERVICE_USER_AGENT_ENV_VAR) { + Ok(ua) if !ua.is_empty() => format!("{} {}", ua, get_default_user_agent()), + _ => get_default_user_agent(), + }); + +/// Map of qualities to the server name +pub static SERVER_NAME_MAP: LazyLock>> = + LazyLock::new(|| { + option_env!("VSCODE_CLI_TUNNEL_SERVER_QUALITIES") + .and_then(|s| serde_json::from_str(s).unwrap()) + }); + +/// Whether i/o interactions are allowed in the current CLI. +pub static IS_A_TTY: LazyLock = LazyLock::new(|| std::io::stdin().is_terminal()); + +/// Whether i/o interactions are allowed in the current CLI. +pub static COLORS_ENABLED: LazyLock = + LazyLock::new(|| *IS_A_TTY && std::env::var(NO_COLOR_ENV).is_err()); + +/// Whether i/o interactions are allowed in the current CLI. +pub static IS_INTERACTIVE_CLI: LazyLock = + LazyLock::new(|| *IS_A_TTY && std::env::var(NONINTERACTIVE_VAR).is_err()); + +/// Map of quality names to arrays of app IDs used for them, for example, `{"stable":["ABC123"]}` +pub static WIN32_APP_IDS: LazyLock>> = LazyLock::new(|| { + option_env!("VSCODE_CLI_WIN32_APP_IDS").map(|s| s.split(',').map(|s| s.to_string()).collect()) +}); diff --git a/cli/src/desktop/version_manager.rs b/cli/src/desktop/version_manager.rs index e9cd1a10450093..18d0a459d0e288 100644 --- a/cli/src/desktop/version_manager.rs +++ b/cli/src/desktop/version_manager.rs @@ -9,9 +9,9 @@ use std::{ path::{Path, PathBuf}, }; -use lazy_static::lazy_static; use regex::Regex; use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; use crate::{ constants::{PRODUCT_DOWNLOAD_URL, QUALITY, QUALITYLESS_PRODUCT_NAME}, @@ -33,9 +33,7 @@ pub enum RequestedVersion { Path(String), } -lazy_static! { - static ref COMMIT_RE: Regex = Regex::new(r"(?i)^[0-9a-f]{40}$").unwrap(); -} +static COMMIT_RE: LazyLock = LazyLock::new(|| Regex::new(r"(?i)^[0-9a-f]{40}$").unwrap()); impl RequestedVersion { pub fn get_command(&self) -> String { diff --git a/cli/src/lib.rs b/cli/src/lib.rs index b2e23cb4d6994e..860025337949a9 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +#![allow(async_fn_in_trait)] // todo: we should reduce the exported surface area over time as things are // moved into a common CLI diff --git a/cli/src/log.rs b/cli/src/log.rs index f58f49b21764e9..3b2bca5ce17b95 100644 --- a/cli/src/log.rs +++ b/cli/src/log.rs @@ -3,11 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -use chrono::Local; -use opentelemetry::{ - sdk::trace::{Tracer, TracerProvider}, - trace::{SpanBuilder, Tracer as TraitTracer, TracerProvider as TracerProviderTrait}, -}; +use jiff::Zoned; use serde::{Deserialize, Serialize}; use std::fmt; use std::{ @@ -103,7 +99,6 @@ pub fn new_rpc_prefix() -> String { // Base logger implementation #[derive(Clone)] pub struct Logger { - tracer: Arc, sink: Vec>, prefix: Option, } @@ -199,28 +194,18 @@ impl LogSink for FileLogSink { impl Logger { pub fn test() -> Self { Self { - tracer: Arc::new(TracerProvider::builder().build().tracer("codeclitest")), sink: vec![], prefix: None, } } - pub fn new(tracer: Tracer, level: Level) -> Self { + pub fn new(level: Level) -> Self { Self { - tracer: Arc::new(tracer), sink: vec![Box::new(StdioLogSink { level })], prefix: None, } } - pub fn span(&self, name: &str) -> SpanBuilder { - self.tracer.span_builder(format!("serverlauncher/{name}")) - } - - pub fn tracer(&self) -> &Tracer { - &self.tracer - } - pub fn emit(&self, level: Level, message: &str) { let prefix = self.prefix.as_deref().unwrap_or(""); for sink in &self.sink { @@ -305,8 +290,8 @@ impl crate::util::io::ReportCopyProgress for DownloadLogger<'_> { } fn format(level: Level, prefix: &str, message: &str, use_colors: bool) -> String { - let current = Local::now(); - let timestamp = current.format("%Y-%m-%d %H:%M:%S").to_string(); + let current = Zoned::now(); + let timestamp = current.strftime("%Y-%m-%d %H:%M:%S").to_string(); let name = level.name().unwrap(); @@ -421,42 +406,3 @@ macro_rules! warning { $logger.emit(log::Level::Warn, &format!($($fmt),+)) }; } - -#[macro_export] -macro_rules! span { - ($logger:expr, $span:expr, $func:expr) => {{ - use opentelemetry::trace::TraceContextExt; - - let span = $span.start($logger.tracer()); - let cx = opentelemetry::Context::current_with_span(span); - let guard = cx.clone().attach(); - let t = $func; - - if let Err(e) = &t { - cx.span().record_error(e); - } - - std::mem::drop(guard); - - t - }}; -} - -#[macro_export] -macro_rules! spanf { - ($logger:expr, $span:expr, $func:expr) => {{ - use opentelemetry::trace::{FutureExt, TraceContextExt}; - - let span = $span.start($logger.tracer()); - let cx = opentelemetry::Context::current_with_span(span); - let t = $func.with_context(cx.clone()).await; - - if let Err(e) = &t { - cx.span().record_error(e); - } - - cx.span().end(); - - t - }}; -} diff --git a/cli/src/self_update.rs b/cli/src/self_update.rs index 45d661e5af98b1..8f5377ced57067 100644 --- a/cli/src/self_update.rs +++ b/cli/src/self_update.rs @@ -29,16 +29,16 @@ static OLD_UPDATE_EXTENSION: &str = "Updating CLI"; impl<'a> SelfUpdate<'a> { pub fn new(update_service: &'a UpdateService) -> Result { - let commit = VSCODE_CLI_COMMIT - .ok_or_else(|| CodeError::UpdatesNotConfigured("unknown build commit"))?; + let commit = + VSCODE_CLI_COMMIT.ok_or(CodeError::UpdatesNotConfigured("unknown build commit"))?; let quality = VSCODE_CLI_QUALITY - .ok_or_else(|| CodeError::UpdatesNotConfigured("no configured quality")) + .ok_or(CodeError::UpdatesNotConfigured("no configured quality")) .and_then(|q| { Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality")) })?; - let platform = Platform::env_default().ok_or_else(|| { + let platform = Platform::env_default().ok_or({ CodeError::UpdatesNotConfigured("Unknown platform, please report this error") })?; diff --git a/cli/src/state.rs b/cli/src/state.rs index 5bc655ef2c1719..e6b7a4ffe592f9 100644 --- a/cli/src/state.rs +++ b/cli/src/state.rs @@ -237,6 +237,14 @@ impl LauncherPaths { }) } + /// Lockfile for the running agent host + pub fn agent_host_lockfile(&self) -> PathBuf { + self.root.join(format!( + "agent-host-{}.lock", + VSCODE_CLI_QUALITY.unwrap_or("oss") + )) + } + /// Suggested path for web server storage pub fn web_server_storage(&self) -> PathBuf { self.root.join("serve-web") diff --git a/cli/src/tunnels/agent_host.rs b/cli/src/tunnels/agent_host.rs index 9d1f240c5099ee..640de39632a458 100644 --- a/cli/src/tunnels/agent_host.rs +++ b/cli/src/tunnels/agent_host.rs @@ -8,7 +8,10 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; -use hyper::{Body, Request, Response}; +use ::http::{Request, Response}; +use http_body_util::BodyExt; +use hyper::body::Incoming; +use hyper_util::rt::TokioIo; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::sync::Mutex; @@ -23,6 +26,7 @@ use crate::update_service::{ use crate::util::command::new_script_command; use crate::util::errors::CodeError; use crate::util::http::{self, BoxedHttp}; +use crate::util::http::{empty_body, full_body, HyperBody}; use crate::util::io::SilentCopyProgress; use crate::util::sync::{new_barrier, Barrier, BarrierOpener}; @@ -318,7 +322,7 @@ impl AgentHostManager { } let quality = VSCODE_CLI_QUALITY - .ok_or_else(|| CodeError::UpdatesNotConfigured("no configured quality")) + .ok_or(CodeError::UpdatesNotConfigured("no configured quality")) .and_then(|q| { Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality")) })?; @@ -389,7 +393,7 @@ impl AgentHostManager { let now = Instant::now(); let quality = VSCODE_CLI_QUALITY - .ok_or_else(|| CodeError::UpdatesNotConfigured("no configured quality")) + .ok_or(CodeError::UpdatesNotConfigured("no configured quality")) .and_then(|q| { Quality::try_from(q).map_err(|_| CodeError::UpdatesNotConfigured("unknown quality")) })?; @@ -472,20 +476,20 @@ impl AgentHostManager { /// Proxies an incoming HTTP/WebSocket request to the agent host's Unix socket. pub async fn handle_request( manager: Arc, - req: Request, -) -> Result, Infallible> { + req: Request, +) -> Result, Infallible> { let socket_path = match manager.ensure_server().await { Ok(p) => p, Err(e) => { error!(manager.log, "Error starting agent host: {:?}", e); return Ok(Response::builder() .status(503) - .body(Body::from(format!("Error starting agent host: {e:?}"))) + .body(full_body(format!("Error starting agent host: {e:?}"))) .unwrap()); } }; - let is_upgrade = req.headers().contains_key(hyper::header::UPGRADE); + let is_upgrade = req.headers().contains_key(::http::header::UPGRADE); let rw = match get_socket_rw_stream(&socket_path).await { Ok(rw) => rw, @@ -496,67 +500,98 @@ pub async fn handle_request( ); return Ok(Response::builder() .status(503) - .body(Body::from(format!("Error connecting to agent host: {e:?}"))) + .body(full_body(format!("Error connecting to agent host: {e:?}"))) .unwrap()); } }; if is_upgrade { - Ok(forward_ws_to_server(rw, req).await) + Ok(forward_ws_to_server(manager.log.clone(), rw, req).await) } else { Ok(forward_http_to_server(rw, req).await) } } /// Proxies a standard HTTP request through the socket. -async fn forward_http_to_server(rw: AsyncPipe, req: Request) -> Response { +async fn forward_http_to_server(rw: AsyncPipe, req: Request) -> Response { let (mut request_sender, connection) = - match hyper::client::conn::Builder::new().handshake(rw).await { + match hyper::client::conn::http1::handshake(TokioIo::new(rw)).await { Ok(r) => r, Err(e) => return connection_err(e), }; tokio::spawn(connection); - request_sender - .send_request(req) - .await - .unwrap_or_else(connection_err) + match request_sender.send_request(req).await { + Ok(res) => res.map(|b| b.boxed()), + Err(e) => connection_err(e), + } } /// Proxies a WebSocket upgrade request through the socket. -async fn forward_ws_to_server(rw: AsyncPipe, mut req: Request) -> Response { +async fn forward_ws_to_server( + log: log::Logger, + rw: AsyncPipe, + mut req: Request, +) -> Response { let (mut request_sender, connection) = - match hyper::client::conn::Builder::new().handshake(rw).await { + match hyper::client::conn::http1::handshake(TokioIo::new(rw)).await { Ok(r) => r, Err(e) => return connection_err(e), }; - tokio::spawn(connection); + tokio::spawn(connection.with_upgrades()); let mut proxied_req = Request::builder().uri(req.uri()); for (k, v) in req.headers() { proxied_req = proxied_req.header(k, v); } - let mut res = request_sender - .send_request(proxied_req.body(Body::empty()).unwrap()) + let mut res = match request_sender + .send_request( + proxied_req + .body(http_body_util::Empty::::new()) + .unwrap(), + ) .await - .unwrap_or_else(connection_err); + { + Ok(r) => r, + Err(e) => return connection_err(e), + }; - let mut proxied_res = Response::new(Body::empty()); + let mut proxied_res = Response::new(empty_body()); *proxied_res.status_mut() = res.status(); for (k, v) in res.headers() { proxied_res.headers_mut().insert(k, v.clone()); } - if res.status() == hyper::StatusCode::SWITCHING_PROTOCOLS { + if res.status() == ::http::StatusCode::SWITCHING_PROTOCOLS { tokio::spawn(async move { let (s_req, s_res) = tokio::join!(hyper::upgrade::on(&mut req), hyper::upgrade::on(&mut res)); - if let (Ok(mut s_req), Ok(mut s_res)) = (s_req, s_res) { - let _ = tokio::io::copy_bidirectional(&mut s_req, &mut s_res).await; + match (s_req, s_res) { + (Ok(s_req), Ok(s_res)) => { + let mut s_req = TokioIo::new(s_req); + let mut s_res = TokioIo::new(s_res); + if let Err(e) = tokio::io::copy_bidirectional(&mut s_req, &mut s_res).await { + debug!(log, "Agent host WebSocket proxy ended with error: {:?}", e); + } + } + (Err(e), _) => { + warning!( + log, + "Agent host client-side WebSocket upgrade failed: {:?}", + e + ); + } + (_, Err(e)) => { + warning!( + log, + "Agent host server-side WebSocket upgrade failed: {:?}", + e + ); + } } }); } @@ -564,10 +599,10 @@ async fn forward_ws_to_server(rw: AsyncPipe, mut req: Request) -> Response proxied_res } -fn connection_err(err: hyper::Error) -> Response { +fn connection_err(err: hyper::Error) -> Response { Response::builder() .status(503) - .body(Body::from(format!( + .body(full_body(format!( "Error connecting to agent host: {err:?}" ))) .unwrap() diff --git a/cli/src/tunnels/code_server.rs b/cli/src/tunnels/code_server.rs index ffabbad19c433c..3e4bb9eb900179 100644 --- a/cli/src/tunnels/code_server.rs +++ b/cli/src/tunnels/code_server.rs @@ -8,6 +8,7 @@ use crate::constants::{ APPLICATION_NAME, EDITOR_WEB_URL, QUALITYLESS_PRODUCT_NAME, QUALITYLESS_SERVER_NAME, }; use crate::download_cache::DownloadCache; +use crate::log; use crate::options::{Quality, TelemetryLevel}; use crate::state::LauncherPaths; use crate::tunnels::paths::{get_server_folder_name, SERVER_FOLDER_NAME}; @@ -23,9 +24,6 @@ use crate::util::http::{self, BoxedHttp}; use crate::util::io::SilentCopyProgress; use crate::util::machine::process_exists; use crate::util::prereqs::skip_requirements_check; -use crate::log; -use lazy_static::lazy_static; -use opentelemetry::KeyValue; use regex::Regex; use serde::Deserialize; use std::fs; @@ -33,6 +31,7 @@ use std::fs::File; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::LazyLock; use std::time::Duration; use tokio::fs::remove_file; use tokio::io::{AsyncBufReadExt, BufReader}; @@ -40,11 +39,10 @@ use tokio::process::{Child, Command}; use tokio::sync::oneshot::Receiver; use tokio::time::{interval, timeout}; -lazy_static! { - static ref LISTENING_PORT_RE: Regex = - Regex::new(r"Extension host agent listening on (.+)").unwrap(); - static ref WEB_UI_RE: Regex = Regex::new(r"Web UI available at (.+)").unwrap(); -} +static LISTENING_PORT_RE: LazyLock = + LazyLock::new(|| Regex::new(r"Extension host agent listening on (.+)").unwrap()); +static WEB_UI_RE: LazyLock = + LazyLock::new(|| Regex::new(r"Web UI available at (.+)").unwrap()); #[derive(Clone, Debug, Default)] pub struct CodeServerArgs { @@ -551,14 +549,7 @@ impl<'a> ServerBuilder<'a> { } pub async fn listen_on_socket(&self, socket: &Path) -> Result { - Ok(spanf!( - self.logger, - self.logger.span("server.start").with_attributes(vec! { - KeyValue::new("commit_id", self.server_params.release.commit.to_string()), - KeyValue::new("quality", format!("{}", self.server_params.release.quality)), - }), - self._listen_on_socket(socket) - )?) + self._listen_on_socket(socket).await } async fn _listen_on_socket(&self, socket: &Path) -> Result { @@ -612,10 +603,11 @@ impl<'a> ServerBuilder<'a> { let cmd = cmd.creation_flags( winapi::um::winbase::CREATE_NO_WINDOW | winapi::um::winbase::CREATE_NEW_PROCESS_GROUP - | get_should_use_breakaway_from_job() - .await - .then_some(winapi::um::winbase::CREATE_BREAKAWAY_FROM_JOB) - .unwrap_or_default(), + | if get_should_use_breakaway_from_job().await { + winapi::um::winbase::CREATE_BREAKAWAY_FROM_JOB + } else { + Default::default() + }, ); let child = cmd @@ -803,6 +795,9 @@ fn parse_port_from(text: &str) -> Option { } pub fn print_listening(log: &log::Logger, tunnel_name: &str) { + use crate::commands::output; + use console::style; + debug!( log, "{} is listening for incoming connections", QUALITYLESS_SERVER_NAME @@ -835,8 +830,25 @@ pub fn print_listening(log: &log::Logger, tunnel_name: &str) { } } - let message = &format!("\nOpen this link in your browser {addr}\n"); - log.result(message); + let arrow = style("➜").green().bold(); + let product = QUALITYLESS_PRODUCT_NAME; + let version = crate::constants::VSCODE_CLI_VERSION.unwrap_or("dev"); + + println!(); + println!( + " {} {}", + style(format!("{product} Tunnel")).cyan().bold(), + style(format!("v{version}")).dim(), + ); + println!(); + output::print_banner_line("Tunnel", tunnel_name); + println!( + " {} {} {}", + arrow, + style("Open:").bold(), + style(&addr).cyan(), + ); + output::print_banner_footer(); } pub async fn download_cli_into_cache( diff --git a/cli/src/tunnels/control_server.rs b/cli/src/tunnels/control_server.rs index 614c05efd90004..ee2cc4df5d4919 100644 --- a/cli/src/tunnels/control_server.rs +++ b/cli/src/tunnels/control_server.rs @@ -28,8 +28,6 @@ use crate::util::sync::{new_barrier, Barrier, BarrierOpener}; use futures::stream::FuturesUnordered; use futures::FutureExt; -use opentelemetry::trace::SpanKind; -use opentelemetry::KeyValue; use std::collections::HashMap; use std::path::PathBuf; use std::process::Stdio; @@ -40,7 +38,6 @@ use tokio_util::codec::Decoder; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; use std::sync::Arc; -use std::time::Instant; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader, DuplexStream}; use tokio::sync::{mpsc, Mutex}; @@ -282,8 +279,8 @@ pub async fn serve( let mgr = mgr.clone(); async move { handle_agent_host_request(mgr, req).await } }); - if let Err(e) = hyper::server::conn::Http::new() - .serve_connection(rw, svc) + if let Err(e) = hyper::server::conn::http1::Builder::new() + .serve_connection(hyper_util::rt::TokioIo::new(rw), svc) .with_upgrades() .await { @@ -311,33 +308,17 @@ pub async fn serve( let own_forwarding = forwarding.handle(); tokio::spawn(async move { - use opentelemetry::trace::{FutureExt, TraceContextExt}; - - let span = own_log.span("server.socket").with_kind(SpanKind::Consumer).start(own_log.tracer()); - let cx = opentelemetry::Context::current_with_span(span); - let serve_at = Instant::now(); - debug!(own_log, "Serving new connection"); let (writehalf, readhalf) = socket.into_split(); - let stats = process_socket(readhalf, writehalf, own_tx, Some(own_forwarding), ServeStreamParams { + let _stats = process_socket(readhalf, writehalf, own_tx, Some(own_forwarding), ServeStreamParams { log: own_log, launcher_paths: own_paths, code_server_args: own_code_server_args, platform, exit_barrier: own_exit, requires_auth: AuthRequired::None, - }).with_context(cx.clone()).await; - - cx.span().add_event( - "socket.bandwidth", - vec![ - KeyValue::new("tx", stats.tx as f64), - KeyValue::new("rx", stats.rx as f64), - KeyValue::new("duration_ms", serve_at.elapsed().as_millis() as f64), - ], - ); - cx.span().end(); + }).await; }); } } @@ -375,8 +356,8 @@ pub async fn serve_stream( } pub struct SocketStats { - rx: usize, - tx: usize, + pub rx: usize, + pub tx: usize, } #[allow(clippy::too_many_arguments)] @@ -791,7 +772,7 @@ async fn handle_serve( // fill params.extensions into code_server_args.install_extensions let mut csa = c.code_server_args.clone(); csa.connection_token = params.connection_token.or(csa.connection_token); - csa.install_extensions.extend(params.extensions.into_iter()); + csa.install_extensions.extend(params.extensions); let params_raw = ServerParamsRaw { commit_id: params.commit_id, @@ -1190,7 +1171,9 @@ async fn handle_call_server_http( code_server: Option, params: CallServerHttpParams, ) -> Result { - use hyper::{body, client::conn::Builder, Body, Request}; + use ::http::Request; + use http_body_util::{BodyExt, Full}; + use hyper_util::rt::TokioIo; // We use Hyper directly here since reqwest doesn't support sockets/pipes. // See https://github.com/seanmonstar/reqwest/issues/39 @@ -1202,8 +1185,7 @@ async fn handle_call_server_http( let rw = get_socket_rw_stream(socket).await?; - let (mut request_sender, connection) = Builder::new() - .handshake(rw) + let (mut request_sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(rw)) .await .map_err(|e| wrap(e, "error establishing connection"))?; @@ -1219,7 +1201,9 @@ async fn handle_call_server_http( request_builder = request_builder.header(k, v); } let request = request_builder - .body(Body::from(params.body.unwrap_or_default())) + .body(Full::new(bytes::Bytes::from( + params.body.unwrap_or_default(), + ))) .map_err(|e| wrap(e, "invalid request"))?; let response = request_sender @@ -1227,17 +1211,21 @@ async fn handle_call_server_http( .await .map_err(|e| wrap(e, "error sending request"))?; + let (parts, body) = response.into_parts(); + let body_bytes = body + .collect() + .await + .map_err(|e| wrap(e, "error reading response body"))? + .to_bytes(); + Ok(CallServerHttpResult { - status: response.status().as_u16(), - headers: response - .headers() - .into_iter() + status: parts.status.as_u16(), + headers: parts + .headers + .iter() .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) .collect(), - body: body::to_bytes(response) - .await - .map_err(|e| wrap(e, "error reading response body"))? - .to_vec(), + body: body_bytes.to_vec(), }) } diff --git a/cli/src/tunnels/dev_tunnels.rs b/cli/src/tunnels/dev_tunnels.rs index bc043cd62af3bc..8341190081c5ff 100644 --- a/cli/src/tunnels/dev_tunnels.rs +++ b/cli/src/tunnels/dev_tunnels.rs @@ -5,27 +5,28 @@ use super::protocol::{self, PortPrivacy, PortProtocol}; use crate::auth; use crate::constants::{IS_INTERACTIVE_CLI, PROTOCOL_VERSION_TAG, TUNNEL_SERVICE_USER_AGENT}; +use crate::log; use crate::state::{LauncherPaths, PersistedState}; use crate::util::errors::{ wrap, AnyError, CodeError, DevTunnelError, InvalidTunnelName, TunnelCreationFailed, WrappedError, }; use crate::util::input::prompt_placeholder; -use crate::log; -use async_trait::async_trait; use futures::future::BoxFuture; use futures::{FutureExt, TryFutureExt}; -use lazy_static::lazy_static; +use http::StatusCode; use rand::prelude::IteratorRandom; use regex::Regex; -use reqwest::StatusCode; use serde::{Deserialize, Serialize}; -use std::sync::{Arc, Mutex}; +use std::future::Future; +use std::sync::{Arc, LazyLock, Mutex}; use std::time::Duration; use tokio::sync::{mpsc, watch}; -use tunnels::connections::{ForwardedPortConnection, RelayTunnelHost}; +use tunnels::connections::{ + ClientRelayHandle, ForwardedPortConnection, PortConnection, RelayTunnelClient, RelayTunnelHost, +}; use tunnels::contracts::{ - Tunnel, TunnelAccessControl, TunnelPort, TunnelRelayTunnelEndpoint, PORT_TOKEN, + Tunnel, TunnelAccessControl, TunnelEndpoint, TunnelPort, PORT_TOKEN, TUNNEL_ACCESS_SCOPES_CONNECT, TUNNEL_PROTOCOL_AUTO, }; use tunnels::management::{ @@ -91,10 +92,10 @@ impl PersistedTunnel { } } -#[async_trait] +#[allow(clippy::manual_async_fn)] trait AccessTokenProvider: Send + Sync { /// Gets the current access token. - async fn refresh_token(&self) -> Result; + fn refresh_token(&self) -> impl Future> + Send; /// Maintains the stored credential by refreshing it against the service /// to ensure its stays current. Returns a future that should be polled and @@ -111,10 +112,10 @@ impl StaticAccessTokenProvider { } } -#[async_trait] +#[allow(clippy::manual_async_fn)] impl AccessTokenProvider for StaticAccessTokenProvider { - async fn refresh_token(&self) -> Result { - Ok(self.0.clone()) + fn refresh_token(&self) -> impl Future> + Send { + async move { Ok(self.0.clone()) } } fn keep_alive(&self) -> BoxFuture<'static, Result<(), AnyError>> { @@ -149,30 +150,31 @@ impl LookupAccessTokenProvider { } } -#[async_trait] +#[allow(clippy::manual_async_fn)] impl AccessTokenProvider for LookupAccessTokenProvider { - async fn refresh_token(&self) -> Result { - if let Some(token) = self.initial_token.lock().unwrap().take() { - return Ok(token); - } + fn refresh_token(&self) -> impl Future> + Send { + async move { + if let Some(token) = self.initial_token.lock().unwrap().take() { + return Ok(token); + } - let tunnel_lookup = spanf!( - self.log, - self.log.span("dev-tunnel.tag.get"), - self.client.get_tunnel( - &self.locator, - &TunnelRequestOptions { - token_scopes: vec!["host".to_string()], - ..Default::default() - } - ) - ); + let tunnel_lookup = self + .client + .get_tunnel( + &self.locator, + &TunnelRequestOptions { + token_scopes: vec!["host".to_string()], + ..Default::default() + }, + ) + .await; - trace!(self.log, "Successfully refreshed access token"); + trace!(self.log, "Successfully refreshed access token"); - match tunnel_lookup { - Ok(tunnel) => Ok(get_host_token_from_tunnel(&tunnel)), - Err(e) => Err(wrap(e, "failed to lookup tunnel for host token")), + match tunnel_lookup { + Ok(tunnel) => Ok(get_host_token_from_tunnel(&tunnel)), + Err(e) => Err(wrap(e, "failed to lookup tunnel for host token")), + } } } @@ -241,8 +243,7 @@ impl ActiveTunnel { return details .as_ref() .map(|r| { - r.base - .port_uri_format + r.port_uri_format .clone() .expect("expected to have port format") }) @@ -297,13 +298,12 @@ fn is_valid_name(name: &str) -> Result<(), InvalidTunnelName> { Ok(()) } -lazy_static! { - static ref HOST_TUNNEL_REQUEST_OPTIONS: TunnelRequestOptions = TunnelRequestOptions { +static HOST_TUNNEL_REQUEST_OPTIONS: LazyLock = + LazyLock::new(|| TunnelRequestOptions { include_ports: true, token_scopes: vec!["host".to_string()], ..Default::default() - }; -} + }); /// Structure optionally passed into `start_existing_tunnel` to forward an existing tunnel. #[derive(Clone, Debug)] @@ -366,13 +366,10 @@ impl DevTunnels { } }; - spanf!( - self.log, - self.log.span("dev-tunnel.delete"), - self.client - .delete_tunnel(&tunnel.into_locator(), NO_REQUEST_OPTIONS) - ) - .map_err(|e| wrap(e, "failed to execute `tunnel delete`"))?; + self.client + .delete_tunnel(&tunnel.into_locator(), NO_REQUEST_OPTIONS) + .await + .map_err(|e| wrap(e, "failed to execute `tunnel delete`"))?; self.launcher_tunnel.save(None)?; Ok(()) @@ -385,6 +382,83 @@ impl DevTunnels { .map(|_| ()) } + /// Connects to a tunnel by name as a client, returning a raw connection + /// to the tunnel's agent host port. The caller is responsible for doing + /// the WebSocket upgrade over the returned stream. + /// + /// The returned [`ClientRelayHandle`] must be kept alive for the duration + /// of the connection; dropping it closes the underlying SSH session. + pub async fn connect_to_tunnel_port( + &mut self, + name: &str, + port: u16, + ) -> Result<(PortConnection, ClientRelayHandle), AnyError> { + let tunnel = self.get_tunnel_with_connect_scope(name).await?; + + let endpoint = tunnel.endpoints.first().ok_or_else(|| { + DevTunnelError(format!( + "Tunnel '{name}' has no active endpoint (is the host running?)", + )) + })?; + + let connect_token = tunnel + .access_tokens + .as_ref() + .and_then(|t| t.get("connect")) + .ok_or_else(|| { + DevTunnelError(format!( + "No connect-scoped access token for tunnel '{name}'", + )) + })?; + + let client = RelayTunnelClient::new(self.client.clone()); + let handle = client + .connect(endpoint, connect_token) + .await + .map_err(|e| wrap(e, "failed to connect to tunnel relay"))?; + + let port_conn = handle + .connect_to_port(port) + .await + .map_err(|e| wrap(e, format!("failed to connect to port {port} on tunnel")))?; + + Ok((port_conn, handle)) + } + + /// Looks up a tunnel by name with connect-scoped access token. + async fn get_tunnel_with_connect_scope(&self, name: &str) -> Result { + let existing: Vec = self + .client + .list_all_tunnels(&TunnelRequestOptions { + labels: vec![self.tag.to_string(), name.to_string()], + require_all_labels: true, + limit: 1, + ..Default::default() + }) + .await + .map_err(|e| wrap(e, "failed to list tunnels"))?; + + let tunnel = match existing.into_iter().next() { + Some(t) => t, + None => { + return Err(DevTunnelError(format!("No tunnel found with name '{name}'")).into()) + } + }; + + let loc = TunnelLocator::try_from(&tunnel).unwrap(); + self.client + .get_tunnel( + &loc, + &TunnelRequestOptions { + include_ports: true, + token_scopes: vec!["connect".to_string()], + ..Default::default() + }, + ) + .await + .map_err(|e| wrap(e, "failed to lookup tunnel").into()) + } + /// Updates the name of the existing persisted tunnel to the new name. /// Gracefully creates a new tunnel if the previous one was deleted. async fn update_tunnel_name( @@ -420,12 +494,11 @@ impl DevTunnels { full_tunnel.labels = desired_tags; - let updated_tunnel = spanf!( - self.log, - self.log.span("dev-tunnel.tag.update"), - self.client.update_tunnel(&full_tunnel, NO_REQUEST_OPTIONS) - ) - .map_err(|e| wrap(e, "failed to rename tunnel"))?; + let updated_tunnel = self + .client + .update_tunnel(&full_tunnel, NO_REQUEST_OPTIONS) + .await + .map_err(|e| wrap(e, "failed to rename tunnel"))?; persisted.name = name; self.launcher_tunnel.save(Some(persisted.clone()))?; @@ -442,17 +515,13 @@ impl DevTunnels { create_with_new_name: Option<&str>, options: &TunnelRequestOptions, ) -> Result<(Tunnel, PersistedTunnel, /* is_new */ bool), AnyError> { - let tunnel_lookup = spanf!( - self.log, - self.log.span("dev-tunnel.tag.get"), - self.client.get_tunnel(&persisted.locator(), options) - ); + let tunnel_lookup = self.client.get_tunnel(&persisted.locator(), options).await; match tunnel_lookup { Ok(ft) => Ok((ft, persisted, false)), Err(HttpError::ResponseError(e)) - if e.status_code == StatusCode::NOT_FOUND - || e.status_code == StatusCode::FORBIDDEN => + if e.status_code.as_u16() == StatusCode::NOT_FOUND.as_u16() + || e.status_code.as_u16() == StatusCode::FORBIDDEN.as_u16() => { let (persisted, tunnel) = self .create_tunnel(create_with_new_name.unwrap_or(&persisted.name), options) @@ -520,12 +589,9 @@ impl DevTunnels { port_to_delete.port_number, NO_REQUEST_OPTIONS, ); - spanf!( - self.log, - self.log.span("dev-tunnel.port.delete"), - output_fut - ) - .map_err(|e| wrap(e, "failed to delete port"))?; + output_fut + .await + .map_err(|e| wrap(e, "failed to delete port"))?; } // cleanup any old trailing tunnel endpoints @@ -536,7 +602,7 @@ impl DevTunnels { NO_REQUEST_OPTIONS, ); - spanf!(self.log, self.log.span("dev-tunnel.endpoint.prune"), fut) + fut.await .map_err(|e| wrap(e, "failed to prune tunnel endpoint"))?; } @@ -570,29 +636,26 @@ impl DevTunnels { let loc = TunnelLocator::try_from(&e).unwrap(); info!(self.log, "Adopting existing tunnel (ID={:?})", loc); - spanf!( - self.log, - self.log.span("dev-tunnel.tag.get"), - self.client.get_tunnel(&loc, &HOST_TUNNEL_REQUEST_OPTIONS) - ) - .map_err(|e| wrap(e, "failed to lookup tunnel"))? + self.client + .get_tunnel(&loc, &HOST_TUNNEL_REQUEST_OPTIONS) + .await + .map_err(|e| wrap(e, "failed to lookup tunnel"))? } None => loop { - let result = spanf!( - self.log, - self.log.span("dev-tunnel.create"), - self.client.create_tunnel( + let result = self + .client + .create_tunnel( Tunnel { labels: self.get_labels(name), ..Default::default() }, - options + options, ) - ); + .await; match result { Err(HttpError::ResponseError(e)) - if e.status_code == StatusCode::TOO_MANY_REQUESTS => + if e.status_code.as_u16() == StatusCode::TOO_MANY_REQUESTS.as_u16() => { if let Some(d) = e.get_details() { let detail = d.detail.unwrap_or_else(|| "unknown".to_string()); @@ -672,11 +735,7 @@ impl DevTunnels { ..Default::default() }; - let result = spanf!( - self.log, - self.log.span("dev-tunnel.protocol-tag-update"), - client.update_tunnel(&tunnel_update, options) - ); + let result = client.update_tunnel(&tunnel_update, options).await; result.map_err(|e| wrap(e, "tunnel tag update failed").into()) } @@ -699,13 +758,10 @@ impl DevTunnels { match recyclable { Some(tunnel) => { trace!(self.log, "Recycling tunnel ID {:?}", tunnel.tunnel_id); - spanf!( - self.log, - self.log.span("dev-tunnel.delete"), - self.client - .delete_tunnel(&tunnel.try_into().unwrap(), NO_REQUEST_OPTIONS) - ) - .map_err(|e| wrap(e, "failed to execute `tunnel delete`"))?; + self.client + .delete_tunnel(&tunnel.try_into().unwrap(), NO_REQUEST_OPTIONS) + .await + .map_err(|e| wrap(e, "failed to execute `tunnel delete`"))?; Ok(true) } None => { @@ -719,24 +775,22 @@ impl DevTunnels { &mut self, tags: &[&'static str], ) -> Result, AnyError> { - let tunnels = spanf!( - self.log, - self.log.span("dev-tunnel.listall"), - self.client.list_all_tunnels(&TunnelRequestOptions { + let tunnels = self + .client + .list_all_tunnels(&TunnelRequestOptions { labels: tags.iter().map(|t| t.to_string()).collect(), ..Default::default() }) - ) - .map_err(|e| wrap(e, "error listing current tunnels"))?; + .await + .map_err(|e| wrap(e, "error listing current tunnels"))?; Ok(tunnels) } async fn get_existing_tunnel_with_name(&self, name: &str) -> Result, AnyError> { - let existing: Vec = spanf!( - self.log, - self.log.span("dev-tunnel.rename.search"), - self.client.list_all_tunnels(&TunnelRequestOptions { + let existing: Vec = self + .client + .list_all_tunnels(&TunnelRequestOptions { labels: vec![self.tag.to_string(), name.to_string()], require_all_labels: true, limit: 1, @@ -744,8 +798,8 @@ impl DevTunnels { token_scopes: vec!["host".to_string()], ..Default::default() }) - ) - .map_err(|e| wrap(e, "failed to list existing tunnels"))?; + .await + .map_err(|e| wrap(e, "failed to list existing tunnels"))?; Ok(existing.into_iter().next()) } @@ -871,11 +925,7 @@ impl DevTunnels { ) -> Result { let mut manager = ActiveTunnelManager::new(self.log.clone(), client, locator, access_token); - let endpoint_result = spanf!( - self.log, - self.log.span("dev-tunnel.serve.callback"), - manager.get_endpoint() - ); + let endpoint_result = manager.get_endpoint().await; let endpoint = match endpoint_result { Ok(endpoint) => endpoint, @@ -903,13 +953,13 @@ impl StatusLock { fn succeed(&self) { let mut status = self.0.lock().unwrap(); status.tunnel = protocol::singleton::TunnelState::Connected; - status.last_connected_at = Some(chrono::Utc::now()); + status.last_connected_at = Some(jiff::Timestamp::now()); } fn fail(&self, reason: String) { let mut status = self.0.lock().unwrap(); if let protocol::singleton::TunnelState::Connected = status.tunnel { - status.last_disconnected_at = Some(chrono::Utc::now()); + status.last_disconnected_at = Some(jiff::Timestamp::now()); status.tunnel = protocol::singleton::TunnelState::Disconnected; } status.last_fail_reason = Some(reason); @@ -923,7 +973,7 @@ impl StatusLock { struct ActiveTunnelManager { close_tx: Option>, - endpoint_rx: watch::Receiver>>, + endpoint_rx: watch::Receiver>>, relay: Arc>, status: StatusLock, } @@ -1020,7 +1070,7 @@ impl ActiveTunnelManager { /// Gets the most recent details from the tunnel process. Returns None if /// the process exited before providing details. - pub async fn get_endpoint(&mut self) -> Result { + pub async fn get_endpoint(&mut self) -> Result { loop { if let Some(details) = &*self.endpoint_rx.borrow() { return details.clone().map_err(AnyError::from); @@ -1055,7 +1105,7 @@ impl ActiveTunnelManager { log: log::Logger, relay: Arc>, mut close_rx: mpsc::Receiver<()>, - endpoint_tx: watch::Sender>>, + endpoint_tx: watch::Sender>>, access_token_provider: impl AccessTokenProvider + 'static, status: StatusLock, ) { diff --git a/cli/src/tunnels/legal.rs b/cli/src/tunnels/legal.rs index 35316af4fde9a7..90aec543377948 100644 --- a/cli/src/tunnels/legal.rs +++ b/cli/src/tunnels/legal.rs @@ -6,13 +6,12 @@ use crate::constants::IS_INTERACTIVE_CLI; use crate::state::{LauncherPaths, PersistedState}; use crate::util::errors::{AnyError, CodeError}; use crate::util::input::prompt_yn; -use lazy_static::lazy_static; use serde::{Deserialize, Serialize}; +use std::sync::LazyLock; -lazy_static! { - static ref LICENSE_TEXT: Option> = - option_env!("VSCODE_CLI_SERVER_LICENSE").and_then(|s| serde_json::from_str(s).unwrap()); -} +static LICENSE_TEXT: LazyLock>> = LazyLock::new(|| { + option_env!("VSCODE_CLI_SERVER_LICENSE").and_then(|s| serde_json::from_str(s).unwrap()) +}); const LICENSE_PROMPT: Option<&'static str> = option_env!("VSCODE_CLI_REMOTE_LICENSE_PROMPT"); diff --git a/cli/src/tunnels/protocol.rs b/cli/src/tunnels/protocol.rs index 0c6329f30439f9..482845965d15fa 100644 --- a/cli/src/tunnels/protocol.rs +++ b/cli/src/tunnels/protocol.rs @@ -353,7 +353,7 @@ pub mod forward_singleton { pub mod singleton { use crate::log; - use chrono::{DateTime, Utc}; + use jiff::Timestamp; use serde::{Deserialize, Serialize}; pub const METHOD_RESTART: &str = "restart"; @@ -385,17 +385,17 @@ pub mod singleton { #[derive(Serialize, Deserialize, Clone)] pub struct Status { - pub started_at: DateTime, + pub started_at: Timestamp, pub tunnel: TunnelState, - pub last_connected_at: Option>, - pub last_disconnected_at: Option>, + pub last_connected_at: Option, + pub last_disconnected_at: Option, pub last_fail_reason: Option, } impl Default for Status { fn default() -> Self { Self { - started_at: Utc::now(), + started_at: Timestamp::now(), tunnel: TunnelState::Disconnected, last_connected_at: None, last_disconnected_at: None, diff --git a/cli/src/tunnels/service.rs b/cli/src/tunnels/service.rs index 66bdf7a8e63a6c..e8b752253abff9 100644 --- a/cli/src/tunnels/service.rs +++ b/cli/src/tunnels/service.rs @@ -5,8 +5,6 @@ use std::path::{Path, PathBuf}; -use async_trait::async_trait; - use crate::log; use crate::state::LauncherPaths; use crate::util::errors::{wrap, AnyError}; @@ -14,7 +12,6 @@ use crate::util::io::{tailf, TailEvent}; pub const SERVICE_LOG_FILE_NAME: &str = "tunnel-service.log"; -#[async_trait] pub trait ServiceContainer: Send { async fn run_service( &mut self, @@ -23,7 +20,6 @@ pub trait ServiceContainer: Send { ) -> Result<(), AnyError>; } -#[async_trait] pub trait ServiceManager { /// Registers the current executable as a service to run with the given set /// of arguments. diff --git a/cli/src/tunnels/service_linux.rs b/cli/src/tunnels/service_linux.rs index 0a3e2df6ea2e4d..4de6ba24d83b7e 100644 --- a/cli/src/tunnels/service_linux.rs +++ b/cli/src/tunnels/service_linux.rs @@ -10,7 +10,6 @@ use std::{ process::Command, }; -use async_trait::async_trait; use zbus::{dbus_proxy, zvariant, Connection}; use crate::{ @@ -66,7 +65,6 @@ impl SystemdService { } } -#[async_trait] impl ServiceManager for SystemdService { async fn register( &self, diff --git a/cli/src/tunnels/service_macos.rs b/cli/src/tunnels/service_macos.rs index 2a51681de1db3a..938c71e9ead017 100644 --- a/cli/src/tunnels/service_macos.rs +++ b/cli/src/tunnels/service_macos.rs @@ -9,8 +9,6 @@ use std::{ path::{Path, PathBuf}, }; -use async_trait::async_trait; - use crate::{ constants::APPLICATION_NAME, log, @@ -37,7 +35,6 @@ impl LaunchdService { } } -#[async_trait] impl ServiceManager for LaunchdService { async fn register( &self, diff --git a/cli/src/tunnels/service_windows.rs b/cli/src/tunnels/service_windows.rs index 395a707f3513ba..e88943569a5651 100644 --- a/cli/src/tunnels/service_windows.rs +++ b/cli/src/tunnels/service_windows.rs @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -use async_trait::async_trait; use shell_escape::windows::escape as shell_escape; use std::os::windows::process::CommandExt; use std::{path::PathBuf, process::Stdio}; @@ -46,7 +45,6 @@ impl WindowsService { } } -#[async_trait] impl CliServiceManager for WindowsService { async fn register(&self, exe: std::path::PathBuf, args: &[&str]) -> Result<(), AnyError> { let key = WindowsService::open_key()?; diff --git a/cli/src/update_service.rs b/cli/src/update_service.rs index de977b736b20ad..1070dac00c4bbe 100644 --- a/cli/src/update_service.rs +++ b/cli/src/update_service.rs @@ -64,7 +64,7 @@ fn get_update_endpoint() -> Result { } VSCODE_CLI_UPDATE_ENDPOINT .map(|s| s.to_string()) - .ok_or_else(|| CodeError::UpdatesNotConfigured("no service url")) + .ok_or(CodeError::UpdatesNotConfigured("no service url")) } impl UpdateService { @@ -91,11 +91,7 @@ impl UpdateService { quality_download_segment(quality), ); - let mut response = spanf!( - self.log, - self.log.span("server.version.resolve"), - self.client.make_request("GET", download_url) - )?; + let mut response = self.client.make_request("GET", download_url).await?; if !response.status_code.is_success() { return Err(response.into_err().await.into()); @@ -131,11 +127,7 @@ impl UpdateService { quality_download_segment(quality), ); - let mut response = spanf!( - self.log, - self.log.span("server.version.resolve"), - self.client.make_request("GET", download_url) - )?; + let mut response = self.client.make_request("GET", download_url).await?; if !response.status_code.is_success() { return Err(response.into_err().await.into()); diff --git a/cli/src/util/errors.rs b/cli/src/util/errors.rs index b7ed029bb98d47..72e82307bdb736 100644 --- a/cli/src/util/errors.rs +++ b/cli/src/util/errors.rs @@ -451,6 +451,8 @@ pub enum CodeError { SingletonLockedProcessExited(u32), #[error("no tunnel process is currently running")] NoRunningTunnel, + #[error("no agent host process is currently running")] + NoRunningAgentHost, #[error("rpc call failed: {0:?}")] TunnelRpcCallFailed(ResponseError), #[cfg(windows)] @@ -493,7 +495,7 @@ pub enum CodeError { #[error("could not parse `host`: {0}")] InvalidHostAddress(std::net::AddrParseError), #[error("could not start server on the given host/port: {0}")] - CouldNotListenOnInterface(hyper::Error), + CouldNotListenOnInterface(std::io::Error), #[error( "Run this command again with --accept-server-license-terms to indicate your agreement." )] diff --git a/cli/src/util/http.rs b/cli/src/util/http.rs index 9658ec1fcbd626..89190a1bb88bd0 100644 --- a/cli/src/util/http.rs +++ b/cli/src/util/http.rs @@ -7,16 +7,16 @@ use crate::{ log, util::errors::{self, WrappedError}, }; -use async_trait::async_trait; +use bytes::Bytes; use core::panic; use futures::stream::TryStreamExt; -use hyper::{ - header::{HeaderName, CONTENT_LENGTH}, - http::HeaderValue, +use http::{ + header::{HeaderName, HeaderValue, CONTENT_LENGTH}, HeaderMap, StatusCode, }; +use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full}; use serde::de::DeserializeOwned; -use std::{io, pin::Pin, str::FromStr, sync::Arc, task::Poll}; +use std::{future::Future, io, pin::Pin, str::FromStr, sync::Arc, task::Poll}; use tokio::{ fs, io::{AsyncRead, AsyncReadExt}, @@ -29,6 +29,23 @@ use super::{ io::{copy_async_progress, ReadBuffer, ReportCopyProgress}, }; +/// Boxed body type used across the HTTP layer. +pub type HyperBody = BoxBody; + +/// Creates a body from some data (string, bytes, etc.) +pub fn full_body(data: impl Into) -> HyperBody { + Full::new(data.into()) + .map_err(|never| match never {}) + .boxed() +} + +/// Creates an empty body. +pub fn empty_body() -> HyperBody { + Empty::::new() + .map_err(|never| match never {}) + .boxed() +} + pub async fn download_into_file( filename: &std::path::Path, progress: T, @@ -119,13 +136,12 @@ impl SimpleResponse { /// the request library on the server (i.e. `reqwest`) but it can also be used /// to make update/download requests on the client rather than the server, /// similar to SSH's `remote.SSH.localServerDownload` setting. -#[async_trait] pub trait SimpleHttp { - async fn make_request( + fn make_request( &self, method: &'static str, url: String, - ) -> Result; + ) -> Pin> + Send + '_>>; } pub type BoxedHttp = Arc; @@ -157,29 +173,30 @@ impl Default for ReqwestSimpleHttp { } } -#[async_trait] impl SimpleHttp for ReqwestSimpleHttp { - async fn make_request( + fn make_request( &self, method: &'static str, url: String, - ) -> Result { - let res = self - .client - .request(reqwest::Method::try_from(method).unwrap(), &url) - .send() - .await?; - - Ok(SimpleResponse { - status_code: res.status(), - headers: res.headers().clone(), - url: Some(res.url().clone()), - read: Box::pin( - res.bytes_stream() - .map_err(futures::io::Error::other) - .into_async_read() - .compat(), - ), + ) -> Pin> + Send + '_>> { + Box::pin(async move { + let res = self + .client + .request(reqwest::Method::try_from(method).unwrap(), &url) + .send() + .await?; + + Ok(SimpleResponse { + status_code: res.status(), + headers: res.headers().clone(), + url: Some(res.url().clone()), + read: Box::pin( + res.bytes_stream() + .map_err(futures::io::Error::other) + .into_async_read() + .compat(), + ), + }) }) } } @@ -243,61 +260,62 @@ impl DelegatedSimpleHttp { } } -#[async_trait] impl SimpleHttp for DelegatedSimpleHttp { - async fn make_request( + fn make_request( &self, method: &'static str, url: String, - ) -> Result { - trace!(self.log, "making delegated request to {}", url); - let (tx, mut rx) = mpsc::unbounded_channel(); - let sent = self - .start_request - .send(DelegatedHttpRequest { - method, - url: url.clone(), - ch: tx, - }) - .await; + ) -> Pin> + Send + '_>> { + Box::pin(async move { + trace!(self.log, "making delegated request to {}", url); + let (tx, mut rx) = mpsc::unbounded_channel(); + let sent = self + .start_request + .send(DelegatedHttpRequest { + method, + url: url.clone(), + ch: tx, + }) + .await; - if sent.is_err() { - return Ok(SimpleResponse::generic_error(&url)); // sender shut down - } + if sent.is_err() { + return Ok(SimpleResponse::generic_error(&url)); // sender shut down + } - match rx.recv().await { - Some(DelegatedHttpEvent::InitResponse { - status_code, - headers, - }) => { - trace!( - self.log, - "delegated request to {} resulted in status = {}", - url, - status_code - ); - let mut headers_map = HeaderMap::with_capacity(headers.len()); - for (k, v) in &headers { - if let (Ok(key), Ok(value)) = ( - HeaderName::from_str(&k.to_lowercase()), - HeaderValue::from_str(v), - ) { - headers_map.insert(key, value); + match rx.recv().await { + Some(DelegatedHttpEvent::InitResponse { + status_code, + headers, + }) => { + trace!( + self.log, + "delegated request to {} resulted in status = {}", + url, + status_code + ); + let mut headers_map = HeaderMap::with_capacity(headers.len()); + for (k, v) in &headers { + if let (Ok(key), Ok(value)) = ( + HeaderName::from_str(&k.to_lowercase()), + HeaderValue::from_str(v), + ) { + headers_map.insert(key, value); + } } - } - Ok(SimpleResponse { - url: url::Url::parse(&url).ok(), - status_code: StatusCode::from_u16(status_code) - .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), - headers: headers_map, - read: Box::pin(DelegatedReader::new(rx)), - }) + Ok(SimpleResponse { + url: url::Url::parse(&url).ok(), + status_code: StatusCode::from_u16(status_code) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + headers: headers_map, + read: Box::pin(DelegatedReader::new(rx)), + }) + } + Some(DelegatedHttpEvent::End) => Ok(SimpleResponse::generic_error(&url)), + Some(_) => panic!("expected initresponse as first message from delegated http"), + None => Ok(SimpleResponse::generic_error(&url)), // sender shut down } - Some(DelegatedHttpEvent::End) => Ok(SimpleResponse::generic_error(&url)), - Some(_) => panic!("expected initresponse as first message from delegated http"), - None => Ok(SimpleResponse::generic_error(&url)), // sender shut down - } + }) } } @@ -357,20 +375,21 @@ impl FallbackSimpleHttp { } } -#[async_trait] impl SimpleHttp for FallbackSimpleHttp { - async fn make_request( + fn make_request( &self, method: &'static str, url: String, - ) -> Result { - let r1 = self.native.make_request(method, url.clone()).await; - if let Ok(res) = r1 { - if !res.status_code.is_server_error() { - return Ok(res); + ) -> Pin> + Send + '_>> { + Box::pin(async move { + let r1 = self.native.make_request(method, url.clone()).await; + if let Ok(res) = r1 { + if !res.status_code.is_server_error() { + return Ok(res); + } } - } - self.delegated.make_request(method, url).await + self.delegated.make_request(method, url).await + }) } } diff --git a/cli/src/util/io.rs b/cli/src/util/io.rs index 2de2a72583ea98..17786463eea115 100644 --- a/cli/src/util/io.rs +++ b/cli/src/util/io.rs @@ -68,7 +68,6 @@ where Ok(bytes_so_far) } - /// Helper used when converting Future interfaces to poll-based interfaces. /// Stores excess data that can be reused on future polls. #[derive(Default)] @@ -221,7 +220,7 @@ pub fn tailf(file: File, n: usize) -> mpsc::UnboundedReceiver { #[cfg(test)] mod tests { - use rand::Rng; + use rand::RngExt; use std::{fs::OpenOptions, io::Write}; use super::*; @@ -315,7 +314,11 @@ mod tests { let mut written = vec![]; let base_line = "Elit ipsum cillum ex cillum. Adipisicing consequat cupidatat do proident ut in sunt Lorem ipsum tempor. Eiusmod ipsum Lorem labore exercitation sunt pariatur excepteur fugiat cillum velit cillum enim. Nisi Lorem cupidatat ad enim velit officia eiusmod esse tempor aliquip. Deserunt pariatur tempor in duis culpa esse sit nulla irure ullamco ipsum voluptate non laboris. Occaecat officia nulla officia mollit do aliquip reprehenderit ad incididunt."; for i in 0..100 { - let line = format!("{}: {}", i, &base_line[..rng.gen_range(0..base_line.len())]); + let line = format!( + "{}: {}", + i, + &base_line[..rng.random_range(0..base_line.len())] + ); writeln!(&mut read_file, "{line}").unwrap(); written.push(line); } diff --git a/cli/src/util/prereqs.rs b/cli/src/util/prereqs.rs index 44c859772e3837..83dd024b181add 100644 --- a/cli/src/util/prereqs.rs +++ b/cli/src/util/prereqs.rs @@ -6,31 +6,32 @@ use std::cmp::Ordering; use crate::constants::QUALITYLESS_SERVER_NAME; use crate::update_service::Platform; -use lazy_static::lazy_static; use regex::bytes::Regex as BinRegex; use regex::Regex; +use std::sync::LazyLock; use tokio::fs; use super::errors::CodeError; -lazy_static! { - static ref LDCONFIG_STDC_RE: Regex = Regex::new(r"libstdc\+\+.* => (.+)").unwrap(); - static ref LDD_VERSION_RE: BinRegex = BinRegex::new(r"^ldd.*\s(\d+)\.(\d+)(?:\.(\d+))?\s").unwrap(); - static ref GENERIC_VERSION_RE: Regex = Regex::new(r"^([0-9]+)\.([0-9]+)$").unwrap(); - static ref LIBSTD_CXX_VERSION_RE: BinRegex = - BinRegex::new(r"GLIBCXX_([0-9]+)\.([0-9]+)(?:\.([0-9]+))?").unwrap(); - static ref MIN_LDD_VERSION: SimpleSemver = SimpleSemver::new(2, 28, 0); -} +static LDCONFIG_STDC_RE: LazyLock = + LazyLock::new(|| Regex::new(r"libstdc\+\+.* => (.+)").unwrap()); +static LDD_VERSION_RE: LazyLock = + LazyLock::new(|| BinRegex::new(r"^ldd.*\s(\d+)\.(\d+)(?:\.(\d+))?\s").unwrap()); +static GENERIC_VERSION_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^([0-9]+)\.([0-9]+)$").unwrap()); +#[cfg(target_os = "linux")] +static LIBSTD_CXX_VERSION_RE: LazyLock = + LazyLock::new(|| BinRegex::new(r"GLIBCXX_([0-9]+)\.([0-9]+)(?:\.([0-9]+))?").unwrap()); +#[cfg(target_os = "linux")] +static MIN_LDD_VERSION: LazyLock = LazyLock::new(|| SimpleSemver::new(2, 28, 0)); +#[cfg(target_os = "linux")] #[cfg(target_arch = "arm")] -lazy_static! { - static ref MIN_CXX_VERSION: SimpleSemver = SimpleSemver::new(3, 4, 26); -} +static MIN_CXX_VERSION: LazyLock = LazyLock::new(|| SimpleSemver::new(3, 4, 26)); +#[cfg(target_os = "linux")] #[cfg(not(target_arch = "arm"))] -lazy_static! { - static ref MIN_CXX_VERSION: SimpleSemver = SimpleSemver::new(3, 4, 25); -} +static MIN_CXX_VERSION: LazyLock = LazyLock::new(|| SimpleSemver::new(3, 4, 25)); const NIXOS_TEST_PATH: &str = "/etc/NIXOS"; @@ -187,10 +188,10 @@ async fn check_is_nixos() -> bool { /// minimum requirements. #[cfg(not(windows))] pub async fn skip_requirements_check() -> bool { - std::env::var("VSCODE_SERVER_CUSTOM_GLIBC_LINKER").is_ok() || - fs::metadata("/tmp/vscode-skip-server-requirements-check") - .await - .is_ok() + std::env::var("VSCODE_SERVER_CUSTOM_GLIBC_LINKER").is_ok() + || fs::metadata("/tmp/vscode-skip-server-requirements-check") + .await + .is_ok() } #[cfg(windows)] @@ -402,5 +403,4 @@ mod tests { Some(SimpleSemver::new(2, 40, 0)), ); } - } diff --git a/cli/src/util/sync.rs b/cli/src/util/sync.rs index 67c777b75ed21e..3eba4e6b1a279c 100644 --- a/cli/src/util/sync.rs +++ b/cli/src/util/sync.rs @@ -2,8 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -use async_trait::async_trait; -use std::{marker::PhantomData, sync::Arc}; +use std::{future::Future, marker::PhantomData, pin::Pin, sync::Arc}; use tokio::sync::{ broadcast, mpsc, watch::{self, error::RecvError}, @@ -35,10 +34,9 @@ where } } -#[async_trait] impl Receivable for Barrier { - async fn recv_msg(&mut self) -> Option { - self.wait().await.ok() + fn recv_msg(&mut self) -> Pin> + Send + '_>> { + Box::pin(async move { self.wait().await.ok() }) } } @@ -70,37 +68,35 @@ where } /// Type that can receive messages in an async way. -#[async_trait] pub trait Receivable { - async fn recv_msg(&mut self) -> Option; + fn recv_msg(&mut self) -> Pin> + Send + '_>>; } // todo: ideally we would use an Arc in the broadcast::Receiver to avoid having // to clone bytes everywhere, requires updating rpc consumers as well. -#[async_trait] impl Receivable for broadcast::Receiver { - async fn recv_msg(&mut self) -> Option { - loop { - match self.recv().await { - Ok(v) => return Some(v), - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => return None, + fn recv_msg(&mut self) -> Pin> + Send + '_>> { + Box::pin(async move { + loop { + match self.recv().await { + Ok(v) => return Some(v), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return None, + } } - } + }) } } -#[async_trait] impl Receivable for mpsc::UnboundedReceiver { - async fn recv_msg(&mut self) -> Option { - self.recv().await + fn recv_msg(&mut self) -> Pin> + Send + '_>> { + Box::pin(async move { self.recv().await }) } } -#[async_trait] impl Receivable for () { - async fn recv_msg(&mut self) -> Option { - futures::future::pending().await + fn recv_msg(&mut self) -> Pin> + Send + '_>> { + Box::pin(async move { futures::future::pending().await }) } } @@ -120,21 +116,22 @@ impl, B: Receivable> ConcatReceivable { } } -#[async_trait] impl, B: Send + Receivable> Receivable for ConcatReceivable { - async fn recv_msg(&mut self) -> Option { - if let Some(left) = &mut self.left { - match left.recv_msg().await { - Some(v) => return Some(v), - None => { - self.left = None; + fn recv_msg(&mut self) -> Pin> + Send + '_>> { + Box::pin(async move { + if let Some(left) = &mut self.left { + match left.recv_msg().await { + Some(v) => return Some(v), + None => { + self.left = None; + } } } - } - return self.right.recv_msg().await; + return self.right.recv_msg().await; + }) } } @@ -154,30 +151,31 @@ impl, B: Receivable> MergedReceivable { } } -#[async_trait] impl, B: Send + Receivable> Receivable for MergedReceivable { - async fn recv_msg(&mut self) -> Option { - loop { - match (&mut self.left, &mut self.right) { - (Some(left), Some(right)) => { - tokio::select! { - left = left.recv_msg() => match left { - Some(v) => return Some(v), - None => { self.left = None; continue; }, - }, - right = right.recv_msg() => match right { - Some(v) => return Some(v), - None => { self.right = None; continue; }, - }, + fn recv_msg(&mut self) -> Pin> + Send + '_>> { + Box::pin(async move { + loop { + match (&mut self.left, &mut self.right) { + (Some(left), Some(right)) => { + tokio::select! { + left = left.recv_msg() => match left { + Some(v) => return Some(v), + None => { self.left = None; continue; }, + }, + right = right.recv_msg() => match right { + Some(v) => return Some(v), + None => { self.right = None; continue; }, + }, + } } + (Some(a), None) => break a.recv_msg().await, + (None, Some(b)) => break b.recv_msg().await, + (None, None) => break None, } - (Some(a), None) => break a.recv_msg().await, - (None, Some(b)) => break b.recv_msg().await, - (None, None) => break None, } - } + }) } } diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index 57e1b1ae66c373..96aed432d98392 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -176,6 +176,7 @@ export abstract class ToolCallingLoop('IBuildPromptContext'); diff --git a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts index 8207853f597c4d..fe7017eae86830 100644 --- a/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts +++ b/extensions/copilot/src/extension/prompt/node/chatMLFetcher.ts @@ -217,6 +217,7 @@ export class ChatMLFetcherImpl extends AbstractChatMLFetcher { const countTokens = () => tokenCountPromise ??= chatEndpoint.acquireTokenizer().countMessagesTokens(messages); const copilotToken = await this._authenticationService.getCopilotToken(); usernameToScrub = copilotToken.username; + const fetchResult = await this._fetchAndStreamChat( chatEndpoint, requestBody, diff --git a/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts b/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts index 5789ccac0bc807..e87c2282218193 100644 --- a/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts +++ b/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts @@ -37,6 +37,8 @@ export interface IExecutionSubagentToolCallingLoopOptions extends IToolCallingLo subAgentInvocationId?: string; /** The tool_call_id from the parent agent's LLM response that triggered this subagent invocation. */ parentToolCallId?: string; + /** The headerRequestId from the parent agent's fetch response that triggered this subagent invocation. */ + parentHeaderRequestId?: string; } export class ExecutionSubagentToolCallingLoop extends ToolCallingLoop { @@ -161,6 +163,7 @@ export class ExecutionSubagentToolCallingLoop extends ToolCallingLoop { promptText: options.input.query, subAgentInvocationId: subAgentInvocationId, parentToolCallId: options.chatStreamToolCallId, + parentHeaderRequestId: this._inputContext?.parentHeaderRequestId, }); const stream = this._inputContext?.stream && ChatResponseStreamImpl.filter( diff --git a/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts b/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts index aa12bfe39c873f..24807c977dd2aa 100644 --- a/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts +++ b/extensions/copilot/src/extension/tools/node/searchSubagentTool.ts @@ -124,6 +124,7 @@ class SearchSubagentTool implements ICopilotTool { promptText: options.input.query, subAgentInvocationId: subAgentInvocationId, parentToolCallId: options.chatStreamToolCallId, + parentHeaderRequestId: this._inputContext?.parentHeaderRequestId, thoroughness: thoroughnessEnabled ? options.input.thoroughness : undefined, }); diff --git a/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts b/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts index ebe31967a7d518..ddccb2b47e6f61 100644 --- a/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts +++ b/extensions/copilot/src/extension/tools/vscode-node/toolsService.ts @@ -175,6 +175,25 @@ export class ToolsService extends BaseToolsService { const startTime = Date.now(); + // Propagate W3C trace context to tool invocations so downstream spans can be + // correlated with this `execute_tool` span. MCP tools forward this onto + // `_meta.traceparent`/`_meta.tracestate` of the JSON-RPC `tools/call` payload + // (MCP SEP-414, see #302301). Only set if not already supplied by the caller. + const optionsWithTrace = options as vscode.LanguageModelToolInvocationOptions & { traceparent?: string; tracestate?: string }; + const ctx = span.getSpanContext(); + if (ctx) { + if (!optionsWithTrace.traceparent) { + // Preserve the upstream W3C trace flags when available. Fall back to `01` + // (sampled) so downstream MCP servers continue to participate in the trace + // when the abstraction does not surface flags (e.g. tests, in-memory impl). + const flags = (ctx.traceFlags ?? 0x01).toString(16).padStart(2, '0'); + optionsWithTrace.traceparent = `00-${ctx.traceId}-${ctx.spanId}-${flags}`; + } + if (!optionsWithTrace.tracestate && ctx.traceState) { + optionsWithTrace.tracestate = ctx.traceState; + } + } + return vscode.lm.invokeTool(getContributedToolName(name), options, token).then( result => { span.setStatus(SpanStatusCode.OK); diff --git a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts index 5c8b0be0542b94..9164caac649e2b 100644 --- a/extensions/copilot/src/platform/endpoint/node/messagesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/messagesApi.ts @@ -654,7 +654,13 @@ export async function processResponseFromMessagesEndpoint( telemetryDataWithUsage = telemetryData.extendedBy({}, { promptTokens: completion.usage.prompt_tokens, completionTokens: completion.usage.completion_tokens, - totalTokens: completion.usage.total_tokens + totalTokens: completion.usage.total_tokens, + ...(completion.usage.prompt_tokens_details && { cachedTokens: completion.usage.prompt_tokens_details.cached_tokens }), + ...(completion.usage.completion_tokens_details && { + reasoningTokens: completion.usage.completion_tokens_details.reasoning_tokens, + acceptedPredictionTokens: completion.usage.completion_tokens_details.accepted_prediction_tokens, + rejectedPredictionTokens: completion.usage.completion_tokens_details.rejected_prediction_tokens, + }), }); } sendEngineMessagesTelemetry(telemetryService, [telemetryMessage], telemetryDataWithUsage, true, logService); diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index a42223b0aa5ac0..ad8fd20459f2ab 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -832,6 +832,12 @@ export function sendCompletionOutputTelemetry(telemetryService: ITelemetryServic promptTokens: completion.usage.prompt_tokens, completionTokens: completion.usage.completion_tokens, totalTokens: completion.usage.total_tokens, + ...(completion.usage.prompt_tokens_details && { cachedTokens: completion.usage.prompt_tokens_details.cached_tokens }), + ...(completion.usage.completion_tokens_details && { + reasoningTokens: completion.usage.completion_tokens_details.reasoning_tokens, + acceptedPredictionTokens: completion.usage.completion_tokens_details.accepted_prediction_tokens, + rejectedPredictionTokens: completion.usage.completion_tokens_details.rejected_prediction_tokens, + }), }); } sendEngineMessagesTelemetry(telemetryService, [telemetryMessage], telemetryDataWithUsage, true, logService); diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index 3bdc4d7dd5630f..e952ccb1b2aef3 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -231,6 +231,8 @@ export type IChatRequestTelemetryProperties = { parentRequestId?: string; /** For a subagent: The tool_call_id from the parent agent's LLM response that triggered this subagent invocation. */ parentToolCallId?: string; + /** For a subagent: The headerRequestId from the parent agent's fetch response that triggered this subagent invocation. */ + parentHeaderRequestId?: string; }; export interface ICreateEndpointBodyOptions extends IMakeChatRequestOptions { diff --git a/extensions/copilot/src/platform/networking/node/chatStream.ts b/extensions/copilot/src/platform/networking/node/chatStream.ts index 385ba7bf53c535..d01e07ee3a0e92 100644 --- a/extensions/copilot/src/platform/networking/node/chatStream.ts +++ b/extensions/copilot/src/platform/networking/node/chatStream.ts @@ -315,6 +315,7 @@ function sendIndividualMessagesTelemetry(telemetryService: ITelemetryService, me // Convert message to JSON string for chunking const messageJsonString = JSON.stringify(message); + const maxChunkSize = 8000; // Split messageJson into chunks of 8000 characters or less @@ -391,6 +392,7 @@ function sendModelCallTelemetry(telemetryService: ITelemetryService, messageData // Send one telemetry event per chunk for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { const parentToolCallId = telemetryData.properties.parentToolCallId; + const parentHeaderRequestId = telemetryData.properties.parentHeaderRequestId; const modelCallData = TelemetryData.createAndMarkAsIssued({ modelCallId, conversationId, // Trajectory identifier linking main and supplementary calls @@ -404,6 +406,7 @@ function sendModelCallTelemetry(telemetryService: ITelemetryService, messageData ...(requestOptionsId && { requestOptionsId }), // Add requestOptionsId for input calls ...(telemetryData.properties.turnIndex && { turnIndex: telemetryData.properties.turnIndex }), // Add turnIndex from original telemetryData ...(parentToolCallId && { parentToolCallId }), // Link subagent calls to parent tool invocation + ...(parentHeaderRequestId && { parentHeaderRequestId }), // Link subagent calls to parent HTTP request }, telemetryData.measurements); // Include measurements from original telemetryData telemetryService.sendInternalMSFTTelemetryEvent(eventName, modelCallData.properties, modelCallData.measurements); @@ -447,6 +450,7 @@ export function sendEngineMessagesTelemetry(telemetryService: ITelemetryService, const telemetryDataWithPrompt = telemetryData.extendedBy({ messagesJson: JSON.stringify(messages), }); + telemetryService.sendEnhancedGHTelemetryEvent('engine.messages', multiplexProperties(telemetryDataWithPrompt.properties), telemetryDataWithPrompt.measurements); // Commenting this out to test a new deduplicated way to collect the same information using sendModelTelemetryEvents() // TO DO remove this line completely if the new way allows for complete reconstruction of entire message arrays with much lower drop rate @@ -537,7 +541,13 @@ export function prepareChatCompletionForReturn( telemetryDataWithUsage = telemetryData.extendedBy({}, { promptTokens: c.usage.prompt_tokens, completionTokens: c.usage.completion_tokens, - totalTokens: c.usage.total_tokens + totalTokens: c.usage.total_tokens, + ...(c.usage.prompt_tokens_details && { cachedTokens: c.usage.prompt_tokens_details.cached_tokens }), + ...(c.usage.completion_tokens_details && { + reasoningTokens: c.usage.completion_tokens_details.reasoning_tokens, + acceptedPredictionTokens: c.usage.completion_tokens_details.accepted_prediction_tokens, + rejectedPredictionTokens: c.usage.completion_tokens_details.rejected_prediction_tokens, + }), }); } diff --git a/extensions/copilot/src/platform/otel/common/otelService.ts b/extensions/copilot/src/platform/otel/common/otelService.ts index 1216fb305f3d8e..34c35537706259 100644 --- a/extensions/copilot/src/platform/otel/common/otelService.ts +++ b/extensions/copilot/src/platform/otel/common/otelService.ts @@ -15,6 +15,14 @@ export const IOTelService = createServiceIdentifier('IOTelService' export interface TraceContext { readonly traceId: string; readonly spanId: string; + /** + * W3C trace flags from the source span context (e.g. `0x01` for sampled). Optional + * because not all impls preserve it; consumers that build a W3C `traceparent` should + * fall back to a sampled value when unset. + */ + readonly traceFlags?: number; + /** W3C tracestate serialized as a comma-separated key=value list, when present. */ + readonly traceState?: string; } /** diff --git a/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts b/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts index 226f59c8ea7791..8d2bda154ff577 100644 --- a/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts +++ b/extensions/copilot/src/platform/otel/node/otelServiceImpl.ts @@ -344,7 +344,7 @@ export class NodeOTelService implements IOTelService { if (!ctx.traceId || !ctx.spanId) { return undefined; } - return { traceId: ctx.traceId, spanId: ctx.spanId }; + return { traceId: ctx.traceId, spanId: ctx.spanId, traceFlags: ctx.traceFlags, traceState: ctx.traceState?.serialize() }; } // ── Trace Context Store ── (for cross-boundary propagation) @@ -620,7 +620,9 @@ class RealSpanHandle implements ISpanHandle { getSpanContext(): TraceContext | undefined { const ctx = this._span.spanContext(); - return ctx.traceId && ctx.spanId ? { traceId: ctx.traceId, spanId: ctx.spanId } : undefined; + return ctx.traceId && ctx.spanId + ? { traceId: ctx.traceId, spanId: ctx.spanId, traceFlags: ctx.traceFlags, traceState: ctx.traceState?.serialize() } + : undefined; } end(): void { diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts index 7ef1bfef61f4bd..8d0acd345b8b15 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; -import { IObservable, ITransaction, ObservablePromise, ObservableResolvedPromise, constObservable, derived, derivedObservableWithWritableCache, mapObservableArrayCached, observableFromValueWithChangeEvent, observableValue, transaction } from '../../../../base/common/observable.js'; +import { IObservable, ITransaction, ObservablePromise, ObservableResolvedPromise, constObservable, derived, derivedObservableWithWritableCache, mapObservableArrayCached, observableFromValueWithChangeEvent, observableValue, transaction, waitForState } from '../../../../base/common/observable.js'; import { timeout } from '../../../../base/common/async.js'; import { URI } from '../../../../base/common/uri.js'; import { ContextKeyValue } from '../../../../platform/contextkey/common/contextkey.js'; @@ -28,6 +28,7 @@ export class MultiDiffEditorViewModel extends Disposable { }); public readonly isLoading; + private readonly _waitForNewDiffs: IObservable[]>>; public readonly items: IObservable; @@ -36,10 +37,12 @@ export class MultiDiffEditorViewModel extends Disposable { (reader, lastValue) => this.focusedDiffItem.read(reader) ?? (lastValue && this.items.read(reader).indexOf(lastValue) !== -1) ? lastValue : undefined ); - public async waitForDiffs(): Promise { - for (const d of this.items.get()) { - await d.diffEditorViewModel.waitForDiff(); + public async waitForDiffOr1s(): Promise { + if (this._documents.get() === 'loading') { + await waitForState(this._documents, documents => documents !== 'loading'); } + + await this._waitForNewDiffs.get().promise; } public collapseAll(): void { @@ -75,7 +78,7 @@ export class MultiDiffEditorViewModel extends Disposable { (d, store) => store.add(RefCounted.create(this._instantiationService.createInstance(DocumentDiffItemViewModel, d, this))) ).recomputeInitiallyAndOnChange(this._store); - const waitForNewDiffs: IObservable[]>> = derived(this, reader => { + this._waitForNewDiffs = derived(this, reader => { const next = allItems.read(reader); const unresolved = next.filter(i => !i.object.waitForInitialDiffOr1s.promiseResult.read(undefined)); if (unresolved.length === 0) { @@ -86,7 +89,7 @@ export class MultiDiffEditorViewModel extends Disposable { ); }); - const resolved = new ObservableResolvedPromise(waitForNewDiffs, [] as readonly RefCounted[], this._store); + const resolved = new ObservableResolvedPromise(this._waitForNewDiffs, [] as readonly RefCounted[], this._store); this.items = derived(this, reader => { const resolvedItems = resolved.lastResolved.read(reader); diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index e55d2c8572bd80..d13343ab852a28 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -124,6 +124,18 @@ export class AgentSideEffects extends Disposable { const agents = this._options.agents.read(reader); this._publishAgentInfos(agents, reader); })); + + // Server-dispatched SessionToolCallComplete actions (e.g. from + // the disconnect timeout in ProtocolServerHandler) bypass + // handleAction, so the agent's SDK deferred never resolves. + // Listen for these envelopes and notify the agent directly. + this._register(this._stateManager.onDidEmitEnvelope(envelope => { + if (!envelope.origin && envelope.action.type === ActionType.SessionToolCallComplete) { + const action = envelope.action; + const agent = this._options.getAgent(action.session); + agent?.onClientToolCallComplete(URI.parse(action.session), action.toolCallId, action.result); + } + })); } /** @@ -622,7 +634,7 @@ export class AgentSideEffects extends Disposable { if (autoApproval !== undefined) { this._toolCallAgents.delete(`${sessionKey}:${e.toolCallId}`); agent.respondToPermissionRequest(e.toolCallId, true); - return; + e = { ...e, confirmationTitle: undefined }; // don't trigger confirmation } this._stateManager.dispatchServerAction( this._permissionManager.createToolReadyAction(e, sessionKey, turnId) diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 11302a1dd2fbcc..88c588345d9115 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -279,7 +279,7 @@ export class CopilotAgent extends Disposable implements IAgent { private async _ensureClient(): Promise { const tokenAtStartup = this._githubToken; if (!tokenAtStartup) { - throw new ProtocolError(AHP_AUTH_REQUIRED, 'Authentication is required to use Copilot'); + throw new ProtocolError(AHP_AUTH_REQUIRED, 'Authentication is required to use Copilot', this.getProtectedResources()); } if (this._client) { return this._client; diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 8c6f6aa3374243..87b21404fb0219 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -31,13 +31,15 @@ import { type ReconnectParams, type IStateSnapshot, } from '../common/state/sessionProtocol.js'; -import { ROOT_STATE_URI, SessionStatus } from '../common/state/sessionState.js'; +import { ResponsePartKind, ROOT_STATE_URI, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionState } from '../common/state/sessionState.js'; import type { IProtocolServer, IProtocolTransport } from '../common/state/sessionTransport.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; /** Default capacity of the server-side action replay buffer. */ const REPLAY_BUFFER_CAPACITY = 1000; +const CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT = 30_000; + /** Build a JSON-RPC success response suitable for transport.send(). */ function jsonRpcSuccess(id: number, result: unknown): JsonRpcResponse { return { jsonrpc: '2.0', id, result }; @@ -100,6 +102,7 @@ export class ProtocolServerHandler extends Disposable { private readonly _clients = new Map(); private readonly _replayBuffer: ActionEnvelope[] = []; + private readonly _clientToolCallDisconnectTimeouts = new Map>(); private readonly _onDidChangeConnectionCount = this._register(new Emitter()); @@ -206,6 +209,7 @@ export class ProtocolServerHandler extends Disposable { this._logService.info(`[ProtocolServer] Client disconnected: ${client.clientId}, subscriptions=${client.subscriptions.size}`); this._clients.delete(client.clientId); this._rejectPendingReverseRequests(client.clientId); + this._handleClientDisconnected(client.clientId); this._onDidChangeConnectionCount.fire(this._clients.size); } disposables.dispose(); @@ -256,6 +260,7 @@ export class ProtocolServerHandler extends Disposable { if (snapshot) { snapshots.push(snapshot); client.subscriptions.add(uri.toString()); + this._clearClientToolCallDisconnectTimeout(params.clientId, uri.toString()); } } } @@ -295,6 +300,7 @@ export class ProtocolServerHandler extends Disposable { const actions: ActionEnvelope[] = []; for (const sub of params.subscriptions) { client.subscriptions.add(sub.toString()); + this._clearClientToolCallDisconnectTimeout(params.clientId, sub.toString()); } for (const envelope of this._replayBuffer) { if (envelope.serverSeq > params.lastSeenServerSeq) { @@ -311,12 +317,108 @@ export class ProtocolServerHandler extends Disposable { if (snapshot) { snapshots.push(snapshot); client.subscriptions.add(sub); + this._clearClientToolCallDisconnectTimeout(params.clientId, sub); } } return { client, response: { type: 'snapshot', snapshots } }; } } + private _handleClientDisconnected(clientId: string): void { + for (const session of this._stateManager.getSessionUris()) { + const state = this._stateManager.getSessionState(session); + const ownsPendingToolCall = state ? this._hasPendingClientToolCall(state, clientId) : false; + if (state?.activeClient?.clientId === clientId) { + this._stateManager.dispatchServerAction({ + type: ActionType.SessionActiveClientChanged, + session, + activeClient: null, + }); + } + if (state?.activeClient?.clientId === clientId || ownsPendingToolCall) { + this._startClientToolCallDisconnectTimeout(clientId, session); + } + } + } + + private _hasPendingClientToolCall(state: ReturnType, clientId: string): boolean { + const activeTurn = state?.activeTurn; + if (!activeTurn) { + return false; + } + return activeTurn.responseParts.some(part => part.kind === ResponsePartKind.ToolCall + && part.toolCall.toolClientId === clientId + && (part.toolCall.status === ToolCallStatus.Streaming || part.toolCall.status === ToolCallStatus.Running || part.toolCall.status === ToolCallStatus.PendingConfirmation)); + } + + private _hasReplacementActiveClientTool(state: SessionState, clientId: string, toolName: string): boolean { + const activeClient = state.activeClient; + return activeClient !== undefined + && activeClient.clientId !== clientId + && activeClient.tools.some(tool => tool.name === toolName); + } + + private _startClientToolCallDisconnectTimeout(clientId: string, session: string): void { + this._clearClientToolCallDisconnectTimeout(clientId, session); + const key = this._clientToolCallDisconnectTimeoutKey(clientId, session); + this._clientToolCallDisconnectTimeouts.set(key, setTimeout(() => { + this._clientToolCallDisconnectTimeouts.delete(key); + this._completeDisconnectedClientToolCalls(clientId, session); + }, CLIENT_TOOL_CALL_DISCONNECT_TIMEOUT)); + } + + private _clearClientToolCallDisconnectTimeout(clientId: string, session: string): void { + const key = this._clientToolCallDisconnectTimeoutKey(clientId, session); + const timeout = this._clientToolCallDisconnectTimeouts.get(key); + if (timeout) { + clearTimeout(timeout); + this._clientToolCallDisconnectTimeouts.delete(key); + } + } + + private _clientToolCallDisconnectTimeoutKey(clientId: string, session: string): string { + return `${clientId}\n${session}`; + } + + private _completeDisconnectedClientToolCalls(clientId: string, session: string): void { + const state = this._stateManager.getSessionState(session); + const activeTurn = state?.activeTurn; + if (!activeTurn) { + return; + } + for (const part of activeTurn.responseParts) { + if (part.kind !== ResponsePartKind.ToolCall) { + continue; + } + const toolCall = part.toolCall; + if (toolCall.toolClientId === clientId && (toolCall.status === ToolCallStatus.Streaming || toolCall.status === ToolCallStatus.Running || toolCall.status === ToolCallStatus.PendingConfirmation)) { + const mayRetryWithReplacementClient = this._hasReplacementActiveClientTool(state, clientId, toolCall.toolName); + if (toolCall.status === ToolCallStatus.Streaming) { + this._stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallReady, + session, + turnId: activeTurn.id, + toolCallId: toolCall.toolCallId, + invocationMessage: toolCall.invocationMessage ?? toolCall.displayName, + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + } + this._stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallComplete, + session, + turnId: activeTurn.id, + toolCallId: toolCall.toolCallId, + result: { + success: false, + pastTenseMessage: `${toolCall.displayName} failed`, + ...(mayRetryWithReplacementClient ? { content: [{ type: ToolResultContentType.Text, text: `The client that was running ${toolCall.displayName} disconnected, but another active client now provides ${toolCall.displayName}. You may try calling the tool again.` }] } : {}), + error: { message: `Client ${clientId} disconnected before completing ${toolCall.displayName}` }, + }, + }); + } + } + } + // ---- Requests (expect a response) --------------------------------------- /** @@ -328,6 +430,7 @@ export class ProtocolServerHandler extends Disposable { try { const snapshot = await this._agentService.subscribe(URI.parse(params.resource)); client.subscriptions.add(params.resource); + this._clearClientToolCallDisconnectTimeout(client.clientId, params.resource); return { snapshot }; } catch (err) { if (err instanceof ProtocolError) { @@ -606,6 +709,10 @@ export class ProtocolServerHandler extends Disposable { pending.reject(new Error('ProtocolServerHandler disposed')); } this._pendingReverseRequests.clear(); + for (const timeout of this._clientToolCallDisconnectTimeouts.values()) { + clearTimeout(timeout); + } + this._clientToolCallDisconnectTimeouts.clear(); this._replayBuffer.length = 0; super.dispose(); } diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 5c45cf83f80b15..217fa25a240851 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -961,6 +961,82 @@ suite('AgentSideEffects', () => { }); }); + // ---- tool_ready progress dispatch ----------------------------------- + + suite('tool_ready dispatches progress actions to advance tool call state', () => { + + test('tool_ready for a non-permission tool dispatches SessionToolCallReady and advances state from Streaming to Running', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + // tool_start puts the tool call into Streaming state + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'tc-ready-1', + toolName: 'runTask', + displayName: 'Run Task', + invocationMessage: 'Running task...', + toolClientId: 'test-client', + }); + + const stateAfterStart = stateManager.getSessionState(sessionUri.toString()); + const partAfterStart = stateAfterStart?.activeTurn?.responseParts[0]; + assert.strictEqual(partAfterStart?.kind, ResponsePartKind.ToolCall); + assert.strictEqual(partAfterStart?.kind === ResponsePartKind.ToolCall ? partAfterStart.toolCall.status : undefined, ToolCallStatus.Streaming); + + // tool_ready without confirmationTitle should dispatch the ready + // action and advance the tool call to Running + agent.fireProgress({ + session: sessionUri, + type: 'tool_ready', + toolCallId: 'tc-ready-1', + invocationMessage: 'Run Task', + toolInput: '{"task":"build"}', + }); + + const stateAfterReady = stateManager.getSessionState(sessionUri.toString()); + const partAfterReady = stateAfterReady?.activeTurn?.responseParts[0]; + assert.strictEqual(partAfterReady?.kind, ResponsePartKind.ToolCall); + assert.strictEqual(partAfterReady?.kind === ResponsePartKind.ToolCall ? partAfterReady.toolCall.status : undefined, ToolCallStatus.Running, + 'tool call should advance from Streaming to Running after tool_ready'); + }); + + test('tool_ready for a permission-gated tool dispatches SessionToolCallReady and advances state to PendingConfirmation', () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + session: sessionUri, + type: 'tool_start', + toolCallId: 'tc-perm-1', + toolName: 'write', + displayName: 'Write File', + invocationMessage: 'Writing file...', + toolClientId: 'test-client', + }); + + // tool_ready with confirmationTitle should dispatch the ready + // action and advance the tool call to PendingConfirmation + agent.fireProgress({ + session: sessionUri, + type: 'tool_ready', + toolCallId: 'tc-perm-1', + invocationMessage: 'Write .env', + confirmationTitle: 'Write .env', + toolInput: '{"path":".env"}', + }); + + const state = stateManager.getSessionState(sessionUri.toString()); + const part = state?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.PendingConfirmation, + 'tool call should advance to PendingConfirmation for permission-gated tool_ready'); + }); + }); + // ---- Session-level auto-approve (config) ---------------------------- suite('session config auto-approve', () => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 3583f69d1ab000..948533c528e334 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -83,12 +83,12 @@ class CapturingLogService extends NullLogService { * {@link ToolResultObject} — which is what {@link CopilotAgentSession}'s * handler implementation actually returns. */ -function invokeClientToolHandler(tool: Pick, toolCallId: string): Promise { - return Promise.resolve(tool.handler({}, { +function invokeClientToolHandler(tool: Pick, toolCallId: string, args: Record = {}): Promise { + return Promise.resolve(tool.handler(args, { sessionId: 'test-session-1', toolCallId, toolName: tool.name, - arguments: {}, + arguments: args, })) as Promise; } @@ -940,7 +940,7 @@ suite('CopilotAgentSession', () => { plugins: [], }; - test('tool_start fires immediately for client tools', async () => { + test('client tool handler waits for completion without emitting tool_ready', async () => { const { session, mockSession, progressEvents } = await createAgentSession(disposables, { clientSnapshot: snapshot }); // SDK emits tool.execution_start — tool_start fires immediately @@ -956,9 +956,13 @@ suite('CopilotAgentSession', () => { assert.strictEqual(progressEvents[0].toolClientId, 'test-client'); } - // SDK invokes the handler + // SDK invokes the handler — it creates a deferred and waits, + // but does NOT fire tool_ready (that comes from the permission flow). const tools = session.createClientSdkTools(); - const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-1'); + const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-1', { file: 'test.ts' }); + + // No tool_ready should have been emitted by the handler + assert.strictEqual(progressEvents.filter(e => e.type === 'tool_ready').length, 0); // Complete the tool call session.handleClientToolCallComplete('tc-client-1', { @@ -972,7 +976,7 @@ suite('CopilotAgentSession', () => { assert.strictEqual(result.textResultForLlm, 'result text'); }); - test('permission request consumes pending auto-ready for client tools', async () => { + test('client tool handler does not emit tool_ready (permission flow owns it)', async () => { const { session, mockSession, progressEvents, waitForProgress } = await createAgentSession(disposables, { clientSnapshot: snapshot }); // SDK emits tool.execution_start — tool_start fires immediately @@ -986,8 +990,7 @@ suite('CopilotAgentSession', () => { assert.strictEqual(progressEvents.filter(e => e.type === 'tool_start').length, 1); assert.strictEqual(progressEvents.filter(e => e.type === 'tool_ready').length, 0); - // Permission request fires — tool_ready from permission flow - // (with confirmationTitle) replaces the auto-ready + // Permission request fires — tool_ready from permission flow. const resultPromise = session.handlePermissionRequest({ kind: 'custom-tool', toolCallId: 'tc-client-perm', @@ -996,17 +999,29 @@ suite('CopilotAgentSession', () => { // tool_ready from permission flow should have fired (with confirmationTitle) await waitForProgress(e => e.type === 'tool_ready'); - const toolReadys = progressEvents.filter(e => e.type === 'tool_ready'); - assert.strictEqual(toolReadys.length, 1); - if (toolReadys[0].type === 'tool_ready') { - assert.strictEqual(toolReadys[0].toolCallId, 'tc-client-perm'); - assert.ok(toolReadys[0].confirmationTitle); + const permissionReady = progressEvents.filter(e => e.type === 'tool_ready'); + assert.strictEqual(permissionReady.length, 1); + if (permissionReady[0].type === 'tool_ready') { + assert.strictEqual(permissionReady[0].toolCallId, 'tc-client-perm'); + assert.ok(permissionReady[0].confirmationTitle); } + const tools = session.createClientSdkTools(); + const handlerPromise = invokeClientToolHandler(tools[0], 'tc-client-perm'); + + // The handler should NOT emit its own tool_ready — only the + // permission flow fires tool_ready for client tools. + assert.strictEqual(progressEvents.filter(e => e.type === 'tool_ready').length, 1, 'handler should not emit a second tool_ready'); + // Approve and clean up session.respondToPermissionRequest('tc-client-perm', true); const permResult = await resultPromise; assert.strictEqual(permResult.kind, 'approved'); + session.handleClientToolCallComplete('tc-client-perm', { + success: true, + pastTenseMessage: 'did it', + }); + await handlerPromise; }); test('handleClientToolCallComplete pre-completes when no handler is waiting yet', async () => { @@ -1120,7 +1135,7 @@ suite('CopilotAgentSession', () => { assert.strictEqual(entry.args[0], '[Copilot:test-session-1] Failed in client tool handler: tool=my_tool, toolCallId=tc-client-error'); }); - test('tool_start stores pending auto-ready data for client tools', async () => { + test('permission request before client tool handler emits only confirmation ready', async () => { const { session, mockSession, progressEvents, waitForProgress } = await createAgentSession(disposables, { clientSnapshot: snapshot }); mockSession.fire('tool.execution_start', { @@ -1132,11 +1147,8 @@ suite('CopilotAgentSession', () => { // tool_start should have fired assert.strictEqual(progressEvents.filter(e => e.type === 'tool_start').length, 1); - // The session should have stored pending auto-ready data. - // We verify this indirectly: if we now fire a permission request - // for the same toolCallId, the pending auto-ready is consumed - // (tested by the permission request test above), and we get - // tool_ready with confirmationTitle instead. + // Permission before the handler should produce only the confirmation + // tool_ready, not a synthetic auto-ready. const resultPromise = session.handlePermissionRequest({ kind: 'custom-tool', toolCallId: 'tc-ready-data', diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index da9ff4ad66cb3f..420c89725f01c2 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -464,8 +464,10 @@ export class ScriptedMockAgent implements IAgent { } case 'client-tool': { - // Fires tool_start with toolClientId to simulate a client-provided tool. - // The server waits for the client to dispatch toolCallComplete. + // Fires tool_start with toolClientId followed by tool_ready + // (without confirmationTitle) to simulate a client-provided tool + // that is ready for execution. The real SDK handler fires + // tool_ready once its deferred is in place. (async () => { await timeout(10); this._onDidSessionProgress.fire({ @@ -477,6 +479,14 @@ export class ScriptedMockAgent implements IAgent { invocationMessage: 'Running tests...', toolClientId: 'test-client-tool', }); + await timeout(5); + this._onDidSessionProgress.fire({ + type: 'tool_ready', + session, + toolCallId: 'tc-client-1', + invocationMessage: 'Running tests...', + toolInput: '{}', + }); })(); // The tool stays pending — the client is responsible for dispatching toolCallComplete. // Once complete, fire a response delta and idle. @@ -627,7 +637,14 @@ export class ScriptedMockAgent implements IAgent { setClientTools(): void { } + private didCompleteToolCalls = new Set(); + onClientToolCallComplete(session: URI, toolCallId: string, result: ToolCallResult): void { + const key = `${session.toString()}:${toolCallId}`; + if (this.didCompleteToolCalls.has(key)) { + return; + } + this.didCompleteToolCalls.add(key); // Fire tool_complete and resolve any pending callback. this._onDidSessionProgress.fire({ type: 'tool_complete', diff --git a/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts index 08a424aba79259..2849e35f563cc1 100644 --- a/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/clientTools.integrationTest.ts @@ -54,16 +54,17 @@ suite('Protocol WebSocket — Client Tools', function () { // ---- Client tool: tool_start with toolClientId -------------------------- - test('client tool_start emits only toolCallStart (no auto-ready)', async function () { + test('client tool_start emits toolCallStart then toolCallReady (auto-confirmed)', async function () { this.timeout(10_000); const sessionUri = await createAndSubscribeSession(client, 'test-client-tool'); dispatchTurnStarted(client, sessionUri, 'turn-ct', 'client-tool', 1); // Wait for toolCallStart - const toolStartNotif = await client.waitForNotification( - n => isActionNotification(n, 'session/toolCallStart'), - ); + const [toolStartNotif, toolReadyNotif] = await Promise.all([ + client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart')), + client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady')), + ]); const toolStartAction = getActionEnvelope(toolStartNotif).action as { toolCallId: string; toolClientId?: string; @@ -71,12 +72,12 @@ suite('Protocol WebSocket — Client Tools', function () { assert.strictEqual(toolStartAction.toolCallId, 'tc-client-1'); assert.strictEqual(toolStartAction.toolClientId, 'test-client-tool'); - // Verify that no auto-ready was emitted alongside the toolCallStart. - // The client tool flow should NOT fire an immediate toolCallReady. - const autoReadyNotifs = client.receivedNotifications( - n => isActionNotification(n, 'session/toolCallReady'), - ); - assert.strictEqual(autoReadyNotifs.length, 0, 'should not have auto-ready for client tools'); + const toolReadyAction = getActionEnvelope(toolReadyNotif).action as { + toolCallId: string; + confirmed?: string; + }; + assert.strictEqual(toolReadyAction.toolCallId, 'tc-client-1'); + assert.strictEqual(toolReadyAction.confirmed, 'not-needed'); // Complete the client tool call client.notify('dispatchAction', { diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 67ec63395164a5..b0807afae93ea1 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; +import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agentService.js'; @@ -14,7 +15,7 @@ import { ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, Ses import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; -import { SessionStatus, type SessionSummary } from '../../common/state/sessionState.js'; +import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, type SessionSummary } from '../../common/state/sessionState.js'; import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { ProtocolServerHandler } from '../../node/protocolServerHandler.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; @@ -504,6 +505,300 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(transport.sent.length, 0); }); + test('client disconnect clears active client and fails owned tool calls after grace period', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + stateManager.dispatchServerAction({ + type: ActionType.SessionActiveClientChanged, + session: sessionUri, + activeClient: { + clientId: 'client-tools', + tools: [{ name: 'runTask', description: 'Runs a task' }], + }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'run it' }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallStart, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + toolName: 'runTask', + displayName: 'Run Task', + toolClientId: 'client-tools', + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallReady, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + invocationMessage: 'Run Task', + toolInput: '{}', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + const transport = connectClient('client-tools', [sessionUri]); + transport.simulateClose(); + + assert.strictEqual(stateManager.getSessionState(sessionUri)?.activeClient, undefined); + let part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.Running); + + await new Promise(r => setTimeout(r, 30_001)); + + part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? { + status: part.toolCall.status, + success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined, + error: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.error?.message : undefined, + } : undefined, { + status: ToolCallStatus.Completed, + success: false, + error: 'Client client-tools disconnected before completing Run Task', + }); + }); + }); + + test('client disconnect fails owned streaming tool calls after grace period', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + stateManager.dispatchServerAction({ + type: ActionType.SessionActiveClientChanged, + session: sessionUri, + activeClient: { + clientId: 'client-tools', + tools: [{ name: 'runTask', description: 'Runs a task' }], + }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'run it' }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallStart, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + toolName: 'runTask', + displayName: 'Run Task', + toolClientId: 'client-tools', + }); + + const transport = connectClient('client-tools', [sessionUri]); + transport.simulateClose(); + + let part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.Streaming); + + await new Promise(r => setTimeout(r, 30_001)); + + part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? { + status: part.toolCall.status, + success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined, + error: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.error?.message : undefined, + } : undefined, { + status: ToolCallStatus.Completed, + success: false, + error: 'Client client-tools disconnected before completing Run Task', + }); + }); + }); + + test('client reconnect without session subscription does not clear tool call disconnect timeout', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + stateManager.dispatchServerAction({ + type: ActionType.SessionActiveClientChanged, + session: sessionUri, + activeClient: { + clientId: 'client-tools', + tools: [{ name: 'runTask', description: 'Runs a task' }], + }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'run it' }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallStart, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + toolName: 'runTask', + displayName: 'Run Task', + toolClientId: 'client-tools', + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallReady, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + invocationMessage: 'Run Task', + toolInput: '{}', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + const transport = connectClient('client-tools', [sessionUri]); + transport.simulateClose(); + + const reconnectTransport = new MockProtocolTransport(); + server.simulateConnection(reconnectTransport); + reconnectTransport.simulateMessage(request(1, 'reconnect', { + clientId: 'client-tools', + lastSeenServerSeq: stateManager.serverSeq, + subscriptions: [], + })); + + await new Promise(r => setTimeout(r, 30_001)); + + const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall ? { + status: part.toolCall.status, + success: part.toolCall.status === ToolCallStatus.Completed ? part.toolCall.success : undefined, + } : undefined, { + status: ToolCallStatus.Completed, + success: false, + }); + }); + }); + + test('client reconnect with session subscription clears tool call disconnect timeout for that session', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + stateManager.dispatchServerAction({ + type: ActionType.SessionActiveClientChanged, + session: sessionUri, + activeClient: { + clientId: 'client-tools', + tools: [{ name: 'runTask', description: 'Runs a task' }], + }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'run it' }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallStart, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + toolName: 'runTask', + displayName: 'Run Task', + toolClientId: 'client-tools', + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallReady, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + invocationMessage: 'Run Task', + toolInput: '{}', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + const transport = connectClient('client-tools', [sessionUri]); + transport.simulateClose(); + + const reconnectTransport = new MockProtocolTransport(); + server.simulateConnection(reconnectTransport); + reconnectTransport.simulateMessage(request(1, 'reconnect', { + clientId: 'client-tools', + lastSeenServerSeq: stateManager.serverSeq, + subscriptions: [sessionUri], + })); + + await new Promise(r => setTimeout(r, 30_001)); + + const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + assert.strictEqual(part?.kind === ResponsePartKind.ToolCall ? part.toolCall.status : undefined, ToolCallStatus.Running); + }); + }); + + test('client tool timeout tells model it may retry when replacement active client provides the tool', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + stateManager.createSession(makeSessionSummary()); + stateManager.dispatchServerAction({ type: ActionType.SessionReady, session: sessionUri }); + stateManager.dispatchServerAction({ + type: ActionType.SessionActiveClientChanged, + session: sessionUri, + activeClient: { + clientId: 'client-tools', + tools: [{ name: 'runTask', description: 'Runs a task' }], + }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionTurnStarted, + session: sessionUri, + turnId: 'turn-1', + userMessage: { text: 'run it' }, + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallStart, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + toolName: 'runTask', + displayName: 'Run Task', + toolClientId: 'client-tools', + }); + stateManager.dispatchServerAction({ + type: ActionType.SessionToolCallReady, + session: sessionUri, + turnId: 'turn-1', + toolCallId: 'tool-1', + invocationMessage: 'Run Task', + toolInput: '{}', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + const transport = connectClient('client-tools', [sessionUri]); + transport.simulateClose(); + stateManager.dispatchServerAction({ + type: ActionType.SessionActiveClientChanged, + session: sessionUri, + activeClient: { + clientId: 'client-replacement', + tools: [{ name: 'runTask', description: 'Runs a task' }], + }, + }); + + await new Promise(r => setTimeout(r, 30_001)); + + const part = stateManager.getSessionState(sessionUri)?.activeTurn?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + assert.deepStrictEqual(part?.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.Completed ? { + status: part.toolCall.status, + success: part.toolCall.success, + content: part.toolCall.content, + } : undefined, { + status: ToolCallStatus.Completed, + success: false, + content: [{ type: ToolResultContentType.Text, text: 'The client that was running Run Task disconnected, but another active client now provides Run Task. You may try calling the tool again.' }], + }); + }); + }); + test('handshake includes defaultDirectory from side effects', () => { const transport = connectClient('client-home'); diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 1dddca7ec3411f..7adb9988775c16 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -150,8 +150,8 @@ .agent-sessions-workbench .part.editor:not(.modal-editor-part) { margin: 0 0 0 0; - background: var(--vscode-editor-background); - border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border, transparent)); + background: var(--vscode-agentsPanel-background); + border: 1px solid var(--vscode-agentsPanel-border, transparent); border-right-width: 0; border-top-right-radius: 0; border-bottom-right-radius: 0; @@ -269,6 +269,31 @@ --tab-border-top-color: transparent !important; } +/* Allow tabs to shrink in narrow viewports so the close button stays reachable. + * The default `sizing-fit` rule sets `min-width: fit-content; flex-shrink: 0;` + * which prevents the tab from shrinking below its label width and pushes the + * close button out of view. */ +.agent-sessions-workbench .part.editor .tabs-container > .tab.sizing-fit { + min-width: 0 !important; + flex-shrink: 1 !important; +} + +.agent-sessions-workbench .part.editor .tabs-container > .tab.sizing-fit .monaco-icon-label, +.agent-sessions-workbench .part.editor .tabs-container > .tab.sizing-fit .monaco-icon-label > .monaco-icon-label-container { + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +/* Keep the close button reserved within the tab so it remains accessible when + * the tab shrinks. Without this the action sits in an `overflow: hidden` + * container that only reveals on hover, which combined with the smaller tab + * makes the close target hard to hit. */ +.agent-sessions-workbench .part.editor .tabs-container > .tab > .tab-actions { + flex: 0 0 auto; + overflow: visible; +} + .agent-sessions-workbench .part.editor .tabs-and-actions-container { --tabs-border-bottom-color: transparent !important; align-items: center; diff --git a/src/vs/sessions/browser/parts/media/editorPart.css b/src/vs/sessions/browser/parts/media/editorPart.css index feb1ff1b7cf2f8..978d095f7c0616 100644 --- a/src/vs/sessions/browser/parts/media/editorPart.css +++ b/src/vs/sessions/browser/parts/media/editorPart.css @@ -40,7 +40,7 @@ display: block; cursor: default; flex: initial; - padding: 0 8px 0 4px; + padding: 0 0 0 4px; height: var(--editor-group-tab-height); } diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index e6236ef89b20f2..5fc35915aaeb4d 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -214,6 +214,10 @@ font-size: 13px; } + .session-title { + color: var(--vscode-strongForeground); + } + .session-time { display: flex; align-items: center; @@ -371,11 +375,11 @@ .monaco-list-row:not(.selected) .session-item.in-progress .session-title { background: linear-gradient( 90deg, - var(--vscode-foreground) 0%, - var(--vscode-foreground) 30%, + var(--vscode-strongForeground) 0%, + var(--vscode-strongForeground) 30%, var(--vscode-chat-thinkingShimmer) 50%, - var(--vscode-foreground) 70%, - var(--vscode-foreground) 100% + var(--vscode-strongForeground) 70%, + var(--vscode-strongForeground) 100% ); background-size: 400% 100%; background-clip: text; @@ -384,14 +388,26 @@ animation: session-title-shimmer 3s linear infinite; } +.vs-dark .monaco-list-row:not(.selected) .session-item.in-progress .session-title, +.hc-black .monaco-list-row:not(.selected) .session-item.in-progress .session-title { + background-image: linear-gradient( + 90deg, + var(--vscode-strongForeground) 0%, + var(--vscode-strongForeground) 30%, + var(--vscode-descriptionForeground) 50%, + var(--vscode-strongForeground) 70%, + var(--vscode-strongForeground) 100% + ); +} + @media (prefers-reduced-motion: reduce) { .monaco-list-row:not(.selected) .session-item.in-progress .session-title { animation: none; background: none; background-clip: border-box; -webkit-background-clip: border-box; - color: var(--vscode-foreground); - -webkit-text-fill-color: var(--vscode-foreground); + color: var(--vscode-strongForeground); + -webkit-text-fill-color: var(--vscode-strongForeground); } } diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index f10129cfb766fb..7c224277f99fc3 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -130,6 +130,8 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape subAgentInvocationId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.subAgentInvocationId : undefined, chatStreamToolCallId: isProposedApiEnabled(extension, 'chatParticipantAdditions') ? options.chatStreamToolCallId : undefined, preToolUseResult: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.preToolUseResult : undefined, + traceparent: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.traceparent : undefined, + tracestate: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.tracestate : undefined, }, token); const dto: Dto = result instanceof SerializableObjectWithBuffers ? result.value : result; @@ -191,6 +193,8 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape options.chatInteractionId = dto.chatInteractionId; options.chatSessionResource = URI.revive(dto.context?.sessionResource); options.subAgentInvocationId = dto.subAgentInvocationId; + options.traceparent = dto.traceparent; + options.tracestate = dto.tracestate; } if (isProposedApiEnabled(item.extension, 'chatParticipantAdditions') && dto.modelId) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 8f207dac6fb81e..d0bc3400246830 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -2138,27 +2138,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const throttler = new Throttler(); reconnectDisposables.add(throttler); - // Wire up awaitConfirmation for tool calls that were already pending - // confirmation at snapshot time so the user can approve/deny them. - // Also start observing any subagent tools that were already running. const cts = new CancellationTokenSource(); reconnectDisposables.add(toDisposable(() => cts.dispose(true))); - for (const [toolCallId, invocation] of activeToolInvocations) { - if (!IChatToolInvocation.isComplete(invocation)) { - // Look up the tool call state to forward protocol options on reconnection - const tcState = currentState?.activeTurn?.responseParts.find( - rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === toolCallId - ); - const tcOptions = tcState?.kind === ResponsePartKind.ToolCall && tcState.toolCall.status === ToolCallStatus.PendingConfirmation - ? tcState.toolCall.options - : undefined; - this._awaitToolConfirmation(invocation, toolCallId, backendSession, turnId, cts.token, tcOptions); - } - if (invocation.toolSpecificData?.kind === 'subagent' && !observedSubagentToolIds.has(toolCallId)) { - observedSubagentToolIds.add(toolCallId); - this._observeSubagentSession(backendSession, toolCallId, (parts) => chatSession.appendProgress(parts), reconnectDisposables, observedSubagentToolIds); - } - } // Track live input request carousels for reconnection const activeInputRequests = new Map(); @@ -2177,6 +2158,38 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC progress: parts => chatSession.appendProgress(parts), cancellationToken: cts.token, }; + + // Wire up tool calls from the initial progress snapshot. + // Client-owned tool calls are re-created through the client tool + // path so _tryInvokeClientTool can execute them. Server tool calls + // get confirmation wiring and subagent observation as before. + for (const [toolCallId, invocation] of activeToolInvocations) { + const tcState = currentState?.activeTurn?.responseParts.find( + rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === toolCallId + ); + const tc = tcState?.kind === ResponsePartKind.ToolCall ? tcState.toolCall : undefined; + + if (tc && tc.toolClientId === this._config.connection.clientId && !IChatToolInvocation.isComplete(invocation)) { + // Complete the snapshot invocation from activeTurnToProgress + // so it does not remain orphaned in the UI — the replacement + // created by _beginClientToolInvocation takes over. + invocation.didExecuteTool(undefined); + this._beginClientToolInvocation(tc, ctx); + this._tryInvokeClientTool(tc, ctx); + continue; + } + + if (!IChatToolInvocation.isComplete(invocation)) { + const tcOptions = tc?.status === ToolCallStatus.PendingConfirmation + ? tc.options + : undefined; + this._awaitToolConfirmation(invocation, toolCallId, backendSession, turnId, cts.token, tcOptions); + } + if (invocation.toolSpecificData?.kind === 'subagent' && !observedSubagentToolIds.has(toolCallId)) { + observedSubagentToolIds.add(toolCallId); + this._observeSubagentSession(backendSession, toolCallId, (parts) => chatSession.appendProgress(parts), reconnectDisposables, observedSubagentToolIds); + } + } const processStateChange = (sessionState: SessionState) => { const isActive = this._processSessionState(sessionState, ctx); this._syncInputRequests(activeInputRequests, sessionState.inputRequests, backendSession, chatSession.sessionResource, cts.token, appendProgress); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 96f457f58f8f73..614dd708f9a8b4 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -1043,6 +1043,18 @@ export class AICustomizationManagementEditor extends EditorPane { this.pluginListWidget?.showBrowseMarketplace(); } } + + // Move focus to the search input so keyboard users can immediately + // filter without extra Tab traversal (parity with mouse-click flow). + if (section === AICustomizationManagementSection.McpServers) { + this.mcpListWidget?.focusSearch(); + } else if (section === AICustomizationManagementSection.Plugins) { + this.pluginListWidget?.focusSearch(); + } else if (section === AICustomizationManagementSection.Models) { + this.modelsWidget?.focusSearch(); + } else { + this.listWidget?.focusSearch(); + } } private ensureSectionsListReflectsActiveSection(section: AICustomizationManagementSection | undefined = this.selectedSection): void { diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 0b32734757b427..56116f2db87fed 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -197,6 +197,14 @@ export interface IToolInvocation { selectedCustomButton?: string; /** Pre-tool-use hook result passed from the extension, if the hook was already executed externally. */ preToolUseResult?: IExternalPreToolUseHookResult; + /** + * Optional W3C trace context `traceparent` value identifying the parent distributed + * tracing span for this tool invocation. Forwarded to MCP tool implementations as + * `_meta.traceparent` (MCP SEP-414). + */ + traceparent?: string; + /** Optional W3C trace context `tracestate` value paired with {@link traceparent}. */ + tracestate?: string; } export interface IToolInvocationContext { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts index 6e3463a7e64090..6b0b5e5b60a105 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostClientTools.test.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { timeout } from '../../../../../../base/common/async.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; @@ -13,15 +15,17 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentSession, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import { isSessionAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type INotification } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionLifecycle, SessionStatus, createSessionState, StateComponents, type SessionState, type SessionSummary, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; -import { ToolResultContentType } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; +import { ToolCallConfirmationReason, ToolResultContentType } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { IChatAgentService } from '../../../common/participants/chatAgents.js'; -import { IChatService } from '../../../common/chatService/chatService.js'; +import { IChatProgress, IChatService, IChatToolInvocation, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; @@ -36,7 +40,7 @@ import { IAgentSubscription } from '../../../../../../platform/agentHost/common/ import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; -import { ILanguageModelToolsService, IToolData, IToolResult, ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IToolData, IToolInvocation, IToolResult, ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { ICustomizationHarnessService } from '../../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; @@ -234,6 +238,9 @@ suite('AgentHostClientTools', () => { function createMockToolsService(disposables: DisposableStore, tools: IToolData[]) { const onDidChangeTools = disposables.add(new Emitter()); + const pendingToolCalls = new Map(); + const begunToolCalls: ChatToolInvocation[] = []; + const invokedToolCalls: IToolInvocation[] = []; return { onDidChangeTools: onDidChangeTools.event, getToolByName: (name: string) => tools.find(t => t.toolReferenceName === name), @@ -243,9 +250,30 @@ suite('AgentHostClientTools', () => { registerTool: () => toDisposable(() => { }), getTools: () => tools, getAllToolsIncludingDisabled: () => tools, - getTool: () => undefined, - invokeTool: async () => ({ content: [] }), - beginToolCall: () => undefined, + getTool: (id: string) => tools.find(t => t.id === id), + invokeTool: async (invocation: IToolInvocation) => { + invokedToolCalls.push(invocation); + const toolInvocation = pendingToolCalls.get(invocation.chatStreamToolCallId ?? invocation.callId); + pendingToolCalls.delete(invocation.chatStreamToolCallId ?? invocation.callId); + toolInvocation?.transitionFromStreaming(undefined, invocation.parameters, { type: ToolConfirmKind.ConfirmationNotNeeded }); + const result: IToolResult = { content: [{ kind: 'text', value: 'done' }] }; + await toolInvocation?.didExecuteTool(result); + return result; + }, + beginToolCall: options => { + const toolData = tools.find(t => t.id === options.toolId); + if (!toolData) { + return undefined; + } + const invocation = ChatToolInvocation.createStreaming({ + toolCallId: options.toolCallId, + toolId: options.toolId, + toolData, + }); + pendingToolCalls.set(options.toolCallId, invocation); + begunToolCalls.push(invocation); + return invocation; + }, updateToolStream: async () => { }, cancelToolCallsForRequest: () => { }, flushToolUpdates: () => { }, @@ -269,7 +297,9 @@ suite('AgentHostClientTools', () => { onDidInvokeTool: Event.None, _serviceBrand: undefined, fireOnDidChangeTools: () => onDidChangeTools.fire(), - } satisfies ILanguageModelToolsService & { fireOnDidChangeTools: () => void }; + begunToolCalls, + invokedToolCalls, + } satisfies ILanguageModelToolsService & { fireOnDidChangeTools: () => void; begunToolCalls: ChatToolInvocation[]; invokedToolCalls: IToolInvocation[] }; } class MockAgentHostConnection extends mock() { @@ -287,22 +317,17 @@ suite('AgentHostClientTools', () => { override dispatch(action: SessionAction | TerminalAction | IRootConfigChangedAction): void { this.dispatchedActions.push(action); - if (isSessionAction(action) && action.type === 'session/activeClientChanged') { - const entry = this._liveSubscriptions.get(action.session); - if (entry) { - entry.state = sessionReducer(entry.state, action as Parameters[1], () => { }); - entry.emitter.fire(entry.state); - } - } - if (isSessionAction(action) && action.type === 'session/activeClientToolsChanged') { - const entry = this._liveSubscriptions.get(action.session); - if (entry) { - entry.state = sessionReducer(entry.state, action as Parameters[1], () => { }); - entry.emitter.fire(entry.state); - } + if (isSessionAction(action)) { + this.applySessionAction(action); } } + applySessionAction(action: SessionAction): void { + const entry = this._ensureLiveSubscription(action.session); + entry.state = sessionReducer(entry.state, action as Parameters[1], () => { }); + entry.emitter.fire(entry.state); + } + override readonly rootState: IAgentSubscription = { value: undefined, verifiedValue: undefined, @@ -313,18 +338,9 @@ suite('AgentHostClientTools', () => { override getSubscription(_kind: StateComponents, resource: URI): IReference> { const resourceStr = resource.toString(); - const emitter = disposables.add(new Emitter()); - const summary: SessionSummary = { - resource: resourceStr, - provider: 'copilot', - title: 'Test', - status: SessionStatus.Idle, - createdAt: Date.now(), - modifiedAt: Date.now(), - }; - const initialState: SessionState = { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready }; - const entry = { state: initialState, emitter: emitter as unknown as Emitter }; - this._liveSubscriptions.set(resourceStr, entry); + this._ensureLiveSubscription(resourceStr); + const entry = this._liveSubscriptions.get(resourceStr)!; + const emitter = entry.emitter as unknown as Emitter; const self = this; const sub: IAgentSubscription = { @@ -341,6 +357,26 @@ suite('AgentHostClientTools', () => { }, }; } + + private _ensureLiveSubscription(resourceStr: string): { state: SessionState; emitter: Emitter } { + let entry = this._liveSubscriptions.get(resourceStr); + if (entry) { + return entry; + } + const emitter = disposables.add(new Emitter()); + const summary: SessionSummary = { + resource: resourceStr, + provider: 'copilot', + title: 'Test', + status: SessionStatus.Idle, + createdAt: Date.now(), + modifiedAt: Date.now(), + }; + const initialState: SessionState = { ...createSessionState(summary), lifecycle: SessionLifecycle.Ready }; + entry = { state: initialState, emitter }; + this._liveSubscriptions.set(resourceStr, entry); + return entry; + } } function createHandlerWithMocks( @@ -515,5 +551,106 @@ suite('AgentHostClientTools', () => { const def = toolDataToDefinition(testRunTestsTool); assert.strictEqual(def.name, 'runTests'); }); + + test('invokes an owned client tool when reconnecting to an active turn', async () => { + const { handler, connection, toolsService } = createHandlerWithMocks(disposables, [testRunTaskTool]); + const sessionResource = URI.parse('agent-host-copilot:/session-1'); + const backendSession = AgentSession.uri('copilot', 'session-1').toString(); + + connection.applySessionAction({ + type: ActionType.SessionTurnStarted, + session: backendSession, + turnId: 'turn-1', + userMessage: { text: 'run the task' }, + } as SessionAction); + connection.applySessionAction({ + type: ActionType.SessionToolCallStart, + session: backendSession, + turnId: 'turn-1', + toolCallId: 'tool-call-1', + toolName: 'runTask', + displayName: 'Run Task', + toolClientId: connection.clientId, + } as SessionAction); + connection.applySessionAction({ + type: ActionType.SessionToolCallReady, + session: backendSession, + turnId: 'turn-1', + toolCallId: 'tool-call-1', + invocationMessage: 'Run Task', + toolInput: '{"task":"build"}', + confirmed: ToolCallConfirmationReason.NotNeeded, + } as SessionAction); + + await handler.provideChatSessionContent(sessionResource, CancellationToken.None); + await timeout(0); + await timeout(0); + + assert.deepStrictEqual(toolsService.invokedToolCalls.map(call => ({ + callId: call.callId, + toolId: call.toolId, + parameters: call.parameters, + chatStreamToolCallId: call.chatStreamToolCallId, + })), [{ + callId: 'tool-call-1', + toolId: 'vscode.runTask', + parameters: { task: 'build' }, + chatStreamToolCallId: 'tool-call-1', + }]); + assert.ok(connection.dispatchedActions.some(action => isSessionAction(action) + && action.type === ActionType.SessionToolCallComplete + && action.toolCallId === 'tool-call-1')); + }); + + test('reconnecting to an active turn with owned client tool completes the initial snapshot invocation', async () => { + const { handler, connection } = createHandlerWithMocks(disposables, [testRunTaskTool]); + const sessionResource = URI.parse('agent-host-copilot:/session-1'); + const backendSession = AgentSession.uri('copilot', 'session-1').toString(); + + connection.applySessionAction({ + type: ActionType.SessionTurnStarted, + session: backendSession, + turnId: 'turn-1', + userMessage: { text: 'run the task' }, + } as SessionAction); + connection.applySessionAction({ + type: ActionType.SessionToolCallStart, + session: backendSession, + turnId: 'turn-1', + toolCallId: 'tool-call-1', + toolName: 'runTask', + displayName: 'Run Task', + toolClientId: connection.clientId, + } as SessionAction); + connection.applySessionAction({ + type: ActionType.SessionToolCallReady, + session: backendSession, + turnId: 'turn-1', + toolCallId: 'tool-call-1', + invocationMessage: 'Run Task', + toolInput: '{"task":"build"}', + confirmed: ToolCallConfirmationReason.NotNeeded, + } as SessionAction); + + const session = await handler.provideChatSessionContent(sessionResource, CancellationToken.None); + + // activeTurnToProgress creates a generic ChatToolInvocation for + // the running client tool which appears in the session's progress + // observable. Grab it before _reconnectToActiveTurn replaces it. + const snapshotInvocation = (session as unknown as { progressObs: { get(): IChatProgress[] } }) + .progressObs.get() + .find((p): p is ChatToolInvocation => p instanceof ChatToolInvocation && p.toolCallId === 'tool-call-1'); + assert.ok(snapshotInvocation, 'activeTurnToProgress should have created a snapshot invocation'); + + await timeout(0); + await timeout(0); + + // The snapshot invocation from activeTurnToProgress should have + // been completed (via didExecuteTool) so it does not remain + // orphaned in the UI while the replacement from + // _beginClientToolInvocation takes over. + assert.ok(IChatToolInvocation.isComplete(snapshotInvocation), + 'the initial snapshot invocation should be completed, not orphaned'); + }); }); }); diff --git a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts index 28400d12acfd66..4bb200467794ae 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts @@ -266,7 +266,12 @@ class McpToolImplementation implements IToolImpl { content: [] }; - const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, { chatRequestId: invocation.chatRequestId, chatSessionResource: invocation.context?.sessionResource }, token); + const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, { + chatRequestId: invocation.chatRequestId, + chatSessionResource: invocation.context?.sessionResource, + traceparent: invocation.traceparent, + tracestate: invocation.tracestate, + }, token); const details: Mutable = { input: JSON.stringify(invocation.parameters, undefined, 2), output: [], diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index 9f93c186e584e9..93edbedde183c0 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -1199,6 +1199,14 @@ export class McpTool implements IMcpTool { if (context?.chatRequestId) { meta['vscode.requestId'] = context.chatRequestId; } + // Propagate W3C trace context to the MCP server (MCP SEP-414) so server-side + // spans can be correlated with the client trace. + if (context?.traceparent) { + meta['traceparent'] = context.traceparent; + if (context.tracestate) { + meta['tracestate'] = context.tracestate; + } + } const taskHint = this._definition.execution?.taskSupport; const serverSupportsTasksForTools = h.capabilities.tasks?.requests?.tools?.call !== undefined; diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index f4fa7a32d96734..bd491bb6647e15 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -453,6 +453,13 @@ export interface IMcpPromptMessage extends MCP.PromptMessage { } export interface IMcpToolCallContext { chatSessionResource: URI | undefined; chatRequestId?: string; + /** + * Optional W3C trace context `traceparent` value to forward to the MCP server + * via `_meta.traceparent` on the JSON-RPC `tools/call` request (MCP SEP-414). + */ + traceparent?: string; + /** Optional W3C trace context `tracestate` value paired with {@link traceparent}. */ + tracestate?: string; } /** diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts index c74df032d4164a..97852b9e0b22e4 100644 --- a/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts +++ b/src/vs/workbench/contrib/mcp/test/common/mcpServerRequestHandler.test.ts @@ -381,6 +381,34 @@ suite('Workbench - MCP - ServerRequestHandler', () => { assert.strictEqual(e.name, 'Canceled'); } }); + + test('callTool forwards _meta.traceparent to the JSON-RPC payload (MCP SEP-414)', async () => { + const traceparent = '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'; + const tracestate = 'rojo=00f067aa0ba902b7'; + + const callPromise = handler.callTool({ + name: 'echo', + arguments: { hello: 'world' }, + _meta: { traceparent, tracestate, progressToken: 'tok-1' }, + }); + + const sentMessages = transport.getSentMessages(); + const callRequest = sentMessages[2] as MCP.JSONRPCRequest & MCP.CallToolRequest; + assert.strictEqual(callRequest.method, 'tools/call'); + assert.deepStrictEqual(callRequest.params._meta, { + traceparent, + tracestate, + progressToken: 'tok-1', + }); + + transport.simulateReceiveMessage({ + jsonrpc: MCP.JSONRPC_VERSION, + id: callRequest.id, + result: { content: [] }, + }); + + await callPromise; + }); }); suite.skip('Workbench - MCP - McpTask', () => { // TODO@connor4312 https://github.com/microsoft/vscode/issues/280126 diff --git a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.ts b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.ts index 9c3b0f6083cc8c..7cb8aff0c8419e 100644 --- a/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.ts +++ b/src/vs/workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.ts @@ -98,7 +98,7 @@ export class MultiDiffEditorInput extends EditorInput implements ILanguageSuppor this._register(model); const vm = new MultiDiffEditorViewModel(model, this._instantiationService); this._register(vm); - await raceTimeout(vm.waitForDiffs(), 1000); + await raceTimeout(vm.waitForDiffOr1s(), 1000); return vm; }); this._resolvedSource = new ObservableLazyPromise(async () => { @@ -276,7 +276,7 @@ export class MultiDiffEditorInput extends EditorInput implements ILanguageSuppor return this; } - override revert(group: GroupIdentifier, options?: IRevertOptions): Promise { + override revert(group: GroupIdentifier, options?: IRevertOptions): Promise { return this.doSaveOrRevert('revert', group, options); } diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts index b22dea113a28d7..a07d465f6843aa 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/inlineChatZoneWidget.fixture.ts @@ -25,6 +25,7 @@ import { IDecorationsService } from '../../../../services/decorations/common/dec import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { IChatInputNotificationService } from '../../../../contrib/chat/browser/widget/input/chatInputNotificationService.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { IChatWidgetService, IChatAccessibilityService } from '../../../../contrib/chat/browser/chat.js'; import { IChatContextPickService } from '../../../../contrib/chat/browser/attachments/chatContextPickService.js'; @@ -63,6 +64,14 @@ import { IMarkdownRendererService, MarkdownRendererService } from '../../../../. import { observableValue } from '../../../../../base/common/observable.js'; import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; import { InlineChatZoneWidget } from '../../../../contrib/inlineChat/browser/inlineChatZoneWidget.js'; +import { ChatModel } from '../../../../contrib/chat/common/model/chatModel.js'; +import { IChatEditingService } from '../../../../contrib/chat/common/editing/chatEditingService.js'; +import { Target } from '../../../../contrib/chat/common/promptSyntax/promptTypes.js'; +import { ICustomizationHarnessService } from '../../../../contrib/chat/common/customizationHarnessService.js'; + +// Side-effect import: registers InputEditorDecorations into ChatWidget.CONTRIBS +// so the placeholder decoration is rendered. +import '../../../../contrib/chat/browser/widget/input/editor/chatInputEditorContrib.js'; // CSS imports import '../../../../contrib/inlineChat/browser/media/inlineChat.css'; @@ -203,9 +212,11 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme }: Compo }()); reg.defineInstance(IChatTipService, new class extends mock() { readonly onDidReceiveTip = Event.None; + override resetSession() { } }()); reg.defineInstance(IChatDebugService, new class extends mock() { override readonly onDidAddEvent = Event.None; + override getEvents() { return []; } }()); reg.defineInstance(IChatEntitlementService, new class extends mock() { override readonly sentimentObs = observableValue('sentiment', { completed: true }); @@ -222,10 +233,26 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme }: Compo override readonly onDidChangeSessionOptions = Event.None; override readonly onDidChangeOptionGroups = Event.None; override readonly onDidChangeAvailability = Event.None; + override readonly onDidChangeCustomizations = Event.None; + override readonly onDidChangeContentProviderSchemes = Event.None; + override readonly onDidChangeItemsProviders = Event.None; + override readonly onDidChangeSessionItems = Event.None; + override readonly onDidCommitSession = Event.None; + override readonly onDidChangeInProgress = Event.None; + override sessionSupportsFork() { return false; } + override supportsDelegationForSessionType() { return false; } + override getOptionGroupsForSessionType() { return undefined; } + override getCustomAgentTargetForSessionType() { return Target.Undefined; } + override requiresCustomModelsForSessionType() { return false; } + override getChatSessionContribution() { return undefined; } + override getCapabilitiesForSessionType() { return undefined; } + override getSessionOptions() { return undefined; } + override hasCustomizationsProvider() { return false; } }()); reg.defineInstance(ILanguageModelsService, new class extends mock() { override readonly onDidChangeLanguageModels = Event.None; override getLanguageModelIds() { return []; } + override getVendors() { return []; } }()); reg.defineInstance(ILanguageModelToolsService, new class extends mock() { override readonly onDidChangeTools = Event.None; @@ -263,6 +290,17 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme }: Compo override getHistory() { return []; } override readonly onDidChangeHistory = Event.None; }()); + reg.defineInstance(IChatEditingService, new class extends mock() { + override editingSessionsObs = observableValue('editingSessionsObs', []); + }()); + reg.defineInstance(IChatInputNotificationService, new class extends mock() { + override readonly onDidChange = Event.None; + override getActiveNotification() { return undefined; } + }()); + reg.defineInstance(ICustomizationHarnessService, new class extends mock() { + override readonly onDidChangeSlashCommands = Event.None; + override readonly onDidChangeCustomAgents = Event.None; + }()); reg.defineInstance(IChatContextPickService, new class extends mock() { }()); reg.defineInstance(IDecorationsService, new class extends mock() { override readonly onDidChangeDecorations = Event.None; }()); reg.defineInstance(ITextFileService, new class extends mock() { override readonly untitled = new class extends mock() { override readonly onDidChangeLabel = Event.None; }(); }()); @@ -362,6 +400,10 @@ function renderInlineChatZoneWidget({ container, disposableStore, theme }: Compo zoneWidget.show(new Position(10, 1)); + const dummyModel = instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.EditorInline, canUseTools: false }); + zoneWidget.widget.chatWidget.setModel(dummyModel); + zoneWidget.widget.chatWidget.setInputPlaceholder('Ask Copilot...'); + // Force a relayout after the initial show so that the chat widget's // contentHeight (which includes the toolbar row rendered below the input) // is fully measured and the zone widget adjusts its height accordingly. diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 2276e22cba05c1..51bdf465a0da61 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -303,6 +303,17 @@ declare module 'vscode' { * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. */ subAgentInvocationId?: string; + /** + * W3C trace context `traceparent` header value identifying the active distributed + * tracing span. When provided to a tool implementation backed by an MCP server, this + * value is forwarded as `_meta.traceparent` on the JSON-RPC `tools/call` request so + * downstream servers can correlate their spans (MCP SEP-414). + */ + traceparent?: string; + /** + * Optional W3C trace context `tracestate` header value paired with `traceparent`. + */ + tracestate?: string; /** * Pre-tool-use hook result, if the hook was already executed by the caller. * When provided, the tools service will skip executing its own preToolUse hook diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 7da2bff277f15a..22d7ab9005134f 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -7,10 +7,10 @@ ![screenshot](https://hediet-screenshots.azurewebsites.net/images/42624fbba5e0db7f32c224b5eb9c5dd3b08245697ae2e7d2a88be0d7c287129b) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/7d1a6d2346754115e77fc2b0b09a0e6fb6fd9fe22acbff6354813eefb8b45fc2) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/041fd8cf01bf03e44367c80186743d6b4eae3aa7b38a6a1551e8a0168cb8f8f7) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/11dbc075c584b7dde0f08314e98db121e420529ced6249effb941cfe2ae3164b) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/64135435974ab9866b41b95e4fd1a7b2fb87b5b4c551617c361ca0d36eb75569) #### editor/inlineChatZoneWidget/InlineChatZoneWidgetTerminated/Dark ![screenshot](https://hediet-screenshots.azurewebsites.net/images/2fbc12507b59ff950d9612d2df92e6b39d8bf0bf500478e42eca2ead4d1ae206)