From 1e85b4d05fc68937ac3f19a4339f2b274d7fd057 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 10:07:38 -0800 Subject: [PATCH 1/4] SSM port-forward transport to the agent (phase 2b), and a Fargate-sized chown host::ssm opens one AWS-StartPortForwardingSession to triple-c-agent in an ECS task by running session-manager-plugin, supervises it, and reopens it on the same loopback port when it drops, so an AgentEndpoint stays valid. The StartSession token reaches the plugin through its environment, never argv; the plugin gets the caller's credentials (and no other AWS_* vars) so it can resume its own data channel. Unit tests drive the supervisor with a fake plugin; ignored live tests run against a real task. entrypoint.sh: chown only what is not already claude's. On Fargate, /home/claude is the image's own copy, and chown -R copied all of it up: the agent came up ~5.5 min after the container started; now 4 s. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/Cargo.lock | 594 +++++++++++++++++- app/src-tauri/Cargo.toml | 7 + app/src-tauri/src/host/mod.rs | 4 + app/src-tauri/src/host/ssm.rs | 729 +++++++++++++++++++++++ app/src-tauri/src/host/ssm/live_tests.rs | 192 ++++++ app/src-tauri/src/host/ssm/tests.rs | 291 +++++++++ container/entrypoint.sh | 11 +- 7 files changed, 1801 insertions(+), 27 deletions(-) create mode 100644 app/src-tauri/src/host/ssm.rs create mode 100644 app/src-tauri/src/host/ssm/live_tests.rs create mode 100644 app/src-tauri/src/host/ssm/tests.rs diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index 663962f..0bd80c2 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -14,7 +14,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -275,6 +275,49 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-config" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ec023cac0d0c0432e606a2d14f04eb70d09c71a739a406141e7be6b923ab95" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http", + "sha1", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "576e8da35751dc85409ec1cd15999f692c277a1fbe9336780fe6f15e49dd637d" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + [[package]] name = "aws-lc-rs" version = "1.18.1" @@ -298,6 +341,373 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "aws-runtime" +version = "1.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de8930c11353c72ae54aa24dc94d692df74d59814839cbaf8069d63dbcb4c519" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http", + "http-body", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-ecs" +version = "1.159.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecd8f39cf3e41848314347fc3b5242cbf14291258c9b8f4f61156735627726bf" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "fastrand", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssm" +version = "1.137.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5dffb9ec07fac5dbbf475396fbe1a4a319ee46528b7054662c518f18d00421" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "fastrand", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.121.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d414ba8f00c2b4b506893ded67827e63be88b5e6cce71b7c0ac8f96ce09bc5" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.123.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db26187b124bcc637641f7963d5200c5ec2409ea5b7730dd7e014ef4e0ab6f19" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-types", + "fastrand", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.126.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c667da7839295125cfe23280f798c07ba694a9fed191af804edb4bda09ac7f10" +dependencies = [ + "arc-swap", + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a4393e6addc3380632887b0998d06ad5f099ab5c478be0a3eeedecf8604161" +dependencies = [ + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http", + "percent-encoding", + "sha2 0.11.1", + "time", + "tracing", +] + +[[package]] +name = "aws-smithy-async" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de681a9df7ff79823b759ee17061076ec049cd61a8fa28ece4293d89c9f6964e" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-http" +version = "0.64.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19af609dd5edf8b056cd0bb001c706c8b3b1458055328b334ec13d7f46f6aaa8" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859a81364f8185349c584fb41cd8765a400cf982f447adada590cf008a9f3b85" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2", + "http", + "hyper", + "hyper-rustls", + "hyper-util", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.65.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45f9fc77ffe92c92c341165a01a565bd30c39ecf5aa8c0719b41719009604f9" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a416257d7b1b0f32a517a470a8c1dd354d8c4aa697ccab81a11a1e6cfb7418e3" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "tracing", +] + +[[package]] +name = "aws-smithy-query" +version = "0.64.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165f6265342a313ad6f7a1ac17a0cdba30c522039d3f41d8d6871ce8a2b34ecd" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "aws-smithy-xml", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bc688673e282f42d3152dcd9cacc1a9a75734d7cd39d02e58989276fc750f3c" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "bytes", + "fastrand", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0467a2414e84b1b01e37452896fb3562b93926e81c619eba6ecf27ce9e682a0e" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api-macros", + "aws-smithy-types", + "bytes", + "http", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-runtime-api-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce237f890a2283c9b328955c3b0918d2d5bf66a1cb6ceccd36a0047246fbc6e6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "aws-smithy-schema" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7ef30d39e131651359b60a1c560de9a9502a4c3754172ab2d6e8734fe21bcba" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-types", + "http", +] + +[[package]] +name = "aws-smithy-types" +version = "1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aff4bf014c512840b2bba80a20417b723cbd6a0a65c02e2d81f50b4268aedfc7" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http", + "http-body", + "http-body-util", + "itoa", + "pin-project-lite", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.64.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590fdc1a70546b2f7f2061c42eaf173fb6f26d935330d069edc04dbd8f01d924" +dependencies = [ + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0d970d3947bb064d2ae1d823efa186c1dbde88f88afa8f6549946e5d8deee" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-schema", + "aws-smithy-types", + "rustc_version", + "tracing", +] + [[package]] name = "axum" version = "0.8.8" @@ -371,6 +781,16 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "base64ct" version = "1.8.3" @@ -413,7 +833,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -425,6 +845,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -556,6 +985,16 @@ dependencies = [ "serde", ] +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "cairo-rs" version = "0.18.5" @@ -705,7 +1144,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -727,6 +1166,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "combine" version = "4.6.7" @@ -746,6 +1191,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be95d4a884358a21a199689323c29e9256b4a1b315e22f183d155422041d3a9a" + [[package]] name = "convert_case" version = "0.4.0" @@ -871,6 +1322,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.29.6" @@ -936,6 +1396,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.20.11" @@ -1158,11 +1627,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1387,7 +1868,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2887,6 +3368,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -2954,6 +3444,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -3002,6 +3501,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", @@ -4158,6 +4658,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "pango" version = "0.18.3" @@ -4742,7 +5248,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -4965,6 +5471,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -5118,7 +5630,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5176,7 +5688,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5369,9 +5881,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -5391,22 +5903,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -5565,7 +6077,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -5582,7 +6094,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -6013,7 +6536,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.117", "tauri-utils", "thiserror 2.0.18", @@ -6227,10 +6750,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6615,6 +7138,9 @@ version = "0.4.0" dependencies = [ "aes-gcm", "argon2", + "aws-config", + "aws-sdk-ecs", + "aws-sdk-ssm", "axum", "base64 0.22.1", "bollard", @@ -6633,7 +7159,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "similar", "tar", "tauri", @@ -6783,7 +7309,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -6806,6 +7332,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -6854,6 +7386,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vswhom" version = "0.1.0" @@ -7181,7 +7719,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7745,7 +8283,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.18", @@ -7790,6 +8328,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "yoke" version = "0.8.1" diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 8579095..cc2a08e 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -55,6 +55,13 @@ similar = "2" # Marketplace repos are fetched on the host into a bare cache (spec §3). # Blocking client + rustls: no git binary or OpenSSL needed on the host. gix = { version = "0.88", default-features = false, features = ["blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "credentials", "sha1"] } +# AWS host (docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md). The +# service crates' default `rustls` feature is the *legacy* hyper 0.14 / +# rustls 0.21 client, a second TLS stack next to the one reqwest already +# brings; `default-https-client` is the current one, so defaults are off. +aws-config = { version = "1.13.2", default-features = false, features = ["default-https-client", "rt-tokio", "credentials-process", "sso", "behavior-version-latest"] } +aws-sdk-ssm = { version = "1.137.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } +aws-sdk-ecs = { version = "1.159.0", default-features = false, features = ["default-https-client", "rt-tokio", "behavior-version-latest"] } # Linux only: WebKitGTK refuses getUserMedia until the embedder enables media # streams and answers the permission request (see src/linux_media.rs). Same diff --git a/app/src-tauri/src/host/mod.rs b/app/src-tauri/src/host/mod.rs index b41ba19..2a1bf8f 100644 --- a/app/src-tauri/src/host/mod.rs +++ b/app/src-tauri/src/host/mod.rs @@ -30,6 +30,10 @@ pub mod agent; pub mod lifecycle; pub mod sessions; +// Nothing in the app opens an SSM tunnel until the AWS host lands (phase 5 of +// the AWS spec); its tests exercise it meanwhile. Remove this allow then. +#[allow(dead_code)] +pub mod ssm; pub mod watch; pub use crate::docker::exec::{ diff --git a/app/src-tauri/src/host/ssm.rs b/app/src-tauri/src/host/ssm.rs new file mode 100644 index 0000000..8d9566e --- /dev/null +++ b/app/src-tauri/src/host/ssm.rs @@ -0,0 +1,729 @@ +//! The transport to an agent in an ECS task: one SSM port-forwarding session +//! (`AWS-StartPortForwardingSession`) from a loopback port on this machine to +//! the agent's port inside the task, kept open for as long as the project is +//! connected and reopened when it drops. Everything else — execs, files, +//! tunnels — then goes through [`AgentEndpoint`] exactly as it does locally. +//! Design: `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md` +//! ("Connectivity"). +//! +//! The SSM data channel is a custom websocket protocol the Rust SDK does not +//! implement, so v1 runs AWS's `session-manager-plugin` as a child process, +//! the way the AWS CLI does: the app calls `ssm:StartSession` itself and hands +//! the plugin the stream URL and token. +//! +//! Three things here are deliberate: +//! +//! - **The session token never goes on a command line.** The plugin reads the +//! `StartSession` response from the environment variable named by its first +//! argument (`AWS_SSM_START_SESSION_RESPONSE`, what the AWS CLI does for +//! plugins that support it), so it is not visible in `ps`. +//! - **The plugin gets the caller's current credentials in its environment, +//! and nothing else of AWS's.** It needs them only to resume its data +//! channel after a network blip (`ssm:ResumeSession`), which keeps every TCP +//! connection through the tunnel — open terminals included — alive. Any +//! `AWS_*` variable this process happens to have is removed first, so the +//! plugin can never act as a different identity than the one that started +//! the session. When those credentials have expired a resume fails, the +//! plugin exits, and the supervisor here opens a fresh session with fresh +//! credentials on the **same local port**, so an [`AgentEndpoint`] built +//! once stays valid across reconnects (open connections do not survive that +//! path; new ones succeed). +//! - **The plugin listens on `127.0.0.1` only** (checked: plugin 1.2.896), and +//! the agent behind it still wants its bearer token. The SSM session's IAM +//! check is the outer gate. + +use aws_config::SdkConfig; +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader}; +use tokio::process::{Child, Command}; +use tokio::sync::watch; +use tokio_util::sync::{CancellationToken, DropGuard}; +use zeroize::Zeroizing; + +use super::AgentEndpoint; + +/// Where `triple-c-agent` listens inside the task (its default). +pub const AGENT_PORT: u16 = 47900; + +const PORT_FORWARD_DOCUMENT: &str = "AWS-StartPortForwardingSession"; +/// The environment variable the plugin reads the `StartSession` response from. +const RESPONSE_ENV: &str = "AWS_SSM_START_SESSION_RESPONSE"; +/// What the plugin prints once its local listener is up. +const READY_LINE: &str = "Waiting for connections"; +/// Plugin start to listener up. Measured at ~1-2 s; this bounds a wedged one. +const READY_TIMEOUT: Duration = if cfg!(test) { + Duration::from_secs(2) +} else { + Duration::from_secs(30) +}; +/// The last lines of plugin output kept for error messages. +const OUTPUT_TAIL_LINES: usize = 20; +/// Reconnect delays; the last repeats. A reconnect that keeps failing for +/// about ten minutes gives up ([`TunnelState::Failed`]) — by then the task is +/// gone or the credentials need the user, and the host layer decides what the +/// project's state is. +const RECONNECT_BACKOFF: &[Duration] = &[ + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(5), + Duration::from_secs(10), + Duration::from_secs(30), +]; +const MAX_RECONNECT_ATTEMPTS: u32 = 25; +/// Attempts to find a free local port for the first connection. +const INITIAL_PORT_ATTEMPTS: u32 = 3; +const TERMINATE_TIMEOUT: Duration = Duration::from_secs(5); + +// ── The task ───────────────────────────────────────────────────────────────── + +/// An ECS task container that an SSM session can target. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EcsTask { + /// The cluster's *name* (the SSM target wants the name, not the ARN). + pub cluster: String, + pub task_id: String, + /// The container's runtime id, from `DescribeTasks`. + pub runtime_id: String, +} + +impl EcsTask { + /// `ecs:__`, the SSM target for one container + /// of one task. + pub fn ssm_target(&self) -> String { + format!("ecs:{}_{}_{}", self.cluster, self.task_id, self.runtime_id) + } + + /// Look up `container` in `task` (an id or ARN) on `cluster` (a name or + /// ARN), and check it can take a session now: the task is running and + /// its ECS Exec agent is up. Fargate starts that agent a few seconds after + /// the container, so "not ready yet" is a normal answer just after start. + pub async fn resolve( + ecs: &aws_sdk_ecs::Client, + cluster: &str, + task: &str, + container: &str, + ) -> Result { + let cluster_name = last_arn_segment(cluster, "cluster/"); + let out = ecs + .describe_tasks() + .cluster(cluster) + .tasks(task) + .send() + .await + .map_err(|e| format!("Could not look up the ECS task: {}", sdk_error(&e)))?; + let t = out + .tasks() + .first() + .ok_or_else(|| format!("ECS task {task} was not found in cluster {cluster_name}"))?; + let task_arn = t.task_arn().unwrap_or(task); + let task_id = last_arn_segment(task_arn, "/").to_string(); + let status = t.last_status().unwrap_or("UNKNOWN"); + if status != "RUNNING" { + return Err(format!("ECS task {task_id} is {status}, not RUNNING")); + } + let c = t + .containers() + .iter() + .find(|c| c.name() == Some(container)) + .ok_or_else(|| format!("ECS task {task_id} has no container named {container}"))?; + let runtime_id = c + .runtime_id() + .ok_or_else(|| format!("Container {container} in task {task_id} has no runtime id yet"))? + .to_string(); + let exec_agent = c + .managed_agents() + .iter() + .find(|a| a.name().map(|n| n.as_str()) == Some("ExecuteCommandAgent")); + match exec_agent.and_then(|a| a.last_status()) { + Some("RUNNING") => {} + Some(s) => { + return Err(format!( + "Container {container} in task {task_id} is not ready for connections yet (ECS Exec agent {s})" + )) + } + None => { + return Err(format!( + "ECS task {task_id} was started without ECS Exec, so it cannot be connected to" + )) + } + } + Ok(Self { + cluster: cluster_name.to_string(), + task_id, + runtime_id, + }) + } +} + +/// The part of an ARN after the last `marker` (`.../cluster/name` → `name`), +/// or the whole string when it is not an ARN. +fn last_arn_segment<'a>(s: &'a str, marker: &str) -> &'a str { + if !s.starts_with("arn:") { + return s; + } + match s.rfind(marker) { + Some(i) => &s[i + marker.len()..], + None => s, + } +} + +/// An SDK error with its cause chain (the bare `Display` is often just +/// "service error"). +fn sdk_error(e: &(dyn std::error::Error + 'static)) -> String { + format!("{}", aws_sdk_ssm::error::DisplayErrorContext(e)) +} + +// ── The plugin ─────────────────────────────────────────────────────────────── + +/// Find `session-manager-plugin`: `TRIPLEC_SSM_PLUGIN` if set, then `PATH`, +/// then where AWS's installers put it (the GUI app on macOS does not get the +/// shell's `PATH`, so the install locations are not optional). +pub fn find_plugin() -> Result { + if let Some(p) = std::env::var_os("TRIPLEC_SSM_PLUGIN").filter(|p| !p.is_empty()) { + let p = PathBuf::from(p); + return if p.is_file() { + Ok(p) + } else { + Err(format!("TRIPLEC_SSM_PLUGIN names {}, which is not a file", p.display())) + }; + } + let name = if cfg!(windows) { "session-manager-plugin.exe" } else { "session-manager-plugin" }; + let on_path = std::env::var_os("PATH") + .map(|paths| std::env::split_paths(&paths).map(|d| d.join(name)).collect::>()) + .unwrap_or_default(); + let installed: Vec = if cfg!(windows) { + ["ProgramFiles", "ProgramFiles(x86)"] + .iter() + .filter_map(|v| std::env::var_os(v)) + .map(|d| PathBuf::from(d).join(r"Amazon\SessionManagerPlugin\bin").join(name)) + .collect() + } else { + [ + "/usr/local/sessionmanagerplugin/bin", + "/usr/local/bin", + "/opt/homebrew/bin", + "/usr/bin", + ] + .iter() + .map(|d| Path::new(d).join(name)) + .collect() + }; + on_path + .into_iter() + .chain(installed) + .find(|p| p.is_file()) + .ok_or_else(|| { + "The AWS Session Manager plugin (session-manager-plugin) is not installed. It is \ + what connects to a workspace on AWS; install it from \ + https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html" + .to_string() + }) +} + +/// A running plugin process with a listener on the local port. +struct Running { + child: Child, + session_id: String, + output: OutputTail, +} + +/// The last lines a plugin printed (stdout and stderr), for error text. +#[derive(Clone, Default)] +struct OutputTail(Arc>>); + +impl OutputTail { + fn push(&self, line: String) { + let mut v = self.0.lock().unwrap(); + v.push(line); + if v.len() > OUTPUT_TAIL_LINES { + v.remove(0); + } + } + + /// The most informative recent line: the last non-blank one. + fn last(&self) -> Option { + self.0.lock().unwrap().iter().rev().find(|l| !l.trim().is_empty()).cloned() + } + + fn joined(&self) -> String { + self.0 + .lock() + .unwrap() + .iter() + .filter(|l| !l.trim().is_empty()) + .cloned() + .collect::>() + .join("; ") + } +} + +/// How to start the plugin for one session. Everything but `env_secret` is +/// safe to log; that is the `StartSession` response. +struct PluginLaunch { + program: PathBuf, + args: Vec, + env: Vec<(String, Zeroizing)>, + session_id: String, +} + +/// Start the plugin and wait until it says its listener is up. Its output is +/// then drained in the background for good (a full pipe would stall it). +async fn spawn_plugin(launch: PluginLaunch) -> Result { + let mut cmd = Command::new(&launch.program); + cmd.args(&launch.args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + // Only the credentials this session was started with: nothing AWS-shaped + // from our own environment rides along. + for (k, _) in std::env::vars_os() { + if k.to_string_lossy().starts_with("AWS_") { + cmd.env_remove(&k); + } + } + for (k, v) in &launch.env { + cmd.env(k, v.as_str()); + } + #[cfg(windows)] + { + // CREATE_NO_WINDOW: no console flashing up per (re)connect. + cmd.creation_flags(0x0800_0000); + } + let mut child = cmd + .spawn() + .map_err(|e| format!("Could not start {}: {e}", launch.program.display()))?; + + let output = OutputTail::default(); + let (ready_tx, mut ready_rx) = watch::channel(false); + let stdout = child.stdout.take().expect("stdout is piped"); + let stderr = child.stderr.take().expect("stderr is piped"); + drain(stdout, output.clone(), Some(ready_tx)); + drain(stderr, output.clone(), None); + + let ready = async { + loop { + if *ready_rx.borrow_and_update() { + return true; + } + if ready_rx.changed().await.is_err() { + // stdout closed without the ready line: the plugin is exiting. + return *ready_rx.borrow(); + } + } + }; + tokio::select! { + r = tokio::time::timeout(READY_TIMEOUT, ready) => match r { + Ok(true) => Ok(Running { child, session_id: launch.session_id, output }), + Ok(false) => { + let _ = child.wait().await; + Err(plugin_failed(&output)) + } + Err(_) => { + let _ = child.kill().await; + Err(format!( + "The SSM port forward did not come up within {} s ({})", + READY_TIMEOUT.as_secs(), + output.joined() + )) + } + }, + // Exited before (or without) printing the ready line. + status = child.wait() => { + // Let the drains catch the last lines before reporting them. + tokio::time::sleep(Duration::from_millis(50)).await; + let _ = status; + Err(plugin_failed(&output)) + } + } +} + +fn plugin_failed(output: &OutputTail) -> String { + match output.joined() { + s if s.is_empty() => "The SSM port forward exited before it was ready".to_string(), + s => format!("The SSM port forward exited before it was ready: {s}"), + } +} + +fn drain( + stream: impl AsyncRead + Unpin + Send + 'static, + tail: OutputTail, + ready: Option>, +) { + tokio::spawn(async move { + let mut lines = BufReader::new(stream).lines(); + while let Ok(Some(line)) = lines.next_line().await { + log::debug!("session-manager-plugin: {line}"); + if let Some(tx) = &ready { + if line.contains(READY_LINE) { + let _ = tx.send(true); + } + } + tail.push(line); + } + // `ready` drops here; a waiter sees the channel close. + }); +} + +// ── Opening sessions ───────────────────────────────────────────────────────── + +/// Opens and ends sessions. The real one calls SSM and runs the plugin; tests +/// substitute a fake plugin without AWS. +trait SessionOpener: Send + Sync + 'static { + fn open(&self, local_port: u16) -> impl Future> + Send; + fn terminate(&self, session_id: String) -> impl Future + Send; +} + +struct SsmOpener { + sdk: SdkConfig, + ssm: aws_sdk_ssm::Client, + region: String, + plugin: PathBuf, + target: String, + remote_port: u16, +} + +impl SessionOpener for SsmOpener { + async fn open(&self, local_port: u16) -> Result { + let out = self + .ssm + .start_session() + .target(&self.target) + .document_name(PORT_FORWARD_DOCUMENT) + .parameters("portNumber", vec![self.remote_port.to_string()]) + .parameters("localPortNumber", vec![local_port.to_string()]) + .send() + .await + .map_err(|e| format!("Could not start the SSM session: {}", sdk_error(&e)))?; + let session_id = out.session_id().unwrap_or_default().to_string(); + let response = Zeroizing::new( + serde_json::json!({ + "SessionId": session_id, + "StreamUrl": out.stream_url().unwrap_or_default(), + "TokenValue": out.token_value().unwrap_or_default(), + }) + .to_string(), + ); + let request = serde_json::json!({ + "Target": self.target, + "DocumentName": PORT_FORWARD_DOCUMENT, + "Parameters": { + "portNumber": [self.remote_port.to_string()], + "localPortNumber": [local_port.to_string()], + }, + }) + .to_string(); + + let mut env = vec![ + (RESPONSE_ENV.to_string(), response), + ("AWS_REGION".to_string(), Zeroizing::new(self.region.clone())), + ]; + env.extend(plugin_credentials(&self.sdk).await); + + let launch = PluginLaunch { + program: self.plugin.clone(), + // StartSession : + // what the AWS CLI passes. No profile: credentials come from env. + args: vec![ + RESPONSE_ENV.to_string(), + self.region.clone(), + "StartSession".to_string(), + String::new(), + request, + ssm_endpoint(&self.region), + ], + env, + session_id: session_id.clone(), + }; + match spawn_plugin(launch).await { + Ok(r) => Ok(r), + Err(e) => { + // The session exists on AWS's side even though no plugin is + // attached to it; don't leave it for the idle timeout. + self.terminate(session_id).await; + Err(e) + } + } + } + + async fn terminate(&self, session_id: String) { + if session_id.is_empty() { + return; + } + let call = self.ssm.terminate_session().session_id(&session_id).send(); + match tokio::time::timeout(TERMINATE_TIMEOUT, call).await { + Ok(Ok(_)) => {} + Ok(Err(e)) => log::warn!("TerminateSession {session_id}: {}", sdk_error(&e)), + Err(_) => log::warn!("TerminateSession {session_id}: timed out"), + } + } +} + +/// The caller's current credentials as the plugin's environment, for its own +/// `ResumeSession` calls. Without them a resume fails and the supervisor's +/// full reconnect takes over, so a failure here is not fatal. +async fn plugin_credentials(sdk: &SdkConfig) -> Vec<(String, Zeroizing)> { + use aws_sdk_ssm::config::ProvideCredentials; + let Some(provider) = sdk.credentials_provider() else { + return vec![]; + }; + match provider.provide_credentials().await { + Ok(c) => { + let mut v = vec![ + ("AWS_ACCESS_KEY_ID".to_string(), Zeroizing::new(c.access_key_id().to_string())), + ( + "AWS_SECRET_ACCESS_KEY".to_string(), + Zeroizing::new(c.secret_access_key().to_string()), + ), + ]; + if let Some(t) = c.session_token() { + v.push(("AWS_SESSION_TOKEN".to_string(), Zeroizing::new(t.to_string()))); + } + v + } + Err(e) => { + log::warn!("No credentials for the SSM plugin's resume: {}", sdk_error(&e)); + vec![] + } + } +} + +fn ssm_endpoint(region: &str) -> String { + let suffix = if region.starts_with("cn-") { "amazonaws.com.cn" } else { "amazonaws.com" }; + format!("https://ssm.{region}.{suffix}") +} + +/// A free loopback port, for the plugin to bind. The plugin binds it a moment +/// later, so another process can take it in between; the first connection +/// retries with a new port when that happens. +fn free_local_port() -> Result { + std::net::TcpListener::bind(("127.0.0.1", 0)) + .and_then(|l| l.local_addr()) + .map(|a| a.port()) + .map_err(|e| format!("Could not find a free local port: {e}")) +} + +// ── The port forward ───────────────────────────────────────────────────────── + +/// Where the tunnel is. Watch it with [`PortForward::state`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TunnelState { + Up, + /// The session dropped and is being reopened. `last_error` is why the + /// previous one ended or the previous attempt failed. + Reconnecting { attempt: u32, last_error: String }, + /// Reconnecting gave up. Nothing listens on the local port. + Failed(String), + /// [`PortForward::close`] ran, or the handle was dropped. + Closed, +} + +/// One SSM port-forward to an agent, supervised: reopened on the same local +/// port when it drops, ended (`TerminateSession`) when this handle is closed +/// or dropped. +pub struct PortForward { + local_port: u16, + state: watch::Receiver, + plugin_pid: watch::Receiver>, + supervisor: Option>, + _cancel: DropGuard, + cancel: CancellationToken, +} + +impl std::fmt::Debug for PortForward { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PortForward") + .field("local_port", &self.local_port) + .field("state", &*self.state.borrow()) + .finish() + } +} + +impl PortForward { + /// Open a port forward from a free loopback port to `remote_port` in + /// `task`, and return once the local listener is up. Errors (no plugin, + /// IAM denial, the task not reachable) come back here rather than as a + /// state. + pub async fn open(sdk: &SdkConfig, task: &EcsTask, remote_port: u16) -> Result { + let region = sdk + .region() + .ok_or("No AWS region is configured for this connection")? + .to_string(); + let opener = SsmOpener { + sdk: sdk.clone(), + ssm: aws_sdk_ssm::Client::new(sdk), + region, + plugin: find_plugin()?, + target: task.ssm_target(), + remote_port, + }; + Self::open_with(opener).await + } + + async fn open_with(opener: O) -> Result { + let mut last_err = String::new(); + for _ in 0..INITIAL_PORT_ATTEMPTS { + let port = free_local_port()?; + match opener.open(port).await { + Ok(running) => return Ok(Self::supervise(opener, port, running)), + Err(e) if is_port_in_use(&e) => last_err = e, + Err(e) => return Err(e), + } + } + Err(last_err) + } + + fn supervise(opener: O, local_port: u16, running: Running) -> Self { + let (state_tx, state) = watch::channel(TunnelState::Up); + let (pid_tx, plugin_pid) = watch::channel(running.child.id()); + let cancel = CancellationToken::new(); + let supervisor = tokio::spawn(run_supervisor( + opener, + local_port, + running, + state_tx, + pid_tx, + cancel.clone(), + )); + Self { + local_port, + state, + plugin_pid, + supervisor: Some(supervisor), + _cancel: cancel.clone().drop_guard(), + cancel, + } + } + + pub fn local_port(&self) -> u16 { + self.local_port + } + + /// The agent at the local end of this tunnel. Stays valid across + /// reconnects (the port does not change). + pub fn endpoint(&self, token: impl Into) -> AgentEndpoint { + AgentEndpoint::loopback(self.local_port, token) + } + + pub fn state(&self) -> watch::Receiver { + self.state.clone() + } + + /// The running plugin's process id, if one is running. + pub fn plugin_pid(&self) -> Option { + *self.plugin_pid.borrow() + } + + /// Stop the plugin and end the session, and wait for both. + pub async fn close(mut self) { + self.cancel.cancel(); + if let Some(s) = self.supervisor.take() { + let _ = s.await; + } + } +} + +fn is_port_in_use(e: &str) -> bool { + let e = e.to_ascii_lowercase(); + e.contains("address already in use") || e.contains("only one usage of each socket address") +} + +async fn run_supervisor( + opener: O, + local_port: u16, + mut running: Running, + state: watch::Sender, + pid: watch::Sender>, + cancel: CancellationToken, +) { + loop { + // ── Up: wait for the plugin to exit, or for close ── + let exited = tokio::select! { + s = running.child.wait() => s, + _ = cancel.cancelled() => { + let _ = running.child.kill().await; + opener.terminate(running.session_id).await; + let _ = pid.send(None); + let _ = state.send(TunnelState::Closed); + return; + } + }; + let _ = pid.send(None); + // Let the drains catch the last lines. + tokio::time::sleep(Duration::from_millis(50)).await; + let mut last_error = match running.output.last() { + Some(line) => format!("the SSM session ended ({line})"), + None => format!("the SSM session ended ({})", describe_exit(exited)), + }; + log::warn!("SSM port forward on 127.0.0.1:{local_port}: {last_error}; reconnecting"); + opener.terminate(running.session_id).await; + + // ── Reconnecting ── + let mut attempt = 0; + running = loop { + attempt += 1; + if attempt > MAX_RECONNECT_ATTEMPTS { + let _ = state.send(TunnelState::Failed(last_error)); + // Nothing to clean up; wait for the handle to go. + cancel.cancelled().await; + let _ = state.send(TunnelState::Closed); + return; + } + let _ = state.send(TunnelState::Reconnecting { + attempt, + last_error: last_error.clone(), + }); + let delay = reconnect_delay(attempt); + tokio::select! { + _ = tokio::time::sleep(delay) => {} + _ = cancel.cancelled() => { + let _ = state.send(TunnelState::Closed); + return; + } + } + let opened = tokio::select! { + r = opener.open(local_port) => r, + _ = cancel.cancelled() => { + let _ = state.send(TunnelState::Closed); + return; + } + }; + match opened { + Ok(r) => break r, + Err(e) => { + log::warn!("SSM port forward on 127.0.0.1:{local_port}: reconnect {attempt}: {e}"); + last_error = e; + } + } + }; + let _ = pid.send(running.child.id()); + let _ = state.send(TunnelState::Up); + log::info!("SSM port forward on 127.0.0.1:{local_port}: reconnected after {attempt} attempt(s)"); + } +} + +fn reconnect_delay(attempt: u32) -> Duration { + let d = RECONNECT_BACKOFF[(attempt as usize).saturating_sub(1).min(RECONNECT_BACKOFF.len() - 1)]; + if cfg!(test) { + d / 100 + } else { + d + } +} + +fn describe_exit(s: std::io::Result) -> String { + match s { + Ok(s) => format!("plugin {s}"), + Err(e) => format!("plugin wait failed: {e}"), + } +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod live_tests; diff --git a/app/src-tauri/src/host/ssm/live_tests.rs b/app/src-tauri/src/host/ssm/live_tests.rs new file mode 100644 index 0000000..dc4cc4d --- /dev/null +++ b/app/src-tauri/src/host/ssm/live_tests.rs @@ -0,0 +1,192 @@ +//! The real thing: an SSM port forward to `triple-c-agent` in a running ECS +//! task, used through `ContainerTarget::Agent` exactly as the app will. +//! +//! Ignored; needs AWS credentials (the default chain — profile, SSO or env), +//! `session-manager-plugin`, and a task started with ECS Exec from an image +//! that has the agent: +//! +//! - `TRIPLEC_SSM_E2E_CLUSTER`, `TRIPLEC_SSM_E2E_TASK` (name/id or ARN) +//! - `TRIPLEC_SSM_E2E_TOKEN` — the agent token the task was given +//! - `TRIPLEC_SSM_E2E_CONTAINER` — default `workspace` +//! - `TRIPLEC_SSM_E2E_HOLD_MINUTES` — only for the hold test +//! +//! The SSM session's IAM gate is what these exercise against AWS; the agent's +//! Docker parity is already pinned by `host::agent::live_tests`. + +use super::*; +use crate::host::{ContainerTarget, ExecChunk}; +use futures_util::StreamExt; +use std::time::Instant; +use tokio::io::AsyncWriteExt; + +fn var(name: &str) -> String { + std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set (see this file's header)")) +} + +async fn connect() -> (PortForward, ContainerTarget, Duration) { + let sdk = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; + let ecs = aws_sdk_ecs::Client::new(&sdk); + let container = std::env::var("TRIPLEC_SSM_E2E_CONTAINER").unwrap_or_else(|_| "workspace".into()); + let task = EcsTask::resolve(&ecs, &var("TRIPLEC_SSM_E2E_CLUSTER"), &var("TRIPLEC_SSM_E2E_TASK"), &container) + .await + .expect("resolve the task"); + let t0 = Instant::now(); + let pf = PortForward::open(&sdk, &task, AGENT_PORT).await.expect("open the port forward"); + let opened = t0.elapsed(); + let target = ContainerTarget::Agent(pf.endpoint(var("TRIPLEC_SSM_E2E_TOKEN"))); + (pf, target, opened) +} + +async fn wait_up(pf: &PortForward, within: Duration) -> Duration { + let t0 = Instant::now(); + let mut s = pf.state(); + tokio::time::timeout(within, s.wait_for(|s| *s == TunnelState::Up)) + .await + .expect("the tunnel did not come back") + .unwrap(); + t0.elapsed() +} + +async fn whoami(t: &ContainerTarget) -> Result { + t.exec_oneshot(vec!["whoami".into()]).await.map(|s| s.trim().to_string()) +} + +#[tokio::test] +#[ignore] +async fn the_agent_works_through_an_ssm_port_forward_and_survives_a_drop() { + let (pf, t, opened) = connect().await; + eprintln!("port forward up in {opened:?} on 127.0.0.1:{}", pf.local_port()); + + // ── the operations the app uses ── + assert!(t.is_running().await.unwrap()); + assert_eq!(whoami(&t).await.unwrap(), "claude"); + + let n = 20; + let t0 = Instant::now(); + for _ in 0..n { + t.exec_oneshot(vec!["true".into()]).await.unwrap(); + } + eprintln!("one-shot exec round trip: {:?} average over {n}", t0.elapsed() / n); + + // attach with stdin + let mut exec = t + .attach(crate::host::ExecSpec::new(vec!["head".into(), "-n1".into()])) + .await + .unwrap(); + exec.input.write_all(b"through ssm\n").await.unwrap(); + exec.input.shutdown().await.unwrap(); + let mut out = Vec::new(); + while let Some(Ok(c)) = exec.output.next().await { + if let ExecChunk::StdOut(b) = c { + out.extend_from_slice(&b); + } + } + assert_eq!(String::from_utf8_lossy(&out), "through ssm\n"); + + // 4 MiB up and back + let data: Vec = (0..4 * 1024 * 1024u32).map(|i| (i % 251) as u8).collect(); + let t0 = Instant::now(); + t.upload_bytes_with_ids("/tmp", "ssm-e2e.bin", &data, 0o644, (1000, 1000)).await.unwrap(); + let up = t0.elapsed(); + let t0 = Instant::now(); + let mut tar = Vec::new(); + let mut s = t.download_archive("/tmp/ssm-e2e.bin"); + while let Some(chunk) = s.next().await { + tar.extend_from_slice(&chunk.unwrap()); + } + let down = t0.elapsed(); + let mut a = tar::Archive::new(&tar[..]); + let mut e = a.entries().unwrap().next().unwrap().unwrap(); + let mut back = Vec::new(); + std::io::Read::read_to_end(&mut e, &mut back).unwrap(); + assert!(back == data, "the file came back different"); + eprintln!("4 MiB: upload {up:?}, download {down:?}"); + t.exec_oneshot(vec!["rm".into(), "-f".into(), "/tmp/ssm-e2e.bin".into()]).await.unwrap(); + + // ── a dropped session: kill the plugin, as a crash or sleep would ── + let pid = pf.plugin_pid().unwrap(); + std::process::Command::new("kill").args(["-9", &pid.to_string()]).status().unwrap(); + let mut s = pf.state(); + s.wait_for(|s| *s != TunnelState::Up).await.unwrap(); + let back_in = wait_up(&pf, Duration::from_secs(60)).await; + eprintln!("reconnected in {back_in:?} (same port {})", pf.local_port()); + assert_eq!(whoami(&t).await.unwrap(), "claude", "the same endpoint works after a reconnect"); + + // ── close ends it ── + let port = pf.local_port(); + pf.close().await; + assert!( + std::net::TcpStream::connect(("127.0.0.1", port)).is_err(), + "something still listens on the closed tunnel's port" + ); +} + +/// Hold one tunnel for `TRIPLEC_SSM_E2E_HOLD_MINUTES` (default 60): a probe +/// every minute for the first and last stretch, and a silent stretch in the +/// middle longer than Session Manager's default 20-minute idle timeout. One +/// attached exec stays open the whole time. Prints a timeline of tunnel +/// states; fails only if the tunnel cannot serve a request at the end. +#[tokio::test] +#[ignore] +async fn an_ssm_port_forward_holds_for_a_long_session() { + let minutes: u64 = std::env::var("TRIPLEC_SSM_E2E_HOLD_MINUTES") + .ok() + .and_then(|m| m.parse().ok()) + .unwrap_or(60); + let (pf, t, opened) = connect().await; + let start = Instant::now(); + let stamp = move || format!("[{:>5.1} min]", start.elapsed().as_secs_f64() / 60.0); + eprintln!("{} up in {opened:?}", stamp()); + + // Log every state change as it happens. + let mut states = pf.state(); + let log_states = tokio::spawn(async move { + while states.changed().await.is_ok() { + eprintln!("{} tunnel: {:?}", stamp(), *states.borrow()); + } + }); + + // A long-lived connection: does one TCP stream survive the whole hold? + let mut held = t.attach(crate::host::ExecSpec::new(vec!["cat".into()])).await.unwrap(); + + let idle_from = minutes / 4; + let idle_to = idle_from + 25.min(minutes / 2); + let mut failures = 0; + for m in 0..minutes { + if m < idle_from || m >= idle_to { + let t0 = Instant::now(); + match whoami(&t).await { + Ok(_) => eprintln!("{} probe ok ({:?})", stamp(), t0.elapsed()), + Err(e) => { + failures += 1; + eprintln!("{} probe FAILED: {e}", stamp()) + } + } + } else if m == idle_from { + eprintln!("{} silent until minute {idle_to}", stamp()); + } + tokio::time::sleep(Duration::from_secs(60)).await; + } + + // Did the held stream survive? Echo through it. + let survived = async { + held.input.write_all(b"still here\n").await.ok()?; + let mut got = Vec::new(); + while let Ok(Some(Ok(c))) = tokio::time::timeout(Duration::from_secs(10), held.output.next()).await { + if let ExecChunk::StdOut(b) = c { + got.extend_from_slice(&b); + if got.ends_with(b"\n") { + break; + } + } + } + Some(got == b"still here\n") + } + .await + .unwrap_or(false); + eprintln!("{} long-lived attach survived: {survived}; probe failures: {failures}", stamp()); + + assert_eq!(whoami(&t).await.unwrap(), "claude", "the tunnel serves at the end"); + pf.close().await; + log_states.abort(); +} diff --git a/app/src-tauri/src/host/ssm/tests.rs b/app/src-tauri/src/host/ssm/tests.rs new file mode 100644 index 0000000..be37b27 --- /dev/null +++ b/app/src-tauri/src/host/ssm/tests.rs @@ -0,0 +1,291 @@ +//! The plugin handling and the supervisor, against a fake plugin (a shell +//! script) instead of AWS. The real thing is in `live_tests.rs`. + +use super::*; + +#[test] +fn the_ssm_target_names_cluster_task_and_container_runtime() { + let t = EcsTask { + cluster: "triple-c".into(), + task_id: "9fb3a6cf".into(), + runtime_id: "9fb3a6cf-0791508406".into(), + }; + assert_eq!(t.ssm_target(), "ecs:triple-c_9fb3a6cf_9fb3a6cf-0791508406"); +} + +#[test] +fn arns_are_reduced_to_their_names() { + assert_eq!( + last_arn_segment("arn:aws:ecs:us-west-2:123456789012:cluster/triple-c", "cluster/"), + "triple-c" + ); + assert_eq!( + last_arn_segment("arn:aws:ecs:us-west-2:123456789012:task/triple-c/9fb3a6cf", "/"), + "9fb3a6cf" + ); + assert_eq!(last_arn_segment("triple-c", "cluster/"), "triple-c"); + assert_eq!(last_arn_segment("9fb3a6cf", "/"), "9fb3a6cf"); +} + +#[test] +fn the_plugin_endpoint_follows_the_partition() { + assert_eq!(ssm_endpoint("us-west-2"), "https://ssm.us-west-2.amazonaws.com"); + assert_eq!(ssm_endpoint("cn-north-1"), "https://ssm.cn-north-1.amazonaws.com.cn"); +} + +#[test] +fn a_port_clash_is_recognised_on_every_platform() { + assert!(is_port_in_use("listen tcp 127.0.0.1:4: bind: address already in use")); + assert!(is_port_in_use( + "bind: Only one usage of each socket address (protocol/network address/port) is normally permitted." + )); + assert!(!is_port_in_use("TargetNotConnected")); +} + +#[test] +fn backoff_grows_then_holds() { + assert!(reconnect_delay(1) < reconnect_delay(2)); + assert_eq!(reconnect_delay(5), reconnect_delay(500)); +} + +#[cfg(unix)] +mod with_a_fake_plugin { + use super::*; + use std::os::unix::fs::PermissionsExt; + use std::sync::atomic::{AtomicU32, Ordering}; + + /// A fake `session-manager-plugin`: a shell script. + fn script(dir: &Path, name: &str, body: &str) -> PathBuf { + let p = dir.join(name); + std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap(); + p + } + + fn launch(program: PathBuf, secret: &str) -> PluginLaunch { + PluginLaunch { + program, + args: vec![RESPONSE_ENV.into(), "us-west-2".into(), "StartSession".into()], + env: vec![(RESPONSE_ENV.into(), Zeroizing::new(secret.into()))], + session_id: "s-1".into(), + } + } + + const READY: &str = "echo 'Port 1 opened for sessionId s.'; echo 'Waiting for connections...'"; + + #[tokio::test] + async fn the_session_token_travels_in_the_environment_never_on_the_command_line() { + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("seen"); + // Nothing AWS-shaped from our own environment may reach the plugin. + // (A name no other test reads, so setting it here is not a race.) + std::env::set_var("AWS_TRIPLEC_SSM_TEST_LEAK", "leaked"); + let p = script( + dir.path(), + "plugin", + &format!( + "echo \"argv=$*\" > {o}; echo \"env=${r}\" >> {o}; echo \"leak=$AWS_TRIPLEC_SSM_TEST_LEAK\" >> {o}; {READY}; exec sleep 30", + o = out.display(), + r = RESPONSE_ENV + ), + ); + let running = spawn_plugin(launch(p, "the-secret-token")).await.expect("ready"); + let seen = std::fs::read_to_string(&out).unwrap(); + std::env::remove_var("AWS_TRIPLEC_SSM_TEST_LEAK"); + assert!(seen.contains("env=the-secret-token"), "{seen}"); + let argv = seen.lines().find(|l| l.starts_with("argv=")).unwrap(); + assert!(!argv.contains("the-secret-token"), "{argv}"); + assert!(argv.contains(RESPONSE_ENV), "the plugin is told which variable to read: {argv}"); + assert!(seen.contains("leak=\n"), "{seen}"); + drop(running); // kill_on_drop + } + + #[tokio::test] + async fn a_plugin_that_exits_early_says_why() { + let dir = tempfile::tempdir().unwrap(); + let p = script( + dir.path(), + "plugin", + "echo 'An error occurred (TargetNotConnected) when calling the StartSession operation' >&2; exit 255", + ); + let err = spawn_plugin(launch(p, "x")).await.err().expect("must fail"); + assert!(err.contains("TargetNotConnected"), "{err}"); + } + + #[tokio::test] + async fn a_plugin_that_never_gets_ready_is_killed_and_reported() { + let dir = tempfile::tempdir().unwrap(); + let p = script(dir.path(), "plugin", "echo 'Starting session'; exec sleep 30"); + let err = spawn_plugin(launch(p, "x")).await.err().expect("must time out"); + assert!(err.contains("did not come up"), "{err}"); + assert!(err.contains("Starting session"), "{err}"); + } + + /// Opens sessions by running a fake plugin; records what it was asked. + struct FakeOpener { + plugin: PathBuf, + ports: Arc>>, + terminated: Arc>>, + opened: Arc, + /// Fail opens whose 1-based number is in here, with this message. + fail: Vec<(u32, &'static str)>, + } + + impl FakeOpener { + fn new(plugin: PathBuf) -> Self { + Self { + plugin, + ports: Default::default(), + terminated: Default::default(), + opened: Default::default(), + fail: vec![], + } + } + } + + impl SessionOpener for FakeOpener { + async fn open(&self, local_port: u16) -> Result { + let n = self.opened.fetch_add(1, Ordering::SeqCst) + 1; + self.ports.lock().unwrap().push(local_port); + if let Some((_, msg)) = self.fail.iter().find(|(i, _)| *i == n) { + return Err(msg.to_string()); + } + let mut l = launch(self.plugin.clone(), "x"); + l.session_id = format!("s-{n}"); + spawn_plugin(l).await + } + + async fn terminate(&self, session_id: String) { + self.terminated.lock().unwrap().push(session_id); + } + } + + async fn wait_for(state: &mut watch::Receiver, want: impl Fn(&TunnelState) -> bool) -> TunnelState { + let s = tokio::time::timeout(Duration::from_secs(10), state.wait_for(|s| want(s))) + .await + .expect("state did not arrive") + .expect("supervisor gone") + .clone(); + s + } + + fn alive(pid: u32) -> bool { + std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .status() + .map(|s| s.success()) + .unwrap_or(false) + } + + fn kill(pid: u32) { + std::process::Command::new("kill").args(["-9", &pid.to_string()]).status().unwrap(); + } + + #[tokio::test] + async fn a_dropped_session_is_reopened_on_the_same_local_port() { + let dir = tempfile::tempdir().unwrap(); + let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30")); + let opener = FakeOpener::new(p); + let (ports, terminated) = (opener.ports.clone(), opener.terminated.clone()); + + let pf = PortForward::open_with(opener).await.expect("open"); + let mut state = pf.state(); + assert_eq!(*state.borrow(), TunnelState::Up); + let first = pf.plugin_pid().expect("a plugin runs"); + + kill(first); + wait_for(&mut state, |s| matches!(s, TunnelState::Reconnecting { .. })).await; + wait_for(&mut state, |s| *s == TunnelState::Up).await; + let second = pf.plugin_pid().expect("a new plugin runs"); + assert_ne!(first, second); + + let ports = ports.lock().unwrap().clone(); + assert_eq!(ports.len(), 2, "{ports:?}"); + assert_eq!(ports[0], ports[1], "the endpoint must stay valid: same port"); + assert_eq!(pf.local_port(), ports[0]); + assert_eq!(*terminated.lock().unwrap(), vec!["s-1".to_string()], "the dead session is ended"); + + pf.close().await; + assert!(!alive(second)); + } + + #[tokio::test] + async fn close_kills_the_plugin_and_ends_the_session() { + let dir = tempfile::tempdir().unwrap(); + let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30")); + let opener = FakeOpener::new(p); + let terminated = opener.terminated.clone(); + let pf = PortForward::open_with(opener).await.expect("open"); + let pid = pf.plugin_pid().unwrap(); + let mut state = pf.state(); + pf.close().await; + assert_eq!(*state.borrow_and_update(), TunnelState::Closed); + assert!(!alive(pid), "the plugin outlived close()"); + assert_eq!(*terminated.lock().unwrap(), vec!["s-1".to_string()]); + } + + #[tokio::test] + async fn dropping_the_handle_also_ends_the_session() { + let dir = tempfile::tempdir().unwrap(); + let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30")); + let opener = FakeOpener::new(p); + let terminated = opener.terminated.clone(); + let pf = PortForward::open_with(opener).await.expect("open"); + let pid = pf.plugin_pid().unwrap(); + let mut state = pf.state(); + drop(pf); + wait_for(&mut state, |s| *s == TunnelState::Closed).await; + assert!(!alive(pid)); + assert_eq!(*terminated.lock().unwrap(), vec!["s-1".to_string()]); + } + + #[tokio::test] + async fn the_first_open_moves_off_a_port_someone_else_took() { + let dir = tempfile::tempdir().unwrap(); + let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30")); + let mut opener = FakeOpener::new(p); + opener.fail = vec![(1, "listen tcp 127.0.0.1:1: bind: address already in use")]; + let opened = opener.opened.clone(); + let pf = PortForward::open_with(opener).await.expect("second port works"); + assert_eq!(opened.load(Ordering::SeqCst), 2); + pf.close().await; + } + + #[tokio::test] + async fn other_first_open_errors_are_returned_not_retried() { + let dir = tempfile::tempdir().unwrap(); + let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30")); + let mut opener = FakeOpener::new(p); + opener.fail = vec![(1, "AccessDeniedException: not authorized to perform ssm:StartSession")]; + let opened = opener.opened.clone(); + let err = PortForward::open_with(opener).await.err().expect("must fail"); + assert!(err.contains("AccessDenied"), "{err}"); + assert_eq!(opened.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn reconnecting_gives_up_after_the_limit_and_says_why() { + let dir = tempfile::tempdir().unwrap(); + let p = script(dir.path(), "plugin", &format!("{READY}; exec sleep 30")); + let mut opener = FakeOpener::new(p); + // Open 1 works; every reconnect after it fails. + opener.fail = (2..=MAX_RECONNECT_ATTEMPTS + 1) + .map(|i| (i, "TargetNotConnected: the task stopped")) + .collect(); + let pf = PortForward::open_with(opener).await.expect("open"); + let mut state = pf.state(); + kill(pf.plugin_pid().unwrap()); + let s = tokio::time::timeout( + Duration::from_secs(30), + state.wait_for(|s| matches!(s, TunnelState::Failed(_))), + ) + .await + .expect("gave up") + .unwrap() + .clone(); + assert_eq!(s, TunnelState::Failed("TargetNotConnected: the task stopped".into())); + assert_eq!(pf.plugin_pid(), None); + pf.close().await; + assert_eq!(*state.borrow_and_update(), TunnelState::Closed); + } +} diff --git a/container/entrypoint.sh b/container/entrypoint.sh index 7cbe10c..c24e0f2 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -99,8 +99,15 @@ remap_uid_gid() { remap_uid_gid -# Fix ownership of home directory after UID/GID change -chown -R claude:claude /home/claude +# Fix ownership of home directory after UID/GID change — only where it is +# wrong. A blanket `chown -R` rewrites every inode, and where /home/claude is +# the image's own copy rather than a volume (an ECS/Fargate task) that copies +# the whole directory up into the writable layer: measured at ~5 minutes on +# 1 vCPU before the agent could start. `find` only stats. `-h` and find's +# default no-follow match `chown -R`'s physical walk (it changes a symlink +# itself, never its target), and like `chown -R` this crosses into the nested +# ~/.claude volume. +find /home/claude \( ! -user claude -o ! -group claude \) -exec chown -h claude:claude {} + # ── Corporate CA certificates ─────────────────────────────────────────────── # The host's CA material is bind-mounted read-only at /tmp/.host-ca. Triple-C -- 2.52.0 From 50f3d5194696bf0110bf01e31ad861fe0b364e5b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 14 Nov 2026 10:15:04 -0800 Subject: [PATCH 2/4] SSM transport: end sessions whose open is cancelled; cache plugin creds Review fixes. A StartSession whose open future is dropped (a close racing a reconnect, a caller timing out the first open) is now ended by a guard instead of left to SSM's idle timeout. The plugin's credentials are cached until 15 minutes before expiry, so a prompting credential_process is not asked again on every reconnect. Fake-plugin tests run the script through /bin/sh, so a parallel test's fork cannot fail the spawn with ETXTBSY. CLAUDE.md: host/ssm.rs. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 + app/src-tauri/src/host/ssm.rs | 141 +++++++++++++++++++++------- app/src-tauri/src/host/ssm/tests.rs | 71 +++++++++++++- 3 files changed, 178 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d2b4cd6..23b9db1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -287,6 +287,11 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li `docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`; AWS planned — `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`) wraps execs, file transfer and `is_running`; `sessions.rs` holds `ExecSessionManager`. + `ssm.rs` is the AWS transport to an `Agent` target: one SSM port-forward per task, run through + AWS's `session-manager-plugin` as a child process and reopened **on the same loopback port** + when it drops, so an `AgentEndpoint` stays valid. The `StartSession` token goes to the plugin + through its environment, never argv, and the plugin's environment is stripped of every + inherited `AWS_*` variable before the session's own credentials are added. `agent.rs` reuses `docker/exec.rs`'s `OneshotOutput`, host-file reader and uid probe so the two variants cannot drift; an `Agent` target's token never appears in `Debug` or error text. **Container-interior code must go through `ContainerTarget`, never `docker::exec`, bollard or diff --git a/app/src-tauri/src/host/ssm.rs b/app/src-tauri/src/host/ssm.rs index 8d9566e..8d4bdd4 100644 --- a/app/src-tauri/src/host/ssm.rs +++ b/app/src-tauri/src/host/ssm.rs @@ -28,6 +28,10 @@ //! credentials on the **same local port**, so an [`AgentEndpoint`] built //! once stays valid across reconnects (open connections do not survive that //! path; new ones succeed). +//! - **Whoever holds a [`PortForward`] must `close()` it when the app exits.** +//! Tauri exits through `std::process::exit`, which drops no runtime tasks, +//! so `kill_on_drop` never fires and the plugin would keep the session and +//! the port until SSM's idle timeout. //! - **The plugin listens on `127.0.0.1` only** (checked: plugin 1.2.896), and //! the agent behind it still wants its bearer token. The SSM session's IAM //! check is the outer gate. @@ -380,6 +384,11 @@ trait SessionOpener: Send + Sync + 'static { struct SsmOpener { sdk: SdkConfig, + /// The credentials last handed to the plugin, reused until they near + /// expiry. Each fetch from the provider can be a `credential_process` + /// (an MFA or biometric prompt), an STS call or an SSO call, and the + /// provider in `SdkConfig` is not the SDK's cached one. + plugin_creds: tokio::sync::Mutex>, ssm: aws_sdk_ssm::Client, region: String, plugin: PathBuf, @@ -400,6 +409,13 @@ impl SessionOpener for SsmOpener { .await .map_err(|e| format!("Could not start the SSM session: {}", sdk_error(&e)))?; let session_id = out.session_id().unwrap_or_default().to_string(); + // From here the session exists on AWS's side. If this future is + // dropped before it returns — a close or drop racing a reconnect, a + // caller timing out the first open — the guard ends the session. + let guard = SessionGuard { + ssm: self.ssm.clone(), + session_id: Some(session_id.clone()), + }; let response = Zeroizing::new( serde_json::json!({ "SessionId": session_id, @@ -422,7 +438,7 @@ impl SessionOpener for SsmOpener { (RESPONSE_ENV.to_string(), response), ("AWS_REGION".to_string(), Zeroizing::new(self.region.clone())), ]; - env.extend(plugin_credentials(&self.sdk).await); + env.extend(self.plugin_credentials().await); let launch = PluginLaunch { program: self.plugin.clone(), @@ -440,55 +456,107 @@ impl SessionOpener for SsmOpener { session_id: session_id.clone(), }; match spawn_plugin(launch).await { - Ok(r) => Ok(r), + Ok(r) => { + guard.disarm(); + Ok(r) + } Err(e) => { - // The session exists on AWS's side even though no plugin is - // attached to it; don't leave it for the idle timeout. - self.terminate(session_id).await; + // No plugin is attached to the session; don't leave it open + // until the idle timeout. + guard.terminate().await; Err(e) } } } async fn terminate(&self, session_id: String) { - if session_id.is_empty() { - return; - } - let call = self.ssm.terminate_session().session_id(&session_id).send(); - match tokio::time::timeout(TERMINATE_TIMEOUT, call).await { - Ok(Ok(_)) => {} - Ok(Err(e)) => log::warn!("TerminateSession {session_id}: {}", sdk_error(&e)), - Err(_) => log::warn!("TerminateSession {session_id}: timed out"), + terminate_session(&self.ssm, session_id).await + } +} + +async fn terminate_session(ssm: &aws_sdk_ssm::Client, session_id: String) { + if session_id.is_empty() { + return; + } + let call = ssm.terminate_session().session_id(&session_id).send(); + match tokio::time::timeout(TERMINATE_TIMEOUT, call).await { + Ok(Ok(_)) => {} + Ok(Err(e)) => log::warn!("TerminateSession {session_id}: {}", sdk_error(&e)), + Err(_) => log::warn!("TerminateSession {session_id}: timed out"), + } +} + +/// Ends a started session unless disarmed: on an error path explicitly, and +/// on drop (a cancelled open) from a spawned task. +struct SessionGuard { + ssm: aws_sdk_ssm::Client, + session_id: Option, +} + +impl SessionGuard { + fn disarm(mut self) { + self.session_id = None; + } + + async fn terminate(mut self) { + if let Some(id) = self.session_id.take() { + terminate_session(&self.ssm, id).await; } } } -/// The caller's current credentials as the plugin's environment, for its own -/// `ResumeSession` calls. Without them a resume fails and the supervisor's -/// full reconnect takes over, so a failure here is not fatal. -async fn plugin_credentials(sdk: &SdkConfig) -> Vec<(String, Zeroizing)> { - use aws_sdk_ssm::config::ProvideCredentials; - let Some(provider) = sdk.credentials_provider() else { - return vec![]; - }; - match provider.provide_credentials().await { - Ok(c) => { - let mut v = vec![ - ("AWS_ACCESS_KEY_ID".to_string(), Zeroizing::new(c.access_key_id().to_string())), - ( - "AWS_SECRET_ACCESS_KEY".to_string(), - Zeroizing::new(c.secret_access_key().to_string()), - ), - ]; - if let Some(t) = c.session_token() { - v.push(("AWS_SESSION_TOKEN".to_string(), Zeroizing::new(t.to_string()))); +impl Drop for SessionGuard { + fn drop(&mut self) { + if let Some(id) = self.session_id.take() { + if let Ok(rt) = tokio::runtime::Handle::try_current() { + let ssm = self.ssm.clone(); + rt.spawn(async move { terminate_session(&ssm, id).await }); } - v } - Err(e) => { - log::warn!("No credentials for the SSM plugin's resume: {}", sdk_error(&e)); - vec![] + } +} + +/// How long before expiry cached plugin credentials are refreshed. A plugin +/// started with them may need them for a resume well after it starts. +const PLUGIN_CREDS_MIN_REMAINING: Duration = Duration::from_secs(15 * 60); + +impl SsmOpener { + /// The caller's credentials as the plugin's environment, for its own + /// `ResumeSession` calls. Without them a resume fails and the + /// supervisor's full reconnect takes over, so a failure here is not fatal. + async fn plugin_credentials(&self) -> Vec<(String, Zeroizing)> { + use aws_sdk_ssm::config::ProvideCredentials; + let mut cached = self.plugin_creds.lock().await; + let fresh = |c: &aws_sdk_ssm::config::Credentials| match c.expiry() { + None => true, + Some(exp) => exp + .duration_since(std::time::SystemTime::now()) + .map(|left| left > PLUGIN_CREDS_MIN_REMAINING) + .unwrap_or(false), + }; + if !cached.as_ref().is_some_and(fresh) { + *cached = None; + if let Some(provider) = self.sdk.credentials_provider() { + match provider.provide_credentials().await { + Ok(c) => *cached = Some(c), + Err(e) => log::warn!("No credentials for the SSM plugin's resume: {}", sdk_error(&e)), + } + } } + let Some(c) = cached.as_ref() else { + return vec![]; + }; + let mut v = vec![ + ("AWS_ACCESS_KEY_ID".to_string(), Zeroizing::new(c.access_key_id().to_string())), + ( + "AWS_SECRET_ACCESS_KEY".to_string(), + Zeroizing::new(c.secret_access_key().to_string()), + ), + ]; + if let Some(t) = c.session_token() { + v.push(("AWS_SESSION_TOKEN".to_string(), Zeroizing::new(t.to_string()))); + } + v } } @@ -555,6 +623,7 @@ impl PortForward { .to_string(); let opener = SsmOpener { sdk: sdk.clone(), + plugin_creds: Default::default(), ssm: aws_sdk_ssm::Client::new(sdk), region, plugin: find_plugin()?, diff --git a/app/src-tauri/src/host/ssm/tests.rs b/app/src-tauri/src/host/ssm/tests.rs index be37b27..a4fa1c4 100644 --- a/app/src-tauri/src/host/ssm/tests.rs +++ b/app/src-tauri/src/host/ssm/tests.rs @@ -62,10 +62,18 @@ mod with_a_fake_plugin { p } - fn launch(program: PathBuf, secret: &str) -> PluginLaunch { + /// Runs the script as `/bin/sh