From 3b5c9d4f5d8063b2b81764ee1a3f18e2b4e410e2 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:46:26 +0000 Subject: [PATCH 01/10] Add paired desktop remote access through the shared Agent host --- apps/maple-agent/Cargo.lock | 1244 ++++++++++++++++- apps/maple-agent/README.md | 7 + apps/maple-agent/app/Cargo.toml | 8 + apps/maple-agent/app/src/backend.rs | 54 +- apps/maple-agent/app/src/backend/local.rs | 53 +- apps/maple-agent/app/src/desktop.rs | 336 ++++- apps/maple-agent/app/src/host.rs | 226 ++- 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 | 60 +- .../app/src/host/recovery_tests.rs | 32 + apps/maple-agent/app/src/main.rs | 65 +- apps/maple-agent/app/src/remote/control.rs | 349 +++++ apps/maple-agent/app/src/remote/manager.rs | 888 ++++++++++++ apps/maple-agent/app/src/remote/mod.rs | 8 + apps/maple-agent/app/src/remote/network.rs | 148 ++ apps/maple-agent/app/src/remote/pairing.rs | 259 ++++ apps/maple-agent/app/src/remote/store.rs | 339 +++++ apps/maple-agent/app/src/remote/wire.rs | 250 ++++ apps/maple-agent/app/src/tray.rs | 52 + apps/maple-agent/app/src/ui/chat/mod.rs | 43 +- apps/maple-agent/app/src/ui/mod.rs | 2 + apps/maple-agent/app/src/ui/remote.rs | 206 +++ apps/maple-agent/app/src/ui/settings.rs | 19 +- .../app/src/ui/settings/navigation.rs | 5 + .../crates/maple-agent/src/agent/types.rs | 2 + .../crates/maple-agent/src/maple_api.rs | 42 + apps/maple-agent/docs/remote-access.md | 85 ++ apps/maple-agent/docs/remote-security.md | 87 ++ apps/maple-agent/docs/remote-validation.md | 81 ++ 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 | 90 ++ .../down.sql | 1 + .../up.sql | 14 + .../opensecret/src/aead_db_tamper_tests.rs | 169 +++ services/opensecret/src/db.rs | 54 + services/opensecret/src/main.rs | 14 + services/opensecret/src/models/mod.rs | 1 + .../src/models/remote_enrollment.rs | 102 ++ services/opensecret/src/models/schema.rs | 14 + services/opensecret/src/remote_enrollment.rs | 92 ++ .../opensecret/src/security_invariants.rs | 1 + services/opensecret/src/web/mod.rs | 1 + services/opensecret/src/web/remote/mod.rs | 403 ++++++ services/opensecret/src/web/remote/relay.rs | 186 +++ services/opensecret/src/web/remote/types.rs | 120 ++ 53 files changed, 6285 insertions(+), 123 deletions(-) create mode 100644 apps/maple-agent/app/src/remote/control.rs create mode 100644 apps/maple-agent/app/src/remote/manager.rs create mode 100644 apps/maple-agent/app/src/remote/mod.rs create mode 100644 apps/maple-agent/app/src/remote/network.rs create mode 100644 apps/maple-agent/app/src/remote/pairing.rs create mode 100644 apps/maple-agent/app/src/remote/store.rs create mode 100644 apps/maple-agent/app/src/remote/wire.rs create mode 100644 apps/maple-agent/app/src/tray.rs create mode 100644 apps/maple-agent/app/src/ui/remote.rs create mode 100644 apps/maple-agent/docs/remote-access.md create mode 100644 apps/maple-agent/docs/remote-security.md create mode 100644 apps/maple-agent/docs/remote-validation.md create mode 100644 sdk/rust/src/remote.rs create mode 100644 sdk/rust/src/remote_registration_v1.json create mode 100644 services/opensecret/docs/remote-enrollment.md create mode 100644 services/opensecret/migrations/2026-10-19-004032-0000_remote_enrollment/down.sql create mode 100644 services/opensecret/migrations/2026-10-19-004032-0000_remote_enrollment/up.sql create mode 100644 services/opensecret/src/models/remote_enrollment.rs create mode 100644 services/opensecret/src/remote_enrollment.rs create mode 100644 services/opensecret/src/web/remote/mod.rs create mode 100644 services/opensecret/src/web/remote/relay.rs create mode 100644 services/opensecret/src/web/remote/types.rs diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index d139f141f..7e6ec8e68 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.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -350,7 +350,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -404,6 +404,15 @@ 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" @@ -750,6 +759,17 @@ 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" @@ -878,7 +898,7 @@ dependencies = [ "log", "num-rational", "num-traits", - "pastey", + "pastey 0.1.1", "rayon", "thiserror 2.0.20", "v_frame", @@ -995,6 +1015,17 @@ 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" @@ -1016,6 +1047,12 @@ 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" @@ -1132,6 +1169,19 @@ 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" @@ -1843,6 +1893,12 @@ 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" @@ -1861,6 +1917,16 @@ 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" @@ -2322,8 +2388,26 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", - "fiat-crypto", + "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", "rustc_version", + "serde", "subtle", "zeroize", ] @@ -2385,6 +2469,26 @@ 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" @@ -2429,7 +2533,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", - "pem-rfc7468", + "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", "zeroize", ] @@ -2489,6 +2604,12 @@ 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" @@ -2552,7 +2673,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2593,6 +2714,17 @@ 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" @@ -2624,6 +2756,12 @@ 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" @@ -2654,12 +2792,39 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest 0.10.7", "elliptic-curve", "rfc6979", - "signature", - "spki", + "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", ] [[package]] @@ -2674,15 +2839,15 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", + "base16ct 0.2.0", "crypto-bigint", "digest 0.10.7", "ff", "generic-array", "group", "hkdf 0.12.4", - "pem-rfc7468", - "pkcs8", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", "rand_core 0.6.4", "sec1", "subtle", @@ -2739,6 +2904,17 @@ 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" @@ -2838,7 +3014,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3007,6 +3183,12 @@ 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" @@ -3286,6 +3468,19 @@ 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" @@ -3385,6 +3580,21 @@ 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" @@ -3503,6 +3713,18 @@ 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" @@ -3878,7 +4100,7 @@ dependencies = [ "windows 0.61.3", "windows-core 0.61.2", "windows-numerics 0.2.0", - "windows-registry", + "windows-registry 0.5.3", ] [[package]] @@ -4105,6 +4327,12 @@ 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" @@ -4244,11 +4472,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", - "system-configuration", + "system-configuration 0.7.0", "tokio", "tower-service", "tracing", - "windows-registry", + "windows-registry 0.6.1", ] [[package]] @@ -4263,7 +4491,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.62.2", ] [[package]] @@ -4362,6 +4590,12 @@ 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" @@ -4539,6 +4773,19 @@ 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" @@ -4546,35 +4793,197 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] -name = "is-docker" -version = "0.2.0" +name = "iroh" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +checksum = "885787b892b5e2507c701f132ecbd45d2bad4bbb75157a19427087c16dadb833" dependencies = [ - "once_cell", + "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", ] [[package]] -name = "is-wsl" -version = "0.4.0" +name = "iroh-base" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +checksum = "6ae9092be76c9f5429776ab3394f5a5a78dc19dc2524267e884a5b9781546c3f" dependencies = [ - "is-docker", - "once_cell", + "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", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "iroh-dns" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +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 = "itertools" -version = "0.13.0" +name = "iroh-metrics" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +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" dependencies = [ "either", ] @@ -4831,6 +5240,16 @@ 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" @@ -4889,6 +5308,24 @@ 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" @@ -5047,6 +5484,19 @@ 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" @@ -5061,6 +5511,9 @@ 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" @@ -5120,6 +5573,12 @@ 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" @@ -5211,6 +5670,7 @@ dependencies = [ "gpui_platform", "httparse", "image", + "iroh", "libc", "log", "maple-agent", @@ -5218,6 +5678,7 @@ dependencies = [ "maple-proxy", "maple-sdk", "notify", + "pake-cpace", "parking_lot", "percent-encoding", "pulldown-cmark", @@ -5235,11 +5696,14 @@ dependencies = [ "tokio", "tokio-util", "tower-http", + "tray-icon", "unicode-segmentation", + "url", "uuid", "wayland-client", "webbrowser", "windows-sys 0.61.2", + "zeroize", ] [[package]] @@ -5477,6 +5941,102 @@ 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" @@ -5549,6 +6109,174 @@ 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" @@ -5620,6 +6348,68 @@ 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" @@ -5676,7 +6466,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6073,6 +6863,20 @@ 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" @@ -6145,24 +6949,59 @@ 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", + "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", ] [[package]] -name = "objc2-quartz-core" +name = "objc2-system-configuration" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" dependencies = [ "bitflags 2.13.1", + "dispatch2", + "libc", "objc2 0.6.4", "objc2-core-foundation", - "objc2-foundation 0.3.2", - "objc2-metal 0.3.2", + "objc2-security", ] [[package]] @@ -6382,7 +7221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -6403,6 +7242,27 @@ 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" @@ -6444,6 +7304,12 @@ 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" @@ -6482,6 +7348,15 @@ 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" @@ -6509,6 +7384,16 @@ 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" @@ -6684,8 +7569,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "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", ] [[package]] @@ -6817,6 +7712,19 @@ 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" @@ -6916,6 +7824,9 @@ 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" @@ -6953,9 +7864,21 @@ 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" @@ -7186,6 +8109,15 @@ 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" @@ -7240,7 +8172,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7958,7 +8890,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7968,6 +8900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -8016,7 +8949,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8227,10 +9160,10 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", - "der", + "base16ct 0.2.0", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "subtle", "zeroize", ] @@ -8277,6 +9210,12 @@ 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" @@ -8293,6 +9232,12 @@ 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" @@ -8478,6 +9423,16 @@ 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" @@ -8564,6 +9519,15 @@ 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" @@ -8601,6 +9565,15 @@ 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" @@ -8697,17 +9670,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.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.1.5", - "hashbrown 0.16.1", + "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", ] [[package]] @@ -8744,7 +9734,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "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", ] [[package]] @@ -8776,7 +9776,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9123,6 +10123,17 @@ 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" @@ -9163,6 +10174,15 @@ 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" @@ -9181,10 +10201,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -9275,6 +10295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -9416,6 +10437,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -9442,11 +10464,35 @@ 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" @@ -9648,6 +10694,26 @@ 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" @@ -9713,7 +10779,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset 0.9.1", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9971,6 +11037,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -10366,7 +11433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.41.0", "quote", ] @@ -10641,6 +11708,12 @@ 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" @@ -10663,7 +11736,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -10977,6 +12050,17 @@ 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" @@ -11450,12 +12534,46 @@ 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" @@ -11511,7 +12629,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "rand_core 0.6.4", "serde", "zeroize", @@ -11542,7 +12660,7 @@ checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" dependencies = [ "bitflags 2.13.1", "libc", - "quick-xml", + "quick-xml 0.41.0", "x11", ] diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index b7e9bda5d..a6488b40d 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -590,3 +590,10 @@ 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 80c304c6a..9cdab3894 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -25,6 +25,7 @@ desktop = [ "dep:notify", "dep:spellbook", "dep:wayland-client", + "dep:tray-icon", ] # `maple-agent acp`: the Agent Client Protocol server on stdio. acp = ["maple-agent/acp"] @@ -72,6 +73,10 @@ rpassword = "7" 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 } @@ -80,6 +85,9 @@ tower-http = { version = "0.6", features = ["cors"], optional = true } [target.'cfg(target_os = "linux")'.dependencies] wayland-client = { version = "0.31", 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 00642e02b..ddf70bce7 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -19,6 +19,8 @@ pub struct AgentBackend { owned_runtime: Option, client: BackendClient, api_url: String, + controller: Option>, + remote_host: Option, } impl Drop for AgentBackend { @@ -68,7 +70,7 @@ macro_rules! operations { self.call(stringify!($name), ($($arg,)* )).await })* } - async fn dispatch_inner(backend: &LocalBackend, request: Request) -> Result { + pub(crate) 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) @@ -173,6 +175,8 @@ impl AgentBackend { owned_runtime: Some(runtime), client: BackendClient::InProcess(Box::new(backend)), api_url, + controller: None, + remote_host: None, }) } @@ -185,14 +189,59 @@ 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)] @@ -300,6 +349,9 @@ 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() }) } diff --git a/apps/maple-agent/app/src/backend/local.rs b/apps/maple-agent/app/src/backend/local.rs index a833c586e..4017139b5 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(super) account_gate: tokio::sync::RwLock<()>, + pub(crate) account_gate: tokio::sync::RwLock<()>, restore_lock: tokio::sync::Mutex<()>, service: MapleAgentService, auth: MapleApiAuthState, @@ -978,6 +978,26 @@ 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> { @@ -2629,6 +2649,37 @@ 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, 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()); + } + #[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 59968d426..25b0caa0f 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -28,6 +28,7 @@ enum Screen { Login(Entity), Chat(Entity), Settings(Entity), + Devices(Entity), } struct MapleApp { @@ -37,6 +38,14 @@ 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>, + remote_chats: std::collections::HashMap>, + selected_host: Option, + remote_error: Option, + 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. @@ -79,11 +88,7 @@ impl MapleApp { events: Vec, cx: &mut Context, ) { - let chat = match &self.screen { - Screen::Chat(chat) => Some(chat.clone()), - _ => self.parked_chat.clone(), - }; - if let Some(chat) = chat { + if let Some(chat) = &self.local_chat { chat.update(cx, |chat, cx| chat.handle_service_events(events, cx)); } } @@ -116,7 +121,11 @@ 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_chats.clear(); + self.selected_host = None; if matches!(self.screen, Screen::Login(_)) { return; } @@ -154,10 +163,117 @@ 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_chats.clear(); + self.selected_host = None; self.subscribe_chat(&chat, cx); cx.notify(); } + fn show_devices(&mut self, cx: &mut Context) { + if let Screen::Chat(chat) = &self.screen { + self.parked_chat = Some(chat.clone()); + } + let backend = self.backend.clone(); + let screen = cx.new(|cx| ui::remote::RemoteScreen::new(backend, cx)); + cx.subscribe(&screen, |app, _, _: &ui::remote::CloseDevices, cx| { + if let Some(chat) = app.parked_chat.take().or_else(|| app.local_chat.clone()) { + app.screen = Screen::Chat(chat); + } + cx.notify(); + }) + .detach(); + cx.subscribe(&screen, |app, _, event: &ui::remote::RemoteEnabled, cx| { + app.set_remote_enabled(event.0, cx) + }) + .detach(); + cx.subscribe( + &screen, + |app, _, event: &ui::remote::ConnectComputer, cx| { + app.connect_computer(event.0.clone(), cx) + }, + ) + .detach(); + self.screen = Screen::Devices(screen); + cx.notify(); + } + + fn set_remote_enabled(&mut self, enabled: bool, cx: &mut Context) { + self.remote_enabled + .store(enabled, std::sync::atomic::Ordering::Release); + #[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) { + if let Some(chat) = self.remote_chats.get(&peer) + && !chat.read(cx).connection_ended() + { + self.screen = Screen::Chat(chat.clone()); + self.selected_host = Some(peer); + self.parked_chat = None; + cx.notify(); + return; + } + let Some(user_id) = self.user_id.clone() else { + return; + }; + let backend = self.backend.clone(); + let target = peer.clone(); + self.remote_error = None; + 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; + } + 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(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(); + app.remote_chats.insert(peer.clone(), chat.clone()); + app.selected_host = Some(peer); + app.screen = Screen::Chat(chat); + app.parked_chat = None; + } + Err(error) => app.remote_error = Some(error), + } + 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 { @@ -167,8 +283,17 @@ 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(); let screen = cx.new(|cx| { - SettingsScreen::new(backend, user_id, settings, shortcut_snapshot, section, cx) + SettingsScreen::new( + backend, + integrations_backend, + user_id, + settings, + shortcut_snapshot, + section, + cx, + ) }); cx.subscribe( &screen, @@ -192,7 +317,7 @@ impl MapleApp { cx.subscribe( &screen, |app: &mut MapleApp, _emitter, _event: &SignOutRequested, cx| { - if let Some(chat) = app.parked_chat.take() { + if let Some(chat) = app.local_chat.take() { chat.update(cx, |chat, cx| chat.sign_out(cx)); } app.show_login(cx); @@ -318,15 +443,77 @@ impl Render for MapleApp { // on transcript/composer via `typography::chat_reading`. .text_sm() .child(titlebar) + .when(self.user_id.is_some(), |view| { + view.child( + div() + .bg(gpui::rgb(ui::theme::bg_sidebar())) + .flex() + .gap_3() + .items_center() + .pl(ui::titlebar::top_row_inset(gpui::px(12.))) + .pr_3() + .py_2() + .child( + ui::widgets::secondary_button("local-computer") + .child("This computer") + .on_click(cx.listener(|app, _, _, cx| { + if let Some(chat) = &app.local_chat { + app.screen = Screen::Chat(chat.clone()); + app.selected_host = None; + app.parked_chat = None; + cx.notify(); + } + })), + ) + .child( + ui::widgets::secondary_button("remote-computers") + .child("Computers…") + .on_click(cx.listener(|app, _, _, cx| app.show_devices(cx))), + ) + .when_some(self.selected_host.as_ref(), |row, host| { + row.child(format!("Remote · {}", &host[..host.len().min(10)])) + }) + .when_some(self.remote_error.as_ref(), |row, error| { + row.child(error.clone()) + }), + ) + }) .child(match &self.screen { Screen::Restoring => restoring_view().into_any_element(), Screen::Login(login) => login.clone().into_any_element(), Screen::Chat(chat) => chat.clone().into_any_element(), Screen::Settings(screen) => screen.clone().into_any_element(), + Screen::Devices(screen) => screen.clone().into_any_element(), }) } } +fn install_close_behavior( + window: &mut Window, + cx: &mut App, + remote_enabled: Arc, + can_background: bool, +) { + window.on_window_should_close(cx, move |_, cx| { + persist_window_state(); + if remote_enabled.load(std::sync::atomic::Ordering::Acquire) { + #[cfg(target_os = "macos")] + { + let _ = can_background; + cx.hide(); + false + } + #[cfg(not(target_os = "macos"))] + { + can_background + } + } else { + cx.quit(); + true + } + }); +} + /// 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 { @@ -504,6 +691,7 @@ 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 @@ -537,14 +725,34 @@ 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 (Some(session_id), Ok(app)) = - (session_id.as_deref(), root.downcast::()) - { - app.update(cx, |app, cx| app.open_task(session_id, cx)); + if let Ok(app) = root.downcast::() { + app.update(cx, |app, cx| { + if let Some((host, task)) = &remote_task { + if let Some(chat) = app.remote_chats.get(host).cloned() { + app.selected_host = Some(host.clone()); + app.screen = Screen::Chat(chat.clone()); + chat.update(cx, |chat, cx| { + chat.select_session(task, cx) + }); + } + } else if let Some(task) = session_id.as_deref() { + if let Some(chat) = app.local_chat.clone() { + app.screen = Screen::Chat(chat); + app.selected_host = None; + } + app.open_task(task, cx); + } + cx.notify(); + }); } }) .ok(); @@ -563,15 +771,36 @@ pub fn run() { } else { WindowBounds::Windowed(bounds) }; + let remote_enabled = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let quit_remote_enabled = remote_enabled.clone(); let quit_backend = backend.clone(); cx.on_app_quit(move |_cx| { let backend = quit_backend.clone(); + let remote_enabled = quit_remote_enabled.clone(); async move { persist_window_state(); - let _ = backend.detach().await; + if remote_enabled.load(std::sync::atomic::Ordering::Acquire) { + let api = backend.api_url().to_owned(); + let _ = backend + .spawn(async move { + tokio::task::spawn_blocking(move || crate::host::stop(&api)).await + }) + .await; + } else { + 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 @@ -581,6 +810,14 @@ pub fn run() { screen: Screen::Restoring, user_id: None, parked_chat: None, + local_chat: None, + remote_chats: std::collections::HashMap::new(), + selected_host: None, + remote_error: None, + 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)), @@ -643,14 +880,17 @@ pub fn run() { .detach(); } let root = window - .update(cx, |_, window, cx| { + .update(cx, |app, window, cx| { // The only window: closing it from the window manager // ends the app the same way the title bar button does. - window.on_window_should_close(cx, |_window, cx| { - persist_window_state(); - cx.quit(); + #[cfg(target_os = "macos")] + let can_background = { + let _ = app; 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()) @@ -659,6 +899,68 @@ 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 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| app.set_remote_enabled(status.enabled, 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(|cx| cx.quit()); + break; + } + 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()); diff --git a/apps/maple-agent/app/src/host.rs b/apps/maple-agent/app/src/host.rs index e9ce67e47..02e2dc47a 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. -mod ipc; +pub(crate) mod ipc; pub mod protocol; pub(crate) mod receipts; #[cfg(test)] @@ -74,6 +74,13 @@ 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())), @@ -190,11 +197,27 @@ 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. - if let Ok((next, welcome)) = connect(&endpoint, ConnectionMode::Client, false).await + 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:")) { + 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)) @@ -209,11 +232,19 @@ async fn maintain_connection( .map_err(|_| ())?; return Ok::<_, ()>(next); } - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(delay).await; + delay = (delay * 2).min(Duration::from_secs(5)); } }; - match tokio::time::timeout(RECONNECT_TIMEOUT, recovery).await { - Ok(Ok(next)) => connection = next, + 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, _ => { state.disconnect(); let _ = events.send(AgentServiceEvent::HostDisconnected).await; @@ -275,6 +306,10 @@ 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); @@ -292,10 +327,20 @@ 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, @@ -304,6 +349,7 @@ async fn handshake( profile: profile.into(), build: protocol::BUILD.into(), mode, + remote_host, }, ) .await?; @@ -326,9 +372,17 @@ async fn connect( start: bool, ) -> Result<(ipc::Connection, Welcome), String> { if let Ok(stream) = endpoint.connect().await { - return tokio::time::timeout(START_TIMEOUT, handshake(stream, &endpoint.profile, mode)) - .await - .map_err(|_| "Maple host did not answer".to_string())?; + 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())?; } if !start { return Err("No Maple host is running for this profile".into()); @@ -345,18 +399,33 @@ async fn connect( } } if let Ok(stream) = endpoint.connect().await { - return tokio::time::timeout_at(deadline, handshake(stream, &endpoint.profile, mode)) - .await - .map_err(|_| "Maple host did not answer".to_string())?; + 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())?; } 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(stream, &endpoint.profile, mode)) - .await - .map_err(|_| "Maple host did not answer".to_string())?; + 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())?; drop(startup); return result; } @@ -461,9 +530,19 @@ 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)); } }); @@ -476,7 +555,10 @@ async fn serve_with_receipts( tokio::select! { _ = stop_rx.recv() => break, _ = &mut shutdown => break, - _ = tokio::time::sleep_until(idle), if clients.is_empty() => 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; } + }, 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; @@ -489,8 +571,9 @@ 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).await + serve_client(connection, endpoint, backend, events, stop, receipts, remote).await }); } } @@ -498,6 +581,7 @@ 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() {} @@ -512,10 +596,19 @@ async fn serve_client( events: broadcast::Sender>, stop: mpsc::Sender<()>, receipts: Arc, + remote: Arc, ) -> Result { let mut subscription = events.subscribe(); - let mode = - accept_handshake(&mut connection, &endpoint.profile, backend.saved_user_id()).await?; + let (mode, link) = accept_scoped_handshake( + &mut connection, + &endpoint.profile, + backend.saved_user_id(), + Some(&remote), + ) + .await?; + if let Some(link) = link { + return crate::remote::control::bridge(connection, link).await; + } match mode { ConnectionMode::Stop => { let _ = stop.send(()).await; @@ -556,12 +649,22 @@ 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 = dispatch_with_receipt(&backend, request, receipts).await; + let result = if request.method.starts_with("remote_") { + remote.dispatch(request).await + } else { dispatch_with_receipt(&backend, request, receipts).await }; let after = backend.saved_user_id(); - if before != after { let _ = events.send(Arc::new(AgentServiceEvent::AccountChanged(after))); } + 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))); + } let _ = responses.send(Message::Response { id, result }).await; }); } @@ -587,11 +690,23 @@ 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) + .await + .map(|(mode, _)| mode) +} + +async fn accept_scoped_handshake( + connection: &mut ipc::Connection, + profile: &str, + user_id: Option, + remote: Option<&Arc>, +) -> Result<(ConnectionMode, Option), String> { let hello = tokio::time::timeout(START_TIMEOUT, protocol::read::(connection)) .await .map_err(|_| "Host handshake timed out".to_string())??; @@ -606,6 +721,10 @@ async fn accept_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.", @@ -617,6 +736,20 @@ async fn accept_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, + }; protocol::write( connection, &Ok::<_, String>(Welcome { @@ -627,7 +760,7 @@ async fn accept_handshake( }), ) .await?; - Ok(hello.mode) + Ok((hello.mode, link)) } pub fn stop(api_url: &str) -> Result<(), String> { @@ -694,10 +827,45 @@ 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 = @@ -711,7 +879,11 @@ async fn dispatch_with_receipt( .map_err(|e| e.to_string())?; } if request.method != "send_message" { - return backend::dispatch(backend, request).await; + return if remote_guarded { + backend::dispatch_inner(backend, request).await + } else { + backend::dispatch(backend, request).await + }; } let id = request .send_id @@ -732,7 +904,11 @@ async fn dispatch_with_receipt( } None => {} } - let result = backend::dispatch(backend, request).await; + let result = if remote_guarded { + backend::dispatch_inner(backend, request).await + } else { + backend::dispatch(backend, request).await + }; let saved = result.clone(); tokio::task::spawn_blocking(move || receipts.finish(&id, &saved)) .await @@ -770,6 +946,7 @@ async fn recover_send(endpoint: &ipc::Endpoint, id: &str) -> Result {} + Message::Closed { reason } => return Err(reason), } } tokio::time::sleep(Duration::from_millis(100)).await; @@ -806,6 +983,7 @@ 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 a896c717b..10edb35dd 100644 --- a/apps/maple-agent/app/src/host/ipc.rs +++ b/apps/maple-agent/app/src/host/ipc.rs @@ -14,6 +14,7 @@ pub type Connection = Box; #[derive(Clone)] pub struct Endpoint { pub profile: String, + pub remote_host: Option, directory: PathBuf, address: PathBuf, } @@ -27,6 +28,7 @@ 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, } @@ -81,6 +83,7 @@ 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 fffa5101c..e88eb926d 100644 --- a/apps/maple-agent/app/src/host/protocol.rs +++ b/apps/maple-agent/app/src/host/protocol.rs @@ -15,6 +15,8 @@ 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)] @@ -43,6 +45,9 @@ 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 a08855458..f7b7aaf1b 100644 --- a/apps/maple-agent/app/src/host/receipts.rs +++ b/apps/maple-agent/app/src/host/receipts.rs @@ -26,10 +26,24 @@ 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 + task TEXT NOT NULL, result TEXT, account TEXT );", ) .map_err(|e| e.to_string())?; + let has_account = 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())? + .iter() + .any(|name| name == "account"); + if !has_account { + connection + .execute_batch("ALTER TABLE sends ADD COLUMN account TEXT") + .map_err(|e| e.to_string())?; + } Ok(Self(Mutex::new(connection))) } @@ -67,8 +81,13 @@ impl Receipts { } connection .execute( - "INSERT INTO sends(id,fingerprint,task) VALUES (?1,?2,?3)", - params![id, fingerprint, task], + "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) + ], ) .map_err(|e| e.to_string())?; Ok(None) @@ -91,6 +110,22 @@ impl Receipts { Ok(()) } + pub fn authorize(&self, id: &str, account: &str) -> Result<(), String> { + let owner: Option> = self + .0 + .lock() + .unwrap() + .query_row("SELECT account 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> = self .0 @@ -115,6 +150,25 @@ 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 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 0594f2dbb..08b8c3145 100644 --- a/apps/maple-agent/app/src/host/recovery_tests.rs +++ b/apps/maple-agent/app/src/host/recovery_tests.rs @@ -382,3 +382,35 @@ 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() + ); +} diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index b0db23e3c..94c1dd101 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -20,9 +20,12 @@ 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")] @@ -70,6 +73,11 @@ 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. @@ -92,6 +100,53 @@ 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)] @@ -187,6 +242,13 @@ 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 @@ -394,7 +456,8 @@ fn init_logging(output: LogOutput) { .open(log_dir.join("maple-agent.log")) .ok() }); - const DEFAULT_FILTER: &str = "info"; + const DEFAULT_FILTER: &str = + "info,iroh=warn,iroh_relay=warn,noq=warn,noq_proto=warn,netwatch=warn,tracing::span=warn"; 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 new file mode 100644 index 000000000..b2b2af2ac --- /dev/null +++ b/apps/maple-agent/app/src/remote/control.rs @@ -0,0 +1,349 @@ +//! 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}, + 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::{Semaphore, mpsc}; + +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 hello: wire::Hello = wire::read(&mut rx, wire::MAX_PREFACE).await.map_err(|e| e.to_string())?; + let outcome = async { + hello.check_version().map_err(|e| e.to_string())?; + node.ensure_valid().await?; + if hello.account != node.account || hello.project != node.project || hello.deployment != node.deployment || hello.channel != crate::profile::PROFILE { + return Err("Remote account or deployment mismatch".to_string()); + } + 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; + Ok(None) + } + wire::Mode::ConfirmGrant { window } => { + let answer = { + let store = node.store.lock().unwrap(); + if store.permits(peer) && store.peers().get(&peer.to_string()).is_some_and(|p| p.grant == window) { + Ok(window) + } else { Err("No current grant matches this pairing".to_string()) } + }; + 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("Remote control grant is missing or revoked".into()); } + let slot = node.controllers.clone().try_acquire_owned().map_err(|_| "Remote connection limit reached")?; + wire::write(&mut tx, &Ok::<(), String>(()), 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, + mut rx: RecvStream, +) { + let _questions = node.backend.question_responder(); + let mut events = node.events.subscribe(); + let mut revoked = node.revoked.subscribe(); + let (incoming, mut requests) = mpsc::channel(1); + let reader = tokio::spawn(async move { + loop { + let request = wire::read::(&mut rx, wire::MAX_FRAME).await; + let terminal = request.is_err(); + if incoming.send(request).await.is_err() || terminal { + break; + } + } + }); + let (responses, mut output) = mpsc::channel(16); + // Desktop bootstrap fans out independent history, model and integration reads. + // Bound that burst without rejecting ordinary startup; urgent task controls + // retain their own capacity. Remote work never borrows local IPC slots. + let permits = Arc::new(Semaphore::new(32)); + let control_permits = Arc::new(Semaphore::new(2)); + let mut calls = tokio::task::JoinSet::new(); + let mut tick = tokio::time::interval(Duration::from_secs(1)); + let result: Result<(), String> = async { + loop { + tokio::select! { + _ = node.stopped.cancelled() => break, + _ = connection.closed() => break, + _ = tick.tick() => { + node.ensure_valid().await?; + if !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() => {}, + request = requests.recv() => { + let Some(request) = request else { break; }; + let request = match request { + Ok(request) => request, + Err(wire::Error::Disconnected) => break, + Err(error) => { send(&mut tx, &Message::Closed { reason: error.to_string() }).await?; break; } + }; + node.ensure_valid().await?; + if !node.store.lock().unwrap().permits(peer) { break; } + let urgent = matches!(request.method, wire::Method::CancelRun | wire::Method::AnswerQuestion | wire::Method::CancelExternalAgent); + let slot = if urgent { control_permits.clone() } else { permits.clone() }.try_acquire_owned(); + let Ok(slot) = slot else { + send(&mut tx, &Message::Response { id: request.id, result: Err("Remote computer is busy; try again".into()) }).await?; + continue; + }; + let node = node.clone(); let responses = responses.clone(); + calls.spawn(async move { + let _slot = slot; + 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 _ = responses.send(Message::Response { id, result }).await; + } + }); + } + Some(response) = output.recv() => send(&mut tx, &response).await?, + event = events.recv() => { + let event = event.map_err(|_| "Remote event stream fell behind")?; + node.ensure_valid().await?; + if !node.store.lock().unwrap().permits(peer) { break; } + // Account transitions are terminal for this connection. + // Local lifecycle markers can never control another UI. + if matches!(&*event, AgentServiceEvent::AccountChanged(_)) { break; } + if remote_event(&event) { send(&mut tx, &Message::Event(Box::new((*event).clone()))).await?; } + } + } + } + Ok(()) + }.await; + let _ = tx.finish(); + let _ = tokio::time::timeout(Duration::from_secs(2), tx.stopped()).await; + if result.is_err() { + connection.close(1u32.into(), b"remote stream closed"); + } + reader.abort(); + // As in local IPC, an admitted mutation finishes even if the viewer leaves. + drop(output); + while calls.join_next().await.is_some() {} +} + +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 tx: SendStream, + 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())?; + let accepted: Result<(), String> = tokio::time::timeout( + Duration::from_secs(10), + wire::read(&mut rx, wire::MAX_PREFACE), + ) + .await + .map_err(|_| "Remote handshake timed out")? + .map_err(|e| e.to_string())?; + accepted.map_err(|error| format!("Remote access ended: {error}"))?; + node.ensure_valid().await?; + Ok(Link { + node, + peer, + connection, + tx, + 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 reader = tokio::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, + 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); + let local_reader = tokio::spawn(async move { + while let Ok(request) = protocol::read::(&mut local_rx).await { + if requests.send(request).await.is_err() { + break; + } + } + }); + 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); } + } + frame = frame_rx.recv() => { + let Some(frame) = frame else { return Ok(false); }; + link.node.ensure_valid().await?; + protocol::write(&mut local_tx, &frame).await?; + } + request = request_rx.recv() => { + let Some(request) = request else { return Ok(true); }; + if request.method == "detach" { + protocol::write(&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) => { protocol::write(&mut local_tx, &Message::Response { id: request.id, result: Err(error) }).await?; continue; } + }; + link.node.ensure_valid().await?; + let sent = wire::write(&mut link.tx, &wire::Command { id: request.id, method, send_id: request.send_id, params: request.params }, wire::MAX_FRAME).await; + match sent { + Ok(()) => {}, + Err(wire::Error::InvalidFrame) => protocol::write(&mut local_tx, &Message::Response { id: request.id, result: Err("Remote message is too large. Try fewer or smaller images.".into()) }).await?, + Err(error) => return Err(error.to_string()), + } + } + } + } + }.await; + reader.abort(); + local_reader.abort(); + link.connection.close(0u32.into(), b"viewer disconnected"); + result +} diff --git a/apps/maple-agent/app/src/remote/manager.rs b/apps/maple-agent/app/src/remote/manager.rs new file mode 100644 index 000000000..5132b375b --- /dev/null +++ b/apps/maple-agent/app/src/remote/manager.rs @@ -0,0 +1,888 @@ +//! 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, +} + +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, + 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(Serialize, Deserialize)] +pub struct Status { + pub enabled: bool, + pub endpoint: 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), + }) + } + pub fn stays_running(&self) -> bool { + self.resident.load(Ordering::Acquire) + } + + pub async fn shutdown(&self) { + self.generation.fetch_add(1, Ordering::SeqCst); + self.resident.store(false, Ordering::Release); + if let Some(node) = self.node.lock().await.take() { + 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 Some(account) = self.backend.saved_user_id() else { + return; + }; + // Reading existing local settings does not start an endpoint or validate + // credentials on the UI thread. Disabled profiles stay entirely offline. + 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, + ) && self.store(&account).is_ok_and(|store| store.enabled()) + { + self.resident.store(true, Ordering::Release); + if let Err(error) = self.active().await { + log::warn!("Remote access could not resume: {error}"); + } + } + } + + 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 = self.generation.load(Ordering::SeqCst); + let current = self.node.lock().await.clone(); + if let Some(node) = current { + if node.account == account && node.valid().await { + return Ok(node); + } + node.stopped.cancel(); + node.endpoint.close().await; + self.node.lock().await.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.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()); + } + // Re-read after network awaits: local disable/revoke may have changed + // settings while initialization was in progress. + 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), + 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 { + 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() { + 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_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 { + enabled: false, + endpoint: None, + peers: Default::default(), + }); + } + let store = self.store(&account)?; + return encode(Status { + enabled: store.enabled(), + endpoint: Some(store.key().public().to_string()), + peers: store.peers().clone(), + }); + } + if matches!( + request.method.as_str(), + "remote_revoke" | "remote_cancel_pairing" + ) || (request.method == "remote_enable" && request.params == serde_json::json!([false])) + { + let account = self + .backend + .saved_user_id() + .ok_or("No local account is selected")?; + let current = self + .node + .lock() + .await + .clone() + .filter(|node| node.account == account); + if let Some(node) = ¤t { + *node.window.lock().await = None; + } + if request.method == "remote_cancel_pairing" { + if let Some(node) = ¤t { + let _store = node.store.lock().unwrap(); + node.pairing_generation.fetch_add(1, Ordering::SeqCst); + } + return Ok(serde_json::Value::Null); + } + let mut disk; + let mut memory; + let store = if let Some(node) = ¤t { + memory = node.store.lock().unwrap(); + &mut *memory + } else { + disk = self.store(&account)?; + &mut disk + }; + if let Some(node) = ¤t { + node.pairing_generation.fetch_add(1, Ordering::SeqCst); + } + if request.method == "remote_revoke" { + 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) = ¤t { + let _ = node.revoked.send(peer); + } + } else { + store.set_enabled(false)?; + self.resident.store(false, Ordering::Release); + if let Some(node) = ¤t { + let _ = node.revoked.send(node.endpoint.id()); + } + } + return Ok(serde_json::Value::Null); + } + let node = self.active().await?; + match request.method.as_str() { + "remote_enable" => { + let (enabled,): (bool,) = + serde_json::from_value(request.params).map_err(|_| "Invalid remote setting")?; + node.store.lock().unwrap().set_enabled(enabled)?; + self.resident.store(enabled, Ordering::Release); + if !enabled { + *node.window.lock().await = None; + } + // Existing control observes this state immediately, including + // when discovery is unavailable. No cloud write is authority. + let _ = node.revoked.send(node.endpoint.id()); + node.publish().await?; + Ok(serde_json::Value::Null) + } + "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?; + 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> { + self.ensure_valid().await?; + let key = self.store.lock().unwrap().key(); + let enabled = self.store.lock().unwrap().enabled(); + 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(), + }, + enabled, + window, + ) + .await?; + self.ensure_valid().await + } + async fn begin_pairing(&self) -> Result { + if !self.store.lock().unwrap().enabled() { + return Err("Enable remote access on this computer first".into()); + } + let mut window = self.window.lock().await; + if window.as_ref().is_some_and(|w| w.is_open(&w.id)) { + return Err("Close the current pairing window before starting another".into()); + } + let next = pairing::Window::new(); + let key = self.store.lock().unwrap().key(); + 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(), + }, + true, + Some(RemoteWindowInit { + id: Uuid::from_bytes(next.id), + remaining_seconds: 300, + }), + ) + .await?; + self.ensure_valid().await?; + 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().unwrap().into(), + 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)?; + 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)) + } + 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?; + self.store + .lock() + .unwrap() + .reserve_attempt(peer, *window.as_bytes())?; + 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())?; + wire::write(&mut tx, &proof.message(), wire::MAX_PREFACE) + .await + .map_err(|e| e.to_string())?; + let (message, host_tag): (Vec, Vec) = wire::read(&mut rx, wire::MAX_PREFACE) + .await + .map_err(|e| e.to_string())?; + 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: [u8; 16] = wire::read(&mut rx, wire::MAX_PREFACE) + .await + .map_err(|e| e.to_string())?; + 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.is_some()) + { + 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], String> = wire::read(&mut rx, wire::MAX_PREFACE) + .await + .map_err(|e| e.to_string())?; + if confirmed? != 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 = self + .session + .check_remote_admission(CheckRemoteAdmission { + handle: admission, + controller: peer.to_string(), + execution: self.endpoint.id().to_string(), + window: Uuid::from_bytes(window), + }) + .await?; + 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, + &(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, + }, + )?; + } + wire::write(tx, &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, + 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: "Maple computer".into(), + 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 new file mode 100644 index 000000000..96db0b259 --- /dev/null +++ b/apps/maple-agent/app/src/remote/mod.rs @@ -0,0 +1,8 @@ +//! Desktop remote access is owned by the profile's local daemon. +mod network; +mod pairing; +mod store; +mod wire; + +pub mod control; +pub mod manager; diff --git a/apps/maple-agent/app/src/remote/network.rs b/apps/maple-agent/app/src/remote/network.rs new file mode 100644 index 000000000..715f24041 --- /dev/null +++ b/apps/maple-agent/app/src/remote/network.rs @@ -0,0 +1,148 @@ +//! 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) + .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 new file mode 100644 index 000000000..2d34c238c --- /dev/null +++ b/apps/maple-agent/app/src/remote/pairing.rs @@ -0,0 +1,259 @@ +//! 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."; + +/// 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 mut bytes = Zeroizing::new([0; 8]); + if text.len() != bytes.len() || !text.bytes().all(|b| ALPHABET.contains(&b)) { + return Err( + "Enter the eight-character pairing code shown on the other computer.".into(), + ); + } + bytes.copy_from_slice(text.as_bytes()); + Ok(Self(bytes)) + } + + /// Only the locally initiated pairing UI may display this value. + pub fn display(&self) -> &str { + std::str::from_utf8(self.0.as_ref()).expect("pairing code is ASCII") + } +} + +/// 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<&str> { + 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(), &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 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(), 8); + 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 new file mode 100644 index 000000000..c6267cf16 --- /dev/null +++ b/apps/maple-agent/app/src/remote/store.rs @@ -0,0 +1,339 @@ +//! 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; + +#[derive(Clone, 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, + 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, + 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()); + } + 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 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 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/wire.rs b/apps/maple-agent/app/src/remote/wire.rs new file mode 100644 index 000000000..26e0668c0 --- /dev/null +++ b/apps/maple-agent/app/src/remote/wire.rs @@ -0,0 +1,250 @@ +//! 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}; + +pub const ALPN: &[u8] = b"maple-agent/remote/1"; +pub const VERSION: u32 = 1; +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 }, +} + +impl Hello { + pub fn check_version(&self) -> Result<(), Error> { + if self.minimum_version == 0 + || self.minimum_version > self.version + || self.minimum_version > VERSION + || self.version < VERSION + { + return Err(Error::Incompatible); + } + Ok(()) + } +} + +#[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 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, + 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::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 = serde_json::to_vec(value).map_err(|_| Error::InvalidFrame)?; + if bytes.len() > limit.min(MAX_FRAME) { + return Err(Error::InvalidFrame); + } + 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) +} + +#[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_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: 1, + minimum_version: 1, + account: "a".into(), + project: "p".into(), + deployment: "d".into(), + channel: "unpackaged".into(), + mode: Mode::Control, + }; + assert!(hello.check_version().is_ok()); + hello.version = 2; + assert!(hello.check_version().is_ok()); + hello.minimum_version = 2; + assert_eq!(hello.check_version(), Err(Error::Incompatible)); + hello.version = 1; + assert_eq!(hello.check_version(), Err(Error::Incompatible)); + } +} diff --git a/apps/maple-agent/app/src/tray.rs b/apps/maple-agent/app/src/tray.rs new file mode 100644 index 000000000..f0d696d1a --- /dev/null +++ b/apps/maple-agent/app/src/tray.rs @@ -0,0 +1,52 @@ +//! 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); + } +} diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index c9f4f7a01..adf48a135 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -658,6 +658,7 @@ enum LoadMode { struct HostRecovery { epoch: u64, unavailable: bool, + ended: bool, events: Option>, overflow: bool, } @@ -669,6 +670,14 @@ 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); @@ -1390,7 +1399,9 @@ impl ChatScreen { return; } let path = path.trim().to_string(); - if path.is_empty() || !std::path::Path::new(&path).is_absolute() { + if path.is_empty() + || (self.backend.remote_host().is_none() && !std::path::Path::new(&path).is_absolute()) + { self.notice = Some("Enter an absolute directory path".into()); cx.notify(); return; @@ -1511,6 +1522,10 @@ 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. @@ -1624,6 +1639,11 @@ 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; }; @@ -5010,7 +5030,19 @@ impl ChatScreen { 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()); + 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; @@ -5064,7 +5096,8 @@ impl ChatScreen { self.thinking = Some(state); } } - AgentServiceEvent::HostDisconnected + AgentServiceEvent::RemoteAccessEnded(_) + | AgentServiceEvent::HostDisconnected | AgentServiceEvent::HostReconnecting | AgentServiceEvent::HostReconnected => return false, AgentServiceEvent::RuntimeStatus(mut status) => { @@ -5809,7 +5842,11 @@ impl ChatScreen { return; } log::info!("desktop notification sent: {title}"); - crate::notify::notify(cx, format!("task:{tag}"), title, body, &[]); + 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, &[]); } /// Load the plan card from the Maple billing API. Failures keep the diff --git a/apps/maple-agent/app/src/ui/mod.rs b/apps/maple-agent/app/src/ui/mod.rs index 46ecb694c..d7030760b 100644 --- a/apps/maple-agent/app/src/ui/mod.rs +++ b/apps/maple-agent/app/src/ui/mod.rs @@ -20,3 +20,5 @@ 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 new file mode 100644 index 000000000..bab2855ab --- /dev/null +++ b/apps/maple-agent/app/src/ui/remote.rs @@ -0,0 +1,206 @@ +//! 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, Entity, EventEmitter, Render, Window, div, prelude::*}; +use maple_sdk::remote::RemoteDevice; +use std::{ + cell::RefCell, + sync::Arc, + time::{Duration, Instant}, +}; + +pub struct ConnectComputer(pub String); +pub struct CloseDevices; +pub struct RemoteEnabled(pub bool); + +pub struct RemoteScreen { + backend: Arc, + status: Option, + devices: Vec, + code_input: Entity, + local_code: Option<(String, Instant)>, + error: Option, + busy: bool, + bridges: RefCell>>, +} +impl EventEmitter for RemoteScreen {} +impl EventEmitter for RemoteScreen {} +impl EventEmitter for RemoteScreen {} + +impl RemoteScreen { + pub fn new(backend: Arc, cx: &mut Context) -> Self { + let mut screen = Self { + backend, + status: None, + devices: Vec::new(), + code_input: cx + .new(|cx| TextInput::new("Eight-character code from the other computer", cx)), + local_code: None, + error: None, + busy: false, + 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| { + 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); + } + cx.notify(); + }) + .is_err() + { + break; + } + } + }); + screen.bridges.borrow_mut().push(timer); + screen + } + fn refresh(&mut self, discover: bool, cx: &mut Context) { + if self.busy { + return; + } + self.busy = true; + let backend = self.backend.clone(); + let bridge = task::call( + &self.backend, + async move { + let status: Status = backend.remote_setting("remote_status", ()).await?; + let devices = if discover { + backend.remote_setting("remote_discover", ()).await? + } else { + Vec::new() + }; + Ok((status, devices)) + }, + cx, + move |screen, result, cx| { + screen.busy = false; + match result { + Ok((status, devices)) => { + cx.emit(RemoteEnabled(status.enabled)); + screen.status = Some(status); + if discover { + screen.devices = devices; + } + screen.error = None; + } + Err(error) => screen.error = Some(error), + } + cx.notify(); + }, + ); + task::retain(&self.bridges, bridge); + } + fn action(&mut self, method: &'static str, params: serde_json::Value, cx: &mut Context) { + if self.busy { + return; + } + self.busy = true; + self.error = 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 = false; + 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()) + } + } + } else if (method == "remote_cancel_pairing" || method == "remote_enable") + && let Some((mut code, _)) = screen.local_code.take() + { + zeroize::Zeroize::zeroize(&mut code); + } + 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) { + if let Some((code, _)) = &mut self.local_code { + zeroize::Zeroize::zeroize(code); + } + } +} +impl Render for RemoteScreen { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let enabled = self.status.as_ref().is_some_and(|s| s.enabled); + let peers = self + .status + .as_ref() + .map(|s| s.peers.clone()) + .unwrap_or_default(); + div().id("remote-devices").flex_1().min_h_0().overflow_y_scroll().bg(gpui::rgb(theme::bg_app())).p_6().flex().flex_col().gap_4() + .child(div().flex().items_center().justify_between().child(div().text_xl().child("Computers")) + .child(widgets::secondary_button("remote-back").child("Back to tasks").on_click(cx.listener(|_, _, _, cx| cx.emit(CloseDevices))))) + .child(div().child("Remote tasks run on the selected computer, with its tools and files. Account and desktop preferences stay here.")) + .child(div().flex().gap_3().items_center().child(if enabled { "This computer accepts paired controllers" } else { "Remote access to this computer is off" }) + .child(widgets::secondary_button("remote-enable").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, |view| view.child(div().flex().gap_3() + .child(widgets::primary_button("pairing-window").child("Pair another computer").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_2() + .child(div().text_xl().child(code.clone())) + .child(format!("One attempt · expires in {} seconds. Enter this code only on the computer you want to pair.", deadline.saturating_duration_since(Instant::now()).as_secs())) + .child(widgets::secondary_button("cancel-window").child("Close pairing window").on_click(cx.listener(|screen, _, _, cx| screen.action("remote_cancel_pairing", serde_json::json!([]), cx)))))) + .child(div().text_lg().child("Paired computers")) + .children(peers.into_iter().map(|(key, peer)| { + let connect = key.clone(); let revoke = key.clone(); let confirm = key.clone(); + div().flex().gap_3().items_center().child(format!("{} · {}", peer.name, &key[..key.len().min(10)])) + .when(peer.pending_confirmation.is_some(), |row| row.child(widgets::secondary_button(format!("confirm-{key}")).child("Confirm previous pairing").on_click(cx.listener(move |screen, _, _, cx| screen.action("remote_reconcile", serde_json::json!([confirm]), cx))))) + .when(peer.can_connect, |row| row.child(widgets::primary_button(format!("connect-{key}")).child("Open tasks").on_click(cx.listener(move |_, _, _, cx| cx.emit(ConnectComputer(connect.clone())))))) + .child(widgets::danger_button(format!("revoke-{key}")).child("Unpair").on_click(cx.listener(move |screen, _, _, cx| screen.action("remote_revoke", serde_json::json!([revoke]), cx)))) + })) + .child(div().flex().gap_3().items_center().child(div().text_lg().child("Find another computer")) + .child(widgets::secondary_button("discover").child("Refresh devices").on_click(cx.listener(|screen, _, _, cx| screen.refresh(true, cx))))) + .child(self.code_input.clone()) + .children(self.devices.iter().map(|device| { + let endpoint = device.endpoint.clone(); + div().flex().gap_3().items_center().child(format!("{} · {}", device.name, &endpoint[..endpoint.len().min(10)])) + .when(device.window.is_some(), |row| row.child(widgets::primary_button(format!("pair-{endpoint}")).child("Pair using code").on_click(cx.listener(move |screen, _, _, cx| { + let code = screen.code_input.read(cx).text().trim().to_ascii_uppercase(); + screen.code_input.update(cx, |input, cx| input.set_text("", cx)); + screen.action("remote_pair", serde_json::json!([endpoint, code]), cx); + })))) + })) + .when(self.busy, |view| view.child("Connecting…")) + .when_some(self.error.as_ref(), |view, error| view.child(div().child(error.clone()))) + } +} diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 386ba5a2d..7cccb3084 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -122,6 +122,7 @@ pub struct SettingsScreen { /// [`crate::ui::task::call`]. bridged_tasks: std::cell::RefCell>>, backend: Arc, + integrations_backend: Arc, user_id: String, settings: AppSettings, /// `settings.theme` parsed once; render only reads the label. @@ -235,6 +236,7 @@ impl EventEmitter for SettingsScreen {} impl SettingsScreen { pub fn new( backend: Arc, + integrations_backend: Arc, user_id: String, settings: AppSettings, shortcut_snapshot: ShortcutSnapshot, @@ -283,6 +285,7 @@ impl SettingsScreen { let this = Self { bridged_tasks: std::cell::RefCell::new(Vec::new()), backend, + integrations_backend, user_id, theme: theme::Preference::parse(&settings.theme), settings, @@ -354,7 +357,7 @@ impl SettingsScreen { } fn load_mcp_servers(&self, cx: &mut Context) { - let backend = self.backend.clone(); + let backend = self.integrations_backend.clone(); let user_id = self.user_id.clone(); self.call( async move { backend.list_mcp_servers(&user_id).await }, @@ -373,7 +376,7 @@ impl SettingsScreen { } fn load_integrations(&self, cx: &mut Context) { - let backend = self.backend.clone(); + let backend = self.integrations_backend.clone(); let user_id = self.user_id.clone(); self.call( async move { backend.list_integrations(&user_id).await }, @@ -429,7 +432,7 @@ impl SettingsScreen { self.integration_notice = None; cx.notify(); - let backend = self.backend.clone(); + let backend = self.integrations_backend.clone(); let user_id = self.user_id.clone(); let request_id = id.clone(); self.call( @@ -472,7 +475,7 @@ impl SettingsScreen { } let id = id.to_string(); - let permissions = match self.backend.begin_integration_setup(&id) { + let permissions = match self.integrations_backend.begin_integration_setup(&id) { Ok(permissions) => permissions, Err(message) => { self.integration_notice = Some(message); @@ -484,7 +487,7 @@ impl SettingsScreen { self.integration_saving.insert(id.clone()); cx.notify(); - let backend = self.backend.clone(); + let backend = self.integrations_backend.clone(); let user_id = self.user_id.clone(); let request_id = id.clone(); self.call( @@ -527,7 +530,7 @@ impl SettingsScreen { self.mcp_saving = true; self.mcp_notice = None; cx.notify(); - let backend = self.backend.clone(); + let backend = self.integrations_backend.clone(); let user_id = self.user_id.clone(); self.call( async move { backend.save_mcp_servers(&user_id, servers).await }, @@ -2136,6 +2139,10 @@ 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 25505dd7b..a862c5aab 100644 --- a/apps/maple-agent/app/src/ui/settings/navigation.rs +++ b/apps/maple-agent/app/src/ui/settings/navigation.rs @@ -664,6 +664,7 @@ mod tests { ); let settings = cx.new(|cx| { SettingsScreen::new( + backend.clone(), backend, "user".to_string(), crate::settings::AppSettings::default(), @@ -748,6 +749,7 @@ mod tests { ); let settings = cx.new(|cx| { SettingsScreen::new( + backend.clone(), backend, "user".to_string(), crate::settings::AppSettings { @@ -822,6 +824,7 @@ mod tests { ); let settings = cx.new(|cx| { SettingsScreen::new( + backend.clone(), backend, "user".to_string(), crate::settings::AppSettings { @@ -893,6 +896,7 @@ mod tests { ); let settings = cx.new(|cx| { SettingsScreen::new( + backend.clone(), backend, "user".to_string(), crate::settings::AppSettings { @@ -975,6 +979,7 @@ mod tests { ); cx.new(|cx| { SettingsScreen::new( + backend.clone(), backend, "user".to_string(), crate::settings::AppSettings { 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 6af4289c0..30a073ea0 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/types.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/types.rs @@ -666,6 +666,8 @@ 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 cd67d5920..2e588567b 100644 --- a/apps/maple-agent/crates/maple-agent/src/maple_api.rs +++ b/apps/maple-agent/crates/maple-agent/src/maple_api.rs @@ -405,6 +405,48 @@ 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_sdk_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_sdk_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_sdk_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_sdk_error) + } + /// The account profile behind this session. pub async fn account(&self) -> Result { let snapshot = self.client_snapshot().await?; diff --git a/apps/maple-agent/docs/remote-access.md b/apps/maple-agent/docs/remote-access.md new file mode 100644 index 000000000..48d51c9c6 --- /dev/null +++ b/apps/maple-agent/docs/remote-access.md @@ -0,0 +1,85 @@ +# 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 **Computers**, enable remote access on the computer that will run tasks, +and choose **Pair another computer**. On the controller, refresh devices and +enter the eight-character code displayed on the execution computer. 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. + +Choose **Open tasks** on a paired computer. The toolbar identifies the selected +host; **This computer** returns to local tasks. 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). 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. +The minimal preset disables public address lookup and router port mapping. +There is no second encryption tunnel or custom routing layer. The remote +protocol uses `maple-agent/remote/1`, a simple minimum-version contract independent +of application builds, bounded frames and an explicit task-operation allowlist. +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. Slow readers lose their stream and +recover from an authoritative snapshot. Accepted mutations finish on the host; +transport loss never causes an automatic prompt replay. A stable send identity +crosses both IPC and Iroh; only the execution daemon records its admission and +result, account-bound in its receipt database. + +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 new file mode 100644 index 000000000..e3f926bd7 --- /dev/null +++ b/apps/maple-agent/docs/remote-security.md @@ -0,0 +1,87 @@ +# 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. +- Code buffers and returned session keys are zeroized where owned by Maple. The + dependency's internal ephemeral scalar/intermediate key is not explicitly + erased on drop. 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 new file mode 100644 index 000000000..81749cd5e --- /dev/null +++ b/apps/maple-agent/docs/remote-validation.md @@ -0,0 +1,81 @@ +# 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, missing 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. Remote +capacity remains bounded and separate from local controls, with reserved slots +for cancellation and question answers. + +## Automated checks + +- Full Agent `just ci`: all four Clippy configurations, workspace build/tests and + headless tests. Runtime: 271 passed / 2 ignored; desktop app: 478 passed / + 1 ignored; headless app: 82 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 key-lease 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. + +## 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 native builds, status-tray interaction and named-pipe behavior must +be checked on those platforms. 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 e9de9a51f..ead7cadfe 100644 --- a/apps/maple-agent/flake.nix +++ b/apps/maple-agent/flake.nix @@ -99,6 +99,7 @@ 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 83ad04f21..153bcdfd4 100644 --- a/apps/maple-agent/scripts/macos-debug-plist.py +++ b/apps/maple-agent/scripts/macos-debug-plist.py @@ -11,6 +11,7 @@ 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 6fe25e7f3..292eaf740 100644 --- a/sdk/rust/src/client.rs +++ b/sdk/rust/src/client.rs @@ -1941,6 +1941,35 @@ 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 0448d10e8..9bc9b9c3e 100644 --- a/sdk/rust/src/lib.rs +++ b/sdk/rust/src/lib.rs @@ -5,6 +5,7 @@ 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 new file mode 100644 index 000000000..8f2ac0a6b --- /dev/null +++ b/sdk/rust/src/remote.rs @@ -0,0 +1,119 @@ +//! 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 new file mode 100644 index 000000000..989ed8171 --- /dev/null +++ b/sdk/rust/src/remote_registration_v1.json @@ -0,0 +1 @@ +{"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 f93b14d6a..1fe985e6b 100644 --- a/services/opensecret/Cargo.lock +++ b/services/opensecret/Cargo.lock @@ -1322,6 +1322,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.13", "curve25519-dalek-derive", + "digest 0.10.7", "fiat-crypto 0.2.9", "rustc_version", "subtle", @@ -1684,6 +1685,16 @@ 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" @@ -1693,6 +1704,20 @@ 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" @@ -1700,7 +1725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek 5.0.0", - "ed25519", + "ed25519 3.0.0", "sha2 0.11.0", "subtle", "zeroize", @@ -3008,7 +3033,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "getrandom 0.2.15", "http 1.5.0", @@ -3060,6 +3085,7 @@ dependencies = [ "diesel", "diesel-derive-enum", "dotenv", + "ed25519-dalek 2.2.0", "futures", "generic-array", "getrandom 0.2.15", @@ -4573,7 +4599,7 @@ dependencies = [ "digest 0.10.7", "dirs", "ecdsa", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "flate2", "futures-util", "getrandom 0.3.4", @@ -5463,6 +5489,7 @@ 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 f242fb8c5..a18396270 100644 --- a/services/opensecret/Cargo.toml +++ b/services/opensecret/Cargo.toml @@ -49,6 +49,7 @@ 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" @@ -69,7 +70,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"] } +zeroize = { version = "1.8", features = ["derive", "serde"] } 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 new file mode 100644 index 000000000..f50d6a281 --- /dev/null +++ b/services/opensecret/docs/remote-enrollment.md @@ -0,0 +1,90 @@ +# 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/service share `sdk/rust/src/remote_registration_v1.json`. + +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. Window tombstones are retained to avoid a repeated W +extending its expiry after 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, account/key substitution, valid foreign-row swaps and relay +lease rejection. 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 new file mode 100644 index 000000000..41506023c --- /dev/null +++ b/services/opensecret/migrations/2026-10-19-004032-0000_remote_enrollment/down.sql @@ -0,0 +1 @@ +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 new file mode 100644 index 000000000..ccd3f210c --- /dev/null +++ b/services/opensecret/migrations/2026-10-19-004032-0000_remote_enrollment/up.sql @@ -0,0 +1,14 @@ +-- 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 2c04b8935..c5f6cab03 100644 --- a/services/opensecret/src/aead_db_tamper_tests.rs +++ b/services/opensecret/src/aead_db_tamper_tests.rs @@ -2365,3 +2365,172 @@ 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() + ); +} diff --git a/services/opensecret/src/db.rs b/services/opensecret/src/db.rs index 7cdb727ac..851de7ee8 100644 --- a/services/opensecret/src/db.rs +++ b/services/opensecret/src/db.rs @@ -143,10 +143,32 @@ 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 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; @@ -753,6 +775,38 @@ 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 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 d1bdee18e..9dcc1d8d0 100644 --- a/services/opensecret/src/main.rs +++ b/services/opensecret/src/main.rs @@ -120,6 +120,7 @@ mod provider_client; mod provider_registry; mod provider_routing; mod proxy_config; +mod remote_enrollment; mod secret_cache_maintenance; #[cfg(test)] mod security_invariants; @@ -1033,6 +1034,7 @@ pub struct AppState { apple_jwt_verifier: Arc, response_executions: web::responses::ResponseExecutionRegistry, kagi_client: Option>, + remote_relay_token: Option>, } #[derive(Clone, Default)] @@ -1165,9 +1167,14 @@ 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 @@ -1492,6 +1499,7 @@ impl AppStateBuilder { apple_jwt_verifier, response_executions: web::responses::ResponseExecutionRegistry::default(), kagi_client, + remote_relay_token: self.remote_relay_token, }) } } @@ -3767,7 +3775,9 @@ 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()) @@ -4118,6 +4128,9 @@ 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) @@ -4139,6 +4152,7 @@ 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 0cea43feb..90612e6c8 100644 --- a/services/opensecret/src/models/mod.rs +++ b/services/opensecret/src/models/mod.rs @@ -14,6 +14,7 @@ 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 new file mode 100644 index 000000000..8ab351082 --- /dev/null +++ b/services/opensecret/src/models/remote_enrollment.rs @@ -0,0 +1,102 @@ +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) + } + + 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 7db204790..c87d91d6d 100644 --- a/services/opensecret/src/models/schema.rs +++ b/services/opensecret/src/models/schema.rs @@ -409,6 +409,18 @@ 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; @@ -623,6 +635,7 @@ 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)); @@ -667,6 +680,7 @@ 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 new file mode 100644 index 000000000..849a522e2 --- /dev/null +++ b/services/opensecret/src/remote_enrollment.rs @@ -0,0 +1,92 @@ +//! 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 7b645fe2a..d9ec5aaf1 100644 --- a/services/opensecret/src/security_invariants.rs +++ b/services/opensecret/src/security_invariants.rs @@ -114,6 +114,7 @@ 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", diff --git a/services/opensecret/src/web/mod.rs b/services/opensecret/src/web/mod.rs index 753a4500b..3e03f4985 100644 --- a/services/opensecret/src/web/mod.rs +++ b/services/opensecret/src/web/mod.rs @@ -10,6 +10,7 @@ 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 new file mode 100644 index 000000000..44c8e5bc7 --- /dev/null +++ b/services/opensecret/src/web/remote/mod.rs @@ -0,0 +1,403 @@ +//! 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; + +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) +} + +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(); + 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/relay.rs b/services/opensecret/src/web/remote/relay.rs new file mode 100644 index 000000000..5e887ef7f --- /dev/null +++ b/services/opensecret/src/web/remote/relay.rs @@ -0,0 +1,186 @@ +//! 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 new file mode 100644 index 000000000..206a30572 --- /dev/null +++ b/services/opensecret/src/web/remote/types.rs @@ -0,0 +1,120 @@ +//! 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!("../../../../../sdk/rust/src/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); + } +} From a0d4e0959eb52caceeec1c24b85b0bb0b2564972 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:52:13 +0000 Subject: [PATCH 02/10] Keep remote protocol fixture within the standalone service package --- apps/maple-agent/docs/remote-validation.md | 5 +++-- services/opensecret/src/web/remote/registration_v1.json | 1 + services/opensecret/src/web/remote/types.rs | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 services/opensecret/src/web/remote/registration_v1.json diff --git a/apps/maple-agent/docs/remote-validation.md b/apps/maple-agent/docs/remote-validation.md index 81749cd5e..b156b74fc 100644 --- a/apps/maple-agent/docs/remote-validation.md +++ b/apps/maple-agent/docs/remote-validation.md @@ -40,8 +40,9 @@ initial remote capacity limit, missing 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. Remote -capacity remains bounded and separate from local controls, with reserved slots +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. ## Automated checks diff --git a/services/opensecret/src/web/remote/registration_v1.json b/services/opensecret/src/web/remote/registration_v1.json new file mode 100644 index 000000000..989ed8171 --- /dev/null +++ b/services/opensecret/src/web/remote/registration_v1.json @@ -0,0 +1 @@ +{"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/types.rs b/services/opensecret/src/web/remote/types.rs index 206a30572..624b7b600 100644 --- a/services/opensecret/src/web/remote/types.rs +++ b/services/opensecret/src/web/remote/types.rs @@ -110,8 +110,8 @@ mod tests { use super::*; #[test] fn proof_schema_matches_shared_version_one_fixture() { - let fixture = - include_str!("../../../../../sdk/rust/src/remote_registration_v1.json").trim_end(); + // 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()); From 34f5f44403180c4f48e3abfbf7644517f01fec27 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:24:34 +0000 Subject: [PATCH 03/10] Group remote computers into project navigation --- apps/maple-agent/app/src/backend/local.rs | 15 +- apps/maple-agent/app/src/desktop.rs | 480 ++++++++++++++---- apps/maple-agent/app/src/remote/manager.rs | 2 +- apps/maple-agent/app/src/settings.rs | 15 + 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 | 94 +++- .../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 | 282 +++++++++- apps/maple-agent/app/src/ui/remote.rs | 168 ++++-- apps/maple-agent/app/src/ui/settings.rs | 50 +- .../app/src/ui/settings/navigation.rs | 2 +- apps/maple-agent/docs/remote-access.md | 23 +- apps/maple-agent/docs/remote-validation.md | 22 +- 16 files changed, 1854 insertions(+), 210 deletions(-) create mode 100644 apps/maple-agent/app/src/ui/chat/computers.rs create mode 100644 apps/maple-agent/app/src/ui/chat/sidebar_computers.rs diff --git a/apps/maple-agent/app/src/backend/local.rs b/apps/maple-agent/app/src/backend/local.rs index 4017139b5..5136edea3 100644 --- a/apps/maple-agent/app/src/backend/local.rs +++ b/apps/maple-agent/app/src/backend/local.rs @@ -1656,6 +1656,16 @@ 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); @@ -1677,7 +1687,7 @@ impl LocalBackend { && Some(&session.project_root) == project_root.as_ref() }) .map(|session| session.id.clone()); - let latest = match latest_id { + let latest = match latest_id.filter(|_| load_latest) { Some(id) => handle.load_session(id).await.ok(), None => None, }; @@ -1696,8 +1706,7 @@ impl LocalBackend { user_id: &str, session_id: Option, ) -> Result { - let mut bootstrap = self.local_bootstrap(user_id).await?; - bootstrap.latest = None; + let bootstrap = self.read_bootstrap(user_id, false).await?; 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)) { diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 25b0caa0f..322d8937a 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -13,6 +13,7 @@ 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::{ @@ -28,7 +29,46 @@ enum Screen { Login(Entity), Chat(Entity), Settings(Entity), - Devices(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 { @@ -39,9 +79,11 @@ struct MapleApp { /// it with its state intact. parked_chat: Option>, local_chat: Option>, - remote_chats: std::collections::HashMap>, selected_host: Option, - remote_error: 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, @@ -93,14 +135,6 @@ impl MapleApp { } } - /// 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. @@ -124,7 +158,8 @@ impl MapleApp { self.set_remote_enabled(false, cx); self.parked_chat = None; self.local_chat = None; - self.remote_chats.clear(); + self.remote_computers.clear(); + self.requested_navigation = None; self.selected_host = None; if matches!(self.screen, Screen::Login(_)) { return; @@ -164,43 +199,194 @@ impl MapleApp { self.parked_chat = None; self.screen = Screen::Chat(chat.clone()); self.local_chat = Some(chat.clone()); - self.remote_chats.clear(); + self.remote_computers.clear(); + self.requested_navigation = None; self.selected_host = None; self.subscribe_chat(&chat, cx); cx.notify(); } - fn show_devices(&mut self, cx: &mut Context) { - if let Screen::Chat(chat) = &self.screen { - self.parked_chat = Some(chat.clone()); + 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; + } + } + } } - let backend = self.backend.clone(); - let screen = cx.new(|cx| ui::remote::RemoteScreen::new(backend, cx)); - cx.subscribe(&screen, |app, _, _: &ui::remote::CloseDevices, cx| { - if let Some(chat) = app.parked_chat.take().or_else(|| app.local_chat.clone()) { - app.screen = Screen::Chat(chat); + 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(); - }) - .detach(); - cx.subscribe(&screen, |app, _, event: &ui::remote::RemoteEnabled, cx| { - app.set_remote_enabled(event.0, cx) - }) - .detach(); - cx.subscribe( - &screen, - |app, _, event: &ui::remote::ConnectComputer, cx| { - app.connect_computer(event.0.clone(), cx) - }, - ) - .detach(); - self.screen = Screen::Devices(screen); - 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) { - self.remote_enabled - .store(enabled, std::sync::atomic::Ordering::Release); + 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); @@ -209,21 +395,21 @@ impl MapleApp { } fn connect_computer(&mut self, peer: String, cx: &mut Context) { - if let Some(chat) = self.remote_chats.get(&peer) - && !chat.read(cx).connection_ended() - { - self.screen = Screen::Chat(chat.clone()); - self.selected_host = Some(peer); - self.parked_chat = None; - cx.notify(); - return; - } 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.remote_error = None; + self.sync_computers(cx); let bridge = ui::task::call( &self.backend, async move { backend.remote(target).await }, @@ -232,11 +418,16 @@ impl MapleApp { 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(backend, user_id, cx)); + 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| { @@ -261,13 +452,38 @@ impl MapleApp { } }) .detach(); - app.remote_chats.insert(peer.clone(), chat.clone()); - app.selected_host = Some(peer); - app.screen = Screen::Chat(chat); - app.parked_chat = None; + 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; + } } - Err(error) => app.remote_error = Some(error), } + app.sync_computers(cx); cx.notify(); }, ); @@ -284,6 +500,7 @@ impl MapleApp { 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, @@ -295,6 +512,38 @@ impl MapleApp { 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| { @@ -341,6 +590,14 @@ 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| { @@ -372,9 +629,15 @@ 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() { - let settings = self.settings.clone(); - chat.update(cx, |chat, cx| chat.apply_defaults(&settings, cx)); + chat.update(cx, |chat, cx| chat.set_visible(true, cx)); self.screen = Screen::Chat(chat); } cx.notify(); @@ -443,47 +706,11 @@ impl Render for MapleApp { // on transcript/composer via `typography::chat_reading`. .text_sm() .child(titlebar) - .when(self.user_id.is_some(), |view| { - view.child( - div() - .bg(gpui::rgb(ui::theme::bg_sidebar())) - .flex() - .gap_3() - .items_center() - .pl(ui::titlebar::top_row_inset(gpui::px(12.))) - .pr_3() - .py_2() - .child( - ui::widgets::secondary_button("local-computer") - .child("This computer") - .on_click(cx.listener(|app, _, _, cx| { - if let Some(chat) = &app.local_chat { - app.screen = Screen::Chat(chat.clone()); - app.selected_host = None; - app.parked_chat = None; - cx.notify(); - } - })), - ) - .child( - ui::widgets::secondary_button("remote-computers") - .child("Computers…") - .on_click(cx.listener(|app, _, _, cx| app.show_devices(cx))), - ) - .when_some(self.selected_host.as_ref(), |row, host| { - row.child(format!("Remote · {}", &host[..host.len().min(10)])) - }) - .when_some(self.remote_error.as_ref(), |row, error| { - row.child(error.clone()) - }), - ) - }) .child(match &self.screen { Screen::Restoring => restoring_view().into_any_element(), Screen::Login(login) => login.clone().into_any_element(), Screen::Chat(chat) => chat.clone().into_any_element(), Screen::Settings(screen) => screen.clone().into_any_element(), - Screen::Devices(screen) => screen.clone().into_any_element(), }) } } @@ -737,19 +964,21 @@ pub fn run() { if let Ok(app) = root.downcast::() { app.update(cx, |app, cx| { if let Some((host, task)) = &remote_task { - if let Some(chat) = app.remote_chats.get(host).cloned() { - app.selected_host = Some(host.clone()); - app.screen = Screen::Chat(chat.clone()); - chat.update(cx, |chat, cx| { - chat.select_session(task, cx) - }); - } - } else if let Some(task) = session_id.as_deref() { - if let Some(chat) = app.local_chat.clone() { - app.screen = Screen::Chat(chat); - app.selected_host = None; - } - app.open_task(task, cx); + 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(); }); @@ -811,9 +1040,11 @@ pub fn run() { user_id: None, parked_chat: None, local_chat: None, - remote_chats: std::collections::HashMap::new(), selected_host: None, - remote_error: 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, @@ -905,6 +1136,7 @@ pub fn run() { 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 @@ -912,8 +1144,11 @@ pub fn run() { .await }); if let Ok(Ok(status)) = task.await { - status_root - .update(cx, |app, cx| app.set_remote_enabled(status.enabled, cx)); + status_root.update(cx, |app, cx| { + if app.user_id == account && account.is_some() { + app.update_computers(status, cx); + } + }); } cx.background_executor() .timer(std::time::Duration::from_secs(5)) @@ -992,6 +1227,33 @@ 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 { diff --git a/apps/maple-agent/app/src/remote/manager.rs b/apps/maple-agent/app/src/remote/manager.rs index 5132b375b..b44c2b3c8 100644 --- a/apps/maple-agent/app/src/remote/manager.rs +++ b/apps/maple-agent/app/src/remote/manager.rs @@ -54,7 +54,7 @@ pub struct Node { pub(super) controllers: Arc, } -#[derive(Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct Status { pub enabled: bool, pub endpoint: Option, diff --git a/apps/maple-agent/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index 9f13f3045..f034dd72d 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -7,6 +7,16 @@ 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. @@ -37,6 +47,10 @@ 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")] @@ -199,6 +213,7 @@ 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/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index c9ccd71da..c6cf349de 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -82,7 +82,11 @@ impl ChatScreen { ChatPopup::Project, chip( "root-picker", - Some("folder-open"), + Some(if self.backend.remote_host().is_some() { + "globe" + } else { + "folder-open" + }), self.project_label.clone(), true, self.popup.is_open(&ChatPopup::Project), @@ -155,6 +159,9 @@ 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 new file mode 100644 index 000000000..4b179d28e --- /dev/null +++ b/apps/maple-agent/app/src/ui/chat/computers.rs @@ -0,0 +1,296 @@ +//! 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 adf48a135..f5580beaf 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -31,6 +31,8 @@ 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; @@ -345,6 +347,14 @@ pub struct ChatScreen { 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, @@ -718,6 +728,12 @@ 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); @@ -753,6 +769,7 @@ impl ChatScreen { sidebar.set_recent_roots(roots, cx); }); self.refresh_selected_title(); + cx.emit(NavigationChanged); } fn sync_sidebar_selection(&mut self, cx: &mut Context) { @@ -1052,6 +1069,12 @@ 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(), @@ -1255,6 +1278,7 @@ 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); }, @@ -1380,8 +1404,11 @@ impl ChatScreen { roots: Vec, cx: &mut Context, ) { - self.recent_roots = roots.into_iter().map(|root| root.path).collect(); - self.sync_sidebar(cx); + let roots = roots.into_iter().map(|root| root.path).collect::>(); + if self.recent_roots != roots { + self.recent_roots = roots; + self.sync_sidebar(cx); + } } /// Select the project context for new tasks without disturbing work that @@ -1566,7 +1593,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.project_label = SharedString::from(self.project_label()); + self.refresh_project_caption(); self.refresh_branch(cx); } @@ -2819,7 +2846,9 @@ 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. - self.completed_unread_sessions.remove(&session.id); + if self.visible { + self.completed_unread_sessions.remove(&session.id); + } self.selected_session = Some(session.id.clone()); self.draft = false; self.sync_sidebar(cx); @@ -3589,16 +3618,22 @@ 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, id)) = self.task_step_target(delta, cx) else { + let Some((row, request)) = self.task_step_target(delta, cx) else { return; }; self.sidebar.read(cx).reveal_row(row); - self.select_session(&id, cx); + 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); + } } /// The task `delta` rows away from the selected one, as the sidebar - /// row it sits on and its id. - fn task_step_target(&self, delta: isize, cx: &gpui::App) -> Option<(usize, String)> { + /// row it sits on and its computer-scoped destination. + fn task_step_target(&self, delta: isize, cx: &gpui::App) -> Option<(usize, Navigate)> { self.sidebar.read(cx).step_target(delta) } @@ -4941,16 +4976,20 @@ impl ChatScreen { this.handle_service_events(events, cx); this.host_recovery.unavailable = false; this.runtime_error = None; - this.refresh_models(cx); - this.refresh_roots(cx); - this.refresh_slash_commands(cx); - if !running { + 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 { // 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; @@ -4986,6 +5025,10 @@ 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 { @@ -5005,6 +5048,26 @@ 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; @@ -5024,6 +5087,7 @@ impl ChatScreen { self.begin_navigation(); self.runtime_error = Some("Connection lost. Reconnecting to the Maple host…".into()); + cx.emit(NavigationChanged); return true; } AgentServiceEvent::HostReconnected => { @@ -5036,6 +5100,7 @@ impl ChatScreen { self.host_recovery.ended = true; self.host_recovery.events = None; self.runtime_error = Some(reason.into()); + cx.emit(NavigationChanged); return true; } AgentServiceEvent::HostDisconnected => { @@ -5047,6 +5112,7 @@ impl ChatScreen { 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; } _ => {} @@ -5103,7 +5169,7 @@ impl ChatScreen { 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 { + if status.running && !self.runtime_running_seen && !self.navigation_only { self.runtime_running_seen = true; self.refresh_slash_commands(cx); } @@ -5333,7 +5399,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.is_selected(session_id) + && !(self.visible && self.is_selected(session_id)) { self.completed_unread_sessions .insert(session_id.to_string()); diff --git a/apps/maple-agent/app/src/ui/chat/navigation.rs b/apps/maple-agent/app/src/ui/chat/navigation.rs index 1f9fed14f..995d11c31 100644 --- a/apps/maple-agent/app/src/ui/chat/navigation.rs +++ b/apps/maple-agent/app/src/ui/chat/navigation.rs @@ -26,6 +26,7 @@ pub(super) enum ChatRegion { #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub(super) enum SidebarTarget { + Computer(super::computers::Navigate), NewTask, Projects, Task(String), @@ -535,6 +536,7 @@ 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 5c5b39ea9..70fec8223 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -10,6 +10,19 @@ //! 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; @@ -34,6 +47,8 @@ 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 @@ -50,7 +65,10 @@ 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. @@ -144,6 +162,9 @@ 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, @@ -289,6 +310,10 @@ 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, @@ -345,6 +370,26 @@ 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, @@ -352,6 +397,13 @@ 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| { @@ -369,6 +421,10 @@ 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, @@ -401,8 +457,11 @@ impl Sidebar { rename: None, rename_input: None, rename_focus_pending: false, - pinned_tasks: settings.pinned_tasks.clone(), - project_names: settings.project_names.clone(), + 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), vim_selected: None, vim_by_row: Vec::new(), vim_order: Vec::new(), @@ -515,7 +574,7 @@ impl Sidebar { .retain(|candidate| !ids.contains(candidate)); if self.pinned_tasks.len() != before { let pinned = self.pinned_tasks.clone(); - persist_settings(move |settings| settings.pinned_tasks = pinned); + self.persist_pins(pinned); } self.rebuild_sections(); cx.notify(); @@ -538,19 +597,38 @@ impl Sidebar { .get(root) .filter(|name| !name.trim().is_empty()) .cloned() - .unwrap_or_else(|| root_display_name(root)) + .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 } /// The task `delta` rows away from the selected one, as the sidebar - /// row it sits on and its id. Stepping walks the flat sidebar list + /// row it sits on and its computer-scoped destination. Stepping walks the flat sidebar list /// and stops at its ends. - pub(super) fn step_target(&self, delta: isize) -> Option<(usize, String)> { - let rows: Vec<(usize, usize)> = self + pub(super) fn step_target(&self, delta: isize) -> Option<(usize, Navigate)> { + let rows: Vec<(usize, Navigate)> = self .entries .iter() .enumerate() .filter_map(|(row, entry)| match entry { - SidebarEntry::Task(task) if !task.archived => Some((row, task.session)), + 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())) + } _ => None, }) .collect(); @@ -559,10 +637,9 @@ impl Sidebar { } let selected = self.selected.as_deref(); let current = selected.and_then(|id| { - rows.iter().position(|(_, session)| { - self.sessions - .get(*session) - .is_some_and(|summary| summary.id == id) + rows.iter().position(|(_, request)| { + request.computer.as_deref() == self.backend.remote_host() + && matches!(&request.destination, Destination::Task(task) if task == id) }) }); let target = match current { @@ -576,9 +653,7 @@ impl Sidebar { None if delta > 0 => 0, None => rows.len() - 1, }; - let (row, session) = rows[target]; - let id = self.sessions.get(session)?.id.clone(); - Some((row, id)) + Some(rows[target].clone()) } pub(super) fn reveal_row(&self, row: usize) { @@ -910,6 +985,9 @@ 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(); @@ -1050,7 +1128,23 @@ impl Sidebar { self.rebuild_sections(); cx.notify(); let pinned = self.pinned_tasks.clone(); - persist_settings(move |settings| settings.pinned_tasks = pinned); + 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; + } + }); } /// Whether a collapsible section shows its rows. @@ -1115,6 +1209,9 @@ 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) @@ -1243,7 +1340,19 @@ impl Sidebar { } self.rebuild_sections(); let names = self.project_names.clone(); - persist_settings(move |settings| settings.project_names = names); + 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); } } } @@ -1525,6 +1634,9 @@ 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, @@ -1683,6 +1795,11 @@ 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) @@ -1804,7 +1921,11 @@ impl Sidebar { } Some(SidebarEntry::ArchivedHeader) => { let expanded = self.archived_expanded; - let count = self.archived_rows.len(); + let count = if self.computers.len() > 1 { + self.computer_archived_count + } else { + self.archived_rows.len() + }; div() .id("archived-toggle") .role(gpui::Role::Button) @@ -1971,6 +2092,9 @@ 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.)) @@ -2191,18 +2315,20 @@ impl Sidebar { .flex() .flex_col() .child(div().line_clamp(1).text_ellipsis().child(row.title.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(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()), + ) + }), ) }) .when_some(activity, |row_element, activity| { @@ -2573,9 +2699,39 @@ 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::root_display_name; + 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"); + } + } #[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 new file mode 100644 index 000000000..a7a7c2c28 --- /dev/null +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -0,0 +1,384 @@ +//! 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 !matches_group && 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 5c2a65810..2738d114c 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -44,6 +44,270 @@ 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, @@ -4036,7 +4300,9 @@ mod state_tests { screen.update(cx, |this, cx| { let sink = events.clone(); cx.subscribe(&this.sidebar, move |_, _, event: &SidebarEvent, _| { - sink.borrow_mut().push(event.clone()); + if !matches!(event, SidebarEvent::PreferencesChanged) { + sink.borrow_mut().push(event.clone()); + } }) .detach(); this.sessions = vec![ @@ -5007,6 +5273,10 @@ 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| { @@ -5017,7 +5287,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, "s2".to_string()))); + assert_eq!(this.task_step_target(1, cx), Some((4, local("s2")))); assert_eq!( this.task_step_target(-1, cx), None, @@ -5025,13 +5295,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, "s3".to_string()))); - assert_eq!(this.task_step_target(-1, cx), Some((3, "s1".to_string()))); + assert_eq!(this.task_step_target(1, cx), Some((5, local("s3")))); + assert_eq!(this.task_step_target(-1, cx), Some((3, local("s1")))); // 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, "s1".to_string()))); - assert_eq!(this.task_step_target(-1, cx), Some((5, "s3".to_string()))); + assert_eq!(this.task_step_target(1, cx), Some((3, local("s1")))); + assert_eq!(this.task_step_target(-1, cx), Some((5, local("s3")))); }); } diff --git a/apps/maple-agent/app/src/ui/remote.rs b/apps/maple-agent/app/src/ui/remote.rs index bab2855ab..d4287daa3 100644 --- a/apps/maple-agent/app/src/ui/remote.rs +++ b/apps/maple-agent/app/src/ui/remote.rs @@ -14,31 +14,52 @@ use std::{ }; pub struct ConnectComputer(pub String); -pub struct CloseDevices; -pub struct RemoteEnabled(pub bool); +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: Vec, code_input: Entity, + names: std::collections::HashMap, + renaming: Option<(String, Entity)>, local_code: Option<(String, Instant)>, error: Option, busy: bool, bridges: RefCell>>, } impl EventEmitter for RemoteScreen {} -impl EventEmitter for RemoteScreen {} -impl EventEmitter for RemoteScreen {} +impl EventEmitter for RemoteScreen {} +impl EventEmitter for RemoteScreen {} impl RemoteScreen { - pub fn new(backend: Arc, cx: &mut Context) -> Self { + pub fn new( + backend: Arc, + names: std::collections::HashMap, + cx: &mut Context, + ) -> Self { let mut screen = Self { backend, status: None, devices: Vec::new(), code_input: cx .new(|cx| TextInput::new("Eight-character code from the other computer", cx)), + names, + renaming: None, local_code: None, error: None, busy: false, @@ -50,6 +71,7 @@ impl RemoteScreen { 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() @@ -58,7 +80,9 @@ impl RemoteScreen { { zeroize::Zeroize::zeroize(&mut code); } - cx.notify(); + if counting { + cx.notify(); + } }) .is_err() { @@ -69,6 +93,49 @@ impl RemoteScreen { screen.bridges.borrow_mut().push(timer); screen } + + 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 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 { return; @@ -91,7 +158,7 @@ impl RemoteScreen { screen.busy = false; match result { Ok((status, devices)) => { - cx.emit(RemoteEnabled(status.enabled)); + cx.emit(ComputersChanged(status.clone())); screen.status = Some(status); if discover { screen.devices = devices; @@ -163,42 +230,71 @@ impl Drop for RemoteScreen { impl Render for RemoteScreen { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let enabled = self.status.as_ref().is_some_and(|s| s.enabled); - let peers = self - .status - .as_ref() - .map(|s| s.peers.clone()) - .unwrap_or_default(); - div().id("remote-devices").flex_1().min_h_0().overflow_y_scroll().bg(gpui::rgb(theme::bg_app())).p_6().flex().flex_col().gap_4() - .child(div().flex().items_center().justify_between().child(div().text_xl().child("Computers")) - .child(widgets::secondary_button("remote-back").child("Back to tasks").on_click(cx.listener(|_, _, _, cx| cx.emit(CloseDevices))))) - .child(div().child("Remote tasks run on the selected computer, with its tools and files. Account and desktop preferences stay here.")) - .child(div().flex().gap_3().items_center().child(if enabled { "This computer accepts paired controllers" } else { "Remote access to this computer is off" }) - .child(widgets::secondary_button("remote-enable").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, |view| view.child(div().flex().gap_3() - .child(widgets::primary_button("pairing-window").child("Pair another computer").on_click(cx.listener(|screen, _, _, cx| screen.action("remote_begin_pairing", serde_json::json!([]), cx)))))) + 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 in another computer’s projects from this window. Tasks use that computer’s tools and files; your account and desktop preferences stay here.")) + .child(div().flex().gap_3().items_center().justify_between() + .child(div().flex_1().child(if enabled { "This computer accepts paired controllers" } else { "Remote access to this computer is off" })) + .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, |view| view.child(div().child( + widgets::primary_button("pairing-window").aria_label("Pair another computer").child("Pair another computer") + .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_2() .child(div().text_xl().child(code.clone())) .child(format!("One attempt · expires in {} seconds. Enter this code only on the computer you want to pair.", deadline.saturating_duration_since(Instant::now()).as_secs())) - .child(widgets::secondary_button("cancel-window").child("Close pairing window").on_click(cx.listener(|screen, _, _, cx| screen.action("remote_cancel_pairing", serde_json::json!([]), cx)))))) - .child(div().text_lg().child("Paired computers")) - .children(peers.into_iter().map(|(key, peer)| { - let connect = key.clone(); let revoke = key.clone(); let confirm = key.clone(); - div().flex().gap_3().items_center().child(format!("{} · {}", peer.name, &key[..key.len().min(10)])) - .when(peer.pending_confirmation.is_some(), |row| row.child(widgets::secondary_button(format!("confirm-{key}")).child("Confirm previous pairing").on_click(cx.listener(move |screen, _, _, cx| screen.action("remote_reconcile", serde_json::json!([confirm]), cx))))) - .when(peer.can_connect, |row| row.child(widgets::primary_button(format!("connect-{key}")).child("Open tasks").on_click(cx.listener(move |_, _, _, cx| cx.emit(ConnectComputer(connect.clone())))))) - .child(widgets::danger_button(format!("revoke-{key}")).child("Unpair").on_click(cx.listener(move |screen, _, _, cx| screen.action("remote_revoke", serde_json::json!([revoke]), cx)))) + .child(widgets::secondary_button("cancel-window").aria_label("Close pairing window").child("Close pairing window") + .on_click(cx.listener(|screen, _, _, cx| screen.action("remote_cancel_pairing", serde_json::json!([]), cx)))))) + .child(div().pt_4().text_lg().child("Paired computers")) + .when(self.status.as_ref().is_some_and(|s| s.peers.is_empty()), |view| view.child( + div().text_color(gpui::rgb(theme::text_muted())).child("No paired computers yet."))) + .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_xs().text_color(gpui::rgb(theme::text_muted())) + .child(if peer.can_connect { "Projects available in the sidebar" } else { "Can control this computer" }))) + .child(widgets::ghost_button(format!("rename-{key}")).aria_label("Rename").child("Rename") + .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(div().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))))) })) - .child(div().flex().gap_3().items_center().child(div().text_lg().child("Find another computer")) - .child(widgets::secondary_button("discover").child("Refresh devices").on_click(cx.listener(|screen, _, _, cx| screen.refresh(true, cx))))) + .child(div().pt_4().flex().gap_3().items_center().justify_between() + .child(div().text_lg().child("Find another computer")) + .child(widgets::secondary_button("discover").aria_label("Refresh devices").child("Refresh devices") + .on_click(cx.listener(|screen, _, _, cx| screen.refresh(true, cx))))) + .child(div().text_color(gpui::rgb(theme::text_secondary())) + .child("Open Remote computers in Settings on the other device and start pairing there. Then refresh and enter its code here.")) .child(self.code_input.clone()) .children(self.devices.iter().map(|device| { let endpoint = device.endpoint.clone(); - div().flex().gap_3().items_center().child(format!("{} · {}", device.name, &endpoint[..endpoint.len().min(10)])) - .when(device.window.is_some(), |row| row.child(widgets::primary_button(format!("pair-{endpoint}")).child("Pair using code").on_click(cx.listener(move |screen, _, _, cx| { - let code = screen.code_input.read(cx).text().trim().to_ascii_uppercase(); - screen.code_input.update(cx, |input, cx| input.set_text("", cx)); - screen.action("remote_pair", serde_json::json!([endpoint, code]), cx); - })))) + div().flex().gap_3().items_center().justify_between() + .child(computer_name(&endpoint, &device.name, self.names.get(&endpoint).map_or("", String::as_str))) + .when(device.window.is_some(), |row| row.child(widgets::primary_button(format!("pair-{endpoint}")).aria_label("Pair using code").child("Pair using code") + .on_click(cx.listener(move |screen, _, _, cx| { + let code = screen.code_input.read(cx).text().trim().to_ascii_uppercase(); + screen.code_input.update(cx, |input, cx| input.set_text("", cx)); + screen.action("remote_pair", serde_json::json!([endpoint, code]), cx); + })))) })) .when(self.busy, |view| view.child("Connecting…")) .when_some(self.error.as_ref(), |view, error| view.child(div().child(error.clone()))) diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 7cccb3084..1b67128ea 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -56,6 +56,7 @@ pub enum Section { Shortcuts, Prompt, Integrations, + Remote, Usage, About, } @@ -70,12 +71,13 @@ 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; 9] = [ + const ALL: [Self; 10] = [ Self::General, Self::Account, Self::Billing, @@ -83,6 +85,7 @@ impl Section { Self::Shortcuts, Self::Prompt, Self::Integrations, + Self::Remote, Self::Usage, Self::About, ]; @@ -123,6 +126,7 @@ pub struct SettingsScreen { 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. @@ -228,6 +232,9 @@ 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 {} @@ -243,6 +250,43 @@ impl SettingsScreen { 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(); @@ -286,6 +330,7 @@ impl SettingsScreen { bridged_tasks: std::cell::RefCell::new(Vec::new()), backend, integrations_backend, + remote_screen, user_id, theme: theme::Preference::parse(&settings.theme), settings, @@ -1657,6 +1702,9 @@ 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() diff --git a/apps/maple-agent/app/src/ui/settings/navigation.rs b/apps/maple-agent/app/src/ui/settings/navigation.rs index a862c5aab..451504db6 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::Usage | Section::About => Vec::new(), + Section::Remote | Section::Usage | Section::About => Vec::new(), } } diff --git a/apps/maple-agent/docs/remote-access.md b/apps/maple-agent/docs/remote-access.md index 48d51c9c6..af13dc9ee 100644 --- a/apps/maple-agent/docs/remote-access.md +++ b/apps/maple-agent/docs/remote-access.md @@ -9,16 +9,31 @@ continue to share those tasks. ACP is not exposed over Iroh. ## Using it -Open **Computers**, enable remote access on the computer that will run tasks, +Open **Settings → Remote computers**, enable remote access on the computer that will run tasks, and choose **Pair another computer**. On the controller, refresh devices and enter the eight-character code displayed on the execution computer. 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. -Choose **Open tasks** on a paired computer. The toolbar identifies the selected -host; **This computer** returns to local tasks. Choose an existing remote project -or enter its absolute path; there is no remote filesystem browser. Images picked +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. 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. In Remote computers settings, +**Rename** changes the computer's label on this desktop; it does not change its +identity or pairing grant. + +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. diff --git a/apps/maple-agent/docs/remote-validation.md b/apps/maple-agent/docs/remote-validation.md index b156b74fc..5f18d493a 100644 --- a/apps/maple-agent/docs/remote-validation.md +++ b/apps/maple-agent/docs/remote-validation.md @@ -36,7 +36,7 @@ the Local backend's configured provider. settings for a Dock/application relaunch. Native testing found and corrected a desktop-startup request burst hitting the -initial remote capacity limit, missing host-selector theme/traffic-light spacing, +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 @@ -45,10 +45,28 @@ 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. + ## Automated checks - Full Agent `just ci`: all four Clippy configurations, workspace build/tests and - headless tests. Runtime: 271 passed / 2 ignored; desktop app: 478 passed / + headless tests. Runtime: 271 passed / 2 ignored; desktop app: 486 passed / 1 ignored; headless app: 82 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. From 6d3485f9f397991fd305ba1ff612691f8b52b4cb Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:05:37 +0000 Subject: [PATCH 04/10] Hide empty computer folders from the sidebar --- apps/maple-agent/app/src/ui/chat/sidebar_computers.rs | 2 +- apps/maple-agent/docs/remote-access.md | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs index a7a7c2c28..adbac8f61 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -88,7 +88,7 @@ impl Sidebar { std::cmp::Reverse(s.updated_ms), ) }); - if !matches_group && tasks.is_empty() { + if tasks.is_empty() { continue; } let heading = self.computer_projects.len(); diff --git a/apps/maple-agent/docs/remote-access.md b/apps/maple-agent/docs/remote-access.md index af13dc9ee..2a4cab4e1 100644 --- a/apps/maple-agent/docs/remote-access.md +++ b/apps/maple-agent/docs/remote-access.md @@ -18,9 +18,10 @@ attempt. A failed attempt requires a new, locally opened window. 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. 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. In Remote computers settings, +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. In Remote computers settings, **Rename** changes the computer's label on this desktop; it does not change its identity or pairing grant. From db585117b8fdb62cd58f273a81eeed0372780cab Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:06:16 +0000 Subject: [PATCH 05/10] Make remote pairing code display and entry clear --- apps/maple-agent/app/src/remote/manager.rs | 2 +- apps/maple-agent/app/src/remote/pairing.rs | 47 ++++++++++++++++------ apps/maple-agent/app/src/ui/remote.rs | 43 +++++++++++++++++--- apps/maple-agent/docs/remote-access.md | 3 +- 4 files changed, 75 insertions(+), 20 deletions(-) diff --git a/apps/maple-agent/app/src/remote/manager.rs b/apps/maple-agent/app/src/remote/manager.rs index b44c2b3c8..37d52be54 100644 --- a/apps/maple-agent/app/src/remote/manager.rs +++ b/apps/maple-agent/app/src/remote/manager.rs @@ -494,7 +494,7 @@ impl Node { return Err("Pairing registration was not acknowledged. Start a new window.".into()); } let result = PairingWindow { - code: next.code().unwrap().into(), + code: next.code().unwrap(), window: Uuid::from_bytes(next.id), expires_in: next.remaining().as_secs(), }; diff --git a/apps/maple-agent/app/src/remote/pairing.rs b/apps/maple-agent/app/src/remote/pairing.rs index 2d34c238c..c7427c655 100644 --- a/apps/maple-agent/app/src/remote/pairing.rs +++ b/apps/maple-agent/app/src/remote/pairing.rs @@ -8,6 +8,7 @@ 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]>); @@ -23,19 +24,25 @@ impl Code { } pub fn parse(text: &str) -> Result { - let mut bytes = Zeroizing::new([0; 8]); - if text.len() != bytes.len() || !text.bytes().all(|b| ALPHABET.contains(&b)) { - return Err( - "Enter the eight-character pairing code shown on the other computer.".into(), - ); + 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()); } - bytes.copy_from_slice(text.as_bytes()); + 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) -> &str { - std::str::from_utf8(self.0.as_ref()).expect("pairing code is ASCII") + 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..]) } } @@ -90,7 +97,7 @@ impl Window { } } - pub fn code(&self) -> Option<&str> { + pub fn code(&self) -> Option { self.code.as_ref().map(Code::display) } @@ -198,12 +205,28 @@ mod tests { #[test] fn correct_proofs_confirm_execution_before_controller() { let ctx = context(); - let a = Controller::new(code(), &ctx).unwrap(); + 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 { @@ -246,8 +269,8 @@ mod tests { #[test] fn window_cannot_be_reused_or_extended_by_admission() { let mut window = Window::new(); - assert_eq!(window.code().unwrap().len(), 8); - assert!(Code::parse(window.code().unwrap()).is_ok()); + 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()); diff --git a/apps/maple-agent/app/src/ui/remote.rs b/apps/maple-agent/app/src/ui/remote.rs index d4287daa3..9face78bf 100644 --- a/apps/maple-agent/app/src/ui/remote.rs +++ b/apps/maple-agent/app/src/ui/remote.rs @@ -5,7 +5,7 @@ use crate::{ remote::manager::{PairingWindow, Status}, ui::{task, text_input::TextInput, theme, widgets}, }; -use gpui::{Context, Entity, EventEmitter, Render, Window, div, prelude::*}; +use gpui::{Context, Div, Entity, EventEmitter, Render, Window, div, prelude::*, px}; use maple_sdk::remote::RemoteDevice; use std::{ cell::RefCell, @@ -56,8 +56,7 @@ impl RemoteScreen { backend, status: None, devices: Vec::new(), - code_input: cx - .new(|cx| TextInput::new("Eight-character code from the other computer", cx)), + code_input: cx.new(|cx| TextInput::new("Pairing code (ABCD-EFGH)", cx)), names, renaming: None, local_code: None, @@ -242,8 +241,9 @@ impl Render for RemoteScreen { .when(enabled, |view| view.child(div().child( widgets::primary_button("pairing-window").aria_label("Pair another computer").child("Pair another computer") .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_2() - .child(div().text_xl().child(code.clone())) + .when_some(self.local_code.as_ref(), |view, (code, deadline)| view.child(widgets::card_row().flex().flex_col().gap_3() + .child(div().text_sm().child("Enter this code on the other computer")) + .child(pairing_code_boxes(code)) .child(format!("One attempt · expires in {} seconds. Enter this code only on the computer you want to pair.", deadline.saturating_duration_since(Instant::now()).as_secs())) .child(widgets::secondary_button("cancel-window").aria_label("Close pairing window").child("Close pairing window") .on_click(cx.listener(|screen, _, _, cx| screen.action("remote_cancel_pairing", serde_json::json!([]), cx)))))) @@ -284,7 +284,12 @@ impl Render for RemoteScreen { .on_click(cx.listener(|screen, _, _, cx| screen.refresh(true, cx))))) .child(div().text_color(gpui::rgb(theme::text_secondary())) .child("Open Remote computers in Settings on the other device and start pairing there. Then refresh and enter its code here.")) - .child(self.code_input.clone()) + .child(div().flex().flex_col().gap_2() + .child(div().text_sm().child("Pairing code")) + .child(widgets::input_frame().w(px(320.)).max_w_full().text_xl() + .font_family(crate::assets::FONT_MONO).child(self.code_input.clone())) + .child(div().text_sm().text_color(gpui::rgb(theme::text_muted())) + .child("Type or paste the code from the other computer. The dash is optional."))) .children(self.devices.iter().map(|device| { let endpoint = device.endpoint.clone(); div().flex().gap_3().items_center().justify_between() @@ -300,3 +305,29 @@ impl Render for RemoteScreen { .when_some(self.error.as_ref(), |view, error| view.child(div().child(error.clone()))) } } + +fn pairing_code_boxes(code: &str) -> Div { + div() + .flex() + .gap_2() + .items_center() + .children(code.chars().map(|character| { + let cell = div() + .flex() + .items_center() + .justify_center() + .h(px(44.)) + .text_xl() + .font_family(crate::assets::FONT_MONO) + .child(character.to_string()); + if character == '-' { + cell.w(px(12.)).text_color(gpui::rgb(theme::text_muted())) + } else { + cell.w(px(34.)) + .rounded(theme::RADIUS_MD) + .bg(gpui::rgb(theme::bg_input())) + .border_1() + .border_color(gpui::rgb(theme::border())) + } + })) +} diff --git a/apps/maple-agent/docs/remote-access.md b/apps/maple-agent/docs/remote-access.md index 2a4cab4e1..5ea0cf815 100644 --- a/apps/maple-agent/docs/remote-access.md +++ b/apps/maple-agent/docs/remote-access.md @@ -11,7 +11,8 @@ continue to share those tasks. ACP is not exposed over Iroh. Open **Settings → Remote computers**, enable remote access on the computer that will run tasks, and choose **Pair another computer**. On the controller, refresh devices and -enter the eight-character code displayed on the execution computer. Both must +enter the eight-character code displayed on the execution computer, grouped as +`ABCD-EFGH`. The code field accepts it with or without the dash. 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. From 439612038ef7348f4e01d865a73cfcf75eefa8d7 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 19 Oct 2026 05:28:32 +0000 Subject: [PATCH 06/10] Guide remote pairing through device selection and code slots --- apps/maple-agent/app/src/desktop.rs | 5 + apps/maple-agent/app/src/remote/manager.rs | 2 +- apps/maple-agent/app/src/remote/store.rs | 2 +- apps/maple-agent/app/src/ui/remote.rs | 437 +++++++++++++++--- apps/maple-agent/app/src/ui/remote/tests.rs | 138 ++++++ apps/maple-agent/app/src/ui/settings.rs | 9 + apps/maple-agent/app/src/ui/text_input.rs | 59 ++- .../maple-agent/app/src/ui/text_input/code.rs | 224 +++++++++ apps/maple-agent/docs/remote-access.md | 13 +- 9 files changed, 809 insertions(+), 80 deletions(-) create mode 100644 apps/maple-agent/app/src/ui/remote/tests.rs create mode 100644 apps/maple-agent/app/src/ui/text_input/code.rs diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 322d8937a..43fefe3d7 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -1146,6 +1146,11 @@ pub fn run() { 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); } }); diff --git a/apps/maple-agent/app/src/remote/manager.rs b/apps/maple-agent/app/src/remote/manager.rs index 37d52be54..245ba0d82 100644 --- a/apps/maple-agent/app/src/remote/manager.rs +++ b/apps/maple-agent/app/src/remote/manager.rs @@ -54,7 +54,7 @@ pub struct Node { pub(super) controllers: Arc, } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Status { pub enabled: bool, pub endpoint: Option, diff --git a/apps/maple-agent/app/src/remote/store.rs b/apps/maple-agent/app/src/remote/store.rs index c6267cf16..32dc947c6 100644 --- a/apps/maple-agent/app/src/remote/store.rs +++ b/apps/maple-agent/app/src/remote/store.rs @@ -12,7 +12,7 @@ use zeroize::Zeroize; const MAX_PEERS: usize = 64; -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Peer { pub name: String, diff --git a/apps/maple-agent/app/src/ui/remote.rs b/apps/maple-agent/app/src/ui/remote.rs index 9face78bf..3413caf1d 100644 --- a/apps/maple-agent/app/src/ui/remote.rs +++ b/apps/maple-agent/app/src/ui/remote.rs @@ -5,7 +5,7 @@ use crate::{ remote::manager::{PairingWindow, Status}, ui::{task, text_input::TextInput, theme, widgets}, }; -use gpui::{Context, Div, Entity, EventEmitter, Render, Window, div, prelude::*, px}; +use gpui::{Context, Div, Entity, EventEmitter, Focusable, Render, Window, div, prelude::*, px}; use maple_sdk::remote::RemoteDevice; use std::{ cell::RefCell, @@ -13,6 +13,9 @@ use std::{ time::{Duration, Instant}, }; +#[cfg(test)] +mod tests; + pub struct ConnectComputer(pub String); pub struct ComputersChanged(pub Status); pub struct ComputerNamed { @@ -33,13 +36,15 @@ pub(crate) fn computer_name(key: &str, advertised: &str, alias: &str) -> String pub struct RemoteScreen { backend: Arc, status: Option, - devices: Vec, + devices: Option>, + selected_device: Option, code_input: Entity, names: std::collections::HashMap, renaming: Option<(String, Entity)>, local_code: Option<(String, Instant)>, error: Option, - busy: bool, + notice: Option, + busy: Option<&'static str>, bridges: RefCell>>, } impl EventEmitter for RemoteScreen {} @@ -52,16 +57,30 @@ impl RemoteScreen { 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: Vec::new(), - code_input: cx.new(|cx| TextInput::new("Pairing code (ABCD-EFGH)", cx)), + devices: None, + selected_device: None, + code_input, names, renaming: None, local_code: None, error: None, - busy: false, + notice: None, + busy: None, bridges: RefCell::new(Vec::new()), }; screen.refresh(false, cx); @@ -78,6 +97,9 @@ impl RemoteScreen { && 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(); @@ -93,6 +115,91 @@ impl RemoteScreen { 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.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| { @@ -136,10 +243,19 @@ impl RemoteScreen { cx.notify(); } fn refresh(&mut self, discover: bool, cx: &mut Context) { - if self.busy { + if self.busy.is_some() { return; } - self.busy = true; + 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, @@ -154,13 +270,13 @@ impl RemoteScreen { }, cx, move |screen, result, cx| { - screen.busy = false; + screen.busy = None; match result { Ok((status, devices)) => { cx.emit(ComputersChanged(status.clone())); - screen.status = Some(status); + screen.update_status(status, cx); if discover { - screen.devices = devices; + screen.devices = Some(devices); } screen.error = None; } @@ -170,13 +286,20 @@ impl RemoteScreen { }, ); task::retain(&self.bridges, bridge); + cx.notify(); } fn action(&mut self, method: &'static str, params: serde_json::Value, cx: &mut Context) { - if self.busy { + if self.busy.is_some() { return; } - self.busy = true; + 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, @@ -187,7 +310,7 @@ impl RemoteScreen { }, cx, move |screen, result, cx| { - screen.busy = false; + screen.busy = None; match result { Ok(value) => { if method == "remote_begin_pairing" { @@ -200,13 +323,21 @@ impl RemoteScreen { )) } Err(_) => { - screen.error = Some("Could not read the pairing window".into()) + screen.error = Some("Could not read the pairing window".into()); + cx.notify(); + return; } } - } else if (method == "remote_cancel_pairing" || method == "remote_enable") - && let Some((mut code, _)) = screen.local_code.take() - { - zeroize::Zeroize::zeroize(&mut code); + } 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()); } screen.refresh(false, cx); } @@ -221,35 +352,215 @@ impl RemoteScreen { } impl Drop for RemoteScreen { fn drop(&mut self) { - if let Some((code, _)) = &mut self.local_code { - zeroize::Zeroize::zeroize(code); - } + self.clear_local_code(); } } impl Render for RemoteScreen { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let enabled = self.status.as_ref().is_some_and(|s| s.enabled); - div().id("remote-devices").w_full().flex().flex_col().gap_4() + 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 in another computer’s projects from this window. Tasks use that computer’s tools and files; your account and desktop preferences stay here.")) - .child(div().flex().gap_3().items_center().justify_between() - .child(div().flex_1().child(if enabled { "This computer accepts paired controllers" } else { "Remote access to this computer is off" })) - .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, |view| view.child(div().child( - widgets::primary_button("pairing-window").aria_label("Pair another computer").child("Pair another computer") + .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())) + }) + .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() + .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 { "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(widgets::card_row().flex().flex_col().gap_3() - .child(div().text_sm().child("Enter this code on the other computer")) + .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(format!("One attempt · expires in {} seconds. Enter this code only on the computer you want to pair.", deadline.saturating_duration_since(Instant::now()).as_secs())) - .child(widgets::secondary_button("cancel-window").aria_label("Close pairing window").child("Close pairing window") - .on_click(cx.listener(|screen, _, _, cx| screen.action("remote_cancel_pairing", serde_json::json!([]), cx)))))) - .child(div().pt_4().text_lg().child("Paired computers")) - .when(self.status.as_ref().is_some_and(|s| s.peers.is_empty()), |view| view.child( - div().text_color(gpui::rgb(theme::text_muted())).child("No paired computers yet."))) + .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(); @@ -262,8 +573,13 @@ impl Render for RemoteScreen { .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_xs().text_color(gpui::rgb(theme::text_muted())) - .child(if peer.can_connect { "Projects available in the sidebar" } else { "Can control this computer" }))) + .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("Rename").child("Rename") .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") @@ -271,59 +587,34 @@ impl Render for RemoteScreen { .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(div().flex_1().child(input.clone())) + .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))))) })) - .child(div().pt_4().flex().gap_3().items_center().justify_between() - .child(div().text_lg().child("Find another computer")) - .child(widgets::secondary_button("discover").aria_label("Refresh devices").child("Refresh devices") - .on_click(cx.listener(|screen, _, _, cx| screen.refresh(true, cx))))) - .child(div().text_color(gpui::rgb(theme::text_secondary())) - .child("Open Remote computers in Settings on the other device and start pairing there. Then refresh and enter its code here.")) - .child(div().flex().flex_col().gap_2() - .child(div().text_sm().child("Pairing code")) - .child(widgets::input_frame().w(px(320.)).max_w_full().text_xl() - .font_family(crate::assets::FONT_MONO).child(self.code_input.clone())) - .child(div().text_sm().text_color(gpui::rgb(theme::text_muted())) - .child("Type or paste the code from the other computer. The dash is optional."))) - .children(self.devices.iter().map(|device| { - let endpoint = device.endpoint.clone(); - div().flex().gap_3().items_center().justify_between() - .child(computer_name(&endpoint, &device.name, self.names.get(&endpoint).map_or("", String::as_str))) - .when(device.window.is_some(), |row| row.child(widgets::primary_button(format!("pair-{endpoint}")).aria_label("Pair using code").child("Pair using code") - .on_click(cx.listener(move |screen, _, _, cx| { - let code = screen.code_input.read(cx).text().trim().to_ascii_uppercase(); - screen.code_input.update(cx, |input, cx| input.set_text("", cx)); - screen.action("remote_pair", serde_json::json!([endpoint, code]), cx); - })))) - })) - .when(self.busy, |view| view.child("Connecting…")) - .when_some(self.error.as_ref(), |view, error| view.child(div().child(error.clone()))) } } fn pairing_code_boxes(code: &str) -> Div { div() .flex() - .gap_2() + .gap(px(6.)) .items_center() .children(code.chars().map(|character| { let cell = div() .flex() .items_center() .justify_center() - .h(px(44.)) + .h(px(48.)) .text_xl() .font_family(crate::assets::FONT_MONO) .child(character.to_string()); if character == '-' { - cell.w(px(12.)).text_color(gpui::rgb(theme::text_muted())) + cell.w(px(16.)).text_color(gpui::rgb(theme::text_muted())) } else { - cell.w(px(34.)) + cell.w(px(40.)) .rounded(theme::RADIUS_MD) .bg(gpui::rgb(theme::bg_input())) .border_1() diff --git a/apps/maple-agent/app/src/ui/remote/tests.rs b/apps/maple-agent/app/src/ui/remote/tests.rs new file mode 100644 index 000000000..c43148390 --- /dev/null +++ b/apps/maple-agent/app/src/ui/remote/tests.rs @@ -0,0 +1,138 @@ +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 { + enabled: true, + 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_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 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); + }); +} diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index 1b67128ea..b97e55959 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -241,6 +241,15 @@ 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, diff --git a/apps/maple-agent/app/src/ui/text_input.rs b/apps/maple-agent/app/src/ui/text_input.rs index 082cf21a4..64706fc88 100644 --- a/apps/maple-agent/app/src/ui/text_input.rs +++ b/apps/maple-agent/app/src/ui/text_input.rs @@ -4,6 +4,7 @@ //! text and grows with its content (Shift+Enter inserts a newline). mod bounds; +mod code; pub mod vim; pub(crate) mod vim_actions; @@ -136,6 +137,8 @@ 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 @@ -269,6 +272,7 @@ 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, @@ -291,6 +295,13 @@ 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 { @@ -519,6 +530,16 @@ 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(); @@ -749,6 +770,16 @@ 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. @@ -1936,6 +1967,11 @@ 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; } @@ -2133,6 +2169,17 @@ 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) @@ -2207,6 +2254,9 @@ 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() { @@ -2236,6 +2286,9 @@ 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 @@ -3089,7 +3142,11 @@ impl Render for TextInput { }) .w_full() .when(self.multiline && self.fill_height, |el| el.h_full()) - .child(TextElement { input: cx.entity() }); + .child(if self.segmented_code { + code::render(self, window, cx).into_any_element() + } else { + TextElement { input: cx.entity() }.into_any_element() + }); // 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 new file mode 100644 index 000000000..ca74ccf68 --- /dev/null +++ b/apps/maple-agent/app/src/ui/text_input/code.rs @@ -0,0 +1,224 @@ +//! 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/docs/remote-access.md b/apps/maple-agent/docs/remote-access.md index 5ea0cf815..add5350ba 100644 --- a/apps/maple-agent/docs/remote-access.md +++ b/apps/maple-agent/docs/remote-access.md @@ -9,10 +9,15 @@ continue to share those tasks. ACP is not exposed over Iroh. ## Using it -Open **Settings → Remote computers**, enable remote access on the computer that will run tasks, -and choose **Pair another computer**. On the controller, refresh devices and -enter the eight-character code displayed on the execution computer, grouped as -`ABCD-EFGH`. The code field accepts it with or without the dash. Both must +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. From c1bcf9a78b122483ed6428005e1644a6c8a00dea Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Mon, 19 Oct 2026 06:41:06 +0000 Subject: [PATCH 07/10] Keep remote control responsive during attachment transfers --- apps/maple-agent/app/src/remote/control.rs | 247 +++++++++++------ apps/maple-agent/app/src/remote/mod.rs | 1 + apps/maple-agent/app/src/remote/network.rs | 1 + apps/maple-agent/app/src/remote/streams.rs | 156 +++++++++++ .../app/src/remote/streams/tests.rs | 261 ++++++++++++++++++ apps/maple-agent/app/src/remote/wire.rs | 37 ++- apps/maple-agent/docs/remote-access.md | 15 +- apps/maple-agent/docs/remote-validation.md | 55 +++- 8 files changed, 667 insertions(+), 106 deletions(-) create mode 100644 apps/maple-agent/app/src/remote/streams.rs create mode 100644 apps/maple-agent/app/src/remote/streams/tests.rs diff --git a/apps/maple-agent/app/src/remote/control.rs b/apps/maple-agent/app/src/remote/control.rs index b2b2af2ac..18e8f0d40 100644 --- a/apps/maple-agent/app/src/remote/control.rs +++ b/apps/maple-agent/app/src/remote/control.rs @@ -2,7 +2,7 @@ //! this transport; accepted sends and their receipts belong exclusively to B. use super::{ manager::{Manager, Node}, - wire, + streams, wire, }; use crate::host::protocol::{self, Message, Request}; use iroh::{ @@ -11,7 +11,7 @@ use iroh::{ }; use maple_agent::agent::AgentServiceEvent; use std::{sync::Arc, time::Duration}; -use tokio::sync::{Semaphore, mpsc}; +use tokio::sync::mpsc; pub(super) fn listen(node: Arc) { tokio::spawn(async move { @@ -92,95 +92,132 @@ async fn serve( peer: EndpointId, connection: Connection, mut tx: SendStream, - mut rx: RecvStream, + _rx: RecvStream, ) { let _questions = node.backend.question_responder(); let mut events = node.events.subscribe(); let mut revoked = node.revoked.subscribe(); - let (incoming, mut requests) = mpsc::channel(1); - let reader = tokio::spawn(async move { + 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 request = wire::read::(&mut rx, wire::MAX_FRAME).await; - let terminal = request.is_err(); - if incoming.send(request).await.is_err() || terminal { - break; + 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 (responses, mut output) = mpsc::channel(16); - // Desktop bootstrap fans out independent history, model and integration reads. - // Bound that burst without rejecting ordinary startup; urgent task controls - // retain their own capacity. Remote work never borrows local IPC slots. - let permits = Arc::new(Semaphore::new(32)); - let control_permits = Arc::new(Semaphore::new(2)); - let mut calls = tokio::task::JoinSet::new(); let mut tick = tokio::time::interval(Duration::from_secs(1)); - let result: Result<(), String> = async { - loop { - tokio::select! { - _ = node.stopped.cancelled() => break, - _ = connection.closed() => break, - _ = tick.tick() => { - node.ensure_valid().await?; - if !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() => {}, - request = requests.recv() => { - let Some(request) = request else { break; }; - let request = match request { - Ok(request) => request, - Err(wire::Error::Disconnected) => break, - Err(error) => { send(&mut tx, &Message::Closed { reason: error.to_string() }).await?; break; } - }; - node.ensure_valid().await?; - if !node.store.lock().unwrap().permits(peer) { break; } - let urgent = matches!(request.method, wire::Method::CancelRun | wire::Method::AnswerQuestion | wire::Method::CancelExternalAgent); - let slot = if urgent { control_permits.clone() } else { permits.clone() }.try_acquire_owned(); - let Ok(slot) = slot else { - send(&mut tx, &Message::Response { id: request.id, result: Err("Remote computer is busy; try again".into()) }).await?; - continue; - }; - let node = node.clone(); let responses = responses.clone(); - calls.spawn(async move { - let _slot = slot; - 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 _ = responses.send(Message::Response { id, result }).await; - } - }); - } - Some(response) = output.recv() => send(&mut tx, &response).await?, - event = events.recv() => { - let event = event.map_err(|_| "Remote event stream fell behind")?; - node.ensure_valid().await?; - if !node.store.lock().unwrap().permits(peer) { break; } - // Account transitions are terminal for this connection. - // Local lifecycle markers can never control another UI. - if matches!(&*event, AgentServiceEvent::AccountChanged(_)) { break; } - if remote_event(&event) { send(&mut tx, &Message::Event(Box::new((*event).clone()))).await?; } + 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 { + let _ = serve_request(node, peer, tx, rx, capacity).await; + }); } } - Ok(()) - }.await; - let _ = tx.finish(); - let _ = tokio::time::timeout(Duration::from_secs(2), tx.stopped()).await; - if result.is_err() { - connection.close(1u32.into(), b"remote stream closed"); } - reader.abort(); - // As in local IPC, an admitted mutation finishes even if the viewer leaves. - drop(output); + 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 streams::reply(&mut tx, &Message::Closed { reason }).await, + }; + 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 request = match header.body(&mut rx).await { + Ok(request) => request, + Err(reason) => return streams::reply(&mut tx, &Message::Closed { 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) { + streams::reply(&mut tx, &Message::Response { id, result }).await?; + } + Ok(()) +} + fn remote_event(event: &AgentServiceEvent) -> bool { // Exhaustive on purpose: new local events need an explicit remote decision. match event { @@ -215,7 +252,6 @@ pub struct Link { pub node: Arc, pub peer: EndpointId, pub connection: Connection, - pub tx: SendStream, pub rx: RecvStream, } @@ -262,11 +298,11 @@ impl Manager { .map_err(|e| e.to_string())?; accepted.map_err(|error| format!("Remote access ended: {error}"))?; node.ensure_valid().await?; + tx.finish().map_err(|_| "Remote handshake closed")?; Ok(Link { node, peer, connection, - tx, rx, }) } @@ -279,13 +315,17 @@ pub async fn bridge( 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 reader = tokio::spawn(async move { + 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, + 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(), @@ -298,13 +338,15 @@ pub async fn bridge( } }); let (requests, mut request_rx) = mpsc::channel(1); - let local_reader = tokio::spawn(async move { + 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 { @@ -315,35 +357,58 @@ pub async fn bridge( 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?; - protocol::write(&mut local_tx, &frame).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" { - protocol::write(&mut local_tx, &Message::Response { id: request.id, result: Ok(serde_json::Value::Null) }).await?; + 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) => { protocol::write(&mut local_tx, &Message::Response { id: request.id, result: Err(error) }).await?; continue; } + Err(error) => { local_send(&mut local_tx, &Message::Response { id: request.id, result: Err(error) }).await?; continue; } }; link.node.ensure_valid().await?; - let sent = wire::write(&mut link.tx, &wire::Command { id: request.id, method, send_id: request.send_id, params: request.params }, wire::MAX_FRAME).await; - match sent { - Ok(()) => {}, - Err(wire::Error::InvalidFrame) => protocol::write(&mut local_tx, &Message::Response { id: request.id, result: Err("Remote message is too large. Try fewer or smaller images.".into()) }).await?, - Err(error) => return Err(error.to_string()), - } + 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; + streams::call(&connection, wire::Command { id: request.id, method, send_id: request.send_id, params: request.params }).await + }); } } } }.await; - reader.abort(); - local_reader.abort(); + 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")? +} diff --git a/apps/maple-agent/app/src/remote/mod.rs b/apps/maple-agent/app/src/remote/mod.rs index 96db0b259..419799e18 100644 --- a/apps/maple-agent/app/src/remote/mod.rs +++ b/apps/maple-agent/app/src/remote/mod.rs @@ -2,6 +2,7 @@ mod network; mod pairing; mod store; +mod streams; mod wire; pub mod control; diff --git a/apps/maple-agent/app/src/remote/network.rs b/apps/maple-agent/app/src/remote/network.rs index 715f24041..c72457c36 100644 --- a/apps/maple-agent/app/src/remote/network.rs +++ b/apps/maple-agent/app/src/remote/network.rs @@ -23,6 +23,7 @@ fn builder(key: SecretKey, relays: RelayMode, relay_only: bool) -> iroh::endpoin .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. diff --git a/apps/maple-agent/app/src/remote/streams.rs b/apps/maple-agent/app/src/remote/streams.rs new file mode 100644 index 000000000..c1521f375 --- /dev/null +++ b/apps/maple-agent/app/src/remote/streams.rs @@ -0,0 +1,156 @@ +//! 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 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 = tokio::time::timeout( + transfer_timeout(self.method), + wire::read(rx, body_limit(self.method)), + ) + .await + .map_err(|_| "Remote request transfer timed out")? + .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 + } +} + +pub async fn reply(tx: &mut SendStream, message: &Message) -> Result<(), String> { + tokio::time::timeout(TRANSFER_TIMEOUT, async { + wire::write(tx, message, wire::MAX_FRAME) + .await + .map_err(|e| e.to_string())?; + tx.finish().map_err(|_| "Remote reply closed")?; + tx.stopped() + .await + .map_err(|_| "Remote reply disconnected")?; + Ok(()) + }) + .await + .map_err(|_| "Remote reply transfer timed out")? +} + +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(transfer_timeout(header.method), async { + wire::write(&mut tx, &header, wire::MAX_PREFACE).await?; + wire::write_encoded(&mut tx, &body).await + }) + .await + .map_err(|_| "Remote request transfer timed out")? + .map_err(|e| e.to_string())?; + tx.finish().map_err(|_| "Remote request closed")?; + match wire::read::(&mut rx, wire::MAX_FRAME).await { + Ok(message @ Message::Response { id: received, .. }) if received == id => Ok(message), + Ok(message @ Message::Closed { .. }) => Ok(message), + Err(wire::Error::Disconnected) => Err("Remote reply disconnected".into()), + _ => 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 new file mode 100644 index 000000000..9f6026604 --- /dev/null +++ b/apps/maple-agent/app/src/remote/streams/tests.rs @@ -0,0 +1,261 @@ +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(); +} diff --git a/apps/maple-agent/app/src/remote/wire.rs b/apps/maple-agent/app/src/remote/wire.rs index 26e0668c0..12b63d3b6 100644 --- a/apps/maple-agent/app/src/remote/wire.rs +++ b/apps/maple-agent/app/src/remote/wire.rs @@ -3,8 +3,9 @@ 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 = 1; +pub const VERSION: u32 = 2; pub const MAX_FRAME: usize = 16 * 1024 * 1024; pub const MAX_PREFACE: usize = 4096; @@ -95,6 +96,12 @@ pub enum Method { } 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()) @@ -156,16 +163,28 @@ pub async fn write( 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) + .write_all(bytes) .await .map_err(|_| Error::Disconnected)?; writer.flush().await.map_err(|_| Error::Disconnected) @@ -231,8 +250,8 @@ mod tests { #[test] fn compatible_versions_do_not_depend_on_build_identity() { let mut hello = Hello { - version: 1, - minimum_version: 1, + version: VERSION, + minimum_version: VERSION, account: "a".into(), project: "p".into(), deployment: "d".into(), @@ -240,11 +259,15 @@ mod tests { mode: Mode::Control, }; assert!(hello.check_version().is_ok()); - hello.version = 2; + 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 = 2; + hello.minimum_version = VERSION + 1; assert_eq!(hello.check_version(), Err(Error::Incompatible)); - hello.version = 1; + hello.version = VERSION; assert_eq!(hello.check_version(), Err(Error::Incompatible)); } } diff --git a/apps/maple-agent/docs/remote-access.md b/apps/maple-agent/docs/remote-access.md index add5350ba..b391d4701 100644 --- a/apps/maple-agent/docs/remote-access.md +++ b/apps/maple-agent/docs/remote-access.md @@ -64,14 +64,21 @@ These administer only the local computer. Never put a pairing code in argv or lo Iroh 1.3.0 supplies authenticated QUIC, direct connectivity and relay fallback. The minimal preset disables public address lookup and router port mapping. There is no second encryption tunnel or custom routing layer. The remote -protocol uses `maple-agent/remote/1`, a simple minimum-version contract independent -of application builds, bounded frames and an explicit task-operation allowlist. +protocol keeps the `maple-agent/remote/1` ALPN family and requires wire version 2 +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. Slow readers lose their stream and -recover from an authoritative snapshot. Accepted mutations finish on the host; +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. Stalled transfers are bounded, +and a failed connection recovers from an authoritative snapshot. Accepted mutations finish on the host; transport loss never causes an automatic prompt replay. A stable send identity crosses both IPC and Iroh; only the execution daemon records its admission and result, account-bound in its receipt database. diff --git a/apps/maple-agent/docs/remote-validation.md b/apps/maple-agent/docs/remote-validation.md index 5f18d493a..5956c7f8f 100644 --- a/apps/maple-agent/docs/remote-validation.md +++ b/apps/maple-agent/docs/remote-validation.md @@ -63,11 +63,33 @@ 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. +Wire version 2 uses 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. + ## Automated checks - Full Agent `just ci`: all four Clippy configurations, workspace build/tests and - headless tests. Runtime: 271 passed / 2 ignored; desktop app: 486 passed / - 1 ignored; headless app: 82 passed / 1 ignored. The ignored app test contacts + 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. @@ -85,6 +107,31 @@ The full component checks below were rerun after this navigation change. [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, while its latest window tombstones are retained. + Reset cleanup and idle-record retention need resolution before hosted rollout. +- 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 @@ -92,8 +139,8 @@ callback in a deployed relay. The callback has unit/disposable-DB coverage; actu Maple Dev relay/TLS/credential configuration, DNS and enclave deployment are separate authorized rollout work. No service or production release was deployed. -Windows/Linux native builds, status-tray interaction and named-pipe behavior must -be checked on those platforms. Two physical machines behind independent networks, +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 From ca4b2bbfc28dac0f2ea3678fa82dd6d0be1233c8 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:32:23 +0000 Subject: [PATCH 08/10] Harden remote pairing, transfer and lifecycle recovery --- apps/maple-agent/Cargo.lock | 1 + apps/maple-agent/app/Cargo.toml | 2 + apps/maple-agent/app/src/backend.rs | 31 +- apps/maple-agent/app/src/backend/local.rs | 54 ++++ apps/maple-agent/app/src/desktop.rs | 214 ++++++++++-- apps/maple-agent/app/src/host.rs | 53 ++- .../app/src/host/recovery_tests.rs | 35 ++ apps/maple-agent/app/src/remote/control.rs | 51 ++- apps/maple-agent/app/src/remote/manager.rs | 305 ++++++++++++------ apps/maple-agent/app/src/remote/streams.rs | 105 ++++-- .../app/src/remote/streams/tests.rs | 51 +++ apps/maple-agent/app/src/remote/wire.rs | 219 ++++++++++++- apps/maple-agent/app/src/remote/wire_v3.json | 10 + apps/maple-agent/app/src/tray.rs | 25 ++ apps/maple-agent/app/src/ui/chat/mod.rs | 117 ++++++- apps/maple-agent/app/src/ui/chat/tests.rs | 38 +++ apps/maple-agent/app/src/ui/remote.rs | 18 +- apps/maple-agent/app/src/ui/remote/tests.rs | 2 + apps/maple-agent/app/src/ui/titlebar.rs | 4 +- .../crates/maple-agent/src/maple_api.rs | 24 +- apps/maple-agent/docs/local-host.md | 13 +- apps/maple-agent/docs/remote-access.md | 39 ++- apps/maple-agent/docs/remote-security.md | 7 +- apps/maple-agent/docs/remote-validation.md | 56 +++- services/opensecret/docs/remote-enrollment.md | 17 +- .../opensecret/src/aead_db_tamper_tests.rs | 119 +++++++ services/opensecret/src/db.rs | 11 + .../src/models/remote_enrollment.rs | 13 + .../opensecret/src/security_invariants.rs | 5 +- services/opensecret/src/web/remote/mod.rs | 37 +++ 30 files changed, 1456 insertions(+), 220 deletions(-) create mode 100644 apps/maple-agent/app/src/remote/wire_v3.json diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index 7e6ec8e68..5bf01aed0 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -5703,6 +5703,7 @@ dependencies = [ "wayland-client", "webbrowser", "windows-sys 0.61.2", + "zbus", "zeroize", ] diff --git a/apps/maple-agent/app/Cargo.toml b/apps/maple-agent/app/Cargo.toml index 9cdab3894..f6a7fb790 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -26,6 +26,7 @@ desktop = [ "dep:spellbook", "dep:wayland-client", "dep:tray-icon", + "dep:zbus", ] # `maple-agent acp`: the Agent Client Protocol server on stdio. acp = ["maple-agent/acp"] @@ -84,6 +85,7 @@ 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 } diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index ddf70bce7..b3d5a1ce4 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -127,7 +127,6 @@ 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; @@ -260,6 +259,27 @@ 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() } @@ -389,6 +409,15 @@ 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 5136edea3..f47de3da8 100644 --- a/apps/maple-agent/app/src/backend/local.rs +++ b/apps/maple-agent/app/src/backend/local.rs @@ -2689,6 +2689,60 @@ mod tests { 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 43fefe3d7..32ce8e4bc 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -715,29 +715,119 @@ impl Render for MapleApp { } } -fn install_close_behavior( - window: &mut Window, - cx: &mut App, +#[derive(Clone)] +struct DesktopLifecycle { + backend: Arc, remote_enabled: Arc, can_background: bool, -) { - window.on_window_should_close(cx, move |_, cx| { - persist_window_state(); - if remote_enabled.load(std::sync::atomic::Ordering::Acquire) { - #[cfg(target_os = "macos")] + closing: 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| cx.quit()), + result => { + state.closing.set(false); + log::warn!("Could not stop Maple host on Quit: {result:?}"); + cx.update(|cx| { + for handle in cx.windows() { + let _ = handle.update(cx, |_, window, cx| { + let prompt = window.prompt(gpui::PromptLevel::Warning, "Maple could not stop its background host", + Some("Remote access may still be running. Keep Maple open and try Quit again."), &["OK"], cx); + cx.spawn(async move |_| { let _ = prompt.await; }).detach(); + }); + } + }); + } + } + }).detach(); +} + +/// 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")] { - let _ = can_background; - cx.hide(); - false + state.can_background && crate::tray::usable().await } - #[cfg(not(target_os = "macos"))] + #[cfg(target_os = "windows")] { - can_background + state.can_background } - } else { - cx.quit(); - true - } + }); + 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 }); } @@ -823,10 +913,59 @@ fn main_window_options(window_bounds: WindowBounds) -> WindowOptions { } } -struct StartupError(String); +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(); + } +} 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() @@ -836,7 +975,23 @@ 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.0.clone()) + .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( div() .id("close-startup-error") @@ -854,7 +1009,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| cx.quit()); + cx.on_action(|_: &QuitApp, cx| quit_app(cx)); ui::menus::install(cx); let bounds = Bounds::centered(None, size(px(560.), px(260.)), cx); let result = cx.open_window( @@ -872,7 +1027,7 @@ fn show_startup_error(error: String) { cx.quit(); true }); - cx.new(|_| StartupError(error)) + cx.new(|_| StartupError::new(error)) }, ); if let Err(error) = result { @@ -929,7 +1084,7 @@ pub fn run() { log::warn!("failed to register bundled fonts: {error}"); } ui::spell::preload(); - cx.on_action(|_: &QuitApp, cx| cx.quit()); + cx.on_action(|_: &QuitApp, cx| quit_app(cx)); #[cfg(target_os = "macos")] { cx.on_action(|_: &Hide, cx| cx.hide()); @@ -1001,6 +1156,12 @@ pub fn run() { 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(), + }); let quit_remote_enabled = remote_enabled.clone(); let quit_backend = backend.clone(); cx.on_app_quit(move |_cx| { @@ -1011,9 +1172,7 @@ pub fn run() { if remote_enabled.load(std::sync::atomic::Ordering::Acquire) { let api = backend.api_url().to_owned(); let _ = backend - .spawn(async move { - tokio::task::spawn_blocking(move || crate::host::stop(&api)).await - }) + .spawn(async move { crate::host::request_stop(&api).await }) .await; } else { let _ = backend.detach().await; @@ -1169,8 +1328,7 @@ pub fn run() { while let Some(action) = events.recv().await { match action { crate::tray::Action::Quit => { - cx.update(|cx| cx.quit()); - break; + cx.update(quit_app); } crate::tray::Action::Open => { cx.update(|cx| { diff --git a/apps/maple-agent/app/src/host.rs b/apps/maple-agent/app/src/host.rs index 02e2dc47a..5783cd026 100644 --- a/apps/maple-agent/app/src/host.rs +++ b/apps/maple-agent/app/src/host.rs @@ -125,6 +125,16 @@ 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())?; @@ -150,7 +160,6 @@ 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 { @@ -164,6 +173,11 @@ 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?, @@ -604,6 +618,7 @@ async fn serve_client( &endpoint.profile, backend.saved_user_id(), Some(&remote), + Some(&stop), ) .await?; if let Some(link) = link { @@ -611,7 +626,6 @@ async fn serve_client( } match mode { ConnectionMode::Stop => { - let _ = stop.send(()).await; return std::future::pending().await; } ConnectionMode::Acp => { @@ -696,7 +710,7 @@ async fn accept_handshake( profile: &str, user_id: Option, ) -> Result { - accept_scoped_handshake(connection, profile, user_id, None) + accept_scoped_handshake(connection, profile, user_id, None, None) .await .map(|(mode, _)| mode) } @@ -706,6 +720,7 @@ async fn accept_scoped_handshake( 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 @@ -750,7 +765,10 @@ async fn accept_scoped_handshake( }, None => None, }; - protocol::write( + // 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( connection, &Ok::<_, String>(Welcome { version: protocol::VERSION, @@ -759,10 +777,26 @@ async fn accept_scoped_handshake( user_id, }), ) - .await?; + .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)?; + connect(&endpoint, ConnectionMode::Stop, false) + .await + .map(|_| ()) +} + pub fn stop(api_url: &str) -> Result<(), String> { let runtime = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; runtime.block_on(async { @@ -955,7 +989,14 @@ 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!( diff --git a/apps/maple-agent/app/src/host/recovery_tests.rs b/apps/maple-agent/app/src/host/recovery_tests.rs index 08b8c3145..76391be95 100644 --- a/apps/maple-agent/app/src/host/recovery_tests.rs +++ b/apps/maple-agent/app/src/host/recovery_tests.rs @@ -414,3 +414,38 @@ async fn remote_protocol_refusal_ends_recovery_without_replaying_or_reconnecting .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(); +} diff --git a/apps/maple-agent/app/src/remote/control.rs b/apps/maple-agent/app/src/remote/control.rs index 18e8f0d40..f5bc19fab 100644 --- a/apps/maple-agent/app/src/remote/control.rs +++ b/apps/maple-agent/app/src/remote/control.rs @@ -29,12 +29,11 @@ pub(super) fn listen(node: Arc) { 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 hello: wire::Hello = wire::read(&mut rx, wire::MAX_PREFACE).await.map_err(|e| e.to_string())?; let outcome = async { - hello.check_version().map_err(|e| e.to_string())?; + 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("Remote account or deployment mismatch".to_string()); + return Err(wire::Refusal::terminal("Remote account or deployment mismatch")); } match hello.mode { wire::Mode::Pair { window, admission } => { @@ -46,9 +45,9 @@ pub(super) fn listen(node: Arc) { wire::Mode::ConfirmGrant { window } => { let answer = { let store = node.store.lock().unwrap(); - if store.permits(peer) && store.peers().get(&peer.to_string()).is_some_and(|p| p.grant == window) { + 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("No current grant matches this pairing".to_string()) } + } 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(); @@ -56,9 +55,9 @@ pub(super) fn listen(node: Arc) { Ok(None) } wire::Mode::Control => { - if !node.store.lock().unwrap().permits(peer) { return Err("Remote control grant is missing or revoked".into()); } + 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::<(), String>(()), wire::MAX_PREFACE).await.map_err(|e| e.to_string())?; + wire::write(&mut tx, &Ok::<(), wire::Refusal>(()), wire::MAX_PREFACE).await.map_err(|e| e.to_string())?; Ok(Some(slot)) } } @@ -143,7 +142,9 @@ async fn serve( let node = node.clone(); let capacity = capacity.clone(); calls.spawn(async move { - let _ = serve_request(node, peer, tx, rx, capacity).await; + if let Err(error) = serve_request(node, peer, tx, rx, capacity).await { + log::debug!("Remote request stream ended: {error}"); + } }); } } @@ -164,7 +165,7 @@ async fn serve_request( ) -> Result<(), String> { let header = match streams::Header::read(&mut rx).await { Ok(header) => header, - Err(reason) => return streams::reply(&mut tx, &Message::Closed { reason }).await, + Err(reason) => return Err(reason), }; tx.set_priority(if header.method.urgent() { 10 } else { 0 }) .map_err(|_| "Remote reply closed")?; @@ -181,9 +182,20 @@ async fn serve_request( .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::Closed { reason }).await, + 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?; @@ -213,6 +225,7 @@ async fn serve_request( ) .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(()) @@ -289,14 +302,14 @@ impl Manager { wire::write(&mut tx, &node.hello(wire::Mode::Control), wire::MAX_PREFACE) .await .map_err(|e| e.to_string())?; - let accepted: Result<(), String> = tokio::time::timeout( + let accepted: Result<(), wire::Refusal> = tokio::time::timeout( Duration::from_secs(10), wire::read(&mut rx, wire::MAX_PREFACE), ) .await .map_err(|_| "Remote handshake timed out")? - .map_err(|e| e.to_string())?; - accepted.map_err(|error| format!("Remote access ended: {error}"))?; + .map_err(|e| format!("Remote access ended: {e}"))?; + accepted.map_err(wire::Refusal::into_error)?; node.ensure_valid().await?; tx.finish().map_err(|_| "Remote handshake closed")?; Ok(Link { @@ -358,7 +371,7 @@ pub async fn bridge( 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")??; + 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?; @@ -393,7 +406,15 @@ pub async fn bridge( let connection = link.connection.clone(); calls.spawn(async move { let _slot = slot; - streams::call(&connection, wire::Command { id: request.id, method, send_id: request.send_id, params: request.params }).await + 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, + }) }, + } }); } } diff --git a/apps/maple-agent/app/src/remote/manager.rs b/apps/maple-agent/app/src/remote/manager.rs index 245ba0d82..0853c91c2 100644 --- a/apps/maple-agent/app/src/remote/manager.rs +++ b/apps/maple-agent/app/src/remote/manager.rs @@ -32,6 +32,8 @@ pub struct Manager { receipts: Arc, resident: AtomicBool, generation: AtomicU64, + resuming: AtomicBool, + last_error: Mutex>, } pub struct Node { @@ -47,6 +49,7 @@ pub struct Node { 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. @@ -58,6 +61,8 @@ pub struct Node { pub struct Status { pub enabled: bool, pub endpoint: Option, + pub ready: bool, + pub error: Option, pub peers: std::collections::BTreeMap, } @@ -82,6 +87,8 @@ impl Manager { receipts, resident: AtomicBool::new(false), generation: AtomicU64::new(0), + resuming: AtomicBool::new(false), + last_error: Mutex::new(None), }) } pub fn stays_running(&self) -> bool { @@ -89,9 +96,13 @@ impl Manager { } pub async fn shutdown(&self) { - self.generation.fetch_add(1, Ordering::SeqCst); - self.resident.store(false, Ordering::Release); - if let Some(node) = self.node.lock().await.take() { + 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; } @@ -108,24 +119,56 @@ impl Manager { } pub async fn restore(self: &Arc) { - let Some(account) = self.backend.saved_user_id() else { + 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; - }; - // Reading existing local settings does not start an endpoint or validate - // credentials on the UI thread. Disabled profiles stay entirely offline. - 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, - ) && self.store(&account).is_ok_and(|store| store.enabled()) - { - self.resident.store(true, Ordering::Release); - if let Err(error) = self.active().await { - log::warn!("Remote access could not resume: {error}"); - } } + 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> { @@ -134,15 +177,20 @@ impl Manager { .saved_user_id() .ok_or("Sign in before using remote access")?; let _initializing = self.initializing.lock().await; - let generation = self.generation.load(Ordering::SeqCst); - let current = self.node.lock().await.clone(); + 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; - self.node.lock().await.take(); + 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), @@ -187,8 +235,8 @@ impl Manager { endpoint.close().await; return Err("The signed-in account changed".into()); } - // Re-read after network awaits: local disable/revoke may have changed - // settings while initialization was in progress. + // 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 { @@ -204,6 +252,7 @@ impl Manager { deployment, window: AsyncMutex::new(None), pairing_generation: AtomicU64::new(0), + registration: AsyncMutex::new(()), stopped: CancellationToken::new(), revoked, admissions: Arc::new(Semaphore::new(4)), @@ -212,6 +261,7 @@ impl Manager { { 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()); } @@ -231,7 +281,10 @@ impl Manager { node.stopped.cancel(); node.endpoint.close().await; if let Some(manager) = manager.upgrade() { - manager.resident.store(false, Ordering::Release); + 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; } @@ -255,6 +308,15 @@ impl Manager { 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 @@ -270,86 +332,91 @@ impl Manager { return encode(Status { 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 { 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 matches!( request.method.as_str(), - "remote_revoke" | "remote_cancel_pairing" - ) || (request.method == "remote_enable" && request.params == serde_json::json!([false])) - { + "remote_revoke" | "remote_cancel_pairing" | "remote_enable" + ) { let account = self .backend .saved_user_id() .ok_or("No local account is selected")?; - let current = self - .node - .lock() - .await - .clone() - .filter(|node| node.account == account); - if let Some(node) = ¤t { - *node.window.lock().await = None; - } - if request.method == "remote_cancel_pairing" { - if let Some(node) = ¤t { - let _store = node.store.lock().unwrap(); - node.pairing_generation.fetch_add(1, Ordering::SeqCst); - } - return Ok(serde_json::Value::Null); - } - let mut disk; - let mut memory; - let store = if let Some(node) = ¤t { - memory = node.store.lock().unwrap(); - &mut *memory + 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 { - disk = self.store(&account)?; - &mut disk + None }; - if let Some(node) = ¤t { + // 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_revoke" { - 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) = ¤t { - let _ = node.revoked.send(peer); - } - } else { - store.set_enabled(false)?; - self.resident.store(false, Ordering::Release); - if let Some(node) = ¤t { - let _ = node.revoked.send(node.endpoint.id()); + 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_enable" => { - let (enabled,): (bool,) = - serde_json::from_value(request.params).map_err(|_| "Invalid remote setting")?; - node.store.lock().unwrap().set_enabled(enabled)?; - self.resident.store(enabled, Ordering::Release); - if !enabled { - *node.window.lock().await = None; - } - // Existing control observes this state immediately, including - // when discovery is unavailable. No cloud write is authority. - let _ = node.revoked.send(node.endpoint.id()); - node.publish().await?; - Ok(serde_json::Value::Null) - } "remote_begin_pairing" => serde_json::to_value(node.begin_pairing().await?) .map_err(|_| "Could not display pairing window".into()), "remote_discover" => { @@ -433,6 +500,9 @@ impl Node { } } 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(); @@ -461,14 +531,29 @@ impl Node { .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()); } - let mut window = self.window.lock().await; - if window.as_ref().is_some_and(|w| w.is_open(&w.id)) { - return Err("Close the current pairing window before starting another".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 address = self.endpoint.addr(); @@ -488,13 +573,19 @@ impl Node { ) .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().unwrap(), + code: next.code().ok_or("Pairing window expired")?, window: Uuid::from_bytes(next.id), expires_in: next.remaining().as_secs(), }; @@ -563,10 +654,6 @@ impl Node { }) .await?; self.ensure_valid().await?; - self.store - .lock() - .unwrap() - .reserve_attempt(peer, *window.as_bytes())?; let controller = self.endpoint.id(); let proof = pairing::Controller::new(code, &self.context(&controller, &peer, window.as_bytes()))?; @@ -593,12 +680,24 @@ impl Node { ) .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 (message, host_tag): (Vec, Vec) = wire::read(&mut rx, 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; @@ -612,9 +711,10 @@ impl Node { wire::write(&mut tx, &tag.as_slice(), wire::MAX_PREFACE) .await .map_err(|e| e.to_string())?; - let grant: [u8; 16] = wire::read(&mut rx, wire::MAX_PREFACE) + 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()); } @@ -639,7 +739,7 @@ impl Node { .unwrap() .peers() .get(&peer.to_string()) - .is_some_and(|p| p.pending_confirmation.is_some()) + .is_some_and(|p| p.pending_confirmation == Some(*window.as_bytes())) { return self.reconcile(peer).await; } @@ -678,10 +778,10 @@ impl Node { ) .await .map_err(|e| e.to_string())?; - let confirmed: Result<[u8; 16], String> = wire::read(&mut rx, wire::MAX_PREFACE) + let confirmed: Result<[u8; 16], wire::Refusal> = wire::read(&mut rx, wire::MAX_PREFACE) .await .map_err(|e| e.to_string())?; - if confirmed? != window { + if confirmed.map_err(|e| e.message)? != window { return Err("Invalid grant acknowledgement".into()); } let _account = self.backend.account_gate.read().await; @@ -716,15 +816,17 @@ impl Node { { return Err("Pairing window is closed".into()); } - let admitted = self - .session - .check_remote_admission(CheckRemoteAdmission { + 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?; + }), + ) + .await + .map_err(|_| "Pairing admission check timed out; try again")??; self.ensure_valid().await?; if !admitted.valid { return Err("Pairing admission was rejected".into()); @@ -744,7 +846,7 @@ impl Node { pairing::Execution::new(code, &self.context(&peer, &execution, &window), message)?; wire::write( tx, - &(proof.message(), proof.confirmation().as_slice()), + &Ok::<_, wire::Refusal>((proof.message(), proof.confirmation().as_slice())), wire::MAX_PREFACE, ) .await @@ -778,7 +880,8 @@ impl Node { }, )?; } - wire::write(tx, &window, wire::MAX_PREFACE) + drop(pending_window); + wire::write(tx, &Ok::<_, wire::Refusal>(window), wire::MAX_PREFACE) .await .map_err(|e| e.to_string())?; Ok(()) diff --git a/apps/maple-agent/app/src/remote/streams.rs b/apps/maple-agent/app/src/remote/streams.rs index c1521f375..b12464a3d 100644 --- a/apps/maple-agent/app/src/remote/streams.rs +++ b/apps/maple-agent/app/src/remote/streams.rs @@ -2,6 +2,7 @@ //! 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}; @@ -66,12 +67,13 @@ impl Header { .map_err(|e| e.to_string()) } pub async fn body(self, rx: &mut RecvStream) -> Result { - let params = tokio::time::timeout( + let params = wire::read_progress( + rx, + body_limit(self.method), transfer_timeout(self.method), - wire::read(rx, body_limit(self.method)), + false, ) .await - .map_err(|_| "Remote request transfer timed out")? .map_err(|e| e.to_string())?; Ok(Command { id: self.id, @@ -98,19 +100,66 @@ fn transfer_timeout(method: Method) -> Duration { } } +// 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> { - tokio::time::timeout(TRANSFER_TIMEOUT, async { - wire::write(tx, message, wire::MAX_FRAME) - .await - .map_err(|e| e.to_string())?; - tx.finish().map_err(|_| "Remote reply closed")?; - tx.stopped() - .await - .map_err(|_| "Remote reply disconnected")?; - Ok(()) - }) - .await - .map_err(|_| "Remote reply transfer timed out")? + 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 { @@ -134,18 +183,26 @@ pub async fn call(connection: &Connection, command: Command) -> Result(&mut rx, wire::MAX_FRAME).await { + 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(wire::Error::Disconnected) => Err("Remote reply disconnected".into()), + 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(), }), diff --git a/apps/maple-agent/app/src/remote/streams/tests.rs b/apps/maple-agent/app/src/remote/streams/tests.rs index 9f6026604..fbbfc1a06 100644 --- a/apps/maple-agent/app/src/remote/streams/tests.rs +++ b/apps/maple-agent/app/src/remote/streams/tests.rs @@ -259,3 +259,54 @@ async fn urgent_body_is_bounded_and_reply_identity_is_checked() { 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 index 12b63d3b6..470c5712c 100644 --- a/apps/maple-agent/app/src/remote/wire.rs +++ b/apps/maple-agent/app/src/remote/wire.rs @@ -5,7 +5,7 @@ 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 = 2; +pub const VERSION: u32 = 3; pub const MAX_FRAME: usize = 16 * 1024 * 1024; pub const MAX_PREFACE: usize = 4096; @@ -29,19 +29,81 @@ pub enum Mode { Pair { window: [u8; 16], admission: String }, } -impl Hello { - pub fn check_version(&self) -> Result<(), Error> { - if self.minimum_version == 0 - || self.minimum_version > self.version - || self.minimum_version > VERSION - || self.version < VERSION - { - return Err(Error::Incompatible); +// 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 { @@ -128,12 +190,14 @@ pub struct Command { #[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.", @@ -190,6 +254,74 @@ pub async fn write_encoded( 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::*; @@ -270,4 +402,73 @@ mod tests { 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_v3.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", "event"] { + check::(&examples[key]); + } + assert_eq!(examples["hello"]["version"], VERSION); + } } diff --git a/apps/maple-agent/app/src/remote/wire_v3.json b/apps/maple-agent/app/src/remote/wire_v3.json new file mode 100644 index 000000000..71185b251 --- /dev/null +++ b/apps/maple-agent/app/src/remote/wire_v3.json @@ -0,0 +1,10 @@ +{ + "hello": {"version":3,"minimum_version":3,"account":"account","project":"project","deployment":"local","channel":"unpackaged","mode":{"kind":"control"}}, + "pair": {"version":3,"minimum_version":3,"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"}}}, + "event": {"Event":"HostReconnected"} +} diff --git a/apps/maple-agent/app/src/tray.rs b/apps/maple-agent/app/src/tray.rs index f0d696d1a..7edfc18d5 100644 --- a/apps/maple-agent/app/src/tray.rs +++ b/apps/maple-agent/app/src/tray.rs @@ -50,3 +50,28 @@ impl StatusIcon { 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/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index f5580beaf..ed7b79b93 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -342,6 +342,13 @@ fn draft_mcp_rows( rows } +struct UnresolvedSend { + session: String, + text: String, + images: Vec, + from_composer: bool, +} + pub struct ChatScreen { backend: Arc, user_id: String, @@ -504,6 +511,8 @@ 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. @@ -1132,6 +1141,8 @@ 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(), @@ -2880,6 +2891,7 @@ impl ChatScreen { { self.start_usage_poller(session_id, cx); } + self.check_unresolved_sends(cx); cx.notify(); } @@ -4249,6 +4261,19 @@ impl ChatScreen { queue_id: Option, cx: &mut Context, ) { + if self + .unresolved_sends + .values() + .any(|send| send.session == session_id) + { + 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(); @@ -4307,7 +4332,7 @@ impl ChatScreen { } cx.notify(); self.call( - async move { backend.send_message(&user_id, request).await }, + async move { backend.send_message_identified(&user_id, request, outgoing_id).await }, cx, move |this, result, cx| match result { Ok(run_id) => { @@ -4319,13 +4344,21 @@ impl ChatScreen { } } Err(message) => { + if message.starts_with("Send outcome unknown") { + this.unresolved_sends.insert(send_id, UnresolvedSend { + session: session_id, text, images: drafts, from_composer, + }); + this.notice = Some("Delivery is uncertain. Your message and images are retained while Maple checks the host; nothing will be resent.".into()); + this.awaiting_first_token = false; + this.check_unresolved_sends(cx); + return; + } // 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() { @@ -4343,6 +4376,85 @@ impl ChatScreen { ); } + 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.keys().cloned().collect(); + let backend = self.backend.clone(); + self.call( + async move { + let mut results = Vec::new(); + for id in ids { + 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; + 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::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("The host has no successful receipt. Your draft is restored; check the task before sending again.".into()); + } else { + self.notice = Some("An uncertain draft is retained. Open its task with an empty composer to recover it before sending again.".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, @@ -4976,6 +5088,7 @@ 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); diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 2738d114c..fe7b59b4b 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -6603,4 +6603,42 @@ 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 { + 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()); + }); + } } diff --git a/apps/maple-agent/app/src/ui/remote.rs b/apps/maple-agent/app/src/ui/remote.rs index 3413caf1d..565014283 100644 --- a/apps/maple-agent/app/src/ui/remote.rs +++ b/apps/maple-agent/app/src/ui/remote.rs @@ -266,13 +266,22 @@ impl RemoteScreen { } else { Vec::new() }; - Ok((status, devices)) + 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)) => { + 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 { @@ -382,6 +391,10 @@ impl Render for RemoteScreen { .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( @@ -400,6 +413,7 @@ impl RemoteScreen { .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") diff --git a/apps/maple-agent/app/src/ui/remote/tests.rs b/apps/maple-agent/app/src/ui/remote/tests.rs index c43148390..5870f5ca7 100644 --- a/apps/maple-agent/app/src/ui/remote/tests.rs +++ b/apps/maple-agent/app/src/ui/remote/tests.rs @@ -11,6 +11,8 @@ fn screen(cx: &mut TestAppContext) -> Entity { backend, status: Some(Status { enabled: true, + ready: true, + error: None, endpoint: Some("local".into()), peers: Default::default(), }), diff --git a/apps/maple-agent/app/src/ui/titlebar.rs b/apps/maple-agent/app/src/ui/titlebar.rs index 930c9522d..db2358b89 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| { - cx.quit(); + .child(control("title-close", "✕", |window, cx| { + crate::desktop::close_window(window, cx); })), ) .into_any_element() 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 2e588567b..c73345a66 100644 --- a/apps/maple-agent/crates/maple-agent/src/maple_api.rs +++ b/apps/maple-agent/crates/maple-agent/src/maple_api.rs @@ -417,14 +417,14 @@ impl MapleApiSession { let snapshot = self.client_snapshot().await?; let response = snapshot.client.register_remote_device(request).await; self.record_refresh(&snapshot).await?; - response.map_err(map_sdk_error) + 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_sdk_error) + response.map_err(map_remote_error) } pub async fn request_remote_admission( @@ -434,7 +434,7 @@ impl MapleApiSession { let snapshot = self.client_snapshot().await?; let response = snapshot.client.request_remote_admission(request).await; self.record_refresh(&snapshot).await?; - response.map_err(map_sdk_error) + response.map_err(map_remote_error) } pub async fn check_remote_admission( @@ -444,7 +444,7 @@ impl MapleApiSession { let snapshot = self.client_snapshot().await?; let response = snapshot.client.check_remote_admission(request).await; self.record_refresh(&snapshot).await?; - response.map_err(map_sdk_error) + response.map_err(map_remote_error) } /// The account profile behind this session. @@ -852,6 +852,22 @@ 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 dcbc15e0a..639ca307b 100644 --- a/apps/maple-agent/docs/local-host.md +++ b/apps/maple-agent/docs/local-host.md @@ -20,16 +20,23 @@ 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. `maple-agent host stop` +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 stops that local host. +See [remote access](remote-access.md) for the no-tray fallback and controller role. +`maple-agent host stop` explicitly stops this profile and all its tasks, even with clients attached. -There is no login item, permanent service, remote listener or remote keepalive. +There is no login item or permanently installed service. Remote listeners are +opt-in, and a never-used remote profile does not initialize networking. 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. Explicit `maple-agent host stop` can stop the +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 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 index b391d4701..55070a979 100644 --- a/apps/maple-agent/docs/remote-access.md +++ b/apps/maple-agent/docs/remote-access.md @@ -20,7 +20,9 @@ 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. +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 @@ -49,7 +51,9 @@ 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). Explicit Quit stops that computer's +(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. @@ -62,9 +66,10 @@ These administer only the local computer. Never put a pairing code in argv or lo ## Transport and recovery Iroh 1.3.0 supplies authenticated QUIC, direct connectivity and relay fallback. -The minimal preset disables public address lookup and router port mapping. +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 wire version 2 +protocol keeps the `maple-agent/remote/1` ALPN family and requires wire version 3 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. @@ -77,11 +82,29 @@ 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. Stalled transfers are bounded, -and a failed connection recovers from an authoritative snapshot. Accepted mutations finish on the host; -transport loss never causes an automatic prompt replay. A stable send identity +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. +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. This is in-memory 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 diff --git a/apps/maple-agent/docs/remote-security.md b/apps/maple-agent/docs/remote-security.md index e3f926bd7..150c9195e 100644 --- a/apps/maple-agent/docs/remote-security.md +++ b/apps/maple-agent/docs/remote-security.md @@ -58,9 +58,10 @@ Evidence assessed: 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. -- Code buffers and returned session keys are zeroized where owned by Maple. The - dependency's internal ephemeral scalar/intermediate key is not explicitly - erased on drop. No claim is made of complete memory erasure or protection from +- 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. diff --git a/apps/maple-agent/docs/remote-validation.md b/apps/maple-agent/docs/remote-validation.md index 5956c7f8f..b7f1ace21 100644 --- a/apps/maple-agent/docs/remote-validation.md +++ b/apps/maple-agent/docs/remote-validation.md @@ -67,7 +67,7 @@ The full component checks below were rerun after this navigation change. 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. -Wire version 2 uses one bounded QUIC stream per request/reply and a separate +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 @@ -85,7 +85,51 @@ 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. -## Automated checks +## 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; the protocol is now 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, handshake refusals, wire examples, uncertain +draft retention, initialization cancellation and Stop with a lost acknowledgement. +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. + +## 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 / @@ -95,7 +139,7 @@ the user's existing desktop builds remained untouched. - 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 key-lease rejection. Migration down/up passed. + 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. @@ -122,8 +166,10 @@ These remain review/rollout work, rather than newly accepted design exceptions: 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, while its latest window tombstones are retained. - Reset cleanup and idle-record retention need resolution before hosted rollout. + 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. - 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. diff --git a/services/opensecret/docs/remote-enrollment.md b/services/opensecret/docs/remote-enrollment.md index f50d6a281..b8aec5eeb 100644 --- a/services/opensecret/docs/remote-enrollment.md +++ b/services/opensecret/docs/remote-enrollment.md @@ -5,7 +5,8 @@ The service supplies account-private discovery and admission, not device grants. `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/service share `sdk/rust/src/remote_registration_v1.json`. +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 @@ -17,8 +18,13 @@ 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. Window tombstones are retained to avoid a repeated W -extending its expiry after cleanup. Records contain no code, password verifier, +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 @@ -83,8 +89,9 @@ 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, account/key substitution, valid foreign-row swaps and relay -lease rejection. Two isolated Local daemons additionally exercise real Transport +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/src/aead_db_tamper_tests.rs b/services/opensecret/src/aead_db_tamper_tests.rs index c5f6cab03..4cd6f1a57 100644 --- a/services/opensecret/src/aead_db_tamper_tests.rs +++ b/services/opensecret/src/aead_db_tamper_tests.rs @@ -2534,3 +2534,122 @@ async fn db_remote_enrollment_rejects_cross_account_row_swaps_and_replayed_windo .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 851de7ee8..39fcf6e27 100644 --- a/services/opensecret/src/db.rs +++ b/services/opensecret/src/db.rs @@ -158,6 +158,10 @@ pub trait DBConnection { 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, @@ -790,6 +794,13 @@ impl DBConnection for PostgresConnection { 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, diff --git a/services/opensecret/src/models/remote_enrollment.rs b/services/opensecret/src/models/remote_enrollment.rs index 8ab351082..b7b2c8fcb 100644 --- a/services/opensecret/src/models/remote_enrollment.rs +++ b/services/opensecret/src/models/remote_enrollment.rs @@ -50,6 +50,19 @@ impl Record { .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 diff --git a/services/opensecret/src/security_invariants.rs b/services/opensecret/src/security_invariants.rs index d9ec5aaf1..9035f681b 100644 --- a/services/opensecret/src/security_invariants.rs +++ b/services/opensecret/src/security_invariants.rs @@ -1440,8 +1440,9 @@ fn collect_schema_tables_with_encrypted_columns(schema_source: &str) -> BTreeSet continue; } - let is_encrypted_column = - trimmed.contains("_enc ->") || trimmed.contains("encrypted_code ->"); + let is_encrypted_column = trimmed.contains("_enc ->") + || trimmed.contains("encrypted_code ->") + || trimmed.contains("ciphertext ->"); 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/remote/mod.rs b/services/opensecret/src/web/remote/mod.rs index 44c8e5bc7..98ecafef3 100644 --- a/services/opensecret/src/web/remote/mod.rs +++ b/services/opensecret/src/web/remote/mod.rs @@ -28,6 +28,9 @@ use crate::{ 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() @@ -197,6 +200,37 @@ fn validate_registration( .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, @@ -205,6 +239,9 @@ pub(crate) fn register_inner( 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| { From 1905501b311cc8bd6ba1853505f4791a5326ecbb Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 20 Oct 2026 07:29:45 +0000 Subject: [PATCH 09/10] Finish remote send and desktop lifecycle recovery --- apps/maple-agent/app/src/desktop.rs | 91 +++++++-- apps/maple-agent/app/src/host.rs | 30 ++- apps/maple-agent/app/src/host/receipts.rs | 87 ++++++-- .../app/src/host/recovery_tests.rs | 11 ++ apps/maple-agent/app/src/remote/control.rs | 75 ++++++- apps/maple-agent/app/src/remote/wire.rs | 24 ++- apps/maple-agent/app/src/remote/wire_v3.json | 10 - apps/maple-agent/app/src/remote/wire_v4.json | 187 ++++++++++++++++++ apps/maple-agent/app/src/ui/chat/mod.rs | 81 +++++--- apps/maple-agent/app/src/ui/chat/tests.rs | 48 +++++ apps/maple-agent/app/src/ui/remote.rs | 3 +- apps/maple-agent/app/src/ui/remote/tests.rs | 26 +++ apps/maple-agent/docs/local-host.md | 5 +- apps/maple-agent/docs/remote-access.md | 10 +- apps/maple-agent/docs/remote-validation.md | 48 ++++- 15 files changed, 639 insertions(+), 97 deletions(-) delete mode 100644 apps/maple-agent/app/src/remote/wire_v3.json create mode 100644 apps/maple-agent/app/src/remote/wire_v4.json diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 32ce8e4bc..36203d4ce 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -721,6 +721,7 @@ struct DesktopLifecycle { remote_enabled: Arc, can_background: bool, closing: std::rc::Rc>, + quit_stop_attempted: std::rc::Rc>, } impl gpui::Global for DesktopLifecycle {} @@ -743,24 +744,52 @@ fn quit_app(cx: &mut App) { 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| cx.quit()), - result => { - state.closing.set(false); - log::warn!("Could not stop Maple host on Quit: {result:?}"); - cx.update(|cx| { - for handle in cx.windows() { - let _ = handle.update(cx, |_, window, cx| { - let prompt = window.prompt(gpui::PromptLevel::Warning, "Maple could not stop its background host", - Some("Remote access may still be running. Keep Maple open and try Quit again."), &["OK"], cx); - cx.spawn(async move |_| { let _ = prompt.await; }).detach(); - }); - } - }); - } + 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(); + }) + .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. @@ -1161,14 +1190,21 @@ pub fn run() { 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| { + cx.on_app_quit(move |cx| { + let stop_attempted = cx + .try_global::() + .is_some_and(|state| state.quit_stop_attempted.get()); 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 @@ -1423,6 +1459,25 @@ mod tests { 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 5783cd026..70aa37475 100644 --- a/apps/maple-agent/app/src/host.rs +++ b/apps/maple-agent/app/src/host.rs @@ -529,6 +529,7 @@ 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 } @@ -792,9 +793,30 @@ async fn accept_scoped_handshake( #[cfg(feature = "desktop")] pub async fn request_stop(api_url: &str) -> Result<(), String> { let endpoint = ipc::Endpoint::new(api_url)?; - connect(&endpoint, ConnectionMode::Stop, false) - .await - .map(|_| ()) + 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) } + } + } } pub fn stop(api_url: &str) -> Result<(), String> { @@ -976,7 +998,7 @@ async fn recover_send(endpoint: &ipc::Endpoint, id: &str) -> Result break, + receipts::Outcome::Pending | receipts::Outcome::Interrupted => break, } } Message::Event(_) => {} diff --git a/apps/maple-agent/app/src/host/receipts.rs b/apps/maple-agent/app/src/host/receipts.rs index f7b7aaf1b..03b8b7779 100644 --- a/apps/maple-agent/app/src/host/receipts.rs +++ b/apps/maple-agent/app/src/host/receipts.rs @@ -12,6 +12,8 @@ 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), } @@ -30,23 +32,40 @@ impl Receipts { );", ) .map_err(|e| e.to_string())?; - let has_account = connection + 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())? - .iter() - .any(|name| name == "account"); - if !has_account { + .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> { @@ -58,15 +77,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)> = connection + let existing: Option<(String, Option, bool)> = connection .query_row( - "SELECT fingerprint, result FROM sends WHERE id=?1", + "SELECT fingerprint, result, interrupted FROM sends WHERE id=?1", [id], - |row| Ok((row.get(0)?, row.get(1)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .optional() .map_err(|e| e.to_string())?; - if let Some((saved, result)) = existing { + if let Some((saved, result, interrupted)) = existing { if saved != fingerprint { return Err("Send identity was reused for different content".into()); } @@ -77,7 +96,11 @@ impl Receipts { .map(Some) .map_err(|e| e.to_string()) }) - .unwrap_or(Ok(Some(Outcome::Pending))); + .unwrap_or(Ok(Some(if interrupted { + Outcome::Interrupted + } else { + Outcome::Pending + }))); } connection .execute( @@ -127,19 +150,22 @@ impl Receipts { } pub fn outcome(&self, id: &str) -> Result { - let result: Option> = self + let result: Option<(Option, bool)> = self .0 .lock() .unwrap() - .query_row("SELECT result FROM sends WHERE id=?1", [id], |row| { - row.get(0) - }) + .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) => Ok(Outcome::Pending), - Some(Some(result)) => serde_json::from_str(&result) + Some((None, true)) => Ok(Outcome::Interrupted), + Some((None, false)) => Ok(Outcome::Pending), + Some((Some(result), _)) => serde_json::from_str(&result) .map(Outcome::Finished) .map_err(|e| e.to_string()), } @@ -169,6 +195,35 @@ mod tests { 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 76391be95..3c2985fcd 100644 --- a/apps/maple-agent/app/src/host/recovery_tests.rs +++ b/apps/maple-agent/app/src/host/recovery_tests.rs @@ -449,3 +449,14 @@ async fn explicit_stop_survives_a_client_that_leaves_before_the_acknowledgement( .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/remote/control.rs b/apps/maple-agent/app/src/remote/control.rs index f5bc19fab..1a5c34041 100644 --- a/apps/maple-agent/app/src/remote/control.rs +++ b/apps/maple-agent/app/src/remote/control.rs @@ -13,6 +13,22 @@ 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(); @@ -231,7 +247,7 @@ async fn serve_request( Ok(()) } -fn remote_event(event: &AgentServiceEvent) -> bool { +pub(super) fn remote_event(event: &AgentServiceEvent) -> bool { // Exhaustive on purpose: new local events need an explicit remote decision. match event { AgentServiceEvent::RuntimeStatus(_) @@ -302,14 +318,7 @@ impl Manager { wire::write(&mut tx, &node.hello(wire::Mode::Control), wire::MAX_PREFACE) .await .map_err(|e| e.to_string())?; - let accepted: Result<(), wire::Refusal> = tokio::time::timeout( - Duration::from_secs(10), - wire::read(&mut rx, wire::MAX_PREFACE), - ) - .await - .map_err(|_| "Remote handshake timed out")? - .map_err(|e| format!("Remote access ended: {e}"))?; - accepted.map_err(wire::Refusal::into_error)?; + read_control_ack(&mut rx).await?; node.ensure_valid().await?; tx.finish().map_err(|_| "Remote handshake closed")?; Ok(Link { @@ -433,3 +442,51 @@ async fn local_send( .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/wire.rs b/apps/maple-agent/app/src/remote/wire.rs index 470c5712c..bcf3c7a0b 100644 --- a/apps/maple-agent/app/src/remote/wire.rs +++ b/apps/maple-agent/app/src/remote/wire.rs @@ -5,7 +5,7 @@ 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 = 3; +pub const VERSION: u32 = 4; pub const MAX_FRAME: usize = 16 * 1024 * 1024; pub const MAX_PREFACE: usize = 4096; @@ -456,7 +456,7 @@ mod tests { #[test] fn shipped_wire_examples_are_stable() { let examples: serde_json::Value = - serde_json::from_str(include_str!("wire_v3.json")).unwrap(); + 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); @@ -466,9 +466,27 @@ mod tests { check::(&examples["header"]); check::(&examples["command"]); check::>(&examples["refusal"]); - for key in ["image", "failure", "event"] { + 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_v3.json b/apps/maple-agent/app/src/remote/wire_v3.json deleted file mode 100644 index 71185b251..000000000 --- a/apps/maple-agent/app/src/remote/wire_v3.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "hello": {"version":3,"minimum_version":3,"account":"account","project":"project","deployment":"local","channel":"unpackaged","mode":{"kind":"control"}}, - "pair": {"version":3,"minimum_version":3,"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"}}}, - "event": {"Event":"HostReconnected"} -} diff --git a/apps/maple-agent/app/src/remote/wire_v4.json b/apps/maple-agent/app/src/remote/wire_v4.json new file mode 100644 index 000000000..18af44762 --- /dev/null +++ b/apps/maple-agent/app/src/remote/wire_v4.json @@ -0,0 +1,187 @@ +{ + "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/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index ed7b79b93..d4dbc4161 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -343,12 +343,22 @@ fn draft_mcp_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, @@ -4264,7 +4274,7 @@ impl ChatScreen { if self .unresolved_sends .values() - .any(|send| send.session == session_id) + .any(|send| send.session == session_id && send.awaiting_receipt()) { self.notice = Some("Checking the previous send before another message can be sent.".into()); @@ -4344,33 +4354,18 @@ impl ChatScreen { } } Err(message) => { - if message.starts_with("Send outcome unknown") { - this.unresolved_sends.insert(send_id, UnresolvedSend { - session: session_id, text, images: drafts, from_composer, - }); + 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.awaiting_first_token = false; this.check_unresolved_sends(cx); - return; - } - // 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 - && 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.notice = Some(format!("Send failed: {message}").into()); + this.push_local_error("Send failed", &message, cx); + this.apply_send_receipt(&send_id, Some(crate::host::receipts::Outcome::Finished(Err(message))), cx); } - this.awaiting_first_token = false; } }, ); @@ -4382,12 +4377,26 @@ impl ChatScreen { return; } self.checking_sends = true; - let ids: Vec<_> = self.unresolved_sends.keys().cloned().collect(); + 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 in ids { + 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()), @@ -4418,12 +4427,18 @@ impl ChatScreen { 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::Finished(Err(_))) => { + Some(Outcome::Missing | Outcome::Interrupted | Outcome::Finished(Err(_))) => { let Some(send) = self.unresolved_sends.get(id) else { return; }; @@ -4444,9 +4459,17 @@ impl ChatScreen { composer.update(cx, |input, cx| input.set_text(&send.text, cx)); } self.draft_images = send.images; - self.notice = Some("The host has no successful receipt. Your draft is restored; check the task before sending again.".into()); + 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("An uncertain draft is retained. Open its task with an empty composer to recover it before sending again.".into()); + 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()); } } _ => { diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index fe7b59b4b..216d5f96d 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -6614,6 +6614,7 @@ mod state_tests { chat.unresolved_sends.insert( "send-original".into(), UnresolvedSend { + outcome: None, session: "s1".into(), text: "original message".into(), from_composer: true, @@ -6641,4 +6642,51 @@ mod state_tests { 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/remote.rs b/apps/maple-agent/app/src/ui/remote.rs index 565014283..c579a52d9 100644 --- a/apps/maple-agent/app/src/ui/remote.rs +++ b/apps/maple-agent/app/src/ui/remote.rs @@ -132,7 +132,8 @@ impl RemoteScreen { if self.status.as_ref() == Some(&status) { return; } - let paired_controller = self.local_code.is_some() + let paired_controller = self.status.is_some() + && self.local_code.is_some() && status.peers.iter().any(|(key, peer)| { peer.controls_this_computer && self diff --git a/apps/maple-agent/app/src/ui/remote/tests.rs b/apps/maple-agent/app/src/ui/remote/tests.rs index 5870f5ca7..88b777337 100644 --- a/apps/maple-agent/app/src/ui/remote/tests.rs +++ b/apps/maple-agent/app/src/ui/remote/tests.rs @@ -138,3 +138,29 @@ fn incoming_pairing_closes_the_code_but_an_outgoing_grant_does_not(cx: &mut Test 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/docs/local-host.md b/apps/maple-agent/docs/local-host.md index 639ca307b..2e1dcd25f 100644 --- a/apps/maple-agent/docs/local-host.md +++ b/apps/maple-agent/docs/local-host.md @@ -22,7 +22,10 @@ An unexpected desktop connection loss instead reserves thirty seconds for recove 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 stops that local host. +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` explicitly stops this profile and all its tasks, even with clients attached. diff --git a/apps/maple-agent/docs/remote-access.md b/apps/maple-agent/docs/remote-access.md index 55070a979..178fd9909 100644 --- a/apps/maple-agent/docs/remote-access.md +++ b/apps/maple-agent/docs/remote-access.md @@ -69,8 +69,7 @@ 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 wire version 3 -or newer, independent of application builds. Older peers receive an explicit +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. @@ -94,7 +93,12 @@ 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. This is in-memory recovery, not persistence across desktop crashes. +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 diff --git a/apps/maple-agent/docs/remote-validation.md b/apps/maple-agent/docs/remote-validation.md index b7f1ace21..4b3a9d638 100644 --- a/apps/maple-agent/docs/remote-validation.md +++ b/apps/maple-agent/docs/remote-validation.md @@ -89,13 +89,16 @@ the user's existing desktop builds remained untouched. The branch was rebased onto `339d306e`, including the merged Windows ACP streaming-test correction (#1174). The focused fixes keep the existing -daemon/pairing architecture; the protocol is now version 3. +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, handshake refusals, wire examples, uncertain -draft retention, initialization cancellation and Stop with a lost acknowledgement. +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, @@ -129,6 +132,41 @@ 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. + ## Initial October 19 automated checks - Full Agent `just ci`: all four Clippy configurations, workspace build/tests and @@ -170,6 +208,10 @@ These remain review/rollout work, rather than newly accepted design exceptions: 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. From f298910f0d06742d19901371f3bec0c335fee796 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:53:30 +0000 Subject: [PATCH 10/10] Give remote computers persistent names and hostname defaults --- apps/maple-agent/Cargo.lock | 12 ++ apps/maple-agent/app/Cargo.toml | 1 + apps/maple-agent/app/src/backend/local.rs | 30 +++- apps/maple-agent/app/src/remote/control.rs | 2 + apps/maple-agent/app/src/remote/manager.rs | 66 ++++++++- apps/maple-agent/app/src/remote/mod.rs | 2 + apps/maple-agent/app/src/remote/store.rs | 156 ++++++++++++++++++++ apps/maple-agent/app/src/remote/wire.rs | 1 + apps/maple-agent/app/src/ui/remote.rs | 67 ++++++++- apps/maple-agent/app/src/ui/remote/tests.rs | 50 +++++++ apps/maple-agent/docs/remote-access.md | 12 +- apps/maple-agent/docs/remote-validation.md | 10 ++ 12 files changed, 402 insertions(+), 7 deletions(-) diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index 5bf01aed0..29b8fd776 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -4342,6 +4342,17 @@ 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" @@ -5668,6 +5679,7 @@ dependencies = [ "env_logger", "gpui", "gpui_platform", + "hostname", "httparse", "image", "iroh", diff --git a/apps/maple-agent/app/Cargo.toml b/apps/maple-agent/app/Cargo.toml index f6a7fb790..6c64efa6b 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -71,6 +71,7 @@ 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" diff --git a/apps/maple-agent/app/src/backend/local.rs b/apps/maple-agent/app/src/backend/local.rs index f47de3da8..20a062c28 100644 --- a/apps/maple-agent/app/src/backend/local.rs +++ b/apps/maple-agent/app/src/backend/local.rs @@ -2672,7 +2672,7 @@ mod tests { let receipts = Arc::new( crate::host::receipts::Receipts::open(&root.path().join("receipts.db")).unwrap(), ); - let remote = crate::remote::manager::Manager::new(backend, events, receipts); + let remote = crate::remote::manager::Manager::new(backend.clone(), events, receipts); remote.restore().await; let status = remote .dispatch(crate::host::protocol::Request { @@ -2687,6 +2687,34 @@ mod tests { 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] diff --git a/apps/maple-agent/app/src/remote/control.rs b/apps/maple-agent/app/src/remote/control.rs index 1a5c34041..39e4c58d9 100644 --- a/apps/maple-agent/app/src/remote/control.rs +++ b/apps/maple-agent/app/src/remote/control.rs @@ -56,6 +56,8 @@ pub(super) fn listen(node: Arc) { 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 } => { diff --git a/apps/maple-agent/app/src/remote/manager.rs b/apps/maple-agent/app/src/remote/manager.rs index 0853c91c2..335ce49d9 100644 --- a/apps/maple-agent/app/src/remote/manager.rs +++ b/apps/maple-agent/app/src/remote/manager.rs @@ -59,6 +59,7 @@ pub struct Node { #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Status { + pub name: String, pub enabled: bool, pub endpoint: Option, pub ready: bool, @@ -217,6 +218,7 @@ impl Manager { &key, (&account, &project, &deployment), RemoteAddresses::default(), + store.name().to_owned(), store.enabled(), None, ) @@ -330,6 +332,7 @@ impl Manager { &account, ) { return encode(Status { + name: super::store::default_name().into(), enabled: false, endpoint: None, ready: false, @@ -340,6 +343,7 @@ impl Manager { 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 @@ -349,6 +353,32 @@ impl Manager { 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" @@ -424,6 +454,7 @@ impl Manager { 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() @@ -506,6 +537,7 @@ impl Node { 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() @@ -525,6 +557,7 @@ impl Node { relays: address.relay_urls().map(ToString::to_string).collect(), direct: address.ip_addrs().map(ToString::to_string).collect(), }, + name, enabled, window, ) @@ -556,6 +589,7 @@ impl Node { 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, @@ -565,6 +599,7 @@ impl Node { 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), @@ -614,6 +649,7 @@ impl Node { } }; validate_devices(&list.devices)?; + self.remember_peer_names(&list.devices); let device = list .devices .into_iter() @@ -637,6 +673,33 @@ impl Node { } 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?; @@ -893,6 +956,7 @@ async fn register( key: &SecretKey, (account, project, deployment): (&str, &str, &str), addresses: RemoteAddresses, + name: String, enabled: bool, window: Option, ) -> Result { @@ -902,7 +966,7 @@ async fn register( deployment: deployment.into(), channel: crate::profile::PROFILE.into(), endpoint: key.public().to_string(), - name: "Maple computer".into(), + name, addresses, accepting_connections: enabled, window, diff --git a/apps/maple-agent/app/src/remote/mod.rs b/apps/maple-agent/app/src/remote/mod.rs index 419799e18..a7be0e379 100644 --- a/apps/maple-agent/app/src/remote/mod.rs +++ b/apps/maple-agent/app/src/remote/mod.rs @@ -7,3 +7,5 @@ mod wire; pub mod control; pub mod manager; + +pub(crate) use store::validate_name; diff --git a/apps/maple-agent/app/src/remote/store.rs b/apps/maple-agent/app/src/remote/store.rs index 32dc947c6..2444e7292 100644 --- a/apps/maple-agent/app/src/remote/store.rs +++ b/apps/maple-agent/app/src/remote/store.rs @@ -12,6 +12,50 @@ 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 { @@ -32,6 +76,8 @@ struct Record { scope: String, secret: [u8; 32], enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option, peers: BTreeMap, #[serde(default)] attempted_windows: BTreeMap>, @@ -93,6 +139,7 @@ impl Store { scope: scope.clone(), secret: SecretKey::generate().to_bytes(), enabled: false, + name: None, peers: BTreeMap::new(), attempted_windows: BTreeMap::new(), }; @@ -108,6 +155,9 @@ impl Store { { 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::() @@ -140,6 +190,46 @@ impl Store { 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 } @@ -249,6 +339,72 @@ impl Store { #[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(); diff --git a/apps/maple-agent/app/src/remote/wire.rs b/apps/maple-agent/app/src/remote/wire.rs index bcf3c7a0b..d9bde29ad 100644 --- a/apps/maple-agent/app/src/remote/wire.rs +++ b/apps/maple-agent/app/src/remote/wire.rs @@ -345,6 +345,7 @@ mod tests { "detach", "stop", "remote_enable", + "remote_set_name", "remote_pair", "transcribe_audio", "synthesize_speech", diff --git a/apps/maple-agent/app/src/ui/remote.rs b/apps/maple-agent/app/src/ui/remote.rs index c579a52d9..b866f5331 100644 --- a/apps/maple-agent/app/src/ui/remote.rs +++ b/apps/maple-agent/app/src/ui/remote.rs @@ -41,6 +41,7 @@ pub struct RemoteScreen { code_input: Entity, names: std::collections::HashMap, renaming: Option<(String, Entity)>, + local_name_input: Option>, local_code: Option<(String, Instant)>, error: Option, notice: Option, @@ -77,6 +78,7 @@ impl RemoteScreen { code_input, names, renaming: None, + local_name_input: None, local_code: None, error: None, notice: None, @@ -217,6 +219,42 @@ impl RemoteScreen { 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; @@ -261,12 +299,12 @@ impl RemoteScreen { let bridge = task::call( &self.backend, async move { - let status: Status = backend.remote_setting("remote_status", ()).await?; 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)) @@ -348,6 +386,9 @@ impl RemoteScreen { ); } 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); } @@ -409,6 +450,28 @@ 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")) @@ -595,7 +658,7 @@ impl RemoteScreen { (false, true) => "Can use this computer’s projects and tools", (false, false) => "Pairing needs confirmation", }))) - .child(widgets::ghost_button(format!("rename-{key}")).aria_label("Rename").child("Rename") + .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())))))) diff --git a/apps/maple-agent/app/src/ui/remote/tests.rs b/apps/maple-agent/app/src/ui/remote/tests.rs index 88b777337..85ed4eb4b 100644 --- a/apps/maple-agent/app/src/ui/remote/tests.rs +++ b/apps/maple-agent/app/src/ui/remote/tests.rs @@ -10,6 +10,7 @@ fn screen(cx: &mut TestAppContext) -> Entity { cx.new(|cx| RemoteScreen { backend, status: Some(Status { + name: "Test Mac".into(), enabled: true, ready: true, error: None, @@ -21,6 +22,7 @@ fn screen(cx: &mut TestAppContext) -> Entity { 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, @@ -44,6 +46,54 @@ fn device(key: &str) -> RemoteDevice { } } +#[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); diff --git a/apps/maple-agent/docs/remote-access.md b/apps/maple-agent/docs/remote-access.md index 178fd9909..e6ad81eb1 100644 --- a/apps/maple-agent/docs/remote-access.md +++ b/apps/maple-agent/docs/remote-access.md @@ -29,9 +29,15 @@ 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. In Remote computers settings, -**Rename** changes the computer's label on this desktop; it does not change its -identity or pairing grant. +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 diff --git a/apps/maple-agent/docs/remote-validation.md b/apps/maple-agent/docs/remote-validation.md index 4b3a9d638..bdfa9b722 100644 --- a/apps/maple-agent/docs/remote-validation.md +++ b/apps/maple-agent/docs/remote-validation.md @@ -167,6 +167,16 @@ 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