diff --git a/AGENTS.md b/AGENTS.md index db4a80e1ef4..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: @@ -162,6 +169,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 39061950f2f..c1a21ba67ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,68 @@ 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 -- + --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, 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 + +- 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}`. +- 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`. +- 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 + +- 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 canonical + workload-to-shell routing is integrated. + ## [0.7.2] - 2026-07-13 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 21f01af449a..48ef32a748c 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,7 @@ dependencies = [ "anyhow", "bitflags 1.3.2", "colorgrad", - "d2b-toolkit-core", + "d2b-client-toolkit", "dirs-next", "enum-display-derive", "env_logger 0.11.8", @@ -991,7 +1092,7 @@ dependencies = [ "log", "luahelper", "mlua", - "nix", + "nix 0.29.0", "notify", "ordered-float 4.6.0", "portable-pty", @@ -1234,34 +1335,99 @@ 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=9dc902243cdd7aba7ef269988b96f0aae6e037da#9dc902243cdd7aba7ef269988b96f0aae6e037da" dependencies = [ - "base64 0.22.1", - "d2b-toolkit-core", - "futures", - "serde", - "serde_json", - "thiserror 1.0.69", + "async-trait", + "d2b-contracts", + "d2b-session", + "protobuf", + "sha2", + "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-client-toolkit" +version = "2.0.0" +source = "git+https://github.com/vicondoa/d2b-toolkit?rev=926de54e7320599c373524a10b65aaf13b6ff422#926de54e7320599c373524a10b65aaf13b6ff422" +dependencies = [ + "d2b-client", + "d2b-contracts", + "d2b-session", + "thiserror 2.0.17", + "tokio", +] + +[[package]] +name = "d2b-contracts" +version = "2.0.0" +source = "git+https://github.com/vicondoa/d2b?rev=9dc902243cdd7aba7ef269988b96f0aae6e037da#9dc902243cdd7aba7ef269988b96f0aae6e037da" dependencies = [ + "async-trait", + "protobuf", + "schemars", "serde", - "serde_json", "sha2", - "thiserror 1.0.69", + "ttrpc", + "zeroize", +] + +[[package]] +name = "d2b-session" +version = "2.0.0" +source = "git+https://github.com/vicondoa/d2b?rev=9dc902243cdd7aba7ef269988b96f0aae6e037da#9dc902243cdd7aba7ef269988b96f0aae6e037da" +dependencies = [ + "async-trait", + "d2b-contracts", + "futures-util", + "protobuf", + "sha2", + "snow", + "tokio", + "ttrpc", ] [[package]] @@ -1397,6 +1563,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1497,6 +1664,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 +1970,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 +2344,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 +2405,7 @@ dependencies = [ "backtrace", "fnv", "gl_generator", - "memoffset", + "memoffset 0.9.1", "smallvec", ] @@ -2487,6 +2676,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" @@ -2635,7 +2833,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -2880,6 +3078,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 +3117,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 +3605,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3a8e7962a5368d5f264d045a5a255e90f9aa3fc1941ae15a8d2940d42cac671" dependencies = [ "cc", - "which", + "which 7.0.3", ] [[package]] @@ -3465,6 +3672,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 +3796,6 @@ name = "mux" version = "0.1.0" dependencies = [ "anyhow", - "async-channel", - "async-io", "async-trait", "base64 0.22.1", "bintree", @@ -3589,13 +3803,10 @@ dependencies = [ "chrono", "config", "crossbeam", - "d2b-client", - "d2b-toolkit-core", "downcast-rs", "fancy-regex", "filedescriptor", "finl_unicode", - "futures", "hostname", "k9", "lazy_static", @@ -3605,7 +3816,7 @@ dependencies = [ "metrics", "mlua", "names", - "nix", + "nix 0.29.0", "ntapi", "parking_lot", "percent-encoding", @@ -3620,7 +3831,6 @@ dependencies = [ "sha2", "shell-words", "smol", - "socket2 0.5.10", "terminfo", "termwiz", "termwiz-funcs", @@ -3729,6 +3939,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 +3962,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 +4308,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 +4681,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 +4731,7 @@ dependencies = [ "lazy_static", "libc", "log", - "nix", + "nix 0.29.0", "serde", "serial2", "shared_library", @@ -4610,6 +4875,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 +5543,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 +5661,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 +5932,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 +6062,7 @@ dependencies = [ "lazycell", "libc", "mach2", - "nix", + "nix 0.29.0", "num-traits", "plist", "uom", @@ -5944,7 +6312,7 @@ dependencies = [ "libc", "log", "memmem", - "nix", + "nix 0.29.0", "num-derive", "num-traits", "ordered-float 4.6.0", @@ -6225,6 +6593,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 +6768,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 +6814,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 +6877,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 +6992,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 +7451,7 @@ dependencies = [ "image", "k9", "log", - "nix", + "nix 0.29.0", "num-derive", "num-traits", "ordered-float 4.6.0", @@ -7547,6 +7970,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 +8789,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..8c8a676a399 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 = { 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-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/README.md b/README.md index 24722e7d53f..86c1afb661a 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,14 @@ 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 + 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 d2b provider packaging, -runtime, and security details. +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 5abf8e20fd9..2d1623e488f 100644 --- a/config/Cargo.toml +++ b/config/Cargo.toml @@ -43,7 +43,7 @@ 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-toolkit.workspace = true # --- end weezterm remote features --- [target."cfg(unix)".dependencies] diff --git a/config/src/d2b.rs b/config/src/d2b.rs index bf237531838..4233339bf29 100644 --- a/config/src/d2b.rs +++ b/config/src/d2b.rs @@ -1,183 +1,237 @@ use crate::config::validate_domain_name; -use d2b_toolkit_core::WorkloadTarget; +use d2b_client_toolkit::contracts::v2_identity::{RealmId, WorkloadId}; +use d2b_client_toolkit::{ServiceKind, ServiceOwner, TargetInput}; 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 + } + + pub fn workload_ids(&self) -> (&RealmId, &WorkloadId) { + match &self.target { + TargetInput::Workload { realm, workload } => (realm, workload), + _ => 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, + } + } +} - /// Optional override for the d2b public daemon socket. - #[dynamic(default)] - pub socket_path: Option, +#[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()) - } - }; - + // 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, - target, - socket_path: raw.socket_path, + name, + 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 { .. })); + assert!(matches!( + config.shell_service_target(), + TargetInput::Service { + owner: ServiceOwner::Workload { .. }, + service: ServiceKind::Shell, + } + )); + } + + #[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 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!( + target.get_by_str("realm_id"), + Some(&Value::String(REALM_ID.to_string())) + ); assert_eq!( - encoded.get_by_str("target"), - Some(&Value::String("corp-vm".to_string())) + 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": "Not-Canonical", + "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")); + assert!(err.contains("canonical realm id")); + assert!(err.contains("\"work\"")); + assert!(!err.contains("Not-Canonical")); + assert!(!err.contains(WORKLOAD_ID)); } #[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`")); + 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")); } } -// --- 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..06927dc1791 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 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, 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..30afceec419 100644 --- a/docs/d2b-provider.md +++ b/docs/d2b-provider.md @@ -1,140 +1,85 @@ -# 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 the `d2b-client-toolkit` 2.0.0 facade from the exact distribution +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"; - }; +```text +926de54e7320599c373524a10b65aaf13b6ff422 +``` - weezterm = { - url = "github:vicondoa/weezterm/v0.7.2"; - inputs.nixpkgs.follows = "nixpkgs"; - }; - }; -} +That distribution re-exports canonical d2b source revision +`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. + +`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: + +```text +share/d2b-client-toolkit/ +├── distribution/ +└── d2b/ ``` -WeezTerm pins toolkit release `v0.2.0` at the `v0.2.0` release tag in Cargo. -`Cargo.lock` resolves that tag to -`fde6af8b842718e7150f5056d4eba73093d4ad77`. +There is no `d2b-toolkit` package, crate, or share path in the current seam. -## 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_toolkit::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 - -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. - -## Relationship to d2b-wlterm - -`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. +Both IDs use d2b's 20-character lowercase unpadded base32 short-ID grammar. +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 + +The configuration seam derives a canonical +`TargetInput::Service { service: ServiceKind::Shell, ... }` from each workload +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 +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/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/mux/Cargo.toml b/mux/Cargo.toml index b0c3d1739aa..bfd54db6698 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"] } @@ -69,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/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/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.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 f0517ac4dd2..a84c35245bb 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,68 @@ 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). 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"; + }); + }; + # --- end weezterm remote features --- + # --- weezterm remote features --- apps = { default = { @@ -388,7 +484,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; }; 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..b33b316f2f6 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 {}: workload-to-shell routing 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() {