From d2e03e4d21f0ca7ce2abbd523c4701a2df6b59fb Mon Sep 17 00:00:00 2001 From: John Vicondoa Date: Thu, 16 Jul 2026 16:52:35 -0700 Subject: [PATCH 1/7] d2b: migrate seam to canonical client targets ( W9 ) Pin the canonical v2 client and contract source, and store configured workload identity as TargetInput. Remove the v1 compatibility transport and session UI rather than guessing the blocked service APIs. ADR: 0010, d2b/0045 --- CHANGELOG.md | 13 + Cargo.lock | 484 ++- Cargo.toml | 4 +- README.md | 10 +- config/Cargo.toml | 3 +- config/src/d2b.rs | 173 +- config/src/keyassignment.rs | 6 - ...010-provider-neutral-d2b-target-domains.md | 8 +- docs/adr/README.md | 2 +- docs/d2b-provider.md | 157 +- mux/Cargo.toml | 7 - mux/src/d2b.rs | 2999 ----------------- mux/src/lib.rs | 1 - mux/src/pane.rs | 14 - wezterm-gui/src/commands.rs | 19 - wezterm-gui/src/main.rs | 84 +- wezterm-gui/src/overlay/d2b_launcher.rs | 846 ----- wezterm-gui/src/overlay/mod.rs | 2 - wezterm-gui/src/termwindow/mod.rs | 297 +- wezterm-mux-server-impl/src/lib.rs | 30 +- 20 files changed, 601 insertions(+), 4558 deletions(-) delete mode 100644 mux/src/d2b.rs delete mode 100644 wezterm-gui/src/overlay/d2b_launcher.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 39061950f2f..ee34d88d78e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Migrated d2b configuration to the canonical v2 `TargetInput` workload type + and pinned `d2b-client` plus `d2b-contracts` to d2b revision + `9183b45c6505cfd496e5d537bf6376f884fb16c7`. + +### Removed + +- Removed the legacy public-socket hello, JSON shell DTO, seqpacket framing, + target-alias, session-picker, and terminal-bridge integration. Native d2b + discovery and persistent-shell attachment remain unavailable until their + owning v2 service contracts are finalized. + ## [0.7.2] - 2026-07-13 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 21f01af449a..5a1fd36c532 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,41 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -344,6 +379,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "async-task" version = "4.7.1" @@ -548,6 +605,15 @@ dependencies = [ "no_std_io2", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block" version = "0.1.6" @@ -727,6 +793,30 @@ dependencies = [ "libc", ] +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.42" @@ -767,6 +857,17 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.5.51" @@ -981,7 +1082,8 @@ dependencies = [ "anyhow", "bitflags 1.3.2", "colorgrad", - "d2b-toolkit-core", + "d2b-client", + "d2b-contracts", "dirs-next", "enum-display-derive", "env_logger 0.11.8", @@ -991,7 +1093,7 @@ dependencies = [ "log", "luahelper", "mlua", - "nix", + "nix 0.29.0", "notify", "ordered-float 4.6.0", "portable-pty", @@ -1234,34 +1336,84 @@ dependencies = [ "phf", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "d2b-client" -version = "0.2.0" -source = "git+https://github.com/vicondoa/d2b-toolkit?tag=v0.2.0#fde6af8b842718e7150f5056d4eba73093d4ad77" +version = "2.0.0" +source = "git+https://github.com/vicondoa/d2b?rev=9183b45c6505cfd496e5d537bf6376f884fb16c7#9183b45c6505cfd496e5d537bf6376f884fb16c7" dependencies = [ - "base64 0.22.1", - "d2b-toolkit-core", - "futures", - "serde", - "serde_json", - "thiserror 1.0.69", + "async-trait", + "d2b-contracts", + "d2b-session", + "protobuf", + "tokio", + "ttrpc", ] [[package]] -name = "d2b-toolkit-core" -version = "0.2.0" -source = "git+https://github.com/vicondoa/d2b-toolkit?tag=v0.2.0#fde6af8b842718e7150f5056d4eba73093d4ad77" +name = "d2b-contracts" +version = "2.0.0" +source = "git+https://github.com/vicondoa/d2b?rev=9183b45c6505cfd496e5d537bf6376f884fb16c7#9183b45c6505cfd496e5d537bf6376f884fb16c7" dependencies = [ + "async-trait", + "protobuf", + "schemars", "serde", - "serde_json", "sha2", - "thiserror 1.0.69", + "ttrpc", +] + +[[package]] +name = "d2b-session" +version = "2.0.0" +source = "git+https://github.com/vicondoa/d2b?rev=9183b45c6505cfd496e5d537bf6376f884fb16c7#9183b45c6505cfd496e5d537bf6376f884fb16c7" +dependencies = [ + "async-trait", + "d2b-contracts", + "protobuf", + "sha2", + "snow", + "tokio", + "ttrpc", ] [[package]] @@ -1397,6 +1549,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1497,6 +1650,12 @@ dependencies = [ "wio", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" version = "1.15.0" @@ -1797,6 +1956,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -2165,6 +2330,16 @@ dependencies = [ "wasip2", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gif" version = "0.14.2" @@ -2216,7 +2391,7 @@ dependencies = [ "backtrace", "fnv", "gl_generator", - "memoffset", + "memoffset 0.9.1", "smallvec", ] @@ -2487,6 +2662,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "hostname" version = "0.4.1" @@ -2880,6 +3064,15 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instability" version = "0.3.12" @@ -2910,7 +3103,7 @@ version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86" dependencies = [ - "memoffset", + "memoffset 0.9.1", ] [[package]] @@ -3398,7 +3591,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3a8e7962a5368d5f264d045a5a255e90f9aa3fc1941ae15a8d2940d42cac671" dependencies = [ "cc", - "which", + "which 7.0.3", ] [[package]] @@ -3465,6 +3658,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -3580,8 +3782,6 @@ name = "mux" version = "0.1.0" dependencies = [ "anyhow", - "async-channel", - "async-io", "async-trait", "base64 0.22.1", "bintree", @@ -3590,12 +3790,10 @@ dependencies = [ "config", "crossbeam", "d2b-client", - "d2b-toolkit-core", "downcast-rs", "fancy-regex", "filedescriptor", "finl_unicode", - "futures", "hostname", "k9", "lazy_static", @@ -3605,7 +3803,7 @@ dependencies = [ "metrics", "mlua", "names", - "nix", + "nix 0.29.0", "ntapi", "parking_lot", "percent-encoding", @@ -3620,7 +3818,6 @@ dependencies = [ "sha2", "shell-words", "smol", - "socket2 0.5.10", "terminfo", "termwiz", "termwiz-funcs", @@ -3729,6 +3926,19 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset 0.7.1", + "pin-utils", +] + [[package]] name = "nix" version = "0.29.0" @@ -3739,7 +3949,20 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", - "memoffset", + "memoffset 0.9.1", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset 0.9.1", ] [[package]] @@ -4072,6 +4295,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.80" @@ -4439,6 +4668,29 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -4466,7 +4718,7 @@ dependencies = [ "lazy_static", "libc", "log", - "nix", + "nix 0.29.0", "serde", "serial2", "shared_library", @@ -4610,6 +4862,57 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "protobuf" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65a1d4ddae7d8b5de68153b48f6aa3bba8cb002b243dbdbc55a5afbc98f99f4" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-codegen" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3976825c0014bbd2f3b34f0001876604fe87e0c86cd8fa54251530f1544ace" +dependencies = [ + "anyhow", + "once_cell", + "protobuf", + "protobuf-parse", + "regex", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "protobuf-parse" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4aeaa1f2460f1d348eeaeed86aea999ce98c1bded6f089ff8514c9d9dbdc973" +dependencies = [ + "anyhow", + "indexmap 2.12.0", + "log", + "protobuf", + "protobuf-support", + "tempfile", + "thiserror 1.0.69", + "which 4.4.2", +] + +[[package]] +name = "protobuf-support" +version = "3.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e36c2f31e0a47f9280fb347ef5e461ffcd2c52dd520d8e216b52f93b0b0d7d6" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "pure-rust-locales" version = "0.8.2" @@ -5227,6 +5530,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.110", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -5321,6 +5648,17 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.110", +] + [[package]] name = "serde_json" version = "1.0.145" @@ -5581,6 +5919,23 @@ dependencies = [ "futures-lite", ] +[[package]] +name = "snow" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "599b506ccc4aff8cf7844bc42cf783009a434c1e26c964432560fb6d6ad02d82" +dependencies = [ + "aes-gcm", + "blake2", + "chacha20poly1305", + "curve25519-dalek", + "getrandom 0.3.4", + "ring", + "rustc_version", + "sha2", + "subtle", +] + [[package]] name = "socket2" version = "0.5.10" @@ -5694,7 +6049,7 @@ dependencies = [ "lazycell", "libc", "mach2", - "nix", + "nix 0.29.0", "num-traits", "plist", "uom", @@ -5944,7 +6299,7 @@ dependencies = [ "libc", "log", "memmem", - "nix", + "nix 0.29.0", "num-derive", "num-traits", "ordered-float 4.6.0", @@ -6225,6 +6580,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-vsock" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b319ef9394889dab2e1b4f0085b45ba11d0c79dc9d1a9d1afc057d009d0f1c7" +dependencies = [ + "bytes", + "futures", + "libc", + "tokio", + "vsock", +] + [[package]] name = "toml" version = "0.5.11" @@ -6387,6 +6755,28 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttrpc" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f12efe237328bbe3b3b50503627f7cc9e74cfae34ffa8438561916623cd8f50" +dependencies = [ + "async-stream", + "async-trait", + "byteorder", + "crossbeam", + "futures", + "libc", + "log", + "nix 0.26.4", + "protobuf", + "protobuf-codegen", + "thiserror 1.0.69", + "tokio", + "tokio-vsock", + "windows-sys 0.48.0", +] + [[package]] name = "typeid" version = "1.0.3" @@ -6411,7 +6801,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ - "memoffset", + "memoffset 0.9.1", "tempfile", "winapi", ] @@ -6474,6 +6864,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -6579,6 +6979,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsock" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2da6e4ac76cd19635dce0f98985378bb62f8044ee2ff80abd2a7334b920ed63" +dependencies = [ + "libc", + "nix 0.30.1", +] + [[package]] name = "vswhom" version = "0.1.0" @@ -7028,7 +7438,7 @@ dependencies = [ "image", "k9", "log", - "nix", + "nix 0.29.0", "num-derive", "num-traits", "ordered-float 4.6.0", @@ -7547,6 +7957,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "which" version = "7.0.3" @@ -8354,7 +8776,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix", + "nix 0.29.0", "ordered-stream", "rand 0.8.6", "serde", diff --git a/Cargo.toml b/Cargo.toml index 2690a3837bd..976ef4e1c01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -293,8 +293,8 @@ zbus = "4.2" zstd = "0.11" zvariant = "4.0" # --- weezterm remote features --- -d2b-client = { version = "=0.2.0", git = "https://github.com/vicondoa/d2b-toolkit", tag = "v0.2.0" } -d2b-toolkit-core = { version = "=0.2.0", git = "https://github.com/vicondoa/d2b-toolkit", tag = "v0.2.0" } +d2b-client = { git = "https://github.com/vicondoa/d2b", rev = "9183b45c6505cfd496e5d537bf6376f884fb16c7", default-features = false } +d2b-contracts = { git = "https://github.com/vicondoa/d2b", rev = "9183b45c6505cfd496e5d537bf6376f884fb16c7", default-features = false, features = ["v2-services"] } # --- end weezterm remote features --- [patch.crates-io] diff --git a/README.md b/README.md index 24722e7d53f..ff6b43eadfa 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,13 @@ WeezTerm extends WezTerm with VS Code Remote SSH-style features: - **Config overlay** — A built-in TUI (`Ctrl+Shift+,`) for browsing and editing ~80 config settings, managing SSH domains, DevContainer domains, and per-monitor overrides - **Window state persistence** — Window position, size, and maximized/fullscreen state saved and restored across restarts, with correct multi-monitor support - **DevContainer domains** — First-class Docker devcontainer support with auto-discovery, a manager overlay (`Ctrl+Shift+D`), and full SSH domain integration -- **Native d2b provider** — Linux persistent shell panes for canonical d2b - workload targets through one provider-neutral public-socket transport, with - explicit unsafe-local/no-isolation posture. +- **d2b client seam** — Canonical v2 workload target configuration backed by + d2b's exact pinned client and identity types. Runtime session integration + remains disabled until the owning d2b service contracts are finalized. See [docs/remote-extensions.md](docs/remote-extensions.md) for detailed documentation of all features and configuration options. -See [docs/d2b-provider.md](docs/d2b-provider.md) for d2b provider packaging, -runtime, and security details. +See [docs/d2b-provider.md](docs/d2b-provider.md) for the d2b source pin, +configuration contract, and current runtime boundary. ## Credits diff --git a/config/Cargo.toml b/config/Cargo.toml index 5abf8e20fd9..305f61565f2 100644 --- a/config/Cargo.toml +++ b/config/Cargo.toml @@ -43,7 +43,8 @@ wezterm-input-types.workspace = true wezterm-ssh.workspace = true wezterm-term = { workspace = true, features=["use_serde"] } # --- weezterm remote features --- -d2b-toolkit-core.workspace = true +d2b-client.workspace = true +d2b-contracts.workspace = true # --- end weezterm remote features --- [target."cfg(unix)".dependencies] diff --git a/config/src/d2b.rs b/config/src/d2b.rs index bf237531838..792f6e799ac 100644 --- a/config/src/d2b.rs +++ b/config/src/d2b.rs @@ -1,183 +1,158 @@ use crate::config::validate_domain_name; -use d2b_toolkit_core::WorkloadTarget; +use d2b_client::TargetInput; +use d2b_contracts::v2_identity::{RealmId, WorkloadId}; use std::convert::TryFrom; -use std::path::PathBuf; use wezterm_dynamic::{FromDynamic, ToDynamic}; -// --- weezterm remote features --- #[derive(Debug, Clone, PartialEq, Eq, FromDynamic, ToDynamic)] #[dynamic(try_from = "RawD2bDomainConfig", into = "SerializedD2bDomainConfig")] pub struct D2bDomainConfig { - /// Unique WeezTerm domain name for this d2b target. + /// Unique WeezTerm domain name reserved for this d2b workload. #[dynamic(validate = "validate_domain_name")] pub name: String, - /// Canonical d2b workload target, or a legacy VM name during migration. - pub target: String, + target: TargetInput, +} + +impl D2bDomainConfig { + pub fn target(&self) -> &TargetInput { + &self.target + } - /// Optional override for the d2b public daemon socket. - #[dynamic(default)] - pub socket_path: Option, + pub fn workload_ids(&self) -> (&RealmId, &WorkloadId) { + match &self.target { + TargetInput::Workload { realm, workload } => (realm, workload), + _ => unreachable!("D2bDomainConfig accepts only workload targets"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, FromDynamic, ToDynamic)] +struct WorkloadTargetConfig { + realm_id: String, + workload_id: String, } #[derive(Debug, Clone, PartialEq, Eq, FromDynamic)] struct RawD2bDomainConfig { #[dynamic(validate = "validate_domain_name")] name: String, - target: Option, - vm: Option, - socket_path: Option, + target: WorkloadTargetConfig, } #[derive(ToDynamic)] struct SerializedD2bDomainConfig { name: String, - target: String, - socket_path: Option, + target: WorkloadTargetConfig, } impl TryFrom for D2bDomainConfig { type Error = String; fn try_from(raw: RawD2bDomainConfig) -> Result { - let target = match (raw.target.as_deref(), raw.vm.as_deref()) { - (Some(target), Some(vm)) => { - let target = normalize_d2b_target(target)?; - let vm = normalize_d2b_target(vm)?; - if target != vm { - return Err("d2b domain `target` and compatibility alias `vm` differ; \ - remove `vm` or make both values identical" - .to_string()); - } - target - } - (Some(target), None) | (None, Some(target)) => normalize_d2b_target(target)?, - (None, None) => { - return Err("d2b domain requires `target` (or compatibility alias `vm`)".to_string()) - } - }; - + let realm = RealmId::parse(raw.target.realm_id) + .map_err(|_| "d2b target has an invalid canonical realm id".to_string())?; + let workload = WorkloadId::parse(raw.target.workload_id) + .map_err(|_| "d2b target has an invalid canonical workload id".to_string())?; Ok(Self { name: raw.name, - target, - socket_path: raw.socket_path, + target: TargetInput::Workload { realm, workload }, }) } } impl From<&D2bDomainConfig> for SerializedD2bDomainConfig { fn from(config: &D2bDomainConfig) -> Self { + let (realm, workload) = config.workload_ids(); Self { name: config.name.clone(), - target: config.target.clone(), - socket_path: config.socket_path.clone(), + target: WorkloadTargetConfig { + realm_id: realm.as_str().to_string(), + workload_id: workload.as_str().to_string(), + }, } } } -pub fn normalize_d2b_target(target: &str) -> Result { - if target.starts_with("d2b://") || target.contains('.') { - return WorkloadTarget::parse(target) - .map(|target| target.to_canonical()) - .map_err(|_| { - "d2b targets must use `..d2b` with lowercase labels".to_string() - }); - } - - validate_d2b_vm_name(target)?; - Ok(target.to_string()) -} - -pub(crate) fn validate_d2b_vm_name(name: &str) -> Result<(), String> { - let mut chars = name.chars(); - match chars.next() { - Some(c) if c.is_ascii_lowercase() => {} - _ => return Err("legacy d2b VM names must start with [a-z]".to_string()), - } - - if !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { - return Err("legacy d2b VM names may contain only [a-z0-9-]".to_string()); - } - - if name.starts_with("sys-") || name == "launcher" { - return Err("legacy d2b VM name is reserved by the framework".to_string()); - } - - Ok(()) -} - #[cfg(test)] mod test { use super::*; use wezterm_dynamic::{FromDynamic, ToDynamic, Value}; + const REALM_ID: &str = "aaaaaaaaaaaaaaaaaaaa"; + const WORKLOAD_ID: &str = "bbbbbbbbbbbbbbbbbbbq"; + fn decode(value: serde_json::Value) -> Result { D2bDomainConfig::from_dynamic(&crate::json_to_dynamic(&value), Default::default()) } #[test] - fn canonical_target_deserializes_and_normalizes_scheme() { + fn canonical_workload_target_deserializes() { let config = decode(serde_json::json!({ - "name": "host-tools", - "target": "d2b://tools.host.d2b" + "name": "work", + "target": { + "realm_id": REALM_ID, + "workload_id": WORKLOAD_ID + } })) .unwrap(); - assert_eq!(config.target, "tools.host.d2b"); - assert_eq!(config.socket_path, None); + let (realm, workload) = config.workload_ids(); + assert_eq!(realm.as_str(), REALM_ID); + assert_eq!(workload.as_str(), WORKLOAD_ID); + assert!(matches!(config.target(), TargetInput::Workload { .. })); } #[test] - fn vm_alias_migrates_to_target_and_serializes_canonically() { + fn canonical_workload_target_round_trips() { let config = decode(serde_json::json!({ "name": "work", - "vm": "corp-vm" + "target": { + "realm_id": REALM_ID, + "workload_id": WORKLOAD_ID + } })) .unwrap(); - assert_eq!(config.target, "corp-vm"); let Value::Object(encoded) = config.to_dynamic() else { panic!("d2b domain did not serialize as an object"); }; + let Some(Value::Object(target)) = encoded.get_by_str("target") else { + panic!("d2b target did not serialize as an object"); + }; assert_eq!( - encoded.get_by_str("target"), - Some(&Value::String("corp-vm".to_string())) + target.get_by_str("realm_id"), + Some(&Value::String(REALM_ID.to_string())) + ); + assert_eq!( + target.get_by_str("workload_id"), + Some(&Value::String(WORKLOAD_ID.to_string())) ); - assert!(encoded.get_by_str("vm").is_none()); } #[test] - fn matching_target_and_vm_alias_are_accepted() { - let config = decode(serde_json::json!({ + fn legacy_string_target_is_rejected() { + let err = decode(serde_json::json!({ "name": "work", - "target": "corp.work.d2b", - "vm": "d2b://corp.work.d2b" + "target": "tools.host.d2b" })) - .unwrap(); - assert_eq!(config.target, "corp.work.d2b"); + .unwrap_err() + .to_string(); + assert!(err.contains("target")); } #[test] - fn conflicting_target_and_vm_alias_fail_closed() { + fn invalid_canonical_ids_are_rejected_without_echoing_values() { let err = decode(serde_json::json!({ "name": "work", - "target": "corp.work.d2b", - "vm": "personal" + "target": { + "realm_id": "work", + "workload_id": WORKLOAD_ID + } })) .unwrap_err() .to_string(); - assert!(err.contains("target")); - assert!(err.contains("compatibility alias")); - assert!(err.contains("differ")); - assert!(!err.contains("corp.work.d2b")); - } - - #[test] - fn missing_target_and_alias_is_actionable() { - let err = decode(serde_json::json!({"name": "work"})) - .unwrap_err() - .to_string(); - assert!(err.contains("requires `target`")); + assert!(err.contains("canonical realm id")); + assert!(!err.contains(WORKLOAD_ID)); } } -// --- end weezterm remote features --- diff --git a/config/src/keyassignment.rs b/config/src/keyassignment.rs index 7436bf53da7..39b1dd39394 100644 --- a/config/src/keyassignment.rs +++ b/config/src/keyassignment.rs @@ -651,12 +651,6 @@ pub enum KeyAssignment { ShowPortForwardOverlay, ShowConfigOverlay, ShowDevContainerManager, - ShowD2bLauncher, - D2bOpenSession { - domain: String, - #[dynamic(default)] - name: Option, - }, // --- end weezterm remote features --- } impl_lua_conversion_dynamic!(KeyAssignment); diff --git a/docs/adr/0010-provider-neutral-d2b-target-domains.md b/docs/adr/0010-provider-neutral-d2b-target-domains.md index 056304d8f1b..2e91569bdae 100644 --- a/docs/adr/0010-provider-neutral-d2b-target-domains.md +++ b/docs/adr/0010-provider-neutral-d2b-target-domains.md @@ -1,6 +1,6 @@ # ADR 0010: Provider-neutral d2b target domains -- **Status:** Accepted +- **Status:** Superseded by d2b ADR 0045 - **Date:** 2026-07-11 - **Deciders:** WeezTerm maintainers (@vicondoa) - **Relates to:** d2b @@ -10,6 +10,12 @@ ## Context +This record describes the retired d2b 1.x integration. The current seam follows +[d2b ADR 0045](https://github.com/vicondoa/d2b/blob/main/docs/adr/0045-provider-and-transport-framework.md): +configuration stores canonical v2 client target types, while daemon discovery +and persistent-shell transport remain absent until their owning service +contracts are finalized. + The first native d2b domain treated every shell endpoint as a local VM name. d2b now exposes provider-neutral canonical workload targets and typed provider, isolation, availability, and capability metadata. Some targets intentionally use diff --git a/docs/adr/README.md b/docs/adr/README.md index e789bbeadae..d0a62387a45 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -53,4 +53,4 @@ Trivial, reversible, or purely-internal changes do **not** need an ADR. | [0007. Windows rendering modes and window-state persistence](0007-windows-rendering-modes-and-state-persistence.md) | Accepted | 2026-05-24 | Three render modes (WgpuDComp / WgpuClassic / SoftwareRdp); Auto uses WgpuDComp, falling back to WgpuClassic for RDP/virtual-GPU (SoftwareRdp is opt-in pending a fix). Plus window-state persistence, validated by `tests/ux/`. | | [0008. CI/CD unification](0008-cicd-unification.md) | Accepted | 2026-04-06 | A single `weezterm_build.yml` builds/tests/releases all platforms; the upstream `gen_*.yml` workflows are kept byte-identical but disabled, to merge cleanly. | | [0009. Panel review and ADR methodology](0009-panel-review-and-adr-methodology.md) | Accepted | 2026-06-07 | Adopt nixling's panel sign-off gate for multi-phase work, this ADR framework, a hybrid commit convention, and an explicit local-vs-CI validation split. | -| [0010. Provider-neutral d2b target domains](0010-provider-neutral-d2b-target-domains.md) | Accepted | 2026-07-11 | Canonical workload targets drive one public-socket shell transport; VM aliases migrate without fallback, unsafe-local posture stays visible, and target-derived filesystem keys are hashed. | +| [0010. Provider-neutral d2b target domains](0010-provider-neutral-d2b-target-domains.md) | Superseded by d2b ADR 0045 | 2026-07-11 | Historical d2b 1.x public-socket domain design; the current seam stores canonical v2 client targets and defers runtime integration to finalized service contracts. | diff --git a/docs/d2b-provider.md b/docs/d2b-provider.md index 4ca72a468e5..586973b8528 100644 --- a/docs/d2b-provider.md +++ b/docs/d2b-provider.md @@ -1,140 +1,49 @@ -# Native d2b provider +# d2b client seam -WeezTerm includes a Linux-only native d2b provider for attaching terminal panes -to persistent shells on canonical d2b workload targets. The provider speaks the -d2b public daemon protocol through `d2b-client` and `d2b-toolkit-core`; it does -not connect to the privileged broker socket and does not shell out through the -`d2b` CLI for the terminal byte stream. +WeezTerm carries a configuration seam for canonical d2b v2 workload targets. +It consumes `d2b-client` and `d2b-contracts` directly from the exact d2b source +revision: -## Input alignment - -Pin WeezTerm and d2b to one `nixpkgs` revision: - -```nix -{ - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - - d2b = { - url = "github:vicondoa/d2b"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - - weezterm = { - url = "github:vicondoa/weezterm/v0.7.2"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; -} +```text +9183b45c6505cfd496e5d537bf6376f884fb16c7 ``` -WeezTerm pins toolkit release `v0.2.0` at the `v0.2.0` release tag in Cargo. -`Cargo.lock` resolves that tag to -`fde6af8b842718e7150f5056d4eba73093d4ad77`. +The Cargo lockfile binds the same revision. WeezTerm defines no d2b handshake, +frame codec, request or response type, shell record, error envelope, or target +parser. -## Configuration +## Configure a target -Use `target` for new d2b domains. A canonical target has the form -`..d2b`; a legacy VM name remains valid during migration. +Each domain uses the canonical `d2b_client::TargetInput::Workload` type. +Configuration supplies the exact canonical realm and workload IDs: ```lua -local wezterm = require 'wezterm' -local act = wezterm.action -local config = wezterm.config_builder() - -config.d2b_domains = { - { - name = 'host-tools', - target = 'tools.host.d2b', - }, - { - name = 'work', - target = 'corp-vm', - }, -} - -config.keys = { - { - key = 'D', - mods = 'CTRL|SHIFT', - action = act.ShowD2bLauncher, - }, - { - key = 'B', - mods = 'CTRL|SHIFT', - action = act.D2bOpenSession { - domain = 'host-tools', - name = 'build', +return { + d2b_domains = { + { + name = "work", + target = { + realm_id = "aaaaaaaaaaaaaaaaaaaa", + workload_id = "bbbbbbbbbbbbbbbbbbbq", + }, }, }, } - -return config ``` -The former `vm` field remains a compatibility alias through the 0.7 release -line. It normalizes into `target`; specifying both with different values is a -configuration error. `WEEZTERM_D2B_BOUND_VM` follows the same rule as an alias -for `WEEZTERM_D2B_BOUND_TARGET`. These aliases never select SSH, a host-terminal -backend, or another provider. - -Canonical targets may contain dots. WeezTerm keeps the validated target as -metadata but derives bounded SHA-256-based keys for mux socket and generated -domain names, so a target is never used as a filesystem path component. - -## Runtime contract - -- Default socket: `/run/d2b/public.sock`. -- Transport: non-abstract Unix socket, bounded public-daemon frames, typed hello - negotiation, and shell operations over the public socket. -- Discovery resolves compatibility VM aliases to the workload's canonical - target and consumes toolkit provider, isolation, availability, and capability - metadata. -- Attach uses target-aware shell operations, then forwards terminal input, - stdout-only PTY output, resize, close-stdin, and close-attach operations. -- Pane close, pane kill, domain detach, and object drop close only the current - attachment. They do not kill the persistent shell session. -- Backpressure, output gaps, stale sessions, daemon disconnects, and timeouts - mark the pane as requiring reattach instead of pretending the stream is still - healthy. -- Unsafe-local workloads are labeled `UNSAFE LOCAL — NO ISOLATION`. They require - the negotiated `unsafe-local-shell-v1` feature before any shell operation. - Helper and user-manager unavailability are shown in the domain and launcher - UI. There is no SSH, direct host shell, or separate host-terminal fallback. -- Window-title d2b identity comes from the mux pane's configured d2b domain, - never terminal-controlled user variables. Compatibility user variables are - presentation metadata and cannot make a local or SSH pane appear d2b-backed. -- d2b pane and tab titles append that trusted identity as - `application title [target:shell]`. OSC title changes remain dynamic. The - most recent OSC 0, OSC 1, or OSC 2 title wins, so an OSC 2 update is not - masked by an older icon title. The native window title follows the active - tab's latest title, including updates received while the tab was in the - background, and changes immediately when the active tab changes. When space - is constrained, WeezTerm truncates the untrusted application portion from the - right or left edge as appropriate while preserving the trailing trusted - identity. Wayland client-side decorations scale configured title fonts down - to fit the compact header with desktop-standard vertical padding. -- The tab-bar new-tab dropdown is target-scoped when its active pane belongs to - a process-bound d2b domain. It offers one immediate generated-shell action and - reattachment actions only for detached shells on that target. It never offers - attached or unavailable sessions, manual shell naming, local/unmanaged - shells, SSH/mux domains, or socket attachment. Non-d2b windows keep the - generic new-tab dropdown. -- A persistent shell has one attachment owner. WeezTerm does not force attach: - attached shells are omitted because another attachment would fail or require - evicting the current owner. The sole owner supplies the initial terminal size - and sends subsequent resize updates. - -## Redaction and diagnostics +Both IDs use d2b's 20-character lowercase unpadded base32 short-ID grammar. +Invalid IDs fail configuration without echoing the submitted identity. +String targets, VM aliases, and direct socket-path overrides are not accepted. -Targets, shell names, opaque session handles, terminal bytes, argv, environment -values, cwd, and raw socket paths are not metric labels. Diagnostics use bounded -operation names and correlation digests so an operator can correlate -reattach-required messages without exposing terminal content. +## Runtime boundary -## Relationship to d2b-wlterm +Configured d2b domains are currently reported as unavailable and are not added +to the mux. WeezTerm does not guess the pending endpoint bootstrap, route +resolution, daemon discovery, session setup, or persistent-shell stream APIs. +There is no legacy public-socket fallback, shell command fallback, SSH mapping, +or direct host-terminal bridge. -`d2b-wlterm` remains the lightweight Waybar/Home Manager launcher for choosing -targets and shell names. WeezTerm is the terminal implementation: when launched -by `d2b-wlterm`, or configured directly by the operator, the native provider -uses the same public d2b shell protocol and toolkit crates. +Runtime integration can return only after the canonical control and +user-session service APIs are finalized. It must use an explicit Tokio runtime +boundary because WeezTerm's UI and mux use smol; copying protocol or transport +code into this repository is not an acceptable adapter. diff --git a/mux/Cargo.toml b/mux/Cargo.toml index b0c3d1739aa..bcab352df37 100644 --- a/mux/Cargo.toml +++ b/mux/Cargo.toml @@ -48,13 +48,6 @@ termwiz.workspace = true textwrap.workspace = true thiserror.workspace = true url.workspace = true -# --- weezterm remote features --- -async-channel.workspace = true -async-io.workspace = true -d2b-toolkit-core.workspace = true -futures.workspace = true -socket2.workspace = true -# --- end weezterm remote features --- wezterm-dynamic.workspace = true wezterm-ssh.workspace = true wezterm-term = { workspace=true, features=["use_serde"] } diff --git a/mux/src/d2b.rs b/mux/src/d2b.rs deleted file mode 100644 index 3f50f78a47d..00000000000 --- a/mux/src/d2b.rs +++ /dev/null @@ -1,2999 +0,0 @@ -use d2b_toolkit_core::WorkloadTarget; -use sha2::{Digest, Sha256}; -use std::path::{Path, PathBuf}; -use termwiz::cell::unicode_column_width; -use termwiz_funcs::{truncate_left, truncate_right}; - -pub use d2b_toolkit_core::{ - IsolationPosture, KnownFeatureFlag, ShellSessionState, WorkloadAvailability, - WorkloadProviderKind, -}; - -pub const D2B_BOUND_TARGET_ENV: &str = "WEEZTERM_D2B_BOUND_TARGET"; -pub const D2B_BOUND_VM_ENV: &str = "WEEZTERM_D2B_BOUND_VM"; -pub const D2B_SHELL_NAME_ENV: &str = "WEEZTERM_D2B_SHELL_NAME"; - -pub fn normalize_d2b_target(target: &str) -> Result { - if target.starts_with("d2b://") || target.contains('.') { - return WorkloadTarget::parse(target) - .map(|target| target.to_canonical()) - .map_err(|_| { - "d2b targets must use `..d2b` with lowercase labels".to_string() - }); - } - - let mut chars = target.chars(); - if !matches!(chars.next(), Some(c) if c.is_ascii_lowercase()) - || !chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - || target.starts_with("sys-") - || target == "launcher" - { - return Err("legacy d2b target has an invalid VM name".to_string()); - } - Ok(target.to_string()) -} - -pub fn resolve_bound_target_aliases( - target: Option<&str>, - vm: Option<&str>, -) -> Result, String> { - match (target, vm) { - (Some(target), Some(vm)) => { - let target = normalize_d2b_target(target)?; - let vm = normalize_d2b_target(vm)?; - if target != vm { - return Err(format!( - "{D2B_BOUND_TARGET_ENV} and compatibility alias {D2B_BOUND_VM_ENV} differ; \ - unset the alias or make both values identical" - )); - } - Ok(Some(target)) - } - (Some(target), None) | (None, Some(target)) => normalize_d2b_target(target).map(Some), - (None, None) => Ok(None), - } -} - -pub fn bound_target_from_env() -> Result, String> { - let target = std::env::var(D2B_BOUND_TARGET_ENV) - .map(Some) - .or_else(|err| match err { - std::env::VarError::NotPresent => Ok(None), - std::env::VarError::NotUnicode(_) => { - Err(format!("{D2B_BOUND_TARGET_ENV} must contain valid UTF-8")) - } - })?; - let vm = std::env::var(D2B_BOUND_VM_ENV) - .map(Some) - .or_else(|err| match err { - std::env::VarError::NotPresent => Ok(None), - std::env::VarError::NotUnicode(_) => { - Err(format!("{D2B_BOUND_VM_ENV} must contain valid UTF-8")) - } - })?; - resolve_bound_target_aliases(target.as_deref(), vm.as_deref()) -} - -pub fn validate_shell_name(name: &str) -> Result<(), String> { - let bytes = name.as_bytes(); - if bytes.is_empty() || bytes.len() > 64 { - return Err("shell names must be 1-64 ASCII bytes".to_string()); - } - - let first = bytes[0]; - if !(first.is_ascii_alphanumeric() || first == b'_') { - return Err("shell names must start with [A-Za-z0-9_]".to_string()); - } - if first == b'-' { - return Err("shell names must not start with '-'".to_string()); - } - - if !bytes - .iter() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-')) - { - return Err("shell names may contain only [A-Za-z0-9._-]".to_string()); - } - - Ok(()) -} - -pub fn friendly_session_name(target: &str, existing: &[String]) -> String { - let legacy_base = format!("{}-shell", sanitize_shell_component(target)); - let base = if !target.contains('.') - && !target.starts_with("d2b://") - && validate_shell_name(&legacy_base).is_ok() - { - legacy_base - } else { - format!("d2b-{}-shell", &stable_target_key(target)[..16]) - }; - if !existing.iter().any(|name| name == &base) { - return base; - } - for idx in 2..=64 { - let candidate = format!("{base}-{idx}"); - if !existing.iter().any(|name| name == &candidate) - && validate_shell_name(&candidate).is_ok() - { - return candidate; - } - } - "default".to_string() -} - -pub fn d2b_tab_title(target: &str, session: &str, guest_osc_title: &str) -> String { - let target = sanitize_display_label(target); - let session = sanitize_display_label(session); - let suffix = format!("[{target}:{session}]"); - let guest_osc_title = sanitize_display_text(guest_osc_title); - if guest_osc_title.is_empty() || guest_osc_title == "wezterm" { - suffix - } else if guest_osc_title == suffix || guest_osc_title.ends_with(&format!(" {suffix}")) { - guest_osc_title - } else { - format!("{guest_osc_title} {suffix}") - } -} - -pub fn d2b_tab_title_for_width( - target: &str, - session: &str, - guest_osc_title: &str, - max_width: usize, -) -> String { - let target = sanitize_display_label(target); - let session = sanitize_display_label(session); - let suffix = format!("[{target}:{session}]"); - let suffix_width = unicode_column_width(&suffix, None); - if max_width <= suffix_width { - return truncate_left(&suffix, max_width); - } - - let guest_osc_title = sanitize_display_text(guest_osc_title); - if guest_osc_title.is_empty() || guest_osc_title == "wezterm" || guest_osc_title == suffix { - return suffix; - } - - let suffix_marker = format!(" {suffix}"); - let guest_osc_title = guest_osc_title - .strip_suffix(&suffix_marker) - .unwrap_or(&guest_osc_title); - let guest_width = max_width - suffix_width - 1; - let guest_osc_title = truncate_right(guest_osc_title, guest_width); - if guest_osc_title.is_empty() { - suffix - } else { - format!("{guest_osc_title} {suffix}") - } -} - -pub fn target_domain_key(target: &str) -> String { - format!("d2b-{}", stable_target_key(target)) -} - -pub fn target_mux_socket_path(runtime_dir: &Path, target: &str) -> PathBuf { - runtime_dir.join(format!( - "gui-sock-d2b-{}-{}", - stable_target_key(target), - std::process::id() - )) -} - -pub fn vm_mux_socket_path(runtime_dir: &Path, vm: &str) -> PathBuf { - target_mux_socket_path(runtime_dir, vm) -} - -fn stable_target_key(target: &str) -> String { - let normalized = normalize_d2b_target(target); - let target = normalized.as_deref().unwrap_or(""); - let mut hasher = Sha256::new(); - hasher.update(b"weezterm-d2b-target-key-v1"); - hasher.update((target.len() as u64).to_le_bytes()); - hasher.update(target.as_bytes()); - let digest = hasher.finalize(); - let mut key = String::with_capacity(32); - for byte in &digest[..16] { - use std::fmt::Write as _; - let _ = write!(key, "{byte:02x}"); - } - key -} - -fn sanitize_shell_component(value: &str) -> String { - let mut out = String::new(); - for c in value.chars() { - if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { - out.push(c); - } else { - out.push('-'); - } - } - let out = out.trim_matches('-'); - if out.is_empty() { - "d2b".to_string() - } else { - out.to_string() - } -} - -pub fn sanitize_display_label(value: &str) -> String { - let label = sanitize_display_text(value); - if label.is_empty() { - "unnamed".to_string() - } else { - label - } -} - -fn sanitize_display_text(value: &str) -> String { - const MAX_DISPLAY_CHARS: usize = 96; - let mut out = String::new(); - let mut chars = value.trim().chars().peekable(); - while let Some(c) = chars.next() { - if c == '\x1b' { - match chars.peek().copied() { - Some('[') => { - chars.next(); - for next in chars.by_ref() { - if ('@'..='~').contains(&next) { - break; - } - } - } - Some(']') => { - chars.next(); - while let Some(next) = chars.next() { - if next == '\x07' { - break; - } - if next == '\x1b' && matches!(chars.peek(), Some('\\')) { - chars.next(); - break; - } - } - } - _ => {} - } - continue; - } - if c.is_control() { - continue; - } - out.push(c); - if out.chars().count() >= MAX_DISPLAY_CHARS { - break; - } - } - out -} - -#[cfg(target_os = "linux")] -mod imp { - use crate::domain::{alloc_domain_id, Domain, DomainId, DomainState}; - use crate::pane::{alloc_pane_id, CachePolicy, CloseReason, LogicalLine, Pane, PaneId}; - use crate::renderable::{ - terminal_for_each_logical_line_in_stable_range_mut, terminal_get_cursor_position, - terminal_get_dimensions, terminal_get_dirty_lines, terminal_get_lines, - terminal_with_lines_mut, RenderableDimensions, StableCursorPosition, - }; - use crate::window::WindowId; - use crate::{Mux, MuxNotification}; - use anyhow::{anyhow, bail}; - use async_channel::{Receiver, Sender, TrySendError}; - use async_io::Async; - use async_trait::async_trait; - use base64::engine::general_purpose::STANDARD; - use base64::Engine as _; - use d2b_client::{AttachedShell, ClientError, FrameBounds, PublicSocketClient}; - use d2b_toolkit_core::{ - Capability, Hello, HelloResponse, KnownFeatureFlag, Redacted, ShellName, ShellSessionState, - SocketClass, TerminalSize as D2bTerminalSize, TerminalStream, ToolkitError, - WorkloadAvailability, WorkloadProviderKind, WorkloadPublicSummary, - }; - use futures::io::{AsyncRead, AsyncWrite}; - use futures::{future, Future}; - use parking_lot::{MappedMutexGuard, Mutex, MutexGuard}; - use rangeset::RangeSet; - use sha2::{Digest, Sha256}; - use socket2::{Domain as SocketDomain, SockAddr, Socket, Type}; - use std::collections::HashMap; - use std::convert::TryInto; - use std::future::Future as StdFuture; - use std::io::{Error as IoError, ErrorKind, Write}; - use std::ops::Range; - use std::os::fd::AsRawFd; - use std::path::{Path, PathBuf}; - use std::pin::Pin; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::{Arc, Weak}; - use std::time::Duration; - use termwiz::escape::parser::Parser; - use termwiz::surface::{Line, SequenceNo}; - use url::Url; - use wezterm_term::color::ColorPalette; - use wezterm_term::{ - Alert, AlertHandler, KeyCode, KeyModifiers, MouseEvent, StableRowIndex, - TerminalConfiguration, TerminalSize, - }; - - const DEFAULT_SOCKET: &str = "/run/d2b/public.sock"; - const DEFAULT_QUEUE_DEPTH: usize = 64; - const DEFAULT_EVENT_DEPTH: usize = 64; - const DEFAULT_READ_MAX: u64 = 64 * 1024; - const DEFAULT_READ_WAIT_MS: u64 = 25; - const MAX_INPUT_CHUNK: usize = 64 * 1024; - - pub type TransportFuture<'a, T> = Pin> + Send + 'a>>; - - #[derive(Clone, Eq, PartialEq)] - pub struct D2bCorrelationId(String); - - impl D2bCorrelationId { - pub fn from_sensitive(kind: &'static str, value: impl AsRef<[u8]>) -> Self { - let value = value.as_ref(); - let mut hasher = Sha256::new(); - hasher.update(b"weezterm-d2b-correlation-v1"); - hasher.update((kind.len() as u64).to_le_bytes()); - hasher.update(kind.as_bytes()); - hasher.update((value.len() as u64).to_le_bytes()); - hasher.update(value); - Self(format!("d2b:{:x}", hasher.finalize())) - } - - pub fn from_target_session(kind: &'static str, target: &str, session: &str) -> Self { - let mut value = Vec::with_capacity(target.len() + session.len() + 16); - value.extend_from_slice(&(target.len() as u64).to_le_bytes()); - value.extend_from_slice(target.as_bytes()); - value.extend_from_slice(&(session.len() as u64).to_le_bytes()); - value.extend_from_slice(session.as_bytes()); - Self::from_sensitive(kind, value) - } - } - - impl std::fmt::Debug for D2bCorrelationId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } - } - - impl std::fmt::Display for D2bCorrelationId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.0) - } - } - - #[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)] - pub enum D2bProviderError { - #[error("d2b provider queue full while {operation}; reattach required ({correlation})")] - Backpressure { - operation: &'static str, - correlation: D2bCorrelationId, - }, - #[error("d2b provider timed out while {operation}; reattach required")] - Timeout { - operation: &'static str, - correlation: Option, - }, - #[error("d2b provider disconnected while {operation}; reattach required")] - Disconnected { - operation: &'static str, - correlation: Option, - }, - #[error("d2b provider stale session while {operation}; reattach required ({correlation})")] - StaleSession { - operation: &'static str, - correlation: D2bCorrelationId, - }, - #[error("d2b provider dropped terminal output; reattach required ({correlation})")] - DroppedOutput { correlation: D2bCorrelationId }, - #[error("d2b daemon returned typed error `{kind}` while {operation}")] - Daemon { - operation: &'static str, - kind: String, - correlation: Option, - }, - #[error("d2b provider attach failed: {kind}")] - AttachFailed { kind: String }, - #[error("d2b daemon is missing required feature `{feature}`; update d2b and retry")] - FeatureSkew { feature: KnownFeatureFlag }, - #[error("d2b target resolution failed: {kind}")] - TargetResolution { kind: &'static str }, - #[error("d2b target shell is unavailable: {reason}")] - TargetUnavailable { reason: &'static str }, - } - - #[derive(Clone, Eq, PartialEq)] - pub struct D2bTargetStatus { - target: String, - pub provider_kind: Option, - pub isolation: Option, - pub availability: Option, - pub shell_capable: bool, - posture_known: bool, - required_feature: Option, - } - - impl D2bTargetStatus { - pub fn new( - target: impl Into, - provider_kind: WorkloadProviderKind, - isolation: d2b_toolkit_core::IsolationPosture, - availability: WorkloadAvailability, - shell_capable: bool, - ) -> Result { - Ok(Self { - target: super::normalize_d2b_target(&target.into())?, - provider_kind: Some(provider_kind), - isolation: Some(isolation), - availability: Some(availability), - shell_capable, - posture_known: true, - required_feature: None, - }) - } - - fn legacy(target: String) -> Self { - Self { - target, - provider_kind: None, - isolation: None, - availability: None, - shell_capable: true, - posture_known: false, - required_feature: None, - } - } - - fn from_workload(workload: &WorkloadPublicSummary) -> Self { - Self { - target: workload.identity().target().to_canonical(), - provider_kind: Some(workload.provider_kind()), - isolation: Some(workload.execution_posture().isolation()), - availability: Some(workload.availability()), - shell_capable: workload.capabilities().has(Capability::PersistentShell) - && workload.capabilities().has(Capability::Pty), - posture_known: true, - required_feature: None, - } - } - - fn require_feature(&mut self, feature: KnownFeatureFlag) { - self.required_feature = Some(feature); - } - - pub fn target(&self) -> &str { - &self.target - } - - pub fn is_unsafe_local(&self) -> bool { - self.provider_kind == Some(WorkloadProviderKind::UnsafeLocal) - || self.isolation == Some(d2b_toolkit_core::IsolationPosture::UnsafeLocal) - } - - pub fn is_shell_ready(&self) -> bool { - self.shell_capable - && self.required_feature.is_none() - && self - .availability - .map(|availability| availability == WorkloadAvailability::Ready) - .unwrap_or(true) - } - - pub fn required_feature(&self) -> Option { - self.required_feature - } - - pub fn warning_text(&self) -> Option { - let mut warnings = Vec::new(); - if self.is_unsafe_local() { - warnings.push("UNSAFE LOCAL — NO ISOLATION".to_string()); - } - if !self.posture_known { - warnings.push("provider posture unavailable (daemon feature skew)".to_string()); - } - if let Some(availability) = self.availability { - if let Some(message) = availability_message(availability) { - warnings.push(message.to_string()); - } - } - if !self.shell_capable { - warnings.push("persistent shell unavailable".to_string()); - } - if let Some(feature) = self.required_feature { - warnings.push(format!("daemon lacks required `{feature}` feature")); - } - (!warnings.is_empty()).then(|| warnings.join("; ")) - } - } - - impl std::fmt::Debug for D2bTargetStatus { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("D2bTargetStatus") - .field("target", &"") - .field("provider_kind", &self.provider_kind) - .field("isolation", &self.isolation) - .field("availability", &self.availability) - .field("shell_capable", &self.shell_capable) - .field("posture_known", &self.posture_known) - .field("required_feature", &self.required_feature) - .finish() - } - } - - #[derive(Clone, Debug, Eq, PartialEq)] - pub struct D2bDiscovery { - pub status: D2bTargetStatus, - pub sessions: Vec, - } - - #[derive(Clone, Eq, PartialEq)] - pub struct D2bSession { - pub id: String, - pub label: String, - pub target: String, - pub workspace: Option, - pub state: ShellSessionState, - pub attached: bool, - pub is_default: bool, - pub correlation_id: D2bCorrelationId, - } - - impl std::fmt::Debug for D2bSession { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("D2bSession") - .field("id", &"") - .field("label", &"") - .field("target", &"") - .field("workspace", &self.workspace.as_ref().map(|_| "")) - .field("state", &self.state) - .field("attached", &self.attached) - .field("is_default", &self.is_default) - .field("correlation_id", &self.correlation_id) - .finish() - } - } - - #[derive(Clone, Eq, PartialEq)] - pub struct D2bPaneHandle { - pub target: String, - pub session_id: String, - pub pane_id: String, - pub correlation_id: D2bCorrelationId, - } - - impl std::fmt::Debug for D2bPaneHandle { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("D2bPaneHandle") - .field("target", &"") - .field("session_id", &"") - .field("pane_id", &"") - .field("correlation_id", &self.correlation_id) - .finish() - } - } - - #[derive(Clone, Copy, Debug, Eq, PartialEq)] - pub enum D2bDetachReason { - PaneClose, - PaneKill, - Drop, - DomainDetach, - } - - #[derive(Clone, Eq, PartialEq)] - pub struct D2bAttachRequest { - pub session_id: Option, - pub size: TerminalSize, - } - - impl std::fmt::Debug for D2bAttachRequest { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("D2bAttachRequest") - .field( - "session_id", - &self.session_id.as_ref().map(|_| ""), - ) - .field("size", &self.size) - .finish() - } - } - - enum D2bPaneCommand { - Write { bytes: Vec }, - Resize { size: TerminalSize }, - CloseAttach { reason: D2bDetachReason }, - } - - impl std::fmt::Debug for D2bPaneCommand { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Write { bytes } => f - .debug_struct("Write") - .field("bytes", &format_args!("", bytes.len())) - .finish(), - Self::Resize { size } => f.debug_struct("Resize").field("size", size).finish(), - Self::CloseAttach { reason } => f - .debug_struct("CloseAttach") - .field("reason", reason) - .finish(), - } - } - } - - enum D2bPaneEvent { - Output(Vec), - Closed, - ReattachRequired(D2bProviderError), - } - - impl std::fmt::Debug for D2bPaneEvent { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Output(bytes) => f - .debug_struct("Output") - .field("bytes", &format_args!("", bytes.len())) - .finish(), - Self::Closed => f.write_str("Closed"), - Self::ReattachRequired(err) => { - f.debug_tuple("ReattachRequired").field(err).finish() - } - } - } - } - - pub struct D2bAttachedPane { - handle: D2bPaneHandle, - command_tx: Sender, - event_rx: Receiver, - } - - impl D2bAttachedPane { - fn new( - handle: D2bPaneHandle, - command_tx: Sender, - event_rx: Receiver, - ) -> Self { - Self { - handle, - command_tx, - event_rx, - } - } - } - - fn d2b_session_from_command( - command: Option, - ) -> anyhow::Result> { - let Some(command) = command else { - return Ok(None); - }; - - let Some(name) = command - .get_env(super::D2B_SHELL_NAME_ENV) - .and_then(|value| value.to_str()) - .map(|value| value.to_string()) - else { - bail!("d2b domains attach existing sessions; command spawning is unsupported"); - }; - - super::validate_shell_name(&name).map_err(|err| anyhow!(err))?; - Ok(Some(name)) - } - - pub trait D2bTransport: Send + Sync { - fn discover(&self) -> TransportFuture<'_, D2bDiscovery>; - - fn attach(&self, request: D2bAttachRequest) -> TransportFuture<'_, D2bAttachedPane>; - } - - #[derive(Clone, Debug)] - pub struct D2bRuntimeConfig { - pub socket_path: PathBuf, - pub connect_timeout: Duration, - pub write_timeout: Duration, - pub read_timeout: Duration, - pub shell_attach_timeout: Duration, - pub command_queue_depth: usize, - pub event_queue_depth: usize, - pub output_read_max: u64, - pub output_wait_ms: u64, - } - - impl Default for D2bRuntimeConfig { - fn default() -> Self { - Self { - socket_path: PathBuf::from(DEFAULT_SOCKET), - connect_timeout: Duration::from_secs(2), - write_timeout: Duration::from_secs(2), - read_timeout: Duration::from_secs(2), - shell_attach_timeout: Duration::from_secs(15), - command_queue_depth: DEFAULT_QUEUE_DEPTH, - event_queue_depth: DEFAULT_EVENT_DEPTH, - output_read_max: DEFAULT_READ_MAX, - output_wait_ms: DEFAULT_READ_WAIT_MS, - } - } - } - - const MAX_PUBLIC_PACKET: usize = 1024 * 1024 + 4; - - pub struct D2bSocket { - fd: Async, - read_buf: Vec, - read_len: usize, - read_pos: usize, - packet_limit: usize, - write_buf: Vec, - } - - impl D2bSocket { - fn new(socket: Socket) -> std::io::Result { - Self::with_packet_limit(socket, MAX_PUBLIC_PACKET) - } - - fn with_packet_limit(socket: Socket, packet_limit: usize) -> std::io::Result { - if packet_limit == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "d2bd public socket packet bound must be non-zero", - )); - } - Ok(Self { - fd: Async::new(socket)?, - read_buf: vec![0_u8; packet_limit], - read_len: 0, - read_pos: 0, - packet_limit, - write_buf: Vec::new(), - }) - } - } - - impl AsyncRead for D2bSocket { - fn poll_read( - mut self: Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - buf: &mut [u8], - ) -> std::task::Poll> { - if self.read_pos >= self.read_len { - loop { - let fd = self.fd.get_ref().as_raw_fd(); - match nix::sys::socket::recv( - fd, - &mut self.read_buf, - nix::sys::socket::MsgFlags::MSG_DONTWAIT - | nix::sys::socket::MsgFlags::MSG_TRUNC, - ) { - Ok(len) if len > self.packet_limit => { - return std::task::Poll::Ready(Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "d2bd public socket packet exceeded the frame bound", - ))); - } - Ok(len) => { - self.read_len = len; - self.read_pos = 0; - if self.read_len == 0 { - return std::task::Poll::Ready(Ok(0)); - } - break; - } - Err(nix::errno::Errno::EAGAIN) => match self.fd.poll_readable(cx) { - std::task::Poll::Pending => return std::task::Poll::Pending, - std::task::Poll::Ready(Ok(())) => continue, - std::task::Poll::Ready(Err(error)) => { - return std::task::Poll::Ready(Err(error)); - } - }, - Err(nix::errno::Errno::EINTR) => continue, - Err(error) => { - return std::task::Poll::Ready(Err(errno_to_io(error))); - } - } - } - } - let available = &self.read_buf[self.read_pos..self.read_len]; - let len = available.len().min(buf.len()); - buf[..len].copy_from_slice(&available[..len]); - self.read_pos += len; - std::task::Poll::Ready(Ok(len)) - } - } - - impl AsyncWrite for D2bSocket { - fn poll_write( - mut self: Pin<&mut Self>, - _cx: &mut std::task::Context<'_>, - buf: &[u8], - ) -> std::task::Poll> { - if self.write_buf.len().saturating_add(buf.len()) > MAX_PUBLIC_PACKET { - return std::task::Poll::Ready(Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - "d2bd public socket packet exceeded the frame bound", - ))); - } - self.write_buf.extend_from_slice(buf); - std::task::Poll::Ready(Ok(buf.len())) - } - - fn poll_flush( - mut self: Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - while !self.write_buf.is_empty() { - match nix::sys::socket::send( - self.fd.get_ref().as_raw_fd(), - &self.write_buf, - nix::sys::socket::MsgFlags::MSG_DONTWAIT, - ) { - Ok(sent) if sent == self.write_buf.len() => self.write_buf.clear(), - Ok(_) => { - return std::task::Poll::Ready(Err(std::io::Error::new( - std::io::ErrorKind::WriteZero, - "short write on public seqpacket socket", - ))); - } - Err(nix::errno::Errno::EAGAIN) => match self.fd.poll_writable(cx) { - std::task::Poll::Pending => return std::task::Poll::Pending, - std::task::Poll::Ready(Ok(())) => continue, - std::task::Poll::Ready(Err(error)) => { - return std::task::Poll::Ready(Err(error)); - } - }, - Err(nix::errno::Errno::EINTR) => continue, - Err(error) => { - return std::task::Poll::Ready(Err(errno_to_io(error))); - } - } - } - std::task::Poll::Ready(Ok(())) - } - - fn poll_close( - self: Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - self.poll_flush(cx) - } - } - - type D2bClient = PublicSocketClient; - type D2bShell = AttachedShell; - - pub struct NativeD2bTransport { - target: String, - config: D2bRuntimeConfig, - bounds: FrameBounds, - } - - impl NativeD2bTransport { - pub fn new( - target: impl Into, - config: D2bRuntimeConfig, - ) -> Result { - let target = super::normalize_d2b_target(&target.into()).map_err(|_| { - D2bProviderError::TargetResolution { - kind: "invalid-target", - } - })?; - Ok(Self { - target, - config, - bounds: FrameBounds::default_public_daemon(), - }) - } - - async fn connect_client(&self) -> Result { - d2b_client::ensure_allowed_socket(classify_socket_path(&self.config.socket_path)) - .map_err(|err| D2bProviderError::AttachFailed { - kind: err.to_string(), - })?; - let mut socket = timeout_result( - "connecting to d2b public socket", - None, - self.config.connect_timeout, - async_unix_connect(&self.config.socket_path), - ) - .await?; - - client_op( - "sending d2b hello", - None, - self.config.write_timeout, - d2b_client::send_hello( - &mut socket, - &Hello::toolkit_client(vec![ - KnownFeatureFlag::TypedErrors.wire_value(), - KnownFeatureFlag::ConfiguredLaunchV1.wire_value(), - KnownFeatureFlag::UnsafeLocalProviderV1.wire_value(), - KnownFeatureFlag::UnsafeLocalShellV1.wire_value(), - ]), - self.bounds, - ), - ) - .await?; - let hello = client_op( - "reading d2b hello", - None, - self.config.read_timeout, - d2b_client::read_hello_response(&mut socket, self.bounds), - ) - .await?; - let HelloResponse::HelloOk(hello) = hello else { - return Err(D2bProviderError::AttachFailed { - kind: "hello-rejected".to_string(), - }); - }; - - Ok(PublicSocketClient::with_bounds_and_negotiated_capabilities( - socket, - self.bounds, - hello.negotiated_capabilities(), - )) - } - - async fn resolve_target( - &self, - client: &mut D2bClient, - ) -> Result { - match client_op( - "reading d2b workload metadata", - None, - self.config.read_timeout, - client.workload_inventory(), - ) - .await - { - Ok(inventory) => { - let workload = select_workload(&self.target, &inventory.workloads)?; - Ok(D2bTargetStatus::from_workload(workload)) - } - Err(D2bProviderError::FeatureSkew { .. }) if is_legacy_target(&self.target) => { - Ok(D2bTargetStatus::legacy(self.target.clone())) - } - Err(err) => Err(err), - } - } - - fn apply_unsafe_shell_requirement( - client: &mut PublicSocketClient, - status: &mut D2bTargetStatus, - ) -> Result<(), D2bProviderError> - where - T: AsyncRead + AsyncWrite + Unpin, - { - if !status.is_unsafe_local() { - return Ok(()); - } - match client.require_unsafe_local_shell() { - Ok(()) => Ok(()), - Err(err) => { - let err = - classify_client_error("checking unsafe-local shell support", None, err); - if let D2bProviderError::FeatureSkew { feature } = err { - status.require_feature(feature); - Ok(()) - } else { - Err(err) - } - } - } - } - } - - fn is_legacy_target(target: &str) -> bool { - !target.contains('.') && !target.starts_with("d2b://") - } - - fn select_workload<'a>( - target: &str, - workloads: &'a [WorkloadPublicSummary], - ) -> Result<&'a WorkloadPublicSummary, D2bProviderError> { - let exact = workloads - .iter() - .filter(|workload| workload.identity().target().as_str() == target) - .collect::>(); - match exact.as_slice() { - [workload] => return Ok(*workload), - [] => {} - _ => { - return Err(D2bProviderError::TargetResolution { - kind: "duplicate-canonical-target", - }) - } - } - - if !is_legacy_target(target) { - return Err(D2bProviderError::TargetResolution { - kind: "canonical-target-not-found", - }); - } - - let legacy = workloads - .iter() - .filter(|workload| { - workload - .identity() - .legacy_vm_name() - .map(|name| name.as_str() == target) - .unwrap_or(false) - }) - .collect::>(); - match legacy.as_slice() { - [workload] => return Ok(*workload), - [] => {} - _ => { - return Err(D2bProviderError::TargetResolution { - kind: "ambiguous-legacy-vm-alias", - }) - } - } - - let workload_id = workloads - .iter() - .filter(|workload| workload.identity().workload_id().as_str() == target) - .collect::>(); - match workload_id.as_slice() { - [workload] => Ok(*workload), - [] => Err(D2bProviderError::TargetResolution { - kind: "legacy-target-not-found", - }), - _ => Err(D2bProviderError::TargetResolution { - kind: "ambiguous-workload-id-alias", - }), - } - } - - fn ensure_target_shell_ready(status: &D2bTargetStatus) -> Result<(), D2bProviderError> { - if let Some(feature) = status.required_feature() { - return Err(D2bProviderError::FeatureSkew { feature }); - } - if !status.shell_capable { - return Err(D2bProviderError::TargetUnavailable { - reason: "persistent-shell-capability-missing", - }); - } - if let Some(availability) = status.availability { - if availability != WorkloadAvailability::Ready { - return Err(D2bProviderError::TargetUnavailable { - reason: availability_slug(availability), - }); - } - } - Ok(()) - } - - fn availability_slug(availability: WorkloadAvailability) -> &'static str { - match availability { - WorkloadAvailability::Ready => "ready", - WorkloadAvailability::HelperUnavailable => "helper-unavailable", - WorkloadAvailability::HelperStale => "helper-stale", - WorkloadAvailability::UserManagerUnavailable => "user-manager-unavailable", - WorkloadAvailability::GraphicalSessionInactive => "graphical-session-inactive", - WorkloadAvailability::WaylandUnavailable => "wayland-unavailable", - WorkloadAvailability::ProxyUnavailable => "proxy-unavailable", - WorkloadAvailability::Degraded => "degraded", - } - } - - fn availability_message(availability: WorkloadAvailability) -> Option<&'static str> { - match availability { - WorkloadAvailability::Ready => None, - WorkloadAvailability::HelperUnavailable => Some("user-session helper unavailable"), - WorkloadAvailability::HelperStale => Some("user-session helper stale"), - WorkloadAvailability::UserManagerUnavailable => { - Some("systemd user manager unavailable") - } - WorkloadAvailability::GraphicalSessionInactive => Some("graphical session inactive"), - WorkloadAvailability::WaylandUnavailable => Some("Wayland unavailable"), - WorkloadAvailability::ProxyUnavailable => Some("Wayland proxy unavailable"), - WorkloadAvailability::Degraded => Some("provider degraded"), - } - } - - impl D2bTransport for NativeD2bTransport { - fn discover(&self) -> TransportFuture<'_, D2bDiscovery> { - Box::pin(async move { - let mut client = self.connect_client().await?; - let mut status = self.resolve_target(&mut client).await?; - Self::apply_unsafe_shell_requirement(&mut client, &mut status)?; - if !status.is_shell_ready() { - return Ok(D2bDiscovery { - status, - sessions: Vec::new(), - }); - } - let result = client_op( - "listing d2b shells", - None, - self.config.write_timeout, - client.shell_list(status.target().to_string()), - ) - .await?; - let sessions = result - .sessions - .into_iter() - .map(|entry| { - let name = entry.name.as_str().to_string(); - D2bSession { - id: name.clone(), - label: if entry.is_default { - format!("{name} (default)") - } else { - name.clone() - }, - target: status.target().to_string(), - workspace: None, - state: entry.state, - attached: entry.attached, - is_default: entry.is_default, - correlation_id: D2bCorrelationId::from_target_session( - "shell", - status.target(), - &name, - ), - } - }) - .collect(); - Ok(D2bDiscovery { status, sessions }) - }) - } - - fn attach(&self, request: D2bAttachRequest) -> TransportFuture<'_, D2bAttachedPane> { - Box::pin(async move { - let mut client = self.connect_client().await?; - let mut status = self.resolve_target(&mut client).await?; - Self::apply_unsafe_shell_requirement(&mut client, &mut status)?; - ensure_target_shell_ready(&status)?; - if let Some(name) = request.session_id.as_deref() { - super::validate_shell_name(name) - .map_err(|kind| D2bProviderError::AttachFailed { kind })?; - } - let name = request - .session_id - .clone() - .map(ShellName::new) - .transpose() - .map_err(|err| D2bProviderError::AttachFailed { - kind: err.to_string(), - })?; - let size = to_d2b_size(request.size); - let shell = client_op( - "attaching d2b shell", - None, - self.config.shell_attach_timeout, - client.attach_shell(status.target().to_string(), name, false, size), - ) - .await?; - let resolved_name = shell.resolved_name().as_str().to_string(); - let correlation_id = D2bCorrelationId::from_target_session( - "attached-shell", - status.target(), - &resolved_name, - ); - let handle = D2bPaneHandle { - target: status.target().to_string(), - session_id: resolved_name.clone(), - pane_id: D2bCorrelationId::from_target_session( - "pane", - status.target(), - &resolved_name, - ) - .to_string(), - correlation_id: correlation_id.clone(), - }; - let (command_tx, command_rx) = - async_channel::bounded(self.config.command_queue_depth); - let (event_tx, event_rx) = async_channel::bounded(self.config.event_queue_depth); - spawn_native_actor( - shell, - command_rx, - event_tx, - self.config.clone(), - correlation_id, - ); - Ok(D2bAttachedPane::new(handle, command_tx, event_rx)) - }) - } - } - - pub fn native_domain( - target: impl Into, - config: D2bRuntimeConfig, - ) -> anyhow::Result { - let target = super::normalize_d2b_target(&target.into()).map_err(anyhow::Error::msg)?; - Ok(D2bDomain::new( - super::target_domain_key(&target), - target.clone(), - Arc::new(NativeD2bTransport::new(target, config)?), - )) - } - - pub fn native_domain_with_name( - name: impl Into, - target: impl Into, - config: D2bRuntimeConfig, - ) -> anyhow::Result { - let target = super::normalize_d2b_target(&target.into()).map_err(anyhow::Error::msg)?; - Ok(D2bDomain::new( - name, - target.clone(), - Arc::new(NativeD2bTransport::new(target, config)?), - )) - } - - async fn async_unix_connect(path: &Path) -> std::io::Result { - let sockaddr = SockAddr::unix(path)?; - let socket = Socket::new( - SocketDomain::UNIX, - Type::from(libc::SOCK_SEQPACKET | libc::SOCK_CLOEXEC | libc::SOCK_NONBLOCK), - None, - )?; - let connecting = match socket.connect(&sockaddr) { - Ok(()) => false, - Err(error) - if error.kind() == std::io::ErrorKind::WouldBlock - || error.raw_os_error().is_some_and(|code| { - matches!( - code, - libc::EINPROGRESS | libc::EALREADY | libc::EAGAIN | libc::EINTR - ) - }) => - { - true - } - Err(error) => return Err(error), - }; - let socket = D2bSocket::new(socket)?; - if connecting { - socket.fd.writable().await?; - if let Some(error) = socket.fd.get_ref().take_error()? { - return Err(error); - } - } - Ok(socket) - } - - fn errno_to_io(error: nix::errno::Errno) -> std::io::Error { - std::io::Error::from_raw_os_error(error as i32) - } - - fn classify_socket_path(path: &Path) -> SocketClass { - let rendered = path.to_string_lossy(); - let trimmed = rendered.trim(); - if trimmed == "/run/d2b/public.sock" { - return SocketClass::PublicDaemon; - } - if trimmed.is_empty() || trimmed == "/run/d2b/priv.sock" { - return SocketClass::PrivilegedBroker; - } - match path.file_name().and_then(|name| name.to_str()) { - Some("priv.sock" | "broker.sock" | "priv-broker.sock") => SocketClass::PrivilegedBroker, - _ => SocketClass::Other, - } - } - - async fn timeout_result( - operation: &'static str, - correlation: Option, - timeout: Duration, - fut: F, - ) -> Result - where - F: StdFuture>, - { - futures::pin_mut!(fut); - let timer = smol::Timer::after(timeout); - futures::pin_mut!(timer); - match future::select(fut, timer).await { - future::Either::Left((result, _)) => { - result.map_err(|_| D2bProviderError::Disconnected { - operation, - correlation, - }) - } - future::Either::Right((_, _)) => Err(D2bProviderError::Timeout { - operation, - correlation, - }), - } - } - - async fn client_op( - operation: &'static str, - correlation: Option, - timeout: Duration, - fut: F, - ) -> Result - where - F: StdFuture>, - { - futures::pin_mut!(fut); - let timer = smol::Timer::after(timeout); - futures::pin_mut!(timer); - match future::select(fut, timer).await { - future::Either::Left((result, _)) => { - result.map_err(|err| classify_client_error(operation, correlation, err)) - } - future::Either::Right((_, _)) => Err(D2bProviderError::Timeout { - operation, - correlation, - }), - } - } - - fn classify_client_error( - operation: &'static str, - correlation: Option, - err: ClientError, - ) -> D2bProviderError { - match err { - ClientError::Daemon { kind } if kind.contains("stale-session") => { - D2bProviderError::StaleSession { - operation, - correlation: correlation.unwrap_or_else(|| { - D2bCorrelationId::from_sensitive("unknown-stale-session", operation) - }), - } - } - ClientError::Daemon { kind } if kind.contains("timeout") => D2bProviderError::Timeout { - operation, - correlation, - }, - ClientError::Daemon { kind } => D2bProviderError::Daemon { - operation, - kind, - correlation, - }, - ClientError::Core(ToolkitError::FeatureUnavailable { feature }) => { - D2bProviderError::FeatureSkew { feature } - } - ClientError::Core(ToolkitError::InvalidTarget { .. }) => { - D2bProviderError::TargetResolution { - kind: "invalid-target", - } - } - ClientError::Core(_) => D2bProviderError::Disconnected { - operation, - correlation, - }, - ClientError::Codec { .. } - | ClientError::Hello { .. } - | ClientError::UnexpectedResponse { .. } - | ClientError::CorrelationMismatch => D2bProviderError::AttachFailed { - kind: err.to_string(), - }, - } - } - - fn spawn_native_actor( - shell: D2bShell, - command_rx: Receiver, - event_tx: Sender, - config: D2bRuntimeConfig, - correlation_id: D2bCorrelationId, - ) { - smol::spawn(async move { - run_native_actor(shell, command_rx, event_tx, config, correlation_id).await; - }) - .detach(); - } - - async fn run_native_actor( - mut shell: D2bShell, - command_rx: Receiver, - event_tx: Sender, - config: D2bRuntimeConfig, - correlation_id: D2bCorrelationId, - ) { - loop { - while let Ok(command) = command_rx.try_recv() { - if handle_actor_command(&mut shell, command, &event_tx, &config, &correlation_id) - .await - .is_break() - { - let _ = client_op( - "closing d2b shell attach", - Some(correlation_id.clone()), - config.write_timeout, - shell.close_attach(), - ) - .await; - let _ = event_tx.try_send(D2bPaneEvent::Closed); - return; - } - } - - if command_rx.is_closed() { - let _ = client_op( - "closing detached d2b shell", - Some(correlation_id.clone()), - config.write_timeout, - shell.close_attach(), - ) - .await; - let _ = event_tx.try_send(D2bPaneEvent::Closed); - return; - } - - for stream in [TerminalStream::Stdout] { - match client_op( - "reading d2b shell output", - Some(correlation_id.clone()), - config.read_timeout, - shell.read_output(stream, config.output_read_max, true, config.output_wait_ms), - ) - .await - { - Ok(chunk) => { - if chunk.dropped_bytes > 0 || chunk.truncated { - let err = D2bProviderError::DroppedOutput { - correlation: correlation_id.clone(), - }; - send_terminal_event(&event_tx, D2bPaneEvent::ReattachRequired(err)) - .await; - let _ = shell.close_attach().await; - return; - } - let encoded = chunk.data_base64.into_inner_for_wire(); - if !encoded.is_empty() { - match STANDARD.decode(encoded) { - Ok(bytes) if !bytes.is_empty() => { - if event_tx.try_send(D2bPaneEvent::Output(bytes)).is_err() { - let err = D2bProviderError::Backpressure { - operation: "forwarding d2b shell output", - correlation: correlation_id.clone(), - }; - send_terminal_event( - &event_tx, - D2bPaneEvent::ReattachRequired(err), - ) - .await; - let _ = shell.close_attach().await; - return; - } - } - Ok(_) => {} - Err(_) => { - let err = D2bProviderError::Disconnected { - operation: "decoding d2b shell output", - correlation: Some(correlation_id.clone()), - }; - send_terminal_event( - &event_tx, - D2bPaneEvent::ReattachRequired(err), - ) - .await; - let _ = shell.close_attach().await; - return; - } - } - } - } - Err(err) => { - send_terminal_event(&event_tx, D2bPaneEvent::ReattachRequired(err)).await; - let _ = shell.close_attach().await; - return; - } - } - } - } - } - - enum ActorCommandResult { - Continue, - Break, - } - - impl ActorCommandResult { - fn is_break(&self) -> bool { - matches!(self, Self::Break) - } - } - - async fn handle_actor_command( - shell: &mut D2bShell, - command: D2bPaneCommand, - event_tx: &Sender, - config: &D2bRuntimeConfig, - correlation_id: &D2bCorrelationId, - ) -> ActorCommandResult { - match command { - D2bPaneCommand::Write { bytes } => { - let result = client_op( - "writing d2b shell input", - Some(correlation_id.clone()), - config.write_timeout, - shell.write_bytes(Redacted::new(bytes), false), - ) - .await; - match result { - Ok(write) if write.backpressured || write.stdin_closed => { - let err = D2bProviderError::Backpressure { - operation: "writing d2b shell input", - correlation: correlation_id.clone(), - }; - send_terminal_event(event_tx, D2bPaneEvent::ReattachRequired(err)).await; - ActorCommandResult::Break - } - Ok(_) => ActorCommandResult::Continue, - Err(err) => { - send_terminal_event(event_tx, D2bPaneEvent::ReattachRequired(err)).await; - ActorCommandResult::Break - } - } - } - D2bPaneCommand::Resize { size } => { - let result = client_op( - "resizing d2b shell", - Some(correlation_id.clone()), - config.write_timeout, - shell.resize( - size.rows.try_into().unwrap_or(u32::MAX), - size.cols.try_into().unwrap_or(u32::MAX), - ), - ) - .await; - match result { - Ok(_) => ActorCommandResult::Continue, - Err(err) => { - send_terminal_event(event_tx, D2bPaneEvent::ReattachRequired(err)).await; - ActorCommandResult::Break - } - } - } - D2bPaneCommand::CloseAttach { reason: _ } => ActorCommandResult::Break, - } - } - - async fn send_terminal_event(event_tx: &Sender, event: D2bPaneEvent) { - let _ = event_tx.send(event).await; - } - - fn to_d2b_size(size: TerminalSize) -> D2bTerminalSize { - D2bTerminalSize { - rows: size.rows.try_into().unwrap_or(u32::MAX), - cols: size.cols.try_into().unwrap_or(u32::MAX), - } - } - - fn try_send_command( - tx: &Sender, - command: D2bPaneCommand, - operation: &'static str, - correlation: D2bCorrelationId, - ) -> Result<(), D2bProviderError> { - match tx.try_send(command) { - Ok(()) => Ok(()), - Err(TrySendError::Full(_)) => { - tx.close(); - Err(D2bProviderError::Backpressure { - operation, - correlation, - }) - } - Err(TrySendError::Closed(_)) => Err(D2bProviderError::Disconnected { - operation, - correlation: Some(correlation), - }), - } - } - - pub struct D2bDomain { - id: DomainId, - name: String, - target: String, - transport: Arc, - state: Mutex, - panes: Mutex>>, - } - - impl D2bDomain { - pub fn new( - name: impl Into, - target: impl Into, - transport: Arc, - ) -> Self { - Self { - id: alloc_domain_id(), - name: name.into(), - target: target.into(), - transport, - state: Mutex::new(DomainState::Detached), - panes: Mutex::new(Vec::new()), - } - } - - pub fn target(&self) -> &str { - &self.target - } - - pub fn vm_name(&self) -> &str { - self.target() - } - - pub async fn discover(&self) -> anyhow::Result { - self.transport.discover().await - } - - pub async fn discover_sessions(&self) -> anyhow::Result> { - Ok(self.discover().await?.sessions) - } - } - - #[async_trait(?Send)] - impl Domain for D2bDomain { - async fn spawn_pane( - &self, - size: TerminalSize, - command: Option, - _command_dir: Option, - ) -> anyhow::Result> { - let session_id = d2b_session_from_command(command)?; - - let attached = self - .transport - .attach(D2bAttachRequest { session_id, size }) - .await?; - - let concrete_pane = D2bPane::new(self.id, size, attached); - self.panes.lock().push(Arc::downgrade(&concrete_pane)); - let pane: Arc = concrete_pane; - Mux::get().add_pane(&pane)?; - Ok(pane) - } - - fn spawnable(&self) -> bool { - true - } - - fn detachable(&self) -> bool { - true - } - - fn domain_id(&self) -> DomainId { - self.id - } - - fn domain_name(&self) -> &str { - &self.name - } - - async fn domain_label(&self) -> String { - match self.discover().await { - Ok(discovery) => { - let availability = if discovery.status.is_shell_ready() { - if discovery.sessions.is_empty() { - "no sessions".to_string() - } else { - format!("{} session(s)", discovery.sessions.len()) - } - } else { - "unavailable".to_string() - }; - match discovery.status.warning_text() { - Some(warning) => { - format!("d2b `{}` — {availability} — {warning}", self.name) - } - None => format!("d2b `{}` — {availability}", self.name), - } - } - Err(err) => { - log::debug!("d2b session discovery failed: {err:#}"); - format!( - "d2b `{}` — unavailable — {}", - self.name, - super::sanitize_display_label(&err.to_string()) - ) - } - } - } - - async fn attach(&self, _window_id: Option) -> anyhow::Result<()> { - *self.state.lock() = DomainState::Attached; - Ok(()) - } - - fn detach(&self) -> anyhow::Result<()> { - *self.state.lock() = DomainState::Detached; - let mut panes = self.panes.lock(); - panes.retain(|pane| { - if let Some(pane) = pane.upgrade() { - pane.detach_non_destructive(D2bDetachReason::DomainDetach); - true - } else { - false - } - }); - Ok(()) - } - - fn state(&self) -> DomainState { - *self.state.lock() - } - } - - pub struct D2bPane { - pane_id: PaneId, - domain_id: DomainId, - terminal: Mutex, - writer: Mutex>, - handle: D2bPaneHandle, - command_tx: Sender, - detached: AtomicBool, - latest_title: Arc>, - } - - struct D2bPaneNotifHandler { - pane_id: PaneId, - latest_title: Arc>, - } - - fn title_from_alert(alert: &Alert) -> Option { - match alert { - Alert::WindowTitleChanged(title) => Some(title.clone()), - Alert::IconTitleChanged(title) => Some(title.clone().unwrap_or_default()), - _ => None, - } - } - - impl AlertHandler for D2bPaneNotifHandler { - fn alert(&mut self, alert: Alert) { - if let Some(title) = title_from_alert(&alert) { - *self.latest_title.lock() = title; - } - let pane_id = self.pane_id; - promise::spawn::spawn_into_main_thread(async move { - let mux = Mux::get(); - if let Alert::TabTitleChanged(title) = &alert { - if let Some((_domain, _window_id, tab_id)) = mux.resolve_pane_id(pane_id) { - if let Some(tab) = mux.get_tab(tab_id) { - tab.set_title(title.as_deref().unwrap_or("")); - } - } - } - mux.notify(MuxNotification::Alert { pane_id, alert }); - }) - .detach(); - } - } - - impl D2bPane { - pub fn new( - domain_id: DomainId, - size: TerminalSize, - attached: D2bAttachedPane, - ) -> Arc { - let pane_id = alloc_pane_id(); - let latest_title = Arc::new(Mutex::new(String::new())); - let mut terminal = wezterm_term::Terminal::new( - size, - Arc::new(config::TermConfig::new()), - config::branding::APP_NAME_DISPLAY, - config::wezterm_version(), - Box::new(D2bInputWriter { - command_tx: attached.command_tx.clone(), - correlation_id: attached.handle.correlation_id.clone(), - }), - ); - terminal.set_notification_handler(Box::new(D2bPaneNotifHandler { - pane_id, - latest_title: Arc::clone(&latest_title), - })); - - let pane = Arc::new(Self { - pane_id, - domain_id, - terminal: Mutex::new(terminal), - writer: Mutex::new(Box::new(D2bInputWriter { - command_tx: attached.command_tx.clone(), - correlation_id: attached.handle.correlation_id.clone(), - })), - handle: attached.handle, - command_tx: attached.command_tx, - detached: AtomicBool::new(false), - latest_title, - }); - spawn_event_forwarder(Arc::downgrade(&pane), attached.event_rx); - pane - } - - fn detach_non_destructive(&self, reason: D2bDetachReason) { - if self.detached.swap(true, Ordering::SeqCst) { - return; - } - if let Err(err) = try_send_command( - &self.command_tx, - D2bPaneCommand::CloseAttach { reason }, - "closing d2b shell attach", - self.handle.correlation_id.clone(), - ) { - log::warn!("failed to enqueue d2b close attach: {err}"); - } - } - - pub fn close_non_destructive(&self) { - self.detach_non_destructive(D2bDetachReason::PaneClose) - } - - fn guest_title(&self) -> String { - let latest_title = self.latest_title.lock().clone(); - if latest_title.is_empty() { - self.terminal.lock().get_title().to_string() - } else { - latest_title - } - } - } - - impl Pane for D2bPane { - fn pane_id(&self) -> PaneId { - self.pane_id - } - - fn get_cursor_position(&self) -> StableCursorPosition { - terminal_get_cursor_position(&mut self.terminal.lock()) - } - - fn get_current_seqno(&self) -> SequenceNo { - self.terminal.lock().current_seqno() - } - - fn get_changed_since( - &self, - lines: Range, - seqno: SequenceNo, - ) -> RangeSet { - terminal_get_dirty_lines(&mut self.terminal.lock(), lines, seqno) - } - - fn get_lines(&self, lines: Range) -> (StableRowIndex, Vec) { - terminal_get_lines(&mut self.terminal.lock(), lines) - } - - fn with_lines_mut( - &self, - lines: Range, - with_lines: &mut dyn crate::pane::WithPaneLines, - ) { - terminal_with_lines_mut(&mut self.terminal.lock(), lines, with_lines) - } - - fn for_each_logical_line_in_stable_range_mut( - &self, - lines: Range, - for_line: &mut dyn crate::pane::ForEachPaneLogicalLine, - ) { - terminal_for_each_logical_line_in_stable_range_mut( - &mut self.terminal.lock(), - lines, - for_line, - ); - } - - fn get_logical_lines(&self, lines: Range) -> Vec { - crate::pane::impl_get_logical_lines_via_get_lines(self, lines) - } - - fn get_dimensions(&self) -> RenderableDimensions { - terminal_get_dimensions(&mut self.terminal.lock()) - } - - fn get_title(&self) -> String { - let guest_title = self.guest_title(); - super::d2b_tab_title(&self.handle.target, &self.handle.session_id, &guest_title) - } - - fn send_paste(&self, text: &str) -> anyhow::Result<()> { - for chunk in text.as_bytes().chunks(MAX_INPUT_CHUNK) { - try_send_command( - &self.command_tx, - D2bPaneCommand::Write { - bytes: chunk.to_vec(), - }, - "queueing d2b paste", - self.handle.correlation_id.clone(), - ) - .map_err(anyhow::Error::from)?; - } - Ok(()) - } - - fn reader(&self) -> anyhow::Result>> { - Ok(None) - } - - fn writer(&self) -> MappedMutexGuard<'_, dyn Write> { - MutexGuard::map(self.writer.lock(), |writer| { - let w: &mut dyn Write = writer.as_mut(); - w - }) - } - - fn resize(&self, size: TerminalSize) -> anyhow::Result<()> { - self.terminal.lock().resize(size); - try_send_command( - &self.command_tx, - D2bPaneCommand::Resize { size }, - "queueing d2b resize", - self.handle.correlation_id.clone(), - ) - .map_err(anyhow::Error::from) - } - - fn key_down(&self, key: KeyCode, mods: KeyModifiers) -> anyhow::Result<()> { - self.terminal.lock().key_down(key, mods) - } - - fn key_up(&self, key: KeyCode, mods: KeyModifiers) -> anyhow::Result<()> { - self.terminal.lock().key_up(key, mods) - } - - fn mouse_event(&self, event: MouseEvent) -> anyhow::Result<()> { - self.terminal.lock().mouse_event(event) - } - - fn perform_actions(&self, actions: Vec) { - self.terminal.lock().perform_actions(actions) - } - - fn is_dead(&self) -> bool { - self.detached.load(Ordering::SeqCst) - } - - fn kill(&self) { - self.detach_non_destructive(D2bDetachReason::PaneKill); - } - - fn palette(&self) -> ColorPalette { - self.terminal.lock().palette() - } - - fn domain_id(&self) -> DomainId { - self.domain_id - } - - fn trusted_d2b_target(&self) -> Option<&str> { - Some(&self.handle.target) - } - - fn trusted_d2b_session(&self) -> Option<&str> { - Some(&self.handle.session_id) - } - - fn d2b_guest_title(&self) -> Option { - Some(self.guest_title()) - } - - fn can_close_without_prompting(&self, _reason: CloseReason) -> bool { - true - } - - fn is_mouse_grabbed(&self) -> bool { - self.terminal.lock().is_mouse_grabbed() - } - - fn is_alt_screen_active(&self) -> bool { - self.terminal.lock().is_alt_screen_active() - } - - fn get_current_working_dir(&self, _policy: CachePolicy) -> Option { - self.terminal.lock().get_current_dir().cloned() - } - - fn set_config(&self, config: Arc) { - self.terminal.lock().set_config(config); - } - - fn get_config(&self) -> Option> { - Some(self.terminal.lock().get_config()) - } - - fn copy_user_vars(&self) -> HashMap { - HashMap::from([ - ( - "weezterm.d2b.target".to_string(), - self.handle.target.clone(), - ), - ("weezterm.d2b.vm".to_string(), self.handle.target.clone()), - ( - "weezterm.d2b.session".to_string(), - self.handle.session_id.clone(), - ), - ]) - } - } - - impl Drop for D2bPane { - fn drop(&mut self) { - self.detach_non_destructive(D2bDetachReason::Drop); - } - } - - struct D2bInputWriter { - command_tx: Sender, - correlation_id: D2bCorrelationId, - } - - impl Write for D2bInputWriter { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - for chunk in buf.chunks(MAX_INPUT_CHUNK) { - try_send_command( - &self.command_tx, - D2bPaneCommand::Write { - bytes: chunk.to_vec(), - }, - "queueing d2b terminal input", - self.correlation_id.clone(), - ) - .map_err(|err| IoError::new(ErrorKind::WouldBlock, err))?; - } - Ok(buf.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - fn spawn_event_forwarder(pane: Weak, event_rx: Receiver) { - smol::spawn(async move { - let mut parser = Parser::new(); - while let Ok(event) = event_rx.recv().await { - let Some(pane) = pane.upgrade() else { break }; - match event { - D2bPaneEvent::Output(bytes) => { - let mut actions = Vec::new(); - parser.parse(&bytes, |action| actions.push(action)); - if !actions.is_empty() { - pane.perform_actions(actions); - Mux::notify_from_any_thread(MuxNotification::PaneOutput( - pane.pane_id(), - )); - } - } - D2bPaneEvent::Closed => { - pane.detached.store(true, Ordering::SeqCst); - Mux::notify_from_any_thread(MuxNotification::PaneOutput(pane.pane_id())); - break; - } - D2bPaneEvent::ReattachRequired(err) => { - pane.detached.store(true, Ordering::SeqCst); - log::warn!("d2b pane requires reattach: {err}"); - Mux::notify_from_any_thread(MuxNotification::PaneOutput(pane.pane_id())); - break; - } - } - } - if let Some(pane) = pane.upgrade() { - if !pane.detached.swap(true, Ordering::SeqCst) { - Mux::notify_from_any_thread(MuxNotification::PaneOutput(pane.pane_id())); - } - } - }) - .detach(); - } - - pub struct UnsupportedD2bTransport; - - impl D2bTransport for UnsupportedD2bTransport { - fn discover(&self) -> TransportFuture<'_, D2bDiscovery> { - Box::pin(async { Err(anyhow!("native d2b transport is not wired yet")) }) - } - - fn attach(&self, _request: D2bAttachRequest) -> TransportFuture<'_, D2bAttachedPane> { - Box::pin(async { Err(anyhow!("native d2b transport is not wired yet")) }) - } - } - - pub fn unsupported_domain(name: impl Into) -> D2bDomain { - D2bDomain::new(name, "unsupported", Arc::new(UnsupportedD2bTransport)) - } - - #[cfg(test)] - mod test { - use super::*; - use d2b_toolkit_core::{NegotiatedCapabilities, PublicResponse, WorkloadOpResponse}; - use futures::io::{AsyncReadExt, Cursor}; - use parking_lot::Mutex; - - #[derive(Default)] - struct FakeTransport { - sessions: Vec, - status: Option, - attach_error: Option, - attach_requests: Arc>>, - queue_depth: usize, - event_depth: usize, - detach_calls: Arc>>, - inputs: Arc>>>, - resizes: Arc>>, - pause_commands: bool, - emit_disconnect: bool, - emit_stale: bool, - emit_drop: bool, - emit_write_timeout: bool, - latency: Duration, - } - - impl FakeTransport { - fn attached(&self, request: D2bAttachRequest) -> D2bAttachedPane { - self.attach_requests.lock().push(request.clone()); - let queue_depth = if self.queue_depth == 0 { - DEFAULT_QUEUE_DEPTH - } else { - self.queue_depth - }; - let event_depth = if self.event_depth == 0 { - DEFAULT_EVENT_DEPTH - } else { - self.event_depth - }; - let (command_tx, command_rx) = async_channel::bounded(queue_depth); - let (event_tx, event_rx) = async_channel::bounded(event_depth); - let target = self - .status - .as_ref() - .map(|status| status.target().to_string()) - .unwrap_or_else(|| "work".to_string()); - let session_id = request - .session_id - .clone() - .unwrap_or_else(|| "session-1".to_string()); - let correlation_id = - D2bCorrelationId::from_target_session("fake-pane", &target, &session_id); - let handle = D2bPaneHandle { - target, - session_id, - pane_id: "pane-1".to_string(), - correlation_id: correlation_id.clone(), - }; - if self.pause_commands { - smol::spawn(async move { - smol::Timer::after(Duration::from_secs(60)).await; - drop(command_rx); - }) - .detach(); - } else { - let detach_calls = Arc::clone(&self.detach_calls); - let inputs = Arc::clone(&self.inputs); - let resizes = Arc::clone(&self.resizes); - let latency = self.latency; - let emit_disconnect = self.emit_disconnect; - let emit_stale = self.emit_stale; - let emit_drop = self.emit_drop; - let emit_write_timeout = self.emit_write_timeout; - smol::spawn(async move { - if latency > Duration::ZERO { - smol::Timer::after(latency).await; - } - if emit_disconnect { - let _ = event_tx - .send(D2bPaneEvent::ReattachRequired( - D2bProviderError::Disconnected { - operation: "fake disconnect", - correlation: Some(correlation_id.clone()), - }, - )) - .await; - return; - } - if emit_stale { - let _ = event_tx - .send(D2bPaneEvent::ReattachRequired( - D2bProviderError::StaleSession { - operation: "fake stale session", - correlation: correlation_id.clone(), - }, - )) - .await; - return; - } - if emit_drop { - let _ = event_tx - .send(D2bPaneEvent::ReattachRequired( - D2bProviderError::DroppedOutput { - correlation: correlation_id.clone(), - }, - )) - .await; - return; - } - while let Ok(command) = command_rx.recv().await { - match command { - D2bPaneCommand::Write { bytes } => { - if emit_write_timeout { - let _ = event_tx - .send(D2bPaneEvent::ReattachRequired( - D2bProviderError::Timeout { - operation: "fake write timeout", - correlation: Some(correlation_id.clone()), - }, - )) - .await; - break; - } - inputs.lock().push(bytes) - } - D2bPaneCommand::Resize { size } => resizes.lock().push(size), - D2bPaneCommand::CloseAttach { reason } => { - detach_calls.lock().push(reason); - let _ = event_tx.try_send(D2bPaneEvent::Closed); - break; - } - } - } - }) - .detach(); - } - D2bAttachedPane::new(handle, command_tx, event_rx) - } - } - - impl D2bTransport for FakeTransport { - fn discover(&self) -> TransportFuture<'_, D2bDiscovery> { - let sessions = self.sessions.clone(); - let status = self - .status - .clone() - .unwrap_or_else(|| D2bTargetStatus::legacy("work".to_string())); - Box::pin(async move { Ok(D2bDiscovery { status, sessions }) }) - } - - fn attach(&self, request: D2bAttachRequest) -> TransportFuture<'_, D2bAttachedPane> { - if let Some(err) = self.attach_error.clone() { - return Box::pin(async move { Err(anyhow::Error::new(err)) }); - } - let pane = self.attached(request); - Box::pin(async move { Ok(pane) }) - } - } - - fn size() -> TerminalSize { - TerminalSize { - rows: 24, - cols: 80, - pixel_width: 640, - pixel_height: 480, - dpi: 96, - } - } - - fn unsafe_workload_fixture() -> WorkloadPublicSummary { - // Mirrored from d2b-toolkit v0.2.0's public-workload-v3-v1 fixtures. - let response: PublicResponse = serde_json::from_str(include_str!( - "../test-data/public-workload-v3-v1/unsafe-local-list-response.json" - )) - .unwrap(); - match response { - PublicResponse::Workload { - response: WorkloadOpResponse::List(mut result), - .. - } => result.workloads.remove(0), - _ => panic!("toolkit unsafe-local fixture had an unexpected shape"), - } - } - - fn handle() -> D2bPaneHandle { - D2bPaneHandle { - target: "work".to_string(), - session_id: "session-1".to_string(), - pane_id: "pane-1".to_string(), - correlation_id: D2bCorrelationId::from_sensitive("test", "session-1"), - } - } - - fn attached_with_transport(transport: &FakeTransport) -> D2bAttachedPane { - transport.attached(D2bAttachRequest { - session_id: Some("session-1".to_string()), - size: size(), - }) - } - - #[test] - fn shell_attach_timeout_covers_daemon_guest_control_budget() { - let config = D2bRuntimeConfig::default(); - assert_eq!(config.shell_attach_timeout, Duration::from_secs(15)); - assert!(config.shell_attach_timeout > config.read_timeout); - assert!(config.shell_attach_timeout > config.write_timeout); - } - - #[test] - fn native_transport_refuses_privileged_socket_before_connect() { - let transport = NativeD2bTransport::new( - "work", - D2bRuntimeConfig { - socket_path: PathBuf::from("/run/d2b/priv.sock"), - ..D2bRuntimeConfig::default() - }, - ) - .unwrap(); - let err = match smol::block_on(transport.connect_client()) { - Ok(_) => panic!("priv socket should be refused"), - Err(err) => err, - }; - let rendered = err.to_string(); - assert!(rendered.contains("refused privileged broker socket")); - assert!(!rendered.contains("/run/d2b/priv.sock")); - } - - #[test] - fn toolkit_fixture_exposes_unsafe_local_and_helper_posture() { - let workload = unsafe_workload_fixture(); - let status = D2bTargetStatus::from_workload(&workload); - - assert_eq!(status.target(), "tools.host.d2b"); - assert_eq!( - status.provider_kind, - Some(WorkloadProviderKind::UnsafeLocal) - ); - assert_eq!( - status.isolation, - Some(d2b_toolkit_core::IsolationPosture::UnsafeLocal) - ); - assert_eq!( - status.availability, - Some(WorkloadAvailability::HelperUnavailable) - ); - let warning = status.warning_text().unwrap(); - assert!(warning.contains("UNSAFE LOCAL — NO ISOLATION")); - assert!(warning.contains("helper unavailable")); - assert!(!status.is_shell_ready()); - } - - #[test] - fn unsafe_local_feature_skew_fails_closed_before_shell_operations() { - let mut status = D2bTargetStatus::new( - "tools.host.d2b", - WorkloadProviderKind::UnsafeLocal, - d2b_toolkit_core::IsolationPosture::UnsafeLocal, - WorkloadAvailability::Ready, - true, - ) - .unwrap(); - let capabilities = NegotiatedCapabilities::from_features([ - KnownFeatureFlag::ConfiguredLaunchV1.wire_value(), - KnownFeatureFlag::UnsafeLocalProviderV1.wire_value(), - ]); - let mut client = PublicSocketClient::with_negotiated_capabilities( - Cursor::new(Vec::new()), - capabilities, - ); - - NativeD2bTransport::apply_unsafe_shell_requirement(&mut client, &mut status).unwrap(); - - assert_eq!( - status.required_feature(), - Some(KnownFeatureFlag::UnsafeLocalShellV1) - ); - let err = ensure_target_shell_ready(&status).unwrap_err(); - assert!(matches!( - err, - D2bProviderError::FeatureSkew { - feature: KnownFeatureFlag::UnsafeLocalShellV1 - } - )); - } - - #[test] - fn unsafe_target_keeps_one_canonical_public_socket_shell_route() { - let mut status = D2bTargetStatus::new( - "tools.host.d2b", - WorkloadProviderKind::UnsafeLocal, - d2b_toolkit_core::IsolationPosture::UnsafeLocal, - WorkloadAvailability::Ready, - true, - ) - .unwrap(); - let capabilities = NegotiatedCapabilities::from_features([ - KnownFeatureFlag::ConfiguredLaunchV1.wire_value(), - KnownFeatureFlag::UnsafeLocalProviderV1.wire_value(), - KnownFeatureFlag::UnsafeLocalShellV1.wire_value(), - ]); - let mut client = PublicSocketClient::with_negotiated_capabilities( - Cursor::new(Vec::new()), - capabilities, - ); - - NativeD2bTransport::apply_unsafe_shell_requirement(&mut client, &mut status).unwrap(); - ensure_target_shell_ready(&status).unwrap(); - let transport = - NativeD2bTransport::new(status.target(), D2bRuntimeConfig::default()).unwrap(); - - assert_eq!(transport.target, "tools.host.d2b"); - assert_eq!( - transport.config.socket_path, - PathBuf::from("/run/d2b/public.sock") - ); - } - - #[test] - fn debug_redacts_session_handle_shell_and_terminal_data_but_keeps_digest() { - let session = D2bSession { - id: "session-secret".to_string(), - label: "quiet-otter".to_string(), - target: "work".to_string(), - workspace: Some("private".to_string()), - state: ShellSessionState::Detached, - attached: false, - is_default: false, - correlation_id: D2bCorrelationId::from_sensitive("session", "session-secret"), - }; - let handle = handle(); - let attach = D2bAttachRequest { - session_id: Some("session-secret".to_string()), - size: size(), - }; - let write = D2bPaneCommand::Write { - bytes: b"terminal bytes and /home/alice".to_vec(), - }; - - for rendered in [format!("{session:?}"), format!("{handle:?}")] { - assert!(!rendered.contains("session-secret")); - assert!(!rendered.contains("quiet-otter")); - assert!(!rendered.contains("session-1")); - assert!(!rendered.contains("pane-1")); - assert!(rendered.contains("redacted")); - assert!(rendered.contains("d2b:")); - } - let rendered = format!("{attach:?}"); - assert!(!rendered.contains("session-secret")); - assert!(rendered.contains("redacted")); - let rendered = format!("{write:?}"); - assert!(rendered.contains("Write")); - assert!(!rendered.contains("terminal bytes")); - assert!(!rendered.contains("alice")); - } - - fn wait_for(mut predicate: F) - where - F: FnMut() -> bool, - { - for _ in 0..50 { - if predicate() { - return; - } - smol::block_on(smol::Timer::after(Duration::from_millis(10))); - } - assert!(predicate()); - } - - #[test] - fn discovery_uses_transport_without_d2bd() { - let transport = Arc::new(FakeTransport { - sessions: vec![D2bSession { - id: "session-1".to_string(), - label: "work".to_string(), - target: "work".to_string(), - workspace: None, - state: ShellSessionState::Detached, - attached: false, - is_default: false, - correlation_id: D2bCorrelationId::from_sensitive("session", "session-1"), - }], - ..Default::default() - }); - let domain = D2bDomain::new("d2b", "work", transport); - - let sessions = smol::block_on(domain.discover_sessions()).unwrap(); - assert_eq!(sessions[0].id, "session-1"); - } - - #[test] - fn domain_label_shows_unsafe_local_and_helper_unavailable() { - let status = D2bTargetStatus::new( - "tools.host.d2b", - WorkloadProviderKind::UnsafeLocal, - d2b_toolkit_core::IsolationPosture::UnsafeLocal, - WorkloadAvailability::HelperUnavailable, - true, - ) - .unwrap(); - let domain = D2bDomain::new( - "host-tools", - "tools.host.d2b", - Arc::new(FakeTransport { - status: Some(status), - ..Default::default() - }), - ); - - let label = smol::block_on(domain.domain_label()); - assert!(label.contains("UNSAFE LOCAL — NO ISOLATION")); - assert!(label.contains("helper unavailable")); - assert!(label.contains("unavailable")); - } - - #[test] - fn attach_failure_is_typed() { - let err = D2bProviderError::AttachFailed { - kind: "guest-control-shell-timeout".to_string(), - }; - let transport = FakeTransport { - attach_error: Some(err), - ..Default::default() - }; - let result = smol::block_on(transport.attach(D2bAttachRequest { - session_id: None, - size: size(), - })); - let err = match result { - Ok(_) => panic!("attach unexpectedly succeeded"), - Err(err) => err, - }; - assert!(err.is::()); - } - - #[test] - fn kill_detaches_without_destroying_session() { - let transport = FakeTransport::default(); - let pane = D2bPane::new(1, size(), attached_with_transport(&transport)); - - pane.kill(); - pane.kill(); - drop(pane); - - wait_for(|| !transport.detach_calls.lock().is_empty()); - assert_eq!( - *transport.detach_calls.lock(), - vec![D2bDetachReason::PaneKill] - ); - } - - #[test] - fn drop_detaches_if_pane_was_not_closed() { - let transport = FakeTransport::default(); - let pane = D2bPane::new(1, size(), attached_with_transport(&transport)); - - drop(pane); - - wait_for(|| !transport.detach_calls.lock().is_empty()); - assert_eq!(*transport.detach_calls.lock(), vec![D2bDetachReason::Drop]); - } - - #[test] - fn explicit_close_uses_detach_not_kill() { - let transport = FakeTransport::default(); - let pane = D2bPane::new(1, size(), attached_with_transport(&transport)); - - pane.close_non_destructive(); - drop(pane); - - wait_for(|| !transport.detach_calls.lock().is_empty()); - assert_eq!( - *transport.detach_calls.lock(), - vec![D2bDetachReason::PaneClose] - ); - } - - #[test] - fn domain_detach_detaches_tracked_panes() { - let transport = Arc::new(FakeTransport::default()); - let domain = D2bDomain::new("d2b", "work", transport.clone()); - let pane = D2bPane::new(1, size(), attached_with_transport(&transport)); - domain.panes.lock().push(Arc::downgrade(&pane)); - - domain.detach().unwrap(); - drop(pane); - - wait_for(|| !transport.detach_calls.lock().is_empty()); - assert_eq!( - *transport.detach_calls.lock(), - vec![D2bDetachReason::DomainDetach] - ); - } - - #[test] - fn paste_and_resize_use_nonblocking_actor_queue() { - let transport = FakeTransport::default(); - let pane = D2bPane::new(1, size(), attached_with_transport(&transport)); - - pane.send_paste("hello").unwrap(); - pane.resize(TerminalSize { - rows: 40, - cols: 100, - pixel_width: 800, - pixel_height: 600, - dpi: 96, - }) - .unwrap(); - - wait_for(|| !transport.inputs.lock().is_empty()); - wait_for(|| !transport.resizes.lock().is_empty()); - assert_eq!(*transport.inputs.lock(), vec![b"hello".to_vec()]); - assert_eq!(transport.resizes.lock()[0].rows, 40); - } - - #[test] - fn full_pane_queue_returns_typed_backpressure_without_blocking() { - let transport = FakeTransport { - queue_depth: 1, - pause_commands: true, - ..Default::default() - }; - let pane = D2bPane::new(1, size(), attached_with_transport(&transport)); - - pane.send_paste("first").unwrap(); - let err = pane.send_paste("second").unwrap_err(); - assert!(err.is::()); - assert!(format!("{err}").contains("queue full")); - } - - #[test] - fn fake_transport_simulates_disconnect_stale_drop_write_timeout_and_latency() { - for (transport, expected) in [ - ( - FakeTransport { - emit_disconnect: true, - latency: Duration::from_millis(1), - ..Default::default() - }, - "disconnected", - ), - ( - FakeTransport { - emit_stale: true, - ..Default::default() - }, - "stale session", - ), - ( - FakeTransport { - emit_drop: true, - ..Default::default() - }, - "dropped terminal output", - ), - ( - FakeTransport { - emit_write_timeout: true, - ..Default::default() - }, - "write timeout", - ), - ] { - let pane = D2bPane::new(1, size(), attached_with_transport(&transport)); - if expected == "write timeout" { - pane.send_paste("trigger").unwrap(); - } - wait_for(|| pane.is_dead()); - assert!(pane.is_dead(), "{}", expected); - } - } - - #[test] - fn d2b_title_format_suffixes_target_and_session_once() { - assert_eq!( - super::super::d2b_tab_title("work", "build", ""), - "[work:build]" - ); - assert_eq!( - super::super::d2b_tab_title("work", "build", "nvim"), - "nvim [work:build]" - ); - assert_eq!( - super::super::d2b_tab_title("work", "build", "wezterm"), - "[work:build]" - ); - assert_eq!( - super::super::d2b_tab_title("work", "build", "nvim [work:build]"), - "nvim [work:build]" - ); - assert_eq!( - super::super::d2b_tab_title("work\x1b[31m", "\x07", "nvim\x1b]0;secret\x07"), - "nvim [work:unnamed]" - ); - } - - #[test] - fn d2b_title_width_truncates_only_the_untrusted_guest_title() { - let title = super::super::d2b_tab_title_for_width( - "work", - "build", - "[fake:admin] malicious title padded to hide identity", - 28, - ); - assert!(title.ends_with(" [work:build]"), "{}", title); - assert!(termwiz::cell::unicode_column_width(&title, None) <= 28); - - let repeated = super::super::d2b_tab_title_for_width( - "work", - "build", - "[fake:admin] padded [work:build]", - 20, - ); - assert!(repeated.ends_with(" [work:build]"), "{}", repeated); - assert_eq!(repeated.matches("[work:build]").count(), 1); - } - - #[test] - fn latest_window_or_icon_title_alert_wins() { - assert_eq!( - title_from_alert(&Alert::IconTitleChanged(Some("shell".to_string()))), - Some("shell".to_string()) - ); - assert_eq!( - title_from_alert(&Alert::WindowTitleChanged("copilot".to_string())), - Some("copilot".to_string()) - ); - assert_eq!( - title_from_alert(&Alert::IconTitleChanged(None)), - Some(String::new()) - ); - assert_eq!(title_from_alert(&Alert::Bell), None); - } - - #[test] - fn display_label_sanitizes_controls_and_has_placeholder() { - assert_eq!( - super::super::sanitize_display_label("build\x1b[31m"), - "build" - ); - assert_eq!( - super::super::sanitize_display_label("\x07\x1b[31m"), - "unnamed" - ); - let long = "a".repeat(128); - assert_eq!(super::super::sanitize_display_label(&long).len(), 96); - } - - #[test] - fn shell_name_validation_matches_d2b_grammar() { - for name in ["default", "build_1", "a.b-c", "9"] { - assert!(super::super::validate_shell_name(name).is_ok(), "{}", name); - } - for name in ["", "-bad", "has space", "slash/name", "{tmpl}"] { - assert!(super::super::validate_shell_name(name).is_err(), "{}", name); - } - } - - #[test] - fn spawn_command_env_selects_d2b_session_without_subprocess_bridge() { - let transport = FakeTransport::default(); - let mut command = portable_pty::CommandBuilder::new_default_prog(); - command.env(super::super::D2B_SHELL_NAME_ENV, "build"); - let session_id = d2b_session_from_command(Some(command)).unwrap(); - - let attached = smol::block_on(transport.attach(D2bAttachRequest { - session_id, - size: size(), - })) - .unwrap(); - let pane = D2bPane::new(1, size(), attached); - - assert_eq!( - transport.attach_requests.lock()[0].session_id.as_deref(), - Some("build") - ); - assert_eq!(pane.get_title(), "[work:build]"); - } - - #[test] - fn canonical_dotted_target_normalizes_and_generates_bounded_shell_name() { - assert_eq!( - super::super::normalize_d2b_target("d2b://tools.host.d2b").unwrap(), - "tools.host.d2b" - ); - let name = super::super::friendly_session_name("tools.host.d2b", &[]); - assert!(super::super::validate_shell_name(&name).is_ok()); - assert!(name.len() <= 64); - assert!(!name.contains("tools.host.d2b")); - assert_eq!( - super::super::friendly_session_name("work", &[]), - "work-shell" - ); - } - - #[test] - fn bound_vm_alias_normalizes_to_target_and_conflicts_fail_closed() { - assert_eq!( - super::super::resolve_bound_target_aliases(None, Some("work")).unwrap(), - Some("work".to_string()) - ); - assert_eq!( - super::super::resolve_bound_target_aliases( - Some("tools.host.d2b"), - Some("d2b://tools.host.d2b") - ) - .unwrap(), - Some("tools.host.d2b".to_string()) - ); - let err = - super::super::resolve_bound_target_aliases(Some("tools.host.d2b"), Some("work")) - .unwrap_err(); - assert!(err.contains(super::super::D2B_BOUND_TARGET_ENV)); - assert!(err.contains(super::super::D2B_BOUND_VM_ENV)); - assert!(!err.contains("tools.host.d2b")); - } - - #[test] - fn target_socket_and_domain_keys_are_bounded_stable_and_non_reversible() { - let base = PathBuf::from("runtime"); - let target = "tools.host.d2b"; - let path = super::super::target_mux_socket_path(&base, target); - let repeated = super::super::target_mux_socket_path(&base, target); - let with_scheme = super::super::target_mux_socket_path(&base, "d2b://tools.host.d2b"); - let other = super::super::target_mux_socket_path(&base, "corp.work.d2b"); - let domain_key = super::super::target_domain_key(target); - - assert_eq!(path, repeated); - assert_eq!(path, with_scheme); - assert_ne!(path, other); - assert!(!path.to_string_lossy().contains(target)); - assert!(!domain_key.contains(target)); - assert_eq!(domain_key.len(), 36); - assert!(path.to_string_lossy().len() < 80); - assert_eq!( - super::super::vm_mux_socket_path(&base, "work"), - super::super::target_mux_socket_path(&base, "work") - ); - } - - #[test] - fn reconnect_identity_uses_canonical_target_and_keeps_vm_user_var_alias() { - let status = D2bTargetStatus::new( - "tools.host.d2b", - WorkloadProviderKind::UnsafeLocal, - d2b_toolkit_core::IsolationPosture::UnsafeLocal, - WorkloadAvailability::Ready, - true, - ) - .unwrap(); - let transport = FakeTransport { - status: Some(status), - ..Default::default() - }; - let pane = D2bPane::new(1, size(), attached_with_transport(&transport)); - let vars = pane.copy_user_vars(); - - assert_eq!( - vars.get("weezterm.d2b.target").map(String::as_str), - Some("tools.host.d2b") - ); - assert_eq!( - vars.get("weezterm.d2b.vm").map(String::as_str), - Some("tools.host.d2b") - ); - assert_eq!(pane.get_title(), "[tools.host.d2b:session-1]"); - assert_eq!(pane.trusted_d2b_target(), Some("tools.host.d2b")); - assert_eq!(pane.trusted_d2b_session(), Some("session-1")); - - let first = D2bCorrelationId::from_target_session( - "attached-shell", - "tools.host.d2b", - "session-1", - ); - let second = D2bCorrelationId::from_target_session( - "attached-shell", - "tools.personal.d2b", - "session-1", - ); - assert_ne!(first, second); - } - - #[test] - fn idle_d2b_socket_yields_to_the_timeout_reactor() { - let (client, _server) = nix::sys::socket::socketpair( - nix::sys::socket::AddressFamily::Unix, - nix::sys::socket::SockType::SeqPacket, - None, - nix::sys::socket::SockFlag::SOCK_CLOEXEC - | nix::sys::socket::SockFlag::SOCK_NONBLOCK, - ) - .unwrap(); - let mut socket = D2bSocket::new(Socket::from(client)).unwrap(); - let mut byte = [0_u8; 1]; - let result = smol::block_on(timeout_result( - "testing idle socket", - None, - Duration::from_millis(20), - socket.read(&mut byte), - )); - assert!(matches!(result, Err(D2bProviderError::Timeout { .. }))); - } - - #[test] - fn oversized_seqpacket_is_rejected_via_msg_trunc_length() { - let (client, server) = nix::sys::socket::socketpair( - nix::sys::socket::AddressFamily::Unix, - nix::sys::socket::SockType::SeqPacket, - None, - nix::sys::socket::SockFlag::SOCK_CLOEXEC - | nix::sys::socket::SockFlag::SOCK_NONBLOCK, - ) - .unwrap(); - let mut socket = D2bSocket::with_packet_limit(Socket::from(client), 8).unwrap(); - let packet = [0_u8; 9]; - assert_eq!( - nix::sys::socket::send( - server.as_raw_fd(), - &packet, - nix::sys::socket::MsgFlags::empty(), - ) - .unwrap(), - packet.len() - ); - let mut byte = [0_u8; 1]; - let error = smol::block_on(socket.read(&mut byte)).unwrap_err(); - assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); - } - } -} - -#[cfg(not(target_os = "linux"))] -mod imp { - use crate::domain::{alloc_domain_id, Domain, DomainId, DomainState, SplitSource}; - use crate::pane::{Pane, PaneId}; - use crate::tab::{SplitRequest, Tab, TabId}; - use crate::window::WindowId; - use async_trait::async_trait; - use std::sync::Arc; - use wezterm_term::TerminalSize; - - pub struct D2bDomain; - - impl D2bDomain { - pub fn unsupported() -> anyhow::Result { - anyhow::bail!("native d2b domains are only supported on Linux") - } - } - - #[async_trait(?Send)] - impl Domain for D2bDomain { - async fn spawn_pane( - &self, - _size: TerminalSize, - _command: Option, - _command_dir: Option, - ) -> anyhow::Result> { - anyhow::bail!("native d2b domains are only supported on Linux") - } - - async fn spawn( - &self, - _size: TerminalSize, - _command: Option, - _command_dir: Option, - _window: WindowId, - ) -> anyhow::Result> { - anyhow::bail!("native d2b domains are only supported on Linux") - } - - async fn split_pane( - &self, - _source: SplitSource, - _tab: TabId, - _pane_id: PaneId, - _split_request: SplitRequest, - ) -> anyhow::Result> { - anyhow::bail!("native d2b domains are only supported on Linux") - } - - fn detachable(&self) -> bool { - false - } - - fn domain_id(&self) -> DomainId { - alloc_domain_id() - } - - fn domain_name(&self) -> &str { - "d2b" - } - - async fn attach(&self, _window_id: Option) -> anyhow::Result<()> { - anyhow::bail!("native d2b domains are only supported on Linux") - } - - fn detach(&self) -> anyhow::Result<()> { - anyhow::bail!("native d2b domains are only supported on Linux") - } - - fn state(&self) -> DomainState { - DomainState::Detached - } - } -} - -pub use imp::*; diff --git a/mux/src/lib.rs b/mux/src/lib.rs index f7e085fbccb..61ecb58c69e 100644 --- a/mux/src/lib.rs +++ b/mux/src/lib.rs @@ -36,7 +36,6 @@ use winapi::um::winsock2::{SOL_SOCKET, SO_RCVBUF, SO_SNDBUF}; pub mod activity; pub mod client; pub mod connui; -pub mod d2b; pub mod domain; pub mod localpane; pub mod pane; diff --git a/mux/src/pane.rs b/mux/src/pane.rs index a6448885a08..9284d4cc12e 100644 --- a/mux/src/pane.rs +++ b/mux/src/pane.rs @@ -254,20 +254,6 @@ pub trait Pane: Downcast + Send + Sync { fn palette(&self) -> ColorPalette; fn domain_id(&self) -> DomainId; - // --- weezterm remote features --- - fn trusted_d2b_target(&self) -> Option<&str> { - None - } - - fn trusted_d2b_session(&self) -> Option<&str> { - None - } - - fn d2b_guest_title(&self) -> Option { - None - } - // --- end weezterm remote features --- - fn get_keyboard_encoding(&self) -> KeyboardEncoding { KeyboardEncoding::Xterm } diff --git a/wezterm-gui/src/commands.rs b/wezterm-gui/src/commands.rs index 7d4bccf6c82..1f1d20623e1 100644 --- a/wezterm-gui/src/commands.rs +++ b/wezterm-gui/src/commands.rs @@ -1636,24 +1636,6 @@ pub fn derive_command_from_key_assignment(action: &KeyAssignment) -> Option CommandDef { - brief: "Show d2b sessions".into(), - doc: "Shows the native provider-neutral d2b target session picker".into(), - keys: vec![], - args: &[ArgType::ActiveWindow], - menubar: &["Shell"], - icon: Some("md_terminal"), - }, - D2bOpenSession { .. } => CommandDef { - brief: "Open d2b session".into(), - doc: "Opens or creates a native d2b target shell session".into(), - keys: vec![], - args: &[ArgType::ActiveWindow], - menubar: &[], - icon: Some("md_terminal"), - }, - // --- end weezterm remote features --- ShowTabNavigator => CommandDef { brief: "Navigate tabs".into(), doc: "Shows the tab navigator".into(), @@ -2197,7 +2179,6 @@ fn compute_default_actions() -> Vec { ShowLauncher, ShowTabNavigator, // --- weezterm remote features --- - ShowD2bLauncher, ShowPortForwardOverlay, ShowConfigOverlay, ShowDevContainerManager, diff --git a/wezterm-gui/src/main.rs b/wezterm-gui/src/main.rs index 16dac0990b9..d6734081db3 100644 --- a/wezterm-gui/src/main.rs +++ b/wezterm-gui/src/main.rs @@ -374,46 +374,6 @@ async fn connect_to_auto_connect_domains() -> anyhow::Result<()> { Ok(()) } -// --- weezterm remote features --- -#[cfg(target_os = "linux")] -fn d2b_target_for_domain_name(domain_name: Option<&str>) -> Option { - let mux = Mux::try_get()?; - let domain = match domain_name { - Some(domain_name) => mux.get_domain_by_name(domain_name)?, - None => mux.default_domain(), - }; - domain - .downcast_ref::() - .map(|domain| domain.target().to_string()) -} - -#[cfg(not(target_os = "linux"))] -fn d2b_target_for_domain_name(_domain_name: Option<&str>) -> Option { - None -} - -fn d2b_target_from_config_domain( - config: &ConfigHandle, - domain_name: Option<&str>, -) -> Option { - let domain_name = domain_name?; - config - .d2b_domains - .iter() - .find(|domain| domain.name == domain_name) - .map(|domain| domain.target.clone()) -} - -fn set_bound_d2b_target(target: Option<&str>) { - for name in [mux::d2b::D2B_BOUND_TARGET_ENV, mux::d2b::D2B_BOUND_VM_ENV] { - match target { - Some(target) => std::env::set_var(name, target), - None => std::env::remove_var(name), - } - } -} -// --- end weezterm remote features --- - async fn trigger_gui_startup( lua: Option>, spawn: Option, @@ -463,36 +423,13 @@ fn cell_pixel_dims(config: &ConfigHandle, dpi: f64) -> anyhow::Result<(usize, us )) } -// --- weezterm remote features --- -fn gui_mux_socket_path_for_domain(domain: Option<&str>) -> anyhow::Result { - let domain_target = d2b_target_for_domain_name(domain); - let bound_target = mux::d2b::bound_target_from_env().map_err(anyhow::Error::msg)?; - let target = match (domain_target, bound_target) { - (Some(domain_target), Some(bound_target)) if domain_target != bound_target => { - anyhow::bail!( - "requested d2b domain conflicts with the process-bound target; start a new process" - ) - } - (Some(target), _) | (None, Some(target)) => Some(target), - (None, None) => None, - }; - if let Some(target) = target { - Ok(mux::d2b::target_mux_socket_path( - &config::RUNTIME_DIR, - &target, - )) - } else { - Ok(config::RUNTIME_DIR.join(format!("gui-sock-{}", unsafe { libc::getpid() }))) - } -} -// --- end weezterm remote features --- - async fn async_run_terminal_gui( cmd: Option, opts: StartCommand, should_publish: bool, ) -> anyhow::Result<()> { - let unix_socket_path = gui_mux_socket_path_for_domain(opts.domain.as_deref())?; + let unix_socket_path = + config::RUNTIME_DIR.join(format!("gui-sock-{}", unsafe { libc::getpid() })); std::env::set_var("WEZTERM_UNIX_SOCKET", unix_socket_path.clone()); wezterm_blob_leases::register_storage(Arc::new( wezterm_blob_leases::simple_tempdir::SimpleTempDir::new_in(&*config::CACHE_DIR)?, @@ -827,16 +764,6 @@ fn run_terminal_gui(opts: StartCommand, default_domain_name: Option) -> let config = config::configuration(); - // --- weezterm remote features --- - let requested_domain = opts - .domain - .as_deref() - .or(default_domain_name.as_deref()) - .or(config.default_domain.as_deref()); - let bound_target = d2b_target_from_config_domain(&config, requested_domain); - set_bound_d2b_target(bound_target.as_deref()); - // --- end weezterm remote features --- - let cmd = if !opts.prog.is_empty() || opts.cwd.is_some() { let prog = opts.prog.iter().map(|s| s.as_os_str()).collect::>(); let mut builder = config.build_prog( @@ -879,11 +806,10 @@ fn run_terminal_gui(opts: StartCommand, default_domain_name: Option) -> // First, let's see if we can ask an already running wezterm to do this. // We must do this before we start the gui frontend as the scheduler // requirements are different. - let is_d2b_target_domain = d2b_target_for_domain_name(opts.domain.as_deref()).is_some(); let mut publish = Publish::resolve( &mux, &config, - opts.always_new_process || opts.position.is_some() || is_d2b_target_domain, + opts.always_new_process || opts.position.is_some(), ); log::trace!("{:?}", publish); if publish.try_spawn( @@ -1344,10 +1270,6 @@ fn run() -> anyhow::Result<()> { if let Some(value) = &config.default_ssh_auth_sock { std::env::set_var("SSH_AUTH_SOCK", value); } - // --- weezterm remote features --- - set_bound_d2b_target(None); - // --- end weezterm remote features --- - let sub = match opts.cmd.as_ref().cloned() { Some(SubCommand::BlockingStart(start)) => { // Act as if the normal start subcommand was used, diff --git a/wezterm-gui/src/overlay/d2b_launcher.rs b/wezterm-gui/src/overlay/d2b_launcher.rs deleted file mode 100644 index 749837bcdea..00000000000 --- a/wezterm-gui/src/overlay/d2b_launcher.rs +++ /dev/null @@ -1,846 +0,0 @@ -use crate::overlay::quickselect; -use crate::termwindow::TermWindowNotif; -use config::keyassignment::KeyAssignment; -use mux::d2b::{ - friendly_session_name, sanitize_display_label, validate_shell_name, D2bDomain, D2bSession, - D2bTargetStatus, ShellSessionState, -}; -use mux::domain::Domain; -use mux::termwiztermtab::TermWizTerminal; -use mux::Mux; -use termwiz::cell::{AttributeChange, CellAttributes, Intensity}; -use termwiz::color::ColorAttribute; -use termwiz::input::{InputEvent, KeyCode, KeyEvent, Modifiers, MouseButtons, MouseEvent}; -use termwiz::surface::{Change, Position}; -use termwiz::terminal::Terminal; -use termwiz_funcs::truncate_right; -use window::WindowOps; - -const ROW_OVERHEAD: usize = 3; -const ALPHABET: &str = "1234567890abcdefghilmnopqrstuvwxyz"; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct D2bTargetScope { - pub domain_name: String, - pub target: String, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PickerKind { - AllTargets, - CurrentTarget, -} - -#[derive(Clone, PartialEq, Eq)] -pub struct D2bTargetPickerEntry { - pub domain_name: String, - pub target: String, - pub status: Option, - pub unavailable_reason: Option, - pub sessions: Vec, - pub generated_name: String, -} - -impl D2bTargetPickerEntry { - fn available(&self) -> bool { - self.unavailable_reason.is_none() - && self - .status - .as_ref() - .map(D2bTargetStatus::is_shell_ready) - .unwrap_or(false) - } - - fn warning_text(&self) -> Option { - self.unavailable_reason - .clone() - .or_else(|| self.status.as_ref().and_then(D2bTargetStatus::warning_text)) - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -enum EntryAction { - Open { - domain: String, - name: String, - }, - Manual { - domain: String, - default_name: String, - }, - Disabled, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct Entry { - label: String, - disabled: bool, - action: EntryAction, -} - -#[derive(Clone)] -pub struct D2bLauncherArgs { - pane_id: mux::pane::PaneId, - targets: Vec, - kind: PickerKind, -} - -impl D2bLauncherArgs { - pub async fn new(pane_id: mux::pane::PaneId) -> Self { - Self::collect(pane_id, None).await - } - - pub async fn new_scoped(pane_id: mux::pane::PaneId, scope: D2bTargetScope) -> Self { - Self::collect(pane_id, Some(scope)).await - } - - async fn collect(pane_id: mux::pane::PaneId, scope: Option) -> Self { - let mux = Mux::get(); - let mut targets = vec![]; - for domain in mux.iter_domains() { - let Some(d2b) = domain.downcast_ref::() else { - continue; - }; - let domain_name = d2b.domain_name().to_string(); - if scope - .as_ref() - .is_some_and(|scope| scope.domain_name != domain_name) - { - continue; - } - let configured_target = d2b.target().to_string(); - match d2b.discover().await { - Ok(discovery) => { - let names = discovery - .sessions - .iter() - .map(|session| session.id.clone()) - .collect::>(); - let target = discovery.status.target().to_string(); - if scope.as_ref().is_some_and(|scope| scope.target != target) { - let trusted_target = scope - .as_ref() - .map(|scope| scope.target.clone()) - .unwrap_or(configured_target); - targets.push(D2bTargetPickerEntry { - domain_name, - generated_name: friendly_session_name(&trusted_target, &names), - target: trusted_target, - status: None, - unavailable_reason: Some( - "trusted target identity mismatch".to_string(), - ), - sessions: vec![], - }); - continue; - } - let generated_name = friendly_session_name(&target, &names); - targets.push(D2bTargetPickerEntry { - domain_name, - target, - status: Some(discovery.status), - unavailable_reason: None, - sessions: discovery.sessions, - generated_name, - }); - } - Err(err) => { - log::debug!("d2b discovery failed: {err:#}"); - let target = scope - .as_ref() - .map(|scope| scope.target.clone()) - .unwrap_or(configured_target); - targets.push(D2bTargetPickerEntry { - domain_name, - generated_name: friendly_session_name(&target, &[]), - target, - status: None, - unavailable_reason: Some(sanitize_display_label(&err.to_string())), - sessions: vec![], - }); - } - } - } - if let Some(scope) = &scope { - if targets.is_empty() { - targets.push(D2bTargetPickerEntry { - domain_name: scope.domain_name.clone(), - target: scope.target.clone(), - status: None, - unavailable_reason: Some("trusted d2b domain is unavailable".to_string()), - sessions: vec![], - generated_name: friendly_session_name(&scope.target, &[]), - }); - } - } - targets.sort_by(|a, b| { - a.target - .cmp(&b.target) - .then(a.domain_name.cmp(&b.domain_name)) - }); - Self { - pane_id, - targets, - kind: if scope.is_some() { - PickerKind::CurrentTarget - } else { - PickerKind::AllTargets - }, - } - } -} - -#[derive(Clone, Debug, PartialEq, Eq)] -enum Mode { - Pick, - Prompt { - domain: String, - input: String, - error: Option, - }, -} - -struct State { - args: D2bLauncherArgs, - window: ::window::Window, - entries: Vec, - active_idx: usize, - top_row: usize, - max_items: usize, - labels: Vec, - selection: String, - mode: Mode, -} - -impl State { - fn new(args: D2bLauncherArgs, window: ::window::Window) -> Self { - Self { - entries: build_entries(&args.targets, args.kind), - args, - window, - active_idx: 0, - top_row: 0, - max_items: 0, - labels: vec![], - selection: String::new(), - mode: Mode::Pick, - } - } - - fn render(&mut self, term: &mut TermWizTerminal) -> termwiz::Result<()> { - match self.mode.clone() { - Mode::Pick => self.render_picker(term), - Mode::Prompt { input, error, .. } => self.render_prompt(term, &input, error.as_deref()), - } - } - - fn render_picker(&mut self, term: &mut TermWizTerminal) -> termwiz::Result<()> { - let size = term.get_screen_size()?; - let max_width = size.cols.saturating_sub(6); - let max_items = size.rows.saturating_sub(ROW_OVERHEAD); - if max_items != self.max_items { - self.labels = quickselect::compute_labels_for_alphabet_with_preserved_case( - ALPHABET, - self.entries.len().min(max_items + 1), - ); - self.max_items = max_items; - } - - let mut changes = vec![ - Change::ClearScreen(ColorAttribute::Default), - Change::CursorPosition { - x: Position::Absolute(0), - y: Position::Absolute(0), - }, - Change::Text(match self.args.kind { - PickerKind::AllTargets => { - "d2b sessions: Enter=open n=name new Esc=cancel\r\n".to_string() - } - PickerKind::CurrentTarget => "d2b shells: Enter=open Esc=cancel\r\n".to_string(), - }), - Change::AllAttributes(CellAttributes::default()), - ]; - let max_label_len = self.labels.iter().map(|s| s.len()).max().unwrap_or(0); - let mut labels = self.labels.iter(); - for (row_num, (entry_idx, entry)) in self - .entries - .iter() - .enumerate() - .skip(self.top_row) - .enumerate() - { - if row_num > max_items { - break; - } - if entry_idx == self.active_idx { - changes.push(AttributeChange::Reverse(true).into()); - } - if let Some(label) = labels.next() { - changes.push(Change::Text(format!(" {label:>max_label_len$}. "))); - } else { - changes.push(Change::Text(" ".repeat(max_label_len + 3))); - } - if entry.disabled { - changes.push(AttributeChange::Intensity(Intensity::Half).into()); - } - changes.push(Change::Text(truncate_right(&entry.label, max_width))); - if entry.disabled { - changes.push(AttributeChange::Intensity(Intensity::Normal).into()); - } - if entry_idx == self.active_idx { - changes.push(AttributeChange::Reverse(false).into()); - } - changes.push(Change::AllAttributes(CellAttributes::default())); - changes.push(Change::Text("\r\n".to_string())); - } - term.render(&changes) - } - - fn render_prompt( - &self, - term: &mut TermWizTerminal, - input: &str, - error: Option<&str>, - ) -> termwiz::Result<()> { - let mut changes = vec![ - Change::ClearScreen(ColorAttribute::Default), - Change::CursorPosition { - x: Position::Absolute(0), - y: Position::Absolute(0), - }, - Change::Text( - "Name new d2b shell (1-64 bytes, [A-Za-z0-9._-]); Enter=open Esc=cancel\r\n" - .to_string(), - ), - Change::Text(format!("> {input}")), - ]; - if let Some(error) = error { - changes.push(Change::Text(format!("\r\nInvalid name: {error}"))); - } - term.render(&changes) - } - - fn move_up(&mut self) { - self.active_idx = self.active_idx.saturating_sub(1); - if self.active_idx < self.top_row { - self.top_row = self.active_idx; - } - } - - fn move_down(&mut self) { - if self.entries.is_empty() { - return; - } - self.active_idx = (self.active_idx + 1).min(self.entries.len() - 1); - if self.active_idx > self.top_row + self.max_items { - self.top_row = self.active_idx.saturating_sub(self.max_items); - } - } - - fn open(&self, domain: String, name: String) { - self.window.notify(TermWindowNotif::PerformAssignment { - pane_id: self.args.pane_id, - assignment: KeyAssignment::D2bOpenSession { - domain, - name: Some(name), - }, - tx: None, - }); - } - - fn launch(&mut self, active_idx: usize) -> bool { - let Some(entry) = self.entries.get(active_idx).cloned() else { - return false; - }; - match entry.action { - EntryAction::Open { domain, name } => { - self.open(domain, name); - true - } - EntryAction::Manual { - domain, - default_name, - } => { - self.mode = Mode::Prompt { - domain, - input: default_name, - error: None, - }; - false - } - EntryAction::Disabled => false, - } - } - - fn run_loop(&mut self, term: &mut TermWizTerminal) -> anyhow::Result<()> { - while let Ok(Some(event)) = term.poll_input(None) { - match self.mode.clone() { - Mode::Pick => self.handle_pick_event(event), - Mode::Prompt { - domain, - input, - error: _, - } => self.handle_prompt_event(event, domain, input), - } - if matches!(self.mode, Mode::Pick) && self.selection == "__done__" { - break; - } - self.render(term)?; - } - Ok(()) - } - - fn handle_pick_event(&mut self, event: InputEvent) { - match event { - InputEvent::Key(KeyEvent { - key: KeyCode::Char(c), - modifiers: Modifiers::NONE, - }) if ALPHABET.contains(c) => { - self.selection.push(c); - if let Some(pos) = self.labels.iter().position(|x| *x == self.selection) { - self.active_idx = self.top_row + pos; - if self.launch(self.active_idx) { - self.selection = "__done__".to_string(); - } - } - } - InputEvent::Key(KeyEvent { - key: KeyCode::Char('n'), - .. - }) => { - if let Some(entry) = self.entries.get(self.active_idx).cloned() { - if let EntryAction::Manual { - domain, - default_name, - } = entry.action - { - self.mode = Mode::Prompt { - domain, - input: default_name, - error: None, - }; - } - } - } - InputEvent::Key(KeyEvent { - key: KeyCode::Char('j'), - .. - }) - | InputEvent::Key(KeyEvent { - key: KeyCode::DownArrow, - .. - }) => self.move_down(), - InputEvent::Key(KeyEvent { - key: KeyCode::Char('k'), - .. - }) - | InputEvent::Key(KeyEvent { - key: KeyCode::UpArrow, - .. - }) => self.move_up(), - InputEvent::Key(KeyEvent { - key: KeyCode::Enter, - .. - }) => { - if self.launch(self.active_idx) { - self.selection = "__done__".to_string(); - } - } - InputEvent::Key(KeyEvent { - key: KeyCode::Escape, - .. - }) - | InputEvent::Key(KeyEvent { - key: KeyCode::Char('G') | KeyCode::Char('['), - modifiers: Modifiers::CTRL, - }) => { - self.selection = "__done__".to_string(); - } - InputEvent::Mouse(MouseEvent { - y, mouse_buttons, .. - }) if mouse_buttons.contains(MouseButtons::VERT_WHEEL) => { - if mouse_buttons.contains(MouseButtons::WHEEL_POSITIVE) { - self.top_row = self.top_row.saturating_sub(1); - } else { - self.top_row = (self.top_row + 1).min( - self.entries - .len() - .saturating_sub(self.max_items) - .saturating_sub(1), - ); - } - if y > 0 && y as usize <= self.entries.len() { - self.active_idx = self.top_row + y as usize - 1; - } - } - _ => {} - } - } - - fn handle_prompt_event(&mut self, event: InputEvent, domain: String, mut input: String) { - match event { - InputEvent::Key(KeyEvent { - key: KeyCode::Escape, - .. - }) => self.mode = Mode::Pick, - InputEvent::Key(KeyEvent { - key: KeyCode::Backspace, - .. - }) => { - input.pop(); - self.mode = Mode::Prompt { - domain, - input, - error: None, - }; - } - InputEvent::Key(KeyEvent { - key: KeyCode::Enter, - .. - }) => match validate_shell_name(&input) { - Ok(()) => { - self.open(domain, input); - self.mode = Mode::Pick; - self.selection = "__done__".to_string(); - } - Err(err) => { - self.mode = Mode::Prompt { - domain, - input, - error: Some(err), - } - } - }, - InputEvent::Key(KeyEvent { - key: KeyCode::Char(c), - .. - }) => { - input.push(c); - self.mode = Mode::Prompt { - domain, - input, - error: None, - }; - } - _ => { - self.mode = Mode::Prompt { - domain, - input, - error: None, - } - } - } - } -} - -fn build_entries(targets: &[D2bTargetPickerEntry], kind: PickerKind) -> Vec { - let mut entries = vec![]; - for target in targets { - let target_label = sanitize_display_label(&target.target); - let warning = target.warning_text(); - if !target.available() { - let reason = warning.unwrap_or_else(|| "unavailable".to_string()); - entries.push(Entry { - label: format!("Unavailable d2b target `{target_label}` ({reason})"), - disabled: true, - action: EntryAction::Disabled, - }); - continue; - } - let posture = warning - .as_deref() - .map(|warning| format!(" — {warning}")) - .unwrap_or_default(); - - if kind == PickerKind::CurrentTarget { - entries.push(Entry { - label: format!( - "New d2b shell `[{target_label}:{}]`{posture}", - sanitize_display_label(&target.generated_name) - ), - disabled: false, - action: EntryAction::Open { - domain: target.domain_name.clone(), - name: target.generated_name.clone(), - }, - }); - - let mut sessions = target - .sessions - .iter() - .filter(|session| !session.attached && session.state == ShellSessionState::Detached) - .cloned() - .collect::>(); - sessions.sort_by(|a, b| b.is_default.cmp(&a.is_default).then(a.id.cmp(&b.id))); - for session in sessions { - let session_label = sanitize_display_label(&session.id); - entries.push(Entry { - label: format!( - "Reattach d2b shell `[{target_label}:{session_label}]`{posture}" - ), - disabled: false, - action: EntryAction::Open { - domain: target.domain_name.clone(), - name: session.id, - }, - }); - } - continue; - } - - entries.push(Entry { - label: format!( - "New d2b shell on `{target_label}` ({}){posture}", - sanitize_display_label(&target.generated_name) - ), - disabled: false, - action: EntryAction::Open { - domain: target.domain_name.clone(), - name: target.generated_name.clone(), - }, - }); - entries.push(Entry { - label: format!("Name new d2b shell on `{target_label}`…{posture}"), - disabled: false, - action: EntryAction::Manual { - domain: target.domain_name.clone(), - default_name: target.generated_name.clone(), - }, - }); - - let mut sessions = target.sessions.clone(); - sessions.sort_by(|a, b| b.is_default.cmp(&a.is_default).then(a.id.cmp(&b.id))); - for session in sessions { - let session_label = sanitize_display_label(&session.id); - let state = if session.is_default { - "default".to_string() - } else if session.attached { - "attached".to_string() - } else { - format!("{:?}", session.state).to_ascii_lowercase() - }; - entries.push(Entry { - label: format!( - "Open d2b shell `{target_label}:{session_label}` ({state}){posture}" - ), - disabled: false, - action: EntryAction::Open { - domain: target.domain_name.clone(), - name: session.id, - }, - }); - } - } - entries -} - -pub fn d2b_launcher( - args: D2bLauncherArgs, - mut term: TermWizTerminal, - window: ::window::Window, -) -> anyhow::Result<()> { - let mut state = State::new(args, window); - term.set_raw_mode()?; - term.render(&[Change::Title("d2b sessions".to_string())])?; - state.render(&mut term)?; - state.run_loop(&mut term) -} - -#[cfg(test)] -mod test { - use super::*; - use mux::d2b::{ - D2bCorrelationId, IsolationPosture, ShellSessionState, WorkloadAvailability, - WorkloadProviderKind, - }; - - fn session(name: &str, is_default: bool) -> D2bSession { - D2bSession { - id: name.to_string(), - label: name.to_string(), - target: "work".to_string(), - workspace: None, - state: ShellSessionState::Detached, - attached: false, - is_default, - correlation_id: D2bCorrelationId::from_sensitive("session", name), - } - } - - fn ready_status(target: &str) -> D2bTargetStatus { - D2bTargetStatus::new( - target, - WorkloadProviderKind::LocalVm, - IsolationPosture::VirtualMachine, - WorkloadAvailability::Ready, - true, - ) - .unwrap() - } - - #[test] - fn entries_disable_offline_targets_without_open_actions() { - let entries = build_entries( - &[D2bTargetPickerEntry { - domain_name: "d2b-work".to_string(), - target: "work".to_string(), - status: None, - unavailable_reason: Some("offline".to_string()), - sessions: vec![], - generated_name: "work-shell".to_string(), - }], - PickerKind::AllTargets, - ); - assert_eq!(entries.len(), 1); - assert!(entries[0].disabled); - assert_eq!(entries[0].action, EntryAction::Disabled); - } - - #[test] - fn entries_include_generated_manual_and_existing_sessions() { - let entries = build_entries( - &[D2bTargetPickerEntry { - domain_name: "d2b-work".to_string(), - target: "work".to_string(), - status: Some(ready_status("work")), - unavailable_reason: None, - sessions: vec![session("default", true), session("build", false)], - generated_name: "work-shell".to_string(), - }], - PickerKind::AllTargets, - ); - assert!(entries - .iter() - .any(|entry| entry.label.contains("work-shell"))); - assert!(entries - .iter() - .any(|entry| matches!(entry.action, EntryAction::Manual { .. }))); - assert!(entries - .iter() - .any(|entry| entry.label.contains("work:default"))); - assert!(entries.iter().all(|entry| !entry.disabled)); - } - - #[test] - fn entries_sanitize_target_and_session_labels() { - let entries = build_entries( - &[D2bTargetPickerEntry { - domain_name: "d2b-work".to_string(), - target: "work\x1b[31m".to_string(), - status: Some(ready_status("work")), - unavailable_reason: None, - sessions: vec![session("bad\x07\x1b[31m", false)], - generated_name: "work-shell".to_string(), - }], - PickerKind::AllTargets, - ); - let combined = entries - .iter() - .map(|entry| entry.label.as_str()) - .collect::>() - .join("\n"); - assert!(!combined.contains('\x1b')); - assert!(!combined.contains('\x07')); - assert!(combined.contains("work:bad")); - } - - #[test] - fn entries_warn_that_unsafe_local_has_no_isolation() { - let status = D2bTargetStatus::new( - "tools.host.d2b", - WorkloadProviderKind::UnsafeLocal, - IsolationPosture::UnsafeLocal, - WorkloadAvailability::Ready, - true, - ) - .unwrap(); - let entries = build_entries( - &[D2bTargetPickerEntry { - domain_name: "host-tools".to_string(), - target: "tools.host.d2b".to_string(), - status: Some(status), - unavailable_reason: None, - sessions: vec![], - generated_name: "d2b-tools-shell".to_string(), - }], - PickerKind::AllTargets, - ); - - assert!(entries.iter().all(|entry| !entry.disabled)); - assert!(entries - .iter() - .all(|entry| entry.label.contains("UNSAFE LOCAL — NO ISOLATION"))); - } - - #[test] - fn helper_unavailable_is_visible_and_disables_target() { - let status = D2bTargetStatus::new( - "tools.host.d2b", - WorkloadProviderKind::UnsafeLocal, - IsolationPosture::UnsafeLocal, - WorkloadAvailability::HelperUnavailable, - true, - ) - .unwrap(); - let entries = build_entries( - &[D2bTargetPickerEntry { - domain_name: "host-tools".to_string(), - target: "tools.host.d2b".to_string(), - status: Some(status), - unavailable_reason: None, - sessions: vec![], - generated_name: "d2b-tools-shell".to_string(), - }], - PickerKind::AllTargets, - ); - - assert_eq!(entries.len(), 1); - assert!(entries[0].disabled); - assert!(entries[0].label.contains("UNSAFE LOCAL — NO ISOLATION")); - assert!(entries[0].label.contains("helper unavailable")); - } - - #[test] - fn scoped_entries_create_immediately_and_only_reattach_detached_shells() { - let mut attached = session("attached", false); - attached.state = ShellSessionState::Attached; - attached.attached = true; - let mut killed = session("killed", false); - killed.state = ShellSessionState::Killed; - let entries = build_entries( - &[D2bTargetPickerEntry { - domain_name: "host-tools".to_string(), - target: "tools.host.d2b".to_string(), - status: Some(ready_status("tools.host.d2b")), - unavailable_reason: None, - sessions: vec![ - session("detached", false), - attached, - killed, - session("default", true), - ], - generated_name: "new-shell".to_string(), - }], - PickerKind::CurrentTarget, - ); - - assert_eq!(entries.len(), 3); - assert!(entries[0].label.contains("[tools.host.d2b:new-shell]")); - assert!(matches!( - &entries[0].action, - EntryAction::Open { name, .. } if name == "new-shell" - )); - assert!(entries - .iter() - .all(|entry| !matches!(entry.action, EntryAction::Manual { .. }))); - let labels = entries - .iter() - .map(|entry| entry.label.as_str()) - .collect::>() - .join("\n"); - assert!(labels.contains("detached")); - assert!(labels.contains("default")); - assert!(!labels.contains("attached")); - assert!(!labels.contains("killed")); - } -} diff --git a/wezterm-gui/src/overlay/mod.rs b/wezterm-gui/src/overlay/mod.rs index de5b3c45c34..2a20eee64b0 100644 --- a/wezterm-gui/src/overlay/mod.rs +++ b/wezterm-gui/src/overlay/mod.rs @@ -17,8 +17,6 @@ pub mod selector; // --- weezterm remote features --- pub mod config_overlay; -#[cfg(target_os = "linux")] -pub mod d2b_launcher; pub mod devcontainer; pub mod port_forward; diff --git a/wezterm-gui/src/termwindow/mod.rs b/wezterm-gui/src/termwindow/mod.rs index 37832002525..b0cbf52caee 100644 --- a/wezterm-gui/src/termwindow/mod.rs +++ b/wezterm-gui/src/termwindow/mod.rs @@ -283,11 +283,6 @@ pub struct PaneInformation { pub pixel_height: usize, pub title: String, pub user_vars: HashMap, - // --- weezterm remote features --- - pub trusted_d2b_target: Option, - pub trusted_d2b_session: Option, - pub d2b_guest_title: Option, - // --- end weezterm remote features --- pub progress: Progress, } @@ -357,190 +352,19 @@ impl UserData for PaneInformation { } // --- weezterm remote features --- -fn effective_tab_title_impl( - tab: &TabInformation, - default_title: String, - max_width: Option, -) -> String { - let Some(pane) = tab.active_pane.as_ref() else { - return default_title; - }; - if let (Some(target), Some(session)) = ( - pane.trusted_d2b_target.as_deref(), - pane.trusted_d2b_session.as_deref(), - ) { - let guest_title = if tab.tab_title.is_empty() { - pane.d2b_guest_title.as_deref().unwrap_or(&default_title) - } else { - &default_title - }; - if let Some(max_width) = max_width { - mux::d2b::d2b_tab_title_for_width(target, session, guest_title, max_width) - } else { - mux::d2b::d2b_tab_title(target, session, guest_title) - } - } else { - default_title - } -} - -pub(crate) fn effective_tab_title(tab: &TabInformation, default_title: String) -> String { - effective_tab_title_impl(tab, default_title, None) +pub(crate) fn effective_tab_title(_tab: &TabInformation, default_title: String) -> String { + default_title } pub(crate) fn effective_tab_title_for_width( - tab: &TabInformation, + _tab: &TabInformation, default_title: String, - max_width: usize, + _max_width: usize, ) -> String { - effective_tab_title_impl(tab, default_title, Some(max_width)) -} - -fn d2b_window_title(active_tab: &TabInformation) -> Option { - let pane = active_tab.active_pane.as_ref()?; - pane.trusted_d2b_target.as_ref()?; - pane.trusted_d2b_session.as_ref()?; - let default_title = if active_tab.tab_title.is_empty() { - pane.title.clone() - } else { - active_tab.tab_title.clone() - }; - Some(effective_tab_title(active_tab, default_title)) -} - -#[cfg(target_os = "linux")] -#[derive(Debug, Clone, PartialEq, Eq)] -enum NewTabDropdownTarget { - D2b(crate::overlay::d2b_launcher::D2bTargetScope), - Generic, -} - -#[cfg(target_os = "linux")] -fn new_tab_dropdown_target( - scope: Option, -) -> NewTabDropdownTarget { - match scope { - Some(scope) => NewTabDropdownTarget::D2b(scope), - None => NewTabDropdownTarget::Generic, - } + default_title } // --- end weezterm remote features --- -#[cfg(test)] -mod d2b_title_tests { - use super::*; - - fn pane_with_untrusted_target(title: &str) -> PaneInformation { - PaneInformation { - pane_id: 999_999, - pane_index: 0, - is_active: true, - is_zoomed: false, - has_unseen_output: false, - left: 0, - top: 0, - width: 80, - height: 24, - pixel_width: 800, - pixel_height: 600, - title: title.to_owned(), - user_vars: HashMap::from([( - "weezterm.d2b.target".to_owned(), - "spoofed.host.d2b".to_owned(), - )]), - trusted_d2b_target: None, - trusted_d2b_session: None, - d2b_guest_title: None, - progress: Progress::default(), - } - } - - fn tab_with_pane(pane: PaneInformation, tab_title: &str) -> TabInformation { - TabInformation { - tab_id: 999_999, - tab_index: 0, - is_active: true, - is_last_active: false, - active_pane: Some(pane), - window_id: 999_999, - tab_title: tab_title.to_owned(), - } - } - - #[test] - fn terminal_user_vars_cannot_claim_d2b_window_identity() { - let mut pane = pane_with_untrusted_target("shell"); - assert_eq!(d2b_window_title(&tab_with_pane(pane.clone(), "")), None); - - pane.trusted_d2b_target = Some("tools.host.d2b".to_owned()); - assert_eq!(d2b_window_title(&tab_with_pane(pane.clone(), "")), None); - - pane.trusted_d2b_session = Some("handy-kingbird".to_owned()); - assert_eq!( - d2b_window_title(&tab_with_pane(pane, "")), - Some("shell [tools.host.d2b:handy-kingbird]".to_owned()) - ); - } - - #[test] - fn explicit_tab_title_gets_the_trusted_d2b_suffix() { - let mut pane = pane_with_untrusted_target("copilot [tools.host.d2b:handy-kingbird]"); - pane.trusted_d2b_target = Some("tools.host.d2b".to_owned()); - pane.trusted_d2b_session = Some("handy-kingbird".to_owned()); - pane.d2b_guest_title = Some("copilot".to_owned()); - - assert_eq!( - d2b_window_title(&tab_with_pane(pane, "reviewing")), - Some("reviewing [tools.host.d2b:handy-kingbird]".to_owned()) - ); - } - - #[test] - fn switching_tabs_uses_each_tabs_latest_title() { - let mut first = pane_with_untrusted_target("first-new [tools.host.d2b:first]"); - first.trusted_d2b_target = Some("tools.host.d2b".to_owned()); - first.trusted_d2b_session = Some("first".to_owned()); - first.d2b_guest_title = Some("first-new".to_owned()); - let mut second = pane_with_untrusted_target("second-old [tools.host.d2b:second]"); - second.trusted_d2b_target = Some("tools.host.d2b".to_owned()); - second.trusted_d2b_session = Some("second".to_owned()); - second.d2b_guest_title = Some("second-old".to_owned()); - - let first_tab = tab_with_pane(first, ""); - second.title = "second-new [tools.host.d2b:second]".to_owned(); - second.d2b_guest_title = Some("second-new".to_owned()); - let second_tab = tab_with_pane(second, ""); - - assert_eq!( - d2b_window_title(&first_tab), - Some("first-new [tools.host.d2b:first]".to_owned()) - ); - assert_eq!( - d2b_window_title(&second_tab), - Some("second-new [tools.host.d2b:second]".to_owned()) - ); - assert_eq!( - d2b_window_title(&first_tab), - Some("first-new [tools.host.d2b:first]".to_owned()) - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn new_tab_dropdown_routes_only_trusted_d2b_scopes_to_the_d2b_picker() { - assert_eq!(new_tab_dropdown_target(None), NewTabDropdownTarget::Generic); - - let scope = crate::overlay::d2b_launcher::D2bTargetScope { - domain_name: "host-tools".to_owned(), - target: "tools.host.d2b".to_owned(), - }; - assert_eq!( - new_tab_dropdown_target(Some(scope.clone())), - NewTabDropdownTarget::D2b(scope) - ); - } -} - #[derive(Default)] pub struct TabState { /// If is_some(), rather than display the actual tab @@ -2963,10 +2787,7 @@ impl TermWindow { Some(title) => title, None => { if let (Some(pos), Some(tab)) = (active_pane, active_tab) { - // --- weezterm remote features --- - if let Some(title) = d2b_window_title(&tab) { - title - } else if num_tabs == 1 { + if num_tabs == 1 { format!("{}{}", if pos.is_zoomed { "[Z] " } else { "" }, pos.title) } else { format!( @@ -2977,7 +2798,6 @@ impl TermWindow { pos.title ) } - // --- end weezterm remote features --- } else { "".to_string() } @@ -3590,17 +3410,6 @@ impl TermWindow { // --- weezterm remote features --- fn show_new_tab_dropdown(&mut self) { - #[cfg(target_os = "linux")] - { - match new_tab_dropdown_target(self.active_d2b_target_scope()) { - NewTabDropdownTarget::D2b(scope) => { - self.show_d2b_launcher_impl(Some(scope)); - return; - } - NewTabDropdownTarget::Generic => {} - } - } - let title = "New Tab".to_string(); let args = LauncherActionArgs { title: Some(title), @@ -3611,21 +3420,6 @@ impl TermWindow { }; self.show_launcher_impl(args, 0); } - - #[cfg(target_os = "linux")] - fn active_d2b_target_scope(&self) -> Option { - let mux = Mux::get(); - let tab = mux.get_active_tab_for_window(self.mux_window_id)?; - let pane = tab.get_active_pane()?; - let target = pane.trusted_d2b_target()?.to_string(); - pane.trusted_d2b_session()?; - let domain = mux.get_domain(pane.domain_id())?; - let d2b = domain.downcast_ref::()?; - Some(crate::overlay::d2b_launcher::D2bTargetScope { - domain_name: mux::domain::Domain::domain_name(d2b).to_string(), - target, - }) - } // --- end weezterm remote features --- fn show_tab_navigator(&mut self) { @@ -3727,76 +3521,6 @@ impl TermWindow { .detach(); } - // --- weezterm remote features --- - #[cfg(target_os = "linux")] - fn show_d2b_launcher(&mut self) { - self.show_d2b_launcher_impl(None); - } - - #[cfg(target_os = "linux")] - fn show_d2b_launcher_impl( - &mut self, - scope: Option, - ) { - let window = self.window.as_ref().unwrap().clone(); - let mux = Mux::get(); - let tab = match mux.get_active_tab_for_window(self.mux_window_id) { - Some(tab) => tab, - None => return, - }; - let pane = match tab.get_active_pane() { - Some(pane) => pane, - None => return, - }; - let pane_id = pane.pane_id(); - let tab_id = tab.tab_id(); - - promise::spawn::spawn(async move { - let args = match scope { - Some(scope) => { - crate::overlay::d2b_launcher::D2bLauncherArgs::new_scoped(pane_id, scope).await - } - None => crate::overlay::d2b_launcher::D2bLauncherArgs::new(pane_id).await, - }; - let win = window.clone(); - win.notify(TermWindowNotif::Apply(Box::new(move |term_window| { - let mux = Mux::get(); - if let Some(tab) = mux.get_tab(tab_id) { - let window = window.clone(); - let (overlay, future) = - start_overlay(term_window, &tab, move |_tab_id, term| { - crate::overlay::d2b_launcher::d2b_launcher(args, term, window) - }); - - term_window.assign_overlay(tab_id, overlay); - promise::spawn::spawn(future).detach(); - } - }))); - }) - .detach(); - } - - #[cfg(not(target_os = "linux"))] - fn show_d2b_launcher(&mut self) { - log::warn!("native d2b session picker is only supported on Linux"); - } - - fn open_d2b_session(&mut self, domain: &str, name: Option<&str>) { - let mut env = HashMap::new(); - if let Some(name) = name { - env.insert(mux::d2b::D2B_SHELL_NAME_ENV.to_string(), name.to_string()); - } - self.spawn_command( - &SpawnCommand { - domain: config::keyassignment::SpawnTabDomain::DomainName(domain.to_string()), - set_environment_variables: env, - ..Default::default() - }, - SpawnWhere::NewTab, - ); - } - // --- end weezterm remote features --- - /// Returns the Prompt semantic zones fn get_semantic_prompt_zones(&mut self, pane: &Arc) -> &[StableRowIndex] { let cache = self @@ -4120,10 +3844,6 @@ impl TermWindow { }; self.show_launcher_impl(args, 0); } - // --- weezterm remote features --- - ShowD2bLauncher => self.show_d2b_launcher(), - D2bOpenSession { domain, name } => self.open_d2b_session(domain, name.as_deref()), - // --- end weezterm remote features --- HideApplication => { let con = Connection::get().expect("call on gui thread"); con.hide_application(); @@ -4795,11 +4515,6 @@ impl TermWindow { pixel_height: pos.pixel_height, title: pos.pane.get_title(), user_vars: pos.pane.copy_user_vars(), - // --- weezterm remote features --- - trusted_d2b_target: pos.pane.trusted_d2b_target().map(str::to_owned), - trusted_d2b_session: pos.pane.trusted_d2b_session().map(str::to_owned), - d2b_guest_title: pos.pane.d2b_guest_title(), - // --- end weezterm remote features --- progress: pos.pane.get_progress(), } } diff --git a/wezterm-mux-server-impl/src/lib.rs b/wezterm-mux-server-impl/src/lib.rs index 7451fb572e7..3230b87cfa6 100644 --- a/wezterm-mux-server-impl/src/lib.rs +++ b/wezterm-mux-server-impl/src/lib.rs @@ -90,39 +90,13 @@ fn update_mux_domains_impl(config: &ConfigHandle, is_standalone_mux: bool) -> an } // --- weezterm remote features --- - #[cfg(target_os = "linux")] - let bound_target = mux::d2b::bound_target_from_env().map_err(anyhow::Error::msg)?; - - #[cfg(target_os = "linux")] - for d2b_dom in &config.d2b_domains { - if let Some(bound_target) = bound_target.as_deref() { - if bound_target != d2b_dom.target.as_str() { - continue; - } - } - if mux.get_domain_by_name(&d2b_dom.name).is_some() { - continue; - } - - let mut runtime = mux::d2b::D2bRuntimeConfig::default(); - if let Some(socket_path) = &d2b_dom.socket_path { - runtime.socket_path = socket_path.clone(); - } - let domain: Arc = Arc::new(mux::d2b::native_domain_with_name( - d2b_dom.name.clone(), - d2b_dom.target.clone(), - runtime, - )?); - mux.add_domain(&domain); - } - - #[cfg(not(target_os = "linux"))] for d2b_dom in &config.d2b_domains { log::warn!( - "Ignoring d2b domain {}: native d2b domains are only supported on Linux", + "Ignoring d2b domain {}: d2b v2 session setup is not available yet", d2b_dom.name ); } + // --- end weezterm remote features --- for dc_dom in &config.devcontainer_domains { if mux.get_domain_by_name(&dc_dom.name).is_some() { From ea9a9b6c025bc0e29d86a81198d8ab0ed29c0bfa Mon Sep 17 00:00:00 2001 From: John Vicondoa Date: Thu, 16 Jul 2026 17:05:07 -0700 Subject: [PATCH 2/7] d2b: correct canonical client source pin ( W9 ) Follow the corrected shared toolkit contract by pinning the landed d2b provider-foundation revision and recording its client distribution fingerprint. ADR: d2b/0045 --- CHANGELOG.md | 2 +- Cargo.lock | 6 +++--- Cargo.toml | 4 ++-- docs/d2b-provider.md | 10 ++++++---- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee34d88d78e..c8630a8446a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Migrated d2b configuration to the canonical v2 `TargetInput` workload type and pinned `d2b-client` plus `d2b-contracts` to d2b revision - `9183b45c6505cfd496e5d537bf6376f884fb16c7`. + `4018d9c9652bd826c2e6a9abccdcdcafb832d944`. ### Removed diff --git a/Cargo.lock b/Cargo.lock index 5a1fd36c532..a192d778530 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1379,7 +1379,7 @@ dependencies = [ [[package]] name = "d2b-client" version = "2.0.0" -source = "git+https://github.com/vicondoa/d2b?rev=9183b45c6505cfd496e5d537bf6376f884fb16c7#9183b45c6505cfd496e5d537bf6376f884fb16c7" +source = "git+https://github.com/vicondoa/d2b?rev=4018d9c9652bd826c2e6a9abccdcdcafb832d944#4018d9c9652bd826c2e6a9abccdcdcafb832d944" dependencies = [ "async-trait", "d2b-contracts", @@ -1392,7 +1392,7 @@ dependencies = [ [[package]] name = "d2b-contracts" version = "2.0.0" -source = "git+https://github.com/vicondoa/d2b?rev=9183b45c6505cfd496e5d537bf6376f884fb16c7#9183b45c6505cfd496e5d537bf6376f884fb16c7" +source = "git+https://github.com/vicondoa/d2b?rev=4018d9c9652bd826c2e6a9abccdcdcafb832d944#4018d9c9652bd826c2e6a9abccdcdcafb832d944" dependencies = [ "async-trait", "protobuf", @@ -1405,7 +1405,7 @@ dependencies = [ [[package]] name = "d2b-session" version = "2.0.0" -source = "git+https://github.com/vicondoa/d2b?rev=9183b45c6505cfd496e5d537bf6376f884fb16c7#9183b45c6505cfd496e5d537bf6376f884fb16c7" +source = "git+https://github.com/vicondoa/d2b?rev=4018d9c9652bd826c2e6a9abccdcdcafb832d944#4018d9c9652bd826c2e6a9abccdcdcafb832d944" dependencies = [ "async-trait", "d2b-contracts", diff --git a/Cargo.toml b/Cargo.toml index 976ef4e1c01..be4d320f815 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -293,8 +293,8 @@ zbus = "4.2" zstd = "0.11" zvariant = "4.0" # --- weezterm remote features --- -d2b-client = { git = "https://github.com/vicondoa/d2b", rev = "9183b45c6505cfd496e5d537bf6376f884fb16c7", default-features = false } -d2b-contracts = { git = "https://github.com/vicondoa/d2b", rev = "9183b45c6505cfd496e5d537bf6376f884fb16c7", default-features = false, features = ["v2-services"] } +d2b-client = { git = "https://github.com/vicondoa/d2b", rev = "4018d9c9652bd826c2e6a9abccdcdcafb832d944", default-features = false } +d2b-contracts = { git = "https://github.com/vicondoa/d2b", rev = "4018d9c9652bd826c2e6a9abccdcdcafb832d944", default-features = false, features = ["v2-services"] } # --- end weezterm remote features --- [patch.crates-io] diff --git a/docs/d2b-provider.md b/docs/d2b-provider.md index 586973b8528..83f15bd6dcd 100644 --- a/docs/d2b-provider.md +++ b/docs/d2b-provider.md @@ -5,12 +5,14 @@ It consumes `d2b-client` and `d2b-contracts` directly from the exact d2b source revision: ```text -9183b45c6505cfd496e5d537bf6376f884fb16c7 +4018d9c9652bd826c2e6a9abccdcdcafb832d944 ``` -The Cargo lockfile binds the same revision. WeezTerm defines no d2b handshake, -frame codec, request or response type, shell record, error envelope, or target -parser. +The Cargo lockfile binds the same revision. It corresponds to client-toolkit +distribution fingerprint +`c2c99bdd77ba66948fce81161dcc3efde608eefefb96f28fa934c9f58d96d838`. +WeezTerm defines no d2b handshake, frame codec, request or response type, shell +record, error envelope, or target parser. ## Configure a target From 79897243053e2755e85a3c35f38e688e8b914e2c Mon Sep 17 00:00:00 2001 From: John Vicondoa Date: Mon, 20 Jul 2026 04:18:33 -0700 Subject: [PATCH 3/7] d2b: consume canonical client toolkit facade ( W9 ) Use the 2.0 facade as the sole source of client and contract types, and retain a typed shell-service target without reviving the retired wire implementation. Keep runtime attachment fail-closed until canonical workload routing is integrated. ADR: 0010, d2b/0045 --- CHANGELOG.md | 10 ++-- Cargo.lock | 16 +++++-- Cargo.toml | 3 +- README.md | 9 ++-- config/Cargo.toml | 3 +- config/src/d2b.rs | 22 ++++++++- ...010-provider-neutral-d2b-target-domains.md | 6 +-- docs/d2b-provider.md | 48 ++++++++++++------- mux/Cargo.toml | 3 -- wezterm-mux-server-impl/src/lib.rs | 2 +- 10 files changed, 82 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8630a8446a..d52503fd6da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,15 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Migrated d2b configuration to the canonical v2 `TargetInput` workload type - and pinned `d2b-client` plus `d2b-contracts` to d2b revision - `4018d9c9652bd826c2e6a9abccdcdcafb832d944`. + re-exported by `d2b-client-toolkit` 2.0.0. Workload domains now retain the + canonical shell-service target without defining a local service DTO. +- Aligned the d2b client distribution name and packaged source layout with + `share/d2b-client-toolkit/{distribution,d2b}`. ### Removed - Removed the legacy public-socket hello, JSON shell DTO, seqpacket framing, target-alias, session-picker, and terminal-bridge integration. Native d2b - discovery and persistent-shell attachment remain unavailable until their - owning v2 service contracts are finalized. + discovery and persistent-shell attachment remain unavailable until canonical + workload-to-shell routing is integrated. ## [0.7.2] - 2026-07-13 diff --git a/Cargo.lock b/Cargo.lock index a192d778530..529f14527a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1082,8 +1082,7 @@ dependencies = [ "anyhow", "bitflags 1.3.2", "colorgrad", - "d2b-client", - "d2b-contracts", + "d2b-client-toolkit", "dirs-next", "enum-display-derive", "env_logger 0.11.8", @@ -1389,6 +1388,18 @@ dependencies = [ "ttrpc", ] +[[package]] +name = "d2b-client-toolkit" +version = "2.0.0" +source = "git+https://github.com/vicondoa/d2b-toolkit?rev=800c2878533f600d8f085b3d2aafcddb970232b2#800c2878533f600d8f085b3d2aafcddb970232b2" +dependencies = [ + "d2b-client", + "d2b-contracts", + "d2b-session", + "thiserror 2.0.17", + "tokio", +] + [[package]] name = "d2b-contracts" version = "2.0.0" @@ -3789,7 +3800,6 @@ dependencies = [ "chrono", "config", "crossbeam", - "d2b-client", "downcast-rs", "fancy-regex", "filedescriptor", diff --git a/Cargo.toml b/Cargo.toml index be4d320f815..c6283798774 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -293,8 +293,7 @@ zbus = "4.2" zstd = "0.11" zvariant = "4.0" # --- weezterm remote features --- -d2b-client = { git = "https://github.com/vicondoa/d2b", rev = "4018d9c9652bd826c2e6a9abccdcdcafb832d944", default-features = false } -d2b-contracts = { git = "https://github.com/vicondoa/d2b", rev = "4018d9c9652bd826c2e6a9abccdcdcafb832d944", default-features = false, features = ["v2-services"] } +d2b-client-toolkit = { git = "https://github.com/vicondoa/d2b-toolkit", rev = "800c2878533f600d8f085b3d2aafcddb970232b2", default-features = false } # --- end weezterm remote features --- [patch.crates-io] diff --git a/README.md b/README.md index ff6b43eadfa..86c1afb661a 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,13 @@ WeezTerm extends WezTerm with VS Code Remote SSH-style features: - **Window state persistence** — Window position, size, and maximized/fullscreen state saved and restored across restarts, with correct multi-monitor support - **DevContainer domains** — First-class Docker devcontainer support with auto-discovery, a manager overlay (`Ctrl+Shift+D`), and full SSH domain integration - **d2b client seam** — Canonical v2 workload target configuration backed by - d2b's exact pinned client and identity types. Runtime session integration - remains disabled until the owning d2b service contracts are finalized. + the `d2b-client-toolkit` distribution and its exact pinned client and identity + types. Runtime session integration fails closed until canonical + workload-to-shell routing is available. See [docs/remote-extensions.md](docs/remote-extensions.md) for detailed documentation of all features and configuration options. -See [docs/d2b-provider.md](docs/d2b-provider.md) for the d2b source pin, -configuration contract, and current runtime boundary. +See [docs/d2b-provider.md](docs/d2b-provider.md) for the toolkit source pin, +packaged share layout, configuration contract, and current runtime boundary. ## Credits diff --git a/config/Cargo.toml b/config/Cargo.toml index 305f61565f2..2d1623e488f 100644 --- a/config/Cargo.toml +++ b/config/Cargo.toml @@ -43,8 +43,7 @@ wezterm-input-types.workspace = true wezterm-ssh.workspace = true wezterm-term = { workspace = true, features=["use_serde"] } # --- weezterm remote features --- -d2b-client.workspace = true -d2b-contracts.workspace = true +d2b-client-toolkit.workspace = true # --- end weezterm remote features --- [target."cfg(unix)".dependencies] diff --git a/config/src/d2b.rs b/config/src/d2b.rs index 792f6e799ac..a0b0c2e7fc1 100644 --- a/config/src/d2b.rs +++ b/config/src/d2b.rs @@ -1,6 +1,6 @@ use crate::config::validate_domain_name; -use d2b_client::TargetInput; -use d2b_contracts::v2_identity::{RealmId, WorkloadId}; +use d2b_client_toolkit::contracts::v2_identity::{RealmId, WorkloadId}; +use d2b_client_toolkit::{ServiceKind, ServiceOwner, TargetInput}; use std::convert::TryFrom; use wezterm_dynamic::{FromDynamic, ToDynamic}; @@ -25,6 +25,17 @@ impl D2bDomainConfig { _ => unreachable!("D2bDomainConfig accepts only workload targets"), } } + + pub fn shell_service_target(&self) -> TargetInput { + let (realm, workload) = self.workload_ids(); + TargetInput::Service { + owner: ServiceOwner::Workload { + realm: realm.clone(), + workload: workload.clone(), + }, + service: ServiceKind::Shell, + } + } } #[derive(Debug, Clone, PartialEq, Eq, FromDynamic, ToDynamic)] @@ -101,6 +112,13 @@ mod test { assert_eq!(realm.as_str(), REALM_ID); assert_eq!(workload.as_str(), WORKLOAD_ID); assert!(matches!(config.target(), TargetInput::Workload { .. })); + assert!(matches!( + config.shell_service_target(), + TargetInput::Service { + owner: ServiceOwner::Workload { .. }, + service: ServiceKind::Shell, + } + )); } #[test] diff --git a/docs/adr/0010-provider-neutral-d2b-target-domains.md b/docs/adr/0010-provider-neutral-d2b-target-domains.md index 2e91569bdae..06927dc1791 100644 --- a/docs/adr/0010-provider-neutral-d2b-target-domains.md +++ b/docs/adr/0010-provider-neutral-d2b-target-domains.md @@ -12,9 +12,9 @@ This record describes the retired d2b 1.x integration. The current seam follows [d2b ADR 0045](https://github.com/vicondoa/d2b/blob/main/docs/adr/0045-provider-and-transport-framework.md): -configuration stores canonical v2 client target types, while daemon discovery -and persistent-shell transport remain absent until their owning service -contracts are finalized. +configuration stores the canonical v2 client target and shell-service target +re-exported by `d2b-client-toolkit`. Daemon discovery and persistent-shell +transport remain absent until workload-to-shell routing is integrated. The first native d2b domain treated every shell endpoint as a local VM name. d2b now exposes provider-neutral canonical workload targets and typed provider, diff --git a/docs/d2b-provider.md b/docs/d2b-provider.md index 83f15bd6dcd..069ed668024 100644 --- a/docs/d2b-provider.md +++ b/docs/d2b-provider.md @@ -1,22 +1,35 @@ # d2b client seam WeezTerm carries a configuration seam for canonical d2b v2 workload targets. -It consumes `d2b-client` and `d2b-contracts` directly from the exact d2b source +It consumes the `d2b-client-toolkit` 2.0.0 facade from the exact distribution revision: ```text -4018d9c9652bd826c2e6a9abccdcdcafb832d944 +800c2878533f600d8f085b3d2aafcddb970232b2 ``` -The Cargo lockfile binds the same revision. It corresponds to client-toolkit -distribution fingerprint +That distribution re-exports canonical d2b source revision +`4018d9c9652bd826c2e6a9abccdcdcafb832d944`, with source fingerprint `c2c99bdd77ba66948fce81161dcc3efde608eefefb96f28fa934c9f58d96d838`. -WeezTerm defines no d2b handshake, frame codec, request or response type, shell -record, error envelope, or target parser. +The Cargo lockfile binds both revisions through the facade. WeezTerm defines no +d2b handshake, frame codec, request or response type, shell record, error +envelope, or target parser. + +The toolkit flake package is named `d2b-client-toolkit`. Its immutable source +layout is: + +```text +share/d2b-client-toolkit/ +├── distribution/ +└── d2b/ +``` + +There is no `d2b-toolkit` package, crate, or share path in the current seam. ## Configure a target -Each domain uses the canonical `d2b_client::TargetInput::Workload` type. +Each domain uses the canonical +`d2b_client_toolkit::TargetInput::Workload` type. Configuration supplies the exact canonical realm and workload IDs: ```lua @@ -39,13 +52,16 @@ String targets, VM aliases, and direct socket-path overrides are not accepted. ## Runtime boundary -Configured d2b domains are currently reported as unavailable and are not added -to the mux. WeezTerm does not guess the pending endpoint bootstrap, route -resolution, daemon discovery, session setup, or persistent-shell stream APIs. -There is no legacy public-socket fallback, shell command fallback, SSH mapping, -or direct host-terminal bridge. +The configuration seam derives a canonical +`TargetInput::Service { service: ServiceKind::Shell, ... }` from each workload +target. Configured d2b domains are currently reported as unavailable and are +not added to the mux because workload-to-shell routing is not integrated. +WeezTerm does not infer a local VM name, read root-owned state, or connect to a +caller-selected socket. There is no legacy public-socket fallback, shell +command fallback, SSH mapping, or direct host-terminal bridge. -Runtime integration can return only after the canonical control and -user-session service APIs are finalized. It must use an explicit Tokio runtime -boundary because WeezTerm's UI and mux use smol; copying protocol or transport -code into this repository is not an acceptable adapter. +Runtime integration must consume canonical client routing and terminal streams +through an explicit Tokio runtime boundary because WeezTerm's UI and mux use +smol. Copying protocol or transport code into this repository is not an +acceptable adapter, and missing integrated routing remains a fail-closed +condition. diff --git a/mux/Cargo.toml b/mux/Cargo.toml index bcab352df37..bfd54db6698 100644 --- a/mux/Cargo.toml +++ b/mux/Cargo.toml @@ -62,8 +62,5 @@ winapi = { workspace=true, features = [ "tlhelp32", ]} -[target."cfg(target_os = \"linux\")".dependencies] -d2b-client.workspace = true - [dev-dependencies] k9.workspace = true diff --git a/wezterm-mux-server-impl/src/lib.rs b/wezterm-mux-server-impl/src/lib.rs index 3230b87cfa6..b33b316f2f6 100644 --- a/wezterm-mux-server-impl/src/lib.rs +++ b/wezterm-mux-server-impl/src/lib.rs @@ -92,7 +92,7 @@ fn update_mux_domains_impl(config: &ConfigHandle, is_standalone_mux: bool) -> an // --- weezterm remote features --- for d2b_dom in &config.d2b_domains { log::warn!( - "Ignoring d2b domain {}: d2b v2 session setup is not available yet", + "Ignoring d2b domain {}: workload-to-shell routing is not available yet", d2b_dom.name ); } From 0c91319f88e82c5a650b64155eec7708053d8670 Mon Sep 17 00:00:00 2001 From: John Vicondoa Date: Mon, 20 Jul 2026 04:50:26 -0700 Subject: [PATCH 4/7] d2b: reconcile frozen service facade ( W9 ) Follow the canonical toolkit head now that it distributes the frozen control and user-service clients. Pin the exact d2b source inventory and keep endpoint acquisition and workload routing unavailable rather than inventing a local fallback. ADR: 0010, d2b/0045 --- CHANGELOG.md | 3 +++ Cargo.lock | 11 +++++++---- Cargo.toml | 2 +- config/src/d2b.rs | 22 ++++++++++++++++++++++ docs/d2b-provider.md | 18 ++++++++++-------- 5 files changed, 43 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d52503fd6da..d5e1ac9db06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Migrated d2b configuration to the canonical v2 `TargetInput` workload type re-exported by `d2b-client-toolkit` 2.0.0. Workload domains now retain the canonical shell-service target without defining a local service DTO. +- Pinned the toolkit facade to the frozen daemon, guest, terminal, and user + service client inventory while keeping endpoint acquisition and integrated + workload routing disabled. - Aligned the d2b client distribution name and packaged source layout with `share/d2b-client-toolkit/{distribution,d2b}`. diff --git a/Cargo.lock b/Cargo.lock index 529f14527a2..606eb5a3c3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1378,12 +1378,13 @@ dependencies = [ [[package]] name = "d2b-client" version = "2.0.0" -source = "git+https://github.com/vicondoa/d2b?rev=4018d9c9652bd826c2e6a9abccdcdcafb832d944#4018d9c9652bd826c2e6a9abccdcdcafb832d944" +source = "git+https://github.com/vicondoa/d2b?rev=9dc902243cdd7aba7ef269988b96f0aae6e037da#9dc902243cdd7aba7ef269988b96f0aae6e037da" dependencies = [ "async-trait", "d2b-contracts", "d2b-session", "protobuf", + "sha2", "tokio", "ttrpc", ] @@ -1391,7 +1392,7 @@ dependencies = [ [[package]] name = "d2b-client-toolkit" version = "2.0.0" -source = "git+https://github.com/vicondoa/d2b-toolkit?rev=800c2878533f600d8f085b3d2aafcddb970232b2#800c2878533f600d8f085b3d2aafcddb970232b2" +source = "git+https://github.com/vicondoa/d2b-toolkit?rev=3d6b75d47c8df66c1722ea324d64334a127d43ec#3d6b75d47c8df66c1722ea324d64334a127d43ec" dependencies = [ "d2b-client", "d2b-contracts", @@ -1403,7 +1404,7 @@ dependencies = [ [[package]] name = "d2b-contracts" version = "2.0.0" -source = "git+https://github.com/vicondoa/d2b?rev=4018d9c9652bd826c2e6a9abccdcdcafb832d944#4018d9c9652bd826c2e6a9abccdcdcafb832d944" +source = "git+https://github.com/vicondoa/d2b?rev=9dc902243cdd7aba7ef269988b96f0aae6e037da#9dc902243cdd7aba7ef269988b96f0aae6e037da" dependencies = [ "async-trait", "protobuf", @@ -1411,15 +1412,17 @@ dependencies = [ "serde", "sha2", "ttrpc", + "zeroize", ] [[package]] name = "d2b-session" version = "2.0.0" -source = "git+https://github.com/vicondoa/d2b?rev=4018d9c9652bd826c2e6a9abccdcdcafb832d944#4018d9c9652bd826c2e6a9abccdcdcafb832d944" +source = "git+https://github.com/vicondoa/d2b?rev=9dc902243cdd7aba7ef269988b96f0aae6e037da#9dc902243cdd7aba7ef269988b96f0aae6e037da" dependencies = [ "async-trait", "d2b-contracts", + "futures-util", "protobuf", "sha2", "snow", diff --git a/Cargo.toml b/Cargo.toml index c6283798774..2b5ee779e63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -293,7 +293,7 @@ zbus = "4.2" zstd = "0.11" zvariant = "4.0" # --- weezterm remote features --- -d2b-client-toolkit = { git = "https://github.com/vicondoa/d2b-toolkit", rev = "800c2878533f600d8f085b3d2aafcddb970232b2", default-features = false } +d2b-client-toolkit = { git = "https://github.com/vicondoa/d2b-toolkit", rev = "3d6b75d47c8df66c1722ea324d64334a127d43ec", default-features = false } # --- end weezterm remote features --- [patch.crates-io] diff --git a/config/src/d2b.rs b/config/src/d2b.rs index a0b0c2e7fc1..2d05c8cfec8 100644 --- a/config/src/d2b.rs +++ b/config/src/d2b.rs @@ -121,6 +121,28 @@ mod test { )); } + #[test] + fn canonical_service_client_inventory_is_pinned() { + assert_eq!( + d2b_client_toolkit::D2B_SOURCE_REVISION, + "9dc902243cdd7aba7ef269988b96f0aae6e037da" + ); + assert_eq!( + d2b_client_toolkit::D2B_SOURCE_FINGERPRINT, + "5a20cef3a64281df819eeb76bdfe385999755479b467b559653011582fb9c043" + ); + let _ = std::any::TypeId::of::(); + for service in [ + ServiceKind::Daemon, + ServiceKind::User, + ServiceKind::Shell, + ServiceKind::Notify, + ServiceKind::Wayland, + ] { + assert!(ServiceKind::ALL.contains(&service)); + } + } + #[test] fn canonical_workload_target_round_trips() { let config = decode(serde_json::json!({ diff --git a/docs/d2b-provider.md b/docs/d2b-provider.md index 069ed668024..2c93a92fa6c 100644 --- a/docs/d2b-provider.md +++ b/docs/d2b-provider.md @@ -5,12 +5,12 @@ It consumes the `d2b-client-toolkit` 2.0.0 facade from the exact distribution revision: ```text -800c2878533f600d8f085b3d2aafcddb970232b2 +3d6b75d47c8df66c1722ea324d64334a127d43ec ``` That distribution re-exports canonical d2b source revision -`4018d9c9652bd826c2e6a9abccdcdcafb832d944`, with source fingerprint -`c2c99bdd77ba66948fce81161dcc3efde608eefefb96f28fa934c9f58d96d838`. +`9dc902243cdd7aba7ef269988b96f0aae6e037da`, with source fingerprint +`5a20cef3a64281df819eeb76bdfe385999755479b467b559653011582fb9c043`. The Cargo lockfile binds both revisions through the facade. WeezTerm defines no d2b handshake, frame codec, request or response type, shell record, error envelope, or target parser. @@ -54,11 +54,13 @@ String targets, VM aliases, and direct socket-path overrides are not accepted. The configuration seam derives a canonical `TargetInput::Service { service: ServiceKind::Shell, ... }` from each workload -target. Configured d2b domains are currently reported as unavailable and are -not added to the mux because workload-to-shell routing is not integrated. -WeezTerm does not infer a local VM name, read root-owned state, or connect to a -caller-selected socket. There is no legacy public-socket fallback, shell -command fallback, SSH mapping, or direct host-terminal bridge. +target. The facade also exposes the frozen typed `DaemonClient`, guest terminal +client, and generated user/desktop service clients without local wrappers. +Configured d2b domains are currently reported as unavailable and are not added +to the mux because canonical endpoint acquisition and workload-to-shell routing +are not integrated. WeezTerm does not infer a local VM name, read root-owned +state, or connect to a caller-selected socket. There is no legacy public-socket +fallback, shell command fallback, SSH mapping, or direct host-terminal bridge. Runtime integration must consume canonical client routing and terminal streams through an explicit Tokio runtime boundary because WeezTerm's UI and mux use From 811a7081b1da0062535f1cf61e23e1a4fcce4899 Mon Sep 17 00:00:00 2001 From: John Vicondoa Date: Tue, 21 Jul 2026 04:16:41 -0700 Subject: [PATCH 5/7] d2b: advance toolkit pin and harden nix precommit ( W9 ) Advance the pinned d2b-client-toolkit distribution revision from 3d6b75d47c8df66c1722ea324d64334a127d43ec to 926de54e7320599c373524a10b65aaf13b6ff422 via `cargo update -p d2b-client-toolkit`, updating Cargo.toml, Cargo.lock, docs/d2b-provider.md, and CHANGELOG.md. Verified against the upstream d2b-toolkit repo and the config::d2b test suite that the canonical d2b source revision (9dc902243cdd7aba7ef269988b96f0aae6e037da) and fingerprint (5a20cef3a64281df819eeb76bdfe385999755479b467b559653011582fb9c043) the facade re-exports are unchanged. Made `nix develop --command make precommit` hermetic: the Nix cargo in the dev shell has no rustup, so `cargo +nightly fmt` failed with 'no such command: +nightly', and the shell lacked cargo-nextest. `make fmt` now falls back to bare `cargo fmt` (which resolves to the same pinned nightly rustfmt already on PATH) only when rustup is absent, so rustup-based checkouts keep using `cargo +nightly fmt` unchanged. Added pkgs.cargo-nextest to the flake devShell so nextest runs without network installs. Updated AGENTS.md's precommit section to document the nix-shell behavior. --- AGENTS.md | 6 ++++++ CHANGELOG.md | 7 +++++++ Cargo.lock | 4 ++-- Cargo.toml | 2 +- Makefile | 14 +++++++++++++- docs/d2b-provider.md | 2 +- nix/flake.nix | 8 +++++++- 7 files changed, 37 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index db4a80e1ef4..9912f6b0ef6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,6 +162,12 @@ cargo nextest run -p wezterm-escape-parser Prefer `ci/build-cross.sh` which covers both platforms. +**In a `nix develop` shell**, `make precommit` is hermetic: the dev shell +pins `cargo-nextest` directly, and `make fmt` falls back to bare `cargo fmt` +(which resolves to that same pinned nightly rustfmt) because there is no +`rustup` to satisfy the `+nightly` toolchain selector. Rustup-based +checkouts are unaffected and keep using `cargo +nightly fmt`. + **Important**: The Linux/WSL build uses a separate Rust toolchain and can surface warnings/errors that don't appear on Windows (e.g., unused code warnings that trigger rustc ICEs on certain Linux compiler versions). Always diff --git a/CHANGELOG.md b/CHANGELOG.md index d5e1ac9db06..f3f17ccdd9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 workload routing disabled. - Aligned the d2b client distribution name and packaged source layout with `share/d2b-client-toolkit/{distribution,d2b}`. +- Advanced the pinned `d2b-client-toolkit` distribution revision to + `926de54e7320599c373524a10b65aaf13b6ff422`. The canonical d2b source + revision and fingerprint the facade re-exports are unchanged. +- Made `nix develop --command make precommit` hermetic: the pinned Nix dev + shell now provides `cargo-nextest` directly, and `make fmt` falls back to + bare `cargo fmt` (which resolves to that same pinned nightly rustfmt) when + no `rustup` is present instead of failing on `cargo +nightly fmt`. ### Removed diff --git a/Cargo.lock b/Cargo.lock index 606eb5a3c3c..48ef32a748c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1392,7 +1392,7 @@ dependencies = [ [[package]] name = "d2b-client-toolkit" version = "2.0.0" -source = "git+https://github.com/vicondoa/d2b-toolkit?rev=3d6b75d47c8df66c1722ea324d64334a127d43ec#3d6b75d47c8df66c1722ea324d64334a127d43ec" +source = "git+https://github.com/vicondoa/d2b-toolkit?rev=926de54e7320599c373524a10b65aaf13b6ff422#926de54e7320599c373524a10b65aaf13b6ff422" dependencies = [ "d2b-client", "d2b-contracts", @@ -2833,7 +2833,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", diff --git a/Cargo.toml b/Cargo.toml index 2b5ee779e63..8c8a676a399 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -293,7 +293,7 @@ zbus = "4.2" zstd = "0.11" zvariant = "4.0" # --- weezterm remote features --- -d2b-client-toolkit = { git = "https://github.com/vicondoa/d2b-toolkit", rev = "3d6b75d47c8df66c1722ea324d64334a127d43ec", default-features = false } +d2b-client-toolkit = { git = "https://github.com/vicondoa/d2b-toolkit", rev = "926de54e7320599c373524a10b65aaf13b6ff422", default-features = false } # --- end weezterm remote features --- [patch.crates-io] diff --git a/Makefile b/Makefile index 700bd4284c4..1f2af4d1f58 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,19 @@ build: cargo build $(BUILD_OPTS) -p strip-ansi-escapes fmt: - cargo +nightly fmt + @# --- weezterm remote features --- + @# `nix develop` shells put a pinned nightly rustfmt directly on PATH and + @# have no rustup, so `cargo +nightly fmt` fails there with "no such + @# command: `+nightly`". Rustup-based checkouts keep selecting the + @# nightly toolchain explicitly; only fall back to bare `cargo fmt` (which + @# cargo resolves to that same pinned nightly rustfmt) when rustup is + @# absent, so non-Nix contributor behavior is unchanged. + @if command -v rustup >/dev/null 2>&1; then \ + cargo +nightly fmt; \ + else \ + cargo fmt; \ + fi + @# --- end weezterm remote features --- docs: ci/build-docs.sh diff --git a/docs/d2b-provider.md b/docs/d2b-provider.md index 2c93a92fa6c..e2a7bf278df 100644 --- a/docs/d2b-provider.md +++ b/docs/d2b-provider.md @@ -5,7 +5,7 @@ It consumes the `d2b-client-toolkit` 2.0.0 facade from the exact distribution revision: ```text -3d6b75d47c8df66c1722ea324d64334a127d43ec +926de54e7320599c373524a10b65aaf13b6ff422 ``` That distribution re-exports canonical d2b source revision diff --git a/nix/flake.nix b/nix/flake.nix index f0517ac4dd2..f0452461392 100644 --- a/nix/flake.nix +++ b/nix/flake.nix @@ -388,7 +388,13 @@ }) nightly."2026-06-06".rustfmt nightly."2026-06-06".rust-analyzer - ]); + ]) + # --- weezterm remote features --- + # `make precommit`/`make test` need `cargo nextest`; there is no + # rustup in this shell to `cargo install` it on demand, so pin it + # from nixpkgs alongside the rest of the toolchain. + ++ [ pkgs.cargo-nextest ]; + # --- end weezterm remote features --- LD_LIBRARY_PATH = libPath; }; From cf05f9d85a48f979f30aa1c18a5acb602804194c Mon Sep 17 00:00:00 2001 From: John Vicondoa Date: Tue, 21 Jul 2026 10:29:43 -0700 Subject: [PATCH 6/7] d2b: pin flake inputs for source vendoring, add fmt/clippy checks ( W9fu1 ) Pin explicit d2b/d2b-toolkit flake = false inputs (matching the exact Cargo.lock git revisions) in flake.nix/nix/flake.nix, and thread their locked narHash into sourcePackage.cargoLock.outputHashes for the d2b-client, d2b-contracts, d2b-session, and d2b-client-toolkit crates. nix build .#source now vendors those four crates through the hermetic fetchgit fixed-output-derivation path with flake.lock as the narHash authority, instead of relying solely on cargoLock.allowBuiltinFetchGit's impure fallback for them. allowBuiltinFetchGit remains set only for the seam's other, unrelated git dependencies (xcb-imdkit, finl_unicode). Verified empirically that fetchgit's default-leaveDotGit hash matches the github flake fetcher's narHash for both pinned revisions before wiring this in. Add checks.cargo-fmt and checks.cargo-clippy flake outputs that reuse sourcePackage's pinned src/patches/vendored cargoLock (no second fetch). cargo-fmt runs cargo fmt --all -- --check across the tree. cargo-clippy runs cargo clippy -p config --all-targets --no-deps and hard-fails only on findings inside config/src/d2b.rs, since the vendored upstream wezterm/config tree carries extensive pre-existing clippy findings this seam does not own and must not fix as a side effect. Improve config/src/d2b.rs's invalid canonical realm_id/workload_id error messages to include the configured domain name so a multi-domain config points at the failing [[d2b_domains]] entry; the rejected target value itself is still never echoed back. Update docs/d2b-provider.md and AGENTS.md to describe the new flake input/outputHashes wiring and the checks' scope, and CHANGELOG.md. --- AGENTS.md | 7 +++ CHANGELOG.md | 28 ++++++++++++ config/src/d2b.rs | 51 +++++++++++++++++++--- docs/d2b-provider.md | 18 +++++++- flake.lock | 36 ++++++++++++++++ flake.nix | 19 ++++++++ nix/flake.lock | 36 ++++++++++++++++ nix/flake.nix | 100 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 288 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9912f6b0ef6..321d9674e27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,6 +150,13 @@ The following checks run in CI on every PR to `main`. All must pass before merge The **`windows`** job is the required status check for merge. The other jobs are informational but should also pass. +`nix flake check` (or `nix build .#checks..cargo-fmt` / +`.#checks..cargo-clippy` individually) additionally runs hermetic +format/lint checks against `nix build .#source`'s exact pinned src, patches, +and vendored `Cargo.lock` (including the `d2b`/`d2b-toolkit` git dependencies +pinned via the `d2b`/`d2b-toolkit` flake inputs) -- no ambient rustup or +cargo-nextest assumed. + ### If `make` is available `make precommit` runs format + check + tests but does NOT cross-build for Linux: diff --git a/CHANGELOG.md b/CHANGELOG.md index f3f17ccdd9f..613f47d9291 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Pinned explicit `d2b` and `d2b-toolkit` `flake = false` inputs (matching + the exact `Cargo.lock` git revisions) in `flake.nix`/`nix/flake.nix`, and + threaded their locked `narHash` into `sourcePackage.cargoLock.outputHashes` + for the `d2b-client`, `d2b-contracts`, `d2b-session`, and + `d2b-client-toolkit` crates. `nix build .#source` now vendors those four + crates through the hermetic `fetchgit` fixed-output-derivation path with + `flake.lock` as the narHash authority, instead of relying solely on + `cargoLock.allowBuiltinFetchGit`'s impure fallback for them. + `allowBuiltinFetchGit` remains set for the seam's other, unrelated git + dependencies (`xcb-imdkit`, `finl_unicode`). +- Added hermetic `checks.cargo-fmt` and `checks.cargo-clippy` flake outputs + that reuse `sourcePackage`'s pinned src/patches/vendored `cargoLock` (no + second fetch), giving `nix build .#source`/`nix flake check` attached + lint/format coverage without changing the existing full source build. + `cargo-fmt` checks `cargo fmt --all -- --check` across the whole tree. + `cargo-clippy` runs `cargo clippy -p config --all-targets --no-deps` and + hard-fails only on findings inside `config/src/d2b.rs`, the file this + seam wholly owns; the vendored upstream `wezterm`/`config` tree carries + pre-existing clippy findings outside this seam's ownership that this + check intentionally does not enforce, consistent with never reformatting + or rewriting upstream files. + ### Changed - Migrated d2b configuration to the canonical v2 `TargetInput` workload type @@ -24,6 +48,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 shell now provides `cargo-nextest` directly, and `make fmt` falls back to bare `cargo fmt` (which resolves to that same pinned nightly rustfmt) when no `rustup` is present instead of failing on `cargo +nightly fmt`. +- d2b domain configuration errors for an invalid canonical `realm_id` or + `workload_id` now name the configured domain (`name`), so a multi-domain + config points at the right `[[d2b_domains]]` entry. The rejected + `realm_id`/`workload_id` value itself is still never echoed back. ### Removed diff --git a/config/src/d2b.rs b/config/src/d2b.rs index 2d05c8cfec8..4233339bf29 100644 --- a/config/src/d2b.rs +++ b/config/src/d2b.rs @@ -61,12 +61,33 @@ impl TryFrom for D2bDomainConfig { type Error = String; fn try_from(raw: RawD2bDomainConfig) -> Result { - let realm = RealmId::parse(raw.target.realm_id) - .map_err(|_| "d2b target has an invalid canonical realm id".to_string())?; - let workload = WorkloadId::parse(raw.target.workload_id) - .map_err(|_| "d2b target has an invalid canonical workload id".to_string())?; + // Include the configured domain name so a multi-domain config makes + // it obvious which `[[d2b_domains]]` entry failed. The rejected + // realm_id/workload_id value itself is never echoed back: it may be + // an operator typo, but it is still target-identity material we + // don't want to round-trip into error text or logs. + // + // `name` is captured into a local up front (rather than read via + // `raw.name` inside the error closures below) because this crate is + // on the 2018 edition, which captures whole variables rather than + // disjoint fields; reading `raw.name` after `raw.target.realm_id` / + // `raw.target.workload_id` are moved out would otherwise be a + // partial-move borrow error. + let name = raw.name; + let realm = RealmId::parse(raw.target.realm_id).map_err(|_| { + format!( + "d2b domain \"{}\" has a target with an invalid canonical realm id", + name + ) + })?; + let workload = WorkloadId::parse(raw.target.workload_id).map_err(|_| { + format!( + "d2b domain \"{}\" has a target with an invalid canonical workload id", + name + ) + })?; Ok(Self { - name: raw.name, + name, target: TargetInput::Workload { realm, workload }, }) } @@ -186,13 +207,31 @@ mod test { let err = decode(serde_json::json!({ "name": "work", "target": { - "realm_id": "work", + "realm_id": "Not-Canonical", "workload_id": WORKLOAD_ID } })) .unwrap_err() .to_string(); assert!(err.contains("canonical realm id")); + assert!(err.contains("\"work\"")); + assert!(!err.contains("Not-Canonical")); assert!(!err.contains(WORKLOAD_ID)); } + + #[test] + fn invalid_workload_id_names_the_domain_without_echoing_the_value() { + let err = decode(serde_json::json!({ + "name": "personal", + "target": { + "realm_id": REALM_ID, + "workload_id": "not a canonical workload id" + } + })) + .unwrap_err() + .to_string(); + assert!(err.contains("canonical workload id")); + assert!(err.contains("\"personal\"")); + assert!(!err.contains("not a canonical workload id")); + } } diff --git a/docs/d2b-provider.md b/docs/d2b-provider.md index e2a7bf278df..30afceec419 100644 --- a/docs/d2b-provider.md +++ b/docs/d2b-provider.md @@ -15,6 +15,19 @@ The Cargo lockfile binds both revisions through the facade. WeezTerm defines no d2b handshake, frame codec, request or response type, shell record, error envelope, or target parser. +`nix build .#source` pins the same two revisions as explicit `flake = false` +inputs (`d2b`, `d2b-toolkit`) in `flake.nix`/`nix/flake.nix`. Their `narHash` +values (captured in `flake.lock`) are threaded into +`sourcePackage.cargoLock.outputHashes` for the `d2b-client`, `d2b-contracts`, +`d2b-session`, and `d2b-client-toolkit` crates, so vendoring those four +crates goes through the hermetic `fetchgit` fixed-output-derivation path +instead of `cargoLock.allowBuiltinFetchGit`'s impure fallback. +`allowBuiltinFetchGit` remains set only for the seam's other, unrelated git +dependencies (`xcb-imdkit`, `finl_unicode`). Bumping either revision requires +updating the `Cargo.toml`/`Cargo.lock` git rev *and* the matching flake input +URL, then re-running `nix flake lock` (both the root and `nix/` lockfiles) to +refresh the pinned `narHash`. + The toolkit flake package is named `d2b-client-toolkit`. Its immutable source layout is: @@ -47,7 +60,10 @@ return { ``` Both IDs use d2b's 20-character lowercase unpadded base32 short-ID grammar. -Invalid IDs fail configuration without echoing the submitted identity. +Invalid IDs fail configuration without echoing the submitted identity; the +error names the configured `name` (the WeezTerm domain), not the rejected +`realm_id`/`workload_id` value, so a multi-domain config still points at the +right `[[d2b_domains]]` entry. String targets, VM aliases, and direct socket-path overrides are not accepted. ## Runtime boundary diff --git a/flake.lock b/flake.lock index 81a15270125..69e0fc7b177 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,39 @@ { "nodes": { + "d2b": { + "flake": false, + "locked": { + "lastModified": 1784543637, + "narHash": "sha256-mDNv+gkV0GKOFDWJEunuR76mPIwQsSg9AJcxsI5qhMQ=", + "owner": "vicondoa", + "repo": "d2b", + "rev": "9dc902243cdd7aba7ef269988b96f0aae6e037da", + "type": "github" + }, + "original": { + "owner": "vicondoa", + "repo": "d2b", + "rev": "9dc902243cdd7aba7ef269988b96f0aae6e037da", + "type": "github" + } + }, + "d2b-toolkit": { + "flake": false, + "locked": { + "lastModified": 1784625987, + "narHash": "sha256-vGb04cQDlO8KBoI5n0N//LLKhoLX8wK4nE0wu2UMJjQ=", + "owner": "vicondoa", + "repo": "d2b-toolkit", + "rev": "926de54e7320599c373524a10b65aaf13b6ff422", + "type": "github" + }, + "original": { + "owner": "vicondoa", + "repo": "d2b-toolkit", + "rev": "926de54e7320599c373524a10b65aaf13b6ff422", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -87,6 +121,8 @@ }, "root": { "inputs": { + "d2b": "d2b", + "d2b-toolkit": "d2b-toolkit", "flake-utils": "flake-utils", "freetype2": "freetype2", "harfbuzz": "harfbuzz", diff --git a/flake.nix b/flake.nix index feccfad2474..2b13dc81597 100644 --- a/flake.nix +++ b/flake.nix @@ -26,6 +26,25 @@ url = "github:madler/zlib/v1.3.1"; flake = false; }; + # --- weezterm remote features --- + # Explicit, pinned raw sources for the two d2b git dependencies consumed + # by config/src/d2b.rs via Cargo.toml. Neither is a flake we build against + # (no nixpkgs to follow), so these are plain `flake = false` sources, same + # as freetype2/harfbuzz/libpng/zlib above. Pinning them as flake inputs + # gives flake.lock narHash authority over the exact revision instead of + # leaving it to an unbound, unlocked `cargoLock.allowBuiltinFetchGit` + # fetch. Keep the revs in sync with the `d2b-client-toolkit` git rev in + # Cargo.toml and the canonical d2b source rev asserted in + # config/src/d2b.rs. + d2b = { + url = "github:vicondoa/d2b/9dc902243cdd7aba7ef269988b96f0aae6e037da"; + flake = false; + }; + d2b-toolkit = { + url = "github:vicondoa/d2b-toolkit/926de54e7320599c373524a10b65aaf13b6ff422"; + flake = false; + }; + # --- end weezterm remote features --- }; outputs = inputs: (import ./nix/flake.nix).outputs inputs; diff --git a/nix/flake.lock b/nix/flake.lock index 58c4c0a8e36..98adff087d3 100644 --- a/nix/flake.lock +++ b/nix/flake.lock @@ -1,5 +1,39 @@ { "nodes": { + "d2b": { + "flake": false, + "locked": { + "lastModified": 1784543637, + "narHash": "sha256-mDNv+gkV0GKOFDWJEunuR76mPIwQsSg9AJcxsI5qhMQ=", + "owner": "vicondoa", + "repo": "d2b", + "rev": "9dc902243cdd7aba7ef269988b96f0aae6e037da", + "type": "github" + }, + "original": { + "owner": "vicondoa", + "repo": "d2b", + "rev": "9dc902243cdd7aba7ef269988b96f0aae6e037da", + "type": "github" + } + }, + "d2b-toolkit": { + "flake": false, + "locked": { + "lastModified": 1784625987, + "narHash": "sha256-vGb04cQDlO8KBoI5n0N//LLKhoLX8wK4nE0wu2UMJjQ=", + "owner": "vicondoa", + "repo": "d2b-toolkit", + "rev": "926de54e7320599c373524a10b65aaf13b6ff422", + "type": "github" + }, + "original": { + "owner": "vicondoa", + "repo": "d2b-toolkit", + "rev": "926de54e7320599c373524a10b65aaf13b6ff422", + "type": "github" + } + }, "flake-utils": { "inputs": { "systems": "systems" @@ -87,6 +121,8 @@ }, "root": { "inputs": { + "d2b": "d2b", + "d2b-toolkit": "d2b-toolkit", "flake-utils": "flake-utils", "freetype2": "freetype2", "harfbuzz": "harfbuzz", diff --git a/nix/flake.nix b/nix/flake.nix index f0452461392..6215907feb7 100644 --- a/nix/flake.nix +++ b/nix/flake.nix @@ -40,6 +40,19 @@ url = "github:madler/zlib/v1.3.1"; flake = false; }; + # --- weezterm remote features --- + # Keep these two in sync with the root flake.nix; see the comment there + # for why they are pinned as `flake = false` inputs instead of relying on + # `cargoLock.allowBuiltinFetchGit`. + d2b = { + url = "github:vicondoa/d2b/9dc902243cdd7aba7ef269988b96f0aae6e037da"; + flake = false; + }; + d2b-toolkit = { + url = "github:vicondoa/d2b-toolkit/926de54e7320599c373524a10b65aaf13b6ff422"; + flake = false; + }; + # --- end weezterm remote features --- }; outputs = @@ -204,7 +217,28 @@ cargoLock = { lockFile = ../Cargo.lock; + # --- weezterm remote features --- + # `allowBuiltinFetchGit` remains only as the fallback path for the + # other git dependencies in Cargo.lock (xcb-imdkit, finl_unicode) + # that this fix does not pin. The four d2b/d2b-toolkit crates + # below get an explicit `outputHashes` entry instead, so their + # vendoring goes through the hermetic `fetchgit` fixed-output + # derivation path rather than the impure, unlocked + # `builtins.fetchGit` fallback. The hash is the pinned `d2b` / + # `d2b-toolkit` flake input's own `narHash`, which is byte-for- + # byte identical to what `fetchgit` (default `leaveDotGit = + # false`) computes for the same url+rev -- verified empirically + # before wiring this in. This makes flake.lock the narHash + # authority for these revisions instead of an ad-hoc unbound + # fetch, without needing a second, divergent content fetch. + outputHashes = { + "d2b-client-2.0.0" = inputs.d2b.narHash; + "d2b-contracts-2.0.0" = inputs.d2b.narHash; + "d2b-session-2.0.0" = inputs.d2b.narHash; + "d2b-client-toolkit-2.0.0" = inputs.d2b-toolkit.narHash; + }; allowBuiltinFetchGit = true; + # --- end weezterm remote features --- }; prePatch = '' @@ -358,6 +392,72 @@ packages.default = if hasPrebuilt then prebuiltPackage else sourcePackage; packages.source = sourcePackage; + # --- weezterm remote features --- + # Hermetic lint/format coverage for `nix build .#source`. Both reuse + # `sourcePackage`'s src/patch/cargoLock (so the same vendored, + # hash-pinned d2b/d2b-toolkit sources are linted, not a second + # fetch), but replace buildPhase/installPhase so neither runs the + # full install (icons, completions, terminfo, etc.) -- they only + # need to fail the build on a format or lint violation. + checks = { + cargo-fmt = sourcePackage.overrideAttrs (old: { + name = "wezterm-cargo-fmt-check"; + nativeBuildInputs = old.nativeBuildInputs ++ [ pkgs.rust-bin.nightly."2026-06-06".rustfmt ]; + doCheck = false; + # No binaries get installed, so there is nothing for the + # inherited preFixup/postFixup patchelf steps to act on. + dontFixup = true; + buildPhase = '' + runHook preBuild + cargo fmt --all -- --check + runHook postBuild + ''; + installPhase = "touch $out"; + }); + + cargo-clippy = sourcePackage.overrideAttrs (old: { + name = "wezterm-cargo-clippy-check"; + nativeBuildInputs = old.nativeBuildInputs ++ [ + (pkgs.rust-bin.stable."1.96.0".minimal.override { extensions = [ "clippy" ]; }) + pkgs.jq + ]; + doCheck = false; + dontFixup = true; + buildPhase = '' + runHook preBuild + # The vendored upstream wezterm/config tree carries pre-existing + # clippy findings this seam does not own and must not "fix" as + # a side effect (see AGENTS.md: never reformat/rewrite upstream + # files). A blanket `--workspace -D warnings` run fails on code + # this seam never touched (even without `-D warnings`, some + # upstream files trip deny-by-default lints). Run clippy for + # just the `config` crate with `--no-deps` (so upstream + # workspace deps like wezterm-char-props aren't linted + # either), capture full diagnostics, and hard-fail only on + # findings inside config/src/d2b.rs -- the file this seam + # wholly owns and maintains. + cargo clippy -p config --all-targets --no-deps --offline --message-format=json > clippy-config.json || true + if jq -e ' + select(.reason == "compiler-message") + | .message.spans[]? + | select(.file_name == "config/src/d2b.rs") + ' clippy-config.json > /dev/null + then + echo "clippy findings in config/src/d2b.rs:" >&2 + jq -r ' + select(.reason == "compiler-message") + | select(.message.spans[]?.file_name == "config/src/d2b.rs") + | .message.rendered + ' clippy-config.json >&2 + exit 1 + fi + runHook postBuild + ''; + installPhase = "touch $out"; + }); + }; + # --- end weezterm remote features --- + # --- weezterm remote features --- apps = { default = { From 1abba49cc593f3922345d3ca29d2fa4fe7151949 Mon Sep 17 00:00:00 2001 From: John Vicondoa Date: Tue, 21 Jul 2026 11:46:07 -0700 Subject: [PATCH 7/7] d2b: preserve clippy exit status, cap lints, fail closed on parse errors ( W9fu2 ) Remove `|| true` from the cargo-clippy flake check so a genuine compilation or tooling failure fails the build. To tolerate pre-existing lint debt in the vendored upstream config crate without `-D warnings`, run clippy with `-- --cap-lints=warn`, which caps every lint (including deny-by-default ones) to at most a warning so only a real compile/tooling failure can make cargo itself exit nonzero. Extract the file-scoped diagnostic filter into nix/clippy-scope-filter.sh, which hard-fails on findings inside config/src/d2b.rs and, critically, also hard-fails if the clippy JSON can't be parsed/filtered instead of silently treating that as "no findings" (the previous inline `jq -e` pattern conflated jq's "no match" and "parse error" exit codes). Add a --self-test mode covering finding/no-finding/malformed/empty-input cases as a lightweight regression check. --- CHANGELOG.md | 19 +++++-- nix/clippy-scope-filter.sh | 111 +++++++++++++++++++++++++++++++++++++ nix/flake.nix | 42 +++++++------- 3 files changed, 144 insertions(+), 28 deletions(-) create mode 100755 nix/clippy-scope-filter.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 613f47d9291..c1a21ba67ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,12 +24,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 second fetch), giving `nix build .#source`/`nix flake check` attached lint/format coverage without changing the existing full source build. `cargo-fmt` checks `cargo fmt --all -- --check` across the whole tree. - `cargo-clippy` runs `cargo clippy -p config --all-targets --no-deps` and + `cargo-clippy` runs `cargo clippy -p config --all-targets --no-deps -- + --cap-lints=warn` (no `|| true`, so a genuine compilation or tooling + failure fails the check), which caps every lint severity to at most a + warning so only that kind of real failure — never lint severity — can + make the `cargo clippy` invocation itself fail. The resulting + `--message-format=json` diagnostics are then filtered by the new + `nix/clippy-scope-filter.sh` helper (with a `--self-test` regression + mode covering finding/no-finding/malformed/empty-input cases), which hard-fails only on findings inside `config/src/d2b.rs`, the file this - seam wholly owns; the vendored upstream `wezterm`/`config` tree carries - pre-existing clippy findings outside this seam's ownership that this - check intentionally does not enforce, consistent with never reformatting - or rewriting upstream files. + seam wholly owns, and also hard-fails if the diagnostics can't be + parsed/filtered rather than silently treating that as "no findings". + The vendored upstream `wezterm`/`config` tree carries pre-existing + clippy findings outside this seam's ownership that this check + intentionally does not enforce, consistent with never reformatting or + rewriting upstream files. ### Changed diff --git a/nix/clippy-scope-filter.sh b/nix/clippy-scope-filter.sh new file mode 100755 index 00000000000..d758e47d370 --- /dev/null +++ b/nix/clippy-scope-filter.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# --- weezterm remote features --- +# Filters `cargo clippy --message-format=json` output down to diagnostics +# whose primary span touches one specific file. Used by the `cargo-clippy` +# flake check (nix/flake.nix) to hard-fail only on findings inside the +# code this seam owns (config/src/d2b.rs), while tolerating pre-existing +# lint debt in the vendored upstream wezterm/config tree it does not. +# +# Usage: +# clippy-scope-filter.sh +# clippy-scope-filter.sh --self-test +# +# Exit status: +# 0 no compiler-message diagnostic has a span in +# 1 a diagnostic was found in , OR the input could not +# be parsed/filtered (malformed JSON, jq failure, etc.) -- a parse +# failure must never be silently treated as "no findings". +# +# With jq 1.8.1 (pinned via nixpkgs; re-verify if that pin ever changes), +# `jq -e` on this exact `select(...) | ... | select(...)` pipeline exits: +# 0 a match was found (truthy last output) +# 4 the pipeline produced zero outputs (no match, or empty input) +# 5 the input could not be parsed as JSON at all +# Any status other than 0 or 4 (5, or anything else) is treated as a +# parsing/filtering failure, not "no findings". +set -euo pipefail + +self_test() { + local tmp status + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' RETURN + + # Case 1: a compiler-message with a span in the scoped file -> must fail. + cat >"$tmp/with-finding.json" <<'JSON' +{"reason":"compiler-message","message":{"rendered":"warning: example\n","spans":[{"file_name":"config/src/d2b.rs"}]}} +JSON + status=0 + bash "$0" "$tmp/with-finding.json" "config/src/d2b.rs" || status=$? + if [ "$status" -eq 0 ]; then + echo "self-test FAILED: expected a finding in the scoped file to fail" >&2 + return 1 + fi + + # Case 2: a compiler-message with a span elsewhere -> must pass. + cat >"$tmp/without-finding.json" <<'JSON' +{"reason":"compiler-message","message":{"rendered":"warning: example\n","spans":[{"file_name":"config/src/lib.rs"}]}} +JSON + status=0 + bash "$0" "$tmp/without-finding.json" "config/src/d2b.rs" || status=$? + if [ "$status" -ne 0 ]; then + echo "self-test FAILED: expected a finding outside the scoped file to pass" >&2 + return 1 + fi + + # Case 3: malformed JSON -> must fail, not be silently treated as clean. + printf 'not json\n' >"$tmp/malformed.json" + status=0 + bash "$0" "$tmp/malformed.json" "config/src/d2b.rs" || status=$? + if [ "$status" -eq 0 ]; then + echo "self-test FAILED: expected malformed input to fail" >&2 + return 1 + fi + + # Case 4: an empty (but well-formed, e.g. no diagnostics) input -> pass. + : >"$tmp/empty.json" + status=0 + bash "$0" "$tmp/empty.json" "config/src/d2b.rs" || status=$? + if [ "$status" -ne 0 ]; then + echo "self-test FAILED: expected empty input to pass" >&2 + return 1 + fi + + echo "clippy-scope-filter.sh self-test OK" +} + +if [ "${1:-}" = "--self-test" ]; then + self_test + exit 0 +fi + +json_file="${1:?usage: clippy-scope-filter.sh }" +scoped_file="${2:?usage: clippy-scope-filter.sh }" + +set +e +jq -e --arg file "$scoped_file" ' + select(.reason == "compiler-message") + | .message.spans[]? + | select(.file_name == $file) + ' "$json_file" >/dev/null +jq_status=$? +set -e + +case "$jq_status" in +0) + echo "clippy findings in $scoped_file:" >&2 + jq -r --arg file "$scoped_file" ' + select(.reason == "compiler-message") + | select(.message.spans[]?.file_name == $file) + | .message.rendered + ' "$json_file" >&2 + exit 1 + ;; +4) + exit 0 + ;; +*) + echo "failed to parse/filter $json_file with jq (exit $jq_status)" >&2 + exit 1 + ;; +esac +# --- end weezterm remote features --- diff --git a/nix/flake.nix b/nix/flake.nix index 6215907feb7..a84c35245bb 100644 --- a/nix/flake.nix +++ b/nix/flake.nix @@ -428,29 +428,25 @@ # The vendored upstream wezterm/config tree carries pre-existing # clippy findings this seam does not own and must not "fix" as # a side effect (see AGENTS.md: never reformat/rewrite upstream - # files). A blanket `--workspace -D warnings` run fails on code - # this seam never touched (even without `-D warnings`, some - # upstream files trip deny-by-default lints). Run clippy for - # just the `config` crate with `--no-deps` (so upstream - # workspace deps like wezterm-char-props aren't linted - # either), capture full diagnostics, and hard-fail only on - # findings inside config/src/d2b.rs -- the file this seam - # wholly owns and maintains. - cargo clippy -p config --all-targets --no-deps --offline --message-format=json > clippy-config.json || true - if jq -e ' - select(.reason == "compiler-message") - | .message.spans[]? - | select(.file_name == "config/src/d2b.rs") - ' clippy-config.json > /dev/null - then - echo "clippy findings in config/src/d2b.rs:" >&2 - jq -r ' - select(.reason == "compiler-message") - | select(.message.spans[]?.file_name == "config/src/d2b.rs") - | .message.rendered - ' clippy-config.json >&2 - exit 1 - fi + # files). Some of those pre-existing findings are deny-by- + # default clippy lints, so merely dropping `-D warnings` is not + # enough to keep cargo from failing on them; `--cap-lints=warn` + # caps every lint (however it was originally set) to at most a + # warning, so only a genuine compilation/tooling failure -- not + # lint severity -- can make this `cargo clippy` invocation + # itself fail. Its exit status is therefore preserved (no + # `|| true`): a nonzero exit here is a real failure. + # + # Scope to just the `config` crate with `--no-deps` (so + # upstream workspace deps like wezterm-char-props aren't + # linted either), capture full diagnostics as JSON, and hard- + # fail only on findings inside config/src/d2b.rs -- the file + # this seam wholly owns and maintains. clippy-scope-filter.sh + # also fails if the JSON itself can't be parsed/filtered, + # rather than silently treating that as "no findings". + cargo clippy -p config --all-targets --no-deps --offline \ + --message-format=json -- --cap-lints=warn > clippy-config.json + bash nix/clippy-scope-filter.sh clippy-config.json config/src/d2b.rs runHook postBuild ''; installPhase = "touch $out";