diff --git a/AGENTS.md b/AGENTS.md index 321d9674e27..db4a80e1ef4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,13 +150,6 @@ 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: @@ -169,12 +162,6 @@ 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 c1a21ba67ea..39061950f2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,68 +7,6 @@ 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 48ef32a748c..21f01af449a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,41 +23,6 @@ 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" @@ -379,28 +344,6 @@ 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" @@ -605,15 +548,6 @@ 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" @@ -793,30 +727,6 @@ 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" @@ -857,17 +767,6 @@ 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" @@ -1082,7 +981,7 @@ dependencies = [ "anyhow", "bitflags 1.3.2", "colorgrad", - "d2b-client-toolkit", + "d2b-toolkit-core", "dirs-next", "enum-display-derive", "env_logger 0.11.8", @@ -1092,7 +991,7 @@ dependencies = [ "log", "luahelper", "mlua", - "nix 0.29.0", + "nix", "notify", "ordered-float 4.6.0", "portable-pty", @@ -1335,99 +1234,34 @@ 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 = "2.0.0" -source = "git+https://github.com/vicondoa/d2b?rev=9dc902243cdd7aba7ef269988b96f0aae6e037da#9dc902243cdd7aba7ef269988b96f0aae6e037da" -dependencies = [ - "async-trait", - "d2b-contracts", - "d2b-session", - "protobuf", - "sha2", - "tokio", - "ttrpc", -] - -[[package]] -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" +version = "0.2.0" +source = "git+https://github.com/vicondoa/d2b-toolkit?tag=v0.2.0#fde6af8b842718e7150f5056d4eba73093d4ad77" dependencies = [ - "async-trait", - "protobuf", - "schemars", + "base64 0.22.1", + "d2b-toolkit-core", + "futures", "serde", - "sha2", - "ttrpc", - "zeroize", + "serde_json", + "thiserror 1.0.69", ] [[package]] -name = "d2b-session" -version = "2.0.0" -source = "git+https://github.com/vicondoa/d2b?rev=9dc902243cdd7aba7ef269988b96f0aae6e037da#9dc902243cdd7aba7ef269988b96f0aae6e037da" +name = "d2b-toolkit-core" +version = "0.2.0" +source = "git+https://github.com/vicondoa/d2b-toolkit?tag=v0.2.0#fde6af8b842718e7150f5056d4eba73093d4ad77" dependencies = [ - "async-trait", - "d2b-contracts", - "futures-util", - "protobuf", + "serde", + "serde_json", "sha2", - "snow", - "tokio", - "ttrpc", + "thiserror 1.0.69", ] [[package]] @@ -1563,7 +1397,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", - "subtle", ] [[package]] @@ -1664,12 +1497,6 @@ 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" @@ -1970,12 +1797,6 @@ 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" @@ -2344,16 +2165,6 @@ 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" @@ -2405,7 +2216,7 @@ dependencies = [ "backtrace", "fnv", "gl_generator", - "memoffset 0.9.1", + "memoffset", "smallvec", ] @@ -2676,15 +2487,6 @@ 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" @@ -2833,7 +2635,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.1", "system-configuration", "tokio", "tower-service", @@ -3078,15 +2880,6 @@ 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" @@ -3117,7 +2910,7 @@ version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86" dependencies = [ - "memoffset 0.9.1", + "memoffset", ] [[package]] @@ -3605,7 +3398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3a8e7962a5368d5f264d045a5a255e90f9aa3fc1941ae15a8d2940d42cac671" dependencies = [ "cc", - "which 7.0.3", + "which", ] [[package]] @@ -3672,15 +3465,6 @@ 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" @@ -3796,6 +3580,8 @@ name = "mux" version = "0.1.0" dependencies = [ "anyhow", + "async-channel", + "async-io", "async-trait", "base64 0.22.1", "bintree", @@ -3803,10 +3589,13 @@ dependencies = [ "chrono", "config", "crossbeam", + "d2b-client", + "d2b-toolkit-core", "downcast-rs", "fancy-regex", "filedescriptor", "finl_unicode", + "futures", "hostname", "k9", "lazy_static", @@ -3816,7 +3605,7 @@ dependencies = [ "metrics", "mlua", "names", - "nix 0.29.0", + "nix", "ntapi", "parking_lot", "percent-encoding", @@ -3831,6 +3620,7 @@ dependencies = [ "sha2", "shell-words", "smol", + "socket2 0.5.10", "terminfo", "termwiz", "termwiz-funcs", @@ -3939,19 +3729,6 @@ 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" @@ -3962,20 +3739,7 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", - "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", + "memoffset", ] [[package]] @@ -4308,12 +4072,6 @@ 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" @@ -4681,29 +4439,6 @@ 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" @@ -4731,7 +4466,7 @@ dependencies = [ "lazy_static", "libc", "log", - "nix 0.29.0", + "nix", "serde", "serial2", "shared_library", @@ -4875,57 +4610,6 @@ 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" @@ -5543,30 +5227,6 @@ 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" @@ -5661,17 +5321,6 @@ 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" @@ -5932,23 +5581,6 @@ 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" @@ -6062,7 +5694,7 @@ dependencies = [ "lazycell", "libc", "mach2", - "nix 0.29.0", + "nix", "num-traits", "plist", "uom", @@ -6312,7 +5944,7 @@ dependencies = [ "libc", "log", "memmem", - "nix 0.29.0", + "nix", "num-derive", "num-traits", "ordered-float 4.6.0", @@ -6593,19 +6225,6 @@ 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" @@ -6768,28 +6387,6 @@ 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" @@ -6814,7 +6411,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ - "memoffset 0.9.1", + "memoffset", "tempfile", "winapi", ] @@ -6877,16 +6474,6 @@ 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" @@ -6992,16 +6579,6 @@ 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" @@ -7451,7 +7028,7 @@ dependencies = [ "image", "k9", "log", - "nix 0.29.0", + "nix", "num-derive", "num-traits", "ordered-float 4.6.0", @@ -7970,18 +7547,6 @@ 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" @@ -8789,7 +8354,7 @@ dependencies = [ "futures-sink", "futures-util", "hex", - "nix 0.29.0", + "nix", "ordered-stream", "rand 0.8.6", "serde", diff --git a/Cargo.toml b/Cargo.toml index 8c8a676a399..2690a3837bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -293,7 +293,8 @@ zbus = "4.2" zstd = "0.11" zvariant = "4.0" # --- weezterm remote features --- -d2b-client-toolkit = { git = "https://github.com/vicondoa/d2b-toolkit", rev = "926de54e7320599c373524a10b65aaf13b6ff422", default-features = false } +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" } # --- end weezterm remote features --- [patch.crates-io] diff --git a/Makefile b/Makefile index 1f2af4d1f58..700bd4284c4 100644 --- a/Makefile +++ b/Makefile @@ -26,19 +26,7 @@ build: cargo build $(BUILD_OPTS) -p strip-ansi-escapes 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 --- + cargo +nightly fmt docs: ci/build-docs.sh diff --git a/README.md b/README.md index 86c1afb661a..24722e7d53f 100644 --- a/README.md +++ b/README.md @@ -17,14 +17,13 @@ WeezTerm extends WezTerm with VS Code Remote SSH-style features: - **Config overlay** — A built-in TUI (`Ctrl+Shift+,`) for browsing and editing ~80 config settings, managing SSH domains, DevContainer domains, and per-monitor overrides - **Window state persistence** — Window position, size, and maximized/fullscreen state saved and restored across restarts, with correct multi-monitor support - **DevContainer domains** — First-class Docker devcontainer support with auto-discovery, a manager overlay (`Ctrl+Shift+D`), and full SSH domain integration -- **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. +- **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. See [docs/remote-extensions.md](docs/remote-extensions.md) for detailed documentation of all features and configuration options. -See [docs/d2b-provider.md](docs/d2b-provider.md) for the toolkit source pin, -packaged share layout, configuration contract, and current runtime boundary. +See [docs/d2b-provider.md](docs/d2b-provider.md) for d2b provider packaging, +runtime, and security details. ## Credits diff --git a/config/Cargo.toml b/config/Cargo.toml index 2d1623e488f..5abf8e20fd9 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-client-toolkit.workspace = true +d2b-toolkit-core.workspace = true # --- end weezterm remote features --- [target."cfg(unix)".dependencies] diff --git a/config/src/d2b.rs b/config/src/d2b.rs index 4233339bf29..bf237531838 100644 --- a/config/src/d2b.rs +++ b/config/src/d2b.rs @@ -1,237 +1,183 @@ use crate::config::validate_domain_name; -use d2b_client_toolkit::contracts::v2_identity::{RealmId, WorkloadId}; -use d2b_client_toolkit::{ServiceKind, ServiceOwner, TargetInput}; +use d2b_toolkit_core::WorkloadTarget; 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 reserved for this d2b workload. + /// Unique WeezTerm domain name for this d2b target. #[dynamic(validate = "validate_domain_name")] pub name: 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, - } - } -} + /// Canonical d2b workload target, or a legacy VM name during migration. + pub target: String, -#[derive(Debug, Clone, PartialEq, Eq, FromDynamic, ToDynamic)] -struct WorkloadTargetConfig { - realm_id: String, - workload_id: String, + /// Optional override for the d2b public daemon socket. + #[dynamic(default)] + pub socket_path: Option, } #[derive(Debug, Clone, PartialEq, Eq, FromDynamic)] struct RawD2bDomainConfig { #[dynamic(validate = "validate_domain_name")] name: String, - target: WorkloadTargetConfig, + target: Option, + vm: Option, + socket_path: Option, } #[derive(ToDynamic)] struct SerializedD2bDomainConfig { name: String, - target: WorkloadTargetConfig, + target: String, + socket_path: Option, } impl TryFrom for D2bDomainConfig { type Error = String; fn try_from(raw: RawD2bDomainConfig) -> Result { - // 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 - ) - })?; + 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()) + } + }; + Ok(Self { - name, - target: TargetInput::Workload { realm, workload }, + name: raw.name, + target, + socket_path: raw.socket_path, }) } } impl From<&D2bDomainConfig> for SerializedD2bDomainConfig { fn from(config: &D2bDomainConfig) -> Self { - let (realm, workload) = config.workload_ids(); Self { name: config.name.clone(), - target: WorkloadTargetConfig { - realm_id: realm.as_str().to_string(), - workload_id: workload.as_str().to_string(), - }, + target: config.target.clone(), + socket_path: config.socket_path.clone(), } } } +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_workload_target_deserializes() { + fn canonical_target_deserializes_and_normalizes_scheme() { let config = decode(serde_json::json!({ - "name": "work", - "target": { - "realm_id": REALM_ID, - "workload_id": WORKLOAD_ID - } + "name": "host-tools", + "target": "d2b://tools.host.d2b" })) .unwrap(); - 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)); - } + assert_eq!(config.target, "tools.host.d2b"); + assert_eq!(config.socket_path, None); } #[test] - fn canonical_workload_target_round_trips() { + fn vm_alias_migrates_to_target_and_serializes_canonically() { let config = decode(serde_json::json!({ "name": "work", - "target": { - "realm_id": REALM_ID, - "workload_id": WORKLOAD_ID - } + "vm": "corp-vm" })) .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!( - target.get_by_str("workload_id"), - Some(&Value::String(WORKLOAD_ID.to_string())) + encoded.get_by_str("target"), + Some(&Value::String("corp-vm".to_string())) ); + assert!(encoded.get_by_str("vm").is_none()); } #[test] - fn legacy_string_target_is_rejected() { - let err = decode(serde_json::json!({ + fn matching_target_and_vm_alias_are_accepted() { + let config = decode(serde_json::json!({ "name": "work", - "target": "tools.host.d2b" + "target": "corp.work.d2b", + "vm": "d2b://corp.work.d2b" })) - .unwrap_err() - .to_string(); - assert!(err.contains("target")); + .unwrap(); + assert_eq!(config.target, "corp.work.d2b"); } #[test] - fn invalid_canonical_ids_are_rejected_without_echoing_values() { + fn conflicting_target_and_vm_alias_fail_closed() { let err = decode(serde_json::json!({ "name": "work", - "target": { - "realm_id": "Not-Canonical", - "workload_id": WORKLOAD_ID - } + "target": "corp.work.d2b", + "vm": "personal" })) .unwrap_err() .to_string(); - assert!(err.contains("canonical realm id")); - assert!(err.contains("\"work\"")); - assert!(!err.contains("Not-Canonical")); - assert!(!err.contains(WORKLOAD_ID)); + assert!(err.contains("target")); + assert!(err.contains("compatibility alias")); + assert!(err.contains("differ")); + assert!(!err.contains("corp.work.d2b")); } #[test] - fn invalid_workload_id_names_the_domain_without_echoing_the_value() { - let err = decode(serde_json::json!({ - "name": "personal", - "target": { - "realm_id": REALM_ID, - "workload_id": "not a canonical workload id" - } - })) - .unwrap_err() - .to_string(); - assert!(err.contains("canonical workload id")); - assert!(err.contains("\"personal\"")); - assert!(!err.contains("not a canonical workload id")); + fn missing_target_and_alias_is_actionable() { + let err = decode(serde_json::json!({"name": "work"})) + .unwrap_err() + .to_string(); + assert!(err.contains("requires `target`")); } } +// --- end weezterm remote features --- diff --git a/config/src/keyassignment.rs b/config/src/keyassignment.rs index 39b1dd39394..7436bf53da7 100644 --- a/config/src/keyassignment.rs +++ b/config/src/keyassignment.rs @@ -651,6 +651,12 @@ 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 06927dc1791..056304d8f1b 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:** Superseded by d2b ADR 0045 +- **Status:** Accepted - **Date:** 2026-07-11 - **Deciders:** WeezTerm maintainers (@vicondoa) - **Relates to:** d2b @@ -10,12 +10,6 @@ ## 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 d0a62387a45..e789bbeadae 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) | 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. | +| [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. | diff --git a/docs/d2b-provider.md b/docs/d2b-provider.md index 30afceec419..4ca72a468e5 100644 --- a/docs/d2b-provider.md +++ b/docs/d2b-provider.md @@ -1,85 +1,140 @@ -# d2b client seam +# Native d2b provider -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: +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. -```text -926de54e7320599c373524a10b65aaf13b6ff422 -``` +## Input alignment + +Pin WeezTerm and d2b to one `nixpkgs` revision: + +```nix +{ + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; -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/ + d2b = { + url = "github:vicondoa/d2b"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + weezterm = { + url = "github:vicondoa/weezterm/v0.7.2"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; +} ``` -There is no `d2b-toolkit` package, crate, or share path in the current seam. +WeezTerm pins toolkit release `v0.2.0` at the `v0.2.0` release tag in Cargo. +`Cargo.lock` resolves that tag to +`fde6af8b842718e7150f5056d4eba73093d4ad77`. -## Configure a target +## Configuration -Each domain uses the canonical -`d2b_client_toolkit::TargetInput::Workload` type. -Configuration supplies the exact canonical realm and workload IDs: +Use `target` for new d2b domains. A canonical target has the form +`..d2b`; a legacy VM name remains valid during migration. ```lua -return { - d2b_domains = { - { - name = "work", - target = { - realm_id = "aaaaaaaaaaaaaaaaaaaa", - workload_id = "bbbbbbbbbbbbbbbbbbbq", - }, +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 config ``` -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. +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. diff --git a/flake.lock b/flake.lock index 69e0fc7b177..81a15270125 100644 --- a/flake.lock +++ b/flake.lock @@ -1,39 +1,5 @@ { "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" @@ -121,8 +87,6 @@ }, "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 2b13dc81597..feccfad2474 100644 --- a/flake.nix +++ b/flake.nix @@ -26,25 +26,6 @@ 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 bfd54db6698..b0c3d1739aa 100644 --- a/mux/Cargo.toml +++ b/mux/Cargo.toml @@ -48,6 +48,13 @@ 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"] } @@ -62,5 +69,8 @@ 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 new file mode 100644 index 00000000000..3f50f78a47d --- /dev/null +++ b/mux/src/d2b.rs @@ -0,0 +1,2999 @@ +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 61ecb58c69e..f7e085fbccb 100644 --- a/mux/src/lib.rs +++ b/mux/src/lib.rs @@ -36,6 +36,7 @@ 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 9284d4cc12e..a6448885a08 100644 --- a/mux/src/pane.rs +++ b/mux/src/pane.rs @@ -254,6 +254,20 @@ 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 deleted file mode 100755 index d758e47d370..00000000000 --- a/nix/clippy-scope-filter.sh +++ /dev/null @@ -1,111 +0,0 @@ -#!/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 98adff087d3..58c4c0a8e36 100644 --- a/nix/flake.lock +++ b/nix/flake.lock @@ -1,39 +1,5 @@ { "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" @@ -121,8 +87,6 @@ }, "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 a84c35245bb..f0517ac4dd2 100644 --- a/nix/flake.nix +++ b/nix/flake.nix @@ -40,19 +40,6 @@ 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 = @@ -217,28 +204,7 @@ 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 = '' @@ -392,68 +358,6 @@ 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 = { @@ -484,13 +388,7 @@ }) 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 1f1d20623e1..7d4bccf6c82 100644 --- a/wezterm-gui/src/commands.rs +++ b/wezterm-gui/src/commands.rs @@ -1636,6 +1636,24 @@ 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(), @@ -2179,6 +2197,7 @@ 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 d6734081db3..16dac0990b9 100644 --- a/wezterm-gui/src/main.rs +++ b/wezterm-gui/src/main.rs @@ -374,6 +374,46 @@ 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, @@ -423,13 +463,36 @@ 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 = - config::RUNTIME_DIR.join(format!("gui-sock-{}", unsafe { libc::getpid() })); + let unix_socket_path = gui_mux_socket_path_for_domain(opts.domain.as_deref())?; 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)?, @@ -764,6 +827,16 @@ 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( @@ -806,10 +879,11 @@ 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(), + opts.always_new_process || opts.position.is_some() || is_d2b_target_domain, ); log::trace!("{:?}", publish); if publish.try_spawn( @@ -1270,6 +1344,10 @@ 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 new file mode 100644 index 00000000000..749837bcdea --- /dev/null +++ b/wezterm-gui/src/overlay/d2b_launcher.rs @@ -0,0 +1,846 @@ +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 2a20eee64b0..de5b3c45c34 100644 --- a/wezterm-gui/src/overlay/mod.rs +++ b/wezterm-gui/src/overlay/mod.rs @@ -17,6 +17,8 @@ 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 b0cbf52caee..37832002525 100644 --- a/wezterm-gui/src/termwindow/mod.rs +++ b/wezterm-gui/src/termwindow/mod.rs @@ -283,6 +283,11 @@ 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, } @@ -352,19 +357,190 @@ impl UserData for PaneInformation { } // --- weezterm remote features --- -pub(crate) fn effective_tab_title(_tab: &TabInformation, default_title: String) -> String { - default_title +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_for_width( - _tab: &TabInformation, + tab: &TabInformation, default_title: String, - _max_width: usize, + max_width: usize, ) -> String { - default_title + 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, + } } // --- 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 @@ -2787,7 +2963,10 @@ impl TermWindow { Some(title) => title, None => { if let (Some(pos), Some(tab)) = (active_pane, active_tab) { - if num_tabs == 1 { + // --- weezterm remote features --- + if let Some(title) = d2b_window_title(&tab) { + title + } else if num_tabs == 1 { format!("{}{}", if pos.is_zoomed { "[Z] " } else { "" }, pos.title) } else { format!( @@ -2798,6 +2977,7 @@ impl TermWindow { pos.title ) } + // --- end weezterm remote features --- } else { "".to_string() } @@ -3410,6 +3590,17 @@ 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), @@ -3420,6 +3611,21 @@ 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) { @@ -3521,6 +3727,76 @@ 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 @@ -3844,6 +4120,10 @@ 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(); @@ -4515,6 +4795,11 @@ 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 b33b316f2f6..7451fb572e7 100644 --- a/wezterm-mux-server-impl/src/lib.rs +++ b/wezterm-mux-server-impl/src/lib.rs @@ -90,13 +90,39 @@ 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 {}: workload-to-shell routing is not available yet", + "Ignoring d2b domain {}: native d2b domains are only supported on Linux", 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() {