From 24a42f2734010b6c4559c6a6d616bf6bc45a7a7c Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:59:47 +0000 Subject: [PATCH] Revert remote access for scoped inference release --- apps/maple-agent/Cargo.lock | 1263 +---------------- apps/maple-agent/README.md | 7 - apps/maple-agent/app/Cargo.toml | 11 - apps/maple-agent/app/src/backend.rs | 85 +- apps/maple-agent/app/src/backend/local.rs | 150 +- apps/maple-agent/app/src/desktop.rs | 850 +---------- apps/maple-agent/app/src/host.rs | 301 +--- apps/maple-agent/app/src/host/ipc.rs | 3 - apps/maple-agent/app/src/host/protocol.rs | 5 - apps/maple-agent/app/src/host/receipts.rs | 135 +- .../app/src/host/recovery_tests.rs | 78 - apps/maple-agent/app/src/main.rs | 65 +- apps/maple-agent/app/src/remote/control.rs | 494 ------- apps/maple-agent/app/src/remote/manager.rs | 1055 -------------- apps/maple-agent/app/src/remote/mod.rs | 11 - apps/maple-agent/app/src/remote/network.rs | 149 -- apps/maple-agent/app/src/remote/pairing.rs | 282 ---- apps/maple-agent/app/src/remote/store.rs | 495 ------- apps/maple-agent/app/src/remote/streams.rs | 213 --- .../app/src/remote/streams/tests.rs | 312 ---- apps/maple-agent/app/src/remote/wire.rs | 493 ------- apps/maple-agent/app/src/remote/wire_v4.json | 187 --- apps/maple-agent/app/src/settings.rs | 15 - apps/maple-agent/app/src/tray.rs | 77 - apps/maple-agent/app/src/ui/chat/composer.rs | 9 +- apps/maple-agent/app/src/ui/chat/computers.rs | 296 ---- apps/maple-agent/app/src/ui/chat/mod.rs | 309 +--- .../maple-agent/app/src/ui/chat/navigation.rs | 2 - apps/maple-agent/app/src/ui/chat/sidebar.rs | 220 +-- .../app/src/ui/chat/sidebar_computers.rs | 384 ----- apps/maple-agent/app/src/ui/chat/tests.rs | 368 +---- apps/maple-agent/app/src/ui/mod.rs | 2 - apps/maple-agent/app/src/ui/remote.rs | 702 --------- apps/maple-agent/app/src/ui/remote/tests.rs | 216 --- apps/maple-agent/app/src/ui/settings.rs | 78 +- .../app/src/ui/settings/navigation.rs | 7 +- apps/maple-agent/app/src/ui/text_input.rs | 59 +- .../maple-agent/app/src/ui/text_input/code.rs | 224 --- apps/maple-agent/app/src/ui/titlebar.rs | 4 +- .../crates/maple-agent/src/agent/types.rs | 2 - .../crates/maple-agent/src/maple_api.rs | 58 - apps/maple-agent/docs/local-host.md | 16 +- apps/maple-agent/docs/remote-access.md | 147 -- apps/maple-agent/docs/remote-security.md | 88 -- apps/maple-agent/docs/remote-validation.md | 245 ---- apps/maple-agent/flake.nix | 1 - apps/maple-agent/scripts/macos-debug-plist.py | 1 - sdk/rust/src/client.rs | 29 - sdk/rust/src/lib.rs | 1 - sdk/rust/src/remote.rs | 119 -- sdk/rust/src/remote_registration_v1.json | 1 - services/opensecret/Cargo.lock | 33 +- services/opensecret/Cargo.toml | 3 +- services/opensecret/docs/remote-enrollment.md | 97 -- .../down.sql | 1 - .../up.sql | 14 - .../opensecret/src/aead_db_tamper_tests.rs | 288 ---- services/opensecret/src/db.rs | 65 - services/opensecret/src/main.rs | 14 - services/opensecret/src/models/mod.rs | 1 - .../src/models/remote_enrollment.rs | 115 -- services/opensecret/src/models/schema.rs | 14 - services/opensecret/src/remote_enrollment.rs | 92 -- .../opensecret/src/security_invariants.rs | 6 +- services/opensecret/src/web/mod.rs | 1 - services/opensecret/src/web/remote/mod.rs | 440 ------ .../src/web/remote/registration_v1.json | 1 - services/opensecret/src/web/remote/relay.rs | 186 --- services/opensecret/src/web/remote/types.rs | 120 -- 69 files changed, 244 insertions(+), 11571 deletions(-) delete mode 100644 apps/maple-agent/app/src/remote/control.rs delete mode 100644 apps/maple-agent/app/src/remote/manager.rs delete mode 100644 apps/maple-agent/app/src/remote/mod.rs delete mode 100644 apps/maple-agent/app/src/remote/network.rs delete mode 100644 apps/maple-agent/app/src/remote/pairing.rs delete mode 100644 apps/maple-agent/app/src/remote/store.rs delete mode 100644 apps/maple-agent/app/src/remote/streams.rs delete mode 100644 apps/maple-agent/app/src/remote/streams/tests.rs delete mode 100644 apps/maple-agent/app/src/remote/wire.rs delete mode 100644 apps/maple-agent/app/src/remote/wire_v4.json delete mode 100644 apps/maple-agent/app/src/tray.rs delete mode 100644 apps/maple-agent/app/src/ui/chat/computers.rs delete mode 100644 apps/maple-agent/app/src/ui/chat/sidebar_computers.rs delete mode 100644 apps/maple-agent/app/src/ui/remote.rs delete mode 100644 apps/maple-agent/app/src/ui/remote/tests.rs delete mode 100644 apps/maple-agent/app/src/ui/text_input/code.rs delete mode 100644 apps/maple-agent/docs/remote-access.md delete mode 100644 apps/maple-agent/docs/remote-security.md delete mode 100644 apps/maple-agent/docs/remote-validation.md delete mode 100644 sdk/rust/src/remote.rs delete mode 100644 sdk/rust/src/remote_registration_v1.json delete mode 100644 services/opensecret/docs/remote-enrollment.md delete mode 100644 services/opensecret/migrations/2026-10-19-004032-0000_remote_enrollment/down.sql delete mode 100644 services/opensecret/migrations/2026-10-19-004032-0000_remote_enrollment/up.sql delete mode 100644 services/opensecret/src/models/remote_enrollment.rs delete mode 100644 services/opensecret/src/remote_enrollment.rs delete mode 100644 services/opensecret/src/web/remote/mod.rs delete mode 100644 services/opensecret/src/web/remote/registration_v1.json delete mode 100644 services/opensecret/src/web/remote/relay.rs delete mode 100644 services/opensecret/src/web/remote/types.rs diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index 29b8fd776..d139f141f 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -339,7 +339,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -350,7 +350,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -404,15 +404,6 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -[[package]] -name = "arc-swap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" -dependencies = [ - "rustversion", -] - [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -759,17 +750,6 @@ dependencies = [ "syn 3.0.4", ] -[[package]] -name = "async_io_stream" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" -dependencies = [ - "futures", - "pharos", - "rustc_version", -] - [[package]] name = "atomic" version = "0.5.3" @@ -898,7 +878,7 @@ dependencies = [ "log", "num-rational", "num-traits", - "pastey 0.1.1", + "pastey", "rayon", "thiserror 2.0.20", "v_frame", @@ -1015,17 +995,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "backon" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" -dependencies = [ - "fastrand", - "gloo-timers", - "tokio", -] - [[package]] name = "backtrace" version = "0.3.76" @@ -1047,12 +1016,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" -[[package]] -name = "base16ct" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" - [[package]] name = "base64" version = "0.22.1" @@ -1169,19 +1132,6 @@ dependencies = [ "wyz", ] -[[package]] -name = "blake3" -version = "1.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" -dependencies = [ - "arrayvec", - "cc", - "cfg-if", - "constant_time_eq", - "cpufeatures 0.3.0", -] - [[package]] name = "block" version = "0.1.6" @@ -1893,12 +1843,6 @@ dependencies = [ "tiny-keccak", ] -[[package]] -name = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - [[package]] name = "convert_case" version = "0.10.0" @@ -1917,16 +1861,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "cordyceps" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b9ab7e0ca1d179628fa0172b2b97203c7fa0cd81be2448bd446fb9559ca9261" -dependencies = [ - "loom", - "tracing", -] - [[package]] name = "core-foundation" version = "0.9.4" @@ -2388,26 +2322,8 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "fiat-crypto 0.2.9", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "curve25519-dalek-derive", - "digest 0.11.3", - "fiat-crypto 0.3.0", - "rand_core 0.10.1", + "fiat-crypto", "rustc_version", - "serde", "subtle", "zeroize", ] @@ -2469,26 +2385,6 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" -[[package]] -name = "data-encoding-macro" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" -dependencies = [ - "data-encoding", - "data-encoding-macro-internal", -] - -[[package]] -name = "data-encoding-macro-internal" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" -dependencies = [ - "data-encoding", - "syn 3.0.4", -] - [[package]] name = "data-url" version = "0.3.2" @@ -2533,18 +2429,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", - "pem-rfc7468 0.7.0", - "zeroize", -] - -[[package]] -name = "der" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" -dependencies = [ - "const-oid 0.10.2", - "pem-rfc7468 1.0.0", + "pem-rfc7468", "zeroize", ] @@ -2604,12 +2489,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "diatomic-waker" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" - [[package]] name = "digest" version = "0.10.7" @@ -2673,7 +2552,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2714,17 +2593,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "dlopen2" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" -dependencies = [ - "libc", - "once_cell", - "winapi", -] - [[package]] name = "document-features" version = "0.2.12" @@ -2756,12 +2624,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dpi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" - [[package]] name = "dunce" version = "1.0.5" @@ -2792,39 +2654,12 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der 0.7.10", + "der", "digest 0.10.7", "elliptic-curve", "rfc6979", - "signature 2.2.0", - "spki 0.7.3", -] - -[[package]] -name = "ed25519" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" -dependencies = [ - "pkcs8 0.11.0", - "serdect", - "signature 3.0.0", -] - -[[package]] -name = "ed25519-dalek" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" -dependencies = [ - "curve25519-dalek 5.0.0", - "ed25519", - "rand_core 0.10.1", - "serde", - "sha2 0.11.0", - "signature 3.0.0", - "subtle", - "zeroize", + "signature", + "spki", ] [[package]] @@ -2839,15 +2674,15 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct 0.2.0", + "base16ct", "crypto-bigint", "digest 0.10.7", "ff", "generic-array", "group", "hkdf 0.12.4", - "pem-rfc7468 0.7.0", - "pkcs8 0.10.2", + "pem-rfc7468", + "pkcs8", "rand_core 0.6.4", "sec1", "subtle", @@ -2904,17 +2739,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" -[[package]] -name = "enum-assoc" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0590c4a94da3372e83493b956755a6e2266830b6e4e3b101afe66e3f39477b91" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.4", -] - [[package]] name = "enumflags2" version = "0.7.12" @@ -3014,7 +2838,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3183,12 +3007,6 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" -[[package]] -name = "fiat-crypto" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" - [[package]] name = "filedescriptor" version = "0.8.3" @@ -3468,19 +3286,6 @@ dependencies = [ "futures-util", ] -[[package]] -name = "futures-buffered" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" -dependencies = [ - "cordyceps", - "diatomic-waker", - "futures-core", - "pin-project-lite", - "spin 0.10.1", -] - [[package]] name = "futures-channel" version = "0.3.34" @@ -3580,21 +3385,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generator" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1be81b31c9ce4caf7f3178eeb90662bb1864d3ad363ec6bdada3201adff19c37" -dependencies = [ - "cc", - "cfg-if", - "libc", - "log", - "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -3713,18 +3503,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "glow" version = "0.17.0" @@ -4100,7 +3878,7 @@ dependencies = [ "windows 0.61.3", "windows-core 0.61.2", "windows-numerics 0.2.0", - "windows-registry 0.5.3", + "windows-registry", ] [[package]] @@ -4327,12 +4105,6 @@ dependencies = [ "digest 0.11.3", ] -[[package]] -name = "hmac-sha512" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66de62217b456dfbbba2bed965a134a4df57c48f0eac4c772018aee528e72244" - [[package]] name = "home" version = "0.5.12" @@ -4342,17 +4114,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "hostname" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" -dependencies = [ - "cfg-if", - "libc", - "windows-link 0.2.1", -] - [[package]] name = "http" version = "1.5.0" @@ -4483,11 +4244,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration 0.7.0", + "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry 0.6.1", + "windows-registry", ] [[package]] @@ -4502,7 +4263,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.57.0", ] [[package]] @@ -4601,12 +4362,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" -[[package]] -name = "identity-hash" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" - [[package]] name = "idna" version = "1.1.0" @@ -4784,19 +4539,6 @@ dependencies = [ "leaky-cow", ] -[[package]] -name = "ipconfig" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" -dependencies = [ - "socket2", - "widestring", - "windows-registry 0.6.1", - "windows-result 0.4.1", - "windows-sys 0.61.2", -] - [[package]] name = "ipnet" version = "2.12.1" @@ -4804,197 +4546,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] -name = "iroh" -version = "1.3.0" +name = "is-docker" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "885787b892b5e2507c701f132ecbd45d2bad4bbb75157a19427087c16dadb833" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" dependencies = [ - "backon", - "blake3", - "bytes", - "cfg_aliases", - "ctutils", - "data-encoding", - "derive_more", - "ed25519-dalek", - "futures-util", - "getrandom 0.4.3", - "http", - "ipnet", - "iroh-base", - "iroh-dns", - "iroh-metrics", - "iroh-relay", - "n0-error", - "n0-future", - "n0-watcher", - "netwatch", - "noq", - "noq-proto", - "noq-udp", - "papaya", - "pin-project", - "portable-atomic", - "rand 0.10.2", - "reqwest 0.13.4", - "rustc-hash 2.1.3", - "rustls", - "rustls-pki-types", - "serde", - "smallvec", - "strum 0.28.0", - "time", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "url", - "wasm-bindgen-futures", + "once_cell", ] [[package]] -name = "iroh-base" -version = "1.3.0" +name = "is-wsl" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ae9092be76c9f5429776ab3394f5a5a78dc19dc2524267e884a5b9781546c3f" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" dependencies = [ - "curve25519-dalek 5.0.0", - "data-encoding", - "data-encoding-macro", - "derive_more", - "ed25519-dalek", - "getrandom 0.4.3", - "n0-error", - "rand 0.10.2", - "serde", - "url", - "zeroize", + "is-docker", + "once_cell", ] [[package]] -name = "iroh-dns" -version = "1.3.0" +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80276761b56e904e26ea71d0863beef0bb8d5ab6b639cbd1338e4b615209e3a1" -dependencies = [ - "arc-swap", - "cfg_aliases", - "derive_more", - "iroh-base", - "n0-dns-resolver", - "n0-error", - "n0-future", - "portable-atomic", - "rand 0.10.2", - "rustls", - "simple-dns", - "strum 0.28.0", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "iroh-metrics" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ede55536349842337f7cdc63012ec33dc637945f3259c98a0763aac364cdf09" -dependencies = [ - "iroh-metrics-derive", - "itoa", - "n0-error", - "portable-atomic", - "ryu", - "serde", - "tracing", -] - -[[package]] -name = "iroh-metrics-derive" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "iroh-relay" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee116a8233f84980574bb8d11e4517503080cd2c3605bafca04ee9c35d708bd9" -dependencies = [ - "blake3", - "bytes", - "cfg_aliases", - "data-encoding", - "derive_more", - "getrandom 0.4.3", - "http", - "http-body-util", - "hyper", - "hyper-util", - "iroh-base", - "iroh-dns", - "iroh-metrics", - "lru", - "n0-error", - "n0-future", - "noq", - "noq-proto", - "num_enum", - "pin-project", - "postcard", - "rand 0.10.2", - "reqwest 0.13.4", - "rustls", - "rustls-pki-types", - "serde", - "serde_bytes", - "strum 0.28.0", - "tokio", - "tokio-rustls", - "tokio-util", - "tokio-websockets", - "tracing", - "url", - "webpki-roots", - "ws_stream_wasm", -] - -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -5251,16 +4831,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "keyboard-types" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" -dependencies = [ - "bitflags 2.13.1", - "serde", -] - [[package]] name = "keyring" version = "4.2.0" @@ -5319,24 +4889,6 @@ dependencies = [ "libc", ] -[[package]] -name = "ksni" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "814b44c24cd2cb236c3b8a41c7f08237b452a8e76ecaa81f1cec40b5b678215b" -dependencies = [ - "async-executor", - "async-io", - "async-lock", - "futures-channel", - "futures-lite", - "futures-util", - "pastey 0.2.3", - "serde", - "task-local", - "zbus", -] - [[package]] name = "kurbo" version = "0.13.1" @@ -5495,19 +5047,6 @@ dependencies = [ "value-bag", ] -[[package]] -name = "loom" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" -dependencies = [ - "cfg-if", - "generator", - "scoped-tls", - "tracing", - "tracing-subscriber", -] - [[package]] name = "loop9" version = "0.1.5" @@ -5522,9 +5061,6 @@ name = "lru" version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" -dependencies = [ - "hashbrown 0.17.1", -] [[package]] name = "lru-slab" @@ -5584,12 +5120,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "mac-addr" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" - [[package]] name = "mac-notification-sys" version = "0.6.15" @@ -5679,10 +5209,8 @@ dependencies = [ "env_logger", "gpui", "gpui_platform", - "hostname", "httparse", "image", - "iroh", "libc", "log", "maple-agent", @@ -5690,7 +5218,6 @@ dependencies = [ "maple-proxy", "maple-sdk", "notify", - "pake-cpace", "parking_lot", "percent-encoding", "pulldown-cmark", @@ -5708,15 +5235,11 @@ dependencies = [ "tokio", "tokio-util", "tower-http", - "tray-icon", "unicode-segmentation", - "url", "uuid", "wayland-client", "webbrowser", "windows-sys 0.61.2", - "zbus", - "zeroize", ] [[package]] @@ -5954,102 +5477,6 @@ dependencies = [ "cc", ] -[[package]] -name = "muda" -version = "0.21.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03" -dependencies = [ - "crossbeam-channel", - "dpi", - "keyboard-types", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "png 0.18.1", - "thiserror 2.0.20", - "windows-sys 0.61.2", -] - -[[package]] -name = "n0-dns-resolver" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535c99c9a786bee714155d2ee4fc2477405c2c43536e1bc6a51de09a0d1448f7" -dependencies = [ - "derive_more", - "ipconfig", - "jni 0.22.4", - "lru", - "n0-error", - "n0-future", - "ndk-context", - "rand 0.10.2", - "reqwest 0.13.4", - "rustc-hash 2.1.3", - "rustls", - "simple-dns", - "system-configuration 0.8.0", - "tokio", - "tokio-rustls", - "tracing", - "webpki-roots", -] - -[[package]] -name = "n0-error" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26c59ea174675ce6bc196878851e5049e11b8b1f9af3ba87e4d6df8d828bb001" -dependencies = [ - "n0-error-macros", - "spez", -] - -[[package]] -name = "n0-error-macros" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4412346410d6616f3668c40e53c298e5320c7b856f7a960e5914e442601be79f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "n0-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" -dependencies = [ - "cfg_aliases", - "derive_more", - "futures-buffered", - "futures-lite", - "futures-util", - "js-sys", - "pin-project", - "send_wrapper", - "tokio", - "tokio-util", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-time", -] - -[[package]] -name = "n0-watcher" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" -dependencies = [ - "derive_more", - "n0-error", - "n0-future", -] - [[package]] name = "naga" version = "29.0.4" @@ -6122,174 +5549,6 @@ dependencies = [ "jni-sys 0.3.1", ] -[[package]] -name = "netdev" -version = "0.45.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "096c44b66a9b09b99b4dd36b1c295ff3a492039ccecaf55466bd6ddcdba59968" -dependencies = [ - "block2 0.6.2", - "dispatch2", - "dlopen2", - "ipnet", - "jni 0.21.1", - "libc", - "mac-addr", - "ndk-context", - "netlink-packet-core 0.8.2", - "netlink-packet-route 0.31.0", - "netlink-sys 0.8.8", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-core-wlan", - "objc2-foundation 0.3.2", - "objc2-system-configuration", - "once_cell", - "plist", - "windows-sys 0.61.2", -] - -[[package]] -name = "netdev" -version = "0.46.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3c52c2584961c68f5a6e3356797344061f818c93b8acc9cd6d7e284795e563e" -dependencies = [ - "block2 0.6.2", - "dispatch2", - "dlopen2", - "ipnet", - "jni 0.21.1", - "libc", - "mac-addr", - "ndk-context", - "netlink-packet-core 0.9.0", - "netlink-packet-route 0.33.0", - "netlink-sys 0.9.0", - "objc2-core-foundation", - "objc2-system-configuration", - "once_cell", - "plist", - "windows-sys 0.61.2", -] - -[[package]] -name = "netlink-packet-core" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b897d7bd4f0af82e68d40d0344cf37e97f9c97ddf74a098de3e4da05e96ca395" -dependencies = [ - "paste", -] - -[[package]] -name = "netlink-packet-core" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6e2daf9a8c2e21302714706860a0e6be02e03d17294ecc66b354ea7d4059dd" - -[[package]] -name = "netlink-packet-route" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" -dependencies = [ - "bitflags 2.13.1", - "libc", - "log", - "netlink-packet-core 0.8.2", -] - -[[package]] -name = "netlink-packet-route" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59d48ffc8b73a506e1ff0878528c72668f2bfbc3d875b6be890a96be5d0d5576" -dependencies = [ - "bitflags 2.13.1", - "libc", - "log", - "netlink-packet-core 0.9.0", - "zerocopy", -] - -[[package]] -name = "netlink-proto" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93af8261786086024cd5e96e0a991dd65ced07bbf7c233a487bbc96b971d5539" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "log", - "netlink-packet-core 0.8.2", - "netlink-sys 0.8.8", - "thiserror 2.0.20", -] - -[[package]] -name = "netlink-sys" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" -dependencies = [ - "bytes", - "futures-util", - "libc", - "log", - "tokio", -] - -[[package]] -name = "netlink-sys" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c99d38e00b420df49e940fe0826e667c3dad82ac68eb4c2bbf754c1c14a83a10" -dependencies = [ - "bytes", - "libc", - "log", -] - -[[package]] -name = "netwatch" -version = "0.19.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39da9cad5f23aa43401f09497d3b280e51b5feba115d9ffbf38ca35d6ff95e96" -dependencies = [ - "atomic-waker", - "bytes", - "cfg_aliases", - "derive_more", - "ipnet", - "js-sys", - "libc", - "n0-error", - "n0-future", - "n0-watcher", - "netdev 0.45.1", - "netdev 0.46.3", - "netlink-packet-core 0.8.2", - "netlink-packet-route 0.31.0", - "netlink-proto", - "netlink-sys 0.8.8", - "noq-udp", - "objc2-core-foundation", - "objc2-system-configuration", - "pin-project-lite", - "serde", - "socket2", - "time", - "tokio", - "tokio-util", - "tracing", - "web-sys", - "windows 0.62.2", - "windows-result 0.4.1", - "wmi", -] - [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -6361,68 +5620,6 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" -[[package]] -name = "noq" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b78be567e796cfa74bb9bdc4117790af2d505eb887019cf8803244353eb09d89" -dependencies = [ - "bytes", - "cfg_aliases", - "derive_more", - "noq-proto", - "noq-udp", - "pin-project-lite", - "rustc-hash 2.1.3", - "rustls", - "socket2", - "thiserror 2.0.20", - "tokio", - "tokio-stream", - "tracing", - "web-time", -] - -[[package]] -name = "noq-proto" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c1e5b6fe668491eca022f745a0a9402585626c73a7b839b3424ace15d6a9c8f" -dependencies = [ - "aes-gcm", - "bytes", - "derive_more", - "enum-assoc", - "getrandom 0.4.3", - "identity-hash", - "lru-slab", - "rand 0.10.2", - "rand_pcg", - "ring", - "rustc-hash 2.1.3", - "rustls", - "rustls-pki-types", - "slab", - "sorted-index-buffer", - "thiserror 2.0.20", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "noq-udp" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dc50afea61aff926e150a36c31f06094608848e114a3fe667d76ec233163b1c" -dependencies = [ - "cfg_aliases", - "libc", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - [[package]] name = "notify" version = "8.2.0" @@ -6479,7 +5676,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6876,20 +6073,6 @@ dependencies = [ "objc2-io-surface", ] -[[package]] -name = "objc2-core-wlan" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" -dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-security", - "objc2-security-foundation", -] - [[package]] name = "objc2-encode" version = "4.1.0" @@ -6962,59 +6145,24 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.13.1", - "block2 0.5.1", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-metal 0.2.2", -] - -[[package]] -name = "objc2-quartz-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" -dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", - "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-metal 0.3.2", -] - -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags 2.13.1", - "objc2 0.6.4", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-security-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" -dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.2", + "bitflags 2.13.1", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", ] [[package]] -name = "objc2-system-configuration" +name = "objc2-quartz-core" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.1", - "dispatch2", - "libc", "objc2 0.6.4", "objc2-core-foundation", - "objc2-security", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", ] [[package]] @@ -7234,7 +6382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -7255,27 +6403,6 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "pake-cpace" -version = "0.1.0" -source = "git+https://github.com/1Password/cpace?rev=7e2e42c7d359969f4356292c2bd2cf3ff6eeeecc#7e2e42c7d359969f4356292c2bd2cf3ff6eeeecc" -dependencies = [ - "curve25519-dalek 4.1.3", - "getrandom 0.2.17", - "hmac-sha512", - "subtle", -] - -[[package]] -name = "papaya" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da2442474a9404698c42509b8967f437249dbc7b50493e83020333d3943ec0ae" -dependencies = [ - "equivalent", - "seize", -] - [[package]] name = "parking" version = "2.2.1" @@ -7317,12 +6444,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" -[[package]] -name = "pastey" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" - [[package]] name = "pathfinder_geometry" version = "0.5.1" @@ -7361,15 +6482,6 @@ dependencies = [ "base64ct", ] -[[package]] -name = "pem-rfc7468" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -7397,16 +6509,6 @@ dependencies = [ "indexmap 2.14.0", ] -[[package]] -name = "pharos" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" -dependencies = [ - "futures", - "rustc_version", -] - [[package]] name = "phf" version = "0.12.1" @@ -7582,18 +6684,8 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der 0.7.10", - "spki 0.7.3", -] - -[[package]] -name = "pkcs8" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" -dependencies = [ - "der 0.8.2", - "spki 0.8.1", + "der", + "spki", ] [[package]] @@ -7725,19 +6817,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "plist" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" -dependencies = [ - "base64 0.23.1", - "indexmap 2.14.0", - "quick-xml 0.42.0", - "serde", - "time", -] - [[package]] name = "png" version = "0.17.16" @@ -7837,9 +6916,6 @@ name = "portable-atomic" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" -dependencies = [ - "serde", -] [[package]] name = "portable-atomic-util" @@ -7877,21 +6953,9 @@ dependencies = [ "embedded-io 0.4.0", "embedded-io 0.6.1", "heapless 0.7.17", - "postcard-derive", "serde", ] -[[package]] -name = "postcard-derive" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "potential_utf" version = "0.1.6" @@ -8122,15 +7186,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quick-xml" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" -dependencies = [ - "memchr", -] - [[package]] name = "quinn" version = "0.11.11" @@ -8185,7 +7240,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8903,7 +7958,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8913,7 +7968,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", - "log", "once_cell", "ring", "rustls-pki-types", @@ -8962,7 +8016,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -9173,10 +8227,10 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct 0.2.0", - "der 0.7.10", + "base16ct", + "der", "generic-array", - "pkcs8 0.10.2", + "pkcs8", "subtle", "zeroize", ] @@ -9223,12 +8277,6 @@ dependencies = [ "libc", ] -[[package]] -name = "seize" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" - [[package]] name = "self_cell" version = "1.3.0" @@ -9245,12 +8293,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "send_wrapper" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" - [[package]] name = "serde" version = "1.0.229" @@ -9436,16 +8478,6 @@ dependencies = [ "unsafe-libyaml", ] -[[package]] -name = "serdect" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" -dependencies = [ - "base16ct 1.0.0", - "serde", -] - [[package]] name = "sha1" version = "0.10.7" @@ -9532,15 +8564,6 @@ dependencies = [ "rand_core 0.6.4", ] -[[package]] -name = "signature" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" -dependencies = [ - "rand_core 0.10.1", -] - [[package]] name = "simd-adler32" version = "0.3.10" @@ -9578,15 +8601,6 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" -[[package]] -name = "simple-dns" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b6f884fa9a8d48101774bfbd3aeb81e968dd22cffd19a372da69f183db22c1a" -dependencies = [ - "bitflags 2.13.1", -] - [[package]] name = "simplecss" version = "0.2.2" @@ -9683,34 +8697,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] -[[package]] -name = "sorted-index-buffer" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" - [[package]] name = "spellbook" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a" dependencies = [ - "foldhash 0.2.0", - "hashbrown 0.17.1", -] - -[[package]] -name = "spez" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "foldhash 0.1.5", + "hashbrown 0.16.1", ] [[package]] @@ -9747,17 +8744,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der 0.7.10", -] - -[[package]] -name = "spki" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" -dependencies = [ - "base64ct", - "der 0.8.2", + "der", ] [[package]] @@ -9789,7 +8776,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -10136,17 +9123,6 @@ dependencies = [ "system-configuration-sys", ] -[[package]] -name = "system-configuration" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "501336eb7ba9e417300a6a0fa985721065467aa83a6dcf0422a8e43e4c0328fa" -dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.10.0", - "system-configuration-sys", -] - [[package]] name = "system-configuration-sys" version = "0.6.0" @@ -10187,15 +9163,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[package]] -name = "task-local" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2972044a9e5e448a506a7ff6f0d03b566d8ef4cd6918a58fc59835a0f8666626" -dependencies = [ - "pin-project-lite", -] - [[package]] name = "tauri-winrt-notification" version = "0.7.3" @@ -10214,10 +9181,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -10308,7 +9275,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "js-sys", "num-conv", "powerfmt", "serde_core", @@ -10450,7 +9416,6 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", - "tokio-util", ] [[package]] @@ -10477,35 +9442,11 @@ dependencies = [ "futures-core", "futures-io", "futures-sink", - "futures-util", "libc", "pin-project-lite", "tokio", ] -[[package]] -name = "tokio-websockets" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-sink", - "getrandom 0.4.3", - "http", - "httparse", - "rand 0.10.2", - "ring", - "rustls-pki-types", - "sha1_smol", - "simdutf8", - "tokio", - "tokio-rustls", - "tokio-util", -] - [[package]] name = "toml" version = "0.8.23" @@ -10707,26 +9648,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "tray-icon" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f5cc5858ecf9574ddd8f0032a2ff7ffd6f7724d2b81e48b9374984b3fd6ddc9" -dependencies = [ - "crossbeam-channel", - "ksni", - "muda", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation 0.3.2", - "once_cell", - "png 0.18.1", - "thiserror 2.0.20", - "windows-sys 0.61.2", -] - [[package]] name = "tree_magic_mini" version = "3.2.2" @@ -10792,7 +9713,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset 0.9.1", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11050,7 +9971,6 @@ dependencies = [ "idna", "percent-encoding", "serde", - "serde_derive", ] [[package]] @@ -11446,7 +10366,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml 0.41.0", + "quick-xml", "quote", ] @@ -11721,12 +10641,6 @@ dependencies = [ "winsafe", ] -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - [[package]] name = "winapi" version = "0.3.9" @@ -11749,7 +10663,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -12063,17 +10977,6 @@ dependencies = [ "windows-strings 0.4.2", ] -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-result" version = "0.1.2" @@ -12547,46 +11450,12 @@ dependencies = [ "wayland-protocols-wlr", ] -[[package]] -name = "wmi" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" -dependencies = [ - "chrono", - "futures", - "log", - "serde", - "thiserror 2.0.20", - "windows 0.62.2", - "windows-core 0.62.2", -] - [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" -[[package]] -name = "ws_stream_wasm" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" -dependencies = [ - "async_io_stream", - "futures", - "js-sys", - "log", - "pharos", - "rustc_version", - "send_wrapper", - "thiserror 2.0.20", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "wyz" version = "0.5.1" @@ -12642,7 +11511,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "curve25519-dalek 4.1.3", + "curve25519-dalek", "rand_core 0.6.4", "serde", "zeroize", @@ -12673,7 +11542,7 @@ checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" dependencies = [ "bitflags 2.13.1", "libc", - "quick-xml 0.41.0", + "quick-xml", "x11", ] diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index a6488b40d..b7e9bda5d 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -590,10 +590,3 @@ are replaced by root monorepo CI. ## License MIT. See `LICENSE`. - -## Desktop remote access - -See [remote access](docs/remote-access.md) for pairing, host selection, recovery, -local-only account controls and the separate infrastructure/native trial gates. -Remote access is off by default; the initial development configuration has no -implicit public-relay fallback. diff --git a/apps/maple-agent/app/Cargo.toml b/apps/maple-agent/app/Cargo.toml index 6c64efa6b..80c304c6a 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -25,8 +25,6 @@ desktop = [ "dep:notify", "dep:spellbook", "dep:wayland-client", - "dep:tray-icon", - "dep:zbus", ] # `maple-agent acp`: the Agent Client Protocol server on stdio. acp = ["maple-agent/acp"] @@ -71,14 +69,9 @@ chrono = "0.4" clap = { version = "4", features = ["derive", "env"] } # Hidden password prompt for `maple-agent login`. rpassword = "7" -hostname = "0.4" maple-proxy = { workspace = true, optional = true } reqwest = { workspace = true, features = ["json"] } percent-encoding = "2" -url = "2.5" -iroh = { version = "=1.3.0", default-features = false, features = ["tls-ring"] } -pake-cpace = { git = "https://github.com/1Password/cpace", rev = "7e2e42c7d359969f4356292c2bd2cf3ff6eeeecc" } -zeroize = "1" axum = { version = "0.8", optional = true } tower-http = { version = "0.6", features = ["cors"], optional = true } @@ -86,10 +79,6 @@ tower-http = { version = "0.6", features = ["cors"], optional = true } # `ui::decorations`. gpui cannot answer that question. [target.'cfg(target_os = "linux")'.dependencies] wayland-client = { version = "0.31", optional = true } -zbus = { version = "=5.19.0", optional = true } - -[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies] -tray-icon = { version = "=0.26.1", default-features = false, features = ["ksni"], optional = true } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index b3d5a1ce4..00642e02b 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -19,8 +19,6 @@ pub struct AgentBackend { owned_runtime: Option, client: BackendClient, api_url: String, - controller: Option>, - remote_host: Option, } impl Drop for AgentBackend { @@ -70,7 +68,7 @@ macro_rules! operations { self.call(stringify!($name), ($($arg,)* )).await })* } - pub(crate) async fn dispatch_inner(backend: &LocalBackend, request: Request) -> Result { + async fn dispatch_inner(backend: &LocalBackend, request: Request) -> Result { match request.method.as_str() { $(stringify!($name) => { let ($($arg,)*): ($(wire_type!($ty),)*) = serde_json::from_value(request.params) @@ -127,6 +125,7 @@ operations! { cancel_queued_message(user_id: (&str), session_id: (&str), queue_id: (&str)) -> AgentDesktopQueueSnapshot; begin_queued_message_edit(user_id: (&str), session_id: (&str), queue_id: (&str)) -> (); end_queued_message_edit(user_id: (&str), session_id: (&str), queue_id: (&str)) -> (); + send_message(user_id: (&str), request: (AgentSendMessageRequest)) -> String; read_image_attachment(user_id: (&str), session_id: (&str), attachment_id: (&str)) -> Vec; cancel_run(user_id: (&str), run_id: (&str)) -> (); available_model_ids(user_id: (&str)) -> Vec; @@ -174,8 +173,6 @@ impl AgentBackend { owned_runtime: Some(runtime), client: BackendClient::InProcess(Box::new(backend)), api_url, - controller: None, - remote_host: None, }) } @@ -188,59 +185,14 @@ impl AgentBackend { owned_runtime: Some(runtime), client: BackendClient::Host(client), api_url, - controller: None, - remote_host: None, }) } - pub async fn remote(self: &Arc, peer: String) -> Result, String> { - let client = RemoteClient::connect_remote(&self.api_url, peer.clone()).await?; - Ok(Arc::new(Self { - runtime: self.runtime.clone(), - owned_runtime: None, - client: BackendClient::Host(client), - api_url: self.api_url.clone(), - controller: Some(self.clone()), - remote_host: Some(peer), - })) - } - pub fn remote_host(&self) -> Option<&str> { - self.remote_host.as_deref() - } - pub async fn remote_setting( - &self, - method: &str, - params: impl Serialize, - ) -> Result { - if !method.starts_with("remote_") || self.remote_host.is_some() { - return Err("Remote settings require the local desktop".into()); - } - self.call(method, params).await - } - async fn call( &self, method: &str, params: impl Serialize, ) -> Result { - // Speech capture/playback and account UI belong to the controller. - // All task operations use the selected host's separate connection. - if let Some(controller) = &self.controller - && matches!( - method, - "audio_capabilities" - | "synthesize_speech" - | "transcribe_audio" - | "plan_usage" - | "account" - ) - { - match &controller.client { - BackendClient::Host(client) => return client.call(method, params).await, - #[cfg(test)] - BackendClient::InProcess(_) => {} - } - } match &self.client { BackendClient::Host(client) => client.call(method, params).await, #[cfg(test)] @@ -259,27 +211,6 @@ impl AgentBackend { } } } - /// The composer retains this ID through uncertain delivery. Only receipt - /// queries, never another send, use it after a connection loss. - pub async fn send_message_identified( - &self, - user: &str, - request: AgentSendMessageRequest, - id: String, - ) -> Result { - match &self.client { - BackendClient::Host(client) => { - client - .call_identified("send_message", (user, request), Some(id)) - .await - } - #[cfg(test)] - BackendClient::InProcess(backend) => backend.send_message(user, request).await, - } - } - pub async fn send_outcome(&self, id: String) -> Result { - self.call("send_outcome", id).await - } pub fn runtime_handle(&self) -> tokio::runtime::Handle { self.runtime.clone() } @@ -369,9 +300,6 @@ impl AgentBackend { }); } pub fn begin_integration_setup(&self, id: &str) -> Result { - if self.remote_host.is_some() { - return Err("Open Maple on the execution computer to grant native permissions.".into()); - } // Native permission prompts remain on the signed app's UI thread. maple_agent::agent::begin_integration_setup(&AgentSetupIntegrationRequest { id: id.into() }) } @@ -409,15 +337,6 @@ async fn dispatch_special( serde_json::to_value(value).map_err(|_| "Could not encode host response".to_string()) }; match request.method.as_str() { - "send_message" => { - let (user, message): (String, AgentSendMessageRequest) = - serde_json::from_value(request.params) - .map_err(|_| "Invalid host request parameters")?; - backend.authorize_account(&user)?; - serde_json::to_value(backend.send_message(&user, message).await?) - .map_err(|_| "Could not encode host response".into()) - } - "restore_auth" => { serde_json::to_value(backend.validate_persisted_auth().await).map_err(|e| e.to_string()) } diff --git a/apps/maple-agent/app/src/backend/local.rs b/apps/maple-agent/app/src/backend/local.rs index 20a062c28..a833c586e 100644 --- a/apps/maple-agent/app/src/backend/local.rs +++ b/apps/maple-agent/app/src/backend/local.rs @@ -87,7 +87,7 @@ pub struct RecoverySnapshot { pub struct LocalBackend { #[cfg(test)] pub(super) ui_fixture: bool, - pub(crate) account_gate: tokio::sync::RwLock<()>, + pub(super) account_gate: tokio::sync::RwLock<()>, restore_lock: tokio::sync::Mutex<()>, service: MapleAgentService, auth: MapleApiAuthState, @@ -978,26 +978,6 @@ impl LocalBackend { self.auth.session_for(user_id).await } - /// Snapshot the locally selected account for remote enrollment. - pub(crate) fn remote_storage_root(&self) -> PathBuf { - self.persisted_auth - .path - .parent() - .expect("auth store has a parent") - .to_path_buf() - } - pub(crate) fn remote_api_url(&self) -> &str { - &self.api_url - } - - pub(crate) async fn remote_session( - &self, - user_id: &str, - ) -> Result, String> { - self.authorize_account(user_id)?; - self.session_for(user_id).await - } - /// Sign out completely: invalidate the live session first, then remove /// only the persisted credentials that this sign-out observed. pub async fn logout_and_clear(&self, user_id: &str) -> Result<(), String> { @@ -1656,16 +1636,6 @@ impl LocalBackend { /// holds the lifecycle lock across its network round trips, and these /// reads would queue behind it. pub async fn local_bootstrap(&self, user_id: &str) -> Result { - self.read_bootstrap(user_id, true).await - } - - /// Navigation and reconnect snapshots need summaries, not an unrelated - /// latest transcript. A controller can observe several computers at once. - async fn read_bootstrap( - &self, - user_id: &str, - load_latest: bool, - ) -> Result { let handle = self.service.handle_for_user(user_id).await?; let config = handle.load_config().await?; let project_root = gui_project_root(&config); @@ -1687,7 +1657,7 @@ impl LocalBackend { && Some(&session.project_root) == project_root.as_ref() }) .map(|session| session.id.clone()); - let latest = match latest_id.filter(|_| load_latest) { + let latest = match latest_id { Some(id) => handle.load_session(id).await.ok(), None => None, }; @@ -1706,7 +1676,8 @@ impl LocalBackend { user_id: &str, session_id: Option, ) -> Result { - let bootstrap = self.read_bootstrap(user_id, false).await?; + let mut bootstrap = self.local_bootstrap(user_id).await?; + bootstrap.latest = None; let handle = self.service.handle_for_user(user_id).await?; let selected = match session_id.filter(|id| bootstrap.sessions.iter().any(|s| &s.id == id)) { @@ -2658,119 +2629,6 @@ mod tests { ); } - #[tokio::test] - async fn checking_remote_status_does_not_create_identity_or_require_network() { - let root = tempfile::tempdir().unwrap(); - let backend = Arc::new(LocalBackend::isolated_for_test(root.path())); - backend.persisted_auth.claim_and_persist(&auth_snapshot( - "fixture-user", - "fixture-session", - 1, - "fixture-token", - )); - let (events, _) = tokio::sync::broadcast::channel(8); - let receipts = Arc::new( - crate::host::receipts::Receipts::open(&root.path().join("receipts.db")).unwrap(), - ); - let remote = crate::remote::manager::Manager::new(backend.clone(), events, receipts); - remote.restore().await; - let status = remote - .dispatch(crate::host::protocol::Request { - id: 1, - method: "remote_status".into(), - params: serde_json::json!([]), - send_id: None, - }) - .await - .unwrap(); - assert_eq!(status["enabled"], false); - assert!(status["endpoint"].is_null()); - assert!(!root.path().join("remote").exists()); - assert!(!remote.stays_running()); - // Naming an offline computer is local even when authentication cannot - // finish. It must not bind an endpoint or enable incoming access. - let _validation = backend.restore_lock.lock().await; - tokio::time::timeout( - std::time::Duration::from_secs(1), - remote.dispatch(crate::host::protocol::Request { - id: 2, - method: "remote_set_name".into(), - params: serde_json::json!(["Offline Mac"]), - send_id: None, - }), - ) - .await - .unwrap() - .unwrap(); - let status = remote - .dispatch(crate::host::protocol::Request { - id: 3, - method: "remote_status".into(), - params: serde_json::json!([]), - send_id: None, - }) - .await - .unwrap(); - assert_eq!(status["name"], "Offline Mac"); - assert_eq!(status["ready"], false); - assert_eq!(status["enabled"], false); - assert!(!remote.stays_running()); - } - - #[tokio::test] - async fn disabling_remote_does_not_wait_for_initializing_credentials() { - use std::{future::Future, task::Poll}; - let root = tempfile::tempdir().unwrap(); - let backend = Arc::new(LocalBackend::isolated_for_test(root.path())); - backend.persisted_auth.claim_and_persist(&auth_snapshot( - "fixture-user", - "fixture-session", - 1, - "fixture-token", - )); - let (events, _) = tokio::sync::broadcast::channel(8); - let receipts = Arc::new( - crate::host::receipts::Receipts::open(&root.path().join("receipts.db")).unwrap(), - ); - let remote = crate::remote::manager::Manager::new(backend.clone(), events, receipts); - let request = |method: &str, params| crate::host::protocol::Request { - id: 1, - method: method.into(), - params, - send_id: None, - }; - remote - .dispatch(request("remote_enable", serde_json::json!([true]))) - .await - .unwrap(); - let validation = backend.restore_lock.lock().await; - let active = remote.active(); - tokio::pin!(active); - std::future::poll_fn(|cx| { - assert!(active.as_mut().poll(cx).is_pending()); - Poll::Ready(()) - }) - .await; - tokio::time::timeout( - std::time::Duration::from_secs(1), - remote.dispatch(request("remote_enable", serde_json::json!([false]))), - ) - .await - .unwrap() - .unwrap(); - assert!(!remote.stays_running()); - let status = remote - .dispatch(request("remote_status", serde_json::json!([]))) - .await - .unwrap(); - assert_eq!(status["enabled"], false); - assert_eq!(status["ready"], false); - drop(validation); - // Invalid fixture credentials cannot create a node after the cancellation. - assert!(active.await.is_err()); - remote.shutdown().await; - } - #[tokio::test] async fn credential_validation_and_acp_attachment_do_not_block_local_operations() { use std::{future::Future, task::Poll}; diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 36203d4ce..59968d426 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -13,7 +13,6 @@ actions!(maple_app, [QuitApp, Hide, HideOthers, ShowAll]); use crate::backend::AgentBackend; use crate::ui; -use crate::ui::chat::computers::{Computer, Destination, Navigate, NavigationChanged}; use crate::ui::chat::{ChatScreen, LoggedOut}; use crate::ui::login::{LoginScreen, LoginSucceeded}; use crate::ui::settings::{ @@ -31,46 +30,6 @@ enum Screen { Settings(Entity), } -/// One cache and one initial connection attempt per paired endpoint. Once -/// attached, the existing IPC/Iroh supervisor owns reconnects and snapshots. -struct RemoteComputer { - name: String, - grant: [u8; 16], - chat: Option>, - attempt: ConnectionAttempt, -} - -#[derive(Clone)] -struct PendingNavigation { - request: Navigate, - source: Option<(gpui::EntityId, u64)>, -} - -enum ConnectionAttempt { - Idle, - Connecting(u64), - Failed { - message: String, - retry_at: std::time::Instant, - }, -} -impl RemoteComputer { - fn should_connect(&self, now: std::time::Instant) -> bool { - self.chat.is_none() - && match &self.attempt { - ConnectionAttempt::Idle => true, - ConnectionAttempt::Connecting(_) => false, - ConnectionAttempt::Failed { retry_at, .. } => now >= *retry_at, - } - } - fn error(&self) -> Option { - match &self.attempt { - ConnectionAttempt::Failed { message, .. } => Some(message.clone()), - _ => None, - } - } -} - struct MapleApp { backend: Arc, screen: Screen, @@ -78,16 +37,6 @@ struct MapleApp { /// The chat screen is parked while settings is open so Back returns to /// it with its state intact. parked_chat: Option>, - local_chat: Option>, - selected_host: Option, - remote_computers: std::collections::BTreeMap, - next_connection_attempt: u64, - requested_navigation: Option, - computers_sync_pending: bool, - remote_enabled: Arc, - #[cfg(any(target_os = "linux", target_os = "windows"))] - tray: Option, - remote_bridges: std::cell::RefCell>>, settings: crate::settings::AppSettings, /// The complete live shortcut map. Settings mutations prepare a full /// replacement before this runtime clears GPUI's process-wide bindings. @@ -130,11 +79,23 @@ impl MapleApp { events: Vec, cx: &mut Context, ) { - if let Some(chat) = &self.local_chat { + let chat = match &self.screen { + Screen::Chat(chat) => Some(chat.clone()), + _ => self.parked_chat.clone(), + }; + if let Some(chat) = chat { chat.update(cx, |chat, cx| chat.handle_service_events(events, cx)); } } + /// Show a task, for a click on its notification. Only the chat screen + /// can: login has no tasks, and settings keeps its parked chat as is. + fn open_task(&mut self, session_id: &str, cx: &mut Context) { + if let Screen::Chat(chat) = &self.screen { + chat.update(cx, |chat, cx| chat.select_session(session_id, cx)); + } + } + /// A newer release exists: tell the chat screen so it shows the banner. /// Without a chat screen (login in progress) nothing is done here; the /// result stays in `update::available()` and seeds the next chat. @@ -155,12 +116,7 @@ impl MapleApp { /// form the user may already be typing in. fn show_login(&mut self, cx: &mut Context) { self.user_id = None; - self.set_remote_enabled(false, cx); self.parked_chat = None; - self.local_chat = None; - self.remote_computers.clear(); - self.requested_navigation = None; - self.selected_host = None; if matches!(self.screen, Screen::Login(_)) { return; } @@ -198,298 +154,10 @@ impl MapleApp { self.user_id = Some(user_id); self.parked_chat = None; self.screen = Screen::Chat(chat.clone()); - self.local_chat = Some(chat.clone()); - self.remote_computers.clear(); - self.requested_navigation = None; - self.selected_host = None; self.subscribe_chat(&chat, cx); cx.notify(); } - fn update_computers(&mut self, status: crate::remote::manager::Status, cx: &mut Context) { - self.set_remote_enabled(status.enabled, cx); - let computers = status - .peers - .into_iter() - .filter(|(_, peer)| peer.can_connect) - .map(|(key, peer)| { - let alias = self - .settings - .remote_sidebars - .get(&key) - .map_or("", |s| s.display_name.as_str()); - let label = ui::remote::computer_name(&key, &peer.name, alias); - (key, (label, peer.grant)) - }) - .collect::>(); - let mut changed = self.remote_computers.len() != computers.len(); - let mut selected_grant_changed = false; - self.remote_computers - .retain(|key, _| computers.contains_key(key)); - for (key, (name, grant)) in computers { - match self.remote_computers.entry(key) { - std::collections::btree_map::Entry::Vacant(entry) => { - changed = true; - entry.insert(RemoteComputer { - name, - grant, - chat: None, - attempt: ConnectionAttempt::Idle, - }); - } - std::collections::btree_map::Entry::Occupied(mut entry) => { - if entry.get().grant != grant { - changed = true; - selected_grant_changed |= self.selected_host.as_ref() == Some(entry.key()); - let computer = entry.get_mut(); - computer.grant = grant; - computer.chat = None; - computer.attempt = ConnectionAttempt::Idle; - } - if entry.get().name != name { - changed = true; - entry.get_mut().name = name; - } - } - } - } - if selected_grant_changed - || self - .selected_host - .as_ref() - .is_some_and(|key| !self.remote_computers.contains_key(key)) - { - self.navigate( - Navigate { - computer: None, - destination: Destination::Overview, - }, - cx, - ); - } - if changed { - self.sync_computers(cx); - } - let now = std::time::Instant::now(); - let ready = self - .remote_computers - .iter() - .filter(|(_, computer)| computer.should_connect(now)) - .map(|(key, _)| key.clone()) - .collect::>(); - for peer in ready { - self.connect_computer(peer, cx); - } - for chat in self.local_chat.iter().chain( - self.remote_computers - .values() - .filter_map(|c| c.chat.as_ref()), - ) { - chat.update(cx, |chat, cx| chat.refresh_navigation_if_due(cx)); - } - } - - fn sync_computers(&mut self, cx: &mut Context) { - let Some(local) = &self.local_chat else { - return; - }; - let mut computers = vec![local.read(cx).computer_snapshot("This computer".into(), cx)]; - for (key, computer) in &self.remote_computers { - computers.push(match &computer.chat { - Some(chat) => chat - .read(cx) - .computer_snapshot(computer.name.clone().into(), cx), - None => Computer::pending(key.clone(), computer.name.clone(), computer.error()), - }); - } - let computers = Arc::new(computers); - for chat in std::iter::once(local).chain( - self.remote_computers - .values() - .filter_map(|c| c.chat.as_ref()), - ) { - chat.update(cx, |chat, cx| chat.set_computers(computers.clone(), cx)); - } - } - - fn schedule_computers_sync(&mut self, cx: &mut Context) { - if self.computers_sync_pending { - return; - } - self.computers_sync_pending = true; - let weak = cx.entity().downgrade(); - cx.defer(move |cx| { - weak.update(cx, |app, cx| { - app.computers_sync_pending = false; - app.sync_computers(cx); - }) - .ok(); - }); - } - - fn navigate(&mut self, request: Navigate, cx: &mut Context) { - self.requested_navigation = Some(PendingNavigation { - request: request.clone(), - source: self.navigation_source(cx), - }); - let chat = match &request.computer { - None => self.local_chat.clone(), - Some(peer) => self - .remote_computers - .get(peer) - .and_then(|computer| computer.chat.as_ref()) - .filter(|chat| !chat.read(cx).connection_ended()) - .cloned(), - }; - if let Some(chat) = chat { - let browse = match &self.screen { - Screen::Chat(previous) if previous.entity_id() != chat.entity_id() => { - Some(previous.read(cx).sidebar_browse_state(cx)) - } - _ => None, - }; - for other in self.local_chat.iter().chain( - self.remote_computers - .values() - .filter_map(|c| c.chat.as_ref()), - ) { - other.update(cx, |other, cx| other.set_visible(false, cx)); - } - self.selected_host = request.computer; - self.screen = Screen::Chat(chat.clone()); - self.parked_chat = None; - chat.update(cx, |chat, cx| { - chat.set_visible(true, cx); - if let Some(browse) = browse { - chat.restore_sidebar_browse_state(browse, cx); - } - chat.navigate(request.destination, cx); - }); - self.requested_navigation = None; - cx.notify(); - } else if let Some(peer) = request.computer { - self.connect_computer(peer, cx); - } - } - - fn navigation_source(&self, cx: &gpui::App) -> Option<(gpui::EntityId, u64)> { - match &self.screen { - Screen::Chat(chat) => Some((chat.entity_id(), chat.read(cx).navigation_generation())), - _ => None, - } - } - - fn set_remote_enabled(&mut self, enabled: bool, cx: &mut Context) { - if self - .remote_enabled - .swap(enabled, std::sync::atomic::Ordering::AcqRel) - == enabled - { - return; - } - #[cfg(any(target_os = "linux", target_os = "windows"))] - if let Some(tray) = &self.tray { - tray.set_enabled(enabled); - } - cx.notify(); - } - - fn connect_computer(&mut self, peer: String, cx: &mut Context) { - let Some(user_id) = self.user_id.clone() else { - return; - }; - let Some(computer) = self.remote_computers.get_mut(&peer) else { - return; - }; - if matches!(computer.attempt, ConnectionAttempt::Connecting(_)) { - return; - } - self.next_connection_attempt += 1; - let attempt = self.next_connection_attempt; - computer.attempt = ConnectionAttempt::Connecting(attempt); - let backend = self.backend.clone(); - let target = peer.clone(); - self.sync_computers(cx); - let bridge = ui::task::call( - &self.backend, - async move { backend.remote(target).await }, - cx, - move |app, result, cx| { - if app.user_id.as_deref() != Some(&user_id) { - return; - } - // Unpairing/re-pairing or signing out discards an old attempt. - // A late completion never resurrects that computer or its view. - if !app.remote_computers.get(&peer).is_some_and(|computer| matches!(computer.attempt, ConnectionAttempt::Connecting(id) if id == attempt)) { - return; - } - match result { - Ok(backend) => { - let source = backend.clone(); - let rx = backend.spawn(async move { source.take_events().await }); - let chat = cx.new(|cx| ChatScreen::new_catalog(backend, user_id, cx)); - app.subscribe_chat(&chat, cx); - let weak = chat.downgrade(); - cx.spawn(async move |_, cx| { - let Some(mut rx) = rx.await.ok().flatten() else { - return; - }; - while let Some(event) = rx.recv().await { - let mut batch = vec![event]; - while batch.len() < 256 { - if let Ok(event) = rx.try_recv() { - batch.push(event); - } else { - break; - } - } - if weak - .update(cx, |chat, cx| chat.handle_service_events(batch, cx)) - .is_err() - { - break; - } - } - }) - .detach(); - if let Some(computer) = app.remote_computers.get_mut(&peer) { - computer.chat = Some(chat.clone()); - computer.attempt = ConnectionAttempt::Idle; - } - if let Some(pending) = app.requested_navigation.clone() - && pending.request.computer.as_deref() == Some(&peer) - { - if pending.source == app.navigation_source(cx) { - app.navigate(pending.request, cx); - } else { - app.requested_navigation = None; - } - } - } - Err(error) => { - if let Some(computer) = app.remote_computers.get_mut(&peer) { - computer.attempt = ConnectionAttempt::Failed { - message: error.clone(), - retry_at: std::time::Instant::now() - + std::time::Duration::from_secs(30), - }; - } - if app.requested_navigation.as_ref().is_some_and(|pending| { - pending.request.computer.as_deref() == Some(&peer) - }) && let Screen::Chat(chat) = &app.screen - { - chat.update(cx, |chat, cx| chat.navigation_notice(error, cx)); - app.requested_navigation = None; - } - } - } - app.sync_computers(cx); - cx.notify(); - }, - ); - ui::task::retain(&self.remote_bridges, bridge); - } - /// Park the chat screen and show settings. fn show_settings(&mut self, section: Section, cx: &mut Context) { let Screen::Chat(chat) = &self.screen else { @@ -499,51 +167,9 @@ impl MapleApp { let user_id = self.user_id.clone().unwrap_or_default(); let settings = self.settings.clone(); let shortcut_snapshot = self.shortcuts.snapshot(); - let integrations_backend = chat.read(cx).execution_backend(); - chat.update(cx, |chat, cx| chat.set_visible(false, cx)); let screen = cx.new(|cx| { - SettingsScreen::new( - backend, - integrations_backend, - user_id, - settings, - shortcut_snapshot, - section, - cx, - ) + SettingsScreen::new(backend, user_id, settings, shortcut_snapshot, section, cx) }); - cx.subscribe( - &screen, - |app, _, event: &ui::remote::ComputersChanged, cx| { - app.update_computers(event.0.clone(), cx); - }, - ) - .detach(); - cx.subscribe(&screen, |app, _, event: &ui::remote::ComputerNamed, cx| { - app.settings - .remote_sidebars - .entry(event.key.clone()) - .or_default() - .display_name = event.name.clone(); - if let Some(computer) = app.remote_computers.get_mut(&event.key) { - computer.name = event.name.clone(); - app.sync_computers(cx); - } - }) - .detach(); - cx.subscribe( - &screen, - |app, _, event: &ui::remote::ConnectComputer, cx| { - app.navigate( - Navigate { - computer: Some(event.0.clone()), - destination: Destination::Overview, - }, - cx, - ); - }, - ) - .detach(); cx.subscribe( &screen, |app: &mut MapleApp, _emitter, event: &SettingsClosed, cx| { @@ -566,7 +192,7 @@ impl MapleApp { cx.subscribe( &screen, |app: &mut MapleApp, _emitter, _event: &SignOutRequested, cx| { - if let Some(chat) = app.local_chat.take() { + if let Some(chat) = app.parked_chat.take() { chat.update(cx, |chat, cx| chat.sign_out(cx)); } app.show_login(cx); @@ -590,14 +216,6 @@ impl MapleApp { /// Wire per-chat events: sign-out and the settings gear. fn subscribe_chat(&mut self, chat: &Entity, cx: &mut Context) { - cx.subscribe(chat, |app, _, _: &NavigationChanged, cx| { - app.schedule_computers_sync(cx) - }) - .detach(); - cx.subscribe(chat, |app, _, request: &Navigate, cx| { - app.navigate(request.clone(), cx) - }) - .detach(); cx.subscribe( chat, |app: &mut MapleApp, _emitter, _event: &LoggedOut, cx| { @@ -629,15 +247,9 @@ impl MapleApp { ui::typography::ChatFontFamily::parse(&self.settings.chat_font_family), self.settings.chat_font_size, ); - for chat in self.local_chat.iter().chain( - self.remote_computers - .values() - .filter_map(|c| c.chat.as_ref()), - ) { - chat.update(cx, |chat, cx| chat.apply_defaults(&self.settings, cx)); - } if let Some(chat) = self.parked_chat.take() { - chat.update(cx, |chat, cx| chat.set_visible(true, cx)); + let settings = self.settings.clone(); + chat.update(cx, |chat, cx| chat.apply_defaults(&settings, cx)); self.screen = Screen::Chat(chat); } cx.notify(); @@ -715,151 +327,6 @@ impl Render for MapleApp { } } -#[derive(Clone)] -struct DesktopLifecycle { - backend: Arc, - remote_enabled: Arc, - can_background: bool, - closing: std::rc::Rc>, - quit_stop_attempted: std::rc::Rc>, -} -impl gpui::Global for DesktopLifecycle {} - -fn quit_app(cx: &mut App) { - let Some(state) = cx.try_global::().cloned() else { - cx.quit(); - return; - }; - if !state - .remote_enabled - .load(std::sync::atomic::Ordering::Acquire) - { - cx.quit(); - return; - } - if state.closing.replace(true) { - return; - } - let api = state.backend.api_url().to_owned(); - let stop = state - .backend - .spawn(async move { crate::host::request_stop(&api).await }); - cx.spawn(async move |cx| match stop.await { - Ok(Ok(())) => cx.update(|cx| { - state.quit_stop_attempted.set(true); - cx.quit(); - }), - result => { - state.closing.set(false); - log::warn!("Could not stop Maple host on Quit: {result:?}"); - cx.update(show_quit_warning); - } - }) - .detach(); -} - -/// A separate window also works when Quit comes from a tray with no open window. -fn show_quit_warning(cx: &mut App) { - let bounds = Bounds::centered(None, size(px(540.), px(230.)), cx); - if let Err(error) = cx.open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - ..Default::default() - }, - |_, cx| cx.new(|_| QuitWarning), - ) { - log::error!("Could not show Quit confirmation: {error}"); - } - cx.activate(true); -} - -struct QuitWarning; -impl Render for QuitWarning { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div().size_full().flex().flex_col().gap_4().p_6() - .bg(gpui::rgb(ui::theme::bg_app())).text_color(gpui::rgb(ui::theme::text_primary())) - .child(div().text_lg().child("Maple could not stop its background host")) - .child("Remote access and running tasks may still be active. Keep Maple open to retry, or quit this window anyway.") - .child(div().flex().gap_4() - .child(ui::widgets::secondary_button("keep-maple-open").debug_selector(|| "keep-maple-open".to_string()).child("Keep open") - .on_click(|_, window, _| window.remove_window())) - .child(ui::widgets::secondary_button("quit-maple-anyway").debug_selector(|| "quit-maple-anyway".to_string()).child("Quit anyway") - .on_click(|_, _, cx| { - // Do not repeat the failed stop in the app-quit callback. - if let Some(state) = cx.try_global::() { state.quit_stop_attempted.set(true); } - cx.quit(); - }))) - } -} - -/// The native close button and our Linux title bar share this policy. -pub(crate) fn close_window(window: &mut Window, cx: &mut App) { - persist_window_state(); - let Some(state) = cx.try_global::().cloned() else { - cx.quit(); - return; - }; - if !state - .remote_enabled - .load(std::sync::atomic::Ordering::Acquire) - { - quit_app(cx); - return; - } - #[cfg(target_os = "macos")] - { - let _ = window; - cx.hide(); - } - #[cfg(not(target_os = "macos"))] - { - if state.closing.replace(true) { - return; - } - let check = state.backend.spawn(async move { - #[cfg(target_os = "linux")] - { - state.can_background && crate::tray::usable().await - } - #[cfg(target_os = "windows")] - { - state.can_background - } - }); - window.spawn(cx, async move |cx| { - let usable = check.await.unwrap_or(false); - let prompt = cx.update(|window, cx| { - if let Some(state) = cx.try_global::() { state.closing.set(false); } - if usable { window.remove_window(); None } else { - Some(window.prompt(gpui::PromptLevel::Warning, "Keep Maple open for remote access?", - Some("This desktop has no available tray to reopen Maple. Quitting stops remote access and running tasks on this computer."), - &["Keep open", "Quit Maple"], cx)) - } - }).ok().flatten(); - if let Some(prompt) = prompt && prompt.await == Ok(1) { - let _ = cx.update(|_, cx| quit_app(cx)); - } - }).detach(); - } -} - -fn install_close_behavior( - window: &mut Window, - cx: &mut App, - remote_enabled: Arc, - can_background: bool, -) { - if cx.has_global::() { - let state = cx.global_mut::(); - state.remote_enabled = remote_enabled; - state.can_background = can_background; - } - window.on_window_should_close(cx, |window, cx| { - close_window(window, cx); - false - }); -} - /// Shown only if a paint happens before `run` picks the first screen, /// which places it in the same event-loop turn as the window open. fn restoring_view() -> gpui::Div { @@ -942,59 +409,10 @@ fn main_window_options(window_bounds: WindowBounds) -> WindowOptions { } } -struct StartupError { - message: String, - stopping: bool, -} -impl StartupError { - fn new(message: String) -> Self { - Self { - message, - stopping: false, - } - } - fn stop_old_host(&mut self, window: &mut Window, cx: &mut Context) { - if self.stopping { - return; - } - let answer = window.prompt(gpui::PromptLevel::Warning, "Stop the existing Maple host?", - Some("This stops running tasks and remote access for this profile. Maple will reopen with this build."), - &["Cancel", "Stop and reopen"], cx); - self.stopping = true; - cx.spawn(async move |this, cx| { - let result = if answer.await == Ok(1) { - Some( - cx.background_executor() - .spawn(async move { - crate::host::stop(&crate::configured_api_url())?; - let exe = std::env::current_exe().map_err(|e| e.to_string())?; - std::process::Command::new(exe) - .spawn() - .map_err(|e| e.to_string())?; - Ok::<(), String>(()) - }) - .await, - ) - } else { - None - }; - let _ = this.update(cx, |this, cx| { - this.stopping = false; - match result { - Some(Ok(())) => cx.quit(), - Some(Err(error)) => this.message = format!("Could not restart Maple: {error}"), - None => {} - } - cx.notify(); - }); - }) - .detach(); - cx.notify(); - } -} +struct StartupError(String); impl Render for StartupError { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div() .size_full() .flex() @@ -1004,23 +422,7 @@ impl Render for StartupError { .bg(gpui::rgb(ui::theme::bg_app())) .text_color(gpui::rgb(ui::theme::text_primary())) .child(div().text_lg().child("Maple could not start")) - .child(self.message.clone()) - .when( - self.message.contains("Incompatible Maple host or profile"), - |view| { - view.child( - ui::widgets::secondary_button("stop-incompatible-host") - .child(if self.stopping { - "Stopping…" - } else { - "Stop existing host and reopen" - }) - .on_click( - cx.listener(|this, _, window, cx| this.stop_old_host(window, cx)), - ), - ) - }, - ) + .child(self.0.clone()) .child( div() .id("close-startup-error") @@ -1038,7 +440,7 @@ impl Render for StartupError { fn show_startup_error(error: String) { gpui_platform::application().run(move |cx| { cx.set_app_identity(crate::profile::BUNDLE_ID, crate::profile::DISPLAY_NAME); - cx.on_action(|_: &QuitApp, cx| quit_app(cx)); + cx.on_action(|_: &QuitApp, cx| cx.quit()); ui::menus::install(cx); let bounds = Bounds::centered(None, size(px(560.), px(260.)), cx); let result = cx.open_window( @@ -1056,7 +458,7 @@ fn show_startup_error(error: String) { cx.quit(); true }); - cx.new(|_| StartupError::new(error)) + cx.new(|_| StartupError(error)) }, ); if let Err(error) = result { @@ -1102,7 +504,6 @@ pub fn run() { .with_assets(crate::assets::Assets) .run(move |cx: &mut App| { cx.set_app_identity(crate::profile::BUNDLE_ID, crate::profile::DISPLAY_NAME); - cx.set_quit_mode(gpui::QuitMode::Explicit); log::debug!("startup: gpui app ready at {} ms", crate::startup_elapsed()); if let Err(error) = cx.text_system().add_fonts( crate::assets::FONTS @@ -1113,7 +514,7 @@ pub fn run() { log::warn!("failed to register bundled fonts: {error}"); } ui::spell::preload(); - cx.on_action(|_: &QuitApp, cx| quit_app(cx)); + cx.on_action(|_: &QuitApp, cx| cx.quit()); #[cfg(target_os = "macos")] { cx.on_action(|_: &Hide, cx| cx.hide()); @@ -1136,36 +537,14 @@ pub fn run() { cx.on_system_notification_response(|response, cx| { cx.activate(true); let session_id = response.tag.strip_prefix("task:").map(str::to_owned); - let remote_task = response - .tag - .strip_prefix("remote-task:") - .and_then(|tag| tag.split_once(':')) - .map(|(host, task)| (host.to_owned(), task.to_owned())); for window in cx.windows() { window .update(cx, |root, window, cx| { window.activate_window(); - if let Ok(app) = root.downcast::() { - app.update(cx, |app, cx| { - if let Some((host, task)) = &remote_task { - app.navigate( - Navigate { - computer: Some(host.clone()), - destination: Destination::Task(task.clone()), - }, - cx, - ); - } else if let Some(task) = &session_id { - app.navigate( - Navigate { - computer: None, - destination: Destination::Task(task.clone()), - }, - cx, - ); - } - cx.notify(); - }); + if let (Some(session_id), Ok(app)) = + (session_id.as_deref(), root.downcast::()) + { + app.update(cx, |app, cx| app.open_task(session_id, cx)); } }) .ok(); @@ -1184,47 +563,15 @@ pub fn run() { } else { WindowBounds::Windowed(bounds) }; - let remote_enabled = Arc::new(std::sync::atomic::AtomicBool::new(false)); - cx.set_global(DesktopLifecycle { - backend: backend.clone(), - remote_enabled: remote_enabled.clone(), - can_background: false, - closing: Default::default(), - quit_stop_attempted: Default::default(), - }); - let quit_remote_enabled = remote_enabled.clone(); let quit_backend = backend.clone(); - cx.on_app_quit(move |cx| { - let stop_attempted = cx - .try_global::() - .is_some_and(|state| state.quit_stop_attempted.get()); + cx.on_app_quit(move |_cx| { let backend = quit_backend.clone(); - let remote_enabled = quit_remote_enabled.clone(); async move { persist_window_state(); - if stop_attempted { - return; - } - if remote_enabled.load(std::sync::atomic::Ordering::Acquire) { - let api = backend.api_url().to_owned(); - let _ = backend - .spawn(async move { crate::host::request_stop(&api).await }) - .await; - } else { - let _ = backend.detach().await; - } + let _ = backend.detach().await; } }) .detach(); - #[cfg(any(target_os = "linux", target_os = "windows"))] - let (tray, tray_events) = match crate::tray::StatusIcon::new() { - Ok((tray, events)) => (Some(tray), Some(events)), - Err(error) => { - log::warn!("{error}"); - (None, None) - } - }; - let root_enabled = remote_enabled.clone(); let root_backend = backend.clone(); let root_settings = startup_settings.clone(); let window = cx @@ -1234,16 +581,6 @@ pub fn run() { screen: Screen::Restoring, user_id: None, parked_chat: None, - local_chat: None, - selected_host: None, - remote_computers: Default::default(), - next_connection_attempt: 0, - requested_navigation: None, - computers_sync_pending: false, - remote_enabled: root_enabled, - #[cfg(any(target_os = "linux", target_os = "windows"))] - tray, - remote_bridges: std::cell::RefCell::new(Vec::new()), settings: root_settings, shortcuts: shortcut_runtime, titlebar: cx.new(|_| TitleBar::new(ui::titlebar::WINDOW_TITLE)), @@ -1306,17 +643,14 @@ pub fn run() { .detach(); } let root = window - .update(cx, |app, window, cx| { + .update(cx, |_, window, cx| { // The only window: closing it from the window manager // ends the app the same way the title bar button does. - #[cfg(target_os = "macos")] - let can_background = { - let _ = app; + window.on_window_should_close(cx, |_window, cx| { + persist_window_state(); + cx.quit(); true - }; - #[cfg(any(target_os = "linux", target_os = "windows"))] - let can_background = app.tray.is_some(); - install_close_behavior(window, cx, remote_enabled.clone(), can_background); + }); // A system theme change must reach every view. window .observe_window_appearance(|_window, cx| cx.refresh_windows()) @@ -1325,76 +659,6 @@ pub fn run() { }) .expect("root entity"); - // Local status is cheap and does not contact the enrollment - // service. This also notices changes made through the CLI. - let status_backend = backend.clone(); - let status_root = root.clone(); - cx.spawn(async move |cx| { - loop { - let account = status_root.update(cx, |app, _| app.user_id.clone()); - let backend = status_backend.clone(); - let task = status_backend.spawn(async move { - backend - .remote_setting::("remote_status", ()) - .await - }); - if let Ok(Ok(status)) = task.await { - status_root.update(cx, |app, cx| { - if app.user_id == account && account.is_some() { - if let Screen::Settings(settings) = &app.screen { - settings.update(cx, |settings, cx| { - settings.update_remote_status(status.clone(), cx) - }); - } - app.update_computers(status, cx); - } - }); - } - cx.background_executor() - .timer(std::time::Duration::from_secs(5)) - .await; - } - }) - .detach(); - #[cfg(any(target_os = "linux", target_os = "windows"))] - if let Some(mut events) = tray_events { - let tray_root = root.clone(); - let enabled = remote_enabled.clone(); - cx.spawn(async move |cx| { - while let Some(action) = events.recv().await { - match action { - crate::tray::Action::Quit => { - cx.update(quit_app); - } - crate::tray::Action::Open => { - cx.update(|cx| { - if cx.windows().is_empty() { - let root = tray_root.clone(); - let enabled = enabled.clone(); - let bounds = - Bounds::centered(None, size(px(1280.), px(860.)), cx); - let _ = cx.open_window( - main_window_options(WindowBounds::Windowed(bounds)), - move |window, cx| { - install_close_behavior(window, cx, enabled, true); - root - }, - ); - } else { - for window in cx.windows() { - let _ = window.update(cx, |_, window, _| { - window.activate_window() - }); - } - } - }); - } - } - } - }) - .detach(); - } - // The event pump runs once for the whole process and routes events to // whichever screen is active. It exits when the root entity is gone. let (spawn_backend, take_backend) = (backend.clone(), backend.clone()); @@ -1426,58 +690,12 @@ pub fn run() { #[cfg(test)] mod tests { - #[test] - fn computer_connection_retries_are_independent_and_not_started_twice() { - let now = std::time::Instant::now(); - let mut computer = super::RemoteComputer { - name: "Office".into(), - grant: [0; 16], - chat: None, - attempt: super::ConnectionAttempt::Idle, - }; - assert!(computer.should_connect(now)); - computer.attempt = super::ConnectionAttempt::Connecting(1); - assert!(!computer.should_connect(now)); - computer.attempt = super::ConnectionAttempt::Failed { - message: "Offline".into(), - retry_at: now + std::time::Duration::from_secs(30), - }; - assert!(!computer.should_connect(now)); - assert!(computer.should_connect(now + std::time::Duration::from_secs(30))); - let other = super::RemoteComputer { - name: "Laptop".into(), - grant: [1; 16], - chat: None, - attempt: super::ConnectionAttempt::Idle, - }; - assert!(other.should_connect(now)); - } - use super::*; fn bounds(width: f32, height: f32) -> Bounds { Bounds::new(gpui::point(px(0.), px(0.)), size(px(width), px(height))) } - #[gpui::test] - fn failed_tray_quit_opens_a_visible_confirmation_without_an_existing_window( - cx: &mut gpui::TestAppContext, - ) { - assert!(cx.windows().is_empty()); - cx.update(show_quit_warning); - assert_eq!(cx.windows().len(), 1); - let mut view = gpui::VisualTestContext::from_window(cx.windows()[0], cx); - view.run_until_parked(); - assert!(view.debug_bounds("quit-maple-anyway").is_some()); - let keep = view.debug_bounds("keep-maple-open").unwrap(); - view.simulate_click(keep.center(), gpui::Modifiers::default()); - view.run_until_parked(); - assert!( - cx.windows().is_empty(), - "Keep open dismisses the confirmation, not the app" - ); - } - #[test] fn fullscreen_does_not_replace_the_windowed_state() { let windowed = window_state_for(WindowBounds::Windowed(bounds(1280., 860.))).unwrap(); diff --git a/apps/maple-agent/app/src/host.rs b/apps/maple-agent/app/src/host.rs index 70aa37475..e9ce67e47 100644 --- a/apps/maple-agent/app/src/host.rs +++ b/apps/maple-agent/app/src/host.rs @@ -1,7 +1,7 @@ //! One on-demand process owns a profile's backend. Frontends attach over //! OS-private IPC; closing a frontend never tears down another client's runtime. -pub(crate) mod ipc; +mod ipc; pub mod protocol; pub(crate) mod receipts; #[cfg(test)] @@ -74,13 +74,6 @@ impl RemoteClient { Ok(Self::attach(endpoint, connection, welcome)) } - pub async fn connect_remote(api_url: &str, peer: String) -> Result { - let mut endpoint = ipc::Endpoint::new(api_url)?; - endpoint.remote_host = Some(peer); - let (connection, welcome) = connect(&endpoint, ConnectionMode::Client, true).await?; - Ok(Self::attach(endpoint, connection, welcome)) - } - fn attach(endpoint: ipc::Endpoint, connection: ipc::Connection, welcome: Welcome) -> Self { let state = Arc::new(ClientState { pending: Mutex::new(Some(HashMap::new())), @@ -125,16 +118,6 @@ impl RemoteClient { &self, method: &str, params: impl Serialize, - ) -> Result { - let send_id = (method == "send_message").then(|| uuid::Uuid::new_v4().to_string()); - self.call_identified(method, params, send_id).await - } - - pub async fn call_identified( - &self, - method: &str, - params: impl Serialize, - send_id: Option, ) -> Result { let params = serde_json::to_value(params) .map_err(|_| "Could not encode host request".to_string())?; @@ -160,6 +143,7 @@ impl RemoteClient { state: &self.state, id, }; + let send_id = (method == "send_message").then(|| uuid::Uuid::new_v4().to_string()); let sent = self .requests .send(Request { @@ -173,11 +157,6 @@ impl RemoteClient { self.state.disconnect(); } let result = match (sent, reply.await) { - (Ok(()), Ok(Err(error))) - if error.starts_with("Send outcome unknown") && send_id.is_some() => - { - recover_send(&self.endpoint, send_id.as_deref().unwrap()).await? - } (Ok(()), Ok(result)) => result?, (_, _) => match send_id { Some(id) => recover_send(&self.endpoint, &id).await?, @@ -211,27 +190,11 @@ async fn maintain_connection( .send(AgentServiceEvent::HostReconnecting) .await .map_err(|_| ())?; - let mut delay = Duration::from_millis(250); loop { - if requests.is_closed() || events.is_closed() { - return Err(()); - } // No startup arbitration or spawn here: explicit host stop and // crashes must never be undone by a frontend reconnect loop. - let connected = connect(&endpoint, ConnectionMode::Client, false).await; - if endpoint.remote_host.is_some() - && connected - .as_ref() - .err() - .is_some_and(|error| error.starts_with("Remote access ended:")) + if let Ok((next, welcome)) = connect(&endpoint, ConnectionMode::Client, false).await { - let reason = connected.err().unwrap(); - let _ = events - .send(AgentServiceEvent::RemoteAccessEnded(reason)) - .await; - return Err(()); - } - if let Ok((next, welcome)) = connected { *state.user_id.write().unwrap() = welcome.user_id.clone(); events .send(AgentServiceEvent::AccountChanged(welcome.user_id)) @@ -246,19 +209,11 @@ async fn maintain_connection( .map_err(|_| ())?; return Ok::<_, ()>(next); } - tokio::time::sleep(delay).await; - delay = (delay * 2).min(Duration::from_secs(5)); + tokio::time::sleep(Duration::from_millis(100)).await; } }; - let recovered = if endpoint.remote_host.is_some() { - recovery.await - } else { - tokio::time::timeout(RECONNECT_TIMEOUT, recovery) - .await - .unwrap_or(Err(())) - }; - match recovered { - Ok(next) => connection = next, + match tokio::time::timeout(RECONNECT_TIMEOUT, recovery).await { + Ok(Ok(next)) => connection = next, _ => { state.disconnect(); let _ = events.send(AgentServiceEvent::HostDisconnected).await; @@ -320,10 +275,6 @@ async fn client_connection( } } message = incoming.recv() => match message { - Some(Message::Closed { reason }) => { - let _ = events.send(AgentServiceEvent::RemoteAccessEnded(reason)).await; - return true; - } Some(Message::Response { id, result }) => { if let Some(sender) = state.pending.lock().unwrap().as_mut().and_then(|pending| pending.remove(&id)) { let _ = sender.send(result); @@ -341,20 +292,10 @@ async fn client_connection( } } -#[cfg(test)] async fn handshake( - stream: ipc::Connection, - profile: &str, - mode: ConnectionMode, -) -> Result<(ipc::Connection, Welcome), String> { - handshake_scoped(stream, profile, mode, None).await -} - -async fn handshake_scoped( mut stream: ipc::Connection, profile: &str, mode: ConnectionMode, - remote_host: Option, ) -> Result<(ipc::Connection, Welcome), String> { protocol::write( &mut stream, @@ -363,7 +304,6 @@ async fn handshake_scoped( profile: profile.into(), build: protocol::BUILD.into(), mode, - remote_host, }, ) .await?; @@ -386,17 +326,9 @@ async fn connect( start: bool, ) -> Result<(ipc::Connection, Welcome), String> { if let Ok(stream) = endpoint.connect().await { - return tokio::time::timeout( - START_TIMEOUT, - handshake_scoped( - stream, - &endpoint.profile, - mode, - endpoint.remote_host.clone(), - ), - ) - .await - .map_err(|_| "Maple host did not answer".to_string())?; + return tokio::time::timeout(START_TIMEOUT, handshake(stream, &endpoint.profile, mode)) + .await + .map_err(|_| "Maple host did not answer".to_string())?; } if !start { return Err("No Maple host is running for this profile".into()); @@ -413,33 +345,18 @@ async fn connect( } } if let Ok(stream) = endpoint.connect().await { - return tokio::time::timeout_at( - deadline, - handshake_scoped( - stream, - &endpoint.profile, - mode, - endpoint.remote_host.clone(), - ), - ) - .await - .map_err(|_| "Maple host did not answer".to_string())?; + return tokio::time::timeout_at(deadline, handshake(stream, &endpoint.profile, mode)) + .await + .map_err(|_| "Maple host did not answer".to_string())?; } spawn_host()?; let deadline = tokio::time::Instant::now() + START_TIMEOUT; loop { if let Ok(stream) = endpoint.connect().await { - let result = tokio::time::timeout_at( - deadline, - handshake_scoped( - stream, - &endpoint.profile, - mode, - endpoint.remote_host.clone(), - ), - ) - .await - .map_err(|_| "Maple host did not answer".to_string())?; + let result = + tokio::time::timeout_at(deadline, handshake(stream, &endpoint.profile, mode)) + .await + .map_err(|_| "Maple host did not answer".to_string())?; drop(startup); return result; } @@ -529,7 +446,6 @@ async fn serve(endpoint: ipc::Endpoint, backend: Arc) -> Result<() let receipts = Arc::new(receipts::Receipts::open( &backend::local::host_data_root().join("send-receipts.db"), )?); - receipts.recover_after_restart()?; serve_with_receipts(endpoint, backend, receipts).await } @@ -545,19 +461,9 @@ async fn serve_with_receipts( .take_events() .await .ok_or("Host event source already taken")?; - let remote = - crate::remote::manager::Manager::new(backend.clone(), events.clone(), receipts.clone()); - let resume = remote.clone(); - tokio::spawn(async move { - resume.restore().await; - }); let sink = events.clone(); - let account_remote = remote.clone(); let pump = tokio::spawn(async move { while let Some(event) = source.recv().await { - if matches!(event, AgentServiceEvent::AccountChanged(_)) { - account_remote.shutdown().await; - } let _ = sink.send(Arc::new(event)); } }); @@ -570,10 +476,7 @@ async fn serve_with_receipts( tokio::select! { _ = stop_rx.recv() => break, _ = &mut shutdown => break, - _ = tokio::time::sleep_until(idle), if clients.is_empty() => { - if remote.stays_running() { idle = tokio::time::Instant::now() + Duration::from_secs(1); } - else { break; } - }, + _ = tokio::time::sleep_until(idle), if clients.is_empty() => break, result = clients.join_next(), if !clients.is_empty() => { if matches!(result, Some(Ok(Ok(false))) | Some(Err(_))) { recovery_until = tokio::time::Instant::now() + DISCONNECT_GRACE; @@ -586,9 +489,8 @@ async fn serve_with_receipts( let connection = match incoming { Ok(connection) => connection, Err(error) => { log::warn!("Host rejected a connection: {error}"); continue; } }; if clients.len() >= MAX_CLIENTS { drop(connection); continue; } let backend = backend.clone(); let endpoint = endpoint.clone(); let events = events.clone(); let stop = stop_tx.clone(); let receipts = receipts.clone(); - let remote = remote.clone(); clients.spawn(async move { - serve_client(connection, endpoint, backend, events, stop, receipts, remote).await + serve_client(connection, endpoint, backend, events, stop, receipts).await }); } } @@ -596,7 +498,6 @@ async fn serve_with_receipts( // Stop accepting first, then stop/drain the runtime while clients can still // receive its final updates. Only this owner shuts down shared children. drop(listener); - remote.shutdown().await; backend.shutdown_host().await; clients.abort_all(); while clients.join_next().await.is_some() {} @@ -611,22 +512,13 @@ async fn serve_client( events: broadcast::Sender>, stop: mpsc::Sender<()>, receipts: Arc, - remote: Arc, ) -> Result { let mut subscription = events.subscribe(); - let (mode, link) = accept_scoped_handshake( - &mut connection, - &endpoint.profile, - backend.saved_user_id(), - Some(&remote), - Some(&stop), - ) - .await?; - if let Some(link) = link { - return crate::remote::control::bridge(connection, link).await; - } + let mode = + accept_handshake(&mut connection, &endpoint.profile, backend.saved_user_id()).await?; match mode { ConnectionMode::Stop => { + let _ = stop.send(()).await; return std::future::pending().await; } ConnectionMode::Acp => { @@ -664,22 +556,12 @@ async fn serve_client( return Ok(true); } let backend = backend.clone(); let responses = responses.clone(); let events = events.clone(); let receipts = receipts.clone(); - let remote = remote.clone(); calls.spawn(async move { let id = request.id; let before = backend.saved_user_id(); - let result = if request.method.starts_with("remote_") { - remote.dispatch(request).await - } else { dispatch_with_receipt(&backend, request, receipts).await }; + let result = dispatch_with_receipt(&backend, request, receipts).await; let after = backend.saved_user_id(); - if before != after { - remote.shutdown().await; - if after.is_some() { - let resume = remote.clone(); - tokio::spawn(async move { resume.restore().await; }); - } - let _ = events.send(Arc::new(AgentServiceEvent::AccountChanged(after))); - } + if before != after { let _ = events.send(Arc::new(AgentServiceEvent::AccountChanged(after))); } let _ = responses.send(Message::Response { id, result }).await; }); } @@ -705,24 +587,11 @@ async fn serve_client( Ok(result.unwrap_or(false)) } -#[cfg(test)] async fn accept_handshake( connection: &mut ipc::Connection, profile: &str, user_id: Option, ) -> Result { - accept_scoped_handshake(connection, profile, user_id, None, None) - .await - .map(|(mode, _)| mode) -} - -async fn accept_scoped_handshake( - connection: &mut ipc::Connection, - profile: &str, - user_id: Option, - remote: Option<&Arc>, - stop: Option<&mpsc::Sender<()>>, -) -> Result<(ConnectionMode, Option), String> { let hello = tokio::time::timeout(START_TIMEOUT, protocol::read::(connection)) .await .map_err(|_| "Host handshake timed out".to_string())??; @@ -737,10 +606,6 @@ async fn accept_scoped_handshake( Some( "Incompatible Maple host or profile. Close the existing host or run `maple-agent host stop` for this profile, then reopen Maple.", ) - } else if hello.remote_host.is_some() - && (hello.mode != ConnectionMode::Client || remote.is_none()) - { - Some("Remote selection is available only to a desktop client") } else if hello.mode == ConnectionMode::Acp && user_id.is_none() { Some( "Maple ACP requires sign-in. Sign in through Maple Desktop or `maple-agent login` first.", @@ -752,24 +617,7 @@ async fn accept_scoped_handshake( protocol::write(connection, &Err::(error)).await?; return Err(error.into()); } - let link = match hello.remote_host { - Some(peer) => match remote - .expect("validated remote manager") - .connect(&peer) - .await - { - Ok(link) => Some(link), - Err(error) => { - protocol::write(connection, &Err::(&error)).await?; - return Err(error); - } - }, - None => None, - }; - // Always commit an authorized Stop, including when the caller leaves before - // its acknowledgement. Attempt the small reply first so shutdown cannot - // abort this task before an attached caller receives it. - let welcome = protocol::write( + protocol::write( connection, &Ok::<_, String>(Welcome { version: protocol::VERSION, @@ -778,45 +626,8 @@ async fn accept_scoped_handshake( user_id, }), ) - .await; - if hello.mode == ConnectionMode::Stop - && let Some(stop) = stop - { - stop.send(()) - .await - .map_err(|_| "Maple host is already stopping")?; - } - welcome?; - Ok((hello.mode, link)) -} - -#[cfg(feature = "desktop")] -pub async fn request_stop(api_url: &str) -> Result<(), String> { - let endpoint = ipc::Endpoint::new(api_url)?; - request_stop_endpoint(&endpoint).await -} - -#[cfg(feature = "desktop")] -async fn request_stop_endpoint(endpoint: &ipc::Endpoint) -> Result<(), String> { - match connect(endpoint, ConnectionMode::Stop, false).await { - Ok(_) => Ok(()), - Err(error) => { - // A missing/inaccessible endpoint alone does not prove the host is - // gone. The same ownership lock protects startup and the full drain. - let endpoint = endpoint.clone(); - let absent = tokio::task::spawn_blocking(move || { - let ownership = endpoint.lock("host.lock")?; - match ownership.try_lock() { - Ok(()) => Ok(true), - Err(std::fs::TryLockError::WouldBlock) => Ok(false), - Err(error) => Err(error.to_string()), - } - }) - .await - .map_err(|e| e.to_string())??; - if absent { Ok(()) } else { Err(error) } - } - } + .await?; + Ok(hello.mode) } pub fn stop(api_url: &str) -> Result<(), String> { @@ -883,45 +694,10 @@ async fn shutdown_signal() { let _ = tokio::signal::ctrl_c().await; } -pub(crate) async fn dispatch_remote_with_receipt( - backend: &LocalBackend, - request: Request, - receipts: Arc, - session: &maple_agent::maple_api::MapleApiSession, - account: &str, -) -> Result { - let _guard = backend.account_gate.read().await; - if !session.remote_session_active().await || backend.saved_user_id().as_deref() != Some(account) - { - return Err("The remote account changed".into()); - } - if request.method == "send_outcome" { - let id = request.params.as_str().ok_or("Invalid send identity")?; - receipts.authorize(id, account)?; - } else if request - .params - .pointer("/0") - .and_then(serde_json::Value::as_str) - != Some(account) - { - return Err("Remote request account mismatch".into()); - } - dispatch_receipt(backend, request, receipts, true).await -} - async fn dispatch_with_receipt( backend: &LocalBackend, request: Request, receipts: Arc, -) -> Result { - dispatch_receipt(backend, request, receipts, false).await -} - -async fn dispatch_receipt( - backend: &LocalBackend, - request: Request, - receipts: Arc, - remote_guarded: bool, ) -> Result { if request.method == "send_outcome" { let id: String = @@ -935,11 +711,7 @@ async fn dispatch_receipt( .map_err(|e| e.to_string())?; } if request.method != "send_message" { - return if remote_guarded { - backend::dispatch_inner(backend, request).await - } else { - backend::dispatch(backend, request).await - }; + return backend::dispatch(backend, request).await; } let id = request .send_id @@ -960,11 +732,7 @@ async fn dispatch_receipt( } None => {} } - let result = if remote_guarded { - backend::dispatch_inner(backend, request).await - } else { - backend::dispatch(backend, request).await - }; + let result = backend::dispatch(backend, request).await; let saved = result.clone(); tokio::task::spawn_blocking(move || receipts.finish(&id, &saved)) .await @@ -998,11 +766,10 @@ async fn recover_send(endpoint: &ipc::Endpoint, id: &str) -> Result break, + receipts::Outcome::Pending => break, } } Message::Event(_) => {} - Message::Closed { reason } => return Err(reason), } } tokio::time::sleep(Duration::from_millis(100)).await; @@ -1011,14 +778,7 @@ async fn recover_send(endpoint: &ipc::Endpoint, id: &str) -> Result Ok(result), Ok(Err(error)) if error.starts_with("The host did not admit") => Err(error), _ => Err(format!( @@ -1046,7 +806,6 @@ mod tests { profile: "same-storage-profile".into(), build: protocol::BUILD.into(), mode, - remote_host: None, }; match mismatch { "profile" => hello.profile = "different-backend".into(), diff --git a/apps/maple-agent/app/src/host/ipc.rs b/apps/maple-agent/app/src/host/ipc.rs index 10edb35dd..a896c717b 100644 --- a/apps/maple-agent/app/src/host/ipc.rs +++ b/apps/maple-agent/app/src/host/ipc.rs @@ -14,7 +14,6 @@ pub type Connection = Box; #[derive(Clone)] pub struct Endpoint { pub profile: String, - pub remote_host: Option, directory: PathBuf, address: PathBuf, } @@ -28,7 +27,6 @@ impl Endpoint { let address = PathBuf::from(format!(r"\\.\pipe\maple-test-{}", uuid::Uuid::new_v4())); Self { profile: "test-profile".into(), - remote_host: None, directory, address, } @@ -83,7 +81,6 @@ impl Endpoint { let address = PathBuf::from(format!(r"\\.\pipe\maple-agent-{namespace}")); Ok(Self { profile, - remote_host: None, directory, address, }) diff --git a/apps/maple-agent/app/src/host/protocol.rs b/apps/maple-agent/app/src/host/protocol.rs index e88eb926d..fffa5101c 100644 --- a/apps/maple-agent/app/src/host/protocol.rs +++ b/apps/maple-agent/app/src/host/protocol.rs @@ -15,8 +15,6 @@ pub struct Hello { pub profile: String, pub build: String, pub mode: ConnectionMode, - #[serde(default)] - pub remote_host: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -45,9 +43,6 @@ pub struct Request { #[derive(Serialize, Deserialize)] pub enum Message { - Closed { - reason: String, - }, Response { id: u64, result: Result, diff --git a/apps/maple-agent/app/src/host/receipts.rs b/apps/maple-agent/app/src/host/receipts.rs index 03b8b7779..a08855458 100644 --- a/apps/maple-agent/app/src/host/receipts.rs +++ b/apps/maple-agent/app/src/host/receipts.rs @@ -12,8 +12,6 @@ use std::{path::Path, sync::Mutex}; pub enum Outcome { Missing, Pending, - /// The previous owner stopped before recording the result. Effects may exist. - Interrupted, Finished(Result), } @@ -28,44 +26,13 @@ impl Receipts { "PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS sends ( id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, - task TEXT NOT NULL, result TEXT, account TEXT + task TEXT NOT NULL, result TEXT );", ) .map_err(|e| e.to_string())?; - let columns = connection - .prepare("PRAGMA table_info(sends)") - .map_err(|e| e.to_string())? - .query_map([], |row| row.get::<_, String>(1)) - .map_err(|e| e.to_string())? - .collect::, _>>() - .map_err(|e| e.to_string())?; - if !columns.iter().any(|name| name == "account") { - connection - .execute_batch("ALTER TABLE sends ADD COLUMN account TEXT") - .map_err(|e| e.to_string())?; - } - if !columns.iter().any(|name| name == "interrupted") { - connection - .execute_batch( - "ALTER TABLE sends ADD COLUMN interrupted INTEGER NOT NULL DEFAULT 0", - ) - .map_err(|e| e.to_string())?; - } Ok(Self(Mutex::new(connection))) } - /// Call once after taking exclusive host ownership, before accepting clients. - /// These sends may have taken effect. Keep their identities reserved forever; - /// expose uncertainty as settled so clients can continue without replaying. - pub fn recover_after_restart(&self) -> Result<(), String> { - self.0 - .lock() - .unwrap() - .execute("UPDATE sends SET interrupted=1 WHERE result IS NULL", []) - .map(|_| ()) - .map_err(|e| e.to_string()) - } - /// Returns None only to the first caller. Reusing an ID with different /// content is a caller error, not another prompt. pub fn reserve(&self, id: &str, params: &serde_json::Value) -> Result, String> { @@ -77,15 +44,15 @@ impl Receipts { .and_then(serde_json::Value::as_str) .ok_or("Invalid send parameters")?; let connection = self.0.lock().unwrap(); - let existing: Option<(String, Option, bool)> = connection + let existing: Option<(String, Option)> = connection .query_row( - "SELECT fingerprint, result, interrupted FROM sends WHERE id=?1", + "SELECT fingerprint, result FROM sends WHERE id=?1", [id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + |row| Ok((row.get(0)?, row.get(1)?)), ) .optional() .map_err(|e| e.to_string())?; - if let Some((saved, result, interrupted)) = existing { + if let Some((saved, result)) = existing { if saved != fingerprint { return Err("Send identity was reused for different content".into()); } @@ -96,21 +63,12 @@ impl Receipts { .map(Some) .map_err(|e| e.to_string()) }) - .unwrap_or(Ok(Some(if interrupted { - Outcome::Interrupted - } else { - Outcome::Pending - }))); + .unwrap_or(Ok(Some(Outcome::Pending))); } connection .execute( - "INSERT INTO sends(id,fingerprint,task,account) VALUES (?1,?2,?3,?4)", - params![ - id, - fingerprint, - task, - params.pointer("/0").and_then(serde_json::Value::as_str) - ], + "INSERT INTO sends(id,fingerprint,task) VALUES (?1,?2,?3)", + params![id, fingerprint, task], ) .map_err(|e| e.to_string())?; Ok(None) @@ -133,39 +91,20 @@ impl Receipts { Ok(()) } - pub fn authorize(&self, id: &str, account: &str) -> Result<(), String> { - let owner: Option> = self + pub fn outcome(&self, id: &str) -> Result { + let result: Option> = self .0 .lock() .unwrap() - .query_row("SELECT account FROM sends WHERE id=?1", [id], |row| { + .query_row("SELECT result FROM sends WHERE id=?1", [id], |row| { row.get(0) }) .optional() .map_err(|e| e.to_string())?; - if owner.is_some_and(|owner| owner.as_deref() != Some(account)) { - return Err("Send receipt is unavailable for this account".into()); - } - Ok(()) - } - - pub fn outcome(&self, id: &str) -> Result { - let result: Option<(Option, bool)> = self - .0 - .lock() - .unwrap() - .query_row( - "SELECT result, interrupted FROM sends WHERE id=?1", - [id], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional() - .map_err(|e| e.to_string())?; match result { None => Ok(Outcome::Missing), - Some((None, true)) => Ok(Outcome::Interrupted), - Some((None, false)) => Ok(Outcome::Pending), - Some((Some(result), _)) => serde_json::from_str(&result) + Some(None) => Ok(Outcome::Pending), + Some(Some(result)) => serde_json::from_str(&result) .map(Outcome::Finished) .map_err(|e| e.to_string()), } @@ -176,54 +115,6 @@ impl Receipts { mod tests { use super::*; - #[test] - fn remote_receipts_are_account_bound_and_legacy_rows_stay_local() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("receipts.db"); - let old = Connection::open(&path).unwrap(); - old.execute_batch("CREATE TABLE sends(id TEXT PRIMARY KEY, fingerprint TEXT NOT NULL, task TEXT NOT NULL, result TEXT); INSERT INTO sends VALUES ('legacy', 'f', 'task', NULL);").unwrap(); - drop(old); - let receipts = Receipts::open(&path).unwrap(); - assert!(receipts.authorize("legacy", "alice").is_err()); - assert_eq!(receipts.outcome("legacy").unwrap(), Outcome::Pending); - let id = uuid::Uuid::new_v4().to_string(); - receipts - .reserve(&id, &serde_json::json!(["alice", {"sessionId":"task"}])) - .unwrap(); - assert!(receipts.authorize(&id, "alice").is_ok()); - assert!(receipts.authorize(&id, "bob").is_err()); - assert!(receipts.authorize("missing", "bob").is_ok()); - } - - #[test] - fn new_owner_marks_unfinished_receipts_interrupted_without_releasing_their_ids() { - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("receipts.db"); - let id = uuid::Uuid::new_v4().to_string(); - let done = uuid::Uuid::new_v4().to_string(); - let params = - serde_json::json!(["alice", {"sessionId":"task", "text":"may already have run"}]); - let receipts = Receipts::open(&path).unwrap(); - receipts.reserve(&id, ¶ms).unwrap(); - receipts.reserve(&done, ¶ms).unwrap(); - let result = Ok(serde_json::json!("run-1")); - receipts.finish(&done, &result).unwrap(); - drop(receipts); // The owner died without recording the first result. - let receipts = Receipts::open(&path).unwrap(); - receipts.recover_after_restart().unwrap(); - assert_eq!(receipts.outcome(&id).unwrap(), Outcome::Interrupted); - assert_eq!( - receipts.reserve(&id, ¶ms).unwrap(), - Some(Outcome::Interrupted) - ); - assert_eq!(receipts.outcome(&done).unwrap(), Outcome::Finished(result)); - assert!(receipts.authorize(&id, "alice").is_ok()); - assert!(receipts.authorize(&id, "bob").is_err()); - let next = uuid::Uuid::new_v4().to_string(); - assert_eq!(receipts.reserve(&next, ¶ms).unwrap(), None); - assert_eq!(receipts.outcome(&next).unwrap(), Outcome::Pending); - } - #[test] fn lost_reply_and_incomplete_admission_survive_reopening_without_resending() { let directory = tempfile::tempdir().unwrap(); diff --git a/apps/maple-agent/app/src/host/recovery_tests.rs b/apps/maple-agent/app/src/host/recovery_tests.rs index 3c2985fcd..0594f2dbb 100644 --- a/apps/maple-agent/app/src/host/recovery_tests.rs +++ b/apps/maple-agent/app/src/host/recovery_tests.rs @@ -382,81 +382,3 @@ async fn graceful_detach_keeps_other_clients_running_and_last_quit_shuts_down() .unwrap(); assert!(endpoint.connect().await.is_err()); } - -#[tokio::test] -async fn remote_protocol_refusal_ends_recovery_without_replaying_or_reconnecting() { - let (_root, endpoint) = endpoint(); - let mut listener = endpoint.bind().unwrap(); - let (client, mut server) = client(&endpoint, &mut listener).await; - let mut events = client.take_events().await.unwrap(); - protocol::write( - &mut server, - &Message::Closed { - reason: "Update both computers".into(), - }, - ) - .await - .unwrap(); - assert!( - matches!(tokio::time::timeout(Duration::from_secs(2), events.recv()).await.unwrap(), - Some(AgentServiceEvent::RemoteAccessEnded(reason)) if reason == "Update both computers") - ); - assert!( - tokio::time::timeout(Duration::from_secs(2), events.recv()) - .await - .unwrap() - .is_none() - ); - assert!(client.call::<()>("send_message", ()).await.is_err()); - assert!( - tokio::time::timeout(Duration::from_millis(100), listener.accept()) - .await - .is_err() - ); -} - -#[tokio::test] -async fn explicit_stop_survives_a_client_that_leaves_before_the_acknowledgement() { - let (_root, endpoint) = endpoint(); - let mut listener = endpoint.bind().unwrap(); - let (stop, mut stopped) = mpsc::channel(1); - let client = async { - let mut client = endpoint.connect().await.unwrap(); - protocol::write( - &mut client, - &Hello { - version: 0, - profile: "older-profile".into(), - build: "older-build".into(), - mode: ConnectionMode::Stop, - remote_host: None, - }, - ) - .await - .unwrap(); - drop(client); - }; - let server = async { - let mut connection = listener.accept().await.unwrap(); - let _ = - accept_scoped_handshake(&mut connection, &endpoint.profile, None, None, Some(&stop)) - .await; - }; - tokio::time::timeout(Duration::from_secs(5), async { - tokio::join!(client, server); - stopped.recv().await.unwrap(); - }) - .await - .unwrap(); -} - -#[cfg(feature = "desktop")] -#[tokio::test] -async fn quit_accepts_a_missing_owner_but_not_an_unreachable_live_owner() { - let (_root, endpoint) = endpoint(); - let owner = endpoint.lock("host.lock").unwrap(); - owner.try_lock().unwrap(); - assert!(request_stop_endpoint(&endpoint).await.is_err()); - drop(owner); - assert!(request_stop_endpoint(&endpoint).await.is_ok()); -} diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index 94c1dd101..b0db23e3c 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -20,12 +20,9 @@ mod notify; #[cfg(feature = "desktop")] mod platform; mod profile; -mod remote; mod settings; #[cfg(feature = "desktop")] mod shortcuts; -#[cfg(all(feature = "desktop", any(target_os = "linux", target_os = "windows")))] -mod tray; #[cfg(feature = "desktop")] mod ui; #[cfg(feature = "desktop")] @@ -73,11 +70,6 @@ enum Mode { #[command(subcommand)] command: HostCommand, }, - /// Manage this computer's remote access and paired computers. - Remote { - #[command(subcommand)] - command: RemoteCommand, - }, /// Serve the Agent Client Protocol on stdio. /// /// Trailing arguments belong to the ACP client and are ignored. @@ -100,53 +92,6 @@ enum HostCommand { Stop, } -#[derive(Debug, Subcommand, PartialEq, Eq)] -enum RemoteCommand { - Status, - Enable, - Disable, - Discover, - /// Display a new five-minute, single-attempt code on this computer. - BeginPairing, - CancelPairing, - /// Pair with an endpoint from Discover; the code is prompted privately. - Pair { - endpoint: String, - }, - Revoke { - endpoint: String, - }, -} - -fn run_remote(command: RemoteCommand) -> Result<(), String> { - let backend = AgentBackend::new(configured_api_url(), String::new())?; - let (method, params) = match command { - RemoteCommand::Status => ("remote_status", serde_json::json!([])), - RemoteCommand::Enable => ("remote_enable", serde_json::json!([true])), - RemoteCommand::Disable => ("remote_enable", serde_json::json!([false])), - RemoteCommand::Discover => ("remote_discover", serde_json::json!([])), - RemoteCommand::BeginPairing => ("remote_begin_pairing", serde_json::json!([])), - RemoteCommand::CancelPairing => ("remote_cancel_pairing", serde_json::json!([])), - RemoteCommand::Pair { endpoint } => { - let code = rpassword::prompt_password("Code shown on the other computer: ") - .map_err(|_| "Could not read pairing code")?; - ( - "remote_pair", - serde_json::json!([endpoint, code.trim().to_ascii_uppercase()]), - ) - } - RemoteCommand::Revoke { endpoint } => ("remote_revoke", serde_json::json!([endpoint])), - }; - let value: serde_json::Value = backend - .runtime_handle() - .block_on(backend.remote_setting(method, params))?; - println!( - "{}", - serde_json::to_string_pretty(&value).map_err(|_| "Could not display remote status")? - ); - Ok(()) -} - /// Settings for `maple-agent login`. The password is always prompted for /// so it never lands in shell history or a process listing. #[derive(Debug, Clone, Default, PartialEq, Eq, Args)] @@ -242,13 +187,6 @@ fn main() { std::process::exit(1); } } - Some(Mode::Remote { command }) => { - init_logging(LogOutput::FileOnly); - if let Err(error) = run_remote(command) { - eprintln!("{error}"); - std::process::exit(1); - } - } Some(Mode::Acp { .. }) => { // stdout is the ACP channel. Logs go to the log file only, so // the client's stderr capture stays quiet. An inherited @@ -456,8 +394,7 @@ fn init_logging(output: LogOutput) { .open(log_dir.join("maple-agent.log")) .ok() }); - const DEFAULT_FILTER: &str = - "info,iroh=warn,iroh_relay=warn,noq=warn,noq_proto=warn,netwatch=warn,tracing::span=warn"; + const DEFAULT_FILTER: &str = "info"; let mut builder = match output { LogOutput::FileAndStderr => env_logger::Builder::from_env( env_logger::Env::default().default_filter_or(DEFAULT_FILTER), diff --git a/apps/maple-agent/app/src/remote/control.rs b/apps/maple-agent/app/src/remote/control.rs deleted file mode 100644 index 39e4c58d9..000000000 --- a/apps/maple-agent/app/src/remote/control.rs +++ /dev/null @@ -1,494 +0,0 @@ -//! Paired control and the A-daemon bridge. No prompt is queued or replayed by -//! this transport; accepted sends and their receipts belong exclusively to B. -use super::{ - manager::{Manager, Node}, - streams, wire, -}; -use crate::host::protocol::{self, Message, Request}; -use iroh::{ - EndpointId, - endpoint::{Connection, RecvStream, SendStream}, -}; -use maple_agent::agent::AgentServiceEvent; -use std::{sync::Arc, time::Duration}; -use tokio::sync::mpsc; - -async fn read_control_ack(reader: &mut (impl tokio::io::AsyncRead + Unpin)) -> Result<(), String> { - let accepted: Result<(), wire::Refusal> = tokio::time::timeout( - Duration::from_secs(10), - wire::read(reader, wire::MAX_PREFACE), - ) - .await - .map_err(|_| "Remote handshake timed out")? - .map_err(|error| match error { - wire::Error::InvalidFrame | wire::Error::Incompatible => { - format!("Remote access ended: {error}") - } - _ => error.to_string(), - })?; - accepted.map_err(wire::Refusal::into_error) -} - -pub(super) fn listen(node: Arc) { - tokio::spawn(async move { - let mut connections = tokio::task::JoinSet::new(); - loop { - tokio::select! { - _ = node.stopped.cancelled() => break, - _ = connections.join_next(), if !connections.is_empty() => {}, - incoming = node.endpoint.accept() => { - let Some(incoming) = incoming else { break; }; - let Ok(admission_slot) = node.admissions.clone().try_acquire_owned() else { incoming.refuse(); continue; }; - let node = node.clone(); - connections.spawn(async move { - let accepted = tokio::time::timeout(Duration::from_secs(15), async { - let connection = incoming.await.map_err(|_| "Remote handshake failed")?; - let peer = connection.remote_id(); - let (mut tx, mut rx) = connection.accept_bi().await.map_err(|_| "Remote handshake closed")?; - let outcome = async { - let hello = wire::read_hello(&mut rx).await?; - node.ensure_valid().await?; - if hello.account != node.account || hello.project != node.project || hello.deployment != node.deployment || hello.channel != crate::profile::PROFILE { - return Err(wire::Refusal::terminal("Remote account or deployment mismatch")); - } - match hello.mode { - wire::Mode::Pair { window, admission } => { - node.accept_pairing(peer, window, admission, &mut tx, &mut rx).await?; - let _ = tx.finish(); - let _ = tokio::time::timeout(Duration::from_secs(3), tx.stopped()).await; - let names = node.clone(); - tokio::spawn(async move { names.refresh_peer_names().await; }); - Ok(None) - } - wire::Mode::ConfirmGrant { window } => { - let answer = { - let store = node.store.lock().unwrap(); - if store.peers().get(&peer.to_string()).is_some_and(|p| p.controls_this_computer) && store.peers().get(&peer.to_string()).is_some_and(|p| p.grant == window) { - Ok(window) - } else { Err(wire::Refusal::terminal("No current grant matches this pairing")) } - }; - wire::write(&mut tx, &answer, wire::MAX_PREFACE).await.map_err(|e| e.to_string())?; - let _ = tx.finish(); - let _ = tokio::time::timeout(Duration::from_secs(3), tx.stopped()).await; - Ok(None) - } - wire::Mode::Control => { - if !node.store.lock().unwrap().permits(peer) { return Err(wire::Refusal::terminal("Remote control grant is missing or revoked")); } - let slot = node.controllers.clone().try_acquire_owned().map_err(|_| "Remote connection limit reached")?; - wire::write(&mut tx, &Ok::<(), wire::Refusal>(()), wire::MAX_PREFACE).await.map_err(|e| e.to_string())?; - Ok(Some(slot)) - } - } - }.await; - // Return a terminal refusal while the stream is - // still alive, so a revoked/incompatible peer does - // not retry it as a transient network outage. - if let Err(error) = &outcome { - let _ = wire::write(&mut tx, &Err::<(), _>(error), wire::MAX_PREFACE).await; - let _ = tx.finish(); - let _ = tokio::time::timeout(Duration::from_secs(2), tx.stopped()).await; - } - outcome.map(|slot| slot.map(|slot| (connection, peer, tx, rx, slot))) - }).await; - drop(admission_slot); - if let Ok(Err(error)) = &accepted { log::debug!("Remote handshake declined: {error}"); } - if accepted.is_err() { log::debug!("Remote handshake timed out"); } - if let Ok(Ok(Some((connection, peer, tx, rx, _slot)))) = accepted { - serve(node, peer, connection, tx, rx).await; - } - }); - } - } - } - connections.abort_all(); - }); -} - -async fn serve( - node: Arc, - peer: EndpointId, - connection: Connection, - mut tx: SendStream, - _rx: RecvStream, -) { - let _questions = node.backend.question_responder(); - let mut events = node.events.subscribe(); - let mut revoked = node.revoked.subscribe(); - let capacity = Arc::new(streams::Capacity::new()); - let mut calls = tokio::task::JoinSet::new(); - // A slow event consumer cannot hold the request accept/dispatch loop. - let event_node = node.clone(); - let _ = tx.set_priority(1); - let mut event_writer = tokio::task::JoinSet::new(); - event_writer.spawn(async move { - loop { - let event = events - .recv() - .await - .map_err(|_| "Remote event stream fell behind")?; - event_node.ensure_valid().await?; - if !event_node.store.lock().unwrap().permits(peer) - || matches!(&*event, AgentServiceEvent::AccountChanged(_)) - { - return Ok::<(), String>(()); - } - if remote_event(&event) { - send(&mut tx, &Message::Event(Box::new((*event).clone()))).await?; - } - } - }); - let mut tick = tokio::time::interval(Duration::from_secs(1)); - loop { - tokio::select! { - _ = node.stopped.cancelled() => break, - _ = connection.closed() => break, - _ = event_writer.join_next() => break, - _ = tick.tick() => { - if !node.valid().await || !node.store.lock().unwrap().permits(peer) { break; } - } - event = revoked.recv() => { - if event.is_err() || event.ok().is_some_and(|key| key == peer || key == node.endpoint.id()) { break; } - } - _ = calls.join_next(), if !calls.is_empty() => {}, - incoming = connection.accept_bi() => { - let Ok((mut tx, mut rx)) = incoming else { break; }; - if calls.len() >= streams::MAX_REQUESTS { - let _ = tx.reset(1u32.into()); - let _ = rx.stop(1u32.into()); - continue; - } - let node = node.clone(); - let capacity = capacity.clone(); - calls.spawn(async move { - if let Err(error) = serve_request(node, peer, tx, rx, capacity).await { - log::debug!("Remote request stream ended: {error}"); - } - }); - } - } - } - event_writer.abort_all(); - connection.close(0u32.into(), b"remote stream closed"); - // Closing streams stops incomplete transfers. An already admitted mutation - // still finishes on B, as with local IPC; A never resends it. - while calls.join_next().await.is_some() {} -} - -async fn serve_request( - node: Arc, - peer: EndpointId, - mut tx: SendStream, - mut rx: RecvStream, - capacity: Arc, -) -> Result<(), String> { - let header = match streams::Header::read(&mut rx).await { - Ok(header) => header, - Err(reason) => return Err(reason), - }; - tx.set_priority(if header.method.urgent() { 10 } else { 0 }) - .map_err(|_| "Remote reply closed")?; - let _slot = match capacity.acquire(header.method) { - Ok(slot) => slot, - Err(error) => { - return streams::reply( - &mut tx, - &Message::Response { - id: header.id, - result: Err(error), - }, - ) - .await; - } - }; - let id = header.id; - let method = header.method; - let request = match header.body(&mut rx).await { - Ok(request) => request, - Err(reason) => { - return streams::reply( - &mut tx, - &Message::Response { - id, - result: Err(reason), - }, - ) - .await; - } - }; - // Recheck after the potentially slow upload, before any backend effect. - node.ensure_valid().await?; - if !node.store.lock().unwrap().permits(peer) { - return Err("Remote access revoked".into()); - } - let id = request.id; - let result = crate::host::dispatch_remote_with_receipt( - &node.backend, - Request { - id, - method: if request.method == wire::Method::TaskBootstrap { - "start_runtime".into() - } else { - request.method.name() - }, - send_id: request.send_id, - params: if request.method == wire::Method::TaskBootstrap { - serde_json::json!([node.account, null]) - } else { - request.params - }, - }, - node.receipts.clone(), - &node.session, - &node.account, - ) - .await; - if node.valid().await && node.store.lock().unwrap().permits(peer) { - let result = streams::encode_result(method, result); - streams::reply(&mut tx, &Message::Response { id, result }).await?; - } - Ok(()) -} - -pub(super) fn remote_event(event: &AgentServiceEvent) -> bool { - // Exhaustive on purpose: new local events need an explicit remote decision. - match event { - AgentServiceEvent::RuntimeStatus(_) - | AgentServiceEvent::Question { .. } - | AgentServiceEvent::QuestionResolved { .. } - | AgentServiceEvent::ThinkingChanged { .. } - | AgentServiceEvent::SessionCreated(_) - | AgentServiceEvent::SessionUpdated { .. } - | AgentServiceEvent::TimelineItem { .. } - | AgentServiceEvent::Run { .. } - | AgentServiceEvent::SideQuestion { .. } => true, - AgentServiceEvent::AccountChanged(_) - | AgentServiceEvent::RemoteAccessEnded(_) - | AgentServiceEvent::HostReconnecting - | AgentServiceEvent::HostReconnected - | AgentServiceEvent::HostDisconnected => false, - } -} - -async fn send(tx: &mut SendStream, message: &Message) -> Result<(), String> { - tokio::time::timeout( - Duration::from_secs(10), - wire::write(tx, message, wire::MAX_FRAME), - ) - .await - .map_err(|_| "Remote consumer stopped reading")? - .map_err(|e| e.to_string()) -} - -pub struct Link { - pub node: Arc, - pub peer: EndpointId, - pub connection: Connection, - pub rx: RecvStream, -} - -impl Manager { - pub async fn connect(self: &Arc, peer: &str) -> Result { - let peer: EndpointId = peer - .parse() - .map_err(|_| "Invalid remote computer identity")?; - let node = self.active().await?; - if !node - .store - .lock() - .unwrap() - .peers() - .get(&peer.to_string()) - .is_some_and(|p| p.can_connect) - { - return Err("Remote access ended: pair this computer before connecting".into()); - } - let (address, _) = node.destination(peer).await?; - let connection = tokio::time::timeout( - Duration::from_secs(10), - node.endpoint.connect(address, wire::ALPN), - ) - .await - .map_err(|_| "Remote computer is not reachable")? - .map_err(|_| "Remote computer is not reachable")?; - if connection.remote_id() != peer { - return Err("Remote identity changed".into()); - } - let (mut tx, mut rx) = connection - .open_bi() - .await - .map_err(|_| "Remote connection closed")?; - wire::write(&mut tx, &node.hello(wire::Mode::Control), wire::MAX_PREFACE) - .await - .map_err(|e| e.to_string())?; - read_control_ack(&mut rx).await?; - node.ensure_valid().await?; - tx.finish().map_err(|_| "Remote handshake closed")?; - Ok(Link { - node, - peer, - connection, - rx, - }) - } -} - -pub async fn bridge( - connection: crate::host::ipc::Connection, - mut link: Link, -) -> Result { - let (mut local_rx, mut local_tx) = tokio::io::split(connection); - let (frames, mut frame_rx) = mpsc::channel(16); - let mut revoked = link.node.revoked.subscribe(); - let mut readers = tokio::task::JoinSet::new(); - readers.spawn(async move { - loop { - let message = match wire::read::(&mut link.rx, wire::MAX_FRAME).await { - Ok(Message::Event(event)) if !remote_event(&event) => Message::Closed { - reason: "Unexpected remote event; update both computers".into(), - }, - Ok(message @ (Message::Event(_) | Message::Closed { .. })) => message, - Ok(Message::Response { .. }) => Message::Closed { - reason: "Unexpected reply on the remote event stream".into(), - }, - Err(wire::Error::Disconnected) => break, - Err(error) => Message::Closed { - reason: error.to_string(), - }, - }; - let terminal = matches!(message, Message::Closed { .. }); - if frames.send(message).await.is_err() || terminal { - break; - } - } - }); - let (requests, mut request_rx) = mpsc::channel(1); - readers.spawn(async move { - while let Ok(request) = protocol::read::(&mut local_rx).await { - if requests.send(request).await.is_err() { - break; - } - } - }); - let capacity = streams::Capacity::new(); - let mut calls = tokio::task::JoinSet::new(); - let mut tick = tokio::time::interval(Duration::from_secs(1)); - let result = async { - loop { - tokio::select! { - _ = link.node.stopped.cancelled() => return Ok(false), - _ = link.connection.closed() => return Ok(false), - _ = tick.tick() => link.node.ensure_valid().await?, - change = revoked.recv() => { - if change.is_err() || change.ok() == Some(link.peer) { return Ok(false); } - } - Some(completed) = calls.join_next(), if !calls.is_empty() => { - let frame = completed.map_err(|_| "Remote request interrupted")?; - link.node.ensure_valid().await?; - let terminal = matches!(frame, Message::Closed { .. }); - local_send(&mut local_tx, &frame).await?; - if terminal { return Ok(true); } - } - frame = frame_rx.recv() => { - let Some(frame) = frame else { return Ok(false); }; - link.node.ensure_valid().await?; - let terminal = matches!(frame, Message::Closed { .. }); - local_send(&mut local_tx, &frame).await?; - if terminal { return Ok(true); } - } - request = request_rx.recv() => { - let Some(request) = request else { return Ok(true); }; - if request.method == "detach" { - local_send(&mut local_tx, &Message::Response { id: request.id, result: Ok(serde_json::Value::Null) }).await?; - return Ok(true); - } - let selected = if request.method == "start_runtime" { Ok(wire::Method::TaskBootstrap) } else { wire::Method::parse(&request.method) }; - let method = match selected { - Ok(method) => method, - Err(error) => { local_send(&mut local_tx, &Message::Response { id: request.id, result: Err(error) }).await?; continue; } - }; - link.node.ensure_valid().await?; - let slot = match capacity.acquire(method) { - Ok(slot) => slot, - Err(error) => { - local_send(&mut local_tx, &Message::Response { id: request.id, result: Err(error) }).await?; - continue; - } - }; - let connection = link.connection.clone(); - calls.spawn(async move { - let _slot = slot; - let id = request.id; - let send_id = request.send_id.clone(); - match streams::call(&connection, wire::Command { id, method, send_id: request.send_id, params: request.params }).await { - Ok(frame) => streams::decode_result(method, frame), - Err(error) => Message::Response { id, result: Err(match send_id { - Some(id) => format!("Send outcome unknown ({id}). {error}. Checking the host receipt is safe; resending is not."), - None => error, - }) }, - } - }); - } - } - } - }.await; - readers.abort_all(); - link.connection.close(0u32.into(), b"viewer disconnected"); - result -} - -async fn local_send( - tx: &mut (impl tokio::io::AsyncWrite + Unpin), - message: &Message, -) -> Result<(), String> { - tokio::time::timeout(streams::IO_TIMEOUT, protocol::write(tx, message)) - .await - .map_err(|_| "Local remote viewer stopped reading")? -} - -#[cfg(test)] -mod tests { - use super::*; - use tokio::io::AsyncWriteExt; - - #[tokio::test] - async fn control_handshake_retries_disconnect_but_not_invalid_frames_or_revocation() { - let (mut tx, mut rx) = tokio::io::duplex(1024); - tx.write_u32(32).await.unwrap(); - tx.write_all(b"{\"Ok\":").await.unwrap(); - drop(tx); - assert_eq!( - read_control_ack(&mut rx).await.unwrap_err(), - "Remote computer disconnected" - ); - let (mut tx, mut rx) = tokio::io::duplex(1024); - wire::write( - &mut tx, - &serde_json::json!({"unexpected": true}), - wire::MAX_PREFACE, - ) - .await - .unwrap(); - assert!( - read_control_ack(&mut rx) - .await - .unwrap_err() - .starts_with("Remote access ended:") - ); - let (mut tx, mut rx) = tokio::io::duplex(1024); - wire::write( - &mut tx, - &Err::<(), _>(wire::Refusal::terminal("Revoked")), - wire::MAX_PREFACE, - ) - .await - .unwrap(); - assert_eq!( - read_control_ack(&mut rx).await.unwrap_err(), - "Remote access ended: Revoked" - ); - wire::write(&mut tx, &Ok::<(), wire::Refusal>(()), wire::MAX_PREFACE) - .await - .unwrap(); - assert!(read_control_ack(&mut rx).await.is_ok()); - } -} diff --git a/apps/maple-agent/app/src/remote/manager.rs b/apps/maple-agent/app/src/remote/manager.rs deleted file mode 100644 index 335ce49d9..000000000 --- a/apps/maple-agent/app/src/remote/manager.rs +++ /dev/null @@ -1,1055 +0,0 @@ -//! Per-account networking owned by the local Maple daemon. Enrollment is local -//! control only; cloud metadata never substitutes for a persisted peer grant. -use super::{ - network, pairing, - store::{Peer, Store}, - wire, -}; -use crate::{ - backend::LocalBackend, - host::{protocol, receipts::Receipts}, -}; -use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMap, RelayMode, RelayUrl, SecretKey}; -use maple_agent::{agent::AgentServiceEvent, maple_api::MapleApiSession}; -use maple_sdk::remote::*; -use serde::{Deserialize, Serialize}; -use std::{ - sync::{ - Arc, Mutex, - atomic::{AtomicBool, AtomicU64, Ordering}, - }, - time::Duration, -}; -use tokio::sync::{Mutex as AsyncMutex, Semaphore, broadcast}; -use tokio_util::sync::CancellationToken; -use uuid::Uuid; - -pub struct Manager { - node: AsyncMutex>>, - initializing: AsyncMutex<()>, - backend: Arc, - events: broadcast::Sender>, - receipts: Arc, - resident: AtomicBool, - generation: AtomicU64, - resuming: AtomicBool, - last_error: Mutex>, -} - -pub struct Node { - pub(super) backend: Arc, - pub(super) events: broadcast::Sender>, - pub(super) receipts: Arc, - pub(super) store: Mutex, - pub(super) session: Arc, - pub(super) endpoint: Endpoint, - addresses: Mutex>, - pub(super) account: String, - pub(super) project: String, - pub(super) deployment: String, - window: AsyncMutex>, - pairing_generation: AtomicU64, - registration: AsyncMutex<()>, - pub(super) stopped: CancellationToken, - pub(super) revoked: broadcast::Sender, - // Remote connections never occupy the local IPC/control capacity. - pub(super) admissions: Arc, - pub(super) controllers: Arc, -} - -#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Status { - pub name: String, - pub enabled: bool, - pub endpoint: Option, - pub ready: bool, - pub error: Option, - pub peers: std::collections::BTreeMap, -} - -#[derive(Serialize, Deserialize)] -pub struct PairingWindow { - pub code: String, - pub window: Uuid, - pub expires_in: u64, -} - -impl Manager { - pub fn new( - backend: Arc, - events: broadcast::Sender>, - receipts: Arc, - ) -> Arc { - Arc::new(Self { - node: AsyncMutex::new(None), - initializing: AsyncMutex::new(()), - backend, - events, - receipts, - resident: AtomicBool::new(false), - generation: AtomicU64::new(0), - resuming: AtomicBool::new(false), - last_error: Mutex::new(None), - }) - } - pub fn stays_running(&self) -> bool { - self.resident.load(Ordering::Acquire) - } - - pub async fn shutdown(&self) { - let node = { - let mut current = self.node.lock().await; - self.generation.fetch_add(1, Ordering::SeqCst); - self.resident.store(false, Ordering::Release); - current.take() - }; - if let Some(node) = node { - node.stopped.cancel(); - node.endpoint.close().await; - } - } - - fn store(&self, account: &str) -> Result { - Store::open( - &self.backend.remote_storage_root(), - self.backend.remote_api_url(), - &crate::backend::local::configured_client_id().to_string(), - crate::profile::PROFILE, - account, - ) - } - - pub async fn restore(self: &Arc) { - let current = self.node.lock().await; - let enabled = self.backend.saved_user_id().is_some_and(|account| { - Store::exists( - &self.backend.remote_storage_root(), - self.backend.remote_api_url(), - &crate::backend::local::configured_client_id().to_string(), - crate::profile::PROFILE, - &account, - ) && self.store(&account).is_ok_and(|s| s.enabled()) - }); - if !enabled { - return; - } - self.resident.store(true, Ordering::Release); - drop(current); - // Invalid configuration is actionable, not a network outage to retry. - if let Err(error) = configuration() { - *self.last_error.lock().unwrap() = Some(error); - return; - } - if self.resuming.swap(true, Ordering::AcqRel) { - return; - } - let weak = Arc::downgrade(self); - tokio::spawn(async move { - let mut delay = Duration::from_secs(1); - loop { - let Some(manager) = weak.upgrade() else { - return; - }; - if !manager.stays_running() { - manager.resuming.store(false, Ordering::Release); - return; - } - match manager.active().await { - Ok(_) => { - *manager.last_error.lock().unwrap() = None; - manager.resuming.store(false, Ordering::Release); - return; - } - Err(error) if manager.stays_running() => { - *manager.last_error.lock().unwrap() = Some(error) - } - Err(_) => {} - } - drop(manager); - tokio::time::sleep(delay).await; - delay = (delay * 2).min(Duration::from_secs(30)); - } - }); - } - - pub async fn active(self: &Arc) -> Result, String> { - let account = self - .backend - .saved_user_id() - .ok_or("Sign in before using remote access")?; - let _initializing = self.initializing.lock().await; - let (generation, current) = { - let current = self.node.lock().await; - (self.generation.load(Ordering::SeqCst), current.clone()) - }; - if let Some(node) = current { - if node.account == account && node.valid().await { - return Ok(node); - } - node.stopped.cancel(); - node.endpoint.close().await; - let mut current = self.node.lock().await; - if current.as_ref().is_some_and(|n| Arc::ptr_eq(n, &node)) { - current.take(); - } - } - let session = tokio::time::timeout( - Duration::from_secs(15), - self.backend.remote_session(&account), - ) - .await - .map_err(|_| "Could not validate remote account; try again when connected")??; - let store = self.store(&account)?; - let key = store.key(); - let project = crate::backend::local::configured_client_id().to_string(); - let deployment = if self.backend.remote_api_url().starts_with("http://") { - "local" - } else if crate::profile::PCR_ENVIRONMENT == "development" { - "dev" - } else { - "prod" - } - .to_string(); - let (relays, relay_only) = configuration()?; - // Register possession before asking an authenticated Maple relay to - // accept this key. Addresses are refreshed after binding. - register( - &session, - &key, - (&account, &project, &deployment), - RemoteAddresses::default(), - store.name().to_owned(), - store.enabled(), - None, - ) - .await?; - if self.generation.load(Ordering::SeqCst) != generation - || !session.remote_session_active().await - || self.backend.saved_user_id().as_deref() != Some(&account) - { - return Err("The signed-in account changed".into()); - } - let endpoint = network::bind(key, relays, relay_only).await?; - if self.generation.load(Ordering::SeqCst) != generation - || !session.remote_session_active().await - || self.backend.saved_user_id().as_deref() != Some(&account) - { - endpoint.close().await; - return Err("The signed-in account changed".into()); - } - // Settings mutations advance generation under node's installation lock. - // Any older initialization is discarded, even after this disk read. - let store = self.store(&account)?; - let (revoked, _) = broadcast::channel(64); - let node = Arc::new(Node { - backend: self.backend.clone(), - events: self.events.clone(), - receipts: self.receipts.clone(), - store: Mutex::new(store), - session, - endpoint, - addresses: Mutex::new(std::collections::HashMap::new()), - account, - project, - deployment, - window: AsyncMutex::new(None), - pairing_generation: AtomicU64::new(0), - registration: AsyncMutex::new(()), - stopped: CancellationToken::new(), - revoked, - admissions: Arc::new(Semaphore::new(4)), - controllers: Arc::new(Semaphore::new(8)), - }); - { - let mut current = self.node.lock().await; - if self.generation.load(Ordering::SeqCst) != generation { - drop(current); - node.endpoint.close().await; - return Err("Remote access was stopped while connecting".into()); - } - self.resident - .store(node.store.lock().unwrap().enabled(), Ordering::Release); - *current = Some(node.clone()); - } - super::control::listen(node.clone()); - let weak = Arc::downgrade(&node); - let manager = Arc::downgrade(self); - tokio::spawn(async move { - loop { - let Some(node) = weak.upgrade() else { - break; - }; - if !node.valid().await { - node.stopped.cancel(); - node.endpoint.close().await; - if let Some(manager) = manager.upgrade() { - let current = manager.node.lock().await; - if current.as_ref().is_some_and(|n| Arc::ptr_eq(n, &node)) { - manager.resident.store(false, Ordering::Release); - } - } - break; - } - // Discovery is only a reachability hint. Existing paired - // control survives an enrollment-service outage. - if let Err(error) = node.publish().await { - log::debug!("Remote discovery refresh unavailable: {error}"); - } - let stopped = node.stopped.clone(); - drop(node); - tokio::select! { _ = stopped.cancelled() => break, _ = tokio::time::sleep(Duration::from_secs(60)) => {} } - } - }); - Ok(node) - } - - pub async fn dispatch( - self: &Arc, - request: protocol::Request, - ) -> Result { - let encode = |value| { - serde_json::to_value(value).map_err(|_| "Could not encode remote settings".to_string()) - }; - if request.method == "remote_pairing_window" { - let current = self.node.lock().await.clone(); - let window = match current { - Some(node) if node.valid().await => node.current_pairing().await, - _ => None, - }; - return serde_json::to_value(window) - .map_err(|_| "Could not display pairing window".into()); - } - if request.method == "remote_status" { - let account = self - .backend - .saved_user_id() - .ok_or("Sign in before using remote access")?; - if !Store::exists( - &self.backend.remote_storage_root(), - self.backend.remote_api_url(), - &crate::backend::local::configured_client_id().to_string(), - crate::profile::PROFILE, - &account, - ) { - return encode(Status { - name: super::store::default_name().into(), - enabled: false, - endpoint: None, - ready: false, - error: None, - peers: Default::default(), - }); - } - let current = self.node.lock().await; - let store = self.store(&account)?; - return encode(Status { - name: store.name().into(), - enabled: store.enabled(), - endpoint: Some(store.key().public().to_string()), - ready: current - .as_ref() - .is_some_and(|n| n.account == account && !n.stopped.is_cancelled()), - error: self.last_error.lock().unwrap().clone(), - peers: store.peers().clone(), - }); - } - if request.method == "remote_set_name" { - let (name,): (String,) = - serde_json::from_value(request.params).map_err(|_| "Invalid computer name")?; - super::validate_name(&name)?; - let account = self - .backend - .saved_user_id() - .ok_or("Sign in before naming this computer")?; - // Like enable/revoke, serialize local writes with node installation. - // Naming an offline/disabled computer must not start networking. - let current = self.node.lock().await; - self.generation.fetch_add(1, Ordering::SeqCst); - let node = current.as_ref().filter(|n| n.account == account).cloned(); - if let Some(node) = &node { - node.store.lock().unwrap().set_name(&name)?; - } else { - self.store(&account)?.set_name(&name)?; - } - drop(current); - if let Some(node) = node { - tokio::spawn(async move { - let _ = node.publish().await; - }); - } - return Ok(serde_json::Value::Null); - } - if matches!( - request.method.as_str(), - "remote_revoke" | "remote_cancel_pairing" | "remote_enable" - ) { - let account = self - .backend - .saved_user_id() - .ok_or("No local account is selected")?; - let enable = if request.method == "remote_enable" { - let (enabled,): (bool,) = serde_json::from_value(request.params.clone()) - .map_err(|_| "Invalid remote setting")?; - Some(enabled) - } else { - None - }; - // This short local lock orders settings writes against node install. - // No credential validation or network operation holds it. - let current = self.node.lock().await; - self.generation.fetch_add(1, Ordering::SeqCst); - let node = current.as_ref().filter(|n| n.account == account).cloned(); - if (request.method != "remote_enable" || enable == Some(false)) - && let Some(node) = &node - { - let mut window = node.window.lock().await; - node.pairing_generation.fetch_add(1, Ordering::SeqCst); - *window = None; - } - if request.method != "remote_cancel_pairing" { - let mut disk; - let mut memory; - let store = if let Some(node) = &node { - memory = node.store.lock().unwrap(); - &mut *memory - } else { - disk = self.store(&account)?; - &mut disk - }; - if let Some(enabled) = enable { - store.set_enabled(enabled)?; - self.resident.store(enabled, Ordering::Release); - *self.last_error.lock().unwrap() = None; - if !enabled && let Some(node) = &node { - let _ = node.revoked.send(node.endpoint.id()); - } - } else { - let (peer,): (String,) = - serde_json::from_value(request.params).map_err(|_| "Invalid device")?; - let peer = peer.parse().map_err(|_| "Invalid device")?; - store.revoke(peer)?; - if let Some(node) = &node { - let _ = node.revoked.send(peer); - } - } - } - drop(current); - if enable == Some(true) { - self.restore().await; - } - // Publish best effort without making local disable/cancel wait on a service. - if let Some(node) = node { - tokio::spawn(async move { - let _ = node.publish().await; - }); - } - return Ok(serde_json::Value::Null); - } - let node = self.active().await?; - match request.method.as_str() { - "remote_begin_pairing" => serde_json::to_value(node.begin_pairing().await?) - .map_err(|_| "Could not display pairing window".into()), - "remote_discover" => { - node.publish().await?; - let list = node.session.list_remote_devices().await?; - validate_devices(&list.devices)?; - node.ensure_valid().await?; - node.remember_peer_names(&list.devices); - serde_json::to_value( - list.devices - .into_iter() - .filter(|d| { - d.channel == crate::profile::PROFILE - && d.endpoint != node.endpoint.id().to_string() - }) - .collect::>(), - ) - .map_err(|_| "Invalid device list".into()) - } - "remote_pair" => { - let (peer, mut code): (String, String) = serde_json::from_value(request.params) - .map_err(|_| "Invalid pairing request")?; - let parsed = pairing::Code::parse(&code); - zeroize::Zeroize::zeroize(&mut code); - node.pair( - peer.parse().map_err(|_| "Invalid remote identity")?, - parsed?, - ) - .await?; - Ok(serde_json::Value::Null) - } - "remote_reconcile" => { - let (peer,): (String,) = - serde_json::from_value(request.params).map_err(|_| "Invalid device")?; - node.reconcile(peer.parse().map_err(|_| "Invalid device")?) - .await?; - Ok(serde_json::Value::Null) - } - _ => Err("Unknown local remote setting".into()), - } - } -} - -impl Node { - pub(super) async fn valid(&self) -> bool { - !self.stopped.is_cancelled() - && self.backend.saved_user_id().as_deref() == Some(&self.account) - && self.session.remote_session_active().await - } - pub(super) async fn ensure_valid(&self) -> Result<(), String> { - if self.valid().await { - Ok(()) - } else { - Err("The remote account is no longer active".into()) - } - } - pub(super) fn hello(&self, mode: wire::Mode) -> wire::Hello { - wire::Hello { - version: wire::VERSION, - minimum_version: wire::VERSION, - account: self.account.clone(), - project: self.project.clone(), - deployment: self.deployment.clone(), - channel: crate::profile::PROFILE.into(), - mode, - } - } - fn context<'a>( - &'a self, - controller: &'a EndpointId, - execution: &'a EndpointId, - window: &'a [u8; 16], - ) -> pairing::Context<'a> { - pairing::Context { - controller: controller.as_bytes(), - execution: execution.as_bytes(), - account: &self.account, - project: &self.project, - deployment: &self.deployment, - channel: crate::profile::PROFILE, - window, - } - } - pub(super) async fn publish(&self) -> Result<(), String> { - // A stale readiness refresh must not overwrite a newly registered W. - // Local cancel/disable never wait on this network serialization gate. - let _registration = self.registration.lock().await; - self.ensure_valid().await?; - let key = self.store.lock().unwrap().key(); - let enabled = self.store.lock().unwrap().enabled(); - let name = self.store.lock().unwrap().name().to_owned(); - let window = self - .window - .lock() - .await - .as_ref() - .filter(|w| w.is_open(&w.id)) - .map(|w| RemoteWindowInit { - id: Uuid::from_bytes(w.id), - remaining_seconds: w.remaining().as_secs().min(300) as u32, - }); - let address = self.endpoint.addr(); - register( - &self.session, - &key, - (&self.account, &self.project, &self.deployment), - RemoteAddresses { - relays: address.relay_urls().map(ToString::to_string).collect(), - direct: address.ip_addrs().map(ToString::to_string).collect(), - }, - name, - enabled, - window, - ) - .await?; - self.ensure_valid().await - } - async fn current_pairing(&self) -> Option { - self.window - .lock() - .await - .as_ref() - .filter(|w| w.is_open(&w.id)) - .and_then(|w| { - Some(PairingWindow { - code: w.code()?, - window: Uuid::from_bytes(w.id), - expires_in: w.remaining().as_secs(), - }) - }) - } - async fn begin_pairing(&self) -> Result { - let _starting = self.registration.lock().await; - if !self.store.lock().unwrap().enabled() { - return Err("Enable remote access on this computer first".into()); - } - if let Some(window) = self.current_pairing().await { - return Ok(window); - } - let generation = self.pairing_generation.load(Ordering::SeqCst); - let next = pairing::Window::new(); - let key = self.store.lock().unwrap().key(); - let name = self.store.lock().unwrap().name().to_owned(); - let address = self.endpoint.addr(); - let registration = register( - &self.session, - &key, - (&self.account, &self.project, &self.deployment), - RemoteAddresses { - relays: address.relay_urls().map(ToString::to_string).collect(), - direct: address.ip_addrs().map(ToString::to_string).collect(), - }, - name, - true, - Some(RemoteWindowInit { - id: Uuid::from_bytes(next.id), - remaining_seconds: 300, - }), - ) - .await?; - self.ensure_valid().await?; - let mut window = self.window.lock().await; - if self.pairing_generation.load(Ordering::SeqCst) != generation - || !self.store.lock().unwrap().enabled() - { - return Err("Pairing was cancelled locally".into()); - } - if registration.window.as_ref().map(|w| w.id) != Some(Uuid::from_bytes(next.id)) - || next.remaining().is_zero() - { - return Err("Pairing registration was not acknowledged. Start a new window.".into()); - } - let result = PairingWindow { - code: next.code().ok_or("Pairing window expired")?, - window: Uuid::from_bytes(next.id), - expires_in: next.remaining().as_secs(), - }; - *window = Some(next); - Ok(result) - } - pub(super) async fn destination( - &self, - peer: EndpointId, - ) -> Result<(EndpointAddr, RemoteDevice), String> { - let available = - tokio::time::timeout(Duration::from_secs(3), self.session.list_remote_devices()).await; - self.ensure_valid().await?; - let list = match available { - Ok(Ok(list)) => list, - _ => { - return self - .addresses - .lock() - .unwrap() - .get(&peer) - .cloned() - .ok_or_else(|| { - "Remote discovery is unavailable and no previous address is known".into() - }); - } - }; - validate_devices(&list.devices)?; - self.remember_peer_names(&list.devices); - let device = list - .devices - .into_iter() - .find(|d| d.channel == crate::profile::PROFILE && d.endpoint == peer.to_string()) - .ok_or("The other computer is not currently discoverable")?; - let mut address = EndpointAddr::new(peer); - if device.addresses.relays.len() > 4 || device.addresses.direct.len() > 16 { - return Err("Invalid remote address list".into()); - } - for relay in &device.addresses.relays { - address = address.with_relay_url(relay.parse().map_err(|_| "Invalid remote relay")?); - } - for ip in &device.addresses.direct { - address = address.with_ip_addr(ip.parse().map_err(|_| "Invalid remote address")?); - } - { - let mut addresses = self.addresses.lock().unwrap(); - if addresses.len() < 64 || addresses.contains_key(&peer) { - addresses.insert(peer, (address.clone(), device.clone())); - } - } - Ok((address, device)) - } - fn remember_peer_names(&self, devices: &[RemoteDevice]) { - let names = devices - .iter() - .filter(|d| d.channel == crate::profile::PROFILE) - .map(|d| (d.endpoint.as_str(), d.name.as_str())); - if self - .store - .lock() - .unwrap() - .refresh_peer_names(names) - .is_err() - { - log::warn!("Could not save updated remote computer names"); - } - } - - /// Pairing authority is already persisted. Resolve the controller's label - /// separately, so optional discovery metadata cannot delay or fail pairing. - pub(super) async fn refresh_peer_names(&self) { - if let Ok(Ok(list)) = - tokio::time::timeout(Duration::from_secs(3), self.session.list_remote_devices()).await - && validate_devices(&list.devices).is_ok() - && self.valid().await - { - self.remember_peer_names(&list.devices); - } - } - async fn pair(&self, peer: EndpointId, code: pairing::Code) -> Result<(), String> { - let generation = self.pairing_generation.load(Ordering::SeqCst); - self.publish().await?; - let (address, device) = self.destination(peer).await?; - let window = device - .window - .ok_or("Open a pairing window on the other computer first")? - .id; - let admission = self - .session - .request_remote_admission(RemoteAdmissionRequest { - controller: self.endpoint.id().to_string(), - execution: peer.to_string(), - window, - }) - .await?; - self.ensure_valid().await?; - let controller = self.endpoint.id(); - let proof = - pairing::Controller::new(code, &self.context(&controller, &peer, window.as_bytes()))?; - let exchange = async { - let connection = self - .endpoint - .connect(address, wire::ALPN) - .await - .map_err(|_| "Could not connect to the other computer")?; - if connection.remote_id() != peer { - return Err("The remote identity changed".into()); - } - let (mut tx, mut rx) = connection - .open_bi() - .await - .map_err(|_| "Pairing connection closed")?; - wire::write( - &mut tx, - &self.hello(wire::Mode::Pair { - window: *window.as_bytes(), - admission: admission.handle, - }), - wire::MAX_PREFACE, - ) - .await - .map_err(|e| e.to_string())?; - // Reserve immediately before the first code-derived byte. A failed - // dial does not burn a window, but a partial proof can never retry. - self.ensure_valid().await?; - { - let mut store = self.store.lock().unwrap(); - if self.pairing_generation.load(Ordering::SeqCst) != generation { - return Err("Pairing was cancelled locally".into()); - } - store.reserve_attempt(peer, *window.as_bytes())?; - } - wire::write(&mut tx, &proof.message(), wire::MAX_PREFACE) - .await - .map_err(|e| e.to_string())?; - let answer: Result<(Vec, Vec), wire::Refusal> = - wire::read(&mut rx, wire::MAX_PREFACE) - .await - .map_err(|e| e.to_string())?; - let (message, host_tag) = answer.map_err(|e| e.message)?; - let tag = proof.confirm(message, &host_tag)?; - { - let _account = self.backend.account_gate.read().await; - self.ensure_valid().await?; - let mut store = self.store.lock().unwrap(); - if self.pairing_generation.load(Ordering::SeqCst) != generation { - return Err("Pairing was cancelled locally".into()); - } - store.remember_host_proof(peer, *window.as_bytes(), device.name)?; - } - wire::write(&mut tx, &tag.as_slice(), wire::MAX_PREFACE) - .await - .map_err(|e| e.to_string())?; - let grant: Result<[u8; 16], wire::Refusal> = wire::read(&mut rx, wire::MAX_PREFACE) - .await - .map_err(|e| e.to_string())?; - let grant = grant.map_err(|e| e.message)?; - if grant != *window.as_bytes() { - return Err("Invalid pairing acknowledgement".into()); - } - { - let _account = self.backend.account_gate.read().await; - self.ensure_valid().await?; - let mut store = self.store.lock().unwrap(); - if self.pairing_generation.load(Ordering::SeqCst) != generation { - return Err("Pairing was cancelled locally".into()); - } - store.confirm_host(peer, grant)?; - } - tx.finish().map_err(|_| "Pairing connection closed")?; - Ok::<_, String>(()) - }; - match tokio::time::timeout(Duration::from_secs(20), exchange).await { - Ok(Ok(())) => Ok(()), - result => { - if self - .store - .lock() - .unwrap() - .peers() - .get(&peer.to_string()) - .is_some_and(|p| p.pending_confirmation == Some(*window.as_bytes())) - { - return self.reconcile(peer).await; - } - result.map_err(|_| "Pairing timed out; open a new window before retrying")? - } - } - } - pub(super) async fn reconcile(&self, peer: EndpointId) -> Result<(), String> { - let generation = self.pairing_generation.load(Ordering::SeqCst); - let window = self - .store - .lock() - .unwrap() - .peers() - .get(&peer.to_string()) - .and_then(|p| p.pending_confirmation) - .ok_or("No host confirmation is pending")?; - let (address, _) = self.destination(peer).await?; - let confirm = async { - let connection = self - .endpoint - .connect(address, wire::ALPN) - .await - .map_err(|_| "Could not reconnect to confirm pairing")?; - if connection.remote_id() != peer { - return Err("Remote identity changed".to_string()); - } - let (mut tx, mut rx) = connection - .open_bi() - .await - .map_err(|_| "Remote connection closed")?; - wire::write( - &mut tx, - &self.hello(wire::Mode::ConfirmGrant { window }), - wire::MAX_PREFACE, - ) - .await - .map_err(|e| e.to_string())?; - let confirmed: Result<[u8; 16], wire::Refusal> = wire::read(&mut rx, wire::MAX_PREFACE) - .await - .map_err(|e| e.to_string())?; - if confirmed.map_err(|e| e.message)? != window { - return Err("Invalid grant acknowledgement".into()); - } - let _account = self.backend.account_gate.read().await; - self.ensure_valid().await?; - let mut store = self.store.lock().unwrap(); - if self.pairing_generation.load(Ordering::SeqCst) != generation { - return Err("Pairing was cancelled locally".into()); - } - store.confirm_host(peer, window) - }; - tokio::time::timeout(Duration::from_secs(15), confirm) - .await - .map_err(|_| "Pairing acknowledgement is still pending; reconnect to confirm it")? - } - pub(super) async fn accept_pairing( - &self, - peer: EndpointId, - window: [u8; 16], - admission: String, - tx: &mut iroh::endpoint::SendStream, - rx: &mut iroh::endpoint::RecvStream, - ) -> Result<(), String> { - if !self.store.lock().unwrap().enabled() { - return Err("Remote access is disabled".into()); - } - if !self - .window - .lock() - .await - .as_ref() - .is_some_and(|w| w.is_open(&window)) - { - return Err("Pairing window is closed".into()); - } - let admitted = tokio::time::timeout( - Duration::from_secs(8), - self.session.check_remote_admission(CheckRemoteAdmission { - handle: admission, - controller: peer.to_string(), - execution: self.endpoint.id().to_string(), - window: Uuid::from_bytes(window), - }), - ) - .await - .map_err(|_| "Pairing admission check timed out; try again")??; - self.ensure_valid().await?; - if !admitted.valid { - return Err("Pairing admission was rejected".into()); - } - let code = self - .window - .lock() - .await - .as_mut() - .ok_or("Pairing window is closed")? - .take_attempt(&window)?; - let message = wire::read(rx, wire::MAX_PREFACE) - .await - .map_err(|e| e.to_string())?; - let execution = self.endpoint.id(); - let proof = - pairing::Execution::new(code, &self.context(&peer, &execution, &window), message)?; - wire::write( - tx, - &Ok::<_, wire::Refusal>((proof.message(), proof.confirmation().as_slice())), - wire::MAX_PREFACE, - ) - .await - .map_err(|e| e.to_string())?; - let tag: Vec = wire::read(rx, wire::MAX_PREFACE) - .await - .map_err(|e| e.to_string())?; - proof.verify(&tag)?; - let _account = self.backend.account_gate.read().await; - self.ensure_valid().await?; - let pending_window = self.window.lock().await; - if !pending_window - .as_ref() - .is_some_and(|w| w.id == window && !w.remaining().is_zero()) - { - return Err("Pairing window was closed or expired".into()); - } - { - let mut store = self.store.lock().unwrap(); - if !store.enabled() { - return Err("Remote access is disabled".into()); - } - store.grant( - peer, - Peer { - name: "Paired controller".into(), - grant: window, - controls_this_computer: true, - can_connect: false, - pending_confirmation: None, - }, - )?; - } - drop(pending_window); - wire::write(tx, &Ok::<_, wire::Refusal>(window), wire::MAX_PREFACE) - .await - .map_err(|e| e.to_string())?; - Ok(()) - } -} - -async fn register( - session: &MapleApiSession, - key: &SecretKey, - (account, project, deployment): (&str, &str, &str), - addresses: RemoteAddresses, - name: String, - enabled: bool, - window: Option, -) -> Result { - let registration = RemoteRegistration { - account: account.parse().map_err(|_| "Invalid account")?, - project: project.parse().map_err(|_| "Invalid project")?, - deployment: deployment.into(), - channel: crate::profile::PROFILE.into(), - endpoint: key.public().to_string(), - name, - addresses, - accepting_connections: enabled, - window, - }; - let signature = key - .sign( - ®istration - .proof_message() - .map_err(|_| "Invalid registration")?, - ) - .to_bytes() - .iter() - .map(|b| format!("{b:02x}")) - .collect(); - tokio::time::timeout( - Duration::from_secs(10), - session.register_remote_device(RegisterRemoteDevice { - registration, - signature, - }), - ) - .await - .map_err(|_| "Remote registration timed out")? -} - -fn configuration() -> Result<(RelayMode, bool), String> { - // Public relays are explicit developer validation only. Packaged builds - // require Maple relay configuration; no automatic public fallback. - let configured = crate::env::env_string("MAPLE_REMOTE_RELAYS").unwrap_or_default(); - if configured == "public-test" && crate::profile::PROFILE == "unpackaged" { - return Ok(( - RelayMode::Default, - crate::env::env_string("MAPLE_REMOTE_RELAY_ONLY").as_deref() == Some("1"), - )); - } - if configured == "none" && crate::profile::PROFILE == "unpackaged" { - return Ok((RelayMode::Disabled, false)); - } - let relays: Vec = configured - .split(',') - .filter(|s| !s.is_empty()) - .map(|s| s.parse().map_err(|_| "Invalid remote relay configuration")) - .collect::>()?; - if relays.is_empty() { - return Err("Maple remote relay service is not configured for this build".into()); - } - Ok((RelayMode::Custom(RelayMap::from_iter(relays)), false)) -} - -fn validate_devices(devices: &[RemoteDevice]) -> Result<(), String> { - if devices.len() > 64 { - return Err("Invalid remote discovery response".into()); - } - for device in devices { - let key = device - .endpoint - .parse::() - .map_err(|_| "Invalid remote discovery identity")?; - if key.to_string() != device.endpoint - || device.name.len() > 128 - || device.name.chars().any(char::is_control) - || device.addresses.direct.len() > 16 - || device.addresses.relays.len() > 4 - { - return Err("Invalid remote discovery metadata".into()); - } - for relay in &device.addresses.relays { - let url = url::Url::parse(relay).map_err(|_| "Invalid remote relay address")?; - if relay.len() > 512 - || url.scheme() != "https" - || !url.username().is_empty() - || url.password().is_some() - || url.query().is_some() - || url.fragment().is_some() - { - return Err("Invalid remote relay address".into()); - } - } - for direct in &device.addresses.direct { - direct - .parse::() - .map_err(|_| "Invalid remote direct address")?; - } - } - Ok(()) -} diff --git a/apps/maple-agent/app/src/remote/mod.rs b/apps/maple-agent/app/src/remote/mod.rs deleted file mode 100644 index a7be0e379..000000000 --- a/apps/maple-agent/app/src/remote/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Desktop remote access is owned by the profile's local daemon. -mod network; -mod pairing; -mod store; -mod streams; -mod wire; - -pub mod control; -pub mod manager; - -pub(crate) use store::validate_name; diff --git a/apps/maple-agent/app/src/remote/network.rs b/apps/maple-agent/app/src/remote/network.rs deleted file mode 100644 index c72457c36..000000000 --- a/apps/maple-agent/app/src/remote/network.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Stock Iroh transport, with no public address publication or custom routing. -use iroh::{Endpoint, RelayMode, SecretKey, endpoint::presets}; - -use super::wire::ALPN; - -pub async fn bind(key: SecretKey, relays: RelayMode, relay_only: bool) -> Result { - let has_relay = !matches!(relays, RelayMode::Disabled); - let endpoint = builder(key, relays, relay_only) - .bind() - .await - .map_err(|_| "Could not start Maple remote networking".to_string())?; - // Binding precedes relay readiness. Publish usable addressing on the first - // registration after restart, rather than an empty hint until the refresh. - // Offline relays must not prevent a direct path or local controls forever. - if has_relay { - let _ = tokio::time::timeout(std::time::Duration::from_secs(10), endpoint.online()).await; - } - Ok(endpoint) -} - -fn builder(key: SecretKey, relays: RelayMode, relay_only: bool) -> iroh::endpoint::Builder { - let mut builder = Endpoint::builder(presets::Minimal) - .secret_key(key) - .alpns(vec![ALPN.to_vec()]) - .relay_mode(relays) - .transport_config(super::streams::transport_config()) - .clear_address_lookup(); - // Used for forced-relay validation without changing this machine's firewall - // or any other process. Production uses Iroh's ordinary path selection. - if relay_only { - builder = builder.clear_ip_transports(); - } - builder -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::remote::{pairing, wire}; - use iroh::EndpointAddr; - use std::time::Duration; - - async fn exchange(relays: RelayMode, relay_only: bool) { - let endpoint = |relays| async move { - if relay_only { - bind(SecretKey::generate(), relays, true).await.unwrap() - } else { - // A deterministic same-machine direct path without relying on - // the VM's bridged LAN interface or altering its firewall. - builder(SecretKey::generate(), relays, false) - .clear_ip_transports() - .bind_addr((std::net::Ipv4Addr::LOCALHOST, 0)) - .unwrap() - .bind() - .await - .unwrap() - } - }; - let a = endpoint(relays.clone()).await; - let b = endpoint(relays).await; - let exercise = async { - if relay_only { - assert!(a.addr().relay_urls().next().is_some()); - assert!(b.addr().relay_urls().next().is_some()); - } - let a_key = *a.id().as_bytes(); - let b_key = *b.id().as_bytes(); - let context = pairing::Context { - controller: &a_key, - execution: &b_key, - account: "isolated-test", - project: "test", - deployment: "local", - channel: "unpackaged", - window: &[9; 16], - }; - let a_proof = - pairing::Controller::new(pairing::Code::parse("ABCDEFG2").unwrap(), &context) - .unwrap(); - let destination: EndpointAddr = b.addr(); - let connect = async { - a.connect(destination, ALPN) - .await - .map_err(|e| e.to_string()) - }; - let accept = async { b.accept().await.unwrap().await.map_err(|e| e.to_string()) }; - let (client, server) = tokio::try_join!(connect, accept).unwrap(); - assert_eq!(client.remote_id(), b.id()); - assert_eq!(server.remote_id(), a.id()); - if relay_only { - assert!(client.paths().iter().all(|path| !path.is_ip())); - assert!(client.paths().iter().any(|path| path.is_relay())); - } else { - assert!(client.paths().iter().any(|path| path.is_ip())); - } - let controller = async { - let (mut tx, mut rx) = client.open_bi().await.unwrap(); - wire::write(&mut tx, &a_proof.message(), wire::MAX_PREFACE) - .await - .unwrap(); - let (message, host_tag): (Vec, Vec) = - wire::read(&mut rx, wire::MAX_PREFACE).await.unwrap(); - let tag = a_proof.confirm(message, &host_tag).unwrap(); - wire::write(&mut tx, &tag.as_slice(), wire::MAX_PREFACE) - .await - .unwrap(); - let ack: String = wire::read(&mut rx, wire::MAX_PREFACE).await.unwrap(); - assert_eq!(ack, "proof verified"); - }; - let execution = async { - let (mut tx, mut rx) = server.accept_bi().await.unwrap(); - let message = wire::read(&mut rx, wire::MAX_PREFACE).await.unwrap(); - let proof = pairing::Execution::new( - pairing::Code::parse("ABCDEFG2").unwrap(), - &context, - message, - ) - .unwrap(); - wire::write( - &mut tx, - &(proof.message(), proof.confirmation().as_slice()), - wire::MAX_PREFACE, - ) - .await - .unwrap(); - let tag: Vec = wire::read(&mut rx, wire::MAX_PREFACE).await.unwrap(); - proof.verify(&tag).unwrap(); - wire::write(&mut tx, &"proof verified", wire::MAX_PREFACE) - .await - .unwrap(); - }; - tokio::join!(controller, execution); - }; - let result = tokio::time::timeout(Duration::from_secs(60), exercise).await; - tokio::join!(a.close(), b.close()); - result.expect("isolated Iroh exchange timed out"); - } - - #[tokio::test] - async fn separate_endpoints_pair_over_direct_iroh() { - exchange(RelayMode::Disabled, false).await; - } - - #[tokio::test] - #[ignore = "explicit public relay network validation, not an offline CI test"] - async fn separate_endpoints_pair_over_public_relay_without_ip_transports() { - exchange(RelayMode::Default, true).await; - } -} diff --git a/apps/maple-agent/app/src/remote/pairing.rs b/apps/maple-agent/app/src/remote/pairing.rs deleted file mode 100644 index c7427c655..000000000 --- a/apps/maple-agent/app/src/remote/pairing.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! The enrollment proof is consumed, never retried. OpenSecret admission is -//! checked by the caller before taking a host window's single proof attempt. -use pake_cpace::{CPace, ChannelIdentifier, NoAD, UnverifiedNegotiation}; -use rand::{RngCore, rngs::OsRng}; -use std::time::{Duration, Instant}; -use zeroize::{Zeroize, Zeroizing}; - -const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; -pub const WINDOW_DURATION: Duration = Duration::from_secs(300); -const FAILED: &str = "Pairing proof failed. Open a new pairing window and try again."; -const INVALID_CODE: &str = "Enter the eight-character pairing code shown on the other computer."; - -/// No Debug/Serialize implementation: the code must not enter logs or storage. -pub struct Code(Zeroizing<[u8; 8]>); - -impl Code { - pub fn generate() -> Self { - let mut bytes = Zeroizing::new([0; 8]); - OsRng.fill_bytes(bytes.as_mut()); - for byte in bytes.iter_mut() { - *byte = ALPHABET[usize::from(*byte & 31)]; - } - Self(bytes) - } - - pub fn parse(text: &str) -> Result { - let text = text.as_bytes(); - let (first, last) = match text.len() { - 8 => text.split_at(4), - 9 if text[4] == b'-' => (&text[..4], &text[5..]), - _ => return Err(INVALID_CODE.into()), - }; - if !first.iter().chain(last).all(|b| ALPHABET.contains(b)) { - return Err(INVALID_CODE.into()); - } - let mut bytes = Zeroizing::new([0; 8]); - bytes[..4].copy_from_slice(first); - bytes[4..].copy_from_slice(last); - Ok(Self(bytes)) - } - - /// Only the locally initiated pairing UI may display this value. - pub fn display(&self) -> String { - let code = std::str::from_utf8(self.0.as_ref()).expect("pairing code is ASCII"); - format!("{}-{}", &code[..4], &code[4..]) - } -} - -/// All identities are known locally or from authenticated account state. In -/// particular, B's peer key comes from Iroh, not the admission response. -pub struct Context<'a> { - pub controller: &'a [u8; 32], - pub execution: &'a [u8; 32], - pub account: &'a str, - pub project: &'a str, - pub deployment: &'a str, - pub channel: &'a str, - pub window: &'a [u8; 16], -} - -fn start(code: Code, context: &Context<'_>) -> Result { - // The library length-prefixes each item and binds it into the generator. - // The fixed domain pins both roles, protocol and the exact CPace variant. - let extra_context: &[&[u8]] = &[ - b"maple.remote.pairing.v1/cpace-ristretto255-draft06/controller-to-execution", - context.account.as_bytes(), - context.project.as_bytes(), - context.deployment.as_bytes(), - context.channel.as_bytes(), - context.window, - ]; - CPace::new( - context.window, - code.0.as_ref(), - &ChannelIdentifier { - id_a: context.controller, - id_b: context.execution, - extra_context, - }, - &NoAD {}, - ) - .map_err(|_| "Could not obtain randomness for pairing".into()) -} - -pub struct Window { - pub id: [u8; 16], - deadline: Instant, - code: Option, -} - -impl Window { - pub fn new() -> Self { - Self { - id: *uuid::Uuid::new_v4().as_bytes(), - deadline: Instant::now() + WINDOW_DURATION, - code: Some(Code::generate()), - } - } - - pub fn code(&self) -> Option { - self.code.as_ref().map(Code::display) - } - - pub fn remaining(&self) -> Duration { - self.deadline.saturating_duration_since(Instant::now()) - } - - pub fn is_open(&self, window: &[u8; 16]) -> bool { - &self.id == window && Instant::now() < self.deadline && self.code.is_some() - } - - /// Call under the window mutex AFTER admission validation, rechecking the - /// exact window and account generation there. Taking closes it even if the - /// subsequent exchange is malformed, cancelled, or disconnected. - pub fn take_attempt(&mut self, window: &[u8; 16]) -> Result { - if !self.is_open(window) { - return Err("Pairing window is closed or already used.".into()); - } - self.code.take().ok_or_else(|| FAILED.into()) - } -} - -/// A sends its public value once, then waits for B's confirmation. Consuming -/// this object prevents a UI retry from testing another host proof on the code. -pub struct Controller(CPace); -impl Controller { - pub fn new(code: Code, context: &Context<'_>) -> Result { - start(code, context).map(Self) - } - - pub fn message(&self) -> &[u8] { - self.0.message() - } - - /// No controller confirmation is exposed until B's proof has verified. - pub fn confirm(self, message: Vec, host_tag: &[u8]) -> Result<[u8; 64], String> { - if message == self.0.message() { - return Err(FAILED.into()); - } - let (proof, _) = self - .0 - .initiator_process_message::(message) - .map_err(|_| FAILED)?; - let mut tag = proof.tag(); - match proof.verify_tag(host_tag) { - Ok(mut key) => { - key.zeroize(); // Pairing pins Iroh identities; no second tunnel. - Ok(tag) - } - Err(_) => { - tag.zeroize(); - Err(FAILED.into()) - } - } - } -} - -pub struct Execution(UnverifiedNegotiation); -impl Execution { - pub fn new(code: Code, context: &Context<'_>, message: Vec) -> Result { - let (processed, _) = - CPace::responder_process_message::(message).map_err(|_| FAILED)?; - let proof = processed - .finish(start(code, context)?) - .map_err(|_| FAILED)?; - if proof.own_message() == proof.other_message() { - return Err(FAILED.into()); - } - Ok(Self(proof)) - } - - pub fn message(&self) -> &[u8] { - self.0.own_message() - } - - pub fn confirmation(&self) -> [u8; 64] { - self.0.tag() - } - - pub fn verify(self, tag: &[u8]) -> Result<(), String> { - let mut key = self.0.verify_tag(tag).map_err(|_| FAILED)?; - key.zeroize(); - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - fn context() -> Context<'static> { - Context { - controller: &[1; 32], - execution: &[2; 32], - account: "account", - project: "project", - deployment: "local", - channel: "unpackaged", - window: &[3; 16], - } - } - fn code() -> Code { - Code::parse("ABCDEFG2").unwrap() - } - - #[test] - fn correct_proofs_confirm_execution_before_controller() { - let ctx = context(); - let a = Controller::new(Code::parse("ABCD-EFG2").unwrap(), &ctx).unwrap(); - let b = Execution::new(code(), &ctx, a.message().to_vec()).unwrap(); - let tag = a.confirm(b.message().to_vec(), &b.confirmation()).unwrap(); - b.verify(&tag).unwrap(); - } - - #[test] - fn display_groups_code_without_changing_its_value() { - assert_eq!(code().display(), "ABCD-EFG2"); - assert_eq!(Code::parse(&code().display()).unwrap().0, code().0); - for invalid in [ - "ABC-DEFG2", - "ABCD--EFG2", - "ABCD-EFG", - "ABCD-EFG22", - "ABCD-EFG1", - "ABCD–EFG2", - ] { - assert!(Code::parse(invalid).is_err()); - } - } - - #[test] - fn mismatched_code_or_each_bound_identity_fails() { - for change in 0..8 { - let ctx = context(); - let a = Controller::new(code(), &ctx).unwrap(); - let mut other = context(); - let mut b_code = code(); - match change { - 0 => other.controller = &[4; 32], - 1 => other.execution = &[4; 32], - 2 => other.account = "other", - 3 => other.project = "other", - 4 => other.deployment = "other", - 5 => other.window = &[4; 16], - 6 => other.channel = "other", - _ => b_code = Code::parse("ABCDEFG3").unwrap(), - } - let b = Execution::new(b_code, &other, a.message().to_vec()).unwrap(); - assert!(a.confirm(b.message().to_vec(), &b.confirmation()).is_err()); - } - } - - #[test] - fn malformed_group_elements_tags_and_reflection_fail() { - for message in [vec![], vec![0; 32], vec![255; 32], vec![1; 31], vec![1; 33]] { - assert!(Execution::new(code(), &context(), message.clone()).is_err()); - let a = Controller::new(code(), &context()).unwrap(); - assert!(a.confirm(message, &[0; 64]).is_err()); - } - for tag in [vec![], vec![0; 63], vec![0; 64], vec![0; 65]] { - let a = Controller::new(code(), &context()).unwrap(); - let b = Execution::new(code(), &context(), a.message().to_vec()).unwrap(); - assert!(a.confirm(b.message().to_vec(), &tag).is_err()); - } - let a = Controller::new(code(), &context()).unwrap(); - let reflected = a.message().to_vec(); - assert!(a.confirm(reflected, &[0; 64]).is_err()); - } - - #[test] - fn window_cannot_be_reused_or_extended_by_admission() { - let mut window = Window::new(); - assert_eq!(window.code().unwrap().len(), 9); - assert!(Code::parse(&window.code().unwrap()).is_ok()); - assert!(window.take_attempt(&[0; 16]).is_err()); - let id = window.id; - assert!(window.take_attempt(&id).is_ok()); - assert!(window.take_attempt(&id).is_err()); - let mut window = Window::new(); - window.deadline = Instant::now(); - assert!(window.take_attempt(&window.id.clone()).is_err()); - } -} diff --git a/apps/maple-agent/app/src/remote/store.rs b/apps/maple-agent/app/src/remote/store.rs deleted file mode 100644 index 2444e7292..000000000 --- a/apps/maple-agent/app/src/remote/store.rs +++ /dev/null @@ -1,495 +0,0 @@ -//! Device authority is local and separate from the refresh-token cache. Cloud -//! discovery never creates a grant, and a missing grant is never reconstructed. -use iroh::{EndpointId, SecretKey}; -use maple_agent::private_file::write_private_json; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::{ - collections::BTreeMap, - path::{Path, PathBuf}, -}; -use zeroize::Zeroize; - -const MAX_PEERS: usize = 64; - -pub(super) fn default_name() -> &'static str { - static NAME: std::sync::LazyLock = std::sync::LazyLock::new(|| { - default_name_from(&hostname::get().unwrap_or_default().to_string_lossy()) - }); - &NAME -} - -fn default_name_from(hostname: &str) -> String { - let mut name = String::new(); - for ch in hostname - .trim() - .chars() - .filter(|ch| !ch.is_control()) - .take(64) - { - if name.len() + ch.len_utf8() > 128 { - break; - } - name.push(ch); - } - if name.trim().is_empty() { - if cfg!(target_os = "macos") { - "Mac".into() - } else if cfg!(target_os = "windows") { - "Windows PC".into() - } else { - "Linux PC".into() - } - } else { - name.trim().to_string() - } -} - -pub(crate) fn validate_name(name: &str) -> Result<&str, String> { - let name = name.trim(); - if name.is_empty() || name.chars().any(char::is_control) { - return Err("Enter a computer name without control characters.".into()); - } - if name.len() > 128 || name.chars().count() > 64 { - return Err("Use a shorter computer name.".into()); - } - Ok(name) -} - -#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Peer { - pub name: String, - pub grant: [u8; 16], - /// Set only on B after confirming A's proof. - pub controls_this_computer: bool, - /// Set only on A after verifying B's proof and durable grant acknowledgement. - pub can_connect: bool, - #[serde(default)] - pub pending_confirmation: Option<[u8; 16]>, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct Record { - version: u32, - scope: String, - secret: [u8; 32], - enabled: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - name: Option, - peers: BTreeMap, - #[serde(default)] - attempted_windows: BTreeMap>, -} -impl Drop for Record { - fn drop(&mut self) { - self.secret.zeroize(); - } -} - -pub struct Store { - path: PathBuf, - record: Record, -} - -fn identity_path( - root: &Path, - api: &str, - project: &str, - channel: &str, - account: &str, -) -> (String, PathBuf) { - // Length-delimited namespace, independent of build and auth tokens. - let mut hash = Sha256::new(); - for part in ["maple.remote.identity.v1", api, project, channel, account] { - hash.update((part.len() as u64).to_be_bytes()); - hash.update(part.as_bytes()); - } - let scope = format!("{:x}", hash.finalize()); - let path = root.join("remote").join(format!("{scope}.json")); - (scope, path) -} - -impl Store { - pub fn exists(root: &Path, api: &str, project: &str, channel: &str, account: &str) -> bool { - identity_path(root, api, project, channel, account) - .1 - .exists() - } - pub fn open( - root: &Path, - api: &str, - project: &str, - channel: &str, - account: &str, - ) -> Result { - let (scope, path) = identity_path(root, api, project, channel, account); - let record: Record = match std::fs::read(&path) { - Ok(mut bytes) => { - let result = serde_json::from_slice(&bytes); - bytes.zeroize(); - result.map_err( - |_| "Remote identity file is invalid; restore it before using remote access.", - )? - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - let record = Record { - version: 1, - scope: scope.clone(), - secret: SecretKey::generate().to_bytes(), - enabled: false, - name: None, - peers: BTreeMap::new(), - attempted_windows: BTreeMap::new(), - }; - write_private_json(&path, &record).map_err(|_| "Could not save remote identity")?; - record - } - Err(_) => return Err("Could not read remote identity".into()), - }; - if record.version != 1 - || record.scope != scope - || record.peers.len() > MAX_PEERS - || record.attempted_windows.len() > MAX_PEERS - { - return Err("Remote identity does not match this account and profile".into()); - } - if let Some(name) = &record.name { - validate_name(name)?; - } - for (key, peer) in &record.peers { - if key - .parse::() - .map_err(|_| "Invalid saved remote device identity")? - .to_string() - != *key - || peer.name.len() > 128 - || peer.name.chars().any(char::is_control) - { - return Err("Invalid saved remote device".into()); - } - } - for (key, windows) in &record.attempted_windows { - if key - .parse::() - .map_err(|_| "Invalid saved pairing history")? - .to_string() - != *key - || windows.len() > 4096 - { - return Err("Invalid saved pairing history".into()); - } - } - Ok(Self { path, record }) - } - - pub fn key(&self) -> SecretKey { - SecretKey::from_bytes(&self.record.secret) - } - pub fn enabled(&self) -> bool { - self.record.enabled - } - pub fn name(&self) -> &str { - self.record - .name - .as_deref() - .unwrap_or_else(|| default_name()) - } - pub fn set_name(&mut self, name: &str) -> Result<(), String> { - let name = validate_name(name)?.to_owned(); - self.update(|record| { - record.name = Some(name); - Ok(()) - }) - } - /// Discovery supplies display metadata only. It cannot create a peer or - /// change its grant, direction, or pending confirmation. - pub fn refresh_peer_names<'a>( - &mut self, - names: impl IntoIterator, - ) -> Result<(), String> { - let changes: Vec<_> = names - .into_iter() - .filter(|(key, name)| { - !name.trim().is_empty() - && name.len() <= 128 - && !name.chars().any(char::is_control) - && self.record.peers.get(*key).is_some_and(|p| p.name != *name) - }) - .collect(); - if changes.is_empty() { - return Ok(()); - } - self.update(|record| { - for (key, name) in changes { - if let Some(peer) = record.peers.get_mut(key) { - peer.name = name.into(); - } - } - Ok(()) - }) - } - pub fn peers(&self) -> &BTreeMap { - &self.record.peers - } - pub fn permits(&self, peer: EndpointId) -> bool { - self.record.enabled - && self - .record - .peers - .get(&peer.to_string()) - .is_some_and(|grant| grant.controls_this_computer) - } - pub fn set_enabled(&mut self, enabled: bool) -> Result<(), String> { - self.update(|record| { - record.enabled = enabled; - Ok(()) - }) - } - pub fn grant(&mut self, key: EndpointId, grant: Peer) -> Result<(), String> { - if grant.name.len() > 128 || grant.name.chars().any(char::is_control) { - return Err("Remote device name is invalid".into()); - } - self.update(|record| { - if !record.peers.contains_key(&key.to_string()) && record.peers.len() >= MAX_PEERS { - return Err("Remove an old paired device before adding another".into()); - } - let mut grant = grant; - if let Some(previous) = record.peers.get(&key.to_string()) { - // Pairing the opposite direction does not revoke an existing - // role. The incoming grant ID belongs to this execution host. - grant.can_connect |= previous.can_connect; - grant.pending_confirmation = previous.pending_confirmation; - } - record.peers.insert(key.to_string(), grant); - Ok(()) - }) - } - pub fn remember_host_proof( - &mut self, - key: EndpointId, - window: [u8; 16], - name: String, - ) -> Result<(), String> { - self.update(|record| { - if !record.peers.contains_key(&key.to_string()) && record.peers.len() >= MAX_PEERS { - return Err("Too many paired computers".into()); - } - let peer = record.peers.entry(key.to_string()).or_insert(Peer { - name: name.clone(), - grant: [0; 16], - controls_this_computer: false, - can_connect: false, - pending_confirmation: None, - }); - peer.name = name; - peer.pending_confirmation = Some(window); - Ok(()) - }) - } - pub fn confirm_host(&mut self, key: EndpointId, window: [u8; 16]) -> Result<(), String> { - self.update(|record| { - let peer = record - .peers - .get_mut(&key.to_string()) - .ok_or("Pairing was removed locally")?; - if peer.pending_confirmation != Some(window) { - return Err("Pairing confirmation is no longer pending".into()); - } - peer.pending_confirmation = None; - peer.can_connect = true; - Ok(()) - }) - } - /// Persist before sending any code-derived proof. A restart cannot retry W. - pub fn reserve_attempt(&mut self, key: EndpointId, window: [u8; 16]) -> Result<(), String> { - self.update(|record| { - if !record.attempted_windows.contains_key(&key.to_string()) && record.attempted_windows.len() >= MAX_PEERS { - return Err("Remote pairing attempt history is full".into()); - } - let windows = record.attempted_windows.entry(key.to_string()).or_default(); - if windows.contains(&window) { return Err("This pairing window has already been attempted. Open a new window on the other computer.".into()); } - // Never evict an attempt that a malicious discovery service could - // replay. Fail closed at a generous per-device lifetime bound. - if windows.len() >= 4096 { return Err("Remote pairing attempt history is full".into()); } - windows.push(window); - Ok(()) - }) - } - pub fn revoke(&mut self, key: EndpointId) -> Result<(), String> { - self.update(|record| { - record.peers.remove(&key.to_string()); - Ok(()) - }) - } - fn update( - &mut self, - edit: impl FnOnce(&mut Record) -> Result<(), String>, - ) -> Result<(), String> { - let mut next = self.record.clone(); - edit(&mut next)?; - write_private_json(&self.path, &next) - .map_err(|_| "Could not save remote device settings")?; - self.record = next; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn computer_names_upgrade_old_records_and_survive_restart_without_changing_identity() { - let root = tempfile::tempdir().unwrap(); - let open = || Store::open(root.path(), "local", "p", "c", "a").unwrap(); - let mut store = open(); - let key = store.key().public(); - // An unset name is omitted, matching records from before names existed. - let record: serde_json::Value = - serde_json::from_slice(&std::fs::read(&store.path).unwrap()).unwrap(); - assert!(record.get("name").is_none()); - assert!(validate_name(open().name()).is_ok()); - store.set_name(" Anthony’s Mac ").unwrap(); - for invalid in ["", "bad\nname", &"x".repeat(65), &"🦀".repeat(33)] { - assert!(store.set_name(invalid).is_err()); - } - drop(store); - let store = open(); - assert_eq!(store.name(), "Anthony’s Mac"); - assert_eq!(store.key().public(), key); - assert!(!store.enabled()); - assert!(store.peers().is_empty()); - let other = Store::open(root.path(), "local", "p", "c", "b").unwrap(); - assert!(other.record.name.is_none()); - assert_eq!( - default_name_from(" Anthony-Mac.local "), - "Anthony-Mac.local" - ); - for hostname in ["", "\n\t", &"🦀".repeat(70)] { - assert!(validate_name(&default_name_from(hostname)).is_ok()); - } - } - - #[test] - fn discovered_names_never_change_or_restore_pairing_authority() { - let root = tempfile::tempdir().unwrap(); - let open = || Store::open(root.path(), "local", "p", "c", "a").unwrap(); - let mut store = open(); - let peer = SecretKey::generate().public(); - let unknown = SecretKey::generate().public().to_string(); - let key = peer.to_string(); - let mut expected = Peer { - name: "Maple computer".into(), - grant: [1; 16], - controls_this_computer: true, - can_connect: false, - pending_confirmation: Some([2; 16]), - }; - store.grant(peer, expected.clone()).unwrap(); - store - .refresh_peer_names([(key.as_str(), "Office Mac"), (&unknown, "Unknown")]) - .unwrap(); - expected.name = "Office Mac".into(); - assert!(open().peers()[&key] == expected); - assert_eq!(store.peers().len(), 1); - assert!(!store.enabled()); - store - .refresh_peer_names([(key.as_str(), "bad\nname")]) - .unwrap(); - assert!(store.peers()[&key] == expected); - store.revoke(peer).unwrap(); - store - .refresh_peer_names([(key.as_str(), "Office Mac")]) - .unwrap(); - assert!(open().peers().is_empty()); - } - - #[test] - fn identities_and_grants_survive_restart_but_not_scope_changes() { - let root = tempfile::tempdir().unwrap(); - let open = - |account| Store::open(root.path(), "local", "project", "unpackaged", account).unwrap(); - let mut a = open("a"); - let key = a.key().public(); - let peer = SecretKey::generate().public(); - a.set_enabled(true).unwrap(); - a.grant( - peer, - Peer { - name: "Test".into(), - grant: [1; 16], - controls_this_computer: true, - can_connect: false, - pending_confirmation: None, - }, - ) - .unwrap(); - drop(a); - let mut a = open("a"); - assert_eq!(a.key().public(), key); - assert!(a.permits(peer)); - assert_ne!(open("b").key().public(), key); - assert!(!open("b").permits(peer)); - assert_ne!( - Store::open(root.path(), "other", "project", "unpackaged", "a") - .unwrap() - .key() - .public(), - key - ); - a.revoke(peer).unwrap(); - drop(a); - assert!(!open("a").permits(peer)); - } - - #[test] - fn lost_ack_and_reverse_pairing_preserve_roles_but_revocation_wins() { - let root = tempfile::tempdir().unwrap(); - let open = || Store::open(root.path(), "local", "p", "c", "a").unwrap(); - let peer = SecretKey::generate().public(); - let mut store = open(); - store.set_enabled(true).unwrap(); - store.reserve_attempt(peer, [1; 16]).unwrap(); - store - .remember_host_proof(peer, [1; 16], "B".into()) - .unwrap(); - assert!(!store.peers()[&peer.to_string()].can_connect); - drop(store); - let mut store = open(); - assert!(store.reserve_attempt(peer, [1; 16]).is_err()); - store.confirm_host(peer, [1; 16]).unwrap(); - store - .grant( - peer, - Peer { - name: "B".into(), - grant: [2; 16], - controls_this_computer: true, - can_connect: false, - pending_confirmation: None, - }, - ) - .unwrap(); - assert!(store.permits(peer)); - assert!(store.peers()[&peer.to_string()].can_connect); - store - .remember_host_proof(peer, [3; 16], "B".into()) - .unwrap(); - assert!(store.permits(peer)); - assert_eq!(store.peers()[&peer.to_string()].grant, [2; 16]); - store.revoke(peer).unwrap(); - assert!(store.confirm_host(peer, [3; 16]).is_err()); - assert!(store.reserve_attempt(peer, [1; 16]).is_err()); - assert!(!open().permits(peer)); - } - - #[test] - fn malformed_identity_is_not_silently_replaced() { - let root = tempfile::tempdir().unwrap(); - let a = Store::open(root.path(), "local", "p", "c", "a").unwrap(); - std::fs::write(&a.path, "broken").unwrap(); - assert!(Store::open(root.path(), "local", "p", "c", "a").is_err()); - assert_eq!(std::fs::read_to_string(&a.path).unwrap(), "broken"); - } -} diff --git a/apps/maple-agent/app/src/remote/streams.rs b/apps/maple-agent/app/src/remote/streams.rs deleted file mode 100644 index b12464a3d..000000000 --- a/apps/maple-agent/app/src/remote/streams.rs +++ /dev/null @@ -1,213 +0,0 @@ -//! One bounded request/reply per QUIC stream. The initial authenticated stream -//! carries ordered events only. No transfer owns the connection's dispatch loop. -use super::wire::{self, Command, Method}; -use crate::host::protocol::Message; -use base64::{Engine as _, engine::general_purpose::STANDARD}; -use iroh::endpoint::{Connection, QuicTransportConfig, RecvStream, SendStream}; -use serde::{Deserialize, Serialize}; -use std::{sync::Arc, time::Duration}; -use tokio::sync::{OwnedSemaphorePermit, Semaphore}; - -pub const MAX_REQUESTS: usize = 40; -pub const IO_TIMEOUT: Duration = Duration::from_secs(10); -// Attachments may take longer than control traffic on a slow link, but cannot -// retain an admitted stream indefinitely. -const TRANSFER_TIMEOUT: Duration = Duration::from_secs(60); -const MAX_CONTROL: usize = 64 * 1024; - -pub fn transport_config() -> QuicTransportConfig { - QuicTransportConfig::builder() - // One event/handshake stream plus bounded request streams. The aggregate - // window exceeds all stream windows, so unread bulk streams cannot use - // up the flow-control credit needed by another stream. - .max_concurrent_bidi_streams(41u32.into()) - .max_concurrent_uni_streams(0u32.into()) - .stream_receive_window((512u32 * 1024).into()) - .receive_window((32u32 * 1024 * 1024).into()) - .build() -} - -pub struct Capacity { - ordinary: Arc, - urgent: Arc, -} - -impl Capacity { - pub fn new() -> Self { - Self { - ordinary: Arc::new(Semaphore::new(32)), - urgent: Arc::new(Semaphore::new(2)), - } - } - pub fn acquire(&self, method: Method) -> Result { - let pool = if method.urgent() { - &self.urgent - } else { - &self.ordinary - }; - pool.clone() - .try_acquire_owned() - .map_err(|_| "Remote computer is busy; try again".into()) - } -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Header { - pub id: u64, - pub method: Method, - pub send_id: Option, -} - -impl Header { - pub async fn read(rx: &mut RecvStream) -> Result { - tokio::time::timeout(IO_TIMEOUT, wire::read(rx, wire::MAX_PREFACE)) - .await - .map_err(|_| "Remote request header timed out")? - .map_err(|e| e.to_string()) - } - pub async fn body(self, rx: &mut RecvStream) -> Result { - let params = wire::read_progress( - rx, - body_limit(self.method), - transfer_timeout(self.method), - false, - ) - .await - .map_err(|e| e.to_string())?; - Ok(Command { - id: self.id, - method: self.method, - send_id: self.send_id, - params, - }) - } -} - -fn body_limit(method: Method) -> usize { - if method.urgent() { - MAX_CONTROL - } else { - wire::MAX_FRAME - } -} - -fn transfer_timeout(method: Method) -> Duration { - if method.urgent() { - IO_TIMEOUT - } else { - TRANSFER_TIMEOUT - } -} - -// The local facade still returns bytes. Only the remote image reply uses base64. -pub fn encode_result( - method: Method, - result: Result, -) -> Result { - let value = result?; - if method != Method::ReadImageAttachment { - return Ok(value); - } - let bytes: Vec = serde_json::from_value(value).map_err(|_| "Invalid image attachment")?; - Ok(serde_json::Value::String(STANDARD.encode(bytes))) -} -pub fn decode_result(method: Method, message: Message) -> Message { - match message { - Message::Response { - id, - result: Ok(value), - } if method == Method::ReadImageAttachment => { - let result = value - .as_str() - .ok_or("Invalid remote image attachment") - .and_then(|text| { - STANDARD - .decode(text) - .map_err(|_| "Invalid remote image attachment") - }) - .and_then(|bytes| { - serde_json::to_value(bytes).map_err(|_| "Invalid remote image attachment") - }) - .map_err(str::to_owned); - Message::Response { id, result } - } - other => other, - } -} - -pub async fn reply(tx: &mut SendStream, message: &Message) -> Result<(), String> { - let bytes = match wire::encode(message, wire::MAX_FRAME) { - Ok(bytes) => bytes, - Err(error) => match message { - Message::Response { id, .. } => wire::encode( - &Message::Response { - id: *id, - result: Err("Remote reply is too large to transfer".into()), - }, - wire::MAX_FRAME, - ) - .map_err(|e| e.to_string())?, - _ => return Err(error.to_string()), - }, - }; - wire::write_progress(tx, &bytes, TRANSFER_TIMEOUT) - .await - .map_err(|e| e.to_string())?; - tx.finish().map_err(|_| "Remote reply closed")?; - tokio::time::timeout(TRANSFER_TIMEOUT, tx.stopped()) - .await - .map_err(|_| "Remote reply transfer timed out")? - .map_err(|_| "Remote reply disconnected")?; - Ok(()) -} - -pub async fn call(connection: &Connection, command: Command) -> Result { - let id = command.id; - // Reject size before transmitting anything. A later transport failure is - // uncertain and must disconnect/query the receipt, never report not-sent. - let Ok(body) = wire::encode(&command.params, body_limit(command.method)) else { - return Ok(Message::Response { - id, - result: Err("Remote message is too large. Try fewer or smaller images.".into()), - }); - }; - let header = Header { - id, - method: command.method, - send_id: command.send_id, - }; - let (mut tx, mut rx) = tokio::time::timeout(IO_TIMEOUT, connection.open_bi()) - .await - .map_err(|_| "Remote stream capacity timed out")? - .map_err(|_| "Remote connection closed")?; - tx.set_priority(if header.method.urgent() { 10 } else { 0 }) - .map_err(|_| "Remote stream closed")?; - tokio::time::timeout(IO_TIMEOUT, wire::write(&mut tx, &header, wire::MAX_PREFACE)) - .await - .map_err(|_| "Remote request header timed out")? - .map_err(|e| e.to_string())?; - wire::write_progress(&mut tx, &body, transfer_timeout(header.method)) - .await - .map_err(|e| e.to_string())?; - tx.finish().map_err(|_| "Remote request closed")?; - let response = wire::read_progress::(&mut rx, wire::MAX_FRAME, TRANSFER_TIMEOUT, true); - let response = if header.method.urgent() { - tokio::time::timeout(IO_TIMEOUT, response) - .await - .map_err(|_| "Remote control reply timed out")? - } else { - response.await - }; - match response { - Ok(message @ Message::Response { id: received, .. }) if received == id => Ok(message), - Ok(message @ Message::Closed { .. }) => Ok(message), - Err(error @ (wire::Error::Disconnected | wire::Error::TimedOut)) => Err(error.to_string()), - _ => Ok(Message::Closed { - reason: "Invalid remote reply; reconnect with compatible Maple versions".into(), - }), - } -} - -#[cfg(test)] -mod tests; diff --git a/apps/maple-agent/app/src/remote/streams/tests.rs b/apps/maple-agent/app/src/remote/streams/tests.rs deleted file mode 100644 index fbbfc1a06..000000000 --- a/apps/maple-agent/app/src/remote/streams/tests.rs +++ /dev/null @@ -1,312 +0,0 @@ -use super::*; -use iroh::{Endpoint, RelayMode, endpoint::presets}; -use serde_json::json; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; - -async fn endpoints() -> (Endpoint, Endpoint, Connection, Connection) { - async fn endpoint() -> Endpoint { - Endpoint::builder(presets::Minimal) - .alpns(vec![wire::ALPN.to_vec()]) - .relay_mode(RelayMode::Disabled) - .clear_address_lookup() - .clear_ip_transports() - .bind_addr((std::net::Ipv4Addr::LOCALHOST, 0)) - .unwrap() - .transport_config(transport_config()) - .bind() - .await - .unwrap() - } - let a = endpoint().await; - let b = endpoint().await; - let (client, server) = tokio::join!( - async { a.connect(b.addr(), wire::ALPN).await.unwrap() }, - async { b.accept().await.unwrap().await.unwrap() }, - ); - (a, b, client, server) -} - -fn command(id: u64, method: Method) -> Command { - Command { - id, - method, - send_id: None, - params: json!(["account", "task"]), - } -} - -async fn receive( - connection: &Connection, - capacity: &Capacity, -) -> (Header, SendStream, RecvStream, OwnedSemaphorePermit) { - let (tx, mut rx) = connection.accept_bi().await.unwrap(); - let header = Header::read(&mut rx).await.unwrap(); - let slot = capacity.acquire(header.method).unwrap(); - (header, tx, rx, slot) -} - -#[tokio::test] -async fn stop_and_answers_pass_stalled_upload_and_download() { - let (a, b, client, server) = endpoints().await; - let exercise = async { - let capacity = Capacity::new(); - let (mut upload, mut upload_reply) = client.open_bi().await.unwrap(); - let body = wire::encode( - &json!(["account", {"image": "x".repeat(2 * 1024 * 1024)}]), - wire::MAX_FRAME, - ) - .unwrap(); - wire::write( - &mut upload, - &Header { - id: 1, - method: Method::SendMessage, - send_id: Some("original-send".into()), - }, - wire::MAX_PREFACE, - ) - .await - .unwrap(); - upload.write_u32(body.len() as u32).await.unwrap(); - upload.write_all(&body[..1024]).await.unwrap(); - let (header, mut tx, mut rx, upload_slot) = receive(&server, &capacity).await; - let upload_received = Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let admitted = upload_received.clone(); - let upload_task = tokio::spawn(async move { - let _slot = upload_slot; - let request = header.body(&mut rx).await.unwrap(); - assert_eq!(request.send_id.as_deref(), Some("original-send")); - admitted.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - reply( - &mut tx, - &Message::Response { - id: request.id, - result: Ok(json!("admitted")), - }, - ) - .await - .unwrap(); - }); - - // Request an image, then stop reading its reply after the length. Its - // payload exceeds the actual per-stream receive window, forcing QUIC - // backpressure rather than relying on a sleeping mock provider. - let (mut download, mut image) = client.open_bi().await.unwrap(); - wire::write( - &mut download, - &Header { - id: 2, - method: Method::ReadImageAttachment, - send_id: None, - }, - wire::MAX_PREFACE, - ) - .await - .unwrap(); - wire::write(&mut download, &json!([]), wire::MAX_FRAME) - .await - .unwrap(); - download.finish().unwrap(); - let (header, mut tx, mut rx, download_slot) = receive(&server, &capacity).await; - let request = header.body(&mut rx).await.unwrap(); - let download_task = tokio::spawn(async move { - let _slot = download_slot; - reply( - &mut tx, - &Message::Response { - id: request.id, - result: Ok(json!("y".repeat(2 * 1024 * 1024))), - }, - ) - .await - .unwrap(); - }); - let image_len = image.read_u32().await.unwrap() as usize; - assert!(image_len > 512 * 1024); - - // Fill the ordinary admission pool as well. Urgent work must not depend - // on a free ordinary slot, a completed upload or a drained reply. - let _ordinary: Vec<_> = (0..30) - .map(|_| capacity.acquire(Method::ListSessions).unwrap()) - .collect(); - assert!(capacity.acquire(Method::SendMessage).is_err()); - for (id, method) in [(3, Method::CancelRun), (4, Method::AnswerQuestion)] { - let send = call(&client, command(id, method)); - let answer = async { - let (header, mut tx, mut rx, _slot) = receive(&server, &capacity).await; - assert!(header.method.urgent()); - let request = header.body(&mut rx).await.unwrap(); - reply( - &mut tx, - &Message::Response { - id: request.id, - result: Ok(json!("done")), - }, - ) - .await - .unwrap(); - }; - let (response, ()) = - tokio::time::timeout(Duration::from_secs(3), async { tokio::join!(send, answer) }) - .await - .expect("urgent command blocked behind a transfer"); - assert!( - matches!(response.unwrap(), Message::Response { id: received, result: Ok(value) } if received == id && value == json!("done")) - ); - } - assert_eq!(upload_received.load(std::sync::atomic::Ordering::SeqCst), 0); - assert!(!download_task.is_finished()); - - // Completing the upload still admits exactly one original command. - let finish_upload = async { - upload.write_all(&body[1024..]).await.unwrap(); - upload.finish().unwrap(); - assert!(matches!( - wire::read::(&mut upload_reply, wire::MAX_FRAME) - .await - .unwrap(), - Message::Response { - id: 1, - result: Ok(_) - } - )); - }; - let finish_download = async { - let mut body = vec![0; image_len]; - image.read_exact(&mut body).await.unwrap(); - assert!(matches!( - serde_json::from_slice::(&body).unwrap(), - Message::Response { - id: 2, - result: Ok(_) - } - )); - }; - tokio::join!(finish_upload, finish_download); - upload_task.await.unwrap(); - download_task.await.unwrap(); - assert_eq!(upload_received.load(std::sync::atomic::Ordering::SeqCst), 1); - }; - let result = tokio::time::timeout(Duration::from_secs(20), exercise).await; - tokio::join!(a.close(), b.close()); - result.unwrap(); -} - -#[tokio::test] -async fn closing_connection_interrupts_incomplete_body_without_admission() { - let (a, b, client, server) = endpoints().await; - let exercise = async { - let (mut tx, _rx) = client.open_bi().await.unwrap(); - wire::write( - &mut tx, - &Header { - id: 1, - method: Method::SendMessage, - send_id: None, - }, - wire::MAX_PREFACE, - ) - .await - .unwrap(); - tx.write_u32(4096).await.unwrap(); - tx.write_all(b"[").await.unwrap(); - let capacity = Capacity::new(); - let (header, _reply, mut body, _slot) = receive(&server, &capacity).await; - // Used by revocation/account change and explicit connection teardown. - server.close(0u32.into(), b"revoked"); - assert!(header.body(&mut body).await.is_err()); - }; - let result = tokio::time::timeout(Duration::from_secs(5), exercise).await; - tokio::join!(a.close(), b.close()); - result.unwrap(); -} - -#[tokio::test] -async fn urgent_body_is_bounded_and_reply_identity_is_checked() { - let (a, b, client, server) = endpoints().await; - let exercise = async { - let mut oversized = command(7, Method::AnswerQuestion); - oversized.params = json!("x".repeat(MAX_CONTROL)); - assert!(matches!( - call(&client, oversized).await.unwrap(), - Message::Response { - id: 7, - result: Err(_) - } - )); - // No stream was emitted for that rejected body: the first accepted - // header below must be the next request. - let send = call(&client, command(8, Method::CancelRun)); - let wrong_reply = async { - let capacity = Capacity::new(); - let (header, mut tx, mut rx, _slot) = receive(&server, &capacity).await; - assert_eq!(header.id, 8); - header.body(&mut rx).await.unwrap(); - reply( - &mut tx, - &Message::Response { - id: 9, - result: Ok(json!(null)), - }, - ) - .await - .unwrap(); - }; - let (response, ()) = tokio::join!(send, wrong_reply); - assert!(matches!(response.unwrap(), Message::Closed { .. })); - }; - let result = tokio::time::timeout(Duration::from_secs(5), exercise).await; - tokio::join!(a.close(), b.close()); - result.unwrap(); -} - -#[tokio::test] -async fn large_image_and_oversized_reply_do_not_break_other_requests() { - let (a, b, client, server) = endpoints().await; - let exercise = async { - let bytes = vec![255_u8; 10 * 1024 * 1024]; - let image = encode_result( - Method::ReadImageAttachment, - Ok(serde_json::to_value(&bytes).unwrap()), - ) - .unwrap(); - assert!(wire::encode(&image, wire::MAX_FRAME).is_ok()); - let cases = [ - (1, Method::ReadImageAttachment, image), - (2, Method::ListSessions, json!("x".repeat(wire::MAX_FRAME))), - (3, Method::CancelRun, json!("stopped")), - ]; - for (id, method, value) in cases { - let send = call(&client, command(id, method)); - let serve = async { - let capacity = Capacity::new(); - let (header, mut tx, mut rx, _slot) = receive(&server, &capacity).await; - header.body(&mut rx).await.unwrap(); - reply( - &mut tx, - &Message::Response { - id, - result: Ok(value), - }, - ) - .await - .unwrap(); - }; - let (reply, ()) = tokio::join!(send, serve); - let Message::Response { result, .. } = decode_result(method, reply.unwrap()) else { - panic!("request closed the connection") - }; - match id { - 1 => assert_eq!( - serde_json::from_value::>(result.unwrap()).unwrap(), - bytes - ), - 2 => assert!(result.unwrap_err().contains("too large")), - _ => assert_eq!(result.unwrap(), json!("stopped")), - } - } - }; - let result = tokio::time::timeout(Duration::from_secs(30), exercise).await; - tokio::join!(a.close(), b.close()); - result.unwrap(); -} diff --git a/apps/maple-agent/app/src/remote/wire.rs b/apps/maple-agent/app/src/remote/wire.rs deleted file mode 100644 index d9bde29ad..000000000 --- a/apps/maple-agent/app/src/remote/wire.rs +++ /dev/null @@ -1,493 +0,0 @@ -//! The remote surface is independently versioned and deliberately smaller than -//! local IPC. Adding a local backend operation never makes it remotely callable. -use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; - -// Keep the ALPN family stable so older peers receive the explicit version refusal. -pub const ALPN: &[u8] = b"maple-agent/remote/1"; -pub const VERSION: u32 = 4; -pub const MAX_FRAME: usize = 16 * 1024 * 1024; -pub const MAX_PREFACE: usize = 4096; - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Hello { - pub version: u32, - pub minimum_version: u32, - pub account: String, - pub project: String, - pub deployment: String, - pub channel: String, - pub mode: Mode, -} - -#[derive(Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] -pub enum Mode { - Control, - ConfirmGrant { window: [u8; 16] }, - Pair { window: [u8; 16], admission: String }, -} - -// Refusals are readable at every handshake stage, including before the peer's -// full Hello can be decoded. Only loss of authority/compatibility ends recovery. -#[derive(Debug, Serialize, Deserialize)] -pub struct Refusal { - pub terminal: bool, - pub message: String, -} -impl Refusal { - pub fn temporary(message: impl Into) -> Self { - Self { - terminal: false, - message: message.into(), - } - } - pub fn terminal(message: impl Into) -> Self { - Self { - terminal: true, - message: message.into(), - } - } - pub fn into_error(self) -> String { - if self.terminal { - format!("Remote access ended: {}", self.message) - } else { - self.message - } - } -} -impl From for Refusal { - fn from(message: String) -> Self { - Self::temporary(message) - } -} -impl From<&str> for Refusal { - fn from(message: &str) -> Self { - Self::temporary(message) - } -} -impl std::fmt::Display for Refusal { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.message) - } -} - -pub async fn read_hello(reader: &mut (impl AsyncRead + Unpin)) -> Result { - let value: serde_json::Value = read(reader, MAX_PREFACE) - .await - .map_err(|error| Refusal::terminal(error.to_string()))?; - #[derive(Deserialize)] - struct Version { - version: u32, - minimum_version: u32, - } - let version: Version = serde_json::from_value(value.clone()) - .map_err(|_| Refusal::terminal(Error::InvalidFrame.to_string()))?; - check_version(version.version, version.minimum_version) - .map_err(|error| Refusal::terminal(error.to_string()))?; - serde_json::from_value(value).map_err(|_| Refusal::terminal(Error::InvalidFrame.to_string())) -} - -fn check_version(version: u32, minimum: u32) -> Result<(), Error> { - if minimum == 0 || minimum > version || minimum > VERSION || version < VERSION { - Err(Error::Incompatible) - } else { - Ok(()) - } -} - -#[cfg(test)] -impl Hello { - pub fn check_version(&self) -> Result<(), Error> { - check_version(self.version, self.minimum_version) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Method { - TaskBootstrap, - RecentProjectRoots, - SelectProjectRoot, - ListSessions, - LocalBootstrap, - RecoverySnapshot, - CreateSession, - LoadSession, - SetSessionState, - DeleteSession, - RemoveProjectRoot, - RenameSession, - ProjectTrust, - SetProjectTrust, - CancelQueuedMessage, - BeginQueuedMessageEdit, - EndQueuedMessageEdit, - SendMessage, - SendOutcome, - ReadImageAttachment, - CancelRun, - AvailableModelIds, - ModelCatalogEntry, - ListSessionMcpServers, - SetSessionMcpServerEnabled, - ListMcpServers, - ListProjectMcpServers, - SetProjectMcpServerEnabled, - ListIntegrations, - SetIntegrationEnabled, - SaveMcpServers, - SetSessionWebEnabled, - CompactSession, - SessionSubagents, - CancelExternalAgent, - ListSlashCommands, - ResolveSlashCommand, - SummarizeToolCall, - SummarizeThinking, - AskSideQuestion, - ContextUsage, - AnswerQuestion, - ThinkingState, - SetThinkingLevel, - SaveDefaultModel, - SavedModel, - LoadToolSummaries, - StoreToolSummary, -} - -impl Method { - pub fn urgent(self) -> bool { - matches!( - self, - Self::CancelRun | Self::AnswerQuestion | Self::CancelExternalAgent - ) - } - pub fn parse(value: &str) -> Result { - serde_json::from_value(serde_json::Value::String(value.into())) - .map_err(|_| "This operation is available only on the local computer.".into()) - } - pub fn name(self) -> String { - // This enum is serialized only as a unit string. - serde_json::to_value(self) - .expect("unit enum serializes") - .as_str() - .expect("unit enum is string") - .into() - } -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Command { - pub id: u64, - pub method: Method, - pub send_id: Option, - pub params: serde_json::Value, -} - -#[derive(Debug, PartialEq, Eq)] -pub enum Error { - Disconnected, - TimedOut, - InvalidFrame, - Incompatible, -} -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::TimedOut => "Remote transfer stopped making progress", - Self::Disconnected => "Remote computer disconnected", - Self::InvalidFrame => "The remote computer sent an invalid message. Update both computers before reconnecting.", - Self::Incompatible => "These Maple remote protocol versions are incompatible. Update both computers.", - }) - } -} -impl std::error::Error for Error {} - -pub async fn read( - reader: &mut (impl AsyncRead + Unpin), - limit: usize, -) -> Result { - let size = reader.read_u32().await.map_err(|_| Error::Disconnected)? as usize; - if size == 0 || size > limit.min(MAX_FRAME) { - return Err(Error::InvalidFrame); - } - let mut bytes = vec![0; size]; - reader - .read_exact(&mut bytes) - .await - .map_err(|_| Error::Disconnected)?; - serde_json::from_slice(&bytes).map_err(|_| Error::InvalidFrame) -} - -pub async fn write( - writer: &mut (impl AsyncWrite + Unpin), - value: &T, - limit: usize, -) -> Result<(), Error> { - let bytes = encode(value, limit)?; - write_encoded(writer, &bytes).await -} - -pub fn encode(value: &T, limit: usize) -> Result, Error> { - let bytes = serde_json::to_vec(value).map_err(|_| Error::InvalidFrame)?; - if bytes.len() > limit.min(MAX_FRAME) { - return Err(Error::InvalidFrame); - } - Ok(bytes) -} - -pub async fn write_encoded( - writer: &mut (impl AsyncWrite + Unpin), - bytes: &[u8], -) -> Result<(), Error> { - writer - .write_u32(bytes.len() as u32) - .await - .map_err(|_| Error::Disconnected)?; - writer - .write_all(bytes) - .await - .map_err(|_| Error::Disconnected)?; - writer.flush().await.map_err(|_| Error::Disconnected) -} - -/// Per-progress deadline, not a minimum network throughput requirement. The -/// first reply byte may wait for backend work; once a frame starts, bounded -/// buffers and an idle deadline prevent a stalled transfer retaining capacity. -pub async fn read_progress( - reader: &mut (impl AsyncRead + Unpin), - limit: usize, - idle: std::time::Duration, - wait_for_reply: bool, -) -> Result { - let mut prefix = [0; 4]; - if wait_for_reply { - reader - .read_exact(&mut prefix[..1]) - .await - .map_err(|_| Error::Disconnected)?; - read_progress_bytes(reader, &mut prefix[1..], idle).await?; - } else { - read_progress_bytes(reader, &mut prefix, idle).await?; - } - let size = u32::from_be_bytes(prefix) as usize; - if size == 0 || size > limit.min(MAX_FRAME) { - return Err(Error::InvalidFrame); - } - let mut bytes = vec![0; size]; - read_progress_bytes(reader, &mut bytes, idle).await?; - serde_json::from_slice(&bytes).map_err(|_| Error::InvalidFrame) -} -async fn read_progress_bytes( - reader: &mut (impl AsyncRead + Unpin), - mut bytes: &mut [u8], - idle: std::time::Duration, -) -> Result<(), Error> { - while !bytes.is_empty() { - let count = tokio::time::timeout(idle, reader.read(bytes)) - .await - .map_err(|_| Error::TimedOut)? - .map_err(|_| Error::Disconnected)?; - if count == 0 { - return Err(Error::Disconnected); - } - bytes = &mut bytes[count..]; - } - Ok(()) -} -pub async fn write_progress( - writer: &mut (impl AsyncWrite + Unpin), - bytes: &[u8], - idle: std::time::Duration, -) -> Result<(), Error> { - let prefix = (bytes.len() as u32).to_be_bytes(); - for mut chunk in std::iter::once(prefix.as_slice()).chain(bytes.chunks(64 * 1024)) { - while !chunk.is_empty() { - let count = tokio::time::timeout(idle, writer.write(chunk)) - .await - .map_err(|_| Error::TimedOut)? - .map_err(|_| Error::Disconnected)?; - if count == 0 { - return Err(Error::Disconnected); - } - chunk = &chunk[count..]; - } - } - tokio::time::timeout(idle, writer.flush()) - .await - .map_err(|_| Error::TimedOut)? - .map_err(|_| Error::Disconnected) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn privileged_local_operations_are_not_remote_commands() { - for name in [ - "login", - "logout_and_clear", - "change_password", - "create_api_key", - "delete_api_key", - "account", - "plan_usage", - "billing_status", - "start_checkout", - "confirm_account_deletion", - "setup_integration", - "stop_runtime", - "start_runtime", - "detach", - "stop", - "remote_enable", - "remote_set_name", - "remote_pair", - "transcribe_audio", - "synthesize_speech", - ] { - assert!(Method::parse(name).is_err(), "{name}"); - } - for name in [ - "send_message", - "send_outcome", - "cancel_run", - "answer_question", - "set_thinking_level", - ] { - assert_eq!(Method::parse(name).unwrap().name(), name); - } - } - - #[tokio::test] - async fn oversized_and_unknown_commands_fail_terminally() { - let (mut tx, mut rx) = tokio::io::duplex(256); - tx.write_u32(MAX_PREFACE as u32 + 1).await.unwrap(); - assert!(matches!( - read::(&mut rx, MAX_PREFACE).await, - Err(Error::InvalidFrame) - )); - let command = - serde_json::json!({"id": 1, "method": "login", "params": [], "send_id": null}); - write(&mut tx, &command, MAX_PREFACE).await.unwrap(); - assert!(matches!( - read::(&mut rx, MAX_PREFACE).await, - Err(Error::InvalidFrame) - )); - } - - #[test] - fn compatible_versions_do_not_depend_on_build_identity() { - let mut hello = Hello { - version: VERSION, - minimum_version: VERSION, - account: "a".into(), - project: "p".into(), - deployment: "d".into(), - channel: "unpackaged".into(), - mode: Mode::Control, - }; - assert!(hello.check_version().is_ok()); - hello.version = VERSION - 1; - hello.minimum_version = VERSION - 1; - assert_eq!(hello.check_version(), Err(Error::Incompatible)); - hello.minimum_version = VERSION; - hello.version = VERSION + 1; - assert!(hello.check_version().is_ok()); - hello.minimum_version = VERSION + 1; - assert_eq!(hello.check_version(), Err(Error::Incompatible)); - hello.version = VERSION; - assert_eq!(hello.check_version(), Err(Error::Incompatible)); - } - #[tokio::test] - async fn future_and_malformed_hello_receive_explicit_terminal_refusals() { - for message in [ - serde_json::json!({"version": VERSION + 1, "minimum_version": VERSION + 1, "future_field": true}), - serde_json::json!({"version": VERSION, "minimum_version": VERSION, "future_field": true}), - ] { - let (mut tx, mut rx) = tokio::io::duplex(4096); - write(&mut tx, &message, MAX_PREFACE).await.unwrap(); - let refusal = match read_hello(&mut rx).await { - Err(r) => r, - Ok(_) => panic!("accepted malformed hello"), - }; - assert!(refusal.terminal); - assert!(refusal.message.contains("Update both computers")); - // All pairing/confirmation stages can decode the same refusal. - let encoded = serde_json::to_value(Err::<(), _>(refusal)).unwrap(); - let proof: Result<(Vec, Vec), Refusal> = - serde_json::from_value(encoded.clone()).unwrap(); - let grant: Result<[u8; 16], Refusal> = serde_json::from_value(encoded).unwrap(); - assert_eq!(proof.unwrap_err().message, grant.unwrap_err().message); - } - } - - #[tokio::test(start_paused = true)] - async fn transfer_deadline_measures_inactivity_not_total_duration() { - use std::time::Duration; - let idle = Duration::from_secs(10); - let (mut tx, mut rx) = tokio::io::duplex(16); - let sender = tokio::spawn(async move { - let bytes = b"\"slow\""; - tx.write_u32(bytes.len() as u32).await.unwrap(); - for byte in bytes { - tokio::time::sleep(Duration::from_secs(6)).await; - tx.write_all(&[*byte]).await.unwrap(); - } - }); - let start = tokio::time::Instant::now(); - assert_eq!( - read_progress::(&mut rx, 64, idle, false) - .await - .unwrap(), - "slow" - ); - assert!(start.elapsed() > idle); - sender.await.unwrap(); - let (_tx, mut rx) = tokio::io::duplex(16); - assert_eq!( - read_progress::(&mut rx, 64, idle, false).await, - Err(Error::TimedOut) - ); - } - #[test] - fn shipped_wire_examples_are_stable() { - let examples: serde_json::Value = - serde_json::from_str(include_str!("wire_v4.json")).unwrap(); - fn check(value: &serde_json::Value) { - let decoded: T = serde_json::from_value(value.clone()).unwrap(); - assert_eq!(serde_json::to_value(decoded).unwrap(), *value); - } - check::(&examples["hello"]); - check::(&examples["pair"]); - check::(&examples["header"]); - check::(&examples["command"]); - check::>(&examples["refusal"]); - for key in ["image", "failure", "sessions", "receipt"] { - check::(&examples[key]); - } - check::(&examples["send"]); - check::<(String, maple_agent::agent::AgentSendMessageRequest)>(&examples["send"]["params"]); - check::>( - &examples["sessions"]["Response"]["result"]["Ok"], - ); - check::(&examples["receipt"]["Response"]["result"]["Ok"]); - for value in examples["events"].as_array().unwrap() { - check::(value); - let crate::host::protocol::Message::Event(event) = - serde_json::from_value(value.clone()).unwrap() - else { - panic!("expected event"); - }; - assert!( - super::super::control::remote_event(&event), - "fixture must exercise a forwarded event" - ); - } - assert_eq!(examples["hello"]["version"], VERSION); - } -} diff --git a/apps/maple-agent/app/src/remote/wire_v4.json b/apps/maple-agent/app/src/remote/wire_v4.json deleted file mode 100644 index 18af44762..000000000 --- a/apps/maple-agent/app/src/remote/wire_v4.json +++ /dev/null @@ -1,187 +0,0 @@ -{ - "hello": { - "version": 4, - "minimum_version": 4, - "account": "account", - "project": "project", - "deployment": "local", - "channel": "unpackaged", - "mode": { - "kind": "control" - } - }, - "pair": { - "version": 4, - "minimum_version": 4, - "account": "account", - "project": "project", - "deployment": "local", - "channel": "unpackaged", - "mode": { - "kind": "pair", - "window": [ - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1, - 1 - ], - "admission": "opaque-admission" - } - }, - "header": { - "id": 7, - "method": "send_message", - "send_id": "original-send" - }, - "command": { - "id": 8, - "method": "send_outcome", - "send_id": null, - "params": "original-send" - }, - "refusal": { - "Err": { - "terminal": false, - "message": "Pairing window is closed" - } - }, - "image": { - "Response": { - "id": 9, - "result": { - "Ok": "AP8=" - } - } - }, - "failure": { - "Response": { - "id": 10, - "result": { - "Err": "Remote reply is too large to transfer" - } - } - }, - "events": [ - { - "Event": { - "SessionUpdated": { - "session_id": "task", - "run_id": "run", - "session": { - "id": "task", - "title": "Fixture task", - "projectRoot": "/project", - "createdMs": 1, - "updatedMs": 2, - "messageCount": 1, - "model": "fixture-model", - "webEnabled": false, - "state": "active", - "acp": false - } - } - } - }, - { - "Event": { - "Run": { - "session_id": "task", - "run_id": "run", - "event": { - "TimelineItem": { - "revision": 3, - "id": "reply", - "itemType": "message", - "role": "assistant", - "text": "hello", - "createdMs": 2, - "merge": "append" - } - } - } - } - }, - { - "Event": { - "Run": { - "session_id": "task", - "run_id": "run", - "event": { - "Finished": "Completed" - } - } - } - }, - { - "Event": { - "QuestionResolved": { - "request_id": "question" - } - } - } - ], - "send": { - "id": 11, - "method": "send_message", - "send_id": "d0c0caaa-90f4-46eb-b78a-747130b56187", - "params": [ - "account", - { - "sessionId": "task", - "text": "Inspect this", - "model": "fixture-model", - "contextLimit": 32000, - "visionCapable": true, - "steer": true, - "queueId": null, - "attachments": [ - { - "name": "pixel.png", - "dataUrl": "data:image/png;base64,AP8=" - } - ] - } - ] - }, - "sessions": { - "Response": { - "id": 12, - "result": { - "Ok": [ - { - "id": "task", - "title": "Fixture task", - "projectRoot": "/project", - "createdMs": 1, - "updatedMs": 2, - "messageCount": 1, - "model": "fixture-model", - "webEnabled": false, - "state": "active", - "acp": false - } - ] - } - } - }, - "receipt": { - "Response": { - "id": 13, - "result": { - "Ok": "Interrupted" - } - } - } -} diff --git a/apps/maple-agent/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index f034dd72d..9f13f3045 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -7,16 +7,6 @@ use std::path::PathBuf; -#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] -pub struct RemoteSidebarSettings { - #[serde(default)] - pub display_name: String, - #[serde(default)] - pub pinned_tasks: Vec, - #[serde(default)] - pub project_names: std::collections::HashMap, -} - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AppSettings { /// Whether tool cards show input/output payloads by default. @@ -47,10 +37,6 @@ pub struct AppSettings { /// Display names for project roots, keyed by absolute path. #[serde(default)] pub project_names: std::collections::HashMap, - /// Local presentation preferences for each paired endpoint. A remote - /// `/work/project` is not the same project as that path on this computer. - #[serde(default)] - pub remote_sidebars: std::collections::HashMap, /// Whether run completion and questions raise desktop notifications /// while the window is not focused. #[serde(default = "default_desktop_notifications")] @@ -213,7 +199,6 @@ impl Default for AppSettings { pinned_roots: Vec::new(), pinned_tasks: Vec::new(), project_names: std::collections::HashMap::new(), - remote_sidebars: std::collections::HashMap::new(), desktop_notifications: default_desktop_notifications(), reduce_motion: false, harness_instructions: String::new(), diff --git a/apps/maple-agent/app/src/tray.rs b/apps/maple-agent/app/src/tray.rs deleted file mode 100644 index 7edfc18d5..000000000 --- a/apps/maple-agent/app/src/tray.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! Stock status-icon backend; no service supervisor or custom native tray code. -use tray_icon::{ - Icon, TrayIcon, TrayIconBuilder, - menu::{Menu, MenuEvent, MenuItem}, -}; - -pub enum Action { - Open, - Quit, -} -pub struct StatusIcon(TrayIcon); -impl StatusIcon { - pub fn new() -> Result<(Self, tokio::sync::mpsc::Receiver), String> { - let menu = Menu::new(); - let open = MenuItem::with_id("maple-open", "Open Maple", true, None); - let quit = MenuItem::with_id("maple-quit", "Quit Maple", true, None); - menu.append_items(&[&open, &quit]) - .map_err(|_| "Could not create the Maple tray menu")?; - let image = image::load_from_memory(include_bytes!("../packaging/maple-agent.png")) - .map_err(|_| "Could not load the Maple tray icon")? - .resize(32, 32, image::imageops::FilterType::Lanczos3) - .into_rgba8(); - let icon = - Icon::from_rgba(image.into_raw(), 32, 32).map_err(|_| "Invalid Maple tray icon")?; - let tray = TrayIconBuilder::new() - .with_icon(icon) - .with_menu(Box::new(menu)) - .with_tooltip("Maple — remote access off") - .build() - .map_err(|_| "The desktop tray is unavailable")?; - tray.set_visible(false) - .map_err(|_| "Could not configure the Maple tray")?; - let (sender, receiver) = tokio::sync::mpsc::channel(8); - MenuEvent::set_event_handler(Some(move |event: MenuEvent| { - let action = match event.id.as_ref() { - "maple-open" => Action::Open, - "maple-quit" => Action::Quit, - _ => return, - }; - let _ = sender.try_send(action); - })); - Ok((Self(tray), receiver)) - } - pub fn set_enabled(&self, enabled: bool) { - let _ = self.0.set_tooltip(Some(if enabled { - "Maple — remote access on" - } else { - "Maple — remote access off" - })); - let _ = self.0.set_visible(enabled); - } -} - -/// Creating an SNI item does not mean the desktop actually displays a tray. -#[cfg(target_os = "linux")] -pub async fn usable() -> bool { - let check = async { - let connection = zbus::Connection::session().await.ok()?; - let watcher = zbus::Proxy::new( - &connection, - "org.kde.StatusNotifierWatcher", - "/StatusNotifierWatcher", - "org.kde.StatusNotifierWatcher", - ) - .await - .ok()?; - watcher - .get_property::("IsStatusNotifierHostRegistered") - .await - .ok() - }; - tokio::time::timeout(std::time::Duration::from_secs(1), check) - .await - .ok() - .flatten() - .unwrap_or(false) -} diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index c6cf349de..c9ccd71da 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -82,11 +82,7 @@ impl ChatScreen { ChatPopup::Project, chip( "root-picker", - Some(if self.backend.remote_host().is_some() { - "globe" - } else { - "folder-open" - }), + Some("folder-open"), self.project_label.clone(), true, self.popup.is_open(&ChatPopup::Project), @@ -159,9 +155,6 @@ impl ChatScreen { let mut menu = Menu::new("project-menu", px(480.)) .label("Projects") .application_vim(self.application_vim_enabled); - if self.computers.len() > 1 && self.root_input.is_none() { - return self.computers_project_menu(); - } for path in header_project_paths(&self.recent_roots) { let pick = path.clone(); let label = diff --git a/apps/maple-agent/app/src/ui/chat/computers.rs b/apps/maple-agent/app/src/ui/chat/computers.rs deleted file mode 100644 index 4b179d28e..000000000 --- a/apps/maple-agent/app/src/ui/chat/computers.rs +++ /dev/null @@ -1,296 +0,0 @@ -//! Host-scoped navigation data. Transcripts, drafts and execution stay in their -//! existing per-computer ChatScreen; only project/task summaries are projected. -use super::*; - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub(crate) enum Destination { - Overview, - Project(String), - Task(String), - NewProject, -} - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -pub(crate) struct Navigate { - pub computer: Option, - pub destination: Destination, -} -pub(crate) struct NavigationChanged; - -#[derive(Clone)] -pub(crate) struct Project { - pub path: String, - pub label: SharedString, -} -#[derive(Clone)] -pub(crate) struct Computer { - pub key: Option, - pub label: SharedString, - pub projects: Vec, - pub sessions: Vec, - pub running: HashSet, - pub unread: HashSet, - pub pinned: Vec, - pub loading: bool, - pub error: Option, -} -impl Computer { - pub fn pending(key: String, label: String, error: Option) -> Self { - Self { - key: Some(key), - label: label.into(), - projects: Vec::new(), - sessions: Vec::new(), - running: HashSet::new(), - unread: HashSet::new(), - pinned: Vec::new(), - loading: error.is_none(), - error: error.map(Into::into), - } - } -} - -impl EventEmitter for ChatScreen {} -impl EventEmitter for ChatScreen {} - -impl ChatScreen { - pub(crate) fn sidebar_browse_state(&self, cx: &gpui::App) -> sidebar::BrowseState { - self.sidebar.read(cx).browse_state(cx) - } - - pub(crate) fn restore_sidebar_browse_state( - &mut self, - state: sidebar::BrowseState, - cx: &mut Context, - ) { - self.sidebar - .update(cx, |sidebar, cx| sidebar.restore_browse_state(state, cx)); - } - - /// Task updates are pushed. The runtime has no project-list event yet, so - /// refresh just the small folder list periodically to pick up empty projects - /// selected by another desktop. Reuse the app's status timer, not one timer - /// per computer, and never reload a conversation here. - pub(crate) fn refresh_navigation_if_due(&mut self, cx: &mut Context) { - let now = std::time::Instant::now(); - if now < self.next_navigation_refresh || self.host_recovery.ended { - return; - } - self.next_navigation_refresh = now + std::time::Duration::from_secs(30); - if self.navigation_only && !self.navigation_ready && self.host_recovery.events.is_none() { - self.recover_host(cx); - } else if !self.host_recovery.unavailable { - self.refresh_roots(cx); - } - } - - pub(crate) fn navigation_generation(&self) -> u64 { - self.selection_generation - } - /// Observe a paired computer without opening its latest conversation or - /// starting its runtime. The normal event stream keeps these summaries live. - pub(crate) fn new_catalog( - backend: Arc, - user_id: String, - cx: &mut Context, - ) -> Self { - let mut this = Self::new_mounted(backend, user_id, cx); - this.navigation_only = true; - this.visible = false; - this.booting = false; - this.recover_host(cx); - this - } - - pub(crate) fn set_visible(&mut self, visible: bool, cx: &mut Context) { - let changed = self.visible != visible; - self.visible = visible; - if changed && visible { - // Only the mounted sidebar projects every computer. Background - // views keep their own summaries and the shared catalog handle; - // they do not rebuild all foreign rows on every update. - let computers = self.computers.clone(); - self.sidebar - .update(cx, |sidebar, cx| sidebar.set_computers(computers, cx)); - } - if visible - && let Some(id) = &self.selected_session - && self.completed_unread_sessions.remove(id) - { - self.sync_sidebar(cx); - } - } - - pub(crate) fn computer_snapshot(&self, label: SharedString, cx: &gpui::App) -> Computer { - let mut roots = self.recent_roots.clone(); - for session in &self.sessions { - if !roots.contains(&session.project_root) { - roots.push(session.project_root.clone()); - } - } - Computer { - key: self.backend.remote_host().map(str::to_owned), - label, - projects: roots - .into_iter() - .map(|path| Project { - label: self.sidebar.read(cx).root_name(&path).into(), - path, - }) - .collect(), - sessions: self.sessions.clone(), - running: self.active_runs.keys().cloned().collect(), - unread: self.completed_unread_sessions.clone(), - pinned: self.sidebar.read(cx).pinned_tasks().to_vec(), - loading: !self.navigation_ready && self.host_recovery.events.is_some(), - error: self.host_recovery.unavailable.then(|| { - if self.host_recovery.ended { - "Offline — select to reconnect" - } else { - "Connecting…" - } - .into() - }), - } - } - - pub(crate) fn set_computers(&mut self, computers: Arc>, cx: &mut Context) { - self.computers = computers.clone(); - self.refresh_project_caption(); - if self.visible { - self.sidebar - .update(cx, |sidebar, cx| sidebar.set_computers(computers, cx)); - cx.notify(); - } - } - - pub(super) fn refresh_project_caption(&mut self) { - let computer = self - .computers - .iter() - .find(|c| c.key.as_deref() == self.backend.remote_host()); - let project = computer - .and_then(|c| { - c.projects - .iter() - .find(|p| Some(p.path.as_str()) == self.project_root.as_deref()) - }) - .map(|p| p.label.to_string()) - .unwrap_or_else(|| self.project_label()); - self.project_label = match self.backend.remote_host() { - Some(host) => { - let name = self - .computers - .iter() - .find(|c| c.key.as_deref() == Some(host)) - .map(|c| c.label.as_ref()) - .unwrap_or("Remote computer"); - format!("{name} · {project}").into() - } - None => project.into(), - }; - } - - pub(crate) fn navigate(&mut self, destination: Destination, cx: &mut Context) { - if !self.navigation_ready { - if self.navigation_only && self.host_recovery.events.is_none() { - self.recover_host(cx); - } - self.pending_navigation = Some((self.selection_generation, destination)); - return; - } - if self.navigation_only { - self.navigation_only = false; - self.booting = true; - self.refresh_slash_commands(cx); - self.start_runtime(cx); - } - match destination { - Destination::Overview => self - .sidebar - .update(cx, |s, cx| s.set_project_filter(None, cx)), - Destination::Task(id) => self.select_session(&id, cx), - Destination::Project(path) => { - self.select_project_root(path, cx); - } - Destination::NewProject => self.choose_root_dialog(cx), - } - cx.notify(); - } - - pub(super) fn finish_navigation_bootstrap(&mut self, cx: &mut Context) { - self.navigation_ready = true; - if let Some((generation, destination)) = self.pending_navigation.take() - && generation == self.selection_generation - { - self.navigate(destination, cx); - } - } - - pub(crate) fn navigation_notice(&mut self, message: String, cx: &mut Context) { - self.notice = Some(message.into()); - cx.notify(); - } - - pub(super) fn computers_project_menu(&self) -> Menu { - let mut menu = Menu::new("computer-project-menu", px(440.)) - .label("Projects") - .max_height(px(440.)) - .application_vim(self.application_vim_enabled); - for (index, computer) in self.computers.iter().enumerate() { - menu = menu.child( - div() - .px_3() - .pt_2() - .pb_1() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .child(computer.label.clone()), - ); - for (project_index, project) in computer.projects.iter().enumerate() { - let request = Navigate { - computer: computer.key.clone(), - destination: Destination::Project(project.path.clone()), - }; - menu = menu.item( - MenuItem::new( - SharedString::from(format!("computer-{index}-project-{project_index}")), - project.label.clone(), - move |_: &mut Self, _, cx| cx.emit(request.clone()), - ) - .icon(if computer.key.is_some() { - "globe" - } else { - "folder" - }) - .current( - computer.key.as_deref() == self.backend.remote_host() - && self.project_root.as_deref() == Some(project.path.as_str()), - ), - ); - } - let request = Navigate { - computer: computer.key.clone(), - destination: Destination::NewProject, - }; - menu = menu.item( - MenuItem::new( - SharedString::from(format!("computer-{index}-new-project")), - "New project…", - move |_: &mut Self, _, cx| cx.emit(request.clone()), - ) - .icon("folder-plus"), - ); - } - menu.separator().item( - MenuItem::new( - "manage-computers", - "Remote computer settings…", - |_: &mut Self, _, cx| { - cx.emit(OpenSettingsSection(Section::Remote)); - }, - ) - .icon("settings"), - ) - } -} diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index d4dbc4161..c9f4f7a01 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -31,8 +31,6 @@ use crate::ui::widgets; mod cache; mod commands; mod composer; -pub(crate) mod computers; -use computers::{Computer, Destination, Navigate, NavigationChanged}; mod dialogs; mod images; mod navigation; @@ -342,36 +340,11 @@ fn draft_mcp_rows( rows } -struct UnresolvedSend { - outcome: Option, - session: String, - text: String, - images: Vec, - from_composer: bool, -} - -impl UnresolvedSend { - fn awaiting_receipt(&self) -> bool { - matches!( - self.outcome, - None | Some(crate::host::receipts::Outcome::Pending) - ) - } -} - pub struct ChatScreen { backend: Arc, user_id: String, /// The task list; its own entity so it renders only when it changes. sidebar: Entity, - computers: Arc>, - navigation_ready: bool, - /// Paired computers initially observe only navigation summaries. Catalogs, - /// tools and a conversation are loaded when that computer is first opened. - navigation_only: bool, - visible: bool, - next_navigation_refresh: std::time::Instant, - pending_navigation: Option<(u64, Destination)>, sessions: Vec, selected_session: Option, timeline: Vec, @@ -521,8 +494,6 @@ pub struct ChatScreen { sidebar_collapsed: bool, /// Images staged for the next message. draft_images: Vec, - unresolved_sends: HashMap, - checking_sends: bool, /// Source of `DraftImage::id`. draft_counter: u64, /// True while the native file dialog is open. @@ -687,7 +658,6 @@ enum LoadMode { struct HostRecovery { epoch: u64, unavailable: bool, - ended: bool, events: Option>, overflow: bool, } @@ -699,14 +669,6 @@ const LOAD_RETRIES: u8 = 2; const FINISHED_RUNS_KEPT: usize = 64; impl ChatScreen { - pub fn connection_ended(&self) -> bool { - self.host_recovery.ended - } - - pub(crate) fn execution_backend(&self) -> Arc { - self.backend.clone() - } - pub fn new(backend: Arc, user_id: String, cx: &mut Context) -> Self { let this = Self::new_mounted(backend, user_id, cx); this.start(cx); @@ -747,12 +709,6 @@ impl ChatScreen { cx: &mut Context, ) { match event.clone() { - SidebarEvent::Navigate(request) => cx.emit(request), - SidebarEvent::PreferencesChanged => { - self.refresh_project_caption(); - cx.emit(NavigationChanged); - cx.notify(); - } SidebarEvent::Select(id) => self.select_session(&id, cx), SidebarEvent::SessionChanged(session) => { self.upsert_session(session, cx); @@ -788,7 +744,6 @@ impl ChatScreen { sidebar.set_recent_roots(roots, cx); }); self.refresh_selected_title(); - cx.emit(NavigationChanged); } fn sync_sidebar_selection(&mut self, cx: &mut Context) { @@ -1088,12 +1043,6 @@ impl ChatScreen { backend, user_id, sidebar, - computers: Arc::new(Vec::new()), - navigation_ready: false, - navigation_only: false, - visible: true, - next_navigation_refresh: std::time::Instant::now() + std::time::Duration::from_secs(30), - pending_navigation: None, sessions: Vec::new(), selected_session: None, timeline: Vec::new(), @@ -1151,8 +1100,6 @@ impl ChatScreen { trust_prompts: true, sidebar_collapsed: false, draft_images: Vec::new(), - unresolved_sends: HashMap::new(), - checking_sends: false, draft_counter: 0, image_picking: false, model_catalog: HashMap::new(), @@ -1299,7 +1246,6 @@ impl ChatScreen { // Not fatal: the runtime start below retreads all of it. Err(message) => log::debug!("local bootstrap unavailable: {message}"), } - this.finish_navigation_bootstrap(cx); this.refresh_slash_commands(cx); this.start_runtime(cx); }, @@ -1425,11 +1371,8 @@ impl ChatScreen { roots: Vec, cx: &mut Context, ) { - let roots = roots.into_iter().map(|root| root.path).collect::>(); - if self.recent_roots != roots { - self.recent_roots = roots; - self.sync_sidebar(cx); - } + self.recent_roots = roots.into_iter().map(|root| root.path).collect(); + self.sync_sidebar(cx); } /// Select the project context for new tasks without disturbing work that @@ -1447,9 +1390,7 @@ impl ChatScreen { return; } let path = path.trim().to_string(); - if path.is_empty() - || (self.backend.remote_host().is_none() && !std::path::Path::new(&path).is_absolute()) - { + if path.is_empty() || !std::path::Path::new(&path).is_absolute() { self.notice = Some("Enter an absolute directory path".into()); cx.notify(); return; @@ -1570,10 +1511,6 @@ impl ChatScreen { } fn choose_root_dialog(&mut self, cx: &mut Context) { - if self.backend.remote_host().is_some() { - self.show_root_input(cx); - return; - } // The platform folder picker through gpui: NSOpenPanel on macOS, // the common file dialog on Windows, the XDG portal on Linux. // The panel closes with the app, so quit is never blocked on it. @@ -1614,7 +1551,7 @@ impl ChatScreen { /// The root changed: update the header label, then read its branch /// and watch its git dir. fn project_root_changed(&mut self, cx: &mut Context) { - self.refresh_project_caption(); + self.project_label = SharedString::from(self.project_label()); self.refresh_branch(cx); } @@ -1687,11 +1624,6 @@ impl ChatScreen { /// dir comes back with it so the watcher follows a root change without /// a file stat on the UI thread. fn read_branch(&mut self, cx: &mut Context) { - if self.backend.remote_host().is_some() { - self.branch_watcher = None; - self.set_branch(None, cx); - return; - } let Some(root) = self.project_root.clone() else { return; }; @@ -2867,9 +2799,7 @@ impl ChatScreen { // Opening the task is what reads its completion, so the unread // marker goes. Only an explicit settle ever moves a task out of // the active inbox. - if self.visible { - self.completed_unread_sessions.remove(&session.id); - } + self.completed_unread_sessions.remove(&session.id); self.selected_session = Some(session.id.clone()); self.draft = false; self.sync_sidebar(cx); @@ -2901,7 +2831,6 @@ impl ChatScreen { { self.start_usage_poller(session_id, cx); } - self.check_unresolved_sends(cx); cx.notify(); } @@ -3640,22 +3569,16 @@ impl ChatScreen { /// Open the task before or after the selected one, in sidebar order. /// Defaults are Command-Option-Up/Down on macOS and Alt-Up/Down elsewhere. fn step_task(&mut self, delta: isize, cx: &mut Context) { - let Some((row, request)) = self.task_step_target(delta, cx) else { + let Some((row, id)) = self.task_step_target(delta, cx) else { return; }; self.sidebar.read(cx).reveal_row(row); - if request.computer.as_deref() == self.backend.remote_host() { - if let Destination::Task(id) = request.destination { - self.select_session(&id, cx); - } - } else { - cx.emit(request); - } + self.select_session(&id, cx); } /// The task `delta` rows away from the selected one, as the sidebar - /// row it sits on and its computer-scoped destination. - fn task_step_target(&self, delta: isize, cx: &gpui::App) -> Option<(usize, Navigate)> { + /// row it sits on and its id. + fn task_step_target(&self, delta: isize, cx: &gpui::App) -> Option<(usize, String)> { self.sidebar.read(cx).step_target(delta) } @@ -4271,19 +4194,6 @@ impl ChatScreen { queue_id: Option, cx: &mut Context, ) { - if self - .unresolved_sends - .values() - .any(|send| send.session == session_id && send.awaiting_receipt()) - { - self.notice = - Some("Checking the previous send before another message can be sent.".into()); - self.check_unresolved_sends(cx); - cx.notify(); - return; - } - let send_id = uuid::Uuid::new_v4().to_string(); - let outgoing_id = send_id.clone(); let session_id = session_id.to_string(); let backend = self.backend.clone(); let user_id = self.user_id.clone(); @@ -4342,7 +4252,7 @@ impl ChatScreen { } cx.notify(); self.call( - async move { backend.send_message_identified(&user_id, request, outgoing_id).await }, + async move { backend.send_message(&user_id, request).await }, cx, move |this, result, cx| match result { Ok(run_id) => { @@ -4354,130 +4264,30 @@ impl ChatScreen { } } Err(message) => { - let uncertain = message.starts_with("Send outcome unknown"); - this.unresolved_sends.insert(send_id.clone(), UnresolvedSend { - outcome: None, session: session_id, text, images: drafts, from_composer, - }); - this.awaiting_first_token = false; - if uncertain { - this.notice = Some("Delivery is uncertain. Your message and images are retained while Maple checks the host; nothing will be resent.".into()); - this.check_unresolved_sends(cx); + // Show the failure in the transcript and give the draft + // back instead of silently dropping it. The composer + // belongs to the selected session: a draft from another + // task must not land in it. + this.push_local_error("Send failed", &message, cx); + if from_composer + && !message.starts_with("Send outcome unknown") + && this.selected_session.as_deref() == Some(session_id.as_str()) + { + if let Some(composer) = this.composer.clone() { + composer.update(cx, |input, cx| input.set_text(&text, cx)); + } + let mut restored = drafts; + restored.append(&mut this.draft_images); + this.draft_images = restored; } else { - this.push_local_error("Send failed", &message, cx); - this.apply_send_receipt(&send_id, Some(crate::host::receipts::Outcome::Finished(Err(message))), cx); + this.notice = Some(format!("Send failed: {message}").into()); } + this.awaiting_first_token = false; } }, ); } - fn check_unresolved_sends(&mut self, cx: &mut Context) { - if self.checking_sends || self.unresolved_sends.is_empty() || self.host_recovery.unavailable - { - return; - } - self.checking_sends = true; - let ids: Vec<_> = self - .unresolved_sends - .iter() - .map(|(id, send)| { - let settled = send - .outcome - .clone() - .filter(|o| !matches!(o, crate::host::receipts::Outcome::Pending)); - (id.clone(), settled) - }) - .collect(); - let backend = self.backend.clone(); - self.call( - async move { - let mut results = Vec::new(); - for (id, settled) in ids { - if settled.is_some() { - results.push((id, settled)); - continue; - } - let result = tokio::time::timeout( - std::time::Duration::from_secs(40), - backend.send_outcome(id.clone()), - ) - .await; - results.push((id, result.ok().and_then(Result::ok))); - } - Ok(results) - }, - cx, - |this, result, cx| { - this.checking_sends = false; - let Ok(results) = result else { - return; - }; - for (id, outcome) in results { - this.apply_send_receipt(&id, outcome, cx); - } - cx.notify(); - }, - ); - } - - fn apply_send_receipt( - &mut self, - id: &str, - outcome: Option, - cx: &mut Context, - ) { - use crate::host::receipts::Outcome; - let Some(send) = self.unresolved_sends.get_mut(id) else { - return; - }; - if outcome.is_some() { - send.outcome = outcome.clone(); - } - match outcome { - Some(Outcome::Finished(Ok(_))) => { - self.unresolved_sends.remove(id); - self.notice = Some("The host received your message. Nothing was resent.".into()); - } - Some(Outcome::Missing | Outcome::Interrupted | Outcome::Finished(Err(_))) => { - let Some(send) = self.unresolved_sends.get(id) else { - return; - }; - // Never overwrite text typed since the uncertain send, - // or restore a draft into another task/computer. - if !send.from_composer { - self.unresolved_sends.remove(id); - return; - } - let empty = self - .composer - .as_ref() - .is_some_and(|c| c.read(cx).text_ref().trim().is_empty()) - && self.draft_images.is_empty(); - if self.selected_session.as_deref() == Some(&send.session) && empty { - let send = self.unresolved_sends.remove(id).unwrap(); - if let Some(composer) = self.composer.clone() { - composer.update(cx, |input, cx| input.set_text(&send.text, cx)); - } - self.draft_images = send.images; - self.notice = Some(if matches!(outcome, Some(Outcome::Interrupted)) { - "The host restarted before recording this send’s result. It may have taken effect. Your draft is restored; check the task before sending again." - } else { - "The send did not complete successfully. Your draft is restored; check the task before sending again." - }.into()); - } else { - self.notice = Some(if matches!(outcome, Some(Outcome::Interrupted)) { - "The host restarted before recording this send’s result; it may have taken effect. You can continue. The original draft is retained; open its task with an empty composer to recover it." - } else { - "The failed draft is retained. You can continue; open its task with an empty composer to recover it." - }.into()); - } - } - _ => { - self.notice = Some("Send outcome is still unknown. Your message and images are retained; Maple will check again after reconnecting.".into()); - } - } - } - fn push_local_error(&mut self, title: &str, message: &str, cx: &mut Context) { let item = AgentTimelineItem { revision: 0, @@ -5111,21 +4921,16 @@ impl ChatScreen { this.handle_service_events(events, cx); this.host_recovery.unavailable = false; this.runtime_error = None; - this.check_unresolved_sends(cx); - cx.emit(NavigationChanged); - if !this.navigation_only { - this.refresh_models(cx); - this.refresh_roots(cx); - this.refresh_slash_commands(cx); - } - if !running && !this.navigation_only { + this.refresh_models(cx); + this.refresh_roots(cx); + this.refresh_slash_commands(cx); + if !running { // A host another client restarted may not have a // runtime yet. Restore catalog/tool availability; // this does not resume or submit any prompt. this.booting = true; this.start_runtime(cx); } - this.finish_navigation_bootstrap(cx); } Err(error) => { this.host_recovery.events = None; @@ -5161,10 +4966,6 @@ impl ChatScreen { self.question_selected.clear(); self.sessions = snapshot.bootstrap.sessions; self.recent_roots = snapshot.bootstrap.recent_roots; - if self.navigation_only { - self.project_root = snapshot.bootstrap.project_root; - self.refresh_project_caption(); - } self.handle_service_events(snapshot.bootstrap.questions, cx); if self.selection_generation == selection { if let Some(detail) = snapshot.selected { @@ -5184,26 +4985,6 @@ impl ChatScreen { /// Apply one event; returns false when nothing visible changed. fn apply_service_event(&mut self, event: AgentServiceEvent, cx: &mut Context) -> bool { - use maple_agent::agent::AgentRunEvent; - // A computer that has not been opened needs navigation state only. - // Do this before the recovery buffer: a busy transcript must not make - // the summary snapshot overflow and reload forever. - if self.navigation_only - && match &event { - AgentServiceEvent::TimelineItem { .. } - | AgentServiceEvent::ThinkingChanged { .. } - | AgentServiceEvent::SideQuestion { .. } => true, - AgentServiceEvent::Run { event, .. } => !matches!( - event, - AgentRunEvent::Started - | AgentRunEvent::Finished(_) - | AgentRunEvent::SessionUpdated(_) - ), - _ => false, - } - { - return false; - } match event { AgentServiceEvent::HostReconnecting => { self.host_recovery.epoch += 1; @@ -5223,32 +5004,17 @@ impl ChatScreen { self.begin_navigation(); self.runtime_error = Some("Connection lost. Reconnecting to the Maple host…".into()); - cx.emit(NavigationChanged); return true; } AgentServiceEvent::HostReconnected => { self.recover_host(cx); return true; } - AgentServiceEvent::RemoteAccessEnded(reason) => { - self.host_recovery.epoch += 1; - self.host_recovery.unavailable = true; - self.host_recovery.ended = true; - self.host_recovery.events = None; - self.runtime_error = Some(reason.into()); - cx.emit(NavigationChanged); - return true; - } AgentServiceEvent::HostDisconnected => { - if self.host_recovery.ended { - return true; - } - self.host_recovery.ended = true; self.host_recovery.epoch += 1; self.host_recovery.unavailable = true; self.host_recovery.events = None; self.runtime_error = Some("The Maple host is unavailable. Reopen Maple to reload your tasks. No message was resent.".into()); - cx.emit(NavigationChanged); return true; } _ => {} @@ -5298,14 +5064,13 @@ impl ChatScreen { self.thinking = Some(state); } } - AgentServiceEvent::RemoteAccessEnded(_) - | AgentServiceEvent::HostDisconnected + AgentServiceEvent::HostDisconnected | AgentServiceEvent::HostReconnecting | AgentServiceEvent::HostReconnected => return false, AgentServiceEvent::RuntimeStatus(mut status) => { // A runtime that just started points Goose at the account's // skills, which the first scan ran before; ask again. - if status.running && !self.runtime_running_seen && !self.navigation_only { + if status.running && !self.runtime_running_seen { self.runtime_running_seen = true; self.refresh_slash_commands(cx); } @@ -5535,7 +5300,7 @@ impl ChatScreen { // task (`set_active_session`) is the only other writer. self.completed_unread_sessions.remove(session_id); if matches!(terminal, maple_agent::agent::AgentRunTerminal::Completed) - && !(self.visible && self.is_selected(session_id)) + && !self.is_selected(session_id) { self.completed_unread_sessions .insert(session_id.to_string()); @@ -6044,11 +5809,7 @@ impl ChatScreen { return; } log::info!("desktop notification sent: {title}"); - let tag = match self.backend.remote_host() { - Some(host) => format!("remote-task:{host}:{tag}"), - None => format!("task:{tag}"), - }; - crate::notify::notify(cx, tag, title, body, &[]); + crate::notify::notify(cx, format!("task:{tag}"), title, body, &[]); } /// Load the plan card from the Maple billing API. Failures keep the diff --git a/apps/maple-agent/app/src/ui/chat/navigation.rs b/apps/maple-agent/app/src/ui/chat/navigation.rs index 995d11c31..1f9fed14f 100644 --- a/apps/maple-agent/app/src/ui/chat/navigation.rs +++ b/apps/maple-agent/app/src/ui/chat/navigation.rs @@ -26,7 +26,6 @@ pub(super) enum ChatRegion { #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub(super) enum SidebarTarget { - Computer(super::computers::Navigate), NewTask, Projects, Task(String), @@ -536,7 +535,6 @@ impl ChatScreen { let open = sidebar.switcher_menu_open(); sidebar.set_switcher_menu_open(!open, cx); }), - Some(SidebarTarget::Computer(request)) => cx.emit(request), Some(SidebarTarget::Task(task_id)) => self.select_session(&task_id, cx), Some(SidebarTarget::Archived) => self.toggle_archived_visibility(cx), None => {} diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 70fec8223..5c5b39ea9 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -10,19 +10,6 @@ //! Clicks that need the window (new task, settings, collapse) call the //! screen directly from plain closures, which run outside any update. -#[path = "sidebar_computers.rs"] -mod computers; -use super::computers::{Computer, Destination, Navigate}; -use computers::{OtherTask, ProjectHeading}; - -/// Browsing belongs to the desktop window; conversations belong to computers. -/// Carry this small view state when swapping the per-computer chat screens. -pub(crate) struct BrowseState { - search: String, - archived_expanded: bool, - scroll: gpui::ListOffset, -} - use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -47,8 +34,6 @@ use crate::ui::widgets; /// What the sidebar asks the screen to do. #[derive(Clone, Debug)] pub(super) enum SidebarEvent { - Navigate(Navigate), - PreferencesChanged, /// Show a task. Select(String), /// A session changed on the backend (a rename); the screen owns the @@ -65,10 +50,7 @@ pub(super) enum SidebarEvent { /// Remove a project; the screen confirms first. RemoveRoot(String), /// Trust or untrust a project. - SetTrust { - path: String, - trusted: bool, - }, + SetTrust { path: String, trusted: bool }, /// Open the folder picker for a new project. ChooseProject, /// Something to tell the user. @@ -162,9 +144,6 @@ enum SidebarPopup { /// rows on screen. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum SidebarEntry { - ComputerProject(usize), - OtherTask(usize), - Computer(usize), NewTask, /// The project switcher row above the sections. ProjectsHeader, @@ -310,10 +289,6 @@ pub(super) struct SwitcherRoot { } pub(super) struct Sidebar { - computers: Arc>, - computer_projects: Vec, - other_tasks: Vec, - computer_archived_count: usize, backend: Arc, user_id: String, chat: WeakEntity, @@ -370,26 +345,6 @@ pub(super) struct Sidebar { impl EventEmitter for Sidebar {} impl Sidebar { - pub(crate) fn browse_state(&self, cx: &gpui::App) -> BrowseState { - BrowseState { - search: self.search_input.read(cx).text(), - archived_expanded: self.archived_expanded, - scroll: self.list.logical_scroll_top(), - } - } - - pub(crate) fn restore_browse_state(&mut self, state: BrowseState, cx: &mut Context) { - self.search_input - .update(cx, |input, cx| input.set_text(&state.search, cx)); - self.filter = state.search.trim().to_lowercase(); - self.archived_expanded = state.archived_expanded; - // A project filter belongs to the previous execution computer. - self.project_filter = None; - self.rebuild_sections(); - self.list.scroll_to(state.scroll); - cx.notify(); - } - pub(super) fn new( backend: Arc, user_id: String, @@ -397,13 +352,6 @@ impl Sidebar { settings: &crate::settings::AppSettings, cx: &mut Context, ) -> Self { - let remote_settings = backend.remote_host().map(|host| { - settings - .remote_sidebars - .get(host) - .cloned() - .unwrap_or_default() - }); let application_vim_enabled = settings.application_vim_enabled; let search_chat = chat.clone(); let search_input = cx.new(move |cx| { @@ -421,10 +369,6 @@ impl Sidebar { }) .detach(); Self { - computers: Arc::new(Vec::new()), - computer_projects: Vec::new(), - other_tasks: Vec::new(), - computer_archived_count: 0, backend, user_id, chat, @@ -457,11 +401,8 @@ impl Sidebar { rename: None, rename_input: None, rename_focus_pending: false, - pinned_tasks: remote_settings - .as_ref() - .map_or_else(|| settings.pinned_tasks.clone(), |s| s.pinned_tasks.clone()), - project_names: remote_settings - .map_or_else(|| settings.project_names.clone(), |s| s.project_names), + pinned_tasks: settings.pinned_tasks.clone(), + project_names: settings.project_names.clone(), vim_selected: None, vim_by_row: Vec::new(), vim_order: Vec::new(), @@ -574,7 +515,7 @@ impl Sidebar { .retain(|candidate| !ids.contains(candidate)); if self.pinned_tasks.len() != before { let pinned = self.pinned_tasks.clone(); - self.persist_pins(pinned); + persist_settings(move |settings| settings.pinned_tasks = pinned); } self.rebuild_sections(); cx.notify(); @@ -597,38 +538,19 @@ impl Sidebar { .get(root) .filter(|name| !name.trim().is_empty()) .cloned() - .unwrap_or_else(|| { - if self.backend.remote_host().is_some() { - remote_root_display_name(root) - } else { - root_display_name(root) - } - }) - } - - pub(super) fn pinned_tasks(&self) -> &[String] { - &self.pinned_tasks + .unwrap_or_else(|| root_display_name(root)) } /// The task `delta` rows away from the selected one, as the sidebar - /// row it sits on and its computer-scoped destination. Stepping walks the flat sidebar list + /// row it sits on and its id. Stepping walks the flat sidebar list /// and stops at its ends. - pub(super) fn step_target(&self, delta: isize) -> Option<(usize, Navigate)> { - let rows: Vec<(usize, Navigate)> = self + pub(super) fn step_target(&self, delta: isize) -> Option<(usize, String)> { + let rows: Vec<(usize, usize)> = self .entries .iter() .enumerate() .filter_map(|(row, entry)| match entry { - SidebarEntry::Task(task) if !task.archived => Some(( - row, - Navigate { - computer: self.backend.remote_host().map(str::to_owned), - destination: Destination::Task(self.sessions.get(task.session)?.id.clone()), - }, - )), - SidebarEntry::OtherTask(index) if !self.other_tasks.get(*index)?.archived => { - Some((row, self.other_tasks.get(*index)?.request.clone())) - } + SidebarEntry::Task(task) if !task.archived => Some((row, task.session)), _ => None, }) .collect(); @@ -637,9 +559,10 @@ impl Sidebar { } let selected = self.selected.as_deref(); let current = selected.and_then(|id| { - rows.iter().position(|(_, request)| { - request.computer.as_deref() == self.backend.remote_host() - && matches!(&request.destination, Destination::Task(task) if task == id) + rows.iter().position(|(_, session)| { + self.sessions + .get(*session) + .is_some_and(|summary| summary.id == id) }) }); let target = match current { @@ -653,7 +576,9 @@ impl Sidebar { None if delta > 0 => 0, None => rows.len() - 1, }; - Some(rows[target].clone()) + let (row, session) = rows[target]; + let id = self.sessions.get(session)?.id.clone(); + Some((row, id)) } pub(super) fn reveal_row(&self, row: usize) { @@ -985,9 +910,6 @@ impl Sidebar { SidebarEmpty::NoMatches })); } - if self.computers.len() > 1 { - entries = self.computer_entries(); - } let old = std::mem::replace(&mut self.entries, entries); if self.application_vim_enabled { self.rebuild_vim_targets(); @@ -1128,23 +1050,7 @@ impl Sidebar { self.rebuild_sections(); cx.notify(); let pinned = self.pinned_tasks.clone(); - self.persist_pins(pinned); - cx.emit(SidebarEvent::PreferencesChanged); - } - - fn persist_pins(&self, pins: Vec) { - let computer = self.backend.remote_host().map(str::to_owned); - persist_settings(move |settings| { - if let Some(key) = computer { - settings - .remote_sidebars - .entry(key) - .or_default() - .pinned_tasks = pins; - } else { - settings.pinned_tasks = pins; - } - }); + persist_settings(move |settings| settings.pinned_tasks = pinned); } /// Whether a collapsible section shows its rows. @@ -1209,9 +1115,6 @@ impl Sidebar { /// Whether `name` is the display name of `root`, without building the /// name the way `root_name` does. fn root_name_matches(&self, root: &str, name: &str) -> bool { - if self.backend.remote_host().is_some() { - return self.root_name(root) == name; - } match self .project_names .get(root) @@ -1340,19 +1243,7 @@ impl Sidebar { } self.rebuild_sections(); let names = self.project_names.clone(); - let computer = self.backend.remote_host().map(str::to_owned); - persist_settings(move |settings| { - if let Some(key) = computer { - settings - .remote_sidebars - .entry(key) - .or_default() - .project_names = names; - } else { - settings.project_names = names; - } - }); - cx.emit(SidebarEvent::PreferencesChanged); + persist_settings(move |settings| settings.project_names = names); } } } @@ -1634,9 +1525,6 @@ impl Sidebar { .entries .iter() .map(|entry| match *entry { - entry @ (SidebarEntry::ComputerProject(_) - | SidebarEntry::OtherTask(_) - | SidebarEntry::Computer(_)) => self.computer_target(entry), SidebarEntry::NewTask => Some(SidebarTarget::NewTask), SidebarEntry::ProjectsHeader => Some(SidebarTarget::Projects), SidebarEntry::SectionLabel(_) | SidebarEntry::Empty(_) => None, @@ -1795,11 +1683,6 @@ impl Sidebar { let selected = self.selected.as_deref(); let chat = self.chat.clone(); let entry = match self.entries.get(ix).copied() { - Some( - entry @ (SidebarEntry::ComputerProject(_) - | SidebarEntry::OtherTask(_) - | SidebarEntry::Computer(_)), - ) => self.render_computer_entry(entry, self.vim_selects_row(ix), cx), Some(SidebarEntry::NewTask) => div() .id("new-task") .role(gpui::Role::Button) @@ -1921,11 +1804,7 @@ impl Sidebar { } Some(SidebarEntry::ArchivedHeader) => { let expanded = self.archived_expanded; - let count = if self.computers.len() > 1 { - self.computer_archived_count - } else { - self.archived_rows.len() - }; + let count = self.archived_rows.len(); div() .id("archived-toggle") .role(gpui::Role::Button) @@ -2092,9 +1971,6 @@ impl Sidebar { /// The menu the project switcher opens: every project, each with an /// overflow menu of its own, plus a way back to all projects. fn switcher_menu(&self, window: &mut Window, cx: &mut Context) -> Menu { - if self.computers.len() > 1 { - return self.computer_switcher_menu(window, cx); - } let mut menu = Menu::new("switcher-menu", px(260.)) .label("Projects") .max_height(px(360.)) @@ -2315,20 +2191,18 @@ impl Sidebar { .flex() .flex_col() .child(div().line_clamp(1).text_ellipsis().child(row.title.clone())) - .when(self.computers.len() <= 1, |column| { - column.child( - div() - .text_xs() - .text_color(gpui::rgb(if is_selected { - theme::text_secondary() - } else { - theme::text_muted() - })) - .line_clamp(1) - .text_ellipsis() - .child(row.project_name.clone()), - ) - }), + .child( + div() + .text_xs() + .text_color(gpui::rgb(if is_selected { + theme::text_secondary() + } else { + theme::text_muted() + })) + .line_clamp(1) + .text_ellipsis() + .child(row.project_name.clone()), + ), ) }) .when_some(activity, |row_element, activity| { @@ -2699,39 +2573,9 @@ pub(super) fn root_display_name(root: &str) -> String { .unwrap_or_else(|| root.to_string()) } -/// Interpret Windows paths on a Unix controller without changing the path sent -/// back to the owner, or treating a backslash in a Unix filename as a separator. -fn remote_root_display_name(root: &str) -> String { - let windows = root.starts_with(r"\\") - || (root.as_bytes().get(1) == Some(&b':') && root.as_bytes().get(2) == Some(&b'\\')); - if windows { - root.trim_end_matches(['\\', '/']) - .rsplit(['\\', '/']) - .next() - .filter(|name| !name.is_empty()) - .unwrap_or(root) - .to_string() - } else { - root_display_name(root) - } -} - #[cfg(test)] mod tests { - use super::{remote_root_display_name, root_display_name}; - - #[test] - fn remote_project_labels_use_the_owners_path_syntax() { - assert_eq!(remote_root_display_name(r"C:\Users\Alex\Maple"), "Maple"); - assert_eq!( - remote_root_display_name(r"\\office\projects\Maple\"), - "Maple" - ); - assert_eq!(remote_root_display_name("/home/alex/Maple"), "Maple"); - if cfg!(unix) { - assert_eq!(remote_root_display_name(r"/home/alex/a\b"), r"a\b"); - } - } + use super::root_display_name; #[test] fn the_default_workspace_is_labeled_maple_workspace() { diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs deleted file mode 100644 index adbac8f61..000000000 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ /dev/null @@ -1,384 +0,0 @@ -//! The normal sidebar list, grouped by computer and folder when paired hosts -//! exist. This is a summary projection, never a second task store. -use super::*; -use crate::ui::chat::computers::{Computer, Destination, Navigate}; - -pub(super) struct ProjectHeading { - request: Navigate, - id: SharedString, - label: SharedString, - remote: bool, -} -pub(super) struct OtherTask { - pub(super) request: Navigate, - pub(super) archived: bool, - id: SharedString, - title: SharedString, - activity: Option, - pinned: bool, -} - -impl Sidebar { - pub(in crate::ui::chat) fn set_computers( - &mut self, - computers: Arc>, - cx: &mut Context, - ) { - self.computers = computers; - self.rebuild_entries(); - self.list.remeasure_items(0..self.entries.len()); - cx.notify(); - } - - pub(super) fn computer_entries(&mut self) -> Vec { - self.computer_projects.clear(); - self.other_tasks.clear(); - self.computer_archived_count = 0; - let mut entries = vec![SidebarEntry::NewTask, SidebarEntry::ProjectsHeader]; - for (computer_index, computer) in self.computers.iter().enumerate() { - let own = computer.key.as_deref() == self.backend.remote_host(); - if self.project_filter.is_some() && !own { - continue; - } - self.computer_archived_count += computer - .sessions - .iter() - .filter(|s| { - s.state == AgentTaskState::Archived - && self - .project_filter - .as_ref() - .is_none_or(|root| root == &s.project_root) - }) - .count(); - if computer.projects.is_empty() || computer.error.is_some() { - entries.push(SidebarEntry::Computer(computer_index)); - } - for project in &computer.projects { - if own - && self - .project_filter - .as_ref() - .is_some_and(|p| p != &project.path) - { - continue; - } - let matches_group = self.filter.is_empty() - || computer.label.to_lowercase().contains(&self.filter) - || project.label.to_lowercase().contains(&self.filter); - let mut tasks = computer - .sessions - .iter() - .filter(|task| { - task.project_root == project.path - && (task.message_count > 0 || computer.running.contains(&task.id)) - && (task.state != AgentTaskState::Archived || self.archived_expanded) - && (matches_group || task.title.to_lowercase().contains(&self.filter)) - }) - .collect::>(); - tasks.sort_by_key(|s| { - ( - s.state == AgentTaskState::Archived, - s.state == AgentTaskState::Settled, - computer - .pinned - .iter() - .position(|id| id == &s.id) - .unwrap_or(usize::MAX), - std::cmp::Reverse(s.updated_ms), - ) - }); - if tasks.is_empty() { - continue; - } - let heading = self.computer_projects.len(); - self.computer_projects.push(ProjectHeading { - request: Navigate { - computer: computer.key.clone(), - destination: Destination::Project(project.path.clone()), - }, - id: format!("computer-{computer_index}-folder-{heading}").into(), - label: format!("{} · {}", computer.label, project.label).into(), - remote: computer.key.is_some(), - }); - entries.push(SidebarEntry::ComputerProject(heading)); - for task in tasks { - if own { - if let Some(index) = self.sessions.iter().position(|s| s.id == task.id) { - entries.push(SidebarEntry::Task(SidebarTaskEntry { - session: index, - pinned: self.pinned_tasks.contains(&task.id), - settled: task.state == AgentTaskState::Settled, - archived: task.state == AgentTaskState::Archived, - })); - } - } else { - let index = self.other_tasks.len(); - self.other_tasks.push(OtherTask { - archived: task.state == AgentTaskState::Archived, - request: Navigate { - computer: computer.key.clone(), - destination: Destination::Task(task.id.clone()), - }, - id: format!("computer-{computer_index}-task-{}", task.id).into(), - title: task.title.clone().into(), - pinned: computer.pinned.contains(&task.id), - activity: if computer.running.contains(&task.id) { - Some(SessionActivity::Running) - } else if computer.unread.contains(&task.id) { - Some(SessionActivity::CompletedUnread) - } else { - None - }, - }); - entries.push(SidebarEntry::OtherTask(index)); - } - } - } - } - if self.computer_archived_count > 0 { - entries.push(SidebarEntry::ArchivedHeader); - } - entries - } - - pub(in crate::ui::chat) fn computer_target( - &self, - entry: SidebarEntry, - ) -> Option { - let request = match entry { - SidebarEntry::ComputerProject(index) => { - self.computer_projects.get(index)?.request.clone() - } - SidebarEntry::OtherTask(index) => self.other_tasks.get(index)?.request.clone(), - SidebarEntry::Computer(index) => Navigate { - computer: self.computers.get(index)?.key.clone(), - destination: Destination::Overview, - }, - _ => return None, - }; - Some(SidebarTarget::Computer(request)) - } - - pub(super) fn render_computer_entry( - &self, - entry: SidebarEntry, - selected: bool, - cx: &mut Context, - ) -> AnyElement { - match entry { - SidebarEntry::ComputerProject(index) => { - let project = &self.computer_projects[index]; - let request = project.request.clone(); - div() - .id(project.id.clone()) - .role(gpui::Role::Button) - .aria_label(project.label.clone()) - .mt_3() - .px_4() - .py_1p5() - .flex() - .gap_2() - .items_center() - .text_xs() - .text_color(gpui::rgb(theme::text_secondary())) - .when(selected, |row| { - row.aria_active_descendant() - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - }) - .hover(|s| { - s.bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() - }) - .on_click(cx.listener(move |_, _, _, cx| { - cx.emit(SidebarEvent::Navigate(request.clone())) - })) - .child(icon( - if project.remote { "globe" } else { "folder" }, - px(14.), - theme::text_muted(), - )) - .child( - div() - .flex_1() - .min_w_0() - .truncate() - .child(project.label.clone()), - ) - .into_any_element() - } - SidebarEntry::OtherTask(index) => { - let task = &self.other_tasks[index]; - let request = task.request.clone(); - div() - .id(task.id.clone()) - .role(gpui::Role::ListBoxOption) - .aria_label(task.title.clone()) - .aria_selected(false) - .pl_8() - .pr_6() - .py_1() - .flex() - .gap_2() - .items_center() - .rounded(theme::RADIUS_MD) - .text_sm() - .text_color(gpui::rgb(theme::text_primary())) - .when(selected, |row| { - row.aria_active_descendant() - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - }) - .hover(|s| { - s.bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() - }) - .on_click(cx.listener(move |_, _, _, cx| { - cx.emit(SidebarEvent::Navigate(request.clone())) - })) - .child( - div() - .flex_1() - .min_w_0() - .line_clamp(1) - .text_ellipsis() - .child(task.title.clone()), - ) - .when_some(task.activity, |row, activity| { - row.child(activity_indicator(&task.id, activity)) - }) - .when(task.pinned, |row| { - row.child(icon("pin", px(13.), theme::accent())) - }) - .into_any_element() - } - SidebarEntry::Computer(index) => { - let computer = &self.computers[index]; - let request = Navigate { - computer: computer.key.clone(), - destination: Destination::Overview, - }; - div() - .id(SharedString::from(format!("computer-{index}"))) - .role(gpui::Role::Button) - .aria_label(computer.label.clone()) - .mt_3() - .px_4() - .py_2() - .flex() - .flex_col() - .gap_1() - .text_xs() - .text_color(gpui::rgb(theme::text_secondary())) - .hover(|s| { - s.bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() - }) - .on_click(cx.listener(move |_, _, _, cx| { - cx.emit(SidebarEvent::Navigate(request.clone())) - })) - .child(computer.label.clone()) - .child(computer.error.clone().unwrap_or_else(|| { - if computer.loading { - "Connecting…" - } else { - "No projects yet" - } - .into() - })) - .into_any_element() - } - _ => div().into_any_element(), - } - } - - pub(super) fn computer_switcher_menu( - &self, - window: &mut Window, - cx: &mut Context, - ) -> Menu { - let mut menu = Menu::new("switcher-menu", px(330.)) - .label("Projects") - .max_height(px(440.)) - .application_vim(self.application_vim_enabled) - .item( - MenuItem::new(ALL_PROJECTS_ID, "All projects", |this: &mut Self, _, cx| { - this.set_project_filter(None, cx) - }) - .current(self.project_filter.is_none()), - ); - for (index, computer) in self.computers.iter().enumerate() { - menu = menu.child( - div() - .px_3() - .pt_2() - .pb_1() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .child(computer.label.clone()), - ); - for (project_index, project) in computer.projects.iter().enumerate() { - // Keep the normal project rename/trust/removal controls for - // this computer; foreign project rows navigate to their owner. - if computer.key.as_deref() == self.backend.remote_host() - && let Some(root) = self - .switcher_roots - .iter() - .find(|root| root.root == project.path) - { - let pick = root.root.clone(); - let group = root.row_group.clone(); - let mut item = MenuItem::new( - root.row_id.clone(), - root.name.clone(), - move |this: &mut Self, _, cx| { - this.set_project_filter(Some(pick.clone()), cx); - }, - ) - .current(self.project_filter.as_deref() == Some(root.root.as_str())) - .style(move |row| row.group(group)) - .trailing(self.project_menu_trigger(root, window, cx)); - if let Some(field) = self.project_rename_field(&root.root) { - item = item.content(field); - } - menu = menu.item(item); - continue; - } - let request = Navigate { - computer: computer.key.clone(), - destination: Destination::Project(project.path.clone()), - }; - menu = menu.item( - MenuItem::new( - SharedString::from(format!( - "switcher-computer-{index}-project-{project_index}" - )), - project.label.clone(), - move |_: &mut Self, _, cx| cx.emit(SidebarEvent::Navigate(request.clone())), - ) - .icon(if computer.key.is_some() { - "globe" - } else { - "folder" - }) - .current( - computer.key.as_deref() == self.backend.remote_host() - && self.project_filter.as_deref() == Some(project.path.as_str()), - ), - ); - } - let request = Navigate { - computer: computer.key.clone(), - destination: Destination::NewProject, - }; - menu = menu.item( - MenuItem::new( - SharedString::from(format!("switcher-computer-{index}-new-project")), - "New project…", - move |_: &mut Self, _, cx| cx.emit(SidebarEvent::Navigate(request.clone())), - ) - .icon("folder-plus"), - ); - } - menu - } -} diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 216d5f96d..5c2a65810 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -44,270 +44,6 @@ mod state_tests { } } - #[gpui::test] - fn computer_catalog_buffers_summary_changes_without_loading_transcripts( - cx: &mut TestAppContext, - ) { - use maple_agent::agent::{AgentRunEvent, AgentRuntimeStatus}; - let chat = screen(cx); - chat.update(cx, |this, cx| { - this.navigation_only = true; - this.visible = false; - this.selected_session = None; - this.host_recovery.events = Some(Vec::new()); - for _ in 0..1000 { - this.handle_service_event( - AgentServiceEvent::TimelineItem { - session_id: "remote-task".into(), - run_id: Some("run".into()), - item: item("reply", "message", Some("streaming text")), - }, - cx, - ); - } - this.handle_service_event( - AgentServiceEvent::SessionCreated(summary("remote-task", "New remote title")), - cx, - ); - this.handle_service_event( - AgentServiceEvent::Run { - session_id: "remote-task".into(), - run_id: "run".into(), - event: AgentRunEvent::Started, - }, - cx, - ); - assert!(!this.host_recovery.overflow); - assert_eq!(this.host_recovery.events.as_ref().unwrap().len(), 2); - let events = this.host_recovery.events.take().unwrap(); - this.apply_recovery_snapshot( - crate::backend::RecoverySnapshot { - bootstrap: crate::backend::LocalBootstrap { - project_root: Some("/other/machine".into()), - recent_roots: vec!["/other/machine".into()], - latest: None, - sessions: Vec::new(), - questions: Vec::new(), - }, - status: AgentRuntimeStatus { - running: true, - project_root: None, - model: None, - active_runs: HashMap::new(), - }, - selected: None, - }, - this.selection_generation, - cx, - ); - this.handle_service_events(events, cx); - this.finish_navigation_bootstrap(cx); - assert!(this.navigation_only); - assert!(this.navigation_ready); - assert!(this.selected_session.is_none()); - assert!(this.timeline.is_empty()); - assert_eq!(this.sessions[0].title, "New remote title"); - assert_eq!( - this.active_runs.get("remote-task").map(String::as_str), - Some("run") - ); - assert_eq!(this.project_root.as_deref(), Some("/other/machine")); - this.handle_service_event(AgentServiceEvent::HostReconnecting, cx); - let cached = this.computer_snapshot("Office".into(), cx); - assert_eq!(cached.sessions.len(), 1); - assert!(cached.error.is_some()); - }); - } - - #[gpui::test] - fn completion_in_a_background_computer_stays_unread_until_opened(cx: &mut TestAppContext) { - use maple_agent::agent::{AgentRunEvent, AgentRunTerminal}; - cx.executor().allow_parking(); - let chat = screen(cx); - chat.update(cx, |this, cx| { - this.sessions = vec![summary("s1", "Remote work")]; - this.selected_session = Some("s1".into()); - this.set_visible(false, cx); - this.handle_run_event( - "s1", - "run", - AgentRunEvent::Finished(AgentRunTerminal::Completed), - cx, - ); - assert!(this.completed_unread_sessions.contains("s1")); - // Merely receiving another summary or snapshot does not read it. - this.sync_sidebar(cx); - assert!( - this.computer_snapshot("Office".into(), cx) - .unread - .contains("s1") - ); - this.set_visible(true, cx); - assert!(!this.completed_unread_sessions.contains("s1")); - }); - } - - #[gpui::test] - fn computer_projects_keep_identical_paths_and_task_ids_separate(cx: &mut TestAppContext) { - use super::super::computers::{Computer, Destination, Navigate, Project}; - use super::super::sidebar::SidebarEntry; - let chat = screen(cx); - chat.update(cx, |this, cx| { - this.sessions = vec![summary("same-id", "Local task")]; - this.recent_roots = vec!["/tmp/proj".into()]; - this.sync_sidebar(cx); - let local = this.computer_snapshot("This computer".into(), cx); - let remote = Computer { - key: Some("paired-device".into()), - label: "Office Mac".into(), - projects: vec![Project { - path: "/tmp/proj".into(), - label: "proj".into(), - }], - sessions: vec![summary("same-id", "Remote task")], - running: HashSet::from(["same-id".into()]), - unread: HashSet::new(), - pinned: Vec::new(), - loading: false, - error: None, - }; - this.set_computers(Arc::new(vec![local, remote]), cx); - this.sidebar.update(cx, |sidebar, _| { - assert_eq!( - sidebar - .entries() - .iter() - .filter(|e| matches!(e, SidebarEntry::ComputerProject(_))) - .count(), - 2 - ); - assert_eq!( - sidebar - .entries() - .iter() - .filter(|e| matches!(e, SidebarEntry::Task(_))) - .count(), - 1 - ); - let entry = *sidebar - .entries() - .iter() - .find(|e| matches!(e, SidebarEntry::OtherTask(_))) - .unwrap(); - assert_eq!( - sidebar.computer_target(entry), - Some(navigation::SidebarTarget::Computer(Navigate { - computer: Some("paired-device".into()), - destination: Destination::Task("same-id".into()), - })) - ); - }); - this.selected_session = Some("same-id".into()); - this.sync_sidebar_selection(cx); - assert_eq!( - this.task_step_target(1, cx).map(|(_, request)| request), - Some(Navigate { - computer: Some("paired-device".into()), - destination: Destination::Task("same-id".into()), - }) - ); - // The projection never merges foreign records into the local runtime list. - assert_eq!(this.sessions.len(), 1); - assert_eq!(this.sessions[0].title, "Local task"); - let local = this.computer_snapshot("This computer".into(), cx); - this.set_computers(Arc::new(vec![local]), cx); - assert!(!this.sidebar.read(cx).entries().iter().any(|e| matches!( - e, - SidebarEntry::OtherTask(_) | SidebarEntry::ComputerProject(_) - ))); - }); - } - - #[gpui::test] - fn computer_project_search_and_live_summary_refresh(cx: &mut TestAppContext) { - use super::super::computers::{Computer, Project}; - use super::super::sidebar::SidebarEntry; - let chat = screen(cx); - chat.update(cx, |this, cx| { - this.sessions = vec![summary("local", "Local title")]; - this.sync_sidebar(cx); - let local = this.computer_snapshot("This computer".into(), cx); - let mut remote = Computer::pending("peer".into(), "Office Mac".into(), None); - remote.projects = vec![Project { - path: "/remote".into(), - label: "Remote folder".into(), - }]; - remote.sessions = vec![summary_at("remote", "Before title", "/remote")]; - this.set_computers(Arc::new(vec![local.clone(), remote.clone()]), cx); - this.set_sidebar_filter("after title", cx); - assert!( - !this - .sidebar - .read(cx) - .entries() - .iter() - .any(|e| matches!(e, SidebarEntry::OtherTask(_))) - ); - remote.sessions[0].title = "After title".into(); - this.set_computers(Arc::new(vec![local, remote]), cx); - assert!( - this.sidebar - .read(cx) - .entries() - .iter() - .any(|e| matches!(e, SidebarEntry::OtherTask(_))) - ); - this.set_sidebar_filter("office mac", cx); - assert!( - this.sidebar - .read(cx) - .entries() - .iter() - .any(|e| matches!(e, SidebarEntry::OtherTask(_))) - ); - assert!( - !this - .sidebar - .read(cx) - .entries() - .iter() - .any(|e| matches!(e, SidebarEntry::Task(_))) - ); - }); - } - - #[gpui::test] - fn navigation_before_bootstrap_keeps_only_the_latest_request(cx: &mut TestAppContext) { - use super::super::computers::Destination; - let chat = screen(cx); - chat.update(cx, |this, cx| { - this.navigate(Destination::Project("/obsolete".into()), cx); - this.navigate(Destination::Overview, cx); - assert_eq!( - this.pending_navigation, - Some((this.selection_generation, Destination::Overview)) - ); - this.finish_navigation_bootstrap(cx); - assert!(this.pending_navigation.is_none()); - assert!(!this.root_selecting); - assert_eq!(this.sidebar.read(cx).project_filter(), None); - }); - } - - #[gpui::test] - fn new_draft_supersedes_a_navigation_waiting_for_the_catalog(cx: &mut TestAppContext) { - let chat = screen(cx); - chat.update(cx, |this, cx| { - this.navigate(Destination::Task("earlier-click".into()), cx); - this.new_session(cx); - this.finish_navigation_bootstrap(cx); - assert!(this.pending_navigation.is_none()); - assert!(this.selected_session.is_none()); - assert!(this.loading_session.is_none()); - assert!(this.draft); - }); - } - fn item(id: &str, item_type: &str, text: Option<&str>) -> AgentTimelineItem { AgentTimelineItem { revision: 0, @@ -4300,9 +4036,7 @@ mod state_tests { screen.update(cx, |this, cx| { let sink = events.clone(); cx.subscribe(&this.sidebar, move |_, _, event: &SidebarEvent, _| { - if !matches!(event, SidebarEvent::PreferencesChanged) { - sink.borrow_mut().push(event.clone()); - } + sink.borrow_mut().push(event.clone()); }) .detach(); this.sessions = vec![ @@ -5273,10 +5007,6 @@ mod state_tests { /// Task stepping walks the sidebar rows across projects and stops at both ends. #[gpui::test] fn test_task_stepping_walks_the_whole_list(cx: &mut TestAppContext) { - let local = |id: &str| Navigate { - computer: None, - destination: Destination::Task(id.into()), - }; cx.executor().allow_parking(); let screen = screen(cx); screen.update(cx, |this, cx| { @@ -5287,7 +5017,7 @@ mod state_tests { this.sync_sidebar(cx); // The screen fixture selects s1: one step moves to s2, and // back stops at the top. - assert_eq!(this.task_step_target(1, cx), Some((4, local("s2")))); + assert_eq!(this.task_step_target(1, cx), Some((4, "s2".to_string()))); assert_eq!( this.task_step_target(-1, cx), None, @@ -5295,13 +5025,13 @@ mod state_tests { ); this.selected_session = Some("s2".to_string()); this.sync_sidebar_selection(cx); - assert_eq!(this.task_step_target(1, cx), Some((5, local("s3")))); - assert_eq!(this.task_step_target(-1, cx), Some((3, local("s1")))); + assert_eq!(this.task_step_target(1, cx), Some((5, "s3".to_string()))); + assert_eq!(this.task_step_target(-1, cx), Some((3, "s1".to_string()))); // With nothing selected, each direction enters from its end. this.selected_session = None; this.sync_sidebar_selection(cx); - assert_eq!(this.task_step_target(1, cx), Some((3, local("s1")))); - assert_eq!(this.task_step_target(-1, cx), Some((5, local("s3")))); + assert_eq!(this.task_step_target(1, cx), Some((3, "s1".to_string()))); + assert_eq!(this.task_step_target(-1, cx), Some((5, "s3".to_string()))); }); } @@ -6603,90 +6333,4 @@ mod state_tests { width before truncating (title {thinking_title:?}, card {card:?})" ); } - #[gpui::test] - fn uncertain_send_keeps_images_and_does_not_overwrite_another_draft(cx: &mut TestAppContext) { - use crate::host::receipts::Outcome; - let chat = screen(cx); - chat.update(cx, |chat, cx| { - let composer = cx.new(|cx| TextInput::new("Message", cx)); - composer.update(cx, |input, cx| input.set_text("new draft", cx)); - chat.composer = Some(composer.clone()); - chat.unresolved_sends.insert( - "send-original".into(), - UnresolvedSend { - outcome: None, - session: "s1".into(), - text: "original message".into(), - from_composer: true, - images: vec![DraftImage { - id: 50, - name: "image.png".into(), - data_url: Some(Arc::from("data:image/png;base64,cGljdHVyZQ==")), - thumbnail: None, - }], - }, - ); - chat.apply_send_receipt("send-original", None, cx); - assert_eq!(chat.unresolved_sends["send-original"].images.len(), 1); - chat.apply_send_receipt("send-original", Some(Outcome::Missing), cx); - assert_eq!(composer.read(cx).text_ref(), "new draft"); - assert!(chat.unresolved_sends.contains_key("send-original")); - composer.update(cx, |input, cx| input.clear(cx)); - chat.selected_session = Some("another-task".into()); - chat.apply_send_receipt("send-original", Some(Outcome::Missing), cx); - assert!(composer.read(cx).text_ref().is_empty()); - chat.selected_session = Some("s1".into()); - chat.apply_send_receipt("send-original", Some(Outcome::Missing), cx); - assert_eq!(composer.read(cx).text_ref(), "original message"); - assert_eq!(chat.draft_images[0].name, "image.png"); - assert!(chat.unresolved_sends.is_empty()); - }); - } - #[gpui::test] - fn failed_and_interrupted_sends_preserve_newer_text_and_release_the_send_gate( - cx: &mut TestAppContext, - ) { - use crate::host::receipts::Outcome; - let chat = screen(cx); - chat.update(cx, |chat, cx| { - let composer = cx.new(|cx| TextInput::new("Message", cx)); - chat.composer = Some(composer.clone()); - chat.selected_session = Some("s1".into()); - for outcome in [ - Outcome::Interrupted, - Outcome::Finished(Err("Not admitted".into())), - ] { - composer.update(cx, |input, cx| input.set_text("newer draft", cx)); - chat.unresolved_sends.insert( - "original".into(), - UnresolvedSend { - outcome: None, - session: "s1".into(), - text: "original draft".into(), - images: vec![], - from_composer: true, - }, - ); - assert!(chat.unresolved_sends["original"].awaiting_receipt()); - chat.apply_send_receipt("original", Some(outcome.clone()), cx); - assert_eq!(composer.read(cx).text_ref(), "newer draft"); - assert!( - !chat.unresolved_sends["original"].awaiting_receipt(), - "a settled receipt cannot block the next send" - ); - if outcome == Outcome::Interrupted { - assert!( - chat.notice - .as_ref() - .unwrap() - .contains("may have taken effect") - ); - } - composer.update(cx, |input, cx| input.clear(cx)); - chat.apply_send_receipt("original", Some(outcome), cx); - assert_eq!(composer.read(cx).text_ref(), "original draft"); - assert!(chat.unresolved_sends.is_empty()); - } - }); - } } diff --git a/apps/maple-agent/app/src/ui/mod.rs b/apps/maple-agent/app/src/ui/mod.rs index d7030760b..46ecb694c 100644 --- a/apps/maple-agent/app/src/ui/mod.rs +++ b/apps/maple-agent/app/src/ui/mod.rs @@ -20,5 +20,3 @@ pub mod theme; pub mod titlebar; pub mod typography; pub mod widgets; - -pub mod remote; diff --git a/apps/maple-agent/app/src/ui/remote.rs b/apps/maple-agent/app/src/ui/remote.rs deleted file mode 100644 index b866f5331..000000000 --- a/apps/maple-agent/app/src/ui/remote.rs +++ /dev/null @@ -1,702 +0,0 @@ -//! Local-only device setup. Codes and account settings never traverse the remote -//! task protocol; a paired computer is selected explicitly before opening tasks. -use crate::{ - backend::AgentBackend, - remote::manager::{PairingWindow, Status}, - ui::{task, text_input::TextInput, theme, widgets}, -}; -use gpui::{Context, Div, Entity, EventEmitter, Focusable, Render, Window, div, prelude::*, px}; -use maple_sdk::remote::RemoteDevice; -use std::{ - cell::RefCell, - sync::Arc, - time::{Duration, Instant}, -}; - -#[cfg(test)] -mod tests; - -pub struct ConnectComputer(pub String); -pub struct ComputersChanged(pub Status); -pub struct ComputerNamed { - pub key: String, - pub name: String, -} - -pub(crate) fn computer_name(key: &str, advertised: &str, alias: &str) -> String { - if !alias.trim().is_empty() { - alias.to_string() - } else if advertised == "Maple computer" { - format!("Computer {}", key.chars().take(8).collect::()) - } else { - advertised.to_string() - } -} - -pub struct RemoteScreen { - backend: Arc, - status: Option, - devices: Option>, - selected_device: Option, - code_input: Entity, - names: std::collections::HashMap, - renaming: Option<(String, Entity)>, - local_name_input: Option>, - local_code: Option<(String, Instant)>, - error: Option, - notice: Option, - busy: Option<&'static str>, - bridges: RefCell>>, -} -impl EventEmitter for RemoteScreen {} -impl EventEmitter for RemoteScreen {} -impl EventEmitter for RemoteScreen {} - -impl RemoteScreen { - pub fn new( - backend: Arc, - names: std::collections::HashMap, - cx: &mut Context, - ) -> Self { - let weak = cx.entity().downgrade(); - let code_input = cx.new(|cx| { - TextInput::new("Eight-character pairing code", cx) - .pairing_code() - .on_enter(move |_, _, cx| { - let weak = weak.clone(); - cx.defer(move |cx| { - weak.update(cx, |screen: &mut Self, cx| screen.pair_selected(cx)) - .ok(); - }); - }) - }); - let mut screen = Self { - backend, - status: None, - devices: None, - selected_device: None, - code_input, - names, - renaming: None, - local_name_input: None, - local_code: None, - error: None, - notice: None, - busy: None, - bridges: RefCell::new(Vec::new()), - }; - screen.refresh(false, cx); - let timer = cx.spawn(async move |this, cx| { - loop { - cx.background_executor().timer(Duration::from_secs(1)).await; - if this - .update(cx, |screen, cx| { - let counting = screen.local_code.is_some(); - if screen - .local_code - .as_ref() - .is_some_and(|(_, deadline)| *deadline <= Instant::now()) - && let Some((mut code, _)) = screen.local_code.take() - { - zeroize::Zeroize::zeroize(&mut code); - screen.notice = Some( - "Pairing code expired. Show a new code when you are ready.".into(), - ); - } - if counting { - cx.notify(); - } - }) - .is_err() - { - break; - } - } - }); - screen.bridges.borrow_mut().push(timer); - screen - } - - fn clear_local_code(&mut self) { - if let Some((mut code, _)) = self.local_code.take() { - zeroize::Zeroize::zeroize(&mut code); - } - } - - fn clear_selection(&mut self, cx: &mut Context) { - self.selected_device = None; - self.code_input.update(cx, |input, cx| input.clear(cx)); - } - - /// Fed by the desktop's existing local-status refresh, including pairing - /// completed by the other computer. No new discovery polling is needed. - pub fn update_status(&mut self, status: Status, cx: &mut Context) { - if self.status.as_ref() == Some(&status) { - return; - } - let paired_controller = self.status.is_some() - && self.local_code.is_some() - && status.peers.iter().any(|(key, peer)| { - peer.controls_this_computer - && self - .status - .as_ref() - .and_then(|old| old.peers.get(key)) - .is_none_or(|old| !old.controls_this_computer || old.grant != peer.grant) - }); - if paired_controller || !status.enabled { - self.clear_local_code(); - } - if paired_controller { - self.notice = - Some("Computer paired. It can now use this computer’s projects and tools.".into()); - } - if self - .selected_device - .as_ref() - .is_some_and(|key| status.peers.get(key).is_some_and(|peer| peer.can_connect)) - { - self.clear_selection(cx); - } - self.status = Some(status); - cx.notify(); - } - - fn can_connect(&self, endpoint: &str) -> bool { - self.status - .as_ref() - .and_then(|s| s.peers.get(endpoint)) - .is_some_and(|peer| peer.can_connect) - } - - fn select_device(&mut self, endpoint: String, window: &mut Window, cx: &mut Context) { - if self.busy.is_some() { - return; - } - self.clear_selection(cx); - self.selected_device = Some(endpoint); - self.error = None; - self.notice = None; - window.focus(&self.code_input.focus_handle(cx), cx); - cx.notify(); - } - - fn pair_selected(&mut self, cx: &mut Context) { - if self.busy.is_some() { - return; - } - let Some(endpoint) = self.selected_device.clone() else { - return; - }; - let code = - zeroize::Zeroizing::new(self.code_input.read(cx).text().trim().to_ascii_uppercase()); - if code.len() != 8 { - self.error = Some("Enter all eight characters shown on the selected computer.".into()); - cx.notify(); - return; - } - self.code_input.update(cx, |input, cx| input.clear(cx)); - self.action( - "remote_pair", - serde_json::json!([endpoint, code.as_str()]), - cx, - ); - } - - fn rename(&mut self, key: String, current: String, cx: &mut Context) { - let weak = cx.entity().downgrade(); - let input = cx.new(|cx| { - let mut input = TextInput::new("Computer name", cx).on_enter(move |_, _, cx| { - let weak = weak.clone(); - cx.defer(move |cx| { - weak.update(cx, |screen, cx| screen.save_name(cx)).ok(); - }); - }); - input.set_text(¤t, cx); - input - }); - self.renaming = Some((key, input)); - cx.notify(); - } - - fn rename_this_computer(&mut self, window: &mut Window, cx: &mut Context) { - let Some(status) = &self.status else { - return; - }; - let current = status.name.clone(); - let weak = cx.entity().downgrade(); - let input = cx.new(|cx| { - let mut input = TextInput::new("Computer name", cx).on_enter(move |_, _, cx| { - let weak = weak.clone(); - cx.defer(move |cx| { - weak.update(cx, |screen, cx| screen.save_this_computer_name(cx)) - .ok(); - }); - }); - input.set_text(¤t, cx); - input - }); - window.focus(&input.focus_handle(cx), cx); - self.local_name_input = Some(input); - self.error = None; - cx.notify(); - } - - fn save_this_computer_name(&mut self, cx: &mut Context) { - let Some(input) = &self.local_name_input else { - return; - }; - match crate::remote::validate_name(&input.read(cx).text()) { - Ok(name) => self.action("remote_set_name", serde_json::json!([name]), cx), - Err(error) => { - self.error = Some(error); - cx.notify(); - } - } - } - - fn save_name(&mut self, cx: &mut Context) { - let Some((key, input)) = &self.renaming else { - return; - }; - let name = input.read(cx).text().trim().to_string(); - if name.is_empty() || name.chars().count() > 64 || name.chars().any(char::is_control) { - self.error = Some("Use a computer name between 1 and 64 characters.".into()); - cx.notify(); - return; - } - let key = key.clone(); - self.names.insert(key.clone(), name.clone()); - self.renaming = None; - self.error = None; - let saved_key = key.clone(); - let saved_name = name.clone(); - crate::settings::update_settings_in_background(move |settings| { - settings - .remote_sidebars - .entry(saved_key) - .or_default() - .display_name = saved_name; - }); - cx.emit(ComputerNamed { key, name }); - cx.notify(); - } - fn refresh(&mut self, discover: bool, cx: &mut Context) { - if self.busy.is_some() { - return; - } - self.busy = Some(if discover { - "Finding computers…" - } else { - "Updating remote settings…" - }); - if discover { - self.clear_selection(cx); - self.notice = None; - } - self.error = None; - let backend = self.backend.clone(); - let bridge = task::call( - &self.backend, - async move { - let devices = if discover { - backend.remote_setting("remote_discover", ()).await? - } else { - Vec::new() - }; - let status: Status = backend.remote_setting("remote_status", ()).await?; - let window: Option = - backend.remote_setting("remote_pairing_window", ()).await?; - Ok((status, devices, window)) - }, - cx, - move |screen, result, cx| { - screen.busy = None; - match result { - Ok((status, devices, window)) => { - screen.clear_local_code(); - if let Some(window) = window { - screen.local_code = Some(( - window.code, - Instant::now() + Duration::from_secs(window.expires_in), - )); - } - cx.emit(ComputersChanged(status.clone())); - screen.update_status(status, cx); - if discover { - screen.devices = Some(devices); - } - screen.error = None; - } - Err(error) => screen.error = Some(error), - } - cx.notify(); - }, - ); - task::retain(&self.bridges, bridge); - cx.notify(); - } - fn action(&mut self, method: &'static str, params: serde_json::Value, cx: &mut Context) { - if self.busy.is_some() { - return; - } - self.busy = Some(match method { - "remote_begin_pairing" => "Preparing pairing code…", - "remote_pair" | "remote_reconcile" => "Pairing computer…", - "remote_revoke" => "Unpairing computer…", - _ => "Updating remote settings…", - }); - self.error = None; - self.notice = None; - let backend = self.backend.clone(); - let bridge = task::call( - &self.backend, - async move { - backend - .remote_setting::(method, params) - .await - }, - cx, - move |screen, result, cx| { - screen.busy = None; - match result { - Ok(value) => { - if method == "remote_begin_pairing" { - match serde_json::from_value::(value) { - Ok(window) => { - screen.local_code = Some(( - window.code, - Instant::now() - + Duration::from_secs(window.expires_in.min(300)), - )) - } - Err(_) => { - screen.error = Some("Could not read the pairing window".into()); - cx.notify(); - return; - } - } - } else if method == "remote_cancel_pairing" || method == "remote_enable" { - screen.clear_local_code(); - } else if method == "remote_pair" || method == "remote_reconcile" { - screen.clear_selection(cx); - screen.notice = Some( - "Computer paired. Its projects are now available in the sidebar." - .into(), - ); - } else if method == "remote_revoke" { - screen.notice = Some("Computer unpaired.".into()); - } else if method == "remote_set_name" { - screen.local_name_input = None; - screen.notice = Some("Computer name saved. Other computers pick it up when they refresh devices or reconnect.".into()); - } - screen.refresh(false, cx); - } - Err(error) => screen.error = Some(error), - } - cx.notify(); - }, - ); - task::retain(&self.bridges, bridge); - cx.notify(); - } -} -impl Drop for RemoteScreen { - fn drop(&mut self) { - self.clear_local_code(); - } -} -impl Render for RemoteScreen { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .id("remote-devices") - .w_full() - .flex() - .flex_col() - .gap_4() - .child(div().text_lg().child("Remote computers")) - .child( - div().text_color(gpui::rgb(theme::text_secondary())).child( - "Work on another computer, or allow a paired computer to work on this one.", - ), - ) - .when_some(self.busy, |view, message| { - view.child(div().text_sm().child(message)) - }) - .when_some(self.error.as_ref(), |view, error| { - view.child( - widgets::card_row() - .border_color(gpui::rgb(theme::status_error())) - .child(error.clone()), - ) - }) - .when_some(self.notice.as_ref(), |view, notice| { - view.child(widgets::card_row().child(notice.clone())) - }) - .when_some( - self.status.as_ref().and_then(|s| s.error.as_ref()), - |view, error| view.child(widgets::card_row().child(error.clone())), - ) - .child(self.render_this_computer(cx)) - .child(self.render_discovery(cx)) - .when( - self.status.as_ref().is_some_and(|s| !s.peers.is_empty()), - |view| view.child(self.render_paired(cx)), - ) - } -} - -impl RemoteScreen { - fn render_this_computer(&self, cx: &mut Context) -> Div { - let enabled = self.status.as_ref().is_some_and(|s| s.enabled); - widgets::card_row().flex().flex_col().gap_4() - .when_some(self.status.as_ref(), |view, status| { - view.child(div().flex().items_center().gap_3().justify_between() - .child(div().flex_1().flex().flex_col().gap_1() - .child(div().text_sm().text_color(gpui::rgb(theme::text_secondary())).child("Computer name")) - .child(div().text_lg().child(status.name.clone()))) - .when(self.local_name_input.is_none(), |row| row.child( - widgets::ghost_button("rename-this-computer").aria_label("Rename this computer").child("Rename") - .debug_selector(|| "rename-this-computer".to_string()) - .on_click(cx.listener(|screen, _, window, cx| screen.rename_this_computer(window, cx)))))) - }) - .when_some(self.local_name_input.as_ref(), |view, input| { - view.child(div().id("this-computer-name-editor").flex().flex_col().gap_2() - .debug_selector(|| "this-computer-name-editor".to_string()) - .child(div().border_1().border_color(gpui::rgb(theme::border())).rounded_lg().p_3().child(input.clone())) - .child(div().text_sm().text_color(gpui::rgb(theme::text_secondary())).child("Shown to your other computers. This does not change the system hostname.")) - .child(div().flex().gap_2() - .child(widgets::secondary_button("save-this-computer-name").aria_label("Save computer name").child("Save") - .on_click(cx.listener(|screen, _, _, cx| screen.save_this_computer_name(cx)))) - .child(widgets::ghost_button("cancel-this-computer-name").aria_label("Cancel rename").child("Cancel") - .debug_selector(|| "cancel-this-computer-name".to_string()) - .on_click(cx.listener(|screen, _, _, cx| { screen.local_name_input = None; screen.error = None; cx.notify(); }))))) - }) - .child(div().flex().gap_4().items_center().justify_between() - .child(div().flex_1().flex().flex_col().gap_1() - .child(div().text_lg().child("This computer")) - .child(div().text_sm().text_color(gpui::rgb(theme::text_secondary())) - .child(if self.status.is_none() { "Loading remote access status…" } - else if enabled && !self.status.as_ref().is_some_and(|s| s.ready) { "Starting remote access. Waiting for a connection…" } - else if enabled { "Remote access is on. Only paired computers can connect." } - else { "Remote access is off. Other computers cannot connect here." }))) - .when(self.status.is_some(), |row| row.child(widgets::secondary_button("remote-enable") - .aria_label(if enabled { "Turn off remote access" } else { "Enable remote access" }) - .child(if enabled { "Turn off" } else { "Enable remote access" }) - .on_click(cx.listener(move |screen, _, _, cx| screen.action("remote_enable", serde_json::json!([!enabled]), cx)))))) - .when(enabled && self.local_code.is_none(), |view| view.child(div().flex().gap_4().items_center().justify_between() - .child(div().flex_1().text_sm().text_color(gpui::rgb(theme::text_secondary())) - .child("To pair a new computer, show a one-time code here and enter it there.")) - .child(widgets::primary_button("pairing-window").aria_label("Show pairing code").child("Show pairing code") - .on_click(cx.listener(|screen, _, _, cx| screen.action("remote_begin_pairing", serde_json::json!([]), cx)))))) - .when_some(self.local_code.as_ref(), |view, (code, deadline)| view.child(div().flex().flex_col().gap_3() - .child(div().text_sm().child("On the other computer, choose Find computers, select this computer, and enter this code.")) - .child(pairing_code_boxes(code)) - .child(div().flex().items_center().gap_4().justify_between() - .child(div().text_sm().text_color(gpui::rgb(theme::text_secondary())) - .child(format!("One attempt · expires in {}:{:02}", - deadline.saturating_duration_since(Instant::now()).as_secs() / 60, - deadline.saturating_duration_since(Instant::now()).as_secs() % 60))) - .child(widgets::secondary_button("cancel-window").aria_label("Cancel pairing").child("Cancel pairing") - .on_click(cx.listener(|screen, _, _, cx| screen.action("remote_cancel_pairing", serde_json::json!([]), cx))))))) - } - - fn render_discovery(&self, cx: &mut Context) -> Div { - let searched = self.devices.is_some(); - let no_devices = self.devices.as_ref().is_some_and(|devices| { - devices - .iter() - .all(|device| self.can_connect(&device.endpoint)) - }); - widgets::card_row().flex().flex_col().gap_3() - .child(div().flex().items_center().gap_4().justify_between() - .child(div().text_lg().child("Connect to another computer")) - .child(widgets::secondary_button("discover") - .aria_label(if searched { "Refresh devices" } else { "Find computers" }) - .child(if searched { "Refresh devices" } else { "Find computers" }) - .on_click(cx.listener(|screen, _, _, cx| screen.refresh(true, cx))))) - .child(div().text_sm().text_color(gpui::rgb(theme::text_secondary())) - .child("Find computers signed into your Maple account. On the other computer, enable remote access and choose Show pairing code.")) - .when(no_devices, |view| view.child(div().text_sm() - .child("No new computers found. Check the other computer is online and signed in, then refresh."))) - .children(self.devices.iter().flatten().filter(|device| !self.can_connect(&device.endpoint)) - .map(|device| self.render_device(device, cx))) - } - - fn render_device(&self, device: &RemoteDevice, cx: &mut Context) -> Div { - let endpoint = device.endpoint.clone(); - let selected = self.selected_device.as_ref() == Some(&endpoint); - let ready = device.window.is_some(); - div() - .p_3() - .border_1() - .border_color(gpui::rgb(theme::border())) - .rounded(theme::RADIUS_MD) - .flex() - .flex_col() - .gap_3() - .child( - div() - .flex() - .items_center() - .gap_4() - .justify_between() - .child( - div() - .flex_1() - .flex() - .flex_col() - .gap_1() - .child(computer_name( - &endpoint, - &device.name, - self.names.get(&endpoint).map_or("", String::as_str), - )) - .child( - div() - .text_sm() - .text_color(gpui::rgb(theme::text_secondary())) - .child(if ready { - "Ready to pair" - } else { - "Show a pairing code on this computer, then refresh." - }), - ), - ) - .when(ready && !selected, |row| { - row.child( - widgets::secondary_button(format!("select-{endpoint}")) - .debug_selector({ - let endpoint = endpoint.clone(); - move || format!("select-{endpoint}") - }) - .aria_label("Enter code") - .child("Enter code") - .on_click(cx.listener(move |screen, _, window, cx| { - screen.select_device(endpoint.clone(), window, cx) - })), - ) - }) - .when(selected, |row| { - row.child( - widgets::ghost_button("cancel-code") - .debug_selector(|| "cancel-code".to_string()) - .aria_label("Cancel code entry") - .child("Cancel") - .on_click(cx.listener(|screen, _, _, cx| { - if screen.busy.is_none() { - screen.clear_selection(cx); - screen.error = None; - cx.notify(); - } - })), - ) - }), - ) - .when(selected && ready, |view| { - view.child( - div() - .flex() - .flex_col() - .gap_2() - .child(div().text_sm().child("Pairing code from this computer")) - .child( - div() - .flex() - .flex_wrap() - .items_center() - .gap_3() - .child( - div() - .id("pairing-code-entry") - .debug_selector(|| "pairing-code-entry".to_string()) - .w(px(384.)) - .child(self.code_input.clone()), - ) - .child( - widgets::primary_button("pair-selected") - .aria_label("Pair computer") - .child("Pair computer") - .on_click( - cx.listener(|screen, _, _, cx| { - screen.pair_selected(cx) - }), - ), - ), - ) - .child( - div() - .text_sm() - .text_color(gpui::rgb(theme::text_muted())) - .child( - "Type or paste its eight-character code. The dash is optional.", - ), - ), - ) - }) - } - - fn render_paired(&self, cx: &mut Context) -> Div { - widgets::card_row().flex().flex_col().gap_3() - .child(div().text_lg().child("Paired computers")) - .children(self.status.iter().flat_map(|s| s.peers.iter()).map(|(key, peer)| { - let connect = key.clone(); - let revoke = key.clone(); - let confirm = key.clone(); - let rename = key.clone(); - let name = computer_name(key, &peer.name, self.names.get(key).map_or("", String::as_str)); - let editing = self.renaming.as_ref().filter(|(target, _)| target == key); - div().flex().flex_col().gap_2().p_3().border_1() - .border_color(gpui::rgb(theme::border())).rounded(theme::RADIUS_MD) - .child(div().flex().gap_3().items_center() - .child(div().flex_1().min_w_0().flex().flex_col().gap_1() - .child(name.clone()) - .child(div().text_sm().text_color(gpui::rgb(theme::text_secondary())) - .child(match (peer.can_connect, peer.controls_this_computer) { - (true, true) => "Can connect in both directions", - (true, false) => "Its projects are available in your sidebar", - (false, true) => "Can use this computer’s projects and tools", - (false, false) => "Pairing needs confirmation", - }))) - .child(widgets::ghost_button(format!("rename-{key}")).aria_label("Set local nickname").child("Nickname") - .on_click(cx.listener(move |screen, _, _, cx| screen.rename(rename.clone(), name.clone(), cx)))) - .when(peer.can_connect, |row| row.child(widgets::secondary_button(format!("connect-{key}")).aria_label("Open projects").child("Open projects") - .on_click(cx.listener(move |_, _, _, cx| cx.emit(ConnectComputer(connect.clone())))))) - .child(widgets::ghost_button(format!("revoke-{key}")).aria_label("Unpair").child("Unpair") - .on_click(cx.listener(move |screen, _, _, cx| screen.action("remote_revoke", serde_json::json!([revoke]), cx))))) - .when_some(editing, |row, (_, input)| row.child(div().flex().gap_2().items_center() - .child(widgets::input_frame().flex_1().child(input.clone())) - .child(widgets::secondary_button(format!("save-name-{key}")).aria_label("Save name").child("Save name") - .on_click(cx.listener(|screen, _, _, cx| screen.save_name(cx)))))) - .when(peer.pending_confirmation.is_some(), |row| row.child( - widgets::secondary_button(format!("confirm-{key}")).aria_label("Confirm previous pairing").child("Confirm previous pairing") - .on_click(cx.listener(move |screen, _, _, cx| screen.action("remote_reconcile", serde_json::json!([confirm]), cx))))) - })) - } -} - -fn pairing_code_boxes(code: &str) -> Div { - div() - .flex() - .gap(px(6.)) - .items_center() - .children(code.chars().map(|character| { - let cell = div() - .flex() - .items_center() - .justify_center() - .h(px(48.)) - .text_xl() - .font_family(crate::assets::FONT_MONO) - .child(character.to_string()); - if character == '-' { - cell.w(px(16.)).text_color(gpui::rgb(theme::text_muted())) - } else { - cell.w(px(40.)) - .rounded(theme::RADIUS_MD) - .bg(gpui::rgb(theme::bg_input())) - .border_1() - .border_color(gpui::rgb(theme::border())) - } - })) -} diff --git a/apps/maple-agent/app/src/ui/remote/tests.rs b/apps/maple-agent/app/src/ui/remote/tests.rs deleted file mode 100644 index 85ed4eb4b..000000000 --- a/apps/maple-agent/app/src/ui/remote/tests.rs +++ /dev/null @@ -1,216 +0,0 @@ -use super::*; -use gpui::TestAppContext; - -fn screen(cx: &mut TestAppContext) -> Entity { - cx.executor().allow_parking(); - let backend = - Arc::new(AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()).unwrap()); - // No status request or timer: drive state transitions without connecting - // to a developer's host or changing pairing authority. - cx.new(|cx| RemoteScreen { - backend, - status: Some(Status { - name: "Test Mac".into(), - enabled: true, - ready: true, - error: None, - endpoint: Some("local".into()), - peers: Default::default(), - }), - devices: None, - selected_device: None, - code_input: cx.new(|cx| TextInput::new("Pairing code", cx).pairing_code()), - names: Default::default(), - renaming: None, - local_name_input: None, - local_code: None, - error: None, - notice: None, - busy: None, - bridges: RefCell::new(Vec::new()), - }) -} - -fn device(key: &str) -> RemoteDevice { - RemoteDevice { - channel: "test".into(), - endpoint: key.into(), - name: key.into(), - addresses: Default::default(), - accepting_connections: true, - window: Some(maple_sdk::remote::RemoteWindow { - id: uuid::Uuid::new_v4(), - expires_at: i64::MAX, - }), - expires_at: i64::MAX, - } -} - -#[gpui::test] -fn own_name_editor_is_visible_and_preserves_edits_during_status_refresh(cx: &mut TestAppContext) { - struct Host(Entity); - impl Render for Host { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div().w(px(950.)).h(px(850.)).child(self.0.clone()) - } - } - let screen = screen(cx); - let (_, cx) = cx.add_window_view(|window, cx| { - window.activate_window(); - crate::desktop::register_key_bindings(cx); - Host(screen.clone()) - }); - cx.simulate_resize(gpui::size(px(950.), px(850.))); - cx.run_until_parked(); - assert!(cx.debug_bounds("this-computer-name-editor").is_none()); - let rename = cx.debug_bounds("rename-this-computer").unwrap(); - cx.simulate_click(rename.center(), gpui::Modifiers::default()); - cx.run_until_parked(); - assert!(cx.debug_bounds("this-computer-name-editor").is_some()); - screen.update(cx, |screen, cx| { - let input = screen.local_name_input.as_ref().unwrap().clone(); - assert_eq!(input.read(cx).text(), "Test Mac"); - input.update(cx, |input, cx| input.set_text("Office Mac", cx)); - let mut status = screen.status.clone().unwrap(); - status.ready = false; - screen.update_status(status, cx); - assert_eq!(input.read(cx).text(), "Office Mac"); - input.update(cx, |input, cx| input.clear(cx)); - screen.save_this_computer_name(cx); - assert!(screen.error.is_some()); - assert!(screen.busy.is_none(), "invalid input never sends a request"); - }); - cx.run_until_parked(); - let cancel = cx.debug_bounds("cancel-this-computer-name").unwrap(); - cx.simulate_click(cancel.center(), gpui::Modifiers::default()); - cx.run_until_parked(); - assert!(cx.debug_bounds("this-computer-name-editor").is_none()); - screen.read_with(cx, |screen, _| { - assert_eq!(screen.status.as_ref().unwrap().name, "Test Mac"); - assert!( - screen.names.is_empty(), - "own-name editing never sets a peer nickname" - ); - }); -} - -#[gpui::test] -fn code_entry_requires_selection_and_does_not_follow_a_different_device(cx: &mut TestAppContext) { - struct Host(Entity); - impl Render for Host { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div().w(px(950.)).h(px(850.)).child(self.0.clone()) - } - } - let screen = screen(cx); - let (_, cx) = cx.add_window_view(|window, cx| { - window.activate_window(); - crate::desktop::register_key_bindings(cx); - Host(screen.clone()) - }); - cx.simulate_resize(gpui::size(px(950.), px(850.))); - cx.run_until_parked(); - assert!(cx.debug_bounds("pairing-code-entry").is_none()); - - screen.update(cx, |screen, cx| { - screen.devices = Some(vec![device("a"), device("b")]); - cx.notify(); - }); - cx.run_until_parked(); - assert!( - cx.debug_bounds("pairing-code-entry").is_none(), - "discovery alone never opens code entry" - ); - let choose = cx.debug_bounds("select-a").unwrap(); - cx.simulate_click(choose.center(), gpui::Modifiers::default()); - cx.run_until_parked(); - assert!(cx.debug_bounds("pairing-code-entry").is_some()); - screen.update(cx, |screen, cx| { - assert_eq!(screen.selected_device.as_deref(), Some("a")); - screen - .code_input - .update(cx, |input, cx| input.set_text("ABCD-EFGH", cx)); - }); - cx.run_until_parked(); - let choose = cx.debug_bounds("select-b").unwrap(); - cx.simulate_click(choose.center(), gpui::Modifiers::default()); - cx.run_until_parked(); - screen.update(cx, |screen, cx| { - assert_eq!(screen.selected_device.as_deref(), Some("b")); - assert!(screen.code_input.read(cx).text().is_empty()); - screen.pair_selected(cx); - assert!( - screen.busy.is_none(), - "empty submission must not start a pairing attempt" - ); - assert!(screen.error.is_some()); - }); - cx.run_until_parked(); - let cancel = cx.debug_bounds("cancel-code").unwrap(); - cx.simulate_click(cancel.center(), gpui::Modifiers::default()); - cx.run_until_parked(); - assert!(cx.debug_bounds("pairing-code-entry").is_none()); - screen.update(cx, |screen, cx| { - screen.pair_selected(cx); - assert!( - screen.busy.is_none(), - "no selection must not start a pairing attempt" - ); - }); -} - -#[gpui::test] -fn incoming_pairing_closes_the_code_but_an_outgoing_grant_does_not(cx: &mut TestAppContext) { - let screen = screen(cx); - screen.update(cx, |screen, cx| { - screen.local_code = Some(( - "ABCD-EFGH".into(), - Instant::now() + Duration::from_secs(300), - )); - let mut status = screen.status.clone().unwrap(); - status.peers.insert( - "peer".into(), - serde_json::from_value(serde_json::json!({ - "name": "Peer", "grant": vec![1; 16], "can_connect": true, - "controls_this_computer": false, "pending_confirmation": null - })) - .unwrap(), - ); - screen.update_status(status.clone(), cx); - assert!( - screen.local_code.is_some(), - "outgoing access is not a new controller" - ); - status.peers.get_mut("peer").unwrap().controls_this_computer = true; - screen.update_status(status, cx); - assert!(screen.local_code.is_none()); - assert!(screen.notice.as_ref().unwrap().contains("Computer paired")); - assert!(screen.status.as_ref().unwrap().peers["peer"].controls_this_computer); - }); -} - -#[gpui::test] -fn settings_reentry_keeps_the_open_window_with_existing_controllers(cx: &mut TestAppContext) { - let screen = screen(cx); - screen.update(cx, |screen, cx| { - let mut status = screen.status.take().unwrap(); - status.peers.insert( - "existing".into(), - serde_json::from_value(serde_json::json!({ - "name": "Existing", "grant": vec![1; 16], "can_connect": false, - "controls_this_computer": true, "pending_confirmation": null - })) - .unwrap(), - ); - let deadline = Instant::now() + Duration::from_secs(120); - screen.local_code = Some(("ABCD-EFGH".into(), deadline)); - screen.update_status(status.clone(), cx); - assert_eq!(screen.local_code, Some(("ABCD-EFGH".into(), deadline))); - assert!(screen.notice.is_none()); - // A real new grant after that baseline still closes the displayed code. - status.peers.get_mut("existing").unwrap().grant = [2; 16]; - screen.update_status(status, cx); - assert!(screen.local_code.is_none()); - assert!(screen.notice.as_ref().unwrap().contains("Computer paired")); - }); -} diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index b97e55959..386ba5a2d 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -56,7 +56,6 @@ pub enum Section { Shortcuts, Prompt, Integrations, - Remote, Usage, About, } @@ -71,13 +70,12 @@ impl Section { Self::Shortcuts => "Keyboard Shortcuts", Self::Prompt => "System prompt", Self::Integrations => "Integrations", - Self::Remote => "Remote computers", Self::Usage => "Usage", Self::About => "About", } } - const ALL: [Self; 10] = [ + const ALL: [Self; 9] = [ Self::General, Self::Account, Self::Billing, @@ -85,7 +83,6 @@ impl Section { Self::Shortcuts, Self::Prompt, Self::Integrations, - Self::Remote, Self::Usage, Self::About, ]; @@ -125,8 +122,6 @@ pub struct SettingsScreen { /// [`crate::ui::task::call`]. bridged_tasks: std::cell::RefCell>>, backend: Arc, - integrations_backend: Arc, - remote_screen: Entity, user_id: String, settings: AppSettings, /// `settings.theme` parsed once; render only reads the label. @@ -232,70 +227,20 @@ struct McpEditor { /// Emitted with the section to open (composer "Manage integrations" link). pub struct OpenSettingsSection(pub Section); -impl EventEmitter for SettingsScreen {} -impl EventEmitter for SettingsScreen {} -impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} impl SettingsScreen { - pub fn update_remote_status( - &mut self, - status: crate::remote::manager::Status, - cx: &mut Context, - ) { - self.remote_screen - .update(cx, |screen, cx| screen.update_status(status, cx)); - } - pub fn new( backend: Arc, - integrations_backend: Arc, user_id: String, settings: AppSettings, shortcut_snapshot: ShortcutSnapshot, section: Section, cx: &mut Context, ) -> Self { - let names = settings - .remote_sidebars - .iter() - .map(|(key, value)| (key.clone(), value.display_name.clone())) - .collect(); - let remote_screen = - cx.new(|cx| super::remote::RemoteScreen::new(backend.clone(), names, cx)); - cx.subscribe( - &remote_screen, - |this, _, event: &super::remote::ComputerNamed, cx| { - this.settings - .remote_sidebars - .entry(event.key.clone()) - .or_default() - .display_name = event.name.clone(); - cx.emit(super::remote::ComputerNamed { - key: event.key.clone(), - name: event.name.clone(), - }); - }, - ) - .detach(); - cx.subscribe( - &remote_screen, - |_, _, event: &super::remote::ComputersChanged, cx| { - cx.emit(super::remote::ComputersChanged(event.0.clone())); - }, - ) - .detach(); - cx.subscribe( - &remote_screen, - |this, _, event: &super::remote::ConnectComputer, cx| { - cx.emit(SettingsClosed(this.settings.clone())); - cx.emit(super::remote::ConnectComputer(event.0.clone())); - }, - ) - .detach(); let prompt_text = settings.effective_harness_instructions(); let application_vim_enabled = settings.application_vim_enabled; let application_focus = cx.focus_handle(); @@ -338,8 +283,6 @@ impl SettingsScreen { let this = Self { bridged_tasks: std::cell::RefCell::new(Vec::new()), backend, - integrations_backend, - remote_screen, user_id, theme: theme::Preference::parse(&settings.theme), settings, @@ -411,7 +354,7 @@ impl SettingsScreen { } fn load_mcp_servers(&self, cx: &mut Context) { - let backend = self.integrations_backend.clone(); + let backend = self.backend.clone(); let user_id = self.user_id.clone(); self.call( async move { backend.list_mcp_servers(&user_id).await }, @@ -430,7 +373,7 @@ impl SettingsScreen { } fn load_integrations(&self, cx: &mut Context) { - let backend = self.integrations_backend.clone(); + let backend = self.backend.clone(); let user_id = self.user_id.clone(); self.call( async move { backend.list_integrations(&user_id).await }, @@ -486,7 +429,7 @@ impl SettingsScreen { self.integration_notice = None; cx.notify(); - let backend = self.integrations_backend.clone(); + let backend = self.backend.clone(); let user_id = self.user_id.clone(); let request_id = id.clone(); self.call( @@ -529,7 +472,7 @@ impl SettingsScreen { } let id = id.to_string(); - let permissions = match self.integrations_backend.begin_integration_setup(&id) { + let permissions = match self.backend.begin_integration_setup(&id) { Ok(permissions) => permissions, Err(message) => { self.integration_notice = Some(message); @@ -541,7 +484,7 @@ impl SettingsScreen { self.integration_saving.insert(id.clone()); cx.notify(); - let backend = self.integrations_backend.clone(); + let backend = self.backend.clone(); let user_id = self.user_id.clone(); let request_id = id.clone(); self.call( @@ -584,7 +527,7 @@ impl SettingsScreen { self.mcp_saving = true; self.mcp_notice = None; cx.notify(); - let backend = self.integrations_backend.clone(); + let backend = self.backend.clone(); let user_id = self.user_id.clone(); self.call( async move { backend.save_mcp_servers(&user_id, servers).await }, @@ -1711,9 +1654,6 @@ impl SettingsScreen { Section::Integrations => { pane = pane.child(self.render_integrations_pane(cx)); } - Section::Remote => { - pane = pane.child(self.remote_screen.clone()); - } Section::Usage => { pane = pane.child( div() @@ -2196,10 +2136,6 @@ impl SettingsScreen { .flex_col() .gap_4() .child(section_title("Integrations")) - .child(div().text_sm().child(match self.integrations_backend.remote_host() { - Some(host) => format!("Execution computer: {}. Native setup and permissions must be completed there.", &host[..host.len().min(10)]), - None => "Execution computer: This computer".to_string(), - })) .child( div() .text_sm() diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index 451504db6..25505dd7b 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -180,7 +180,7 @@ impl SettingsScreen { .map(|server| SettingsTarget::McpServer(server.name.clone())), ) .collect(), - Section::Remote | Section::Usage | Section::About => Vec::new(), + Section::Usage | Section::About => Vec::new(), } } @@ -664,7 +664,6 @@ mod tests { ); let settings = cx.new(|cx| { SettingsScreen::new( - backend.clone(), backend, "user".to_string(), crate::settings::AppSettings::default(), @@ -749,7 +748,6 @@ mod tests { ); let settings = cx.new(|cx| { SettingsScreen::new( - backend.clone(), backend, "user".to_string(), crate::settings::AppSettings { @@ -824,7 +822,6 @@ mod tests { ); let settings = cx.new(|cx| { SettingsScreen::new( - backend.clone(), backend, "user".to_string(), crate::settings::AppSettings { @@ -896,7 +893,6 @@ mod tests { ); let settings = cx.new(|cx| { SettingsScreen::new( - backend.clone(), backend, "user".to_string(), crate::settings::AppSettings { @@ -979,7 +975,6 @@ mod tests { ); cx.new(|cx| { SettingsScreen::new( - backend.clone(), backend, "user".to_string(), crate::settings::AppSettings { diff --git a/apps/maple-agent/app/src/ui/text_input.rs b/apps/maple-agent/app/src/ui/text_input.rs index 64706fc88..082cf21a4 100644 --- a/apps/maple-agent/app/src/ui/text_input.rs +++ b/apps/maple-agent/app/src/ui/text_input.rs @@ -4,7 +4,6 @@ //! text and grows with its content (Shift+Enter inserts a newline). mod bounds; -mod code; pub mod vim; pub(crate) mod vim_actions; @@ -137,8 +136,6 @@ pub struct TextInput { popup: Popup>, /// Render '*' in place of content characters (password fields). mask: bool, - /// Eight ASCII code characters, drawn as slots around a four/four separator. - segmented_code: bool, /// Clear the content once the Enter hook has consumed it (composer behavior). /// Explicit tab order for this input within its surface. Inputs without /// distinct indices collapse onto the same tab-stop path, which makes @@ -272,7 +269,6 @@ impl TextInput { is_selecting: false, popup: Popup::new(|this| &mut this.popup, cx), mask: false, - segmented_code: false, tab_index: None, on_enter: None, on_paste_image: None, @@ -295,13 +291,6 @@ impl TextInput { } } - /// One native text field with code slots, retaining ordinary editing and - /// accessibility. Pairing submission remains an explicit form action. - pub fn pairing_code(mut self) -> Self { - self.segmented_code = true; - self - } - /// Mark this input as the chat composer and choose its initial modal /// state. Ordinary inputs never call this builder. pub fn composer_vim(mut self, enabled: bool) -> Self { @@ -530,16 +519,6 @@ impl TextInput { } pub fn set_text(&mut self, text: &str, cx: &mut Context) { - let normalized; - let text = if self.segmented_code { - let Some(value) = code::normalize(text, 0) else { - return; - }; - normalized = value; - normalized.as_str() - } else { - text - }; if self.vim.is_some() { self.finish_vim_lifecycle(LifecycleEvent::ExternalDraftReplacement, cx); self.forget_edits(); @@ -770,16 +749,6 @@ impl TextInput { new_text: &str, cx: &mut Context, ) { - let normalized; - let new_text = if self.segmented_code { - let Some(value) = code::normalize(new_text, self.content.len() - range.len()) else { - return; - }; - normalized = value; - normalized.as_str() - } else { - new_text - }; // An IME composition was already recorded when it started; a // single typed or deleted character continues the run the last // one started; anything else is its own undo step. @@ -1967,11 +1936,6 @@ impl TextInput { } fn index_for_mouse_position(&self, position: gpui::Point) -> usize { - if self.segmented_code { - return self.last_bounds.map_or(0, |bounds| { - code::index_at(position.x - bounds.left(), self.content.len()) - }); - } if self.content.is_empty() { return 0; } @@ -2169,17 +2133,6 @@ impl EntityInputHandler for TextInput { .or(self.marked_range.clone()) .unwrap_or(self.selected_range.clone()); - let normalized; - let new_text = if self.segmented_code { - let Some(value) = code::normalize(new_text, self.content.len() - range.len()) else { - return; - }; - normalized = value; - normalized.as_str() - } else { - new_text - }; - if self .vim_mode() .is_some_and(|mode| mode != VimMode::Disabled) @@ -2254,9 +2207,6 @@ impl EntityInputHandler for TextInput { _window: &mut Window, _cx: &mut Context, ) -> Option> { - if self.segmented_code { - return Some(code::range_bounds(bounds, range_utf16)); - } let layout = self.last_layout.as_ref()?; let range = self.range_from_utf16(&range_utf16); let caret_affinity = if range.is_empty() && range.start == self.cursor_offset() { @@ -2286,9 +2236,6 @@ impl EntityInputHandler for TextInput { _cx: &mut Context, ) -> Option { let bounds = self.last_bounds?; - if self.segmented_code { - return Some(code::index_at(point.x - bounds.left(), self.content.len())); - } let layout = self.last_layout.as_ref()?; if !self.layout_is_current(layout) { // Layout is from a previous frame; report no hit instead of @@ -3142,11 +3089,7 @@ impl Render for TextInput { }) .w_full() .when(self.multiline && self.fill_height, |el| el.h_full()) - .child(if self.segmented_code { - code::render(self, window, cx).into_any_element() - } else { - TextElement { input: cx.entity() }.into_any_element() - }); + .child(TextElement { input: cx.entity() }); // The right-click menu sits beside the field's key context, not in // it: while the menu has focus, keys it does not bind must not edit // the text or reach the field's Vim. diff --git a/apps/maple-agent/app/src/ui/text_input/code.rs b/apps/maple-agent/app/src/ui/text_input/code.rs deleted file mode 100644 index ca74ccf68..000000000 --- a/apps/maple-agent/app/src/ui/text_input/code.rs +++ /dev/null @@ -1,224 +0,0 @@ -//! Segmented presentation of one native text input. The editor still owns -//! selection, clipboard, undo, keyboard navigation and accessibility. -use super::*; - -const COUNT: usize = 8; -const CELL: f32 = 40.; -const GAP: f32 = 6.; -const SEPARATOR: f32 = 16.; -const HEIGHT: f32 = 48.; -pub(super) const WIDTH: f32 = CELL * COUNT as f32 + GAP * COUNT as f32 + SEPARATOR; - -fn left(index: usize) -> Pixels { - px(index as f32 * (CELL + GAP) + if index >= 4 { SEPARATOR + GAP } else { 0. }) -} - -fn caret_x(index: usize) -> Pixels { - if index >= COUNT { - left(COUNT - 1) + px(30.) - } else { - left(index) + px(10.) - } -} - -pub(super) fn index_at(x: Pixels, len: usize) -> usize { - (0..len.min(COUNT)) - .find(|&index| x < left(index) + px(CELL / 2.)) - .unwrap_or(len.min(COUNT)) -} - -pub(super) fn range_bounds(bounds: Bounds, range: Range) -> Bounds { - Bounds::from_corners( - point(bounds.left() + caret_x(range.start), bounds.top()), - point(bounds.left() + caret_x(range.end), bounds.bottom()), - ) -} - -/// Normalize only a complete optional separator and ASCII letter case. Reject -/// invalid/oversized edits rather than silently dropping or truncating data. -/// The host remains responsible for checking the actual pairing alphabet. -pub(super) fn normalize(text: &str, retained: usize) -> Option { - if !text.is_empty() && text.trim().is_empty() { - return None; - } - let text = text.trim(); - let value = if text.len() == 9 && text.as_bytes()[4] == b'-' { - format!("{}{}", &text[..4], &text[5..]) - } else { - text.to_owned() - }; - (value.is_ascii() - && value.bytes().all(|b| b.is_ascii_alphanumeric()) - && retained + value.len() <= COUNT) - .then(|| value.to_ascii_uppercase()) -} - -pub(super) fn render( - input: &TextInput, - window: &mut Window, - cx: &mut Context, -) -> gpui::Div { - let focused = input.focus_handle.is_focused(window); - let cursor = input.cursor_offset(); - let mut row = div() - .relative() - .flex() - .items_center() - .gap(px(GAP)) - .w(px(WIDTH)) - .flex_shrink_0() - .h(px(HEIGHT)) - .font_family(crate::assets::FONT_MONO) - .text_xl() - .text_color(rgb(theme::text_primary())); - for index in 0..COUNT { - if index == 4 { - row = row.child( - div() - .w(px(SEPARATOR)) - .flex_shrink_0() - .text_center() - .text_color(rgb(theme::text_muted())) - .child("–"), - ); - } - let selected = input.selected_range.contains(&index); - let active = focused && input.selected_range.is_empty() && index == cursor.min(COUNT - 1); - let character = input - .content - .as_bytes() - .get(index) - .map(|b| char::from(*b).to_string()); - row = row.child( - div() - .relative() - .w(px(CELL)) - .h(px(HEIGHT)) - .flex_shrink_0() - .flex() - .items_center() - .justify_center() - .rounded(theme::RADIUS_MD) - .border_1() - .border_color(rgb(if active { - theme::accent() - } else { - theme::border() - })) - .bg(rgb(theme::bg_input())) - .when(selected, |cell| cell.bg(theme::text_selection())) - .children(character) - .when(active, |cell| { - cell.child( - div() - .absolute() - .top(px(13.)) - .left(if cursor == COUNT { px(30.) } else { px(10.) }) - .w(px(2.)) - .h(px(22.)) - .bg(rgb(theme::text_cursor())), - ) - }), - ); - } - let entity = cx.entity(); - // Register the whole row as ONE native field, with matching slot geometry - // for mouse selection and OS text-range queries. No hidden off-screen input. - row.child( - gpui::canvas( - |_, _, _| (), - move |bounds, _, window, cx| { - let focus = entity.read(cx).focus_handle.clone(); - window.handle_input(&focus, ElementInputHandler::new(bounds, entity.clone()), cx); - entity.update(cx, |input, _| input.last_bounds = Some(bounds)); - }, - ) - .absolute() - .top_0() - .left_0() - .size_full(), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn code_edits_normalize_format_without_truncating_invalid_input() { - assert_eq!(normalize(" abcd-efgh ", 0).as_deref(), Some("ABCDEFGH")); - assert_eq!(normalize("efgh", 4).as_deref(), Some("EFGH")); - assert_eq!(normalize("", 7).as_deref(), Some("")); - for text in [ - "ABCDEFGHI", - "AB-CD-EFGH", - "ABCD–EFGH", - "abcd!efg", - "éé-EFGH", - ] { - assert_eq!(normalize(text, 0), None); - } - assert_eq!(normalize("Z", 8), None); - } - - #[gpui::test] - fn code_slots_keep_native_paste_selection_navigation_and_undo(cx: &mut gpui::TestAppContext) { - cx.executor().allow_parking(); - struct Host(Entity); - impl Render for Host { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - div().w(px(WIDTH)).child(self.0.clone()) - } - } - let input = cx.new(|cx| { - crate::desktop::register_key_bindings(cx); - TextInput::new("Pairing code", cx).pairing_code() - }); - let (_, cx) = cx.add_window_view(|window, cx| { - window.activate_window(); - window.focus(&input.read(cx).focus_handle(cx), cx); - Host(input.clone()) - }); - cx.simulate_resize(size(px(500.), px(200.))); - cx.run_until_parked(); - cx.update(|window, cx| { - cx.write_to_clipboard(ClipboardItem::new_string("abcd-efgh".into())); - input.update(cx, |input, cx| input.paste(&Paste, window, cx)); - }); - assert_eq!(input.read_with(cx, |input, _| input.text()), "ABCDEFGH"); - cx.simulate_input("Z"); - assert_eq!( - input.read_with(cx, |input, _| input.text()), - "ABCDEFGH", - "ninth character rejected" - ); - cx.simulate_keystrokes("left backspace"); - cx.simulate_input("z"); - assert_eq!(input.read_with(cx, |input, _| input.text()), "ABCDEFZH"); - cx.simulate_keystrokes("shift-left"); - cx.simulate_input("g"); - assert_eq!(input.read_with(cx, |input, _| input.text()), "ABCDEFGH"); - cx.update(|window, cx| input.update(cx, |input, cx| input.undo(&Undo, window, cx))); - assert_eq!(input.read_with(cx, |input, _| input.text()), "ABCDEFZH"); - cx.run_until_parked(); - cx.update(|window, cx| { - input.update(cx, |input, cx| { - let bounds = input.last_bounds.expect("native code field bounds"); - let click = point(bounds.left() + left(4) + px(12.), bounds.center().y); - assert_eq!(input.character_index_for_point(click, window, cx), Some(4)); - assert_eq!(input.index_for_mouse_position(click), 4); - let caret = input.bounds_for_range(4..4, bounds, window, cx).unwrap(); - assert_eq!(caret.left(), bounds.left() + caret_x(4)); - input.select_all(&SelectAll, window, cx); - }) - }); - cx.simulate_input("wxyz-2345"); - assert_eq!(input.read_with(cx, |input, _| input.text()), "WXYZ2345"); - cx.simulate_keystrokes("enter"); - assert_eq!( - input.read_with(cx, |input, _| input.text()), - "WXYZ2345", - "no implicit submission" - ); - } -} diff --git a/apps/maple-agent/app/src/ui/titlebar.rs b/apps/maple-agent/app/src/ui/titlebar.rs index db2358b89..930c9522d 100644 --- a/apps/maple-agent/app/src/ui/titlebar.rs +++ b/apps/maple-agent/app/src/ui/titlebar.rs @@ -161,8 +161,8 @@ impl Render for TitleBar { window.zoom_window(); }) })) - .child(control("title-close", "✕", |window, cx| { - crate::desktop::close_window(window, cx); + .child(control("title-close", "✕", |_window, cx| { + cx.quit(); })), ) .into_any_element() diff --git a/apps/maple-agent/crates/maple-agent/src/agent/types.rs b/apps/maple-agent/crates/maple-agent/src/agent/types.rs index 30a073ea0..6af4289c0 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/types.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/types.rs @@ -666,8 +666,6 @@ pub enum AgentServiceEvent { HostReconnected, /// Automatic recovery ended; reopening is required. No commands were replayed. HostDisconnected, - /// Remote authorization/protocol ended; reconnecting cannot repair it. - RemoteAccessEnded(String), RuntimeStatus(AgentRuntimeStatus), /// The agent asked the user one or more related questions (ask_user /// tool); every question in the batch is answered in one card. diff --git a/apps/maple-agent/crates/maple-agent/src/maple_api.rs b/apps/maple-agent/crates/maple-agent/src/maple_api.rs index c73345a66..cd67d5920 100644 --- a/apps/maple-agent/crates/maple-agent/src/maple_api.rs +++ b/apps/maple-agent/crates/maple-agent/src/maple_api.rs @@ -405,48 +405,6 @@ impl MapleApiSession { Ok(response.map_err(map_sdk_error)?.token) } - /// Whether this exact signed-in session has not been invalidated locally. - pub async fn remote_session_active(&self) -> bool { - self.inner.read().await.active - } - - pub async fn register_remote_device( - &self, - request: maple_sdk::remote::RegisterRemoteDevice, - ) -> Result { - let snapshot = self.client_snapshot().await?; - let response = snapshot.client.register_remote_device(request).await; - self.record_refresh(&snapshot).await?; - response.map_err(map_remote_error) - } - - pub async fn list_remote_devices(&self) -> Result { - let snapshot = self.client_snapshot().await?; - let response = snapshot.client.list_remote_devices().await; - self.record_refresh(&snapshot).await?; - response.map_err(map_remote_error) - } - - pub async fn request_remote_admission( - &self, - request: maple_sdk::remote::RemoteAdmissionRequest, - ) -> Result { - let snapshot = self.client_snapshot().await?; - let response = snapshot.client.request_remote_admission(request).await; - self.record_refresh(&snapshot).await?; - response.map_err(map_remote_error) - } - - pub async fn check_remote_admission( - &self, - request: maple_sdk::remote::CheckRemoteAdmission, - ) -> Result { - let snapshot = self.client_snapshot().await?; - let response = snapshot.client.check_remote_admission(request).await; - self.record_refresh(&snapshot).await?; - response.map_err(map_remote_error) - } - /// The account profile behind this session. pub async fn account(&self) -> Result { let snapshot = self.client_snapshot().await?; @@ -852,22 +810,6 @@ pub fn is_auth_rejection(message: &str) -> bool { message == AUTH_REJECTED_MESSAGE || message == AUTH_WRONG_ACCOUNT_MESSAGE } -fn map_remote_error(error: maple_sdk::Error) -> String { - match error { - maple_sdk::Error::Authentication(_) - | maple_sdk::Error::Api { - status: 401 | 403, .. - } => AUTH_REJECTED_MESSAGE.to_owned(), - maple_sdk::Error::Api { status: 429, .. } => { - "Remote enrollment limit reached. Try again later.".into() - } - maple_sdk::Error::Api { status: 400, .. } => { - "Remote enrollment request was rejected. Refresh devices and try again.".into() - } - _ => "Remote enrollment service is unavailable. Try again when connected.".into(), - } -} - fn map_sdk_error(error: maple_sdk::Error) -> String { log::warn!( "OpenSecret SDK authentication operation failed ({})", diff --git a/apps/maple-agent/docs/local-host.md b/apps/maple-agent/docs/local-host.md index 2e1dcd25f..dcbc15e0a 100644 --- a/apps/maple-agent/docs/local-host.md +++ b/apps/maple-agent/docs/local-host.md @@ -20,26 +20,16 @@ After the last intentional desktop Quit or ACP detach, a two-second grace period allows a replacement to attach, then the host cancels and drains its work and exits. An unexpected desktop connection loss instead reserves thirty seconds for recovery, including when another client briefly connects and leaves during that interval. -Attached clients keep the host alive. With incoming remote access enabled, the -host stays available without attached clients; closing the desktop window -backgrounds it through the Dock/tray. Explicit desktop Quit requests that local -host stop. A missing owner needs no stop; if ownership remains but Stop fails, -a visible confirmation offers Keep open or Quit anyway, including with no main -window open. Quit anyway exits the desktop without claiming the host stopped. -See [remote access](remote-access.md) for the no-tray fallback and controller role. -`maple-agent host stop` +Attached clients keep the host alive. `maple-agent host stop` explicitly stops this profile and all its tasks, even with clients attached. -There is no login item or permanently installed service. Remote listeners are -opt-in, and a never-used remote profile does not initialize networking. +There is no login item, permanent service, remote listener or remote keepalive. One OS user, build channel and canonical config/data roots identify the storage owner. Startup and lifetime file locks prevent two owners. Backend URL, billing URL, client identity, attestation environment, executable source version and compiled features must match before a client joins. A mismatched client reports an error; it does not terminate or replace the owner. Desktop shows a startup -error window instead of panicking, with a user-confirmed Stop-and-reopen action -for an incompatible owner. It warns that existing tasks and remote access stop. -Explicit `maple-agent host stop` can stop the +error window instead of panicking. Explicit `maple-agent host stop` can stop the owner of the same storage namespace even when its build or backend settings differ; this exception does not allow other commands. Hosts predating that stop exception must be stopped using their matching build/configuration. Quit the old diff --git a/apps/maple-agent/docs/remote-access.md b/apps/maple-agent/docs/remote-access.md deleted file mode 100644 index e6ad81eb1..000000000 --- a/apps/maple-agent/docs/remote-access.md +++ /dev/null @@ -1,147 +0,0 @@ -# Desktop remote access - -Remote access is off by default. The local `maple-agent host serve` daemon owns -its device identity, pairing records and Iroh endpoint. Desktop is a local IPC -client; selecting another computer opens a second, host-scoped IPC connection -through that daemon. The selected computer owns the tasks, workspace, tools, -MCP servers, computer use and send receipts. Its desktop and local ACP clients -continue to share those tasks. ACP is not exposed over Iroh. - -## Using it - -Open **Settings → Remote computers**. Under **This computer**, enable remote -access on the computer that will run tasks, then choose **Show pairing code**. -On the controller, use **Connect to another computer → Find computers**, choose -**Enter code** beside that computer, and enter its code before choosing -**Pair computer**. Code entry appears only for the selected computer; canceling, -refreshing discovery, or selecting a different computer clears it. Codes are -shown in eight individual boxes grouped as `ABCD-EFGH`. The single underlying -field supports typing, selection and paste with or without the dash. Filling it -does not submit a pairing attempt automatically. Both must -be signed into the same Maple account and deployment/channel. Account login -alone does not grant access. The code expires in five minutes and permits one -attempt. A failed attempt requires a new, locally opened window. Leaving Settings -keeps the current window alive; returning shows the same code and remaining time, -with Cancel available. Navigation never renews its deadline or attempt. - -Paired computers appear alongside local projects in the sidebar and project -menus. Each group is **computer · folder**, with its tasks beneath it; a globe -marks remote folders. The sidebar only shows folders with visible tasks; empty -folders remain available in the project menus. Selecting a task or project also -selects its execution computer. Select a **This computer** project to return to -local work. Local-only installations retain the existing sidebar layout. - -The computer name defaults to its OS hostname. In **Remote computers → Computer -name → Rename**, set the name advertised by this computer for this account and -profile. This works with remote access off and does not change the system -hostname. Other paired computers learn changes when refreshing discovery or -reconnecting; the name is not broadcast over active task streams. **Nickname** -on a paired row overrides its label on the viewing desktop only. Neither name -changes device identity or pairing authority. - -Each computer loads independently. The desktop first reads project/task -summaries and opens a transcript only when that computer is used. Cached groups -stay visible during a disconnect, alongside the connection status. Runs and task -updates arrive through the existing event stream; a small folder-list refresh -every thirty seconds also discovers empty projects selected by another desktop. -This is an in-memory navigation cache, not offline transcript replication. Search, -sidebar position and each computer's composer remain intact when switching. - -Choose an existing remote project or enter its absolute path; there is no remote -filesystem browser. Images picked -on the controller are explicit uploads. Remote frames are limited to 16 MiB, -including JSON/base64 overhead; reduce attachments if a send exceeds that bound. -Microphone, playback, billing and account operations stay on the controller. -The Integrations pane manages the selected execution computer and labels it; -account and application preference panes still use this computer. Native -permission setup must be performed on the execution computer. - -Closing a window with remote access enabled keeps the application available -(macOS Dock; Windows/Linux tray Open/Quit). On a desktop without a usable tray, -Close offers Keep open or Quit Maple, explaining that Quit stops remote access. -Explicit Quit stops that computer's -local daemon and drains its work. Closing a controller view does not cancel work -on another computer. Revocation and disabling access take effect locally even -when discovery is unavailable; re-enabling does not restore revoked grants. - -CLI equivalents: `remote status`, `remote enable`, `remote disable`, -`remote discover`, `remote begin-pairing`, `remote cancel-pairing`, -`remote pair ` (hidden code prompt), and `remote revoke `. -These administer only the local computer. Never put a pairing code in argv or logs. - -## Transport and recovery - -Iroh 1.3.0 supplies authenticated QUIC, direct connectivity and relay fallback. -Maple explicitly clears public address lookup and builds Iroh without its -port-mapping feature. The Minimal preset alone is not the port-mapping policy. -There is no second encryption tunnel or custom routing layer. The remote -protocol keeps the `maple-agent/remote/1` ALPN family and requires compatible wire version 4 or newer, independent of application builds. Older peers receive an explicit -version refusal. Bounded frames and an explicit task-operation allowlist apply. -Account operations and whole-host administration cannot be forwarded. - -Four pre-admission handshakes and eight paired control connections have capacity -separate from local IPC. Each control connection bounds ordinary calls and keeps -separate capacity for cancellation/questions. Each request/reply uses an -independent bidirectional QUIC stream; the authenticated initial stream carries -ordered events. A bounded header admits each request before its attachment body -is read. Stop and question answers have reserved capacity and higher native stream -priority, so an incomplete upload or unread image reply cannot hold their dispatch -loop. Streams still share the connection's bandwidth; this is not a latency -guarantee on a saturated or unavailable network. Transfers use bounded frames and -inactivity deadlines, allowing slow transfers that keep making progress. Image -readback uses base64 on the remote wire. -A request failure or oversized reply returns a request error, while authority loss -or incompatible framing ends the connection. A failed connection recovers from an -authoritative snapshot. Transport loss does not cancel an accepted mutation on -the host or cause an automatic prompt replay; explicit host shutdown still stops -its work. A stable send identity -crosses both IPC and Iroh; only the execution daemon records its admission and -result, account-bound in its receipt database. An uncertain send keeps its original -identity, text and draft images in the desktop view and queries the execution -host's receipt after reconnecting. It never replays the command or overwrites a -newer draft. If the execution daemon restarts before recording a send's result, -it marks that receipt interrupted, preserving the identity against replay. The -message may already have taken effect: the desktop says so, retains/restores its -draft without overwriting newer input, and lets the user continue the task. A -pending receipt from a live daemon still blocks another send until resolved. -This is in-memory draft recovery, not persistence across desktop crashes. - -A remote-enabled daemon retries transient startup failures with bounded backoff. -Local settings show whether networking is ready and retain actionable configuration -errors. Disable and revoke invalidate initialization already in flight; their local -effect does not wait for discovery publication. - -After an abnormal exit, an older local daemon can still own this profile when an -updated application starts. The startup error offers a user-confirmed -Stop-and-reopen action; it stops the old host's tasks and remote access before -starting the new build. There is no automatic takeover. - -The controller retries transient connection loss with bounded exponential delay -while the view remains attached. Known revocation and incompatible/unknown wire -messages terminate recovery. Recent validated address hints can be reused during -a discovery outage, but a daemon restart or new relay admission can still require -OpenSecret. No offline history replica or migration of workspaces is provided. - -## Configuration and release boundary - -`MAPLE_REMOTE_RELAYS` accepts an explicit comma-separated HTTPS relay list. An -empty list fails closed with a configuration error, with no public fallback. -The initial change does not invent or deploy a Maple relay hostname; the approved -Dev relay must be configured before a packaged remote trial. The ordinary -application remains usable with remote access off. - -Unpackaged validation builds additionally accept `MAPLE_REMOTE_RELAYS=public-test` -and `MAPLE_REMOTE_RELAY_ONLY=1`. The latter removes IP transports, proving a real -relay path even for two processes on one machine. `MAPLE_REMOTE_RELAYS=none` is -available for direct-only development. Never ship public-test as a release default. -The execution and controller profiles need separate XDG config/data roots and -proxy ports when testing on one machine. Do not reuse installed production state. - -The encryption/enrollment contract and dependency qualification are in -[remote-security.md](remote-security.md). The backend relay callback and stock -relay deployment inputs are documented in -[OpenSecret remote enrollment](../../../services/opensecret/docs/remote-enrollment.md). -Infrastructure deployment, signed/native-permission validation, Windows/Linux -tray testing and two real machines remain distinct release gates. - -See [implementation validation](remote-validation.md) for observed behavior and remaining trial gates. diff --git a/apps/maple-agent/docs/remote-security.md b/apps/maple-agent/docs/remote-security.md deleted file mode 100644 index 150c9195e..000000000 --- a/apps/maple-agent/docs/remote-security.md +++ /dev/null @@ -1,88 +0,0 @@ -# Remote pairing security and qualification - -## Authority - -Each account/deployment/channel/storage scope owns a random persistent Iroh key -and local grants in an owner-only, atomically replaced file, separate from the -auth token cache. Changing builds does not change identity. Losing that file -requires re-pairing; discovery cannot reconstruct grants. Signing out invalidates -the account session and closes its remote connections. Same-account sign-in can -reuse persisted grants. Local revocation wins over delayed pairing acknowledgements. - -The execution daemon opens a monotonic five-minute window W with eight uniform -Base32 characters (40 bits). It registers its endpoint, W and remaining duration -through authenticated Transport V2 with an endpoint-key signature before displaying -the code. OpenSecret returns a short-lived opaque admission handle bound to the -account, controller key, execution key and W. The execution daemon checks the -actual authenticated Iroh peer, asks OpenSecret to validate admission, then -rechecks and consumes its one local attempt before processing a PAKE message. - -PAKE binds both roles/endpoint keys, account, project, deployment, channel, W and -protocol variant. The execution daemon confirms first. Only after verifying that -proof does the controller confirm. The execution grant is durable before its -acknowledgement. The controller persists that it verified the execution proof -before sending confirmation. A lost acknowledgement can query the existing grant; -it cannot retry the code, create a missing grant or undo revocation. A controller -also persists attempted windows before its code-derived message leaves the process. - -A compromised enrollment service can deny service, redirect discovery, burn an -open window or make one online code guess. It cannot reset the daemon's local -attempt counter, learn the code from stored records or create a local grant. -A malicious database can disrupt availability and replay old valid ciphertext; -AEAD is not an anti-rollback system. Local windows, authenticated endpoint keys, -account generations and local grants remain authority under such replay. - -## Selected PAKE and evidence - -The implementation pins `pake-cpace` 0.1.0 from 1Password/cpace at -`7e2e42c7d359969f4356292c2bd2cf3ff6eeeecc` (initial public source release, -2026-06-04). It implements CPace Ristretto255/SHA-512 from draft-06 with HMAC -confirmation, **not** draft-21 or an RFC-conformance claim. The domain string -names this exact variant; changing it requires a new pairing protocol/version. -No local changes to the cryptographic construction are made. - -Evidence assessed: - -- The repository's eleven upstream tests, including its draft-06 vectors, pass - at that exact pin. Maple's tests exercise correct confirmation order, every - identity/context binding, wrong codes, reflection, malformed/noncanonical and - identity group elements, wrong-length tags, expiry and one-attempt consumption. -- The included Cure53 report describes a December 2022 review by two testers - over six days of approximately 750 lines including vectors. Its single - informational Rust lifetime finding was reported fixed. The report does not - identify this 2026 commit, and the public history begins at one release commit: - there is no verifiable audited-commit-to-current diff. Underlying curve - primitives were outside that audit. This is supporting construction/source - evidence, not an audit of Maple or proof that this exact pin was audited. -- Source inspection confirms canonical Ristretto decoding, identity rejection, - constant-time confirmation verification and CSPRNG input. Maple accepts only - fixed eight-byte ASCII codes, avoiding unbounded password input to the draft-06 - generator; context fields come from bounded authenticated identities. -- Maple zeroizes the `Code` byte buffer, explicitly handled returned session keys, - and the Settings code string when cleared. Ordinary input/IPC/serialization - copies and the dependency's internal ephemeral scalar/intermediate key are not - all explicitly erased. No claim is made of complete memory erasure or protection from - a compromised local process. Codes, permits, PAKE messages and endpoint secrets - are not diagnostic output or service database values. - -This evidence qualifies the dependency for the scoped implementation/internal -trial. It does not substitute for a review of the complete concrete implementation -before broad release. A cryptographic dependency update must reassess the variant, -source/audit relationship, malformed inputs, vectors and confirmation ordering. - -Sources: [1Password source](https://github.com/1Password/cpace), -[review report](https://github.com/1Password/cpace/blob/7e2e42c7d359969f4356292c2bd2cf3ff6eeeecc/assets/2022-Cure53-Audit.pdf), -[current CPace draft](https://www.ietf.org/archive/id/draft-irtf-cfrg-cpace-21.html). - -## Exposure and validation limits - -A paired controller has the ordinary host agent's tool authority, including shell -and computer use. No additional remote tool-approval system is introduced. Remote -API account/auth/billing/API-key/host-stop commands are excluded. Protocol versions, -identity scope, grant checks and account fences are checked before dispatch; -account-local receipt lookup cannot expose another account's outcome. - -Tests on one machine can prove separate profiles, identity/authentication, -framing, public-relay traffic, cancellation, snapshot recovery and receipt behavior. -They cannot prove two independent NATs, sleep/wake routing, Windows/Linux native -lifecycle or signed macOS TCC identity. Record those as separate trial evidence. diff --git a/apps/maple-agent/docs/remote-validation.md b/apps/maple-agent/docs/remote-validation.md deleted file mode 100644 index bdfa9b722..000000000 --- a/apps/maple-agent/docs/remote-validation.md +++ /dev/null @@ -1,245 +0,0 @@ -# Phase 2 implementation validation - -Local validation on 2026-10-19 used macOS 27.0.1 / Apple Silicon and the -`agent-remote-p2` managed Local environment. The controller and execution daemon -had independent config/data roots, endpoint keys, proxy ports and debug bundle -identities. The installed production Agent, other development apps and Paseo -were not reconfigured. Test accounts and credentials remained inside the Local -MDE; no production OpenSecret account or backend was used. Live model calls used -the Local backend's configured provider. - -## Observed behavior - -- Two isolated daemons registered/discovered through the Local OpenSecret's real - Transport V2 routes, then paired with CPace through public Iroh relays. IP - transports were explicitly disabled. The focused transport test additionally - asserted a relay path and pinned peer identities. A separate loopback UDP test - covered a direct path; this is not evidence of real LAN/NAT traversal. -- Remote task lists matched the execution daemon. Account/password/whole-host - controls were refused by the remote operation allowlist. -- GLM 5.3 Flash executed Bash on the execution daemon. The controller wrote its - send request without reading the acknowledgement, then restarted during the - turn. Recovery queried the execution daemon's original receipt and snapshot. - The tool's proof file contained exactly one line: the prompt was not replayed. -- A wrong proof consumed the execution window even for a different admitted - controller. A locally cancelled window could not be used. Disabling remote - access refused control; re-enabling retained an existing grant. Signing the - execution account out denied reconnect without signing out the controller. - Same-account sign-in retained endpoint identity and pairing. -- The native controller opened the execution task, sent a real Bash turn and - displayed the resulting text/tool summary. Local ACP loaded that same task, - streamed a continuation, and both desktops displayed it. No ACP-over-Iroh - adapter or Paseo configuration was involved. -- Remote desktop Stop terminated the Bash process and its recorded child. - Closing the execution window preserved remote task access. Explicit Quit - stopped its daemon. The isolated debug bundles preserve their public relay - settings for a Dock/application relaunch. - -Native testing found and corrected a desktop-startup request burst hitting the -initial remote capacity limit, initial host-selector theme/traffic-light spacing, -discovery publishing an empty address before the relay became ready, and the -Integrations settings pane retaining the controller backend after selecting a -remote task. Account/preferences remain local while Integrations uses the -selected execution backend. A disabled execution-only MCP fixture was visible -in both the remote task menu and Integrations pane; the controller configuration -remained empty. Remote capacity remains bounded and separate from local controls, with reserved slots -for cancellation and question answers. - -## Computer and project navigation follow-up - -The top-level computer toolbar has been replaced by computer/folder groups in the -normal sidebar and project menus. Remote setup lives in Settings. Three isolated -stores on this Mac (local plus two remote execution computers) used the same -folder paths and completed separate GLM Flash tasks through public Iroh relays. -The native desktop showed their independent titles and groups; switching between -computers preserved an unsent composer. Renaming a computer updated its local -label. Taking the third execution daemon offline retained its cached task and -folder rows; restoring the endpoint recovered its conversation without resending -the prompt. This is still same-machine development evidence. - -Navigation regressions cover identical task IDs and folder paths on different -computers, keyboard destinations, search across computer/project names, summary -updates without transcript loading, a thousand streamed chunks during catalog -recovery, background unread state and superseded navigation during startup. -The full component checks below were rerun after this navigation change. - -## Independent request streams - -The pre-review audit found that reserved request slots did not prevent a large -transfer from blocking control messages on the original single ordered stream. -The initial wire version 2 used one bounded QUIC stream per request/reply and a separate -ordered event stream, retaining the existing authentication, receipts and recovery. - -The new transport tests use actual Iroh endpoints with the production flow-control -configuration. They leave an upload incomplete and an image reply larger than its -receive window unread, fill ordinary admission capacity, and verify that Stop and -question answers still complete. Finishing the upload admits the original send -once. Additional coverage closes an incomplete transfer before admission, rejects -oversized urgent bodies before transmission, and refuses a mismatched reply ID. -These are transport tests, not evidence of latency across a real slow network. - -A fresh isolated headless build of this correction also repeated the Local -registration, pairing and control flow with IP transports disabled. GLM 5.3 Flash -completed its tool turn after the controller restarted with an unread send -acknowledgement; the original receipt and exactly one proof-file write were -verified. Revocation refused reconnect. All test-owned daemons were stopped, and -the user's existing desktop builds remained untouched. - -## October 20 review follow-up - -The branch was rebased onto `339d306e`, including the merged Windows ACP -streaming-test correction (#1174). The focused fixes keep the existing -daemon/pairing architecture; that revision used protocol version 3. - -Full Agent `just ci` passed again on macOS: all four lint configurations, -workspace build/tests, 271 runtime tests / 2 ignored, 501 desktop tests / -1 ignored and 92 headless tests / 1 ignored. The new regressions cover image -readback, inactivity deadlines, Hello parsing/refusal framing, wire envelopes, -uncertain draft retention, disabling while initialization waits on credentials, -and Stop with a lost acknowledgement. The initialization test does not itself -prove successful retry or a late valid endpoint install; the Local probes below -provide the separate runtime evidence. -These local results do not establish the rebased head's hosted CI status. - -Fresh disposable Local profiles repeated the following through public Iroh relays, -with direct IP transports disabled and the user's apps left running: - -- Reattaching the local view retrieved the same pairing code/window/deadline. - A wrong proof consumed its single attempt; another controller received a clear - closed-window refusal. A new window paired successfully. This check also found - a stale startup advertisement overwriting a new pairing window; registration - publication is now serialized without delaying local Cancel or Disable. -- A 5,881,918-byte PNG (23,524,264 bytes in the old JSON-array representation) - returned intact, followed by another task request on the same connection. - The automated transport regression additionally covers a 10 MiB reply and an - oversized reply error followed by a successful control request. -- GLM 5.3 Flash completed its Bash turn after the controller restarted with an - unread acknowledgement. The original receipt was recovered and the tool wrote - its proof file exactly once. Disable/re-enable preserved the existing grant; - revocation denied reconnect. -- A temporary local forwarding proxy made startup unavailable, then recovered: - an already-enabled host retried automatically without another user action. - Gating initialization while issuing Disable proved that its local effect did - not wait on the service and that late completion could not install the endpoint. -- The isolated debug macOS GUI opened and rendered, then its own host was stopped. - This does not establish the Linux/Windows tray UX or signed macOS permissions. - -The disposable PostgreSQL suite passed 22 AEAD/database, 2 OAuth and 8 callback -tests, including stale window reclamation under forged expiry hints and a -concurrent replacement that compare-and-delete must preserve. All-feature -OpenSecret Clippy and unit tests passed (865 passed / 33 ignored). The guarded DB -run used `PGGSSENCMODE=disable` only for its temporary loopback database after a -macOS Kerberos-library teardown abort; no application authentication setting or -test was disabled. - -## Final focused follow-up, October 20 - -The next change closes the remaining five trial issues without changing the -pairing or daemon architecture. Wire version 4 adds an explicit interrupted-send -receipt; mixed builds receive an update-required refusal. On startup, only the -new exclusive owner marks unfinished receipts interrupted. Those identities stay -reserved, the UI warns that effects may exist, and the task can continue without -replaying or overwriting a newer draft. Quit checks ownership before treating a -missing endpoint as stopped, and failed Stop offers a visible Quit anyway choice. -Settings re-entry establishes a baseline before detecting a newly paired computer; -mid-handshake EOF remains a transient disconnect. - -Full Agent `just ci` passed on macOS: four lint configurations, 271 runtime tests / -2 ignored, 507 desktop tests / 1 ignored, and 94 headless tests / 1 ignored, plus -the workspace suites. Focused regressions exercise startup receipt classification -and account isolation, the send gate/draft preservation, truncated acknowledgement -versus invalid frames/revocation, Settings re-entry with an existing controller, -absent versus unreachable host ownership, and the tray Quit confirmation with no -main window. Wire fixtures cover selected forwarded events, attachment send -parameters, session summaries and receipt outcomes; they are not exhaustive -protocol conformance or mixed-build interoperability evidence. - -Fresh disposable Local profiles repeated one-attempt pairing/refusals, unchanged -pairing-window reattachment, 5,881,918-byte image readback plus a subsequent task -request, disable/re-enable, and revoke over forced public Iroh relays. With the -execution daemon stopped, a synthetic unfinished row was inserted only into its -disposable receipt database. Restart classified it as `Interrupted` and remote -task access recovered. This is a deterministic crash-state fixture, not a claim -that a real crash was timed inside the admission transaction. Separately, a live -GLM 5.3 Flash/Bash turn survived controller restart with an unread acknowledgement; -the original execution receipt and exactly one proof-file write were verified. -All owned probe processes were stopped; user applications were untouched. These -probes used the changed source atop `ca4b2bbf` before commit, not a production -package or a second physical machine. - -## Computer names, October 20 - -Two fresh Local profiles verified OS-derived defaults, renaming while remote -access is off without starting networking, and discovery of both names during -public-relay pairing. Subsequent discovery refreshes propagated each rename -without changing endpoint identity, grants or direction. Remote task access and -both names survived restarting both daemons. A remote request to rename the -execution computer was refused; naming is local administration. This uses the -existing discovery metadata and leaves Iroh wire version 4 unchanged. - -## Initial October 19 automated checks - -- Full Agent `just ci`: all four Clippy configurations, workspace build/tests and - headless tests. Runtime: 271 passed / 2 ignored; desktop app: 494 passed / - 1 ignored; headless app: 86 passed / 1 ignored. The ignored app test contacts - public relays and is run explicitly for this validation, not silently counted - as ordinary offline CI coverage. Existing Pi/runtime workspace suites pass too. -- OpenSecret all-feature Clippy and unit tests: 817 passed / 32 ignored. - Disposable PostgreSQL checks explicitly ran 21 AEAD tamper tests, 2 OAuth and - 8 callback binding tests, including valid foreign-row/lookup-column swaps, - repeated-window expiry and relay registration/key rejection. Migration down/up passed. -- Rust SDK format, all-feature Clippy, 119 unit tests / 1 ignored, and documentation - with warnings denied passed. Client/service registration fixture bytes match. -- Root and Agent Nix flake checks/evaluation passed on the current platform. -- The backend's configured advisory and incident-ban checks passed. Its policy - does not configure a license allowlist; an exploratory unrestricted - `cargo deny check` rejects ordinary MIT/Apache dependencies for that reason. - No security policy was changed to suppress a finding. -- All eleven tests/vectors at the pinned upstream CPace revision passed. See - [qualification and evidence limits](remote-security.md); this is not a claim - that the complete Maple implementation received an external security audit. - -At the audit baseline `3bd0c4a3`, GitHub Actions ran actual Linux, macOS and Windows -Agent tests, unsigned Dev/Prod macOS packaging and downloaded Linux artifact -verification successfully. Signed macOS jobs intentionally skipped. Those results -do not validate subsequent transport edits; those have the local checks above -and require their own hosted CI results. - -## Open implementation findings - -These remain review/rollout work, rather than newly accepted design exceptions: - -- Disabling incoming remote access closes control connections but leaves an - initialized networking endpoint alive until account/daemon teardown. Closing - unused networking must preserve any outgoing controller role. -- User/project deletion cascades to enrollment records; destructive password - reset does not yet remove them. Expired device/admission rows are removed on - that account's later writes. Registration now reclaims other endpoints' window - tombstones using authenticated expiry after 24 hours, preserving the current - endpoint's repeated-W guard. Reset cleanup and cleanup for accounts that never - write again remain hosted-rollout work. -- Pre-admission has a global connection cap and deadline. A separate per-peer - validation bound remains hosted-rollout work. -- Quit/sign-out close local authority but do not yet unpublish discovery - best-effort. Listings can remain stale until lease expiry; they confer no grant. -- Enrollment has row caps, leases and payload bounds, but no dedicated - publish/list/admission request-rate enforcement yet. Row caps do not bound - request traffic. - -Cancel/disable is authoritative on the device even if discovery briefly retains -stale pairing readiness until refresh/expiry. That is a UI freshness limitation; -it does not permit another code attempt or grant control. - -## Remaining trial/release evidence - -Public relay validation does not exercise the prepared Maple relay admission -callback in a deployed relay. The callback has unit/disposable-DB coverage; actual -Maple Dev relay/TLS/credential configuration, DNS and enclave deployment are -separate authorized rollout work. No service or production release was deployed. - -Windows/Linux interactive status-tray behavior still needs platform testing; -builds and automated tests are covered by the baseline CI above. Two physical machines behind independent networks, -sleep/wake recovery, and signed macOS permissions/CUA remain distinct trial gates. -This same-machine debug exercise does not establish those results. A VM bridged -address did not establish the direct test path; the explicit loopback and forced -public-relay paths are the evidence reported above. diff --git a/apps/maple-agent/flake.nix b/apps/maple-agent/flake.nix index ead7cadfe..e9de9a51f 100644 --- a/apps/maple-agent/flake.nix +++ b/apps/maple-agent/flake.nix @@ -99,7 +99,6 @@ cargoLock = { lockFile = ./Cargo.lock; outputHashes = { - "pake-cpace-0.1.0" = "07ig50fcmrlsccn0pmxk30bznbdqq9fgs01y9qw9881vv34a18gj"; "collections-0.1.0" = "sha256-d2GVmZgvJzLk1pbNtPedw0V09+ANZFORZjTSLVxw7jc="; "proptest-1.10.0" = "sha256-p5NTcHhruI8QQvANACg8AMRVNmuvGxs2NLit+/8PaWo="; "wasm_thread-0.3.3" = "sha256-+lRLCIk0S6Y5ORYjDKsYYHia2FtoSoh+rWkQh7mnPBE="; diff --git a/apps/maple-agent/scripts/macos-debug-plist.py b/apps/maple-agent/scripts/macos-debug-plist.py index 153bcdfd4..83ad04f21 100644 --- a/apps/maple-agent/scripts/macos-debug-plist.py +++ b/apps/maple-agent/scripts/macos-debug-plist.py @@ -11,7 +11,6 @@ PUBLIC_CONFIG = ( "MAPLE_API_URL", "MAPLE_BILLING_API_URL", "MAPLE_CLIENT_ID", "MAPLE_PROXY_HOST", "MAPLE_PORT", "MAPLE_ENABLE_CORS", - "MAPLE_REMOTE_RELAYS", "MAPLE_REMOTE_RELAY_ONLY", ) diff --git a/sdk/rust/src/client.rs b/sdk/rust/src/client.rs index 292eaf740..6fe25e7f3 100644 --- a/sdk/rust/src/client.rs +++ b/sdk/rust/src/client.rs @@ -1941,35 +1941,6 @@ impl OpenSecretClient { .await } - pub async fn register_remote_device( - &self, - request: crate::remote::RegisterRemoteDevice, - ) -> Result { - self.authenticated_api_call("/v1/remote/devices", "POST", Some(request)) - .await - } - - pub async fn list_remote_devices(&self) -> Result { - self.authenticated_api_call("/v1/remote/devices", "GET", None::<()>) - .await - } - - pub async fn request_remote_admission( - &self, - request: crate::remote::RemoteAdmissionRequest, - ) -> Result { - self.authenticated_api_call("/v1/remote/admissions", "POST", Some(request)) - .await - } - - pub async fn check_remote_admission( - &self, - request: crate::remote::CheckRemoteAdmission, - ) -> Result { - self.authenticated_api_call("/v1/remote/admissions/check", "POST", Some(request)) - .await - } - pub async fn register_push_device( &self, request: RegisterPushDeviceRequest, diff --git a/sdk/rust/src/lib.rs b/sdk/rust/src/lib.rs index 9bc9b9c3e..0448d10e8 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -5,7 +5,6 @@ pub mod crypto; pub mod error; pub mod pcr; pub mod push; -pub mod remote; pub mod session; pub mod system_one; pub mod types; diff --git a/sdk/rust/src/remote.rs b/sdk/rust/src/remote.rs deleted file mode 100644 index 8f2ac0a6b..000000000 --- a/sdk/rust/src/remote.rs +++ /dev/null @@ -1,119 +0,0 @@ -//! Native desktop discovery and pairing admission over authenticated Transport V2. -//! These records do not authorize control: devices keep their own pairing grants. -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteRegistration { - pub account: Uuid, - pub project: Uuid, - pub deployment: String, - pub channel: String, - pub endpoint: String, - pub name: String, - pub addresses: RemoteAddresses, - pub accepting_connections: bool, - pub window: Option, -} - -/// Account-private bootstrap hints; they are not published in public DNS. -#[derive(Clone, Default, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteAddresses { - pub relays: Vec, - pub direct: Vec, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteWindowInit { - pub id: Uuid, - pub remaining_seconds: u32, -} - -impl RemoteRegistration { - /// Fixed schema serialization, prefixed by its purpose. Field order is part - /// of version 1 and covered by a fixture in the service/client tests. - pub fn proof_message(&self) -> Result, serde_json::Error> { - let mut bytes = b"maple.remote.registration.v1\0".to_vec(); - bytes.extend(serde_json::to_vec(self)?); - Ok(bytes) - } -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RegisterRemoteDevice { - pub registration: RemoteRegistration, - pub signature: String, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteDevice { - pub channel: String, - pub endpoint: String, - pub name: String, - pub addresses: RemoteAddresses, - pub accepting_connections: bool, - pub window: Option, - pub expires_at: i64, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteWindow { - pub id: Uuid, - pub expires_at: i64, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteAdmissionRequest { - pub controller: String, - pub execution: String, - pub window: Uuid, -} - -/// Deliberately not Debug: an admission handle must not enter application logs. -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteAdmission { - pub handle: String, - pub expires_at: i64, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CheckRemoteAdmission { - pub handle: String, - pub controller: String, - pub execution: String, - pub window: Uuid, -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteAdmissionCheck { - pub valid: bool, -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteDeviceList { - pub devices: Vec, -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn proof_schema_matches_shared_version_one_fixture() { - let fixture = include_str!("remote_registration_v1.json").trim_end(); - let registration: RemoteRegistration = serde_json::from_str(fixture).unwrap(); - let mut expected = b"maple.remote.registration.v1\0".to_vec(); - expected.extend_from_slice(fixture.as_bytes()); - assert_eq!(registration.proof_message().unwrap(), expected); - } -} diff --git a/sdk/rust/src/remote_registration_v1.json b/sdk/rust/src/remote_registration_v1.json deleted file mode 100644 index 989ed8171..000000000 --- a/sdk/rust/src/remote_registration_v1.json +++ /dev/null @@ -1 +0,0 @@ -{"account":"00000000-0000-0000-0000-000000000001","project":"00000000-0000-0000-0000-000000000002","deployment":"local","channel":"unpackaged","endpoint":"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a","name":"Example computer","addresses":{"relays":["https://relay.example.com/"],"direct":["127.0.0.1:12345"]},"accepting_connections":true,"window":{"id":"00000000-0000-0000-0000-000000000003","remaining_seconds":300}} diff --git a/services/opensecret/Cargo.lock b/services/opensecret/Cargo.lock index 25c5a7a8d..07e1dfc8a 100644 --- a/services/opensecret/Cargo.lock +++ b/services/opensecret/Cargo.lock @@ -1322,7 +1322,6 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.13", "curve25519-dalek-derive", - "digest 0.10.7", "fiat-crypto 0.2.9", "rustc_version", "subtle", @@ -1685,16 +1684,6 @@ dependencies = [ "serde", ] -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature 2.2.0", -] - [[package]] name = "ed25519" version = "3.0.0" @@ -1704,20 +1693,6 @@ dependencies = [ "signature 3.0.0", ] -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek 4.1.3", - "ed25519 2.2.3", - "serde", - "sha2 0.10.8", - "subtle", - "zeroize", -] - [[package]] name = "ed25519-dalek" version = "3.0.0" @@ -1725,7 +1700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek 5.0.0", - "ed25519 3.0.0", + "ed25519", "sha2 0.11.0", "subtle", "zeroize", @@ -3033,7 +3008,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.15", "http 1.5.0", @@ -3085,7 +3060,6 @@ dependencies = [ "diesel", "diesel-derive-enum", "dotenv", - "ed25519-dalek 2.2.0", "futures", "generic-array", "getrandom 0.2.15", @@ -4599,7 +4573,7 @@ dependencies = [ "digest 0.10.7", "dirs", "ecdsa", - "ed25519-dalek 3.0.0", + "ed25519-dalek", "flate2", "futures-util", "getrandom 0.3.4", @@ -5489,7 +5463,6 @@ version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" dependencies = [ - "serde", "zeroize_derive", ] diff --git a/services/opensecret/Cargo.toml b/services/opensecret/Cargo.toml index a18396270..f242fb8c5 100644 --- a/services/opensecret/Cargo.toml +++ b/services/opensecret/Cargo.toml @@ -49,7 +49,6 @@ bytes = "1.0" clru = "=0.6.3" sha2 = { version = "0.10", default-features = false } hex = "0.4.3" -ed25519-dalek = "2.2" base64 = "0.22.1" vsock = "0.5.1" resend-rs = "0.9.1" @@ -70,7 +69,7 @@ validator = { version = "0.20.0", features = ["derive"] } regex = "1.9.0" lazy_static = "1.4.0" subtle = "2.6.1" -zeroize = { version = "1.8", features = ["derive", "serde"] } +zeroize = { version = "1.8", features = ["derive"] } tiktoken-rs = "0.5" once_cell = "1.19" pulldown-cmark = { version = "0.13.0", default-features = false } diff --git a/services/opensecret/docs/remote-enrollment.md b/services/opensecret/docs/remote-enrollment.md deleted file mode 100644 index b8aec5eeb..000000000 --- a/services/opensecret/docs/remote-enrollment.md +++ /dev/null @@ -1,97 +0,0 @@ -# Maple remote enrollment and relay admission - -The service supplies account-private discovery and admission, not device grants. -`GET/POST /v1/remote/devices`, `POST /v1/remote/admissions`, and -`POST /v1/remote/admissions/check` require JWT authorization and Transport V2. -The Rust SDK provides the corresponding typed methods. Registration additionally -proves possession of the endpoint Ed25519 key over a versioned fixed-schema -message. Client and service have matching copies of `remote_registration_v1.json` -with independent fixture tests; they are not a single shared source file. - -Device leases expire after 15 minutes; active daemons refresh every minute. A -pairing window lasts at most five minutes and repeated registration of the same -window does not extend its saved expiry. Admission handles are random 256-bit -values, stored under their SHA-256 identifier, bound to both endpoints and W, -and expire within two minutes or the window's remaining lifetime. Both endpoints -must have current authenticated registrations in the same channel. The execution -daemon separately validates its own monotonic window and one-attempt budget. - -`remote_enrollment_records` is shared PostgreSQL state, not enclave memory. -Writes serialize only the account's enrollment rows and cap devices/windows at -64 and admissions at 128. The current endpoint's window tombstone is retained -when it re-registers, so identical W cannot extend its saved expiry. On window -registration, other endpoints' tombstones with authenticated expiry over 24 hours -old are eligible for cleanup; compare-and-delete preserves a concurrent newer -record. Cleanup does not trust the editable expiry hint. These caps therefore -limit retained records, not lifetime device identities. Controller-side attempted -window history is separate and is not pruned by this service cleanup. Records contain no code, password verifier, -PAKE message or control grant. Encryption derives a dedicated key/domain from -the enclave root; canonical AAD binds the expected authenticated account, -project, deployment, record kind and record identity. Authorization uses encrypted -expiry and endpoint fields, not plaintext indexes. Copying another user's valid -ciphertext together with rewritten lookup columns fails authentication. Deletion -or rollback can still deny service; local endpoint/grant/window authority prevents -cloud state from silently enrolling a controller. - -## Stock relay callback - -`POST /v1/remote/relay-access` is deliberately outside client Transport V2/JWT. -It accepts only a dedicated relay-to-service bearer credential and the -relay-authenticated endpoint in **`X-Iroh-NodeId`**, the actual header emitted by -`iroh-relay 1.3.0` (that version's nearby documentation names a different header). -A current encrypted device lease for that exact key allows admission. Success is -HTTP 200 with the literal body `true`; all denial paths fail closed. No credential -means all relay admission is denied. Clients never receive the relay credential. - -For a future deployment, provision `maple_remote_relay_auth_v1` through the -existing KMS-encrypted enclave-secret mechanism. Its authenticated plaintext is: - -```json -{"purpose":"maple-remote-relay-admission-v1","deployment":"dev","token":"<64 random hexadecimal characters>"} -``` - -Use a distinct production credential with `deployment: "prod"`. Purpose and -deployment are checked inside the decrypted envelope so an attacker cannot move -another valid KMS secret under this database key and have it accepted. Missing -configuration leaves the route disabled; invalid configured credentials fail -startup. Local-only tests can supply `MAPLE_REMOTE_RELAY_AUTH_TOKEN` instead. -Do not provision secrets, DNS or infrastructure as part of an ordinary code PR. - -## Prepared relay configuration, not a deployment - -Use stock `iroh-relay = 1.3.0`. Choose a Maple-owned Dev hostname/certificate and -OpenSecret URL during the authorized infrastructure rollout. Example access and -receive-bandwidth settings, to combine with that server's TLS/listener config: - -```toml -[access.http] -url = "https:///v1/remote/relay-access" - -[limits.client.rx] -bytes_per_second = 10485760 -max_burst_bytes = 20971520 -``` - -Inject the matching credential through the relay's -`IROH_RELAY_HTTP_BEARER_TOKEN`, not TOML checked into Git. Bind metrics privately. -Apply connection/resource limits in the deployment's existing ingress/service -manager. In 1.3.0, `accept_conn_limit` and `accept_conn_burst` are documented in -source as **not implemented**; do not claim those settings protect admission. -The client receive-byte limiter is implemented. Validate actual access failures, -lease refresh and capacity with the exact deployed executable before trial. -Do not advertise Maple's relay as public infrastructure. - -No new admission is allowed while the check service is unavailable. Existing -peer grants remain local, and established connections/direct paths can continue; -a network change requiring new relay admission may remain disconnected until -service recovers. There is no custom cache or relay fallback that weakens this. - -## Evidence boundaries - -Unit tests cover binding and credentials; disposable-DB tests cover migrations, -repeated windows, bounded tombstone cleanup, account/key substitution, valid -foreign-row swaps and relay registration/key checks. They do not establish a -live relay enforcing lease expiry. Two isolated Local daemons additionally exercise real Transport -V2 registration and direct PAKE over public Iroh relays. That public-relay test -does not validate a deployed Maple relay, its TLS/operator setup, or production -enclave/KMS configuration. Those require the separate rollout gate. diff --git a/services/opensecret/migrations/2026-10-19-004032-0000_remote_enrollment/down.sql b/services/opensecret/migrations/2026-10-19-004032-0000_remote_enrollment/down.sql deleted file mode 100644 index 41506023c..000000000 --- a/services/opensecret/migrations/2026-10-19-004032-0000_remote_enrollment/down.sql +++ /dev/null @@ -1 +0,0 @@ -DROP TABLE remote_enrollment_records; diff --git a/services/opensecret/migrations/2026-10-19-004032-0000_remote_enrollment/up.sql b/services/opensecret/migrations/2026-10-19-004032-0000_remote_enrollment/up.sql deleted file mode 100644 index ccd3f210c..000000000 --- a/services/opensecret/migrations/2026-10-19-004032-0000_remote_enrollment/up.sql +++ /dev/null @@ -1,14 +0,0 @@ --- Short-lived, root-key encrypted remote discovery/admission records. This is --- not a device-grant store; grants and one-attempt pairing windows live locally. -CREATE TABLE remote_enrollment_records ( - user_id UUID NOT NULL REFERENCES users(uuid) ON DELETE CASCADE, - project_id INTEGER NOT NULL REFERENCES org_projects(id) ON DELETE CASCADE, - kind TEXT NOT NULL CHECK (kind IN ('device', 'window', 'admission')), - record_id TEXT NOT NULL CHECK (octet_length(record_id) <= 128), - nonce UUID NOT NULL, - expires_at BIGINT NOT NULL, - ciphertext BYTEA NOT NULL CHECK (octet_length(ciphertext) <= 16384), - PRIMARY KEY (user_id, kind, record_id) -); -CREATE INDEX remote_enrollment_expiry ON remote_enrollment_records (expires_at); -CREATE INDEX remote_enrollment_endpoint ON remote_enrollment_records (record_id) WHERE kind = 'device'; diff --git a/services/opensecret/src/aead_db_tamper_tests.rs b/services/opensecret/src/aead_db_tamper_tests.rs index 4cd6f1a57..2c04b8935 100644 --- a/services/opensecret/src/aead_db_tamper_tests.rs +++ b/services/opensecret/src/aead_db_tamper_tests.rs @@ -2365,291 +2365,3 @@ fn copy_victim_seed_wrap_ciphertext_to_attacker_for_kind( )) .expect("tampered attacker seed wrap should update"); } - -#[tokio::test] -#[ignore = "requires AEAD_TAMPER_TEST_DATABASE_URL pointing at disposable migrated local Postgres"] -async fn db_remote_enrollment_rejects_cross_account_row_swaps_and_replayed_windows() { - use crate::models::schema::remote_enrollment_records as records; - use crate::web::remote::{admit_inner, read_record, register_inner, types::*}; - use ed25519_dalek::{Signer, SigningKey}; - let state = - build_local_test_app_state(std::env::var("AEAD_TAMPER_TEST_DATABASE_URL").unwrap()).await; - let project = first_active_project(&state); - let alice = state - .db - .create_user(NewUser::new(None, None, project.id)) - .unwrap(); - let bob = state - .db - .create_user(NewUser::new(None, None, project.id)) - .unwrap(); - let a_key = SigningKey::from_bytes(&[8; 32]); - let b_key = SigningKey::from_bytes(&[9; 32]); - let init = |user: &User, key: &SigningKey, window| { - let registration = RemoteRegistration { - account: user.uuid, - project: project.client_id, - deployment: "local".into(), - channel: "unpackaged".into(), - endpoint: hex::encode(key.verifying_key().to_bytes()), - name: "Synthetic computer".into(), - addresses: RemoteAddresses::default(), - accepting_connections: true, - window, - }; - let signature = hex::encode(key.sign(®istration.proof_message().unwrap()).to_bytes()); - RegisterRemoteDevice { - registration, - signature, - } - }; - let window = Uuid::new_v4(); - let first = register_inner( - &state, - &alice, - init( - &alice, - &b_key, - Some(RemoteWindowInit { - id: window, - remaining_seconds: 1, - }), - ), - ) - .unwrap(); - let repeated = register_inner( - &state, - &alice, - init( - &alice, - &b_key, - Some(RemoteWindowInit { - id: window, - remaining_seconds: 300, - }), - ), - ) - .unwrap(); - assert_eq!( - first.window.as_ref().unwrap().expires_at, - repeated.window.as_ref().unwrap().expires_at - ); - // Expired plaintext cleanup hints must not revive an identical W. - { - let conn = &mut state.db.get_pool().get().unwrap(); - diesel::update( - records::table - .filter(records::user_id.eq(alice.uuid)) - .filter(records::kind.eq("window")), - ) - .set(records::expires_at.eq(0_i64)) - .execute(conn) - .unwrap(); - } - let repeated = register_inner( - &state, - &alice, - init( - &alice, - &b_key, - Some(RemoteWindowInit { - id: window, - remaining_seconds: 300, - }), - ), - ) - .unwrap(); - assert_eq!( - first.window.as_ref().unwrap().expires_at, - repeated.window.as_ref().unwrap().expires_at - ); - let fresh = Uuid::new_v4(); - let b = register_inner( - &state, - &alice, - init( - &alice, - &b_key, - Some(RemoteWindowInit { - id: fresh, - remaining_seconds: 300, - }), - ), - ) - .unwrap(); - let a = register_inner(&state, &alice, init(&alice, &a_key, None)).unwrap(); - let request = || RemoteAdmissionRequest { - controller: a.endpoint.clone(), - execution: b.endpoint.clone(), - window: fresh, - }; - assert!(crate::web::remote::relay::registered(&state, &a.endpoint)); - assert!(admit_inner(&state, &alice, request()).is_ok()); - assert!(admit_inner(&state, &bob, request()).is_err()); - let mut forged = init(&alice, &a_key, None); - forged.registration.account = bob.uuid; - assert!(register_inner(&state, &bob, forged).is_err()); - // The DB attacker copies a valid row AND rewrites its owner/project lookup - // columns. Only authenticated Bob supplies the AAD identity on the read. - let mut copied = state - .db - .get_remote_enrollment(alice.uuid, "device", &b.endpoint) - .unwrap() - .unwrap(); - copied.user_id = bob.uuid; - copied.project_id = bob.project_id; - state - .db - .put_remote_enrollment(copied, Utc::now().timestamp()) - .unwrap(); - assert!(read_record::(&state, &bob, "device", &b.endpoint).is_err()); - // Same-owner, different device-key substitution also fails. - let mut swapped = state - .db - .get_remote_enrollment(alice.uuid, "device", &b.endpoint) - .unwrap() - .unwrap(); - swapped.record_id = a.endpoint.clone(); - state - .db - .put_remote_enrollment(swapped, Utc::now().timestamp()) - .unwrap(); - assert!(read_record::(&state, &alice, "device", &a.endpoint).is_err()); - assert!(!crate::web::remote::relay::registered(&state, &a.endpoint)); - // A valid admission ciphertext cannot become another account's permit. - let admission_row = state - .db - .list_remote_enrollment(alice.uuid, "admission") - .unwrap() - .pop() - .unwrap(); - let mut copied = admission_row.clone(); - copied.user_id = bob.uuid; - state - .db - .put_remote_enrollment(copied, Utc::now().timestamp()) - .unwrap(); - assert!( - read_record::(&state, &bob, "admission", &admission_row.record_id) - .is_err() - ); -} - -#[tokio::test] -#[ignore = "requires AEAD_TAMPER_TEST_DATABASE_URL pointing at disposable migrated local Postgres"] -async fn db_remote_windows_reclaim_authenticated_expiry_without_deleting_newer_records() { - use crate::models::remote_enrollment::Record; - use crate::remote_enrollment::Binding; - use crate::web::remote::{register_inner, types::*}; - use ed25519_dalek::{Signer, SigningKey}; - let state = - build_local_test_app_state(std::env::var("AEAD_TAMPER_TEST_DATABASE_URL").unwrap()).await; - let project = first_active_project(&state); - let user = state - .db - .create_user(NewUser::new(None, None, project.id)) - .unwrap(); - let now = Utc::now().timestamp(); - for number in 0..64 { - let endpoint = hex::encode( - SigningKey::from_bytes(&[number; 32]) - .verifying_key() - .to_bytes(), - ); - let window = RemoteWindow { - id: Uuid::new_v4(), - expires_at: now - 172800, - }; - let ciphertext = Binding { - account: user.uuid, - project: project.id, - deployment: "local", - kind: "window", - id: &endpoint, - } - .seal(&state.enclave_key, &window) - .unwrap(); - state - .db - .put_remote_enrollment( - Record { - user_id: user.uuid, - project_id: project.id, - kind: "window".into(), - record_id: endpoint, - nonce: window.id, - expires_at: i64::MAX, - ciphertext, - }, - now, - ) - .unwrap(); - } - let old = state - .db - .list_remote_enrollment(user.uuid, "window") - .unwrap() - .pop() - .unwrap(); - let newer = RemoteWindow { - id: Uuid::new_v4(), - expires_at: now + 300, - }; - let mut replaced = old.clone(); - replaced.nonce = newer.id; - replaced.ciphertext = Binding { - account: user.uuid, - project: project.id, - deployment: "local", - kind: "window", - id: &old.record_id, - } - .seal(&state.enclave_key, &newer) - .unwrap(); - state.db.put_remote_enrollment(replaced, now).unwrap(); - state.db.delete_remote_enrollment_unchanged(&old).unwrap(); - assert_eq!( - state - .db - .get_remote_enrollment(user.uuid, "window", &old.record_id) - .unwrap() - .unwrap() - .nonce, - newer.id - ); - let key = SigningKey::from_bytes(&[99; 32]); - let registration = RemoteRegistration { - account: user.uuid, - project: project.client_id, - deployment: "local".into(), - channel: "unpackaged".into(), - endpoint: hex::encode(key.verifying_key().to_bytes()), - name: "New computer".into(), - addresses: RemoteAddresses::default(), - accepting_connections: true, - window: Some(RemoteWindowInit { - id: Uuid::new_v4(), - remaining_seconds: 300, - }), - }; - let signature = hex::encode(key.sign(®istration.proof_message().unwrap()).to_bytes()); - register_inner( - &state, - &user, - RegisterRemoteDevice { - registration, - signature, - }, - ) - .unwrap(); - let remaining = state - .db - .list_remote_enrollment(user.uuid, "window") - .unwrap(); - assert_eq!( - remaining.len(), - 2, - "the live replacement and new window must survive" - ); - assert!(remaining.iter().any(|r| r.nonce == newer.id)); -} diff --git a/services/opensecret/src/db.rs b/services/opensecret/src/db.rs index 39fcf6e27..7cdb727ac 100644 --- a/services/opensecret/src/db.rs +++ b/services/opensecret/src/db.rs @@ -143,36 +143,10 @@ pub enum DBError { AppDataMigrationError(#[from] AppDataMigrationError), #[error("Credential state changed during update")] StaleCredentialState, - #[error("Remote enrollment capacity reached")] - RemoteEnrollmentCapacity, } #[allow(dead_code)] pub trait DBConnection { - fn remote_relay_candidates( - &self, - endpoint: &str, - ) -> Result, DBError>; - fn put_remote_enrollment( - &self, - record: crate::models::remote_enrollment::Record, - now: i64, - ) -> Result; - fn delete_remote_enrollment_unchanged( - &self, - record: &crate::models::remote_enrollment::Record, - ) -> Result<(), DBError>; - fn get_remote_enrollment( - &self, - user: Uuid, - kind: &str, - id: &str, - ) -> Result, DBError>; - fn list_remote_enrollment( - &self, - user: Uuid, - kind: &str, - ) -> Result, DBError>; fn create_user(&self, new_user: NewUser) -> Result; fn get_user_by_uuid(&self, uuid: Uuid) -> Result; fn get_user_by_email(&self, email: String, project_id: i32) -> Result; @@ -779,45 +753,6 @@ pub(crate) struct PostgresConnection { } impl DBConnection for PostgresConnection { - fn remote_relay_candidates( - &self, - endpoint: &str, - ) -> Result, DBError> { - let conn = &mut self.db.get().map_err(|_| DBError::ConnectionError)?; - crate::models::remote_enrollment::Record::relay_candidates(conn, endpoint) - } - fn put_remote_enrollment( - &self, - record: crate::models::remote_enrollment::Record, - now: i64, - ) -> Result { - let conn = &mut self.db.get().map_err(|_| DBError::ConnectionError)?; - record.put(conn, now) - } - fn delete_remote_enrollment_unchanged( - &self, - record: &crate::models::remote_enrollment::Record, - ) -> Result<(), DBError> { - let conn = &mut self.db.get().map_err(|_| DBError::ConnectionError)?; - record.delete_unchanged(conn) - } - fn get_remote_enrollment( - &self, - user: Uuid, - kind: &str, - id: &str, - ) -> Result, DBError> { - let conn = &mut self.db.get().map_err(|_| DBError::ConnectionError)?; - crate::models::remote_enrollment::Record::get(conn, user, kind, id) - } - fn list_remote_enrollment( - &self, - user: Uuid, - kind: &str, - ) -> Result, DBError> { - let conn = &mut self.db.get().map_err(|_| DBError::ConnectionError)?; - crate::models::remote_enrollment::Record::list(conn, user, kind) - } fn create_user(&self, new_user: NewUser) -> Result { let conn = &mut self.db.get().map_err(|_| DBError::ConnectionError)?; let result = new_user.insert(conn).map_err(DBError::from); diff --git a/services/opensecret/src/main.rs b/services/opensecret/src/main.rs index 9dcc1d8d0..d1bdee18e 100644 --- a/services/opensecret/src/main.rs +++ b/services/opensecret/src/main.rs @@ -120,7 +120,6 @@ mod provider_client; mod provider_registry; mod provider_routing; mod proxy_config; -mod remote_enrollment; mod secret_cache_maintenance; #[cfg(test)] mod security_invariants; @@ -1034,7 +1033,6 @@ pub struct AppState { apple_jwt_verifier: Arc, response_executions: web::responses::ResponseExecutionRegistry, kagi_client: Option>, - remote_relay_token: Option>, } #[derive(Clone, Default)] @@ -1167,14 +1165,9 @@ pub struct AppStateBuilder { os_flags_base_url: Option, os_flags_api_key: Option, kagi_api_key: Option, - remote_relay_token: Option>, } impl AppStateBuilder { - pub fn remote_relay_token(mut self, token: Option>) -> Self { - self.remote_relay_token = token; - self - } pub fn app_mode(mut self, app_mode: AppMode) -> Self { self.app_mode = Some(app_mode); self @@ -1499,7 +1492,6 @@ impl AppStateBuilder { apple_jwt_verifier, response_executions: web::responses::ResponseExecutionRegistry::default(), kagi_client, - remote_relay_token: self.remote_relay_token, }) } } @@ -3775,9 +3767,7 @@ async fn retrieve_kagi_api_key( fn application_routes(app_state: Arc) -> Router<()> { protected_routes(app_state.clone()) - .merge(web::remote::router(app_state.clone())) .route_layer(from_fn_with_state(app_state.clone(), validate_jwt)) - .merge(web::remote::relay::router(app_state.clone())) .merge(login_routes(app_state.clone())) .merge( openai_routes(app_state.clone()) @@ -4128,9 +4118,6 @@ async fn main() -> Result<(), Error> { optional_env("OS_FLAGS_BASE_URL") }; - let remote_relay_token = - web::remote::relay::load_token(&app_mode, aws_credential_manager.clone(), db.clone()) - .await?; let app_state = AppStateBuilder::default() .app_mode(app_mode.clone()) .db(db) @@ -4152,7 +4139,6 @@ async fn main() -> Result<(), Error> { .os_flags_base_url(os_flags_base_url) .os_flags_api_key(os_flags_api_key) .kagi_api_key(kagi_api_key) - .remote_relay_token(remote_relay_token) .build() .await?; tracing::info!("App state created, app_mode: {:?}", app_mode); diff --git a/services/opensecret/src/models/mod.rs b/services/opensecret/src/models/mod.rs index 90612e6c8..0cea43feb 100644 --- a/services/opensecret/src/models/mod.rs +++ b/services/opensecret/src/models/mod.rs @@ -14,7 +14,6 @@ pub mod platform_invite_codes; pub mod platform_password_reset; pub mod platform_users; pub mod project_settings; -pub mod remote_enrollment; pub mod responses; pub mod schema; pub mod token_usage; diff --git a/services/opensecret/src/models/remote_enrollment.rs b/services/opensecret/src/models/remote_enrollment.rs deleted file mode 100644 index b7b2c8fcb..000000000 --- a/services/opensecret/src/models/remote_enrollment.rs +++ /dev/null @@ -1,115 +0,0 @@ -use diesel::prelude::*; -use uuid::Uuid; - -use crate::{ - db::DBError, - models::schema::{remote_enrollment_records, users}, -}; - -#[derive(Clone, Queryable, Insertable, AsChangeset)] -#[diesel(table_name = remote_enrollment_records)] -pub struct Record { - pub user_id: Uuid, - pub project_id: i32, - pub kind: String, - pub record_id: String, - pub nonce: Uuid, - // Index/cleanup hint only. Authorization reads expiry from the ciphertext. - pub expires_at: i64, - pub ciphertext: Vec, -} - -impl Record { - pub fn relay_candidates(conn: &mut PgConnection, endpoint: &str) -> Result, DBError> { - remote_enrollment_records::table - .filter(remote_enrollment_records::kind.eq("device")) - .filter(remote_enrollment_records::record_id.eq(endpoint)) - .limit(64) - .load(conn) - .map_err(Into::into) - } - pub fn get( - conn: &mut PgConnection, - user: Uuid, - kind: &str, - id: &str, - ) -> Result, DBError> { - remote_enrollment_records::table - .find((user, kind, id)) - .first(conn) - .optional() - .map_err(Into::into) - } - - pub fn list(conn: &mut PgConnection, user: Uuid, kind: &str) -> Result, DBError> { - remote_enrollment_records::table - .filter(remote_enrollment_records::user_id.eq(user)) - .filter(remote_enrollment_records::kind.eq(kind)) - .limit(128) - .load(conn) - .map_err(Into::into) - } - - /// A cleanup decision was made from authenticated ciphertext. Do not delete - /// a newer registration installed by another enclave in the meantime. - pub fn delete_unchanged(&self, conn: &mut PgConnection) -> Result<(), DBError> { - diesel::delete( - remote_enrollment_records::table - .find((self.user_id, &self.kind, &self.record_id)) - .filter(remote_enrollment_records::nonce.eq(self.nonce)) - .filter(remote_enrollment_records::ciphertext.eq(&self.ciphertext)), - ) - .execute(conn)?; - Ok(()) - } - - pub fn put(&self, conn: &mut PgConnection, now: i64) -> Result { - conn.transaction(|conn| { - // Serialize only this account's bounded enrollment writes across - // enclaves. No network awaits or process-local correctness cache. - users::table - .filter(users::uuid.eq(self.user_id)) - .select(users::id) - .for_update() - .first::(conn)?; - diesel::delete( - remote_enrollment_records::table - .filter(remote_enrollment_records::user_id.eq(self.user_id)) - .filter(remote_enrollment_records::expires_at.le(now)) - .filter(remote_enrollment_records::kind.ne("window")), - ) - .execute(conn)?; - let previous = Self::get(conn, self.user_id, &self.kind, &self.record_id)?; - // An identical W registration never extends its original expiry. - if self.kind == "window" { - if let Some(ref record) = previous { - if record.nonce == self.nonce { - return Ok(record.clone()); - } - } - } - if previous.is_none() { - let count = remote_enrollment_records::table - .filter(remote_enrollment_records::user_id.eq(self.user_id)) - .filter(remote_enrollment_records::kind.eq(&self.kind)) - .count() - .get_result::(conn)?; - let limit = if self.kind == "admission" { 128 } else { 64 }; - if count >= limit { - return Err(DBError::RemoteEnrollmentCapacity); - } - } - diesel::insert_into(remote_enrollment_records::table) - .values(self) - .on_conflict(( - remote_enrollment_records::user_id, - remote_enrollment_records::kind, - remote_enrollment_records::record_id, - )) - .do_update() - .set(self) - .get_result(conn) - .map_err(Into::into) - }) - } -} diff --git a/services/opensecret/src/models/schema.rs b/services/opensecret/src/models/schema.rs index c87d91d6d..7db204790 100644 --- a/services/opensecret/src/models/schema.rs +++ b/services/opensecret/src/models/schema.rs @@ -409,18 +409,6 @@ diesel::table! { } } -diesel::table! { - remote_enrollment_records (user_id, kind, record_id) { - user_id -> Uuid, - project_id -> Int4, - kind -> Text, - record_id -> Text, - nonce -> Uuid, - expires_at -> Int8, - ciphertext -> Bytea, - } -} - diesel::table! { use diesel::sql_types::*; use super::sql_types::ResponseStatus; @@ -635,7 +623,6 @@ diesel::joinable!(project_settings -> org_projects (project_id)); diesel::joinable!(reasoning_items -> assistant_messages (assistant_message_id)); diesel::joinable!(reasoning_items -> conversations (conversation_id)); diesel::joinable!(reasoning_items -> responses (response_id)); -diesel::joinable!(remote_enrollment_records -> org_projects (project_id)); diesel::joinable!(responses -> conversations (conversation_id)); diesel::joinable!(tool_calls -> conversations (conversation_id)); diesel::joinable!(tool_calls -> responses (response_id)); @@ -680,7 +667,6 @@ diesel::allow_tables_to_appear_in_same_query!( project_settings, push_devices, reasoning_items, - remote_enrollment_records, responses, token_usage, tool_calls, diff --git a/services/opensecret/src/remote_enrollment.rs b/services/opensecret/src/remote_enrollment.rs deleted file mode 100644 index 849a522e2..000000000 --- a/services/opensecret/src/remote_enrollment.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! Enrollment records are discovery/admission hints, never device grants. -//! Expected account/project/deployment come from authenticated request context; -//! copying another account's valid row and its lookup columns cannot authorize it. -use serde::{de::DeserializeOwned, Serialize}; -use uuid::Uuid; -use zeroize::Zeroizing; - -use crate::encrypt::{decrypt_aead_v1, derive_key, encrypt_aead_v1, CanonicalBytes, EncryptError}; - -const KEY_DOMAIN: &[u8] = b"os.remote-enrollment.aead-key.v1"; -const AAD_DOMAIN: &str = "os.remote-enrollment.record.v1"; - -pub(crate) struct Binding<'a> { - pub account: Uuid, - pub project: i32, - pub deployment: &'a str, - pub kind: &'a str, - pub id: &'a str, -} - -impl Binding<'_> { - fn aad(&self) -> Vec { - let mut aad = CanonicalBytes::new(AAD_DOMAIN); - aad.append_i16(1) - .append_uuid(self.account) - .append_i32(self.project) - .append_str(self.deployment) - .append_str(self.kind) - .append_str(self.id); - aad.into_bytes() - } - - pub fn seal(&self, root: &[u8], value: &T) -> Result, EncryptError> { - let key = Zeroizing::new(derive_key(root, KEY_DOMAIN)?); - let body = - Zeroizing::new(serde_json::to_vec(value).map_err(|_| EncryptError::FailedToDecrypt)?); - encrypt_aead_v1(&key, &body, &self.aad()) - } - - pub fn open( - &self, - root: &[u8], - encrypted: &[u8], - ) -> Result { - let key = Zeroizing::new(derive_key(root, KEY_DOMAIN)?); - let body = Zeroizing::new(decrypt_aead_v1(&key, encrypted, &self.aad())?); - serde_json::from_slice(&body).map_err(|_| EncryptError::FailedToDecrypt) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::{json, Value}; - fn binding() -> Binding<'static> { - Binding { - account: Uuid::from_u128(1), - project: 7, - deployment: "local", - kind: "window", - id: "endpoint:window", - } - } - - #[test] - fn valid_foreign_rows_and_swapped_context_are_rejected() { - let root = [42; 32]; - let expected = binding(); - let value = json!({"endpoint": "host", "window": "nonce", "expires": 100}); - let ciphertext = expected.seal(&root, &value).unwrap(); - assert_eq!(expected.open::(&root, &ciphertext).unwrap(), value); - for field in 0..5 { - let mut foreign = binding(); - match field { - 0 => foreign.account = Uuid::from_u128(2), - 1 => foreign.project = 8, - 2 => foreign.deployment = "prod", - 3 => foreign.kind = "device", - _ => foreign.id = "other-endpoint:other-window", - } - // The attacker has a fully valid foreign row, including all its - // metadata. The reader still supplies the authenticated identity - // and requested context rather than trusting those columns. - let foreign_ciphertext = foreign.seal(&root, &value).unwrap(); - assert!(expected.open::(&root, &foreign_ciphertext).is_err()); - assert!(foreign.open::(&root, &ciphertext).is_err()); - } - let mut changed = ciphertext; - *changed.last_mut().unwrap() ^= 1; - assert!(expected.open::(&root, &changed).is_err()); - } -} diff --git a/services/opensecret/src/security_invariants.rs b/services/opensecret/src/security_invariants.rs index 9035f681b..7b645fe2a 100644 --- a/services/opensecret/src/security_invariants.rs +++ b/services/opensecret/src/security_invariants.rs @@ -114,7 +114,6 @@ const DESTRUCTIVE_RESET_CASCADE_ENCRYPTED_TABLES: &[(&str, &str)] = &[ const DESTRUCTIVE_RESET_UPDATED_ENCRYPTED_TABLES: &[&str] = &["users"]; const ENCRYPTED_TABLES_NOT_USER_PRIVATE_STORAGE: &[&str] = &[ - "remote_enrollment_records", "account_deletion_requests", "org_project_secrets", "password_reset_requests", @@ -1440,9 +1439,8 @@ fn collect_schema_tables_with_encrypted_columns(schema_source: &str) -> BTreeSet continue; } - let is_encrypted_column = trimmed.contains("_enc ->") - || trimmed.contains("encrypted_code ->") - || trimmed.contains("ciphertext ->"); + let is_encrypted_column = + trimmed.contains("_enc ->") || trimmed.contains("encrypted_code ->"); if is_encrypted_column { let table_name = current_table.as_ref().unwrap_or_else(|| { panic!("encrypted column `{trimmed}` should appear inside a table declaration") diff --git a/services/opensecret/src/web/mod.rs b/services/opensecret/src/web/mod.rs index 3e03f4985..753a4500b 100644 --- a/services/opensecret/src/web/mod.rs +++ b/services/opensecret/src/web/mod.rs @@ -10,7 +10,6 @@ pub mod openai_auth; pub mod platform; pub mod protected_routes; pub mod provider_error; -pub(crate) mod remote; pub mod responses; pub(crate) mod system_one; pub(crate) mod transcription; diff --git a/services/opensecret/src/web/remote/mod.rs b/services/opensecret/src/web/remote/mod.rs deleted file mode 100644 index 98ecafef3..000000000 --- a/services/opensecret/src/web/remote/mod.rs +++ /dev/null @@ -1,440 +0,0 @@ -//! Same-account discovery and admission. The service never sees a pairing code -//! or PAKE message and never creates a device's local control grant. -pub(crate) mod relay; -pub(crate) mod types; -use types::*; - -use axum::{ - extract::State, - middleware::from_fn_with_state, - response::Response, - routing::{get, post}, - Extension, Router, -}; -use chrono::Utc; -use ed25519_dalek::{Signature, VerifyingKey}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::{net::SocketAddr, sync::Arc}; -use uuid::Uuid; - -use crate::{ - db::DBError, - models::{remote_enrollment::Record, users::User}, - remote_enrollment::Binding, - web::encryption_middleware::{decrypt_request, encrypt_response, Decrypted, TransportSession}, - ApiError, AppState, -}; - -const DEVICE_LEASE: i64 = 900; -const ADMISSION_LEASE: i64 = 120; -// Keep recent tombstones across short interruptions. Local device windows still -// enforce their original deadline and one attempt after service cleanup. -const WINDOW_RETENTION: i64 = 24 * 60 * 60; - -pub(crate) fn router(state: Arc) -> Router<()> { - Router::new() - .route( - "/v1/remote/devices", - get(list) - .layer(from_fn_with_state(state.clone(), decrypt_request::<()>)) - .merge(post(register).layer(from_fn_with_state( - state.clone(), - decrypt_request::, - ))), - ) - .route( - "/v1/remote/admissions", - post(admit).layer(from_fn_with_state( - state.clone(), - decrypt_request::, - )), - ) - .route( - "/v1/remote/admissions/check", - post(check).layer(from_fn_with_state( - state.clone(), - decrypt_request::, - )), - ) - .with_state(state) -} - -fn db_error(error: DBError) -> ApiError { - match error { - DBError::RemoteEnrollmentCapacity => ApiError::TooManyRequests, - _ => ApiError::InternalServerError, - } -} - -fn binding<'a>(user: &User, deployment: &'a str, kind: &'a str, id: &'a str) -> Binding<'a> { - Binding { - account: user.uuid, - project: user.project_id, - deployment, - kind, - id, - } -} - -pub(crate) fn read_record( - state: &AppState, - user: &User, - kind: &str, - id: &str, -) -> Result, ApiError> { - let row = state - .db - .get_remote_enrollment(user.uuid, kind, id) - .map_err(db_error)?; - row.map(|row| { - binding(user, &state.app_mode.to_string(), kind, id) - .open(&state.enclave_key, &row.ciphertext) - .map_err(|_| ApiError::BadRequest) - }) - .transpose() -} - -fn save_record( - state: &AppState, - user: &User, - kind: &str, - id: &str, - nonce: Uuid, - expires_at: i64, - value: &T, -) -> Result { - let deployment = state.app_mode.to_string(); - let expected = binding(user, &deployment, kind, id); - let ciphertext = expected - .seal(&state.enclave_key, value) - .map_err(|_| ApiError::InternalServerError)?; - let row = state - .db - .put_remote_enrollment( - Record { - user_id: user.uuid, - project_id: user.project_id, - kind: kind.into(), - record_id: id.into(), - nonce, - expires_at, - ciphertext, - }, - Utc::now().timestamp(), - ) - .map_err(db_error)?; - expected - .open(&state.enclave_key, &row.ciphertext) - .map_err(|_| ApiError::BadRequest) -} - -fn public_key(text: &str) -> Result { - if text.len() != 64 - || text - .bytes() - .any(|c| !c.is_ascii_digit() && !(b'a'..=b'f').contains(&c)) - { - return Err(ApiError::BadRequest); - } - let bytes: [u8; 32] = hex::decode(text) - .map_err(|_| ApiError::BadRequest)? - .try_into() - .map_err(|_| ApiError::BadRequest)?; - VerifyingKey::from_bytes(&bytes).map_err(|_| ApiError::BadRequest) -} - -fn validate_registration( - state: &AppState, - user: &User, - request: &RegisterRemoteDevice, -) -> Result<(), ApiError> { - let registration = &request.registration; - let project = state - .db - .get_org_project_by_id(user.project_id) - .map_err(db_error)?; - if registration.account != user.uuid - || registration.project != project.client_id - || registration.deployment != state.app_mode.to_string() - || !matches!(registration.channel.as_str(), "unpackaged" | "dev" | "prod") - || registration.name.is_empty() - || registration.name.len() > 128 - || registration.name.chars().any(char::is_control) - || registration.addresses.relays.len() > 4 - || registration.addresses.direct.len() > 16 - { - return Err(ApiError::BadRequest); - } - for address in ®istration.addresses.direct { - address - .parse::() - .map_err(|_| ApiError::BadRequest)?; - } - for address in ®istration.addresses.relays { - let url = url::Url::parse(address).map_err(|_| ApiError::BadRequest)?; - if address.len() > 512 - || url.scheme() != "https" - || !url.username().is_empty() - || url.password().is_some() - || url.query().is_some() - || url.fragment().is_some() - { - return Err(ApiError::BadRequest); - } - } - if let Some(window) = ®istration.window { - if !registration.accepting_connections || !(1..=300).contains(&window.remaining_seconds) { - return Err(ApiError::BadRequest); - } - } - let key = public_key(®istration.endpoint)?; - let signature = hex::decode(&request.signature).map_err(|_| ApiError::BadRequest)?; - let signature = Signature::from_slice(&signature).map_err(|_| ApiError::BadRequest)?; - key.verify_strict( - ®istration - .proof_message() - .map_err(|_| ApiError::BadRequest)?, - &signature, - ) - .map_err(|_| ApiError::BadRequest) -} - -fn prune_old_windows( - state: &AppState, - user: &User, - current_endpoint: &str, - now: i64, -) -> Result<(), ApiError> { - let deployment = state.app_mode.to_string(); - for row in state - .db - .list_remote_enrollment(user.uuid, "window") - .map_err(db_error)? - { - // Never remove the current endpoint's W before save_record compares it. - if row.record_id == current_endpoint { - continue; - } - let Ok(window) = binding(user, &deployment, "window", &row.record_id) - .open::(&state.enclave_key, &row.ciphertext) - else { - continue; - }; - if window.expires_at < now.saturating_sub(WINDOW_RETENTION) { - state - .db - .delete_remote_enrollment_unchanged(&row) - .map_err(db_error)?; - } - } - Ok(()) -} - -pub(crate) fn register_inner( - state: &AppState, - user: &User, - request: RegisterRemoteDevice, -) -> Result { - validate_registration(state, user, &request)?; - let registration = request.registration; - let now = Utc::now().timestamp(); - if registration.window.is_some() { - prune_old_windows(state, user, ®istration.endpoint, now)?; - } - let window = registration - .window - .map(|window| { - let saved = RemoteWindow { - id: window.id, - expires_at: now + i64::from(window.remaining_seconds), - }; - save_record( - state, - user, - "window", - ®istration.endpoint, - window.id, - saved.expires_at, - &saved, - ) - }) - .transpose()?; - let device = RemoteDevice { - channel: registration.channel, - endpoint: registration.endpoint.clone(), - name: registration.name, - addresses: registration.addresses, - accepting_connections: registration.accepting_connections, - window, - expires_at: now + DEVICE_LEASE, - }; - save_record( - state, - user, - "device", - ®istration.endpoint, - Uuid::new_v4(), - device.expires_at, - &device, - ) -} - -async fn register( - State(state): State>, - Extension(session): Extension, - Extension(user): Extension, - Decrypted(request): Decrypted, -) -> Result { - if !session.is_v2() { - return Err(ApiError::BadRequest); - } - let worker = state.clone(); - let value = tokio::task::spawn_blocking(move || register_inner(&worker, &user, request)) - .await - .map_err(|_| ApiError::InternalServerError)??; - encrypt_response(&state, &session, &value).await -} - -async fn list( - State(state): State>, - Extension(session): Extension, - Extension(user): Extension, -) -> Result { - if !session.is_v2() { - return Err(ApiError::BadRequest); - } - let worker = state.clone(); - let devices = tokio::task::spawn_blocking(move || { - let now = Utc::now().timestamp(); - let deployment = worker.app_mode.to_string(); - let rows = worker - .db - .list_remote_enrollment(user.uuid, "device") - .map_err(db_error)?; - let mut devices = Vec::new(); - for row in rows { - let expected = binding(&user, &deployment, "device", &row.record_id); - // A corrupt/foreign row is not an available device. Never use its - // identity columns as the account authority for decryption. - if let Ok(mut device) = - expected.open::(&worker.enclave_key, &row.ciphertext) - { - if device.expires_at > now && device.endpoint == row.record_id { - if device.window.as_ref().is_some_and(|w| w.expires_at <= now) { - device.window = None; - } - devices.push(device); - } - } - } - Ok::<_, ApiError>(devices) - }) - .await - .map_err(|_| ApiError::InternalServerError)??; - encrypt_response(&state, &session, &RemoteDeviceList { devices }).await -} - -#[derive(Serialize, Deserialize)] -struct AdmissionRecord { - controller: String, - execution: String, - window: Uuid, - expires_at: i64, -} - -fn admission_id(handle: &str) -> Result { - if handle.len() != 64 || !handle.bytes().all(|c| c.is_ascii_hexdigit()) { - return Err(ApiError::BadRequest); - } - Ok(hex::encode(Sha256::digest(handle.as_bytes()))) -} - -pub(crate) fn admit_inner( - state: &AppState, - user: &User, - request: RemoteAdmissionRequest, -) -> Result { - public_key(&request.controller)?; - public_key(&request.execution)?; - if request.controller == request.execution { - return Err(ApiError::BadRequest); - } - let now = Utc::now().timestamp(); - let controller: RemoteDevice = - read_record(state, user, "device", &request.controller)?.ok_or(ApiError::BadRequest)?; - let execution: RemoteDevice = - read_record(state, user, "device", &request.execution)?.ok_or(ApiError::BadRequest)?; - let window = execution.window.ok_or(ApiError::BadRequest)?; - if controller.channel != execution.channel - || controller.expires_at <= now - || execution.expires_at <= now - || !execution.accepting_connections - || window.id != request.window - || window.expires_at <= now - { - return Err(ApiError::BadRequest); - } - let expires_at = window.expires_at.min(now + ADMISSION_LEASE); - let mut random = [0; 32]; - getrandom::getrandom(&mut random).map_err(|_| ApiError::InternalServerError)?; - let handle = hex::encode(random); - let id = admission_id(&handle)?; - let record = AdmissionRecord { - controller: request.controller, - execution: request.execution, - window: request.window, - expires_at, - }; - let _: AdmissionRecord = save_record( - state, - user, - "admission", - &id, - Uuid::new_v4(), - expires_at, - &record, - )?; - Ok(RemoteAdmission { handle, expires_at }) -} - -async fn admit( - State(state): State>, - Extension(session): Extension, - Extension(user): Extension, - Decrypted(request): Decrypted, -) -> Result { - if !session.is_v2() { - return Err(ApiError::BadRequest); - } - let worker = state.clone(); - let value = tokio::task::spawn_blocking(move || admit_inner(&worker, &user, request)) - .await - .map_err(|_| ApiError::InternalServerError)??; - encrypt_response(&state, &session, &value).await -} - -async fn check( - State(state): State>, - Extension(session): Extension, - Extension(user): Extension, - Decrypted(request): Decrypted, -) -> Result { - if !session.is_v2() { - return Err(ApiError::BadRequest); - } - let worker = state.clone(); - let valid = tokio::task::spawn_blocking(move || { - let id = admission_id(&request.handle)?; - let record: Option = read_record(&worker, &user, "admission", &id)?; - Ok::<_, ApiError>(record.is_some_and(|record| { - record.expires_at > Utc::now().timestamp() - && record.controller == request.controller - && record.execution == request.execution - && record.window == request.window - })) - }) - .await - .map_err(|_| ApiError::InternalServerError)??; - encrypt_response(&state, &session, &RemoteAdmissionCheck { valid }).await -} diff --git a/services/opensecret/src/web/remote/registration_v1.json b/services/opensecret/src/web/remote/registration_v1.json deleted file mode 100644 index 989ed8171..000000000 --- a/services/opensecret/src/web/remote/registration_v1.json +++ /dev/null @@ -1 +0,0 @@ -{"account":"00000000-0000-0000-0000-000000000001","project":"00000000-0000-0000-0000-000000000002","deployment":"local","channel":"unpackaged","endpoint":"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a","name":"Example computer","addresses":{"relays":["https://relay.example.com/"],"direct":["127.0.0.1:12345"]},"accepting_connections":true,"window":{"id":"00000000-0000-0000-0000-000000000003","remaining_seconds":300}} diff --git a/services/opensecret/src/web/remote/relay.rs b/services/opensecret/src/web/remote/relay.rs deleted file mode 100644 index 5e887ef7f..000000000 --- a/services/opensecret/src/web/remote/relay.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! Stock iroh-relay 1.3 HTTP admission. This service credential is never in a -//! desktop build. The relay supplies the endpoint identity it authenticated. -use crate::{db::DBConnection, AppMode, AppState, AwsCredentialManager, Error}; -use axum::{ - extract::State, - http::{HeaderMap, StatusCode}, - routing::post, - Router, -}; -use base64::{engine::general_purpose::STANDARD, Engine}; -use serde::Deserialize; -use sha2::{Digest, Sha256}; -use std::sync::Arc; -use subtle::ConstantTimeEq; -use zeroize::Zeroizing; - -const PURPOSE: &str = "maple-remote-relay-admission-v1"; - -pub(crate) fn router(state: Arc) -> Router<()> { - Router::new() - .route("/v1/remote/relay-access", post(access)) - .with_state(state) -} - -pub(crate) async fn load_token( - mode: &AppMode, - credentials: Arc>>, - db: Arc, -) -> Result>, Error> { - if *mode == AppMode::Local { - return std::env::var("MAPLE_REMOTE_RELAY_AUTH_TOKEN") - .ok() - .map(validate_token) - .transpose(); - } - let Some(row) = db.get_enclave_secret_by_key("maple_remote_relay_auth_v1")? else { - return Ok(None); - }; - let manager = credentials - .read() - .await - .clone() - .ok_or(Error::SecretParsingError)?; - let credentials = manager - .get_credentials() - .await - .ok_or(Error::SecretParsingError)?; - let plaintext = Zeroizing::new( - crate::decrypt_with_kms( - &credentials.region, - &credentials.access_key_id, - &credentials.secret_access_key, - &credentials.token, - &STANDARD.encode(row.value), - ) - .map_err(|_| Error::SecretParsingError)?, - ); - decode_token(&plaintext, &mode.to_string()).map(Some) -} - -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct Credential { - purpose: String, - deployment: String, - token: Zeroizing, -} - -fn validate_token(token: String) -> Result, Error> { - let token = Zeroizing::new(token); - if token.len() != 64 || !token.bytes().all(|b| b.is_ascii_hexdigit()) { - return Err(Error::SecretParsingError); - } - Ok(token) -} - -fn decode_token(plaintext: &[u8], deployment: &str) -> Result, Error> { - // Bind the KMS-authenticated plaintext itself, not the attacker-controlled - // DB key label. A valid credential for another purpose/environment fails. - let credential: Credential = - serde_json::from_slice(plaintext).map_err(|_| Error::SecretParsingError)?; - if credential.purpose != PURPOSE - || credential.deployment != deployment - || credential.token.len() != 64 - || !credential.token.bytes().all(|b| b.is_ascii_hexdigit()) - { - return Err(Error::SecretParsingError); - } - Ok(credential.token) -} - -fn authorized(headers: &HeaderMap, expected: Option<&str>) -> bool { - let Some(expected) = expected else { - return false; - }; - let Some(provided) = headers - .get(axum::http::header::AUTHORIZATION) - .and_then(|h| h.to_str().ok()) - .and_then(|h| h.strip_prefix("Bearer ")) - else { - return false; - }; - provided.len() == 64 - && bool::from( - Sha256::digest(provided.as_bytes()).ct_eq(&Sha256::digest(expected.as_bytes())), - ) -} - -pub(crate) fn registered(state: &AppState, endpoint: &str) -> bool { - if super::public_key(endpoint).is_err() { - return false; - } - let Ok(rows) = state.db.remote_relay_candidates(endpoint) else { - return false; - }; - for row in rows { - let Ok(user) = state.db.get_user_by_uuid(row.user_id) else { - continue; - }; - let deployment = state.app_mode.to_string(); - let expected = super::binding(&user, &deployment, "device", endpoint); - if let Ok(device) = - expected.open::(&state.enclave_key, &row.ciphertext) - { - if device.endpoint == endpoint && device.expires_at > chrono::Utc::now().timestamp() { - return true; - } - } - } - false -} - -async fn access( - State(state): State>, - headers: HeaderMap, -) -> (StatusCode, &'static str) { - if !authorized( - &headers, - state.remote_relay_token.as_deref().map(|s| s.as_str()), - ) { - return (StatusCode::FORBIDDEN, "false"); - } - // Pinned to the actual 1.3.0 executable, whose header is X-Iroh-NodeId. - let Some(endpoint) = headers - .get("X-Iroh-NodeId") - .and_then(|v| v.to_str().ok()) - .map(str::to_owned) - else { - return (StatusCode::FORBIDDEN, "false"); - }; - let allowed = tokio::task::spawn_blocking(move || registered(&state, &endpoint)) - .await - .unwrap_or(false); - if allowed { - (StatusCode::OK, "true") - } else { - (StatusCode::FORBIDDEN, "false") - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn relay_credential_is_bound_and_missing_configuration_denies() { - let token = "a".repeat(64); - let credential = - serde_json::json!({"purpose": PURPOSE, "deployment": "prod", "token": token}); - let bytes = serde_json::to_vec(&credential).unwrap(); - assert!(decode_token(&bytes, "prod").is_ok()); - assert!(decode_token(&bytes, "dev").is_err()); - assert!(decode_token( - br#"{"purpose":"billing","deployment":"prod","token":"secret"}"#, - "prod" - ) - .is_err()); - let mut headers = HeaderMap::new(); - headers.insert( - axum::http::header::AUTHORIZATION, - format!("Bearer {token}").parse().unwrap(), - ); - assert!(!authorized(&headers, None)); - assert!(authorized(&headers, Some(&token))); - assert!(!authorized(&headers, Some(&"b".repeat(64)))); - } -} diff --git a/services/opensecret/src/web/remote/types.rs b/services/opensecret/src/web/remote/types.rs deleted file mode 100644 index 624b7b600..000000000 --- a/services/opensecret/src/web/remote/types.rs +++ /dev/null @@ -1,120 +0,0 @@ -//! Native desktop discovery and pairing admission over authenticated Transport V2. -//! These records do not authorize control: devices keep their own pairing grants. -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteRegistration { - pub account: Uuid, - pub project: Uuid, - pub deployment: String, - pub channel: String, - pub endpoint: String, - pub name: String, - pub addresses: RemoteAddresses, - pub accepting_connections: bool, - pub window: Option, -} - -/// Account-private bootstrap hints; they are not published in public DNS. -#[derive(Clone, Default, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteAddresses { - pub relays: Vec, - pub direct: Vec, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteWindowInit { - pub id: Uuid, - pub remaining_seconds: u32, -} - -impl RemoteRegistration { - /// Fixed schema serialization, prefixed by its purpose. Field order is part - /// of version 1 and covered by a fixture in the service/client tests. - pub fn proof_message(&self) -> Result, serde_json::Error> { - let mut bytes = b"maple.remote.registration.v1\0".to_vec(); - bytes.extend(serde_json::to_vec(self)?); - Ok(bytes) - } -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RegisterRemoteDevice { - pub registration: RemoteRegistration, - pub signature: String, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteDevice { - pub channel: String, - pub endpoint: String, - pub name: String, - pub addresses: RemoteAddresses, - pub accepting_connections: bool, - pub window: Option, - pub expires_at: i64, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteWindow { - pub id: Uuid, - pub expires_at: i64, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteAdmissionRequest { - pub controller: String, - pub execution: String, - pub window: Uuid, -} - -/// Deliberately not Debug: an admission handle must not enter application logs. -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteAdmission { - pub handle: String, - pub expires_at: i64, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CheckRemoteAdmission { - pub handle: String, - pub controller: String, - pub execution: String, - pub window: Uuid, -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteAdmissionCheck { - pub valid: bool, -} - -#[derive(Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RemoteDeviceList { - pub devices: Vec, -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn proof_schema_matches_shared_version_one_fixture() { - // Keep this golden vector inside the standalone service package too. - let fixture = include_str!("registration_v1.json").trim_end(); - let registration: RemoteRegistration = serde_json::from_str(fixture).unwrap(); - let mut expected = b"maple.remote.registration.v1\0".to_vec(); - expected.extend_from_slice(fixture.as_bytes()); - assert_eq!(registration.proof_message().unwrap(), expected); - } -}